From 9e710a92b21e6257422b5edf951563d21cedf51d Mon Sep 17 00:00:00 2001 From: shancheas Date: Thu, 27 Aug 2026 11:06:00 +0700 Subject: [PATCH] feat: introduce index layout skill for FULL_PAGE index/list tables - Added a new skill for managing index and list table layouts in the ERP project, detailing layout and data contracts. - Updated project guidelines to reference the new index layout skill. - Introduced rules for page composition, column definitions, audit fields, action column width, and toolbar functionality. - Created utility functions for computing action column width and formatting audit fields, along with corresponding unit tests to ensure reliability. These changes enhance the application by providing a structured approach to index layouts, improving consistency and usability across the ERP project. --- .agents/skills/index-layout/SKILL.md | 95 +++++++++++++++++++ .../project-guidelines-example/SKILL.md | 1 + .cursor/rules/web-design-layout.mdc | 1 + .cursor/rules/web-index-table.mdc | 33 +++++++ .../data-table/action-column.utils.test.ts | 57 +++++++++++ .../data-table/action-column.utils.ts | 32 +++++++ .../data-table/audit-column.utils.test.ts | 65 +++++++++++++ .../data-table/audit-column.utils.ts | 33 +++++++ .../components/data-table/index.tsx | 63 +++++++----- 9 files changed, 358 insertions(+), 22 deletions(-) create mode 100644 .agents/skills/index-layout/SKILL.md create mode 100644 .cursor/rules/web-index-table.mdc create mode 100644 packages/ui/src/foundations/enterprise-module/components/data-table/action-column.utils.test.ts create mode 100644 packages/ui/src/foundations/enterprise-module/components/data-table/action-column.utils.ts create mode 100644 packages/ui/src/foundations/enterprise-module/components/data-table/audit-column.utils.test.ts create mode 100644 packages/ui/src/foundations/enterprise-module/components/data-table/audit-column.utils.ts diff --git a/.agents/skills/index-layout/SKILL.md b/.agents/skills/index-layout/SKILL.md new file mode 100644 index 0000000..14e5966 --- /dev/null +++ b/.agents/skills/index-layout/SKILL.md @@ -0,0 +1,95 @@ +--- +name: index-layout +description: Use whenever building or editing a FULL_PAGE index / list table in the ERP project (master-data lists, transaction lists, system users/privileges, any EnterpriseDataTable index). Governs LAYOUT and table contracts ONLY — page chrome, module columnDefs vs shared prefix/postfix columns, action-column width, audit field names, and the search/filter/reload toolbar. Does not govern colors, fonts, border-radius, or other visual styling. Trigger this any time an index page, list table, row-action column, audit columns, or index toolbar is added or restructured — including when the user says "build a list page", "index table", or "add a refresh button". +--- + +# Index Layout + +Layout and data contracts for **FULL_PAGE index / list tables**. Visual style (color, weight, radius, shadows) belongs to `@repo/ui`, not this skill. + +Walk these rules in order whenever you add or change an index table. + +## Project chrome (do not rebuild) + +Index routes already render inside `EnterpriseIndexPageProvider` → `EnterpriseDataTable`. That chrome owns: + +- Page header (title, description, breadcrumbs, Create) +- Toolbar: search, filter, **reload** +- Prefix columns: selection, row actions, status +- Postfix columns: Created by, Created at, Updated by, Updated at +- Pagination / server-side row model + +**Do not** re-implement the toolbar, action column, status column, or audit columns in a module page. This skill governs the **`columnDefs` you pass in** and the **entity/transformer field names** the shared table reads. + +## 1. Page composition + +```tsx + + + +``` + +Copy `example/full-page`. Do not invent a third index layout. + +## 2. Module `columnDefs` = business fields only + +Module index pages declare **business columns only** (code, name, relations, flags). + +Do **not** redeclare: + +- `action_column` / row actions +- `status` (the shared status badge column) +- `createdBy` / `createdAt` / `updatedBy` / `updatedAt` + +```tsx +// GOOD +const columnDefs = [ + { field: 'username', headerName: t('common:fields.username'), minWidth: 160 }, + { field: 'privilege', headerName: t('common:fields.privilege'), valueGetter: ({ data }) => relationLabel(data?.privilege) }, +]; + +// BAD — duplicates shared chrome +const columnDefs = [ + { colId: 'action_column', width: 180 }, + { field: 'created_at', headerName: 'Created at' }, +]; +``` + +## 3. Audit fields are camelCase + +API and transformers map: + +| Entity field | Meaning | +|---|---| +| `createdBy` | actor string, `{ username }`, or uuid | +| `createdAt` | unix ms | +| `updatedBy` | actor string, `{ username }`, or uuid | +| `updatedAt` | unix ms | + +`EnterpriseDataTable` postfix columns bind those names and fall back to legacy snake_case (`creator_name`, `created_at`, `editor_name`, `updated_at`) for leftover local/Pouch rows. + +Empty values render as `-`. Nested actors display `username` || `name` || `id`. + +Do not alias audit fields to snake_case in TrackGo transformers. + +## 4. Action column width is shared + +Row actions stay a single nowrap icon row (`RowActions` with `responsiveView={false}`). Width is computed in `EnterpriseDataTable` from privileges + `moduleType` (`View` always, then Edit / Duplicate / Activate-or-Deactivate or transaction flags / Delete). `flex: 0` so the column does not shrink. + +Do **not** override action column `width` / `minWidth` in module `columnDefs` or `customPrefixColumn` unless the module adds extra custom row actions **and** you widen via `customPrefixColumn` to fit them. + +## 5. Toolbar always includes reload + +The index toolbar is search + filter + reload. Reload must call `gridApi.refreshServerSide({ purge: true })` (same as search/filter) — **not** `window.location.reload()`. + +Label: `common:actions.reload` (“Reload” / “Muat Ulang”). Icon: `RefreshCw`. + +Do not add a second module-level refresh control that duplicates this. + +## Checklist + +- [ ] Index page uses `EnterpriseIndexPageProvider` + `EnterpriseDataTable` (no custom table chrome) +- [ ] `columnDefs` are business fields only +- [ ] Transformer maps `createdAt` / `createdBy` / `updatedAt` / `updatedBy` +- [ ] Action column width left to the shared table +- [ ] Reload is the shared toolbar button, not a full browser refresh diff --git a/.agents/skills/project-guidelines-example/SKILL.md b/.agents/skills/project-guidelines-example/SKILL.md index 87070de..6e2bb35 100644 --- a/.agents/skills/project-guidelines-example/SKILL.md +++ b/.agents/skills/project-guidelines-example/SKILL.md @@ -177,5 +177,6 @@ See `apps/web/.env.example`. Typical `VITE_*` keys: `VITE_APP_ENV`, `VITE_API_BA - `.agents/skills/coding-standards/` — TypeScript/React practices - `.agents/skills/form-layout/` — form layout - `.agents/skills/detail-layout/` — detail page layout +- `.agents/skills/index-layout/` — index / list table layout - `.agents/skills/tdd-workflow/` — TDD - `.agents/skills/security-review/` — frontend/Electron security diff --git a/.cursor/rules/web-design-layout.mdc b/.cursor/rules/web-design-layout.mdc index 7e0e554..cc78d0d 100644 --- a/.cursor/rules/web-design-layout.mdc +++ b/.cursor/rules/web-design-layout.mdc @@ -27,6 +27,7 @@ Reference usage: `apps/showcase` (ui-components, forms, action-tools, shell-demo Detail **content** layout (section stack, key-value grids, status blocks, tabs for many categories): follow `.agents/skills/detail-layout/SKILL.md` — layout/position only. Form **content** layout: follow `.agents/skills/form-layout/SKILL.md`. +Index / list layout (toolbar, action column, audit columns): follow `.agents/skills/index-layout/SKILL.md`. Entities with `latitude` / `longitude`: follow `web-location-maps.mdc` — form must pick coords on `LocationMap`; detail must render `LocationMap`. Every page header (`pageHeaderProps`) should include i18n `title`, `description`, `breadcrumbs`, and Lucide `icon` when useful. diff --git a/.cursor/rules/web-index-table.mdc b/.cursor/rules/web-index-table.mdc new file mode 100644 index 0000000..6f05455 --- /dev/null +++ b/.cursor/rules/web-index-table.mdc @@ -0,0 +1,33 @@ +--- +description: Index / list table contracts for FULL_PAGE modules (actions, audit columns, reload) +globs: apps/web/src/apps/**/*.page.index.tsx,packages/ui/src/foundations/enterprise-module/components/data-table/** +alwaysApply: false +--- + +# Web Index Table + +Index pages: `EnterpriseIndexPageProvider` + `EnterpriseDataTable`. Do not rebuild the toolbar or default columns. Full layout: `.agents/skills/index-layout/SKILL.md`. + +## Module columnDefs + +Business fields only. Do **not** redeclare action, status, or audit columns. + +```tsx +// GOOD + + +// BAD — duplicates shared prefix/postfix +columnDefs={[{ colId: 'action_column', width: 180 }, { field: 'created_at' }]} +``` + +## Audit fields + +Transformers map camelCase: `createdBy`, `createdAt`, `updatedBy`, `updatedAt`. The shared table postfix binds those names (with snake_case fallback). Do not emit `creator_name` / `created_at` in TrackGo transformers. + +## Action column + +Width is computed in `EnterpriseDataTable` from privileges (`flex: 0`, nowrap icons). Do not hard-code `width: 180` in module pages. + +## Reload + +Toolbar includes search, filter, and reload. Reload calls `gridApi.refreshServerSide({ purge: true })` — never `window.location.reload()`. i18n: `common:actions.reload`. diff --git a/packages/ui/src/foundations/enterprise-module/components/data-table/action-column.utils.test.ts b/packages/ui/src/foundations/enterprise-module/components/data-table/action-column.utils.test.ts new file mode 100644 index 0000000..4695e81 --- /dev/null +++ b/packages/ui/src/foundations/enterprise-module/components/data-table/action-column.utils.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; +import { defaultPrivileges, noPrivileges } from '../../constant/default-privilege'; +import { ACTION_COLUMN_CELL_PADDING, ACTION_ICON_WIDTH, computeActionColumnWidth } from './action-column.utils'; + +describe('computeActionColumnWidth', () => { + it('sizes for View only when no mutating privileges are granted', () => { + expect( + computeActionColumnWidth({ + moduleType: 'MASTER_DATA', + privileges: noPrivileges, + }), + ).toBe(ACTION_ICON_WIDTH + ACTION_COLUMN_CELL_PADDING); + }); + + it('grows with MASTER_DATA privileges (View, Edit, Duplicate, Activate/Deactivate, Delete)', () => { + expect( + computeActionColumnWidth({ + moduleType: 'MASTER_DATA', + privileges: defaultPrivileges, + }), + ).toBe(5 * ACTION_ICON_WIDTH + ACTION_COLUMN_CELL_PADDING); + }); + + it('treats activate and deactivate as a single slot on MASTER_DATA', () => { + const activateOnly = computeActionColumnWidth({ + moduleType: 'MASTER_DATA', + privileges: { ...noPrivileges, ALLOW_ACTIVATE: true }, + }); + const both = computeActionColumnWidth({ + moduleType: 'MASTER_DATA', + privileges: { ...noPrivileges, ALLOW_ACTIVATE: true, ALLOW_DEACTIVATE: true }, + }); + expect(activateOnly).toBe(both); + expect(activateOnly).toBe(2 * ACTION_ICON_WIDTH + ACTION_COLUMN_CELL_PADDING); + }); + + it('grows with TRANSACTION privileges (View, Edit, Duplicate, Hold, Rollback, Cancel, Confirm, Delete)', () => { + expect( + computeActionColumnWidth({ + moduleType: 'TRANSACTION', + privileges: defaultPrivileges, + }), + ).toBe(8 * ACTION_ICON_WIDTH + ACTION_COLUMN_CELL_PADDING); + }); + + it('is wider for a full TRANSACTION action set than MASTER_DATA', () => { + const master = computeActionColumnWidth({ + moduleType: 'MASTER_DATA', + privileges: defaultPrivileges, + }); + const transaction = computeActionColumnWidth({ + moduleType: 'TRANSACTION', + privileges: defaultPrivileges, + }); + expect(transaction).toBeGreaterThan(master); + }); +}); diff --git a/packages/ui/src/foundations/enterprise-module/components/data-table/action-column.utils.ts b/packages/ui/src/foundations/enterprise-module/components/data-table/action-column.utils.ts new file mode 100644 index 0000000..b2b707c --- /dev/null +++ b/packages/ui/src/foundations/enterprise-module/components/data-table/action-column.utils.ts @@ -0,0 +1,32 @@ +import type { PrivilegeEntity } from '../../entities/entity'; + +export const ACTION_ICON_WIDTH = 42; +export const ACTION_COLUMN_CELL_PADDING = 24; + +export interface ComputeActionColumnWidthParams { + moduleType: string; + privileges: PrivilegeEntity; +} + +export function computeActionColumnWidth(params: ComputeActionColumnWidthParams): number { + const { moduleType, privileges } = params; + const isTransaction = moduleType === 'TRANSACTION'; + + let actionCount = 1; + + if (privileges.ALLOW_EDIT) actionCount += 1; + if (privileges.ALLOW_CREATE) actionCount += 1; + + if (isTransaction) { + if (privileges.ALLOW_HOLD) actionCount += 1; + if (privileges.ALLOW_ROLLBACK) actionCount += 1; + if (privileges.ALLOW_CANCEL) actionCount += 1; + if (privileges.ALLOW_CONFIRM) actionCount += 1; + } else if (privileges.ALLOW_ACTIVATE || privileges.ALLOW_DEACTIVATE) { + actionCount += 1; + } + + if (privileges.ALLOW_DELETE) actionCount += 1; + + return actionCount * ACTION_ICON_WIDTH + ACTION_COLUMN_CELL_PADDING; +} diff --git a/packages/ui/src/foundations/enterprise-module/components/data-table/audit-column.utils.test.ts b/packages/ui/src/foundations/enterprise-module/components/data-table/audit-column.utils.test.ts new file mode 100644 index 0000000..fc3d135 --- /dev/null +++ b/packages/ui/src/foundations/enterprise-module/components/data-table/audit-column.utils.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { DateUtils } from '@repo/utils'; +import { + EMPTY_AUDIT_DISPLAY, + formatAuditActor, + formatAuditTimestamp, + resolveAuditValue, +} from './audit-column.utils'; + +describe('resolveAuditValue', () => { + it('prefers camelCase over snake_case', () => { + expect( + resolveAuditValue({ createdBy: 'alice', creator_name: 'legacy' }, 'createdBy', 'creator_name'), + ).toBe('alice'); + }); + + it('falls back to snake_case when camelCase is missing', () => { + expect(resolveAuditValue({ creator_name: 'legacy' }, 'createdBy', 'creator_name')).toBe('legacy'); + expect(resolveAuditValue({ created_at: 1_700_000_000_000 }, 'createdAt', 'created_at')).toBe( + 1_700_000_000_000, + ); + }); + + it('returns undefined when neither field is present', () => { + expect(resolveAuditValue({}, 'createdBy', 'creator_name')).toBeUndefined(); + expect(resolveAuditValue(undefined, 'createdBy', 'creator_name')).toBeUndefined(); + }); +}); + +describe('formatAuditActor', () => { + it('returns a string actor as-is', () => { + expect(formatAuditActor('alice')).toBe('alice'); + }); + + it('prefers username, then name, then id on nested actors', () => { + expect(formatAuditActor({ username: 'alice', name: 'Alice', id: 'u-1' })).toBe('alice'); + expect(formatAuditActor({ name: 'Alice', id: 'u-1' })).toBe('Alice'); + expect(formatAuditActor({ id: 'u-1' })).toBe('u-1'); + }); + + it('returns a dash when the actor is empty', () => { + expect(formatAuditActor(undefined)).toBe(EMPTY_AUDIT_DISPLAY); + expect(formatAuditActor(null)).toBe(EMPTY_AUDIT_DISPLAY); + expect(formatAuditActor('')).toBe(EMPTY_AUDIT_DISPLAY); + expect(formatAuditActor({})).toBe(EMPTY_AUDIT_DISPLAY); + }); +}); + +describe('formatAuditTimestamp', () => { + it('formats unix milliseconds with DateUtils', () => { + const ms = 1_700_000_000_000; + expect(formatAuditTimestamp(ms)).toBe(new DateUtils(ms).format('DD-MM-YYYY, HH:mm')); + }); + + it('accepts numeric strings', () => { + const ms = '1700000000000'; + expect(formatAuditTimestamp(ms)).toBe(new DateUtils(Number(ms)).format('DD-MM-YYYY, HH:mm')); + }); + + it('returns a dash when the timestamp is empty', () => { + expect(formatAuditTimestamp(undefined)).toBe(EMPTY_AUDIT_DISPLAY); + expect(formatAuditTimestamp(null)).toBe(EMPTY_AUDIT_DISPLAY); + expect(formatAuditTimestamp('')).toBe(EMPTY_AUDIT_DISPLAY); + }); +}); diff --git a/packages/ui/src/foundations/enterprise-module/components/data-table/audit-column.utils.ts b/packages/ui/src/foundations/enterprise-module/components/data-table/audit-column.utils.ts new file mode 100644 index 0000000..f079b17 --- /dev/null +++ b/packages/ui/src/foundations/enterprise-module/components/data-table/audit-column.utils.ts @@ -0,0 +1,33 @@ +import { DateUtils } from '@repo/utils'; + +export const EMPTY_AUDIT_DISPLAY = '-'; + +const AUDIT_TIMESTAMP_FORMAT = 'DD-MM-YYYY, HH:mm'; + +export function resolveAuditValue( + row: Record | null | undefined, + camelKey: string, + snakeKey: string, +): unknown { + if (!row) return undefined; + const camelValue = row[camelKey]; + if (camelValue !== undefined && camelValue !== null) return camelValue; + return row[snakeKey]; +} + +export function formatAuditActor(value: unknown): string { + if (value === undefined || value === null || value === '') return EMPTY_AUDIT_DISPLAY; + if (typeof value === 'string') return value; + if (typeof value === 'object') { + const actor = value as { username?: unknown; name?: unknown; id?: unknown }; + const label = actor.username ?? actor.name ?? actor.id; + if (label === undefined || label === null || label === '') return EMPTY_AUDIT_DISPLAY; + return String(label); + } + return String(value); +} + +export function formatAuditTimestamp(value: unknown): string { + if (value === undefined || value === null || value === '') return EMPTY_AUDIT_DISPLAY; + return new DateUtils(Number(value)).format(AUDIT_TIMESTAMP_FORMAT); +} diff --git a/packages/ui/src/foundations/enterprise-module/components/data-table/index.tsx b/packages/ui/src/foundations/enterprise-module/components/data-table/index.tsx index 5624a8b..d6abe1b 100644 --- a/packages/ui/src/foundations/enterprise-module/components/data-table/index.tsx +++ b/packages/ui/src/foundations/enterprise-module/components/data-table/index.tsx @@ -20,6 +20,7 @@ import { useDisclosure } from '@mantine/hooks'; import { Search, Filter, + RefreshCw, // Settings } from 'lucide-react'; @@ -50,7 +51,8 @@ import { BulkActionConfirmationModal } from '../bulk-action-confirmation'; import { TableFilterDrawer, TableFilterConfig } from './components/table-filter-drawer'; // import { TableSettingDrawer } from './components/table-setting-drawer'; import { EntityId } from '../../../../../../core-api/src/data-services/types'; -import { DateUtils } from '@repo/utils'; +import { computeActionColumnWidth } from './action-column.utils'; +import { formatAuditActor, formatAuditTimestamp, resolveAuditValue } from './audit-column.utils'; export * from 'ag-grid-community'; export * from 'ag-grid-react'; @@ -222,9 +224,10 @@ export function EnterpriseDataTable(props: EnterpriseDataT } = useEnterpriseModuleSelectionContext(); const navigation = useEnterpriseModuleNavigationContext(); - const { config } = useEnterpriseModuleConfigContext(); + const { config, privileges } = useEnterpriseModuleConfigContext(); const { moduleType } = config; const isTransaction = moduleType === 'TRANSACTION'; + const actionColumnWidth = computeActionColumnWidth({ moduleType, privileges }); // --------------------------------------------------------------------------- // Local UI State @@ -295,6 +298,10 @@ export function EnterpriseDataTable(props: EnterpriseDataT gridApiRef.current?.refreshServerSide({ purge: true }); }, []); + const handleReload = useCallback(() => { + gridApiRef.current?.refreshServerSide({ purge: true }); + }, []); + // --------------------------------------------------------------------------- // Action Handlers & Modal State // --------------------------------------------------------------------------- @@ -647,8 +654,9 @@ export function EnterpriseDataTable(props: EnterpriseDataT const actionColumn: ColDef = { colId: 'action_column', pinned: 'left', // Pin to the right so it remains visible during horizontal scrolling, - width: 180, - minWidth: 100, + flex: 0, + width: actionColumnWidth, + minWidth: actionColumnWidth, sortable: false, // Disable sorting filter: false, // Disable filtering suppressHeaderMenuButton: true, // Suppress menu to keep the header clean @@ -690,34 +698,36 @@ export function EnterpriseDataTable(props: EnterpriseDataT const postfixColumn: ColDef[] = [ { - colId: 'creator_name', - field: 'creator_name' as any, + colId: 'createdBy', + field: 'createdBy' as any, + minWidth: 160, headerName: t('common:fields.createdBy'), - cellRenderer: ({ value }: any) => value ?? '-', + valueGetter: (params) => resolveAuditValue(params.data, 'createdBy', 'creator_name'), + cellRenderer: ({ value }: { value: unknown }) => formatAuditActor(value), }, - { - colId: 'created_at', - field: 'created_at', + colId: 'createdAt', + field: 'createdAt' as any, + minWidth: 160, headerName: t('common:fields.createdAt'), - cellRenderer: ({ value }: any) => { - return value ? new DateUtils(Number(value)).format('DD-MM-YYYY, HH:mm') : '-'; - }, + valueGetter: (params) => resolveAuditValue(params.data, 'createdAt', 'created_at'), + cellRenderer: ({ value }: { value: unknown }) => formatAuditTimestamp(value), }, { - colId: 'editor_name', - field: 'editor_name' as any, + colId: 'updatedBy', + field: 'updatedBy' as any, + minWidth: 160, headerName: t('common:fields.updatedBy'), - cellRenderer: ({ value }: any) => value ?? '-', + valueGetter: (params) => resolveAuditValue(params.data, 'updatedBy', 'editor_name'), + cellRenderer: ({ value }: { value: unknown }) => formatAuditActor(value), }, - { - colId: 'updated_at', - field: 'updated_at', + colId: 'updatedAt', + field: 'updatedAt' as any, + minWidth: 160, headerName: t('common:fields.updatedAt'), - cellRenderer: ({ value }: any) => { - return value ? new DateUtils(Number(value)).format('DD-MM-YYYY, HH:mm') : '-'; - }, + valueGetter: (params) => resolveAuditValue(params.data, 'updatedAt', 'updated_at'), + cellRenderer: ({ value }: { value: unknown }) => formatAuditTimestamp(value), }, ]; @@ -734,6 +744,7 @@ export function EnterpriseDataTable(props: EnterpriseDataT statusKey, customRowActions, handleActionClick, + actionColumnWidth, ]); // Default configuration applied to all columns in the grid @@ -946,6 +957,14 @@ export function EnterpriseDataTable(props: EnterpriseDataT tooltipLabel: t('common:actions.filter'), onClick: openFilter, }, + { + key: 'reload', + icon: , + variant: 'default', + showLabel: false, + tooltipLabel: t('common:actions.reload'), + onClick: handleReload, + }, // { // key: 'setting', // icon: ,