Merge pull request 'core/page-provider' (#26) from core/page-provider into main

Reviewed-on: eigen/fe-monorepo-template#26
This commit is contained in:
2026-07-22 06:42:51 +00:00
11 changed files with 281 additions and 58 deletions
@@ -8,9 +8,7 @@
"create_page_description": "Fill out the form below to add a new record to the system.",
"duplicate_page_description": "Copy and modify information from an existing record to quickly create a new one.",
"fields": {
"status": "Status",
"name": "Name",
"code": "Code",
"description": "Description"
"name": "Name"
}
}
@@ -8,9 +8,7 @@
"create_page_description": "Lengkapi formulir di bawah ini untuk menambahkan data baru ke dalam sistem.",
"duplicate_page_description": "Salin dan sesuaikan informasi dari data yang sudah ada untuk mempercepat pembuatan data baru.",
"fields": {
"status": "Status",
"name": "Nama",
"code": "Kode",
"description": "Deskripsi"
"name": "Nama"
}
}
@@ -6,10 +6,10 @@ export default function FullPagePageDetail() {
return (
<EnterpriseDetailPageProvider
highlightDataKey="code"
pageHeaderProps={{
title: t('detail_page_title'),
description: t('detail_page_description'),
// showBadges: false,
breadcrumbs: [
{ label: t('nav:example-module'), type: 'text' },
{ label: t('nav:example-full-page'), type: 'link', href: `${fullPageModuleConfig.webUrl}/index` },
@@ -1,38 +1,32 @@
import { EnterpriseIndexPageProvider, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
import {
EnterpriseIndexPageProvider,
useEnterpriseModuleTranslationContext,
EnterpriseDataTable,
} from '@repo/ui/foundations';
import { ColDef } from '@repo/ui/components';
import { Trans } from '@repo/core-i18n';
import { Badge, Text } from '@repo/ui/components';
import { Activity, LayoutDashboard } from 'lucide-react';
import { DataTable } from '../components/data-table';
import { Text } from '@repo/ui/components';
import { LayoutDashboard } from 'lucide-react';
import { useMemo } from 'react';
export default function FullPagePageIndex() {
const { t } = useEnterpriseModuleTranslationContext();
const columnDefs: ColDef<any>[] = useMemo(() => {
return [
{ field: 'code', headerName: t('fields.code'), minWidth: 150 },
{ field: 'name', headerName: t('fields.name'), minWidth: 150 },
];
}, [t]);
return (
<EnterpriseIndexPageProvider
pageHeaderProps={{
title: t('title'),
description: (
<Trans
t={t}
i18nKey="description"
components={{
1: <Text span fw={500} c="var(--mantine-color-text)" />,
}}
/>
),
badges: (
<Badge
variant="light"
color="teal"
size="sm"
radius="sm"
fw={600}
leftSection={<Activity size={10} strokeWidth={3} />}
style={{ textTransform: 'capitalize' }}
>
Healthy
</Badge>
<Trans t={t} i18nKey="description" components={{ 1: <Text span fw={500} c="var(--mantine-color-text)" /> }} />
),
icon: LayoutDashboard,
breadcrumbs: [
{ label: t('nav:example-module'), type: 'text' },
@@ -40,7 +34,7 @@ export default function FullPagePageIndex() {
],
}}
>
<DataTable />
<EnterpriseDataTable columnDefs={columnDefs} />
</EnterpriseIndexPageProvider>
);
}
@@ -5,10 +5,10 @@ import { FullPageEntity } from '../../domain/entities';
export interface FullPageStoreState extends EnterpriseModuleState<FullPageEntity> {}
export const fullPageStore = create<FullPageStoreState>((set) => ({
metaData: null,
metaData: { limit: 15 },
setMetaData: (data) => set({ metaData: data }),
filterData: { page: 1, limit: 10 },
filterData: {},
setFilterData: (data) => set({ filterData: data }),
selectedRows: [],
@@ -10,10 +10,11 @@ export interface NestJSPaginationMeta {
}
export interface StandardPaginationMeta {
page: number;
limit: number;
total: number;
totalPages: number;
page?: number;
limit?: number;
total?: number;
totalPages?: number;
countPerPage?: number;
}
export interface PaginatedResponse<T> {
@@ -21,7 +22,7 @@ export interface PaginatedResponse<T> {
meta: StandardPaginationMeta;
}
export function defaultTransformPaginationMeta(meta: any): StandardPaginationMeta {
export function defaultTransformPaginationMeta(meta: NestJSPaginationMeta): StandardPaginationMeta {
if (!meta) {
return { page: 1, limit: 10, total: 0, totalPages: 0 };
}
@@ -30,6 +31,7 @@ export function defaultTransformPaginationMeta(meta: any): StandardPaginationMet
limit: meta.itemsPerPage ?? meta.limit ?? 10,
total: meta.totalItems ?? meta.total ?? 0,
totalPages: meta.totalPages ?? 0,
countPerPage: meta?.itemCount ?? 0,
};
}
@@ -113,6 +113,41 @@
"system_menu": {
"history": "History",
"bookmark": "Bookmark"
},
"fields": {
"id": "ID",
"code": "Code",
"name": "Name",
"description": "Description",
"number": "Number",
"status": "Status",
"type": "Type",
"category": "Category",
"action": "Action",
"createdAt": "Created At",
"updatedAt": "Updated At",
"createdBy": "Created By",
"updatedBy": "Updated By",
"deletedAt": "Deleted At",
"date": "Date",
"amount": "Amount",
"price": "Price",
"quantity": "Quantity",
"total": "Total",
"balance": "Balance",
"address": "Address",
"phone": "Phone Number",
"email": "Email",
"user": "User",
"role": "Role",
"notes": "Notes",
"isActive": "Active",
"isDefault": "Default",
"isRequired": "Required",
"startDate": "Start Date",
"endDate": "End Date",
"attachment": "Attachment",
"reference": "Reference"
}
}
}
@@ -113,6 +113,41 @@
"system_menu": {
"history": "Riwayat",
"bookmark": "Bookmark"
},
"fields": {
"id": "ID",
"code": "Kode",
"name": "Nama",
"description": "Deskripsi",
"number": "Nomor",
"status": "Status",
"type": "Tipe",
"category": "Kategori",
"action": "Aksi",
"createdAt": "Tanggal Dibuat",
"updatedAt": "Tanggal Diperbarui",
"createdBy": "Dibuat Oleh",
"updatedBy": "Diperbarui Oleh",
"deletedAt": "Tanggal Dihapus",
"date": "Tanggal",
"amount": "Nominal",
"price": "Harga",
"quantity": "Kuantitas",
"total": "Total",
"balance": "Saldo",
"address": "Alamat",
"phone": "No. Telepon",
"email": "Email",
"user": "Pengguna",
"role": "Peran",
"notes": "Catatan",
"isActive": "Aktif",
"isDefault": "Bawaan",
"isRequired": "Wajib",
"startDate": "Tanggal Mulai",
"endDate": "Tanggal Selesai",
"attachment": "Lampiran",
"reference": "Referensi"
}
}
}
@@ -0,0 +1,61 @@
import { useMemo } from 'react';
import { ModuleAction, ModuleActionType } from '../../../entities/entity';
import { useEnterpriseModuleTranslationContext } from '../../../hooks/use-module.context';
import { Trash2, CheckCircle, XCircle } from 'lucide-react';
import { PageActionProps } from '../../../../../components';
export interface UseBulkActionsProps {
selectedRows: any[];
onActionClick: (action: ModuleActionType, data: any[]) => void;
statusKey?: string;
customBulkActions?: (selectedRows: any[], defaultActions: PageActionProps[]) => PageActionProps[];
}
export function useBulkActions({
selectedRows,
onActionClick,
statusKey = 'status',
customBulkActions,
}: UseBulkActionsProps) {
const { t } = useEnterpriseModuleTranslationContext();
return useMemo(() => {
if (!selectedRows || selectedRows.length === 0) return [];
const hasActive = selectedRows.some((row) => row[statusKey]?.toLowerCase() === 'active');
const hasInactive = selectedRows.some((row) => row[statusKey]?.toLowerCase() === 'inactive');
const defaultActions: PageActionProps[] = [];
if (hasActive) {
defaultActions.push({
key: ModuleAction.DEACTIVATE,
label: t('common:actions.deactivate'),
icon: <XCircle size={16} />,
variant: 'default',
onClick: () => onActionClick(ModuleAction.DEACTIVATE, selectedRows),
});
}
if (hasInactive) {
defaultActions.push({
key: ModuleAction.ACTIVATE,
label: t('common:actions.activate'),
icon: <CheckCircle size={16} />,
variant: 'default',
onClick: () => onActionClick(ModuleAction.ACTIVATE, selectedRows),
});
}
defaultActions.push({
key: ModuleAction.DELETE,
label: t('common:actions.delete'),
icon: <Trash2 size={16} />,
variant: 'outline',
intent: 'destructive',
onClick: () => onActionClick(ModuleAction.DELETE, selectedRows),
});
return customBulkActions ? customBulkActions(selectedRows, defaultActions) : defaultActions;
}, [selectedRows, statusKey, t, onActionClick, customBulkActions]);
}
@@ -0,0 +1,77 @@
import { useMemo } from 'react';
import { Menu, ActionIcon } from '@mantine/core';
import { MoreVertical, Eye, Trash2, CheckCircle, XCircle } from 'lucide-react';
import { ModuleAction, ModuleActionType } from '../../../entities/entity';
import { useEnterpriseModuleTranslationContext } from '../../../hooks/use-module.context';
export interface RowActionMenuProps {
data: any;
rowIndex: number;
onActionClick: (action: ModuleActionType, data: any) => void;
statusKey?: string;
customActions?: (data: any, defaultActions: any[]) => any[];
}
export function RowActionMenu({ data, onActionClick, statusKey = 'status', customActions }: RowActionMenuProps) {
const { t } = useEnterpriseModuleTranslationContext();
const status = data?.[statusKey]?.toLowerCase();
const defaultActions = useMemo(() => {
const actions: any[] = [];
// View Details
actions.push({
key: 'VIEW',
label: t('common:actions.detail'),
icon: <Eye size={14} />,
onClick: () => onActionClick('VIEW' as any, data),
});
// Active/Inactive toggle
if (status === 'active') {
actions.push({
key: ModuleAction.DEACTIVATE,
label: t('common:actions.deactivate'),
icon: <XCircle size={14} />,
onClick: () => onActionClick(ModuleAction.DEACTIVATE, data),
});
} else if (status === 'inactive') {
actions.push({
key: ModuleAction.ACTIVATE,
label: t('common:actions.activate'),
icon: <CheckCircle size={14} />,
onClick: () => onActionClick(ModuleAction.ACTIVATE, data),
});
}
// Delete
actions.push({
key: ModuleAction.DELETE,
label: t('common:actions.delete'),
icon: <Trash2 size={14} />,
color: 'red',
onClick: () => onActionClick(ModuleAction.DELETE, data),
});
return actions;
}, [status, t, onActionClick, data]);
const finalActions = customActions ? customActions(data, defaultActions) : defaultActions;
return (
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon variant="subtle" color="gray">
<MoreVertical size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
{finalActions.map((action, idx) => (
<Menu.Item key={action.key || idx} color={action.color} leftSection={action.icon} onClick={action.onClick}>
{action.label}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
);
}
@@ -7,14 +7,13 @@ import {
IServerSideDatasource,
IServerSideGetRowsParams,
SelectionChangedEvent,
GetContextMenuItemsParams,
MenuItemDef,
DefaultMenuItem,
StatusBar,
} from 'ag-grid-community';
import { Box } from '@mantine/core';
import { DataGrid } from '../../../../components';
import { DataGrid, StatusBadge } from '../../../../components';
import {
useEnterpriseModuleDataServiceContext,
@@ -86,7 +85,7 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
// ---------------------------------------------------------------------------
const { t } = useEnterpriseModuleTranslationContext();
const { dataServices } = useEnterpriseModuleDataServiceContext<E>();
const { setSelectedRows, setFilterData, metaData, setMetaData } = useEnterpriseModuleSelectionContext<E>();
const { setSelectedRows, metaData, setMetaData } = useEnterpriseModuleSelectionContext<E>();
// ---------------------------------------------------------------------------
// Local UI State
@@ -100,6 +99,7 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
// ---------------------------------------------------------------------------
// Determine the number of rows per page based on metadata, defaulting to 10
const perPage = useMemo(() => {
console.log({ metaData });
return metaData?.limit ?? 10;
}, [metaData]);
@@ -108,8 +108,20 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
// Ensure column definitions are referentially stable
const finalColumnDefs = useMemo<ColDef<E>[]>(() => {
return columnDefs;
}, [columnDefs]);
const masterDetailColumn: ColDef<E> = { maxWidth: 50, sortable: false, cellRenderer: 'agGroupCellRenderer' };
const statusColumn: ColDef<E> = {
maxWidth: 130,
field: 'status' as any,
headerName: t('common:fields.status'),
cellRenderer: ({ value }: any) => <StatusBadge status={value} />,
};
const prefixColumn: ColDef<E>[] = [props.masterDetail ? masterDetailColumn : (null as any), statusColumn].filter(
Boolean,
);
return [...prefixColumn, ...columnDefs];
}, [columnDefs, props.masterDetail]);
// Default configuration applied to all columns in the grid
const defaultColDef = useMemo<ColDef>(() => ({ flex: 1, minWidth: 100, sortable: true, resizable: true }), []);
@@ -186,35 +198,45 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
);
// Configures the context menu items available when right-clicking a cell
const getContextMenuItems = useCallback(
(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
params: GetContextMenuItemsParams,
): (DefaultMenuItem | MenuItemDef)[] | Promise<(DefaultMenuItem | MenuItemDef)[]> => {
const getContextMenuItems = useCallback(():
| (DefaultMenuItem | MenuItemDef)[]
| Promise<(DefaultMenuItem | MenuItemDef)[]> => {
return ['copy', 'copyWithHeaders'];
},
[],
);
}, []);
const statusBar = useMemo<StatusBar | undefined>(() => {
if (!showStatusbar) return undefined;
return { statusPanels: [{ statusPanel: 'agSelectedRowCountComponent', align: 'left' }] };
}, [showStatusbar]);
const domLayout = useMemo(() => {
const meta = { total: metaData?.total ?? 0, limit: metaData?.limit ?? 0 };
if (!isPaginated) {
return meta?.total < meta?.limit ? 'autoHeight' : 'normal';
}
return 'autoHeight';
}, [metaData, isPaginated]);
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
return (
<Box>
{/* GRID CONTAINER */}
<Box style={{ height: gridHeight, width: '100%' }} className="erp-data-grid-container">
<Box
className="erp-data-grid-container"
style={{
width: '100%',
height: domLayout === 'autoHeight' || restAgGridProps.domLayout === 'autoHeight' ? '100%' : gridHeight,
}}
>
<DataGrid<E>
rowModelType="serverSide"
getRowId={(v) => v.data.id as string}
cacheBlockSize={perPage}
pagination={isPaginated}
paginationPageSize={isPaginated ? perPage : undefined}
paginationPageSizeSelector={isPaginated ? [10, 20, 50] : undefined}
paginationPageSizeSelector={isPaginated ? [10, 15, 20, 50] : undefined}
columnDefs={finalColumnDefs}
defaultColDef={defaultColDef}
animateRows={true}
@@ -224,6 +246,7 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
onGridReady={onGridReady}
getContextMenuItems={getContextMenuItems}
statusBar={statusBar}
domLayout={domLayout}
overlayLoadingTemplate={`<span style="padding:10px">${loadingMessage}</span>`}
overlayNoRowsTemplate={`<span style="padding:10px;color:var(--ag-foreground-color,#868e96)">${noRowsMessage}</span>`}
{...restAgGridProps}