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',
|
key: 'sales-reports',
|
||||||
label: 'nav:reports-coming-soon',
|
label: 'nav:sales-reports',
|
||||||
icon: FileText,
|
icon: FileText,
|
||||||
path: '/app/sales/reports',
|
path: '/app/sales/reports/index',
|
||||||
isPlaceholder: true,
|
moduleKey: 'SALES.REPORT',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -165,10 +165,10 @@ export const MENU_ITEMS: MenuItemType[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'logistics-reports',
|
key: 'logistics-reports',
|
||||||
label: 'nav:reports-coming-soon',
|
label: 'nav:logistics-reports',
|
||||||
icon: FileText,
|
icon: FileText,
|
||||||
path: '/app/logistics/reports',
|
path: '/app/logistics/reports/index',
|
||||||
isPlaceholder: true,
|
moduleKey: 'LOGISTICS.REPORT',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -31,6 +31,8 @@
|
|||||||
"data": "Data",
|
"data": "Data",
|
||||||
"activities": "Activities",
|
"activities": "Activities",
|
||||||
"reports-coming-soon": "Reports (Coming Soon)",
|
"reports-coming-soon": "Reports (Coming Soon)",
|
||||||
|
"sales-reports": "Sales Reports",
|
||||||
|
"logistics-reports": "Logistics Reports",
|
||||||
"user": "User",
|
"user": "User",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"settings-general": "General Settings",
|
"settings-general": "General Settings",
|
||||||
|
|||||||
@@ -31,6 +31,8 @@
|
|||||||
"data": "Data",
|
"data": "Data",
|
||||||
"activities": "Aktivitas",
|
"activities": "Aktivitas",
|
||||||
"reports-coming-soon": "Laporan (Segera Hadir)",
|
"reports-coming-soon": "Laporan (Segera Hadir)",
|
||||||
|
"sales-reports": "Laporan Penjualan",
|
||||||
|
"logistics-reports": "Laporan Logistik",
|
||||||
"user": "Pengguna",
|
"user": "Pengguna",
|
||||||
"settings": "Pengaturan",
|
"settings": "Pengaturan",
|
||||||
"settings-general": "Pengaturan Umum",
|
"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 { lazy } from 'react';
|
||||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
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 EmployeesModule = lazy(() => import('../../configuration/employees/presentation/factory'));
|
||||||
const CyclesModule = lazy(() => import('../cycles/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="/cycles/*" element={<CyclesModule purpose="logistics" />} />
|
||||||
<Route path="/plans/*" element={<PlansModule purpose="logistics" />} />
|
<Route path="/plans/*" element={<PlansModule purpose="logistics" />} />
|
||||||
<Route path="/packing-slips/*" element={<PackingSlipsModule />} />
|
<Route path="/packing-slips/*" element={<PackingSlipsModule />} />
|
||||||
<Route path="/reports" element={<EmbeddedComingSoonPage />} />
|
<Route path="/reports/*" element={<LogisticsReportsModule />} />
|
||||||
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
|
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { lazy } from 'react';
|
import { lazy } from 'react';
|
||||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
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 RequestsModule = lazy(() => import('./requests/presentation/factory'));
|
||||||
const OrdersModule = lazy(() => import('./orders/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="/plans/*" element={<PlansModule purpose="sales" />} />
|
||||||
<Route path="/invoices/*" element={<InvoicesModule />} />
|
<Route path="/invoices/*" element={<InvoicesModule />} />
|
||||||
<Route path="/payments/*" element={<PaymentsModule />} />
|
<Route path="/payments/*" element={<PaymentsModule />} />
|
||||||
<Route path="/reports" element={<EmbeddedComingSoonPage />} />
|
<Route path="/reports/*" element={<ReportsModule />} />
|
||||||
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
|
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
|
||||||
</Routes>
|
</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;
|
||||||
|
}
|
||||||
@@ -11,5 +11,14 @@ export { initAgGrid } from './ag-grid-setup';
|
|||||||
export type { AgGridInitOptions } from './ag-grid-setup';
|
export type { AgGridInitOptions } from './ag-grid-setup';
|
||||||
|
|
||||||
/* ── Re-export commonly used AG Grid types ───────── */
|
/* ── Re-export commonly used AG Grid types ───────── */
|
||||||
export type { ColDef, GridReadyEvent, GridOptions, ValueFormatterParams } from 'ag-grid-community';
|
export type {
|
||||||
|
ColDef,
|
||||||
|
ColumnState,
|
||||||
|
GridApi,
|
||||||
|
GridReadyEvent,
|
||||||
|
GridOptions,
|
||||||
|
IServerSideDatasource,
|
||||||
|
IServerSideGetRowsParams,
|
||||||
|
ValueFormatterParams,
|
||||||
|
} from 'ag-grid-community';
|
||||||
export type { AgGridReactProps } from 'ag-grid-react';
|
export type { AgGridReactProps } from 'ag-grid-react';
|
||||||
|
|||||||
Reference in New Issue
Block a user