feat: enhance plan document handling and validation in forms

- Introduced new functions for loading and managing plan document options, improving user experience in selecting documents based on customer context.
- Implemented grouping of documents by customer in the `FormDocumentsPreview` component, enhancing clarity in document displays.
- Updated validation schemas to ensure proper handling of document associations with selected customers, preventing invalid selections.
- Added unit tests for new functionalities, ensuring reliability and correctness in document management.
- Enhanced language support for user prompts related to document selection, improving overall usability.

These changes significantly improve the handling of plan documents within the application, streamlining user interactions and ensuring data integrity.
This commit is contained in:
shancheas
2026-09-01 09:50:53 +07:00
parent 991bf3fe65
commit 05c8459d12
35 changed files with 552 additions and 237 deletions
+14 -14
View File
@@ -6,23 +6,23 @@ Architecture and APIs: [trackgo-be/docs/report-engine.md](../../../../../trackgo
## 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 |
| 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` |
| 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.
@@ -98,7 +98,9 @@ const MenuItemExpanded = memo(function MenuItemExpanded({
styles={{
root: {
borderRadius: 'var(--mantine-radius-md)',
color: isParentActive ? 'light-dark(var(--mantine-color-brand-6), var(--mantine-color-brand-4))' : undefined,
color: isParentActive
? 'light-dark(var(--mantine-color-brand-6), var(--mantine-color-brand-4))'
: undefined,
},
label: {
overflow: 'hidden',
@@ -21,11 +21,7 @@ export function shouldShowMenuChildren(isOpened: boolean, isSearching: boolean):
* Keys that must stay in the accessible tree when a branch is open.
* Nested Collapse height bugs clip these siblings after refresh; this list is the contract.
*/
export function getVisibleMenuKeys(
items: MenuItemType[],
openedKeys: Set<string>,
isSearching = false,
): string[] {
export function getVisibleMenuKeys(items: MenuItemType[], openedKeys: Set<string>, isSearching = false): string[] {
const keys: string[] = [];
const walk = (nodes: MenuItemType[]) => {
@@ -1,10 +1,7 @@
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';
import { logisticsReportsModuleConfig, type ReportShellEntity } from '../constants/reports.constants';
class ReportShellTransformer extends BaseDataTransformer<ReportShellEntity> {
transformToEntity(dto: ReportShellEntity): ReportShellEntity {
@@ -2,10 +2,7 @@ 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 { logisticsReportsModuleConfig, type ReportShellEntity } from '../../domain/constants/reports.constants';
import { logisticsReportsDataService } from '../../domain/factories';
import { logisticsReportsStore } from '../store';
@@ -1,8 +1,6 @@
import { lazy } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
const LogisticsReportsModule = lazy(
() => import('../logistics-reports/presentation/factory'),
);
const LogisticsReportsModule = lazy(() => import('../logistics-reports/presentation/factory'));
const EmployeesModule = lazy(() => import('../../configuration/employees/presentation/factory'));
const CyclesModule = lazy(() => import('../cycles/presentation/factory'));
@@ -0,0 +1,23 @@
import { describe, expect, it, vi } from 'vitest';
import { loadPlanDocumentOptions } from './load-plan-document-options';
describe('loadPlanDocumentOptions', () => {
it('returns no options until customers are selected', async () => {
const getMany = vi.fn();
const load = loadPlanDocumentOptions(getMany, []);
await expect(load('', 1, [])).resolves.toEqual({ options: [], hasMore: false });
expect(getMany).not.toHaveBeenCalled();
});
it('requests documents for the selected customers', async () => {
const getMany = vi.fn().mockResolvedValue({
data: { data: [{ id: 'inv-1' }], meta: { totalPages: 1 } },
});
const load = loadPlanDocumentOptions(getMany, ['cus-1', 'cus-2']);
const result = await load('INV', 1, []);
expect(getMany).toHaveBeenCalledWith({
params: { search: 'INV', page: 1, limit: 20, customerIds: 'cus-1,cus-2' },
});
expect(result).toEqual({ options: [{ id: 'inv-1' }], hasMore: false });
});
});
@@ -0,0 +1,12 @@
import type { LoadOptionsFn } from '@repo/ui/form';
import { createOptionLoader } from '../../shared/create-option-loader';
export function loadPlanDocumentOptions<T>(
getMany: (config: { params: Record<string, unknown> }) => Promise<{ data?: unknown }>,
customerIds: string[],
): LoadOptionsFn<T> {
if (customerIds.length === 0) {
return async () => ({ options: [], hasMore: false });
}
return createOptionLoader<T>(getMany, { customerIds: customerIds.join(',') });
}
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest';
import { calendarDateToday, isCalendarDateBefore } from './plan-date';
describe('calendarDateToday', () => {
it('formats a local calendar date as YYYY-MM-DD', () => {
expect(calendarDateToday(new Date(2026, 8, 1, 22, 15))).toBe('2026-09-01');
});
});
describe('isCalendarDateBefore', () => {
it('compares ISO calendar dates lexicographically', () => {
expect(isCalendarDateBefore('2026-08-31', '2026-09-01')).toBe(true);
expect(isCalendarDateBefore('2026-09-01', '2026-09-01')).toBe(false);
expect(isCalendarDateBefore('2026-09-02', '2026-09-01')).toBe(false);
});
});
@@ -0,0 +1,10 @@
export function calendarDateToday(now = new Date()): string {
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
export function isCalendarDateBefore(date: string, minDate: string): boolean {
return date < minDate;
}
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import {
documentCustomerId,
documentsBelongToCustomers,
keepDocumentsForCustomers,
needsDocumentHydration,
} from './plan-documents';
describe('documentCustomerId', () => {
it('prefers customerId then nested customer.id', () => {
expect(documentCustomerId({ customerId: 'cus-1', customer: { id: 'cus-2' } })).toBe('cus-1');
expect(documentCustomerId({ customer: { id: 'cus-2' } })).toBe('cus-2');
expect(documentCustomerId({})).toBe('');
});
});
describe('documentsBelongToCustomers', () => {
it('allows stubs without a customer and rejects other customers', () => {
expect(documentsBelongToCustomers([{ id: 'inv-1' } as never, { customerId: 'cus-1' }], ['cus-1'])).toBe(true);
expect(documentsBelongToCustomers([{ customerId: 'cus-2' }], ['cus-1'])).toBe(false);
});
});
describe('keepDocumentsForCustomers', () => {
it('drops documents whose customer is not selected and keeps stubs without a customer', () => {
expect(
keepDocumentsForCustomers(
[{ id: 'inv-1', customerId: 'cus-1' }, { id: 'inv-2', customerId: 'cus-2' }, { id: 'inv-3' }],
['cus-1'],
),
).toEqual([{ id: 'inv-1', customerId: 'cus-1' }, { id: 'inv-3' }]);
});
});
describe('needsDocumentHydration', () => {
it('is true when only an id is present', () => {
expect(needsDocumentHydration({})).toBe(true);
expect(needsDocumentHydration({ id: 'inv-1' })).toBe(true);
});
it('is false when a customer is already present', () => {
expect(needsDocumentHydration({ customerId: 'cus-1' })).toBe(false);
});
});
@@ -0,0 +1,50 @@
export type PlanDocumentCustomer = {
id?: string | number;
code?: string | null;
name?: string;
};
export type PlanDocumentRef = {
id?: string | number;
customerId?: string | null;
customer?: PlanDocumentCustomer | null;
};
export function documentCustomerId(doc: PlanDocumentRef): string {
if (doc.customerId) return String(doc.customerId);
if (doc.customer?.id != null && doc.customer.id !== '') return String(doc.customer.id);
return '';
}
export function documentsBelongToCustomers(
documents: ReadonlyArray<PlanDocumentRef> | undefined,
customerIds: Array<string | undefined>,
): boolean {
const allowed = new Set(customerIds.filter((id): id is string => Boolean(id)));
return (documents ?? []).every((doc) => {
const customerId = documentCustomerId(doc);
return !customerId || allowed.has(customerId);
});
}
export function keepDocumentsForCustomers<T extends PlanDocumentRef>(
documents: T[] | undefined,
customerIds: Array<string | undefined>,
): T[] {
const allowed = new Set(customerIds.filter((id): id is string => Boolean(id)));
return (documents ?? []).filter((doc) => {
const customerId = documentCustomerId(doc);
return !customerId || allowed.has(customerId);
});
}
export function needsDocumentHydration(doc: PlanDocumentRef): boolean {
return !documentCustomerId(doc);
}
export function documentIdsKey(documents: Array<{ id?: string | number }> | undefined): string {
return (documents ?? [])
.map((item) => (item.id == null ? '' : String(item.id)))
.filter(Boolean)
.join(',');
}
@@ -1,12 +1,12 @@
import { describe, expect, it } from 'vitest';
import { createPlanSchema } from './plan.validator';
import { createGeneratePlansSchema, createPlanSchema } from './plan.validator';
describe('createPlanSchema', () => {
const t = (key: string) => key;
const schema = createPlanSchema(t);
const schema = createPlanSchema(t, { today: '2026-09-01' });
const valid = {
employee: { id: 'emp-1' },
date: '2026-01-12',
date: '2026-09-01',
startBranch: { id: 'br-1' },
endBranch: { id: 'br-2' },
customers: [{ id: 'cus-1' }],
@@ -20,10 +20,37 @@ describe('createPlanSchema', () => {
expect(schema.safeParse({ ...valid, date: '' }).success).toBe(false);
});
it('rejects a date before today', () => {
expect(schema.safeParse({ ...valid, date: '2026-08-31' }).success).toBe(false);
});
it('allows a past date when editing an existing plan', () => {
const editSchema = createPlanSchema(t, { today: '2026-09-01', allowPast: true });
expect(editSchema.safeParse({ ...valid, date: '2026-08-31' }).success).toBe(true);
});
it('rejects empty customers', () => {
expect(schema.safeParse({ ...valid, customers: [] }).success).toBe(false);
});
it('rejects invoices that belong to other customers', () => {
expect(
schema.safeParse({
...valid,
invoices: [{ id: 'inv-1', customerId: 'cus-2' }],
}).success,
).toBe(false);
});
it('keeps invoices for selected customers', () => {
expect(
schema.safeParse({
...valid,
invoices: [{ id: 'inv-1', customerId: 'cus-1', customer: { id: 'cus-1' } }],
}).success,
).toBe(true);
});
it('keeps customer location fields for the form preview', () => {
const result = schema.safeParse({
...valid,
@@ -38,3 +65,17 @@ describe('createPlanSchema', () => {
});
});
});
describe('createGeneratePlansSchema', () => {
const t = (key: string) => key;
const schema = createGeneratePlansSchema(t, { today: '2026-09-01' });
const valid = { employee: { id: 'emp-1' }, from: '2026-09-01', to: '2026-09-08' };
it('accepts a range starting today', () => {
expect(schema.safeParse(valid).success).toBe(true);
});
it('rejects a from date before today', () => {
expect(schema.safeParse({ ...valid, from: '2026-08-31' }).success).toBe(false);
});
});
@@ -1,4 +1,6 @@
import { z } from 'zod';
import { calendarDateToday, isCalendarDateBefore } from '../plan-date';
import { documentsBelongToCustomers, type PlanDocumentRef } from '../plan-documents';
const relationSchema = z
.object({
@@ -8,7 +10,21 @@ const relationSchema = z
})
.passthrough();
export const createPlanSchema = (t: (key: string) => string) => {
export type PlanSchemaOptions = {
today?: string;
allowPast?: boolean;
};
function requiredIssue(t: (key: string) => string, fieldKey: string) {
return JSON.stringify({ key: 'validation:required', values: { field: t(fieldKey) } });
}
function notPastIssue(t: (key: string) => string, fieldKey: string) {
return JSON.stringify({ key: 'validation:not_past', values: { field: t(fieldKey) } });
}
export const createPlanSchema = (t: (key: string) => string, options?: PlanSchemaOptions) => {
const today = options?.today ?? calendarDateToday();
return z
.object({
employee: relationSchema.nullable().optional(),
@@ -24,34 +40,63 @@ export const createPlanSchema = (t: (key: string) => string) => {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['employee'],
message: JSON.stringify({ key: 'validation:required', values: { field: t('common:fields.employee') } }),
message: requiredIssue(t, 'common:fields.employee'),
});
}
if (value.date && isCalendarDateBefore(value.date, today) && !options?.allowPast) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['date'],
message: notPastIssue(t, 'common:fields.date'),
});
}
if (!value.startBranch?.id) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['startBranch'],
message: JSON.stringify({ key: 'validation:required', values: { field: t('common:fields.startBranch') } }),
message: requiredIssue(t, 'common:fields.startBranch'),
});
}
if (!value.endBranch?.id) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['endBranch'],
message: JSON.stringify({ key: 'validation:required', values: { field: t('common:fields.endBranch') } }),
message: requiredIssue(t, 'common:fields.endBranch'),
});
}
if (!value.customers?.length) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['customers'],
message: JSON.stringify({ key: 'validation:required', values: { field: t('common:fields.customers') } }),
message: requiredIssue(t, 'common:fields.customers'),
});
}
const customerIds = (value.customers ?? []).map((customer) => customer.id);
if (!documentsBelongToCustomers(value.invoices as PlanDocumentRef[] | undefined, customerIds)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['invoices'],
message: JSON.stringify({
key: 'validation:not_in_selection',
values: { field: t('common:fields.invoices') },
}),
});
}
if (!documentsBelongToCustomers(value.packingSlips as PlanDocumentRef[] | undefined, customerIds)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['packingSlips'],
message: JSON.stringify({
key: 'validation:not_in_selection',
values: { field: t('common:fields.packingSlips') },
}),
});
}
});
};
export const createGeneratePlansSchema = (t: (key: string) => string) => {
export const createGeneratePlansSchema = (t: (key: string) => string, options?: PlanSchemaOptions) => {
const today = options?.today ?? calendarDateToday();
return z
.object({
employee: relationSchema.nullable().optional(),
@@ -63,7 +108,21 @@ export const createGeneratePlansSchema = (t: (key: string) => string) => {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['employee'],
message: JSON.stringify({ key: 'validation:required', values: { field: t('common:fields.employee') } }),
message: requiredIssue(t, 'common:fields.employee'),
});
}
if (value.from && isCalendarDateBefore(value.from, today)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['from'],
message: notPastIssue(t, 'common:fields.from'),
});
}
if (value.to && isCalendarDateBefore(value.to, today)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['to'],
message: notPastIssue(t, 'common:fields.to'),
});
}
});
@@ -18,7 +18,7 @@ import { relationLabel } from '../../../../shared/relation-label';
import type { SalesInvoiceEntity } from '../../../../../sales/invoices/domain/entities';
import type { PackingSlipEntity } from '../../../../packing-slips/domain/entities';
import type { SalesDocumentEntity, SalesLineEntity } from '../../../../../sales/shared/sales-document.entity';
import { salesLinesTotal, unwrapEntity } from './plan-form-preview';
import { salesLinesTotal, unwrapEntity, groupDocumentsByCustomer } from './plan-form-preview';
import { useHydratedRecords } from './use-hydrated-records';
type PreviewDocument = SalesDocumentEntity & {
@@ -38,9 +38,11 @@ async function fetchPackingSlip(id: string): Promise<PackingSlipEntity | null> {
export function FormDocumentsPreview({
kind,
items,
customers = [],
}: {
kind: 'invoice' | 'packingSlip';
items: Array<SalesInvoiceEntity | PackingSlipEntity>;
customers?: Array<{ id?: string | number; code?: string | null; name?: string }>;
}) {
const { t } = useEnterpriseModuleTranslationContext();
const { records: hydratedInvoices, pending: invoicesPending } = useHydratedRecords(
@@ -53,6 +55,7 @@ export function FormDocumentsPreview({
);
const hydrated = kind === 'invoice' ? hydratedInvoices : hydratedSlips;
const pending = kind === 'invoice' ? invoicesPending : slipsPending;
const groups = groupDocumentsByCustomer(hydrated, customers);
if (hydrated.length === 0) return null;
return (
@@ -60,14 +63,23 @@ export function FormDocumentsPreview({
<Text fw={600} mb="md">
{kind === 'invoice' ? t('section_preview_invoices') : t('section_preview_packing_slips')}
</Text>
<Stack gap="lg">
{hydrated.map((document, index) => (
<DocumentPreviewCard
key={String(document.id ?? index)}
document={document}
showBalance={kind === 'invoice'}
pending={pending}
/>
<Stack gap="xl">
{groups.map((group) => (
<Box key={group.customerId || 'unassigned'}>
<Text fw={600} mb="md">
{group.label || t('unassigned_customer')}
</Text>
<Stack gap="lg">
{group.documents.map((document, index) => (
<DocumentPreviewCard
key={String(document.id ?? index)}
document={document}
showBalance={kind === 'invoice'}
pending={pending}
/>
))}
</Stack>
</Box>
))}
</Stack>
</Paper>
@@ -1,14 +1,15 @@
import { useEffect, useMemo } from 'react';
import { Box, FieldAsyncSelect, FieldDatePicker, Paper, SimpleGrid, Stack, Text } from '@repo/ui/components';
import {
useEnterpriseModuleTranslationContext,
useFormPageContext,
useEnterpriseModuleConfigContext,
} from '@repo/ui/foundations';
import { parseDateValue } from '@repo/ui/form';
import { purposeFromModuleKey } from '../../../../../../../../core/domain/field-purpose';
import { loadLogisticsEmployeeOptions, loadSalesEmployeeOptions } from '../../../../shared/load-employee-options';
import { loadBranchOptions } from '../../../../shared/load-branch-options';
import { loadCustomerOptions } from '../../../../shared/load-customer-options';
import { createOptionLoader } from '../../../../shared/create-option-loader';
import { relationLabel } from '../../../../shared/relation-label';
import { salesInvoicesDataService } from '../../../../../sales/invoices/domain/factories';
import { packingSlipsModuleDataService } from '../../../../packing-slips/domain/factories';
@@ -17,14 +18,26 @@ import type { BranchEntity } from '../../../../../configuration/branches/domain/
import type { CustomerEntity } from '../../../../../configuration/customers/domain/entities';
import type { SalesInvoiceEntity } from '../../../../../sales/invoices/domain/entities';
import type { PackingSlipEntity } from '../../../../packing-slips/domain/entities';
import { documentOptionLabel } from './plan-form-preview';
import { calendarDateToday } from '../../../domain/plan-date';
import {
documentCustomerId,
documentIdsKey,
keepDocumentsForCustomers,
needsDocumentHydration,
} from '../../../domain/plan-documents';
import { loadPlanDocumentOptions } from '../../../domain/load-plan-document-options';
import { documentOptionLabel, unwrapEntity } from './plan-form-preview';
import { FormCustomersPreview } from './form-customers-preview';
import { FormDocumentsPreview } from './form-documents-preview';
import { useHydratedRecords } from './use-hydrated-records';
const loadInvoiceOptions = createOptionLoader<SalesInvoiceEntity>((config) =>
salesInvoicesDataService.getMany(config),
);
const loadSlipOptions = createOptionLoader<PackingSlipEntity>((config) => packingSlipsModuleDataService.getMany(config));
async function fetchInvoice(id: string): Promise<SalesInvoiceEntity | null> {
return unwrapEntity<SalesInvoiceEntity>(await salesInvoicesDataService.getOne(id));
}
async function fetchPackingSlip(id: string): Promise<PackingSlipEntity | null> {
return unwrapEntity<PackingSlipEntity>(await packingSlipsModuleDataService.getOne(id));
}
export function FormGeneral() {
const { formControl } = useFormPageContext();
@@ -37,6 +50,41 @@ export function FormGeneral() {
const customers = (formControl.watch('customers') ?? []) as CustomerEntity[];
const invoices = (formControl.watch('invoices') ?? []) as SalesInvoiceEntity[];
const packingSlips = (formControl.watch('packingSlips') ?? []) as PackingSlipEntity[];
const customerIds = useMemo(
() => customers.map((customer) => String(customer.id ?? '')).filter(Boolean),
[customers],
);
const customerIdsKey = customerIds.join(',');
const minDate = parseDateValue(calendarDateToday());
const loadInvoiceOptions = useMemo(
() =>
loadPlanDocumentOptions<SalesInvoiceEntity>((config) => salesInvoicesDataService.getMany(config), customerIds),
[customerIdsKey],
);
const loadSlipOptions = useMemo(
() =>
loadPlanDocumentOptions<PackingSlipEntity>(
(config) => packingSlipsModuleDataService.getMany(config),
customerIds,
),
[customerIdsKey],
);
const { records: hydratedInvoices } = useHydratedRecords(invoices, fetchInvoice, needsDocumentHydration);
const { records: hydratedSlips } = useHydratedRecords(packingSlips, fetchPackingSlip, needsDocumentHydration);
const invoiceHydrationKey = hydratedInvoices.map((item) => `${item.id ?? ''}:${documentCustomerId(item)}`).join(',');
const slipHydrationKey = hydratedSlips.map((item) => `${item.id ?? ''}:${documentCustomerId(item)}`).join(',');
const documentsDisabled = customerIds.length === 0;
useEffect(() => {
const nextInvoices = keepDocumentsForCustomers(hydratedInvoices, customerIds);
if (documentIdsKey(nextInvoices) !== documentIdsKey(invoices)) {
formControl.setValue('invoices', nextInvoices, { shouldDirty: true, shouldValidate: true });
}
const nextSlips = keepDocumentsForCustomers(hydratedSlips, customerIds);
if (documentIdsKey(nextSlips) !== documentIdsKey(packingSlips)) {
formControl.setValue('packingSlips', nextSlips, { shouldDirty: true, shouldValidate: true });
}
}, [customerIdsKey, invoiceHydrationKey, slipHydrationKey]);
return (
<Stack gap="md">
@@ -58,7 +106,13 @@ export function FormGeneral() {
defaultOptions={employee ? [employee] : []}
renderLabel={relationLabel}
/>
<FieldDatePicker control={formControl.control} name="date" label={t('common:fields.date')} required />
<FieldDatePicker
control={formControl.control}
name="date"
label={t('common:fields.date')}
required
minDate={minDate ?? undefined}
/>
<FieldAsyncSelect<BranchEntity>
control={formControl.control}
name="startBranch"
@@ -102,6 +156,7 @@ export function FormGeneral() {
<Box mt="md">
{purpose === 'sales' ? (
<FieldAsyncSelect<SalesInvoiceEntity>
key={`invoices-${customerIdsKey}`}
control={formControl.control}
name="invoices"
label={t('common:fields.invoices')}
@@ -109,12 +164,15 @@ export function FormGeneral() {
labelKey="code"
searchable
multiple
disabled={documentsDisabled}
description={documentsDisabled ? t('select_customers_first') : undefined}
loadOptions={loadInvoiceOptions}
defaultOptions={invoices}
renderLabel={documentOptionLabel}
/>
) : (
<FieldAsyncSelect<PackingSlipEntity>
key={`packing-slips-${customerIdsKey}`}
control={formControl.control}
name="packingSlips"
label={t('common:fields.packingSlips')}
@@ -122,6 +180,8 @@ export function FormGeneral() {
labelKey="code"
searchable
multiple
disabled={documentsDisabled}
description={documentsDisabled ? t('select_customers_first') : undefined}
loadOptions={loadSlipOptions}
defaultOptions={packingSlips}
renderLabel={documentOptionLabel}
@@ -133,9 +193,9 @@ export function FormGeneral() {
<FormCustomersPreview customers={customers} startBranch={startBranch} endBranch={endBranch} />
{purpose === 'sales' ? (
<FormDocumentsPreview kind="invoice" items={invoices} />
<FormDocumentsPreview kind="invoice" items={hydratedInvoices} customers={customers} />
) : (
<FormDocumentsPreview kind="packingSlip" items={packingSlips} />
<FormDocumentsPreview kind="packingSlip" items={hydratedSlips} customers={customers} />
)}
</Stack>
);
@@ -1,6 +1,9 @@
import { describe, expect, it } from 'vitest';
import {
documentCustomerId,
documentOptionLabel,
groupDocumentsByCustomer,
keepDocumentsForCustomers,
mergeHydrated,
needsCustomerHydration,
salesLinesTotal,
@@ -18,7 +21,10 @@ describe('selectionIds', () => {
describe('mergeHydrated', () => {
it('replaces selected stubs with fetched details by id', () => {
const merged = mergeHydrated(
[{ id: 'cus-1', name: 'Stub' }, { id: 'cus-2', name: 'Keep' }],
[
{ id: 'cus-1', name: 'Stub' },
{ id: 'cus-2', name: 'Keep' },
],
{ 'cus-1': { id: 'cus-1', name: 'Acme', address: 'Jl Sudirman' } },
);
expect(merged).toEqual([
@@ -106,3 +112,60 @@ describe('documentOptionLabel', () => {
).toBe('INV-1 · C1 - Acme');
});
});
describe('documentCustomerId', () => {
it('prefers customerId then nested customer.id', () => {
expect(documentCustomerId({ customerId: 'cus-1', customer: { id: 'cus-2' } })).toBe('cus-1');
expect(documentCustomerId({ customer: { id: 'cus-2' } })).toBe('cus-2');
expect(documentCustomerId({})).toBe('');
});
});
describe('keepDocumentsForCustomers', () => {
it('drops documents whose customer is not selected and keeps stubs without a customer', () => {
expect(
keepDocumentsForCustomers(
[{ id: 'inv-1', customerId: 'cus-1' }, { id: 'inv-2', customerId: 'cus-2' }, { id: 'inv-3' }],
['cus-1'],
),
).toEqual([{ id: 'inv-1', customerId: 'cus-1' }, { id: 'inv-3' }]);
});
});
describe('groupDocumentsByCustomer', () => {
it('groups documents in customer order and keeps unknown customers last', () => {
expect(
groupDocumentsByCustomer(
[
{ id: 'inv-2', customerId: 'cus-2', customer: { id: 'cus-2', code: 'C2', name: 'Beta' } },
{ id: 'inv-1', customerId: 'cus-1', customer: { id: 'cus-1', code: 'C1', name: 'Acme' } },
{ id: 'inv-3', customerId: 'cus-1', customer: { id: 'cus-1', code: 'C1', name: 'Acme' } },
{ id: 'inv-4' },
],
[
{ id: 'cus-1', code: 'C1', name: 'Acme' },
{ id: 'cus-2', code: 'C2', name: 'Beta' },
],
),
).toEqual([
{
customerId: 'cus-1',
label: 'C1 - Acme',
documents: [
{ id: 'inv-1', customerId: 'cus-1', customer: { id: 'cus-1', code: 'C1', name: 'Acme' } },
{ id: 'inv-3', customerId: 'cus-1', customer: { id: 'cus-1', code: 'C1', name: 'Acme' } },
],
},
{
customerId: 'cus-2',
label: 'C2 - Beta',
documents: [{ id: 'inv-2', customerId: 'cus-2', customer: { id: 'cus-2', code: 'C2', name: 'Beta' } }],
},
{
customerId: '',
label: '',
documents: [{ id: 'inv-4' }],
},
]);
});
});
@@ -1,15 +1,16 @@
import type { RouteGeometry } from '../../../../cycles/domain/entities';
import { documentCustomerId, type PlanDocumentRef } from '../../../domain/plan-documents';
import { relationLabel } from '../../../../shared/relation-label';
export { documentCustomerId, keepDocumentsForCustomers } from '../../../domain/plan-documents';
export type GeoPoint = {
latitude?: number | null;
longitude?: number | null;
};
export function selectionIds(selected: Array<{ id?: string | number }> | undefined): string[] {
return (selected ?? [])
.map((item) => (item.id == null ? '' : String(item.id)))
.filter(Boolean);
return (selected ?? []).map((item) => (item.id == null ? '' : String(item.id))).filter(Boolean);
}
export function mergeHydrated<T extends { id?: string | number }>(selected: T[], details: Record<string, T>): T[] {
@@ -80,3 +81,41 @@ export function documentOptionLabel(item: {
if (base && customer) return `${base} · ${customer}`;
return base || customer;
}
export function groupDocumentsByCustomer<T extends PlanDocumentRef>(
documents: T[],
customers: Array<{ id?: string | number; code?: string | null; name?: string }> = [],
): Array<{ customerId: string; label: string; documents: T[] }> {
const buckets = new Map<string, T[]>();
for (const document of documents) {
const customerId = documentCustomerId(document);
buckets.set(customerId, [...(buckets.get(customerId) ?? []), document]);
}
const groups: Array<{ customerId: string; label: string; documents: T[] }> = [];
const seen = new Set<string>();
for (const customer of customers) {
const customerId = customer.id == null ? '' : String(customer.id);
if (!customerId || seen.has(customerId)) continue;
const grouped = buckets.get(customerId);
if (!grouped?.length) continue;
seen.add(customerId);
groups.push({
customerId,
label: relationLabel(customer) || customerId,
documents: grouped,
});
}
for (const [customerId, grouped] of buckets) {
if (seen.has(customerId) || grouped.length === 0) continue;
groups.push({
customerId,
label: relationLabel(grouped[0]?.customer) || customerId,
documents: grouped,
});
}
return groups;
}
@@ -7,9 +7,11 @@ import {
useEnterpriseModuleDataServiceContext,
useEnterpriseModuleTranslationContext,
} from '@repo/ui/foundations';
import { parseDateValue } from '@repo/ui/form';
import { purposeFromModuleKey } from '../../../../../../../../core/domain/field-purpose';
import { loadLogisticsEmployeeOptions, loadSalesEmployeeOptions } from '../../../../shared/load-employee-options';
import { relationLabel } from '../../../../shared/relation-label';
import { calendarDateToday } from '../../../domain/plan-date';
import { createGeneratePlansSchema } from '../../../domain/validators/plan.validator';
import type { PlansRemoteDataServices } from '../../../data/plan.remote.service';
import type { EmployeeEntity } from '../../../../../configuration/employees/domain/entities';
@@ -22,6 +24,7 @@ export function GeneratePlansModal({ opened, onClose }: { opened: boolean; onClo
const { dataServices } = useEnterpriseModuleDataServiceContext<PlanEntity, PlansRemoteDataServices>();
const validator = useMemo(() => createGeneratePlansSchema(t), [t]);
const form = useForm({ resolver: zodResolver(validator) });
const minDate = parseDateValue(calendarDateToday());
const handleSubmit = form.handleSubmit(async (values) => {
const result = await dataServices.generate({
@@ -55,8 +58,20 @@ export function GeneratePlansModal({ opened, onClose }: { opened: boolean; onClo
loadOptions={purpose === 'sales' ? loadSalesEmployeeOptions : loadLogisticsEmployeeOptions}
renderLabel={relationLabel}
/>
<FieldDatePicker control={form.control as any} name="from" label={t('common:fields.from')} required />
<FieldDatePicker control={form.control as any} name="to" label={t('common:fields.to')} required />
<FieldDatePicker
control={form.control as any}
name="from"
label={t('common:fields.from')}
required
minDate={minDate ?? undefined}
/>
<FieldDatePicker
control={form.control as any}
name="to"
label={t('common:fields.to')}
required
minDate={minDate ?? undefined}
/>
<Group justify="flex-end">
<Button variant="default" type="button" onClick={onClose}>
{t('common:cancel')}
@@ -25,6 +25,8 @@
"empty_route": "No route geometry",
"empty_products": "No products on this document",
"preview_loading": "Loading document details…",
"select_customers_first": "Select customers first",
"unassigned_customer": "Unassigned",
"purpose_sales": "Sales",
"purpose_logistics": "Logistics",
"status_draft": "Draft",
@@ -25,6 +25,8 @@
"empty_route": "Tidak ada geometri rute",
"empty_products": "Tidak ada produk pada dokumen ini",
"preview_loading": "Memuat detail dokumen…",
"select_customers_first": "Pilih pelanggan terlebih dahulu",
"unassigned_customer": "Belum ditetapkan",
"purpose_sales": "Penjualan",
"purpose_logistics": "Logistik",
"status_draft": "Draft",
@@ -31,7 +31,7 @@ export default function PlanPageForm({ formPageType }: { formPageType: FormPageT
return { title: '', description: '' };
}, [formPageType, t]);
const validator = useMemo(() => createPlanSchema(t), [t]);
const validator = useMemo(() => createPlanSchema(t, { allowPast: formPageType === 'EDIT' }), [t, formPageType]);
const formControl = useForm({ resolver: zodResolver(validator) });
return (
@@ -1,10 +1,7 @@
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';
import { salesReportsModuleConfig, type ReportShellEntity } from '../constants/reports.constants';
class ReportShellTransformer extends BaseDataTransformer<ReportShellEntity> {
transformToEntity(dto: ReportShellEntity): ReportShellEntity {
@@ -1,11 +1,5 @@
import { useEffect, useState } from 'react';
import {
Button,
Drawer,
Group,
Stack,
Text,
} from '@repo/ui/components';
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';
@@ -17,12 +11,7 @@ export interface ReportBookmarkListProps {
onApplied: () => void;
}
export function ReportBookmarkList({
opened,
onClose,
config,
onApplied,
}: ReportBookmarkListProps) {
export function ReportBookmarkList({ opened, onClose, config, onApplied }: ReportBookmarkListProps) {
const [bookmarks, setBookmarks] = useState<ReportBookmark[]>([]);
useEffect(() => {
@@ -63,7 +52,9 @@ export function ReportBookmarkList({
<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>
<Text size="xs" c="dimmed">
{bookmark.type}
</Text>
</Stack>
<Group gap="xs">
{bookmark.type === REPORT_BOOKMARK_TYPE.FILTER_TABLE && (
@@ -1,17 +1,6 @@
import { useEffect } from 'react';
import {
Button,
Drawer,
Stack,
} from '@repo/ui/components';
import {
FieldDatePicker,
FieldSelect,
FieldTagsInput,
FieldTextInput,
useForm,
FormProvider,
} from '@repo/ui/form';
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';
@@ -21,10 +10,7 @@ export interface ReportFilterDrawerProps {
config: ReportConfig;
initialValues: Record<string, unknown>;
onSubmit: (values: Record<string, unknown>) => void;
onSubmitAndBookmark: (
values: Record<string, unknown>,
label: string,
) => Promise<void>;
onSubmitAndBookmark: (values: Record<string, unknown>, label: string) => Promise<void>;
}
export function ReportFilterDrawer({
@@ -50,9 +36,7 @@ export function ReportFilterDrawer({
});
const handleBookmark = form.handleSubmit(async (values) => {
const label =
(values.bookmarkLabel as string) ||
`${config.label} filter ${new Date().toISOString()}`;
const label = (values.bookmarkLabel as string) || `${config.label} filter ${new Date().toISOString()}`;
await onSubmitAndBookmark(values, label);
});
@@ -84,32 +68,14 @@ export function ReportFilterDrawer({
/>
);
case FILTER_FIELD_TYPE.INPUT_TAG:
return (
<FieldTagsInput
key={name}
name={name}
label={filterConfig.fieldLabel}
/>
);
return <FieldTagsInput key={name} name={name} label={filterConfig.fieldLabel} />;
case FILTER_FIELD_TYPE.INPUT_TEXT:
return (
<FieldTextInput
key={name}
name={name}
label={filterConfig.fieldLabel}
/>
);
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)`}
/>
<FieldDatePicker name={`${name}.from`} label={`${filterConfig.fieldLabel} (from)`} />
<FieldDatePicker name={`${name}.to`} label={`${filterConfig.fieldLabel} (to)`} />
</Stack>
);
default:
@@ -10,11 +10,7 @@ export interface ReportProviderProps {
defaultFilterPerItem?: Record<string, Record<string, unknown>>;
}
export function ReportProvider({
groupName,
commonDefaultFilter,
defaultFilterPerItem,
}: ReportProviderProps) {
export function ReportProvider({ groupName, commonDefaultFilter, defaultFilterPerItem }: ReportProviderProps) {
const [configs, setConfigs] = useState<ReportConfig[]>([]);
const [activeTab, setActiveTab] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
@@ -47,10 +43,7 @@ export function ReportProvider({
};
}, [groupName]);
const activeConfig = useMemo(
() => configs.find((c) => c.uniqueName === activeTab),
[configs, activeTab],
);
const activeConfig = useMemo(() => configs.find((c) => c.uniqueName === activeTab), [configs, activeTab]);
if (loading) {
return <div>Loading reports...</div>;
@@ -1,26 +1,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Button,
Group,
Stack,
Box,
} from '@repo/ui/components';
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 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 { restoreFilterFormValues, transformFilterValue } from '../utils/filter.helper';
import { ReportFilterDrawer } from './report-filter-drawer';
import { ReportBookmarkList } from './report-bookmark-list';
@@ -32,21 +18,14 @@ export interface ReportTableProps {
additionalDefaultFilter?: Record<string, unknown>;
}
export function ReportTable({
config,
commonDefaultFilter,
additionalDefaultFilter,
}: ReportTableProps) {
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 columnDefs = useMemo<ColDef[]>(() => buildColumnDefs(config.columnConfigs), [config.columnConfigs]);
const defaultColDef = useMemo<ColDef>(
() => ({
@@ -110,17 +89,11 @@ export function ReportTable({
const createDatasource = useCallback((): IServerSideDatasource => {
return {
getRows: async (params: IServerSideGetRowsParams) => {
const drawerFilter = transformFilterValue(
config.filterConfigs,
filterValuesRef.current,
);
const drawerFilter = transformFilterValue(config.filterConfigs, filterValuesRef.current);
const mergedFilterModel = {
...params.request.filterModel,
...transformFilterValue(config.filterConfigs, commonDefaultFilter ?? {}),
...transformFilterValue(
config.filterConfigs,
additionalDefaultFilter ?? {},
),
...transformFilterValue(config.filterConfigs, additionalDefaultFilter ?? {}),
...drawerFilter,
};
+3 -6
View File
@@ -49,23 +49,20 @@ export const FILTER_FIELD_TYPE = {
MONTH_RANGE_PICKER: 'month_range_picker',
} as const;
export type FilterFieldType =
(typeof FILTER_FIELD_TYPE)[keyof typeof FILTER_FIELD_TYPE];
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 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 type ReportGroupName = (typeof REPORT_GROUP)[keyof typeof REPORT_GROUP];
export const COUNT_CHILD_GROUP_COLUMN = 'countChildGroup';
@@ -1,10 +1,5 @@
import { apiClient } from '../../lib/api-client';
import type {
ReportBookmark,
ReportConfig,
ReportMeta,
ReportQueryPayload,
} from '../entities';
import type { ReportBookmark, ReportConfig, ReportMeta, ReportQueryPayload } from '../entities';
export class ReportRemoteService {
async getConfigs(groupNames: string[]): Promise<ReportConfig[]> {
@@ -18,10 +13,7 @@ export class ReportRemoteService {
}
async getData(payload: ReportQueryPayload): Promise<Record<string, unknown>[]> {
const response = await apiClient.post<Record<string, unknown>[]>(
'/reports/data',
payload,
);
const response = await apiClient.post<Record<string, unknown>[]>('/reports/data', payload);
return response.data;
}
@@ -31,10 +23,7 @@ export class ReportRemoteService {
}
async listBookmarks(params: Record<string, unknown>) {
const response = await apiClient.get<{ data: ReportBookmark[] }>(
'/report-bookmarks',
{ params },
);
const response = await apiClient.get<{ data: ReportBookmark[] }>('/report-bookmarks', { params });
return response.data;
}
@@ -46,24 +35,17 @@ export class ReportRemoteService {
applied?: boolean;
configuration: unknown;
}): Promise<ReportBookmark> {
const response = await apiClient.post<ReportBookmark>(
'/report-bookmarks',
body,
);
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}`,
);
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}`,
);
const response = await apiClient.put<ReportBookmark>(`/report-bookmarks/unapplied/${id}`);
return response.data;
}
+1 -4
View File
@@ -6,7 +6,4 @@ 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';
export { transformFilterValue, restoreFilterFormValues } from './utils/filter.helper';
@@ -1,9 +1,6 @@
import { describe, expect, it } from 'vitest';
import { DATA_FORMAT, DATA_TYPE } from '../constants';
import {
buildColumnDefs,
formatReportCellDisplay,
} from '../utils/column.helper';
import { buildColumnDefs, formatReportCellDisplay } from '../utils/column.helper';
describe('buildColumnDefs', () => {
it('maps dimension and measure columns', () => {
@@ -33,11 +30,7 @@ describe('buildColumnDefs', () => {
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',
);
expect(formatReportCellDisplay('10.50000', DATA_FORMAT.CURRENCY)).toBe('10.50000');
expect(formatReportCellDisplay('1234.5000', DATA_FORMAT.NUMBER)).toBe('1234.5000');
});
});
@@ -2,16 +2,9 @@ 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,
]);
const NUMERIC_FORMATS = new Set<string>([DATA_FORMAT.NUMBER, DATA_FORMAT.CURRENCY, DATA_FORMAT.MINUS_CURRENCY]);
export function formatReportCellDisplay(
value: unknown,
format: string,
): string {
export function formatReportCellDisplay(value: unknown, format: string): string {
if (value === null || value === undefined || value === '') {
return '';
}
@@ -42,9 +35,7 @@ function formatDecimalDisplay(value: unknown): string {
return String(value);
}
export function buildColumnDefs(
columnConfigs: ReportColumnConfig[],
): ColDef[] {
export function buildColumnDefs(columnConfigs: ReportColumnConfig[]): ColDef[] {
return columnConfigs.map((col) => {
const isNumeric = NUMERIC_FORMATS.has(col.format);
@@ -55,8 +46,7 @@ export function buildColumnDefs(
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),
valueFormatter: (params: ValueFormatterParams) => formatReportCellDisplay(params.value, col.format),
};
});
}
@@ -27,9 +27,7 @@ export function transformFilterValue(
continue;
}
filter = {
from: range.from
? dayjs(range.from).startOf('day').valueOf()
: undefined,
from: range.from ? dayjs(range.from).startOf('day').valueOf() : undefined,
to: range.to ? dayjs(range.to).endOf('day').valueOf() : undefined,
};
}
@@ -54,11 +52,7 @@ export function restoreFilterFormValues(
for (const config of filterConfigs) {
const value = configuration[config.filterColumn];
if (
config.fieldType === FILTER_FIELD_TYPE.DATE_RANGE_PICKER &&
value &&
typeof value === 'object'
) {
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,