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:
@@ -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",
|
||||
|
||||
+13
@@ -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>
|
||||
);
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"title": "Logistics Reports",
|
||||
"description": "Config-driven logistics report tables"
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"title": "Laporan Logistik",
|
||||
"description": "Tabel laporan logistik berbasis konfigurasi"
|
||||
}
|
||||
+27
@@ -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"
|
||||
}
|
||||
+27
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user