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} /> - - + +