feat: implement report engine with sales and logistics modules

- Added a new report engine UI, including configuration-driven report screens for sales and logistics.
- Created `ReportProvider`, `ReportTable`, and related components for rendering reports with server-side data fetching.
- Introduced `logisticsReportsModuleConfig` and `salesReportsModuleConfig` for managing report configurations and API interactions.
- Updated navigation to include sales and logistics reports with proper labels and paths.
- Enhanced language support for report titles in English and Indonesian.
- Implemented bookmark and filter functionalities for reports, improving user interaction and data management.

These changes establish a robust reporting framework, enhancing the application's data visualization capabilities and user experience.
This commit is contained in:
shancheas
2026-09-01 08:46:08 +07:00
parent 90b606f865
commit 991bf3fe65
33 changed files with 1359 additions and 11 deletions
+42
View File
@@ -0,0 +1,42 @@
# Reports UI
Generic, config-driven report screens live in `apps/web/src/core/report/`. Report definitions are backend `ReportConfigEntity` objects.
Architecture and APIs: [trackgo-be/docs/report-engine.md](../../../../../trackgo-be/docs/report-engine.md)
## Layout
| Path | Role |
| --- | --- |
| `constants/` | `FILTER_TYPE`, `DATA_FORMAT`, `REPORT_GROUP` — keep in sync with backend |
| `entities/` | Frontend mirror of config / query contracts |
| `data/report.remote.service.ts` | HTTP client (`apiClient`) |
| `utils/filter.helper.ts` | Form values → `filterModel` |
| `utils/column.helper.ts` | `columnConfigs` → AG Grid `columnDefs` |
| `components/report-provider.tsx` | Load configs → Mantine tabs |
| `components/report-table.tsx` | AG Grid SSRM + filter/bookmark actions |
| `components/report-filter-drawer.tsx` | Config-driven filter form |
| `components/report-bookmark-list.tsx` | Bookmark apply / delete |
## Product modules
| Module | Path | `moduleKey` |
| --- | --- | --- |
| Sales reports | `apps/main/modules/sales/reports/` | `SALES.REPORT` |
| Logistics reports | `apps/main/modules/field/logistics-reports/` | `LOGISTICS.REPORT` |
Each module wraps `ReportProvider` with `groupName` `sales_report` or `logistics_report` inside `EnterpriseModuleProvider` for RBAC.
## Grid contract
Every server-side block sends:
```ts
{
groupName,
uniqueName,
queryModel: { /* AG Grid IServerSideGetRowsRequest + merged filterModel */ }
}
```
Column defs, filters, and formats come from the config payload. Adding a report is a backend config change only.
@@ -106,10 +106,10 @@ export const MENU_ITEMS: MenuItemType[] = [
},
{
key: 'sales-reports',
label: 'nav:reports-coming-soon',
label: 'nav:sales-reports',
icon: FileText,
path: '/app/sales/reports',
isPlaceholder: true,
path: '/app/sales/reports/index',
moduleKey: 'SALES.REPORT',
},
],
},
@@ -165,10 +165,10 @@ export const MENU_ITEMS: MenuItemType[] = [
},
{
key: 'logistics-reports',
label: 'nav:reports-coming-soon',
label: 'nav:logistics-reports',
icon: FileText,
path: '/app/logistics/reports',
isPlaceholder: true,
path: '/app/logistics/reports/index',
moduleKey: 'LOGISTICS.REPORT',
},
],
},
@@ -31,6 +31,8 @@
"data": "Data",
"activities": "Activities",
"reports-coming-soon": "Reports (Coming Soon)",
"sales-reports": "Sales Reports",
"logistics-reports": "Logistics Reports",
"user": "User",
"settings": "Settings",
"settings-general": "General Settings",
@@ -31,6 +31,8 @@
"data": "Data",
"activities": "Aktivitas",
"reports-coming-soon": "Laporan (Segera Hadir)",
"sales-reports": "Laporan Penjualan",
"logistics-reports": "Laporan Logistik",
"user": "Pengguna",
"settings": "Pengaturan",
"settings-general": "Pengaturan Umum",
@@ -0,0 +1,13 @@
import type { BaseEntity } from '@repo/core-api/data-services';
import type { ModuleConfigEntity } from '@repo/ui/foundations';
export type ReportShellEntity = BaseEntity & { id: string };
export const logisticsReportsModuleConfig: ModuleConfigEntity<ReportShellEntity> = {
moduleKey: 'LOGISTICS.REPORT',
translationNamespace: 'LOGISTICS_REPORTS',
apiUrl: '/reports',
webUrl: '/app/logistics/reports',
moduleCategory: 'SINGLE_PAGE',
moduleType: 'TRANSACTION',
} as const;
@@ -0,0 +1,23 @@
import { BaseDataTransformer } from '@repo/core-api/data-services';
import { apiClient } from '../../../../../../../core/lib/api-client';
import { TrackGoRemoteDataServices } from '../../../../../../../core/lib/trackgo-remote-data-services';
import {
logisticsReportsModuleConfig,
type ReportShellEntity,
} from '../constants/reports.constants';
class ReportShellTransformer extends BaseDataTransformer<ReportShellEntity> {
transformToEntity(dto: ReportShellEntity): ReportShellEntity {
return dto;
}
transformToDTO(entity: ReportShellEntity): ReportShellEntity {
return entity;
}
}
export const logisticsReportsDataService = new TrackGoRemoteDataServices(apiClient, {
apiUrl: logisticsReportsModuleConfig.apiUrl,
moduleKey: logisticsReportsModuleConfig.moduleKey,
transformer: new ReportShellTransformer(),
});
@@ -0,0 +1,36 @@
import { lazy } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import { EnterpriseModuleProvider } from '@repo/ui/foundations';
import { registerModuleNamespace } from '@repo/core-i18n';
import {
logisticsReportsModuleConfig,
type ReportShellEntity,
} from '../../domain/constants/reports.constants';
import { logisticsReportsDataService } from '../../domain/factories';
import { logisticsReportsStore } from '../store';
import reportsEn from '../languages/en/reports.json';
import reportsId from '../languages/id/reports.json';
const IndexPage = lazy(() => import('../pages/reports.page.index'));
registerModuleNamespace(logisticsReportsModuleConfig.translationNamespace, {
en: reportsEn,
id: reportsId,
});
export default function LogisticsReportsModule() {
return (
<EnterpriseModuleProvider<ReportShellEntity>
config={logisticsReportsModuleConfig}
dataServices={logisticsReportsDataService}
store={logisticsReportsStore}
>
<Routes>
<Route path="/index" element={<IndexPage />} />
<Route path="/" element={<Navigate to={`${logisticsReportsModuleConfig.webUrl}/index`} replace />} />
<Route path="*" element={<Navigate to="/404" replace />} />
</Routes>
</EnterpriseModuleProvider>
);
}
@@ -0,0 +1,4 @@
{
"title": "Logistics Reports",
"description": "Config-driven logistics report tables"
}
@@ -0,0 +1,4 @@
{
"title": "Laporan Logistik",
"description": "Tabel laporan logistik berbasis konfigurasi"
}
@@ -0,0 +1,27 @@
import { FileText } from 'lucide-react';
import { CorePageContainer } from '@repo/ui/components';
import { ModulePageHeader } from '@repo/ui/foundations';
import { useTranslation } from '@repo/core-i18n';
import { REPORT_GROUP, ReportProvider } from '../../../../../../../core/report';
import { logisticsReportsModuleConfig } from '../../domain/constants/reports.constants';
export default function LogisticsReportsPage() {
const { t } = useTranslation(logisticsReportsModuleConfig.translationNamespace);
const { t: tNav } = useTranslation('nav');
return (
<CorePageContainer>
<ModulePageHeader
icon={FileText}
title={t('title')}
description={t('description')}
moduleKey={logisticsReportsModuleConfig.moduleKey}
breadcrumbs={[
{ label: tNav('logistics'), type: 'text' },
{ label: t('title'), type: 'text' },
]}
/>
<ReportProvider groupName={REPORT_GROUP.LOGISTICS_REPORT} />
</CorePageContainer>
);
}
@@ -0,0 +1,16 @@
import { create } from 'zustand';
import { EnterpriseModuleState } from '@repo/ui/foundations';
import type { ReportShellEntity } from '../../domain/constants/reports.constants';
export const logisticsReportsStore = create<EnterpriseModuleState<ReportShellEntity>>((set) => ({
metaData: { limit: 15 },
setMetaData: (data) => set({ metaData: data }),
filterData: {},
setFilterData: (data) => set({ filterData: data }),
selectedRows: [],
setSelectedRows: (rows) => set({ selectedRows: rows }),
privileges: [],
setPrivileges: (privileges) => set({ privileges }),
tableConfig: null,
setTableConfig: (config) => set({ tableConfig: config }),
}));
@@ -1,6 +1,8 @@
import { lazy } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import { EmbeddedComingSoonPage } from '../../../../../core/components/coming-soon-page';
const LogisticsReportsModule = lazy(
() => import('../logistics-reports/presentation/factory'),
);
const EmployeesModule = lazy(() => import('../../configuration/employees/presentation/factory'));
const CyclesModule = lazy(() => import('../cycles/presentation/factory'));
@@ -14,7 +16,7 @@ export default function LogisticsFieldModule() {
<Route path="/cycles/*" element={<CyclesModule purpose="logistics" />} />
<Route path="/plans/*" element={<PlansModule purpose="logistics" />} />
<Route path="/packing-slips/*" element={<PackingSlipsModule />} />
<Route path="/reports" element={<EmbeddedComingSoonPage />} />
<Route path="/reports/*" element={<LogisticsReportsModule />} />
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
</Routes>
);
@@ -1,6 +1,6 @@
import { lazy } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import { EmbeddedComingSoonPage } from '../../../../core/components/coming-soon-page';
const ReportsModule = lazy(() => import('./reports/presentation/factory'));
const RequestsModule = lazy(() => import('./requests/presentation/factory'));
const OrdersModule = lazy(() => import('./orders/presentation/factory'));
@@ -20,7 +20,7 @@ export default function SalesModule() {
<Route path="/plans/*" element={<PlansModule purpose="sales" />} />
<Route path="/invoices/*" element={<InvoicesModule />} />
<Route path="/payments/*" element={<PaymentsModule />} />
<Route path="/reports" element={<EmbeddedComingSoonPage />} />
<Route path="/reports/*" element={<ReportsModule />} />
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
</Routes>
);
@@ -0,0 +1,13 @@
import type { BaseEntity } from '@repo/core-api/data-services';
import type { ModuleConfigEntity } from '@repo/ui/foundations';
export type ReportShellEntity = BaseEntity & { id: string };
export const salesReportsModuleConfig: ModuleConfigEntity<ReportShellEntity> = {
moduleKey: 'SALES.REPORT',
translationNamespace: 'SALES_REPORTS',
apiUrl: '/reports',
webUrl: '/app/sales/reports',
moduleCategory: 'SINGLE_PAGE',
moduleType: 'TRANSACTION',
} as const;
@@ -0,0 +1,23 @@
import { BaseDataTransformer } from '@repo/core-api/data-services';
import { apiClient } from '../../../../../../../core/lib/api-client';
import { TrackGoRemoteDataServices } from '../../../../../../../core/lib/trackgo-remote-data-services';
import {
salesReportsModuleConfig,
type ReportShellEntity,
} from '../constants/reports.constants';
class ReportShellTransformer extends BaseDataTransformer<ReportShellEntity> {
transformToEntity(dto: ReportShellEntity): ReportShellEntity {
return dto;
}
transformToDTO(entity: ReportShellEntity): ReportShellEntity {
return entity;
}
}
export const salesReportsDataService = new TrackGoRemoteDataServices(apiClient, {
apiUrl: salesReportsModuleConfig.apiUrl,
moduleKey: salesReportsModuleConfig.moduleKey,
transformer: new ReportShellTransformer(),
});
@@ -0,0 +1,33 @@
import { lazy } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import { EnterpriseModuleProvider } from '@repo/ui/foundations';
import { registerModuleNamespace } from '@repo/core-i18n';
import { salesReportsModuleConfig, type ReportShellEntity } from '../../domain/constants/reports.constants';
import { salesReportsDataService } from '../../domain/factories';
import { salesReportsStore } from '../store';
import reportsEn from '../languages/en/reports.json';
import reportsId from '../languages/id/reports.json';
const IndexPage = lazy(() => import('../pages/reports.page.index'));
registerModuleNamespace(salesReportsModuleConfig.translationNamespace, {
en: reportsEn,
id: reportsId,
});
export default function SalesReportsModule() {
return (
<EnterpriseModuleProvider<ReportShellEntity>
config={salesReportsModuleConfig}
dataServices={salesReportsDataService}
store={salesReportsStore}
>
<Routes>
<Route path="/index" element={<IndexPage />} />
<Route path="/" element={<Navigate to={`${salesReportsModuleConfig.webUrl}/index`} replace />} />
<Route path="*" element={<Navigate to="/404" replace />} />
</Routes>
</EnterpriseModuleProvider>
);
}
@@ -0,0 +1,4 @@
{
"title": "Sales Reports",
"description": "Config-driven sales report tables"
}
@@ -0,0 +1,4 @@
{
"title": "Laporan Penjualan",
"description": "Tabel laporan penjualan berbasis konfigurasi"
}
@@ -0,0 +1,27 @@
import { FileText } from 'lucide-react';
import { CorePageContainer } from '@repo/ui/components';
import { ModulePageHeader } from '@repo/ui/foundations';
import { useTranslation } from '@repo/core-i18n';
import { REPORT_GROUP, ReportProvider } from '../../../../../../../core/report';
import { salesReportsModuleConfig } from '../../domain/constants/reports.constants';
export default function SalesReportsPage() {
const { t } = useTranslation(salesReportsModuleConfig.translationNamespace);
const { t: tNav } = useTranslation('nav');
return (
<CorePageContainer>
<ModulePageHeader
icon={FileText}
title={t('title')}
description={t('description')}
moduleKey={salesReportsModuleConfig.moduleKey}
breadcrumbs={[
{ label: tNav('sales'), type: 'text' },
{ label: t('title'), type: 'text' },
]}
/>
<ReportProvider groupName={REPORT_GROUP.SALES_REPORT} />
</CorePageContainer>
);
}
@@ -0,0 +1,16 @@
import { create } from 'zustand';
import { EnterpriseModuleState } from '@repo/ui/foundations';
import type { ReportShellEntity } from '../../domain/constants/reports.constants';
export const salesReportsStore = create<EnterpriseModuleState<ReportShellEntity>>((set) => ({
metaData: { limit: 15 },
setMetaData: (data) => set({ metaData: data }),
filterData: {},
setFilterData: (data) => set({ filterData: data }),
selectedRows: [],
setSelectedRows: (rows) => set({ selectedRows: rows }),
privileges: [],
setPrivileges: (privileges) => set({ privileges }),
tableConfig: null,
setTableConfig: (config) => set({ tableConfig: config }),
}));
@@ -0,0 +1,88 @@
import { useEffect, useState } from 'react';
import {
Button,
Drawer,
Group,
Stack,
Text,
} from '@repo/ui/components';
import { REPORT_BOOKMARK_TYPE } from '../constants';
import type { ReportBookmark, ReportConfig } from '../entities';
import { reportRemoteService } from '../data/report.remote.service';
export interface ReportBookmarkListProps {
opened: boolean;
onClose: () => void;
config: ReportConfig;
onApplied: () => void;
}
export function ReportBookmarkList({
opened,
onClose,
config,
onApplied,
}: ReportBookmarkListProps) {
const [bookmarks, setBookmarks] = useState<ReportBookmark[]>([]);
useEffect(() => {
if (!opened) {
return;
}
reportRemoteService
.listBookmarks({
groupName: config.groupName,
uniqueName: config.uniqueName,
limit: 50,
page: 1,
})
.then((res) => setBookmarks(res.data ?? []))
.catch(() => setBookmarks([]));
}, [opened, config.groupName, config.uniqueName]);
const apply = async (id: string) => {
await reportRemoteService.applyBookmark(id);
onApplied();
};
const unapply = async (id: string) => {
await reportRemoteService.unapplyBookmark(id);
onApplied();
};
const remove = async (id: string) => {
await reportRemoteService.deleteBookmark(id);
setBookmarks((prev) => prev.filter((b) => b.id !== id));
};
return (
<Drawer opened={opened} onClose={onClose} title="Report bookmarks" position="right" size="md">
<Stack gap="md">
{bookmarks.length === 0 && <Text size="sm">No bookmarks yet.</Text>}
{bookmarks.map((bookmark) => (
<Group key={bookmark.id} justify="space-between" align="flex-start">
<Stack gap={2}>
<Text fw={600}>{bookmark.label}</Text>
<Text size="xs" c="dimmed">{bookmark.type}</Text>
</Stack>
<Group gap="xs">
{bookmark.type === REPORT_BOOKMARK_TYPE.FILTER_TABLE && (
<Button size="xs" onClick={() => apply(bookmark.id)}>
Apply
</Button>
)}
{bookmark.applied && (
<Button size="xs" variant="light" onClick={() => unapply(bookmark.id)}>
Unapply
</Button>
)}
<Button size="xs" variant="subtle" color="red" onClick={() => remove(bookmark.id)}>
Delete
</Button>
</Group>
</Group>
))}
</Stack>
</Drawer>
);
}
@@ -0,0 +1,132 @@
import { useEffect } from 'react';
import {
Button,
Drawer,
Stack,
} from '@repo/ui/components';
import {
FieldDatePicker,
FieldSelect,
FieldTagsInput,
FieldTextInput,
useForm,
FormProvider,
} from '@repo/ui/form';
import { FILTER_FIELD_TYPE } from '../constants';
import type { ReportConfig } from '../entities';
export interface ReportFilterDrawerProps {
opened: boolean;
onClose: () => void;
config: ReportConfig;
initialValues: Record<string, unknown>;
onSubmit: (values: Record<string, unknown>) => void;
onSubmitAndBookmark: (
values: Record<string, unknown>,
label: string,
) => Promise<void>;
}
export function ReportFilterDrawer({
opened,
onClose,
config,
initialValues,
onSubmit,
onSubmitAndBookmark,
}: ReportFilterDrawerProps) {
const form = useForm({
defaultValues: initialValues,
});
useEffect(() => {
if (opened) {
form.reset(initialValues);
}
}, [opened, initialValues, form]);
const handleSubmit = form.handleSubmit((values) => {
onSubmit(values);
});
const handleBookmark = form.handleSubmit(async (values) => {
const label =
(values.bookmarkLabel as string) ||
`${config.label} filter ${new Date().toISOString()}`;
await onSubmitAndBookmark(values, label);
});
return (
<Drawer opened={opened} onClose={onClose} title="Report filters" position="right" size="md">
<FormProvider {...form}>
<Stack gap="md">
{config.filterConfigs?.map((filterConfig) => {
if (filterConfig.hideField) {
return null;
}
const name = filterConfig.filterColumn;
switch (filterConfig.fieldType) {
case FILTER_FIELD_TYPE.SELECT:
return (
<FieldSelect
key={name}
name={name}
label={filterConfig.fieldLabel}
data={
filterConfig.selectCustomOptions?.map((opt) => ({
value: opt,
label: opt,
})) ?? []
}
clearable
/>
);
case FILTER_FIELD_TYPE.INPUT_TAG:
return (
<FieldTagsInput
key={name}
name={name}
label={filterConfig.fieldLabel}
/>
);
case FILTER_FIELD_TYPE.INPUT_TEXT:
return (
<FieldTextInput
key={name}
name={name}
label={filterConfig.fieldLabel}
/>
);
case FILTER_FIELD_TYPE.DATE_RANGE_PICKER:
return (
<Stack key={name} gap="xs">
<FieldDatePicker
name={`${name}.from`}
label={`${filterConfig.fieldLabel} (from)`}
/>
<FieldDatePicker
name={`${name}.to`}
label={`${filterConfig.fieldLabel} (to)`}
/>
</Stack>
);
default:
return null;
}
})}
<FieldTextInput name="bookmarkLabel" label="Bookmark label (optional)" />
<Stack gap="sm">
<Button onClick={handleSubmit}>Apply filter</Button>
<Button variant="light" onClick={handleBookmark}>
Submit & bookmark
</Button>
</Stack>
</Stack>
</FormProvider>
</Drawer>
);
}
@@ -0,0 +1,88 @@
import { useEffect, useMemo, useState } from 'react';
import { Tabs } from '@repo/ui/components';
import type { ReportConfig } from '../entities';
import { reportRemoteService } from '../data/report.remote.service';
import { ReportTable } from './report-table';
export interface ReportProviderProps {
groupName: string;
commonDefaultFilter?: Record<string, unknown>;
defaultFilterPerItem?: Record<string, Record<string, unknown>>;
}
export function ReportProvider({
groupName,
commonDefaultFilter,
defaultFilterPerItem,
}: ReportProviderProps) {
const [configs, setConfigs] = useState<ReportConfig[]>([]);
const [activeTab, setActiveTab] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
reportRemoteService
.getConfigs([groupName])
.then((data) => {
if (!cancelled) {
setConfigs(data);
setActiveTab(data[0]?.uniqueName ?? null);
setError(null);
}
})
.catch(() => {
if (!cancelled) {
setError('Failed to load report configs');
}
})
.finally(() => {
if (!cancelled) {
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [groupName]);
const activeConfig = useMemo(
() => configs.find((c) => c.uniqueName === activeTab),
[configs, activeTab],
);
if (loading) {
return <div>Loading reports...</div>;
}
if (error) {
return <div>{error}</div>;
}
if (configs.length === 0) {
return <div>No reports available</div>;
}
return (
<Tabs value={activeTab} onChange={setActiveTab}>
<Tabs.List>
{configs.map((config) => (
<Tabs.Tab key={config.uniqueName} value={config.uniqueName}>
{config.label}
</Tabs.Tab>
))}
</Tabs.List>
{activeConfig && (
<Tabs.Panel value={activeConfig.uniqueName} pt="md">
<ReportTable
config={activeConfig}
commonDefaultFilter={commonDefaultFilter}
additionalDefaultFilter={defaultFilterPerItem?.[activeConfig.uniqueName]}
/>
</Tabs.Panel>
)}
</Tabs>
);
}
@@ -0,0 +1,249 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Button,
Group,
Stack,
Box,
} from '@repo/ui/components';
import { DataGrid } from '@repo/ui/ag-grid';
import type {
ColDef,
ColumnState,
GridApi,
IServerSideDatasource,
IServerSideGetRowsParams,
} from '@repo/ui/ag-grid';
import { COUNT_CHILD_GROUP_COLUMN } from '../constants';
import type { ReportConfig, ReportQueryPayload } from '../entities';
import { reportRemoteService } from '../data/report.remote.service';
import { buildColumnDefs } from '../utils/column.helper';
import {
restoreFilterFormValues,
transformFilterValue,
} from '../utils/filter.helper';
import { ReportFilterDrawer } from './report-filter-drawer';
import { ReportBookmarkList } from './report-bookmark-list';
const CACHE_BLOCK_SIZE = 100;
export interface ReportTableProps {
config: ReportConfig;
commonDefaultFilter?: Record<string, unknown>;
additionalDefaultFilter?: Record<string, unknown>;
}
export function ReportTable({
config,
commonDefaultFilter,
additionalDefaultFilter,
}: ReportTableProps) {
const gridApiRef = useRef<GridApi | null>(null);
const filterValuesRef = useRef<Record<string, unknown>>({});
const [filterOpen, setFilterOpen] = useState(false);
const [bookmarkOpen, setBookmarkOpen] = useState(false);
const [filterFormValues, setFilterFormValues] = useState<Record<string, unknown>>({});
const columnDefs = useMemo<ColDef[]>(
() => buildColumnDefs(config.columnConfigs),
[config.columnConfigs],
);
const defaultColDef = useMemo<ColDef>(
() => ({
flex: 1,
minWidth: 120,
sortable: true,
filter: true,
}),
[],
);
const sideBar = useMemo(
() => ({
toolPanels: [
{
id: 'columns',
labelDefault: 'Columns',
labelKey: 'columns',
iconKey: 'columns',
toolPanel: 'agColumnsToolPanel',
},
],
defaultToolPanel: 'columns',
}),
[],
);
const applyBookmarkState = useCallback(() => {
const api = gridApiRef.current;
if (!api) {
return;
}
if (config.activeTableConfig?.configuration) {
const tableState = config.activeTableConfig.configuration as {
columnState?: unknown;
columnGroupState?: unknown;
isPivotMode?: boolean;
};
if (tableState.columnState) {
api.applyColumnState({
state: tableState.columnState as ColumnState[],
applyOrder: true,
});
}
if (tableState.isPivotMode) {
api.setGridOption('pivotMode', true);
}
}
if (config.activeFilter?.configuration) {
const restored = restoreFilterFormValues(
config.activeFilter.configuration as Record<string, unknown>,
config.filterConfigs,
);
setFilterFormValues(restored);
filterValuesRef.current = restored;
}
}, [config]);
const createDatasource = useCallback((): IServerSideDatasource => {
return {
getRows: async (params: IServerSideGetRowsParams) => {
const drawerFilter = transformFilterValue(
config.filterConfigs,
filterValuesRef.current,
);
const mergedFilterModel = {
...params.request.filterModel,
...transformFilterValue(config.filterConfigs, commonDefaultFilter ?? {}),
...transformFilterValue(
config.filterConfigs,
additionalDefaultFilter ?? {},
),
...drawerFilter,
};
const payload: ReportQueryPayload = {
groupName: config.groupName,
uniqueName: config.uniqueName,
queryModel: {
...params.request,
startRow: params.request.startRow ?? 0,
endRow: params.request.endRow ?? CACHE_BLOCK_SIZE,
filterModel: mergedFilterModel,
} as ReportQueryPayload['queryModel'],
};
try {
const isFirstBlock = params.request.startRow === 0;
let rowCount: number | undefined;
if (isFirstBlock) {
const meta = await reportRemoteService.getMeta(payload);
rowCount = meta.totalRow;
}
const rows = await reportRemoteService.getData(payload);
params.success({
rowData: rows,
rowCount,
});
} catch {
params.fail();
}
},
};
}, [config, commonDefaultFilter, additionalDefaultFilter]);
const onGridReady = useCallback(
(params: { api: GridApi }) => {
gridApiRef.current = params.api;
params.api.setGridOption('serverSideDatasource', createDatasource());
applyBookmarkState();
},
[applyBookmarkState, createDatasource],
);
useEffect(() => {
const api = gridApiRef.current;
if (api) {
api.setGridOption('serverSideDatasource', createDatasource());
api.refreshServerSide({ purge: true });
}
}, [createDatasource]);
const refreshGrid = () => {
gridApiRef.current?.refreshServerSide({ purge: true });
};
const handleApplyFilter = (values: Record<string, unknown>) => {
filterValuesRef.current = values;
setFilterFormValues(values);
setFilterOpen(false);
refreshGrid();
};
return (
<Stack gap="md">
<Group justify="space-between">
<Group>
<Button variant="default" onClick={() => setFilterOpen(true)}>
Filter
</Button>
<Button variant="light" onClick={() => setBookmarkOpen(true)}>
Bookmarks
</Button>
<Button variant="subtle" onClick={refreshGrid}>
Reload
</Button>
</Group>
</Group>
<Box style={{ height: 600, width: '100%' }}>
<DataGrid
columnDefs={columnDefs}
defaultColDef={defaultColDef}
rowModelType="serverSide"
cacheBlockSize={CACHE_BLOCK_SIZE}
maxBlocksInCache={2}
rowGroupPanelShow="always"
pivotPanelShow="always"
sideBar={sideBar}
animateRows
suppressAggFuncInHeader
getChildCount={(data) => data?.[COUNT_CHILD_GROUP_COLUMN]}
onGridReady={onGridReady}
/>
</Box>
<ReportFilterDrawer
opened={filterOpen}
onClose={() => setFilterOpen(false)}
config={config}
initialValues={filterFormValues}
onSubmit={handleApplyFilter}
onSubmitAndBookmark={async (values, label) => {
await reportRemoteService.createBookmark({
groupName: config.groupName,
uniqueName: config.uniqueName,
label,
type: 'FILTER_TABLE',
applied: true,
configuration: values,
});
handleApplyFilter(values);
}}
/>
<ReportBookmarkList
opened={bookmarkOpen}
onClose={() => setBookmarkOpen(false)}
config={config}
onApplied={() => {
refreshGrid();
setBookmarkOpen(false);
}}
/>
</Stack>
);
}
@@ -0,0 +1,71 @@
export const FILTER_TYPE = {
TEXT_EQUALS: 'text_equals',
TEXT_NOT_EQUAL: 'text_not_equal',
TEXT_CONTAINS: 'text_contains',
TEXT_NOT_CONTAINS: 'text_not_contains',
TEXT_MULTIPLE_CONTAINS: 'text_multiple_contains',
TEXT_IN_MEMBER_TEXT: 'text_inMemberText',
NUMBER_EQUALS: 'number_equals',
NUMBER_NOT_EQUAL: 'number_not_equal',
NUMBER_GREATER_THAN: 'number_greater_than',
NUMBER_LESS_THAN: 'number_less_than',
NUMBER_IN_RANGE: 'number_in_range',
TEXT_IN_DATE_RANGE_EPOCH: 'text_inDateRange_epoch',
TEXT_IN_DATE_RANGE_TIMESTAMP: 'text_inDateRange_timestamp',
} as const;
export type FilterType = (typeof FILTER_TYPE)[keyof typeof FILTER_TYPE];
export const DATA_FORMAT = {
TEXT: 'text',
TEXT_UPPERCASE: 'text_uppercase',
TEXT_LOWERCASE: 'text_lowercase',
NUMBER: 'number',
CURRENCY: 'currency',
MINUS_CURRENCY: 'minus_currency',
PERCENTAGE: 'percentage',
BOOLEAN: 'boolean',
STATUS: 'status',
DATE_EPOCH: 'date_epoch',
DATE_TIMESTAMP: 'date_timestamp',
} as const;
export type DataFormat = (typeof DATA_FORMAT)[keyof typeof DATA_FORMAT];
export const DATA_TYPE = {
DIMENSION: 'dimension',
MEASURE: 'measure',
} as const;
export type DataType = (typeof DATA_TYPE)[keyof typeof DATA_TYPE];
export const FILTER_FIELD_TYPE = {
SELECT: 'select',
INPUT_TEXT: 'input_text',
INPUT_NUMBER: 'input_number',
INPUT_TAG: 'input_tag',
DATE_PICKER: 'date_picker',
DATE_RANGE_PICKER: 'date_range_picker',
MONTH_RANGE_PICKER: 'month_range_picker',
} as const;
export type FilterFieldType =
(typeof FILTER_FIELD_TYPE)[keyof typeof FILTER_FIELD_TYPE];
export const REPORT_BOOKMARK_TYPE = {
TABLE_CONFIG: 'TABLE_CONFIG',
FILTER_TABLE: 'FILTER_TABLE',
} as const;
export type ReportBookmarkType =
(typeof REPORT_BOOKMARK_TYPE)[keyof typeof REPORT_BOOKMARK_TYPE];
export const REPORT_GROUP = {
SALES_REPORT: 'sales_report',
LOGISTICS_REPORT: 'logistics_report',
} as const;
export type ReportGroupName =
(typeof REPORT_GROUP)[keyof typeof REPORT_GROUP];
export const COUNT_CHILD_GROUP_COLUMN = 'countChildGroup';
@@ -0,0 +1,82 @@
import { apiClient } from '../../lib/api-client';
import type {
ReportBookmark,
ReportConfig,
ReportMeta,
ReportQueryPayload,
} from '../entities';
export class ReportRemoteService {
async getConfigs(groupNames: string[]): Promise<ReportConfig[]> {
const response = await apiClient.get<ReportConfig[]>('/reports/config', {
params: { groupNames },
paramsSerializer: {
indexes: null,
},
});
return response.data;
}
async getData(payload: ReportQueryPayload): Promise<Record<string, unknown>[]> {
const response = await apiClient.post<Record<string, unknown>[]>(
'/reports/data',
payload,
);
return response.data;
}
async getMeta(payload: ReportQueryPayload): Promise<ReportMeta> {
const response = await apiClient.post<ReportMeta>('/reports/meta', payload);
return response.data;
}
async listBookmarks(params: Record<string, unknown>) {
const response = await apiClient.get<{ data: ReportBookmark[] }>(
'/report-bookmarks',
{ params },
);
return response.data;
}
async createBookmark(body: {
groupName: string;
uniqueName: string;
label: string;
type: string;
applied?: boolean;
configuration: unknown;
}): Promise<ReportBookmark> {
const response = await apiClient.post<ReportBookmark>(
'/report-bookmarks',
body,
);
return response.data;
}
async applyBookmark(id: string): Promise<ReportBookmark> {
const response = await apiClient.put<ReportBookmark>(
`/report-bookmarks/applied/${id}`,
);
return response.data;
}
async unapplyBookmark(id: string): Promise<ReportBookmark> {
const response = await apiClient.put<ReportBookmark>(
`/report-bookmarks/unapplied/${id}`,
);
return response.data;
}
async deleteBookmark(id: string): Promise<void> {
await apiClient.delete(`/report-bookmarks/${id}`);
}
async labelHistory(label?: string): Promise<string[]> {
const response = await apiClient.get<string[]>('/report-bookmarks/label-history', {
params: label ? { label } : undefined,
});
return response.data;
}
}
export const reportRemoteService = new ReportRemoteService();
+107
View File
@@ -0,0 +1,107 @@
import type {
DataFormat,
DataType,
FilterFieldType,
FilterType,
ReportBookmarkType,
ReportGroupName,
} from '../constants';
export interface ReportColumnConfig {
column: string;
query: string;
label: string;
type: DataType;
format: DataFormat;
dateFormat?: string;
}
export interface FilterConfig {
filterColumn: string;
filterType: FilterType;
fieldType: FilterFieldType;
fieldLabel: string;
hideField?: boolean;
selectDataSourceUrl?: string;
selectCustomOptions?: string[];
selectValueKey?: string;
selectLabelKey?: string;
dateFormat?: string;
}
export interface FilterModelEntry {
type: FilterType;
filter: unknown;
}
export interface ReportConfig {
groupName: ReportGroupName;
uniqueName: string;
privilegeKey: string;
label: string;
tableSchema: string;
mainTableAlias?: string;
columnConfigs: ReportColumnConfig[];
filterConfigs?: FilterConfig[];
filterPeriodConfig?: { hidden?: boolean };
whereDefaultConditions?: string[];
ignoreFilterKeys?: string[];
defaultOrderBy?: string[];
lowLevelOrderBy?: string[];
activeFilter?: ReportBookmark | null;
activeTableConfig?: ReportBookmark | null;
}
export interface ReportBookmark {
id: string;
groupName: string;
uniqueName: string;
label: string;
type: ReportBookmarkType;
applied: boolean;
configuration: unknown;
status?: string;
createdAt?: number;
updatedAt?: number;
}
export interface RowGroupCol {
id: string;
displayName: string;
field: string;
}
export interface ValueCol {
id: string;
field: string;
aggFunc: string;
}
export interface SortModelEntry {
colId: string;
sort: 'asc' | 'desc';
}
export interface QueryModel {
startRow: number;
endRow: number;
rowGroupCols: RowGroupCol[];
valueCols: ValueCol[];
pivotCols: unknown[];
pivotMode: boolean;
groupKeys: unknown[];
filterModel: Record<string, FilterModelEntry>;
sortModel: SortModelEntry[];
}
export interface ReportQueryPayload {
groupName: string;
uniqueName: string;
queryModel: QueryModel;
}
export interface ReportMeta {
totalRow: number;
limit: number;
offset: number;
}
+12
View File
@@ -0,0 +1,12 @@
export { ReportProvider } from './components/report-provider';
export { ReportTable } from './components/report-table';
export { ReportFilterDrawer } from './components/report-filter-drawer';
export { ReportBookmarkList } from './components/report-bookmark-list';
export * from './constants';
export * from './entities';
export { reportRemoteService } from './data/report.remote.service';
export { buildColumnDefs, formatReportCellDisplay } from './utils/column.helper';
export {
transformFilterValue,
restoreFilterFormValues,
} from './utils/filter.helper';
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import { DATA_FORMAT, DATA_TYPE } from '../constants';
import {
buildColumnDefs,
formatReportCellDisplay,
} from '../utils/column.helper';
describe('buildColumnDefs', () => {
it('maps dimension and measure columns', () => {
const defs = buildColumnDefs([
{
column: 'main__code',
query: 'main.code',
label: 'Code',
type: DATA_TYPE.DIMENSION,
format: 'text',
},
{
column: 'main__amount',
query: 'main.amount',
label: 'Amount',
type: DATA_TYPE.MEASURE,
format: 'currency',
},
]);
expect(defs[0]?.enableRowGroup).toBe(true);
expect(defs[1]?.enableValue).toBe(true);
expect(defs[1]?.aggFunc).toBe('sum');
expect(defs[1]?.cellDataType).toBe(false);
});
});
describe('formatReportCellDisplay', () => {
it('preserves decimal scale from API strings', () => {
expect(formatReportCellDisplay('10.50000', DATA_FORMAT.CURRENCY)).toBe(
'10.50000',
);
expect(formatReportCellDisplay('1234.5000', DATA_FORMAT.NUMBER)).toBe(
'1234.5000',
);
});
});
@@ -0,0 +1,62 @@
import type { ColDef, ValueFormatterParams } from '@repo/ui/ag-grid';
import { DATA_FORMAT, DATA_TYPE } from '../constants';
import type { ReportColumnConfig } from '../entities';
const NUMERIC_FORMATS = new Set<string>([
DATA_FORMAT.NUMBER,
DATA_FORMAT.CURRENCY,
DATA_FORMAT.MINUS_CURRENCY,
]);
export function formatReportCellDisplay(
value: unknown,
format: string,
): string {
if (value === null || value === undefined || value === '') {
return '';
}
if (NUMERIC_FORMATS.has(format)) {
return formatDecimalDisplay(value);
}
return String(value);
}
function formatDecimalDisplay(value: unknown): string {
if (typeof value === 'string') {
const trimmed = value.trim();
if (trimmed === '') {
return '';
}
if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
return trimmed;
}
}
const numeric = Number(value);
if (!Number.isFinite(numeric)) {
return String(value);
}
return String(value);
}
export function buildColumnDefs(
columnConfigs: ReportColumnConfig[],
): ColDef[] {
return columnConfigs.map((col) => {
const isNumeric = NUMERIC_FORMATS.has(col.format);
return {
field: col.column,
headerName: col.label,
enableRowGroup: col.type === DATA_TYPE.DIMENSION,
enableValue: col.type === DATA_TYPE.MEASURE,
aggFunc: col.type === DATA_TYPE.MEASURE ? 'sum' : undefined,
cellDataType: isNumeric ? false : undefined,
valueFormatter: (params: ValueFormatterParams) =>
formatReportCellDisplay(params.value, col.format),
};
});
}
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import { FILTER_FIELD_TYPE, FILTER_TYPE } from '../constants';
import { transformFilterValue } from '../utils/filter.helper';
describe('transformFilterValue', () => {
it('maps text equals filter', () => {
const result = transformFilterValue(
[
{
filterColumn: 'main__status',
filterType: FILTER_TYPE.TEXT_EQUALS,
fieldType: FILTER_FIELD_TYPE.INPUT_TEXT,
fieldLabel: 'Status',
},
],
{ main__status: 'active' },
);
expect(result.main__status).toEqual({
type: FILTER_TYPE.TEXT_EQUALS,
filter: 'active',
});
});
it('maps date range to epoch milliseconds', () => {
const result = transformFilterValue(
[
{
filterColumn: 'main__date',
filterType: FILTER_TYPE.TEXT_IN_DATE_RANGE_EPOCH,
fieldType: FILTER_FIELD_TYPE.DATE_RANGE_PICKER,
fieldLabel: 'Date',
},
],
{
main__date: { from: '2026-01-01', to: '2026-01-31' },
},
);
expect(result.main__date?.type).toBe(FILTER_TYPE.TEXT_IN_DATE_RANGE_EPOCH);
expect((result.main__date?.filter as { from: number }).from).toBeGreaterThan(0);
});
});
@@ -0,0 +1,71 @@
import dayjs from 'dayjs';
import { FILTER_FIELD_TYPE, FILTER_TYPE } from '../constants';
import type { FilterConfig, FilterModelEntry } from '../entities';
export function transformFilterValue(
filterConfigs: FilterConfig[] | undefined,
formValues: Record<string, unknown>,
): Record<string, FilterModelEntry> {
const result: Record<string, FilterModelEntry> = {};
if (!filterConfigs) {
return result;
}
for (const config of filterConfigs) {
const raw = formValues[config.filterColumn];
if (raw === undefined || raw === null || raw === '') {
continue;
}
let filter: unknown = raw;
if (
config.fieldType === FILTER_FIELD_TYPE.DATE_RANGE_PICKER &&
config.filterType === FILTER_TYPE.TEXT_IN_DATE_RANGE_EPOCH
) {
const range = raw as { from?: string; to?: string };
if (!range.from && !range.to) {
continue;
}
filter = {
from: range.from
? dayjs(range.from).startOf('day').valueOf()
: undefined,
to: range.to ? dayjs(range.to).endOf('day').valueOf() : undefined,
};
}
result[config.filterColumn] = {
type: config.filterType,
filter,
};
}
return result;
}
export function restoreFilterFormValues(
configuration: Record<string, unknown>,
filterConfigs: FilterConfig[] | undefined,
): Record<string, unknown> {
const restored: Record<string, unknown> = { ...configuration };
if (!filterConfigs) {
return restored;
}
for (const config of filterConfigs) {
const value = configuration[config.filterColumn];
if (
config.fieldType === FILTER_FIELD_TYPE.DATE_RANGE_PICKER &&
value &&
typeof value === 'object'
) {
const range = value as { from?: number; to?: number };
restored[config.filterColumn] = {
from: range.from ? dayjs(range.from).format('YYYY-MM-DD') : undefined,
to: range.to ? dayjs(range.to).format('YYYY-MM-DD') : undefined,
};
}
}
return restored;
}