From 05c8459d12b457698deb5aa916e71d67b1d80cc2 Mon Sep 17 00:00:00 2001 From: shancheas Date: Tue, 1 Sep 2026 09:50:53 +0700 Subject: [PATCH] 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. --- apps/web/docs/report-engine-fe.md | 28 +++---- .../apps/main/layouts/components/sidebar.tsx | 4 +- .../main/layouts/components/sidebar.utils.ts | 6 +- .../domain/factories/index.ts | 5 +- .../presentation/factory/index.tsx | 5 +- .../main/modules/field/logistics/index.tsx | 4 +- .../domain/load-plan-document-options.test.ts | 23 ++++++ .../domain/load-plan-document-options.ts | 12 +++ .../field/plans/domain/plan-date.test.ts | 16 ++++ .../modules/field/plans/domain/plan-date.ts | 10 +++ .../field/plans/domain/plan-documents.test.ts | 44 +++++++++++ .../field/plans/domain/plan-documents.ts | 50 ++++++++++++ .../domain/validators/plan.validator.test.ts | 47 ++++++++++- .../plans/domain/validators/plan.validator.ts | 73 +++++++++++++++-- .../form-component/form-documents-preview.tsx | 30 ++++--- .../form-component/form-general.tsx | 78 ++++++++++++++++--- .../form-component/plan-form-preview.test.ts | 65 +++++++++++++++- .../form-component/plan-form-preview.ts | 45 ++++++++++- .../index-component/generate-modal.tsx | 19 ++++- .../presentation/languages/en/plans.json | 2 + .../presentation/languages/id/plans.json | 2 + .../presentation/pages/plan.page.form.tsx | 2 +- .../sales/reports/domain/factories/index.ts | 5 +- .../components/report-bookmark-list.tsx | 19 ++--- .../components/report-filter-drawer.tsx | 50 ++---------- .../report/components/report-provider.tsx | 11 +-- .../core/report/components/report-table.tsx | 41 ++-------- apps/web/src/core/report/constants/index.ts | 9 +-- .../core/report/data/report.remote.service.ts | 30 ++----- apps/web/src/core/report/index.ts | 5 +- .../core/report/utils/column.helper.test.ts | 13 +--- .../src/core/report/utils/column.helper.ts | 18 +---- .../src/core/report/utils/filter.helper.ts | 10 +-- .../src/languages/en/validation.json | 4 +- .../src/languages/id/validation.json | 4 +- 35 files changed, 552 insertions(+), 237 deletions(-) create mode 100644 apps/web/src/apps/main/modules/field/plans/domain/load-plan-document-options.test.ts create mode 100644 apps/web/src/apps/main/modules/field/plans/domain/load-plan-document-options.ts create mode 100644 apps/web/src/apps/main/modules/field/plans/domain/plan-date.test.ts create mode 100644 apps/web/src/apps/main/modules/field/plans/domain/plan-date.ts create mode 100644 apps/web/src/apps/main/modules/field/plans/domain/plan-documents.test.ts create mode 100644 apps/web/src/apps/main/modules/field/plans/domain/plan-documents.ts diff --git a/apps/web/docs/report-engine-fe.md b/apps/web/docs/report-engine-fe.md index a736478..3b06d6f 100644 --- a/apps/web/docs/report-engine-fe.md +++ b/apps/web/docs/report-engine-fe.md @@ -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. diff --git a/apps/web/src/apps/main/layouts/components/sidebar.tsx b/apps/web/src/apps/main/layouts/components/sidebar.tsx index 88b8407..b1d9439 100644 --- a/apps/web/src/apps/main/layouts/components/sidebar.tsx +++ b/apps/web/src/apps/main/layouts/components/sidebar.tsx @@ -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', diff --git a/apps/web/src/apps/main/layouts/components/sidebar.utils.ts b/apps/web/src/apps/main/layouts/components/sidebar.utils.ts index 448849d..e55eb97 100644 --- a/apps/web/src/apps/main/layouts/components/sidebar.utils.ts +++ b/apps/web/src/apps/main/layouts/components/sidebar.utils.ts @@ -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, - isSearching = false, -): string[] { +export function getVisibleMenuKeys(items: MenuItemType[], openedKeys: Set, isSearching = false): string[] { const keys: string[] = []; const walk = (nodes: MenuItemType[]) => { diff --git a/apps/web/src/apps/main/modules/field/logistics-reports/domain/factories/index.ts b/apps/web/src/apps/main/modules/field/logistics-reports/domain/factories/index.ts index 1f0db04..5ca920f 100644 --- a/apps/web/src/apps/main/modules/field/logistics-reports/domain/factories/index.ts +++ b/apps/web/src/apps/main/modules/field/logistics-reports/domain/factories/index.ts @@ -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 { transformToEntity(dto: ReportShellEntity): ReportShellEntity { diff --git a/apps/web/src/apps/main/modules/field/logistics-reports/presentation/factory/index.tsx b/apps/web/src/apps/main/modules/field/logistics-reports/presentation/factory/index.tsx index 506b170..669304d 100644 --- a/apps/web/src/apps/main/modules/field/logistics-reports/presentation/factory/index.tsx +++ b/apps/web/src/apps/main/modules/field/logistics-reports/presentation/factory/index.tsx @@ -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'; diff --git a/apps/web/src/apps/main/modules/field/logistics/index.tsx b/apps/web/src/apps/main/modules/field/logistics/index.tsx index e4b6e92..7f0b54d 100644 --- a/apps/web/src/apps/main/modules/field/logistics/index.tsx +++ b/apps/web/src/apps/main/modules/field/logistics/index.tsx @@ -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')); diff --git a/apps/web/src/apps/main/modules/field/plans/domain/load-plan-document-options.test.ts b/apps/web/src/apps/main/modules/field/plans/domain/load-plan-document-options.test.ts new file mode 100644 index 0000000..3191963 --- /dev/null +++ b/apps/web/src/apps/main/modules/field/plans/domain/load-plan-document-options.test.ts @@ -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 }); + }); +}); diff --git a/apps/web/src/apps/main/modules/field/plans/domain/load-plan-document-options.ts b/apps/web/src/apps/main/modules/field/plans/domain/load-plan-document-options.ts new file mode 100644 index 0000000..914e3e9 --- /dev/null +++ b/apps/web/src/apps/main/modules/field/plans/domain/load-plan-document-options.ts @@ -0,0 +1,12 @@ +import type { LoadOptionsFn } from '@repo/ui/form'; +import { createOptionLoader } from '../../shared/create-option-loader'; + +export function loadPlanDocumentOptions( + getMany: (config: { params: Record }) => Promise<{ data?: unknown }>, + customerIds: string[], +): LoadOptionsFn { + if (customerIds.length === 0) { + return async () => ({ options: [], hasMore: false }); + } + return createOptionLoader(getMany, { customerIds: customerIds.join(',') }); +} diff --git a/apps/web/src/apps/main/modules/field/plans/domain/plan-date.test.ts b/apps/web/src/apps/main/modules/field/plans/domain/plan-date.test.ts new file mode 100644 index 0000000..e531348 --- /dev/null +++ b/apps/web/src/apps/main/modules/field/plans/domain/plan-date.test.ts @@ -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); + }); +}); diff --git a/apps/web/src/apps/main/modules/field/plans/domain/plan-date.ts b/apps/web/src/apps/main/modules/field/plans/domain/plan-date.ts new file mode 100644 index 0000000..f26db51 --- /dev/null +++ b/apps/web/src/apps/main/modules/field/plans/domain/plan-date.ts @@ -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; +} diff --git a/apps/web/src/apps/main/modules/field/plans/domain/plan-documents.test.ts b/apps/web/src/apps/main/modules/field/plans/domain/plan-documents.test.ts new file mode 100644 index 0000000..d1f5165 --- /dev/null +++ b/apps/web/src/apps/main/modules/field/plans/domain/plan-documents.test.ts @@ -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); + }); +}); diff --git a/apps/web/src/apps/main/modules/field/plans/domain/plan-documents.ts b/apps/web/src/apps/main/modules/field/plans/domain/plan-documents.ts new file mode 100644 index 0000000..506d10f --- /dev/null +++ b/apps/web/src/apps/main/modules/field/plans/domain/plan-documents.ts @@ -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 | undefined, + customerIds: Array, +): 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( + documents: T[] | undefined, + customerIds: Array, +): 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(','); +} diff --git a/apps/web/src/apps/main/modules/field/plans/domain/validators/plan.validator.test.ts b/apps/web/src/apps/main/modules/field/plans/domain/validators/plan.validator.test.ts index 50ee45f..9b72e58 100644 --- a/apps/web/src/apps/main/modules/field/plans/domain/validators/plan.validator.test.ts +++ b/apps/web/src/apps/main/modules/field/plans/domain/validators/plan.validator.test.ts @@ -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); + }); +}); diff --git a/apps/web/src/apps/main/modules/field/plans/domain/validators/plan.validator.ts b/apps/web/src/apps/main/modules/field/plans/domain/validators/plan.validator.ts index 0169a26..8713279 100644 --- a/apps/web/src/apps/main/modules/field/plans/domain/validators/plan.validator.ts +++ b/apps/web/src/apps/main/modules/field/plans/domain/validators/plan.validator.ts @@ -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'), }); } }); diff --git a/apps/web/src/apps/main/modules/field/plans/presentation/components/form-component/form-documents-preview.tsx b/apps/web/src/apps/main/modules/field/plans/presentation/components/form-component/form-documents-preview.tsx index 80feed6..84b921d 100644 --- a/apps/web/src/apps/main/modules/field/plans/presentation/components/form-component/form-documents-preview.tsx +++ b/apps/web/src/apps/main/modules/field/plans/presentation/components/form-component/form-documents-preview.tsx @@ -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 { export function FormDocumentsPreview({ kind, items, + customers = [], }: { kind: 'invoice' | 'packingSlip'; items: Array; + 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({ {kind === 'invoice' ? t('section_preview_invoices') : t('section_preview_packing_slips')} - - {hydrated.map((document, index) => ( - + + {groups.map((group) => ( + + + {group.label || t('unassigned_customer')} + + + {group.documents.map((document, index) => ( + + ))} + + ))} diff --git a/apps/web/src/apps/main/modules/field/plans/presentation/components/form-component/form-general.tsx b/apps/web/src/apps/main/modules/field/plans/presentation/components/form-component/form-general.tsx index 671f8eb..9421946 100644 --- a/apps/web/src/apps/main/modules/field/plans/presentation/components/form-component/form-general.tsx +++ b/apps/web/src/apps/main/modules/field/plans/presentation/components/form-component/form-general.tsx @@ -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((config) => - salesInvoicesDataService.getMany(config), -); -const loadSlipOptions = createOptionLoader((config) => packingSlipsModuleDataService.getMany(config)); +async function fetchInvoice(id: string): Promise { + return unwrapEntity(await salesInvoicesDataService.getOne(id)); +} + +async function fetchPackingSlip(id: string): Promise { + return unwrapEntity(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((config) => salesInvoicesDataService.getMany(config), customerIds), + [customerIdsKey], + ); + const loadSlipOptions = useMemo( + () => + loadPlanDocumentOptions( + (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 ( @@ -58,7 +106,13 @@ export function FormGeneral() { defaultOptions={employee ? [employee] : []} renderLabel={relationLabel} /> - + control={formControl.control} name="startBranch" @@ -102,6 +156,7 @@ export function FormGeneral() { {purpose === 'sales' ? ( + 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} /> ) : ( + 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() { {purpose === 'sales' ? ( - + ) : ( - + )} ); diff --git a/apps/web/src/apps/main/modules/field/plans/presentation/components/form-component/plan-form-preview.test.ts b/apps/web/src/apps/main/modules/field/plans/presentation/components/form-component/plan-form-preview.test.ts index ff2155e..5d90f71 100644 --- a/apps/web/src/apps/main/modules/field/plans/presentation/components/form-component/plan-form-preview.test.ts +++ b/apps/web/src/apps/main/modules/field/plans/presentation/components/form-component/plan-form-preview.test.ts @@ -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' }], + }, + ]); + }); +}); diff --git a/apps/web/src/apps/main/modules/field/plans/presentation/components/form-component/plan-form-preview.ts b/apps/web/src/apps/main/modules/field/plans/presentation/components/form-component/plan-form-preview.ts index 8cee271..42a738e 100644 --- a/apps/web/src/apps/main/modules/field/plans/presentation/components/form-component/plan-form-preview.ts +++ b/apps/web/src/apps/main/modules/field/plans/presentation/components/form-component/plan-form-preview.ts @@ -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(selected: T[], details: Record): T[] { @@ -80,3 +81,41 @@ export function documentOptionLabel(item: { if (base && customer) return `${base} · ${customer}`; return base || customer; } + +export function groupDocumentsByCustomer( + documents: T[], + customers: Array<{ id?: string | number; code?: string | null; name?: string }> = [], +): Array<{ customerId: string; label: string; documents: T[] }> { + const buckets = new Map(); + 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(); + + 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; +} diff --git a/apps/web/src/apps/main/modules/field/plans/presentation/components/index-component/generate-modal.tsx b/apps/web/src/apps/main/modules/field/plans/presentation/components/index-component/generate-modal.tsx index c2ca9e4..d942796 100644 --- a/apps/web/src/apps/main/modules/field/plans/presentation/components/index-component/generate-modal.tsx +++ b/apps/web/src/apps/main/modules/field/plans/presentation/components/index-component/generate-modal.tsx @@ -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(); 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} /> - - + +