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.
This commit is contained in:
shancheas
2026-08-31 15:05:48 +07:00
parent 25f1b79042
commit 5a6b109a89
13 changed files with 161 additions and 19 deletions
@@ -1,12 +1,8 @@
import type { LoadOptionsFn } from '@repo/ui/form';
import { divisionsDataService } from '../divisions/domain/factories'; import { divisionsDataService } from '../divisions/domain/factories';
import type { DivisionEntity } from '../divisions/domain/entities'; import type { DivisionEntity } from '../divisions/domain/entities';
import { ACTIVE_LOOKUP_PARAMS, createOptionLoader } from '../../field/shared/create-option-loader';
export const loadDivisionOptions: LoadOptionsFn<DivisionEntity> = async (search, page) => { export const loadDivisionOptions = createOptionLoader<DivisionEntity>(
const result = await divisionsDataService.getMany({ (config) => divisionsDataService.getMany(config),
params: { search, page, limit: 20 }, ACTIVE_LOOKUP_PARAMS,
}); );
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 };
};
@@ -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);
});
});
@@ -1,11 +1,14 @@
import type { LoadOptionsFn } from '@repo/ui/form'; import type { LoadOptionsFn } from '@repo/ui/form';
export const ACTIVE_LOOKUP_PARAMS = { status: 'active' } as const;
export function createOptionLoader<T>( export function createOptionLoader<T>(
getMany: (config: { params: Record<string, unknown> }) => Promise<{ data?: unknown }>, getMany: (config: { params: Record<string, unknown> }) => Promise<{ data?: unknown }>,
extraParams?: Record<string, unknown>,
): LoadOptionsFn<T> { ): LoadOptionsFn<T> {
return async (search, page) => { return async (search, page) => {
const result = await getMany({ 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 rows = (result.data as { data?: T[]; meta?: { totalPages?: number } })?.data ?? [];
const totalPages = (result.data as { meta?: { totalPages?: number } })?.meta?.totalPages ?? 1; const totalPages = (result.data as { meta?: { totalPages?: number } })?.meta?.totalPages ?? 1;
@@ -1,5 +1,8 @@
import { branchesDataService } from '../../configuration/branches/domain/factories'; import { branchesDataService } from '../../configuration/branches/domain/factories';
import type { BranchEntity } from '../../configuration/branches/domain/entities'; 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<BranchEntity>((config) => branchesDataService.getMany(config)); export const loadBranchOptions = createOptionLoader<BranchEntity>(
(config) => branchesDataService.getMany(config),
ACTIVE_LOOKUP_PARAMS,
);
@@ -1,5 +1,8 @@
import { customersDataService } from '../../configuration/customers/domain/factories'; import { customersDataService } from '../../configuration/customers/domain/factories';
import type { CustomerEntity } from '../../configuration/customers/domain/entities'; 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<CustomerEntity>((config) => customersDataService.getMany(config)); export const loadCustomerOptions = createOptionLoader<CustomerEntity>(
(config) => customersDataService.getMany(config),
ACTIVE_LOOKUP_PARAMS,
);
@@ -6,7 +6,7 @@ import {
salesEmployeesDataService, salesEmployeesDataService,
} from '../../configuration/employees/domain/factories'; } from '../../configuration/employees/domain/factories';
import type { EmployeeEntity } from '../../configuration/employees/domain/entities'; 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) { function employeeServiceForPurpose(purpose?: FieldPurpose) {
if (purpose === 'sales') { if (purpose === 'sales') {
@@ -20,7 +20,7 @@ function employeeServiceForPurpose(purpose?: FieldPurpose) {
export function loadEmployeeOptionsForPurpose(purpose?: FieldPurpose): LoadOptionsFn<EmployeeEntity> { export function loadEmployeeOptionsForPurpose(purpose?: FieldPurpose): LoadOptionsFn<EmployeeEntity> {
const service = employeeServiceForPurpose(purpose); const service = employeeServiceForPurpose(purpose);
return createOptionLoader<EmployeeEntity>((config) => service.getMany(config)); return createOptionLoader<EmployeeEntity>((config) => service.getMany(config), ACTIVE_LOOKUP_PARAMS);
} }
export const loadSalesEmployeeOptions = loadEmployeeOptionsForPurpose('sales'); export const loadSalesEmployeeOptions = loadEmployeeOptionsForPurpose('sales');
@@ -5,6 +5,7 @@ import { loadBranchOptions } from '../../field/shared/load-branch-options';
import { loadCustomerOptions } from '../../field/shared/load-customer-options'; import { loadCustomerOptions } from '../../field/shared/load-customer-options';
import { loadDivisionOptions } from '../../configuration/shared/load-division-options'; import { loadDivisionOptions } from '../../configuration/shared/load-division-options';
import { relationLabel } from '../../field/shared/relation-label'; import { relationLabel } from '../../field/shared/relation-label';
import { customerLocationFromSelection } from './sales-document.mapper';
import type { EmployeeEntity } from '../../configuration/employees/domain/entities'; import type { EmployeeEntity } from '../../configuration/employees/domain/entities';
import type { BranchEntity } from '../../configuration/branches/domain/entities'; import type { BranchEntity } from '../../configuration/branches/domain/entities';
import type { DivisionEntity } from '../../configuration/divisions/domain/entities'; import type { DivisionEntity } from '../../configuration/divisions/domain/entities';
@@ -86,6 +87,13 @@ export function FormGeneral() {
loadOptions={loadCustomerOptions} loadOptions={loadCustomerOptions}
defaultOptions={customer ? [customer] : []} defaultOptions={customer ? [customer] : []}
renderLabel={relationLabel} 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 });
}}
/> />
</SimpleGrid> </SimpleGrid>
</Box> </Box>
@@ -14,6 +14,7 @@ import { useFieldArray, useWatch } from '@repo/ui/form';
import { Plus, Trash2 } from 'lucide-react'; import { Plus, Trash2 } from 'lucide-react';
import { CurrencyUtils } from '@repo/utils'; import { CurrencyUtils } from '@repo/utils';
import { loadProductOptions } from './load-product-options'; import { loadProductOptions } from './load-product-options';
import { excludeProductIds, selectedProductIdsExceptLine } from './sales-line-options';
import { relationLabel } from '../../field/shared/relation-label'; import { relationLabel } from '../../field/shared/relation-label';
import type { ProductEntity } from '../../configuration/products/domain/entities'; import type { ProductEntity } from '../../configuration/products/domain/entities';
@@ -58,6 +59,7 @@ export function FormProducts() {
<Table.Tbody> <Table.Tbody>
{fields.map((field, index) => { {fields.map((field, index) => {
const line = products[index]; const line = products[index];
const excludedIds = new Set(selectedProductIdsExceptLine(products, index));
return ( return (
<Table.Tr key={field.id}> <Table.Tr key={field.id}>
<Table.Td miw={240}> <Table.Td miw={240}>
@@ -67,7 +69,11 @@ export function FormProducts() {
valueKey="id" valueKey="id"
labelKey="name" labelKey="name"
searchable 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] : []} defaultOptions={line?.product ? [line.product] : []}
renderLabel={relationLabel} renderLabel={relationLabel}
/> />
@@ -1,5 +1,8 @@
import { productsDataService } from '../../configuration/products/domain/factories'; import { productsDataService } from '../../configuration/products/domain/factories';
import type { ProductEntity } from '../../configuration/products/domain/entities'; 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<ProductEntity>((config) => productsDataService.getMany(config)); export const loadProductOptions = createOptionLoader<ProductEntity>(
(config) => productsDataService.getMany(config),
ACTIVE_LOOKUP_PARAMS,
);
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import {
customerLocationFromSelection,
mapSalesDocumentFromDto, mapSalesDocumentFromDto,
salesRequestToFormValues, salesRequestToFormValues,
toSalesFilterPayload, 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', () => { it('copies nested lookups onto form values for edit', () => {
const formValues = salesRequestToFormValues(mapSalesDocumentFromDto(nestedResponse)); const formValues = salesRequestToFormValues(mapSalesDocumentFromDto(nestedResponse));
@@ -8,6 +8,24 @@ import type {
SalesLineEntity, SalesLineEntity,
} from './sales-document.entity'; } 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 { export function relationId(value: unknown): string | undefined {
if (value && typeof value === 'object' && 'id' in value) { if (value && typeof value === 'object' && 'id' in value) {
const id = (value as { id?: unknown }).id; const id = (value as { id?: unknown }).id;
@@ -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' },
]);
});
});
@@ -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<T extends { id?: string | number }>(
options: T[],
excludeIds: ReadonlySet<string>,
): T[] {
if (excludeIds.size === 0) return options;
return options.filter((option) => option.id == null || !excludeIds.has(String(option.id)));
}