From 5a6b109a8952b6b3cfe9b1d47ea59fc20dafb391 Mon Sep 17 00:00:00 2001 From: shancheas Date: Mon, 31 Aug 2026 15:05:48 +0700 Subject: [PATCH] feat: refactor option loading functions to support active status filtering - Updated `loadDivisionOptions`, `loadBranchOptions`, `loadCustomerOptions`, `loadEmployeeOptionsForPurpose`, and `loadProductOptions` to utilize the new `createOptionLoader` function with active status parameters. - Introduced `ACTIVE_LOOKUP_PARAMS` to streamline the inclusion of additional parameters in option loading. - Added unit tests for `createOptionLoader` and new utility functions for managing product options, ensuring robust functionality and reliability. These changes enhance the option loading mechanism across various modules, improving data retrieval and filtering capabilities. --- .../shared/load-division-options.ts | 14 +++----- .../field/shared/create-option-loader.test.ts | 32 +++++++++++++++++++ .../field/shared/create-option-loader.ts | 5 ++- .../field/shared/load-branch-options.ts | 7 ++-- .../field/shared/load-customer-options.ts | 7 ++-- .../field/shared/load-employee-options.ts | 4 +-- .../modules/sales/shared/form-general.tsx | 8 +++++ .../modules/sales/shared/form-products.tsx | 8 ++++- .../sales/shared/load-product-options.ts | 7 ++-- .../shared/sales-document.mapper.test.ts | 24 ++++++++++++++ .../sales/shared/sales-document.mapper.ts | 18 +++++++++++ .../sales/shared/sales-line-options.test.ts | 28 ++++++++++++++++ .../sales/shared/sales-line-options.ts | 18 +++++++++++ 13 files changed, 161 insertions(+), 19 deletions(-) create mode 100644 apps/web/src/apps/main/modules/field/shared/create-option-loader.test.ts create mode 100644 apps/web/src/apps/main/modules/sales/shared/sales-line-options.test.ts create mode 100644 apps/web/src/apps/main/modules/sales/shared/sales-line-options.ts diff --git a/apps/web/src/apps/main/modules/configuration/shared/load-division-options.ts b/apps/web/src/apps/main/modules/configuration/shared/load-division-options.ts index 986e2d2..8a91af7 100644 --- a/apps/web/src/apps/main/modules/configuration/shared/load-division-options.ts +++ b/apps/web/src/apps/main/modules/configuration/shared/load-division-options.ts @@ -1,12 +1,8 @@ -import type { LoadOptionsFn } from '@repo/ui/form'; import { divisionsDataService } from '../divisions/domain/factories'; import type { DivisionEntity } from '../divisions/domain/entities'; +import { ACTIVE_LOOKUP_PARAMS, createOptionLoader } from '../../field/shared/create-option-loader'; -export const loadDivisionOptions: LoadOptionsFn = async (search, page) => { - const result = await divisionsDataService.getMany({ - params: { search, page, limit: 20 }, - }); - const rows = (result.data as { data?: DivisionEntity[]; meta?: { totalPages?: number } })?.data ?? []; - const totalPages = (result.data as { meta?: { totalPages?: number } })?.meta?.totalPages ?? 1; - return { options: rows, hasMore: page < totalPages }; -}; +export const loadDivisionOptions = createOptionLoader( + (config) => divisionsDataService.getMany(config), + ACTIVE_LOOKUP_PARAMS, +); diff --git a/apps/web/src/apps/main/modules/field/shared/create-option-loader.test.ts b/apps/web/src/apps/main/modules/field/shared/create-option-loader.test.ts new file mode 100644 index 0000000..1779c28 --- /dev/null +++ b/apps/web/src/apps/main/modules/field/shared/create-option-loader.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createOptionLoader } from './create-option-loader'; + +describe('createOptionLoader', () => { + it('requests the first page with search and limit', async () => { + const getMany = vi.fn().mockResolvedValue({ + data: { data: [{ id: 'a' }], meta: { totalPages: 1 } }, + }); + + const load = createOptionLoader(getMany); + const result = await load('ada', 1, []); + + expect(getMany).toHaveBeenCalledWith({ + params: { search: 'ada', page: 1, limit: 20 }, + }); + expect(result).toEqual({ options: [{ id: 'a' }], hasMore: false }); + }); + + it('merges extra params so lookups can require active status', async () => { + const getMany = vi.fn().mockResolvedValue({ + data: { data: [{ id: 'a', status: 'active' }], meta: { totalPages: 3 } }, + }); + + const load = createOptionLoader(getMany, { status: 'active' }); + const result = await load('', 2, []); + + expect(getMany).toHaveBeenCalledWith({ + params: { search: '', page: 2, limit: 20, status: 'active' }, + }); + expect(result.hasMore).toBe(true); + }); +}); diff --git a/apps/web/src/apps/main/modules/field/shared/create-option-loader.ts b/apps/web/src/apps/main/modules/field/shared/create-option-loader.ts index 88290a6..9afdc06 100644 --- a/apps/web/src/apps/main/modules/field/shared/create-option-loader.ts +++ b/apps/web/src/apps/main/modules/field/shared/create-option-loader.ts @@ -1,11 +1,14 @@ import type { LoadOptionsFn } from '@repo/ui/form'; +export const ACTIVE_LOOKUP_PARAMS = { status: 'active' } as const; + export function createOptionLoader( getMany: (config: { params: Record }) => Promise<{ data?: unknown }>, + extraParams?: Record, ): LoadOptionsFn { return async (search, page) => { const result = await getMany({ - params: { search, page, limit: 20 }, + params: { search, page, limit: 20, ...extraParams }, }); const rows = (result.data as { data?: T[]; meta?: { totalPages?: number } })?.data ?? []; const totalPages = (result.data as { meta?: { totalPages?: number } })?.meta?.totalPages ?? 1; diff --git a/apps/web/src/apps/main/modules/field/shared/load-branch-options.ts b/apps/web/src/apps/main/modules/field/shared/load-branch-options.ts index 4221cd6..f224b82 100644 --- a/apps/web/src/apps/main/modules/field/shared/load-branch-options.ts +++ b/apps/web/src/apps/main/modules/field/shared/load-branch-options.ts @@ -1,5 +1,8 @@ import { branchesDataService } from '../../configuration/branches/domain/factories'; import type { BranchEntity } from '../../configuration/branches/domain/entities'; -import { createOptionLoader } from './create-option-loader'; +import { ACTIVE_LOOKUP_PARAMS, createOptionLoader } from './create-option-loader'; -export const loadBranchOptions = createOptionLoader((config) => branchesDataService.getMany(config)); +export const loadBranchOptions = createOptionLoader( + (config) => branchesDataService.getMany(config), + ACTIVE_LOOKUP_PARAMS, +); diff --git a/apps/web/src/apps/main/modules/field/shared/load-customer-options.ts b/apps/web/src/apps/main/modules/field/shared/load-customer-options.ts index 3b76747..7653511 100644 --- a/apps/web/src/apps/main/modules/field/shared/load-customer-options.ts +++ b/apps/web/src/apps/main/modules/field/shared/load-customer-options.ts @@ -1,5 +1,8 @@ import { customersDataService } from '../../configuration/customers/domain/factories'; import type { CustomerEntity } from '../../configuration/customers/domain/entities'; -import { createOptionLoader } from './create-option-loader'; +import { ACTIVE_LOOKUP_PARAMS, createOptionLoader } from './create-option-loader'; -export const loadCustomerOptions = createOptionLoader((config) => customersDataService.getMany(config)); +export const loadCustomerOptions = createOptionLoader( + (config) => customersDataService.getMany(config), + ACTIVE_LOOKUP_PARAMS, +); diff --git a/apps/web/src/apps/main/modules/field/shared/load-employee-options.ts b/apps/web/src/apps/main/modules/field/shared/load-employee-options.ts index b744d25..9f8000e 100644 --- a/apps/web/src/apps/main/modules/field/shared/load-employee-options.ts +++ b/apps/web/src/apps/main/modules/field/shared/load-employee-options.ts @@ -6,7 +6,7 @@ import { salesEmployeesDataService, } from '../../configuration/employees/domain/factories'; import type { EmployeeEntity } from '../../configuration/employees/domain/entities'; -import { createOptionLoader } from './create-option-loader'; +import { ACTIVE_LOOKUP_PARAMS, createOptionLoader } from './create-option-loader'; function employeeServiceForPurpose(purpose?: FieldPurpose) { if (purpose === 'sales') { @@ -20,7 +20,7 @@ function employeeServiceForPurpose(purpose?: FieldPurpose) { export function loadEmployeeOptionsForPurpose(purpose?: FieldPurpose): LoadOptionsFn { const service = employeeServiceForPurpose(purpose); - return createOptionLoader((config) => service.getMany(config)); + return createOptionLoader((config) => service.getMany(config), ACTIVE_LOOKUP_PARAMS); } export const loadSalesEmployeeOptions = loadEmployeeOptionsForPurpose('sales'); diff --git a/apps/web/src/apps/main/modules/sales/shared/form-general.tsx b/apps/web/src/apps/main/modules/sales/shared/form-general.tsx index 2c7d218..32ae810 100644 --- a/apps/web/src/apps/main/modules/sales/shared/form-general.tsx +++ b/apps/web/src/apps/main/modules/sales/shared/form-general.tsx @@ -5,6 +5,7 @@ import { loadBranchOptions } from '../../field/shared/load-branch-options'; import { loadCustomerOptions } from '../../field/shared/load-customer-options'; import { loadDivisionOptions } from '../../configuration/shared/load-division-options'; import { relationLabel } from '../../field/shared/relation-label'; +import { customerLocationFromSelection } from './sales-document.mapper'; import type { EmployeeEntity } from '../../configuration/employees/domain/entities'; import type { BranchEntity } from '../../configuration/branches/domain/entities'; import type { DivisionEntity } from '../../configuration/divisions/domain/entities'; @@ -86,6 +87,13 @@ export function FormGeneral() { loadOptions={loadCustomerOptions} defaultOptions={customer ? [customer] : []} renderLabel={relationLabel} + onSelect={(value) => { + const location = customerLocationFromSelection(Array.isArray(value) ? value[0] : value); + if (!location) return; + formControl.setValue('address', location.address, { shouldDirty: true, shouldValidate: true }); + formControl.setValue('latitude', location.latitude, { shouldDirty: true, shouldValidate: true }); + formControl.setValue('longitude', location.longitude, { shouldDirty: true, shouldValidate: true }); + }} /> diff --git a/apps/web/src/apps/main/modules/sales/shared/form-products.tsx b/apps/web/src/apps/main/modules/sales/shared/form-products.tsx index 371fc5d..43b4da3 100644 --- a/apps/web/src/apps/main/modules/sales/shared/form-products.tsx +++ b/apps/web/src/apps/main/modules/sales/shared/form-products.tsx @@ -14,6 +14,7 @@ import { useFieldArray, useWatch } from '@repo/ui/form'; import { Plus, Trash2 } from 'lucide-react'; import { CurrencyUtils } from '@repo/utils'; import { loadProductOptions } from './load-product-options'; +import { excludeProductIds, selectedProductIdsExceptLine } from './sales-line-options'; import { relationLabel } from '../../field/shared/relation-label'; import type { ProductEntity } from '../../configuration/products/domain/entities'; @@ -58,6 +59,7 @@ export function FormProducts() { {fields.map((field, index) => { const line = products[index]; + const excludedIds = new Set(selectedProductIdsExceptLine(products, index)); return ( @@ -67,7 +69,11 @@ export function FormProducts() { valueKey="id" labelKey="name" searchable - loadOptions={loadProductOptions} + loadOptions={async (search, page, prevOptions) => { + const result = await loadProductOptions(search, page, prevOptions); + return { ...result, options: excludeProductIds(result.options, excludedIds) }; + }} + filterOption={(item) => item.id == null || !excludedIds.has(String(item.id))} defaultOptions={line?.product ? [line.product] : []} renderLabel={relationLabel} /> diff --git a/apps/web/src/apps/main/modules/sales/shared/load-product-options.ts b/apps/web/src/apps/main/modules/sales/shared/load-product-options.ts index 62d9272..b751096 100644 --- a/apps/web/src/apps/main/modules/sales/shared/load-product-options.ts +++ b/apps/web/src/apps/main/modules/sales/shared/load-product-options.ts @@ -1,5 +1,8 @@ import { productsDataService } from '../../configuration/products/domain/factories'; import type { ProductEntity } from '../../configuration/products/domain/entities'; -import { createOptionLoader } from '../../field/shared/create-option-loader'; +import { ACTIVE_LOOKUP_PARAMS, createOptionLoader } from '../../field/shared/create-option-loader'; -export const loadProductOptions = createOptionLoader((config) => productsDataService.getMany(config)); +export const loadProductOptions = createOptionLoader( + (config) => productsDataService.getMany(config), + ACTIVE_LOOKUP_PARAMS, +); diff --git a/apps/web/src/apps/main/modules/sales/shared/sales-document.mapper.test.ts b/apps/web/src/apps/main/modules/sales/shared/sales-document.mapper.test.ts index ce5d1ee..49ddf72 100644 --- a/apps/web/src/apps/main/modules/sales/shared/sales-document.mapper.test.ts +++ b/apps/web/src/apps/main/modules/sales/shared/sales-document.mapper.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { + customerLocationFromSelection, mapSalesDocumentFromDto, salesRequestToFormValues, toSalesFilterPayload, @@ -111,6 +112,29 @@ describe('sales document mapper', () => { }); }); + it('copies customer address and coordinates when a customer is picked', () => { + expect( + customerLocationFromSelection({ + address: 'Jl Gatot Subroto No 8', + latitude: -6.2, + longitude: 106.8, + }), + ).toEqual({ + address: 'Jl Gatot Subroto No 8', + latitude: -6.2, + longitude: 106.8, + }); + }); + + it('defaults missing customer location fields instead of leaving the previous customer', () => { + expect(customerLocationFromSelection({ address: 'Depot A' })).toEqual({ + address: 'Depot A', + latitude: null, + longitude: null, + }); + expect(customerLocationFromSelection(null)).toBeNull(); + }); + it('copies nested lookups onto form values for edit', () => { const formValues = salesRequestToFormValues(mapSalesDocumentFromDto(nestedResponse)); diff --git a/apps/web/src/apps/main/modules/sales/shared/sales-document.mapper.ts b/apps/web/src/apps/main/modules/sales/shared/sales-document.mapper.ts index e2eaa11..26b1ed5 100644 --- a/apps/web/src/apps/main/modules/sales/shared/sales-document.mapper.ts +++ b/apps/web/src/apps/main/modules/sales/shared/sales-document.mapper.ts @@ -8,6 +8,24 @@ import type { SalesLineEntity, } from './sales-document.entity'; +export function customerLocationFromSelection( + customer: + | { + address?: string | null; + latitude?: number | null; + longitude?: number | null; + } + | null + | undefined, +) { + if (!customer) return null; + return { + address: customer.address ?? '', + latitude: customer.latitude ?? null, + longitude: customer.longitude ?? null, + }; +} + export function relationId(value: unknown): string | undefined { if (value && typeof value === 'object' && 'id' in value) { const id = (value as { id?: unknown }).id; diff --git a/apps/web/src/apps/main/modules/sales/shared/sales-line-options.test.ts b/apps/web/src/apps/main/modules/sales/shared/sales-line-options.test.ts new file mode 100644 index 0000000..30be0b0 --- /dev/null +++ b/apps/web/src/apps/main/modules/sales/shared/sales-line-options.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { excludeProductIds, selectedProductIdsExceptLine } from './sales-line-options'; + +describe('sales line product options', () => { + const lines = [ + { product: { id: 'prd-1' } }, + { product: { id: 'prd-2' } }, + { product: null }, + ]; + + it('collects product ids from other lines only', () => { + expect(selectedProductIdsExceptLine(lines, 0)).toEqual(['prd-2']); + expect(selectedProductIdsExceptLine(lines, 2)).toEqual(['prd-1', 'prd-2']); + }); + + it('hides products already used on other lines', () => { + const options = [{ id: 'prd-1' }, { id: 'prd-2' }, { id: 'prd-3' }]; + expect(excludeProductIds(options, new Set(selectedProductIdsExceptLine(lines, 2)))).toEqual([{ id: 'prd-3' }]); + }); + + it('keeps the current line product available', () => { + const options = [{ id: 'prd-1' }, { id: 'prd-3' }]; + expect(excludeProductIds(options, new Set(selectedProductIdsExceptLine(lines, 0)))).toEqual([ + { id: 'prd-1' }, + { id: 'prd-3' }, + ]); + }); +}); diff --git a/apps/web/src/apps/main/modules/sales/shared/sales-line-options.ts b/apps/web/src/apps/main/modules/sales/shared/sales-line-options.ts new file mode 100644 index 0000000..01d0f94 --- /dev/null +++ b/apps/web/src/apps/main/modules/sales/shared/sales-line-options.ts @@ -0,0 +1,18 @@ +export function selectedProductIdsExceptLine( + lines: Array<{ product?: { id?: string | number } | null } | null | undefined>, + lineIndex: number, +): string[] { + return lines.flatMap((line, index) => { + const id = line?.product?.id; + if (index === lineIndex || id == null || id === '') return []; + return [String(id)]; + }); +} + +export function excludeProductIds( + options: T[], + excludeIds: ReadonlySet, +): T[] { + if (excludeIds.size === 0) return options; + return options.filter((option) => option.id == null || !excludeIds.has(String(option.id))); +}