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.
This commit is contained in:
shancheas
2026-08-27 11:06:00 +07:00
parent b22ce99840
commit 9e710a92b2
9 changed files with 358 additions and 22 deletions
@@ -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);
});
});
@@ -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;
}
@@ -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);
});
});
@@ -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<string, unknown> | 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);
}
@@ -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<E extends BaseEntity>(props: EnterpriseDataT
} = useEnterpriseModuleSelectionContext<E>();
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<E extends BaseEntity>(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<E extends BaseEntity>(props: EnterpriseDataT
const actionColumn: ColDef<any> = {
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<E extends BaseEntity>(props: EnterpriseDataT
const postfixColumn: ColDef<E>[] = [
{
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<E extends BaseEntity>(props: EnterpriseDataT
statusKey,
customRowActions,
handleActionClick,
actionColumnWidth,
]);
// Default configuration applied to all columns in the grid
@@ -946,6 +957,14 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
tooltipLabel: t('common:actions.filter'),
onClick: openFilter,
},
{
key: 'reload',
icon: <RefreshCw size={16} />,
variant: 'default',
showLabel: false,
tooltipLabel: t('common:actions.reload'),
onClick: handleReload,
},
// {
// key: 'setting',
// icon: <Settings size={16} />,