diff --git a/apps/web/docs/report-engine-fe.md b/apps/web/docs/report-engine-fe.md new file mode 100644 index 0000000..a736478 --- /dev/null +++ b/apps/web/docs/report-engine-fe.md @@ -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. diff --git a/apps/web/src/apps/main/layouts/data/menu.data.ts b/apps/web/src/apps/main/layouts/data/menu.data.ts index 57c0059..bf80d9c 100644 --- a/apps/web/src/apps/main/layouts/data/menu.data.ts +++ b/apps/web/src/apps/main/layouts/data/menu.data.ts @@ -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', }, ], }, diff --git a/apps/web/src/apps/main/layouts/languages/en/nav.json b/apps/web/src/apps/main/layouts/languages/en/nav.json index f98db11..d0ca598 100644 --- a/apps/web/src/apps/main/layouts/languages/en/nav.json +++ b/apps/web/src/apps/main/layouts/languages/en/nav.json @@ -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", diff --git a/apps/web/src/apps/main/layouts/languages/id/nav.json b/apps/web/src/apps/main/layouts/languages/id/nav.json index b6a91f9..c2e5705 100644 --- a/apps/web/src/apps/main/layouts/languages/id/nav.json +++ b/apps/web/src/apps/main/layouts/languages/id/nav.json @@ -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", diff --git a/apps/web/src/apps/main/modules/field/logistics-reports/domain/constants/reports.constants.ts b/apps/web/src/apps/main/modules/field/logistics-reports/domain/constants/reports.constants.ts new file mode 100644 index 0000000..620d9e0 --- /dev/null +++ b/apps/web/src/apps/main/modules/field/logistics-reports/domain/constants/reports.constants.ts @@ -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 = { + moduleKey: 'LOGISTICS.REPORT', + translationNamespace: 'LOGISTICS_REPORTS', + apiUrl: '/reports', + webUrl: '/app/logistics/reports', + moduleCategory: 'SINGLE_PAGE', + moduleType: 'TRANSACTION', +} as const; diff --git a/apps/web/src/apps/main/modules/field/logistics-reports/domain/factories/index.ts b/apps/web/src/apps/main/modules/field/logistics-reports/domain/factories/index.ts new file mode 100644 index 0000000..1f0db04 --- /dev/null +++ b/apps/web/src/apps/main/modules/field/logistics-reports/domain/factories/index.ts @@ -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 { + 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(), +}); diff --git a/apps/web/src/apps/main/modules/field/logistics-reports/presentation/factory/index.tsx b/apps/web/src/apps/main/modules/field/logistics-reports/presentation/factory/index.tsx new file mode 100644 index 0000000..506b170 --- /dev/null +++ b/apps/web/src/apps/main/modules/field/logistics-reports/presentation/factory/index.tsx @@ -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 ( + + config={logisticsReportsModuleConfig} + dataServices={logisticsReportsDataService} + store={logisticsReportsStore} + > + + } /> + } /> + } /> + + + ); +} diff --git a/apps/web/src/apps/main/modules/field/logistics-reports/presentation/languages/en/reports.json b/apps/web/src/apps/main/modules/field/logistics-reports/presentation/languages/en/reports.json new file mode 100644 index 0000000..0ff0bcf --- /dev/null +++ b/apps/web/src/apps/main/modules/field/logistics-reports/presentation/languages/en/reports.json @@ -0,0 +1,4 @@ +{ + "title": "Logistics Reports", + "description": "Config-driven logistics report tables" +} diff --git a/apps/web/src/apps/main/modules/field/logistics-reports/presentation/languages/id/reports.json b/apps/web/src/apps/main/modules/field/logistics-reports/presentation/languages/id/reports.json new file mode 100644 index 0000000..af66a20 --- /dev/null +++ b/apps/web/src/apps/main/modules/field/logistics-reports/presentation/languages/id/reports.json @@ -0,0 +1,4 @@ +{ + "title": "Laporan Logistik", + "description": "Tabel laporan logistik berbasis konfigurasi" +} diff --git a/apps/web/src/apps/main/modules/field/logistics-reports/presentation/pages/reports.page.index.tsx b/apps/web/src/apps/main/modules/field/logistics-reports/presentation/pages/reports.page.index.tsx new file mode 100644 index 0000000..fa55ab2 --- /dev/null +++ b/apps/web/src/apps/main/modules/field/logistics-reports/presentation/pages/reports.page.index.tsx @@ -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 ( + + + + + ); +} diff --git a/apps/web/src/apps/main/modules/field/logistics-reports/presentation/store/index.ts b/apps/web/src/apps/main/modules/field/logistics-reports/presentation/store/index.ts new file mode 100644 index 0000000..710f948 --- /dev/null +++ b/apps/web/src/apps/main/modules/field/logistics-reports/presentation/store/index.ts @@ -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>((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 }), +})); diff --git a/apps/web/src/apps/main/modules/field/logistics/index.tsx b/apps/web/src/apps/main/modules/field/logistics/index.tsx index 2a6f9be..e4b6e92 100644 --- a/apps/web/src/apps/main/modules/field/logistics/index.tsx +++ b/apps/web/src/apps/main/modules/field/logistics/index.tsx @@ -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() { } /> } /> } /> - } /> + } /> } /> ); diff --git a/apps/web/src/apps/main/modules/sales/index.tsx b/apps/web/src/apps/main/modules/sales/index.tsx index 7da2c0a..a5a202d 100644 --- a/apps/web/src/apps/main/modules/sales/index.tsx +++ b/apps/web/src/apps/main/modules/sales/index.tsx @@ -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() { } /> } /> } /> - } /> + } /> } /> ); diff --git a/apps/web/src/apps/main/modules/sales/reports/domain/constants/reports.constants.ts b/apps/web/src/apps/main/modules/sales/reports/domain/constants/reports.constants.ts new file mode 100644 index 0000000..af958c6 --- /dev/null +++ b/apps/web/src/apps/main/modules/sales/reports/domain/constants/reports.constants.ts @@ -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 = { + moduleKey: 'SALES.REPORT', + translationNamespace: 'SALES_REPORTS', + apiUrl: '/reports', + webUrl: '/app/sales/reports', + moduleCategory: 'SINGLE_PAGE', + moduleType: 'TRANSACTION', +} as const; diff --git a/apps/web/src/apps/main/modules/sales/reports/domain/factories/index.ts b/apps/web/src/apps/main/modules/sales/reports/domain/factories/index.ts new file mode 100644 index 0000000..650526c --- /dev/null +++ b/apps/web/src/apps/main/modules/sales/reports/domain/factories/index.ts @@ -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 { + 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(), +}); diff --git a/apps/web/src/apps/main/modules/sales/reports/presentation/factory/index.tsx b/apps/web/src/apps/main/modules/sales/reports/presentation/factory/index.tsx new file mode 100644 index 0000000..e4d23a7 --- /dev/null +++ b/apps/web/src/apps/main/modules/sales/reports/presentation/factory/index.tsx @@ -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 ( + + config={salesReportsModuleConfig} + dataServices={salesReportsDataService} + store={salesReportsStore} + > + + } /> + } /> + } /> + + + ); +} diff --git a/apps/web/src/apps/main/modules/sales/reports/presentation/languages/en/reports.json b/apps/web/src/apps/main/modules/sales/reports/presentation/languages/en/reports.json new file mode 100644 index 0000000..a59d65e --- /dev/null +++ b/apps/web/src/apps/main/modules/sales/reports/presentation/languages/en/reports.json @@ -0,0 +1,4 @@ +{ + "title": "Sales Reports", + "description": "Config-driven sales report tables" +} diff --git a/apps/web/src/apps/main/modules/sales/reports/presentation/languages/id/reports.json b/apps/web/src/apps/main/modules/sales/reports/presentation/languages/id/reports.json new file mode 100644 index 0000000..a8be2cc --- /dev/null +++ b/apps/web/src/apps/main/modules/sales/reports/presentation/languages/id/reports.json @@ -0,0 +1,4 @@ +{ + "title": "Laporan Penjualan", + "description": "Tabel laporan penjualan berbasis konfigurasi" +} diff --git a/apps/web/src/apps/main/modules/sales/reports/presentation/pages/reports.page.index.tsx b/apps/web/src/apps/main/modules/sales/reports/presentation/pages/reports.page.index.tsx new file mode 100644 index 0000000..9e7391a --- /dev/null +++ b/apps/web/src/apps/main/modules/sales/reports/presentation/pages/reports.page.index.tsx @@ -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 ( + + + + + ); +} diff --git a/apps/web/src/apps/main/modules/sales/reports/presentation/store/index.ts b/apps/web/src/apps/main/modules/sales/reports/presentation/store/index.ts new file mode 100644 index 0000000..3089f08 --- /dev/null +++ b/apps/web/src/apps/main/modules/sales/reports/presentation/store/index.ts @@ -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>((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 }), +})); diff --git a/apps/web/src/core/report/components/report-bookmark-list.tsx b/apps/web/src/core/report/components/report-bookmark-list.tsx new file mode 100644 index 0000000..9471359 --- /dev/null +++ b/apps/web/src/core/report/components/report-bookmark-list.tsx @@ -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([]); + + 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 ( + + + {bookmarks.length === 0 && No bookmarks yet.} + {bookmarks.map((bookmark) => ( + + + {bookmark.label} + {bookmark.type} + + + {bookmark.type === REPORT_BOOKMARK_TYPE.FILTER_TABLE && ( + + )} + {bookmark.applied && ( + + )} + + + + ))} + + + ); +} diff --git a/apps/web/src/core/report/components/report-filter-drawer.tsx b/apps/web/src/core/report/components/report-filter-drawer.tsx new file mode 100644 index 0000000..cafd2d6 --- /dev/null +++ b/apps/web/src/core/report/components/report-filter-drawer.tsx @@ -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; + onSubmit: (values: Record) => void; + onSubmitAndBookmark: ( + values: Record, + label: string, + ) => Promise; +} + +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 ( + + + + {config.filterConfigs?.map((filterConfig) => { + if (filterConfig.hideField) { + return null; + } + + const name = filterConfig.filterColumn; + + switch (filterConfig.fieldType) { + case FILTER_FIELD_TYPE.SELECT: + return ( + ({ + value: opt, + label: opt, + })) ?? [] + } + clearable + /> + ); + case FILTER_FIELD_TYPE.INPUT_TAG: + return ( + + ); + case FILTER_FIELD_TYPE.INPUT_TEXT: + return ( + + ); + case FILTER_FIELD_TYPE.DATE_RANGE_PICKER: + return ( + + + + + ); + default: + return null; + } + })} + + + + + + + + + + + ); +} diff --git a/apps/web/src/core/report/components/report-provider.tsx b/apps/web/src/core/report/components/report-provider.tsx new file mode 100644 index 0000000..4f66246 --- /dev/null +++ b/apps/web/src/core/report/components/report-provider.tsx @@ -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; + defaultFilterPerItem?: Record>; +} + +export function ReportProvider({ + groupName, + commonDefaultFilter, + defaultFilterPerItem, +}: ReportProviderProps) { + const [configs, setConfigs] = useState([]); + const [activeTab, setActiveTab] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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
Loading reports...
; + } + + if (error) { + return
{error}
; + } + + if (configs.length === 0) { + return
No reports available
; + } + + return ( + + + {configs.map((config) => ( + + {config.label} + + ))} + + + {activeConfig && ( + + + + )} + + ); +} diff --git a/apps/web/src/core/report/components/report-table.tsx b/apps/web/src/core/report/components/report-table.tsx new file mode 100644 index 0000000..511c501 --- /dev/null +++ b/apps/web/src/core/report/components/report-table.tsx @@ -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; + additionalDefaultFilter?: Record; +} + +export function ReportTable({ + config, + commonDefaultFilter, + additionalDefaultFilter, +}: ReportTableProps) { + const gridApiRef = useRef(null); + const filterValuesRef = useRef>({}); + const [filterOpen, setFilterOpen] = useState(false); + const [bookmarkOpen, setBookmarkOpen] = useState(false); + const [filterFormValues, setFilterFormValues] = useState>({}); + + const columnDefs = useMemo( + () => buildColumnDefs(config.columnConfigs), + [config.columnConfigs], + ); + + const defaultColDef = useMemo( + () => ({ + 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, + 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) => { + filterValuesRef.current = values; + setFilterFormValues(values); + setFilterOpen(false); + refreshGrid(); + }; + + return ( + + + + + + + + + + + data?.[COUNT_CHILD_GROUP_COLUMN]} + onGridReady={onGridReady} + /> + + + 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); + }} + /> + + setBookmarkOpen(false)} + config={config} + onApplied={() => { + refreshGrid(); + setBookmarkOpen(false); + }} + /> + + ); +} diff --git a/apps/web/src/core/report/constants/index.ts b/apps/web/src/core/report/constants/index.ts new file mode 100644 index 0000000..301de03 --- /dev/null +++ b/apps/web/src/core/report/constants/index.ts @@ -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'; diff --git a/apps/web/src/core/report/data/report.remote.service.ts b/apps/web/src/core/report/data/report.remote.service.ts new file mode 100644 index 0000000..6b51ff8 --- /dev/null +++ b/apps/web/src/core/report/data/report.remote.service.ts @@ -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 { + const response = await apiClient.get('/reports/config', { + params: { groupNames }, + paramsSerializer: { + indexes: null, + }, + }); + return response.data; + } + + async getData(payload: ReportQueryPayload): Promise[]> { + const response = await apiClient.post[]>( + '/reports/data', + payload, + ); + return response.data; + } + + async getMeta(payload: ReportQueryPayload): Promise { + const response = await apiClient.post('/reports/meta', payload); + return response.data; + } + + async listBookmarks(params: Record) { + 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 { + const response = await apiClient.post( + '/report-bookmarks', + body, + ); + return response.data; + } + + async applyBookmark(id: string): Promise { + const response = await apiClient.put( + `/report-bookmarks/applied/${id}`, + ); + return response.data; + } + + async unapplyBookmark(id: string): Promise { + const response = await apiClient.put( + `/report-bookmarks/unapplied/${id}`, + ); + return response.data; + } + + async deleteBookmark(id: string): Promise { + await apiClient.delete(`/report-bookmarks/${id}`); + } + + async labelHistory(label?: string): Promise { + const response = await apiClient.get('/report-bookmarks/label-history', { + params: label ? { label } : undefined, + }); + return response.data; + } +} + +export const reportRemoteService = new ReportRemoteService(); diff --git a/apps/web/src/core/report/entities/index.ts b/apps/web/src/core/report/entities/index.ts new file mode 100644 index 0000000..d9355f6 --- /dev/null +++ b/apps/web/src/core/report/entities/index.ts @@ -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; + sortModel: SortModelEntry[]; +} + +export interface ReportQueryPayload { + groupName: string; + uniqueName: string; + queryModel: QueryModel; +} + +export interface ReportMeta { + totalRow: number; + limit: number; + offset: number; +} diff --git a/apps/web/src/core/report/index.ts b/apps/web/src/core/report/index.ts new file mode 100644 index 0000000..6b5baf1 --- /dev/null +++ b/apps/web/src/core/report/index.ts @@ -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'; diff --git a/apps/web/src/core/report/utils/column.helper.test.ts b/apps/web/src/core/report/utils/column.helper.test.ts new file mode 100644 index 0000000..a1fdcc0 --- /dev/null +++ b/apps/web/src/core/report/utils/column.helper.test.ts @@ -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', + ); + }); +}); diff --git a/apps/web/src/core/report/utils/column.helper.ts b/apps/web/src/core/report/utils/column.helper.ts new file mode 100644 index 0000000..1b296cc --- /dev/null +++ b/apps/web/src/core/report/utils/column.helper.ts @@ -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([ + 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), + }; + }); +} diff --git a/apps/web/src/core/report/utils/filter.helper.test.ts b/apps/web/src/core/report/utils/filter.helper.test.ts new file mode 100644 index 0000000..eda99a4 --- /dev/null +++ b/apps/web/src/core/report/utils/filter.helper.test.ts @@ -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); + }); +}); diff --git a/apps/web/src/core/report/utils/filter.helper.ts b/apps/web/src/core/report/utils/filter.helper.ts new file mode 100644 index 0000000..51c8201 --- /dev/null +++ b/apps/web/src/core/report/utils/filter.helper.ts @@ -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, +): Record { + const result: Record = {}; + 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, + filterConfigs: FilterConfig[] | undefined, +): Record { + const restored: Record = { ...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; +} diff --git a/packages/ui/src/components/ag-grid/index.ts b/packages/ui/src/components/ag-grid/index.ts index f00e83a..f1e4ba3 100644 --- a/packages/ui/src/components/ag-grid/index.ts +++ b/packages/ui/src/components/ag-grid/index.ts @@ -11,5 +11,14 @@ export { initAgGrid } from './ag-grid-setup'; export type { AgGridInitOptions } from './ag-grid-setup'; /* ── 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';