Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05c8459d12 |
@@ -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 {
|
||||
|
||||
+1
-4
@@ -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(',');
|
||||
}
|
||||
+44
-3
@@ -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'),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
+21
-9
@@ -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>
|
||||
|
||||
+69
-9
@@ -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>
|
||||
);
|
||||
|
||||
+64
-1
@@ -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' }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
+42
-3
@@ -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;
|
||||
}
|
||||
|
||||
+17
-2
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
"invalid_phone": "Invalid phone number format",
|
||||
"invalid_format": "{{field}} format is invalid",
|
||||
"invalid_password_simple": "Password must be at least {{min}} characters",
|
||||
"invalid_password_complex": "Password must contain at least 1 uppercase, 1 lowercase, 1 number, and 1 special character"
|
||||
"invalid_password_complex": "Password must contain at least 1 uppercase, 1 lowercase, 1 number, and 1 special character",
|
||||
"not_past": "{{field}} cannot be in the past",
|
||||
"not_in_selection": "{{field}} must belong to the selected customers"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
"invalid_phone": "Format nomor telepon tidak valid",
|
||||
"invalid_format": "Format {{field}} tidak valid",
|
||||
"invalid_password_simple": "Kata sandi minimal {{min}} karakter",
|
||||
"invalid_password_complex": "Kata sandi harus mengandung minimal 1 huruf besar, 1 huruf kecil, 1 angka, dan 1 karakter spesial"
|
||||
"invalid_password_complex": "Kata sandi harus mengandung minimal 1 huruf besar, 1 huruf kecil, 1 angka, dan 1 karakter spesial",
|
||||
"not_past": "{{field}} tidak boleh mundur dari hari ini",
|
||||
"not_in_selection": "{{field}} harus milik pelanggan yang dipilih"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user