feat: enhance plan form previews with customer and document displays
- Added `FormCustomersPreview` and `FormDocumentsPreview` components to display customer and document information in the plan form. - Implemented customer location handling in the validation schema to ensure proper data representation. - Updated the `FormGeneral` component to integrate the new previews, improving user experience during plan creation. - Added unit tests for new functionalities to ensure reliability and correctness. - Enhanced language support for preview sections in both English and Indonesian. These changes improve the plan form's usability by providing clear previews of customers and documents, streamlining the planning process.
This commit is contained in:
@@ -23,4 +23,18 @@ describe('createPlanSchema', () => {
|
|||||||
it('rejects empty customers', () => {
|
it('rejects empty customers', () => {
|
||||||
expect(schema.safeParse({ ...valid, customers: [] }).success).toBe(false);
|
expect(schema.safeParse({ ...valid, customers: [] }).success).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps customer location fields for the form preview', () => {
|
||||||
|
const result = schema.safeParse({
|
||||||
|
...valid,
|
||||||
|
customers: [{ id: 'cus-1', name: 'Acme', address: 'Jl Sudirman', latitude: -6.2, longitude: 106.8 }],
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
if (!result.success) return;
|
||||||
|
expect(result.data.customers?.[0]).toMatchObject({
|
||||||
|
address: 'Jl Sudirman',
|
||||||
|
latitude: -6.2,
|
||||||
|
longitude: 106.8,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
const relationSchema = z.object({
|
|
||||||
id: z.string(),
|
const relationSchema = z
|
||||||
code: z.string().optional(),
|
.object({
|
||||||
name: z.string().optional(),
|
id: z.string(),
|
||||||
});
|
code: z.string().optional(),
|
||||||
|
name: z.string().optional(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
export const createPlanSchema = (t: (key: string) => string) => {
|
export const createPlanSchema = (t: (key: string) => string) => {
|
||||||
return z
|
return z
|
||||||
|
|||||||
+73
@@ -0,0 +1,73 @@
|
|||||||
|
import { Box, Paper, Stack, Table, Text } from '@repo/ui/components';
|
||||||
|
import { RouteMap } from '@repo/ui/map';
|
||||||
|
import { useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
|
||||||
|
import { customersDataService } from '../../../../../configuration/customers/domain/factories';
|
||||||
|
import type { CustomerEntity } from '../../../../../configuration/customers/domain/entities';
|
||||||
|
import type { BranchEntity } from '../../../../../configuration/branches/domain/entities';
|
||||||
|
import { needsCustomerHydration, toPlanTrackGeometry, unwrapEntity } from './plan-form-preview';
|
||||||
|
import { useHydratedRecords } from './use-hydrated-records';
|
||||||
|
|
||||||
|
async function fetchCustomer(id: string): Promise<CustomerEntity | null> {
|
||||||
|
return unwrapEntity<CustomerEntity>(await customersDataService.getOne(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormCustomersPreview({
|
||||||
|
customers,
|
||||||
|
startBranch,
|
||||||
|
endBranch,
|
||||||
|
}: {
|
||||||
|
customers: CustomerEntity[];
|
||||||
|
startBranch?: BranchEntity | null;
|
||||||
|
endBranch?: BranchEntity | null;
|
||||||
|
}) {
|
||||||
|
const { t } = useEnterpriseModuleTranslationContext();
|
||||||
|
const { records: hydrated } = useHydratedRecords(customers, fetchCustomer, needsCustomerHydration);
|
||||||
|
const geometry = toPlanTrackGeometry(startBranch, hydrated, endBranch);
|
||||||
|
|
||||||
|
if (hydrated.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Paper withBorder shadow="sm" radius="md" p="xl">
|
||||||
|
<Text fw={600} mb="md">
|
||||||
|
{t('section_preview_customers')}
|
||||||
|
</Text>
|
||||||
|
<Box style={{ overflowX: 'auto' }}>
|
||||||
|
<Table striped>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>{t('common:fields.code')}</Table.Th>
|
||||||
|
<Table.Th>{t('common:fields.name')}</Table.Th>
|
||||||
|
<Table.Th>{t('common:fields.phone')}</Table.Th>
|
||||||
|
<Table.Th>{t('common:fields.address')}</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{hydrated.map((customer, index) => (
|
||||||
|
<Table.Tr key={String(customer.id ?? index)}>
|
||||||
|
<Table.Td>{customer.code || '-'}</Table.Td>
|
||||||
|
<Table.Td>{customer.name || '-'}</Table.Td>
|
||||||
|
<Table.Td>{customer.phone || '-'}</Table.Td>
|
||||||
|
<Table.Td>{customer.address || '-'}</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper withBorder shadow="sm" radius="md" p="xl">
|
||||||
|
<Text fw={600} mb="md">
|
||||||
|
{t('section_preview_track')}
|
||||||
|
</Text>
|
||||||
|
{geometry ? (
|
||||||
|
<RouteMap geometry={geometry} />
|
||||||
|
) : (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t('empty_route')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
+190
@@ -0,0 +1,190 @@
|
|||||||
|
import {
|
||||||
|
Box,
|
||||||
|
FieldValue,
|
||||||
|
Paper,
|
||||||
|
RenderCurrency,
|
||||||
|
RenderDate,
|
||||||
|
SimpleGrid,
|
||||||
|
Stack,
|
||||||
|
StatusBadge,
|
||||||
|
Table,
|
||||||
|
Text,
|
||||||
|
} from '@repo/ui/components';
|
||||||
|
import { useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
|
||||||
|
import { formatRupiah } from '@repo/utils';
|
||||||
|
import { salesInvoicesDataService } from '../../../../../sales/invoices/domain/factories';
|
||||||
|
import { packingSlipsModuleDataService } from '../../../../packing-slips/domain/factories';
|
||||||
|
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 { useHydratedRecords } from './use-hydrated-records';
|
||||||
|
|
||||||
|
type PreviewDocument = SalesDocumentEntity & {
|
||||||
|
balance?: string | null;
|
||||||
|
salesOrder?: { id: string; code?: string; name?: string } | null;
|
||||||
|
packingSlip?: { id: string; code?: string; name?: string } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fetchInvoice(id: string): Promise<SalesInvoiceEntity | null> {
|
||||||
|
return unwrapEntity<SalesInvoiceEntity>(await salesInvoicesDataService.getOne(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPackingSlip(id: string): Promise<PackingSlipEntity | null> {
|
||||||
|
return unwrapEntity<PackingSlipEntity>(await packingSlipsModuleDataService.getOne(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormDocumentsPreview({
|
||||||
|
kind,
|
||||||
|
items,
|
||||||
|
}: {
|
||||||
|
kind: 'invoice' | 'packingSlip';
|
||||||
|
items: Array<SalesInvoiceEntity | PackingSlipEntity>;
|
||||||
|
}) {
|
||||||
|
const { t } = useEnterpriseModuleTranslationContext();
|
||||||
|
const { records: hydratedInvoices, pending: invoicesPending } = useHydratedRecords(
|
||||||
|
kind === 'invoice' ? (items as SalesInvoiceEntity[]) : [],
|
||||||
|
fetchInvoice,
|
||||||
|
);
|
||||||
|
const { records: hydratedSlips, pending: slipsPending } = useHydratedRecords(
|
||||||
|
kind === 'packingSlip' ? (items as PackingSlipEntity[]) : [],
|
||||||
|
fetchPackingSlip,
|
||||||
|
);
|
||||||
|
const hydrated = kind === 'invoice' ? hydratedInvoices : hydratedSlips;
|
||||||
|
const pending = kind === 'invoice' ? invoicesPending : slipsPending;
|
||||||
|
if (hydrated.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper withBorder shadow="sm" radius="md" p="xl">
|
||||||
|
<Text fw={600} mb="md">
|
||||||
|
{kind === 'invoice' ? t('section_preview_invoices') : t('section_preview_packing_slips')}
|
||||||
|
</Text>
|
||||||
|
<Stack gap="lg">
|
||||||
|
{hydrated.map((document, index) => (
|
||||||
|
<DocumentPreviewCard
|
||||||
|
key={String(document.id ?? index)}
|
||||||
|
document={document}
|
||||||
|
showBalance={kind === 'invoice'}
|
||||||
|
pending={pending}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DocumentPreviewCard({
|
||||||
|
document,
|
||||||
|
showBalance,
|
||||||
|
pending,
|
||||||
|
}: {
|
||||||
|
document: PreviewDocument;
|
||||||
|
showBalance: boolean;
|
||||||
|
pending: boolean;
|
||||||
|
}) {
|
||||||
|
const { t } = useEnterpriseModuleTranslationContext();
|
||||||
|
const lines = document.products ?? [];
|
||||||
|
const total = salesLinesTotal(lines);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Text fw={600} mb="sm">
|
||||||
|
{document.code || relationLabel(document) || document.id}
|
||||||
|
</Text>
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="md" mb="md">
|
||||||
|
<FieldValue
|
||||||
|
label={t('common:fields.date')}
|
||||||
|
value={document.date}
|
||||||
|
render={(val) => <RenderDate value={typeof val === 'string' || typeof val === 'number' ? val : null} />}
|
||||||
|
/>
|
||||||
|
<FieldValue
|
||||||
|
label={t('common:fields.customer')}
|
||||||
|
value={relationLabel(document.customer) || document.customerId}
|
||||||
|
/>
|
||||||
|
<FieldValue
|
||||||
|
label={t('common:fields.status')}
|
||||||
|
value={document.status}
|
||||||
|
render={(val) => <StatusBadge status={String(val ?? '')} />}
|
||||||
|
/>
|
||||||
|
{showBalance ? (
|
||||||
|
<FieldValue
|
||||||
|
label={t('common:fields.balance')}
|
||||||
|
value={document.balance}
|
||||||
|
render={(val) => <RenderCurrency value={val as string | number | null} />}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FieldValue
|
||||||
|
label={t('common:fields.salesOrder')}
|
||||||
|
value={relationLabel(document.salesOrder) || document.salesOrder?.id}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</SimpleGrid>
|
||||||
|
{showBalance && document.salesOrder ? (
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="md" mb="md">
|
||||||
|
<FieldValue label={t('common:fields.salesOrder')} value={relationLabel(document.salesOrder)} />
|
||||||
|
{document.packingSlip ? (
|
||||||
|
<FieldValue label={t('common:fields.packingSlip')} value={relationLabel(document.packingSlip)} />
|
||||||
|
) : null}
|
||||||
|
</SimpleGrid>
|
||||||
|
) : null}
|
||||||
|
<DocumentProductsTable lines={lines} total={total} pending={pending} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DocumentProductsTable({
|
||||||
|
lines,
|
||||||
|
total,
|
||||||
|
pending,
|
||||||
|
}: {
|
||||||
|
lines: SalesLineEntity[];
|
||||||
|
total: number;
|
||||||
|
pending: boolean;
|
||||||
|
}) {
|
||||||
|
const { t } = useEnterpriseModuleTranslationContext();
|
||||||
|
return (
|
||||||
|
<Box style={{ overflowX: 'auto' }}>
|
||||||
|
<Table striped>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>{t('common:fields.product')}</Table.Th>
|
||||||
|
<Table.Th ta="right">{t('common:fields.quantity')}</Table.Th>
|
||||||
|
<Table.Th ta="right">{t('common:fields.price')}</Table.Th>
|
||||||
|
<Table.Th ta="right">{t('common:fields.lineTotal')}</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{lines.length === 0 ? (
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td colSpan={4}>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{pending ? t('preview_loading') : t('empty_products')}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
) : (
|
||||||
|
lines.map((line, index) => {
|
||||||
|
const qty = Number(line.quantity);
|
||||||
|
const price = Number(line.price);
|
||||||
|
const lineTotal = Number.isFinite(qty) && Number.isFinite(price) ? qty * price : 0;
|
||||||
|
return (
|
||||||
|
<Table.Tr key={line.id ?? `${line.productId}-${index}`}>
|
||||||
|
<Table.Td>{relationLabel(line.product) || line.productId}</Table.Td>
|
||||||
|
<Table.Td ta="right">{line.quantity}</Table.Td>
|
||||||
|
<Table.Td ta="right">{line.price ? formatRupiah(line.price) : '-'}</Table.Td>
|
||||||
|
<Table.Td ta="right">{formatRupiah(lineTotal)}</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
{!pending || lines.length > 0 ? (
|
||||||
|
<Text fw={600} ta="right" mt="md">
|
||||||
|
{t('common:fields.total')}: {formatRupiah(total)}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
+107
-87
@@ -1,4 +1,4 @@
|
|||||||
import { Box, FieldAsyncSelect, FieldDatePicker, Paper, SimpleGrid, Text } from '@repo/ui/components';
|
import { Box, FieldAsyncSelect, FieldDatePicker, Paper, SimpleGrid, Stack, Text } from '@repo/ui/components';
|
||||||
import {
|
import {
|
||||||
useEnterpriseModuleTranslationContext,
|
useEnterpriseModuleTranslationContext,
|
||||||
useFormPageContext,
|
useFormPageContext,
|
||||||
@@ -8,12 +8,23 @@ import { purposeFromModuleKey } from '../../../../../../../../core/domain/field-
|
|||||||
import { loadLogisticsEmployeeOptions, loadSalesEmployeeOptions } from '../../../../shared/load-employee-options';
|
import { loadLogisticsEmployeeOptions, loadSalesEmployeeOptions } from '../../../../shared/load-employee-options';
|
||||||
import { loadBranchOptions } from '../../../../shared/load-branch-options';
|
import { loadBranchOptions } from '../../../../shared/load-branch-options';
|
||||||
import { loadCustomerOptions } from '../../../../shared/load-customer-options';
|
import { loadCustomerOptions } from '../../../../shared/load-customer-options';
|
||||||
import { loadSalesInvoiceOptions, loadPackingSlipOptions } from '../../../../shared/lookup.factories';
|
import { createOptionLoader } from '../../../../shared/create-option-loader';
|
||||||
import { relationLabel } from '../../../../shared/relation-label';
|
import { relationLabel } from '../../../../shared/relation-label';
|
||||||
|
import { salesInvoicesDataService } from '../../../../../sales/invoices/domain/factories';
|
||||||
|
import { packingSlipsModuleDataService } from '../../../../packing-slips/domain/factories';
|
||||||
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 { CustomerEntity } from '../../../../../configuration/customers/domain/entities';
|
import type { CustomerEntity } from '../../../../../configuration/customers/domain/entities';
|
||||||
import type { LookupEntity } from '../../../../shared/lookup.entity';
|
import type { SalesInvoiceEntity } from '../../../../../sales/invoices/domain/entities';
|
||||||
|
import type { PackingSlipEntity } from '../../../../packing-slips/domain/entities';
|
||||||
|
import { documentOptionLabel } from './plan-form-preview';
|
||||||
|
import { FormCustomersPreview } from './form-customers-preview';
|
||||||
|
import { FormDocumentsPreview } from './form-documents-preview';
|
||||||
|
|
||||||
|
const loadInvoiceOptions = createOptionLoader<SalesInvoiceEntity>((config) =>
|
||||||
|
salesInvoicesDataService.getMany(config),
|
||||||
|
);
|
||||||
|
const loadSlipOptions = createOptionLoader<PackingSlipEntity>((config) => packingSlipsModuleDataService.getMany(config));
|
||||||
|
|
||||||
export function FormGeneral() {
|
export function FormGeneral() {
|
||||||
const { formControl } = useFormPageContext();
|
const { formControl } = useFormPageContext();
|
||||||
@@ -21,102 +32,111 @@ export function FormGeneral() {
|
|||||||
const { config } = useEnterpriseModuleConfigContext();
|
const { config } = useEnterpriseModuleConfigContext();
|
||||||
const purpose = purposeFromModuleKey(config.moduleKey);
|
const purpose = purposeFromModuleKey(config.moduleKey);
|
||||||
const employee = formControl.watch('employee');
|
const employee = formControl.watch('employee');
|
||||||
const startBranch = formControl.watch('startBranch');
|
const startBranch = formControl.watch('startBranch') as BranchEntity | null | undefined;
|
||||||
const endBranch = formControl.watch('endBranch');
|
const endBranch = formControl.watch('endBranch') as BranchEntity | null | undefined;
|
||||||
const customers = formControl.watch('customers') ?? [];
|
const customers = (formControl.watch('customers') ?? []) as CustomerEntity[];
|
||||||
const invoices = formControl.watch('invoices') ?? [];
|
const invoices = (formControl.watch('invoices') ?? []) as SalesInvoiceEntity[];
|
||||||
const packingSlips = formControl.watch('packingSlips') ?? [];
|
const packingSlips = (formControl.watch('packingSlips') ?? []) as PackingSlipEntity[];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper withBorder shadow="sm" radius="md" p="xl">
|
<Stack gap="md">
|
||||||
<Text fw={600} mb="md">
|
<Paper withBorder shadow="sm" radius="md" p="xl">
|
||||||
{t('section_general')}
|
<Text fw={600} mb="md">
|
||||||
</Text>
|
{t('section_general')}
|
||||||
<Box>
|
</Text>
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
<Box>
|
||||||
<FieldAsyncSelect<EmployeeEntity>
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||||
control={formControl.control}
|
<FieldAsyncSelect<EmployeeEntity>
|
||||||
name="employee"
|
|
||||||
label={t('common:fields.employee')}
|
|
||||||
valueKey="id"
|
|
||||||
labelKey="name"
|
|
||||||
required
|
|
||||||
searchable
|
|
||||||
loadOptions={purpose === 'sales' ? loadSalesEmployeeOptions : loadLogisticsEmployeeOptions}
|
|
||||||
defaultOptions={employee ? [employee] : []}
|
|
||||||
renderLabel={relationLabel}
|
|
||||||
/>
|
|
||||||
<FieldDatePicker control={formControl.control} name="date" label={t('common:fields.date')} required />
|
|
||||||
<FieldAsyncSelect<BranchEntity>
|
|
||||||
control={formControl.control}
|
|
||||||
name="startBranch"
|
|
||||||
label={t('common:fields.startBranch')}
|
|
||||||
valueKey="id"
|
|
||||||
labelKey="name"
|
|
||||||
required
|
|
||||||
searchable
|
|
||||||
loadOptions={loadBranchOptions}
|
|
||||||
defaultOptions={startBranch ? [startBranch] : []}
|
|
||||||
renderLabel={relationLabel}
|
|
||||||
/>
|
|
||||||
<FieldAsyncSelect<BranchEntity>
|
|
||||||
control={formControl.control}
|
|
||||||
name="endBranch"
|
|
||||||
label={t('common:fields.endBranch')}
|
|
||||||
valueKey="id"
|
|
||||||
labelKey="name"
|
|
||||||
required
|
|
||||||
searchable
|
|
||||||
loadOptions={loadBranchOptions}
|
|
||||||
defaultOptions={endBranch ? [endBranch] : []}
|
|
||||||
renderLabel={relationLabel}
|
|
||||||
/>
|
|
||||||
</SimpleGrid>
|
|
||||||
<Box mt="md">
|
|
||||||
<FieldAsyncSelect<CustomerEntity>
|
|
||||||
control={formControl.control}
|
|
||||||
name="customers"
|
|
||||||
label={t('common:fields.customers')}
|
|
||||||
valueKey="id"
|
|
||||||
labelKey="name"
|
|
||||||
required
|
|
||||||
searchable
|
|
||||||
multiple
|
|
||||||
loadOptions={loadCustomerOptions}
|
|
||||||
defaultOptions={customers}
|
|
||||||
renderLabel={relationLabel}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
<Box mt="md">
|
|
||||||
{purpose === 'sales' ? (
|
|
||||||
<FieldAsyncSelect<LookupEntity>
|
|
||||||
control={formControl.control}
|
control={formControl.control}
|
||||||
name="invoices"
|
name="employee"
|
||||||
label={t('common:fields.invoices')}
|
label={t('common:fields.employee')}
|
||||||
valueKey="id"
|
valueKey="id"
|
||||||
labelKey="code"
|
labelKey="name"
|
||||||
|
required
|
||||||
searchable
|
searchable
|
||||||
multiple
|
loadOptions={purpose === 'sales' ? loadSalesEmployeeOptions : loadLogisticsEmployeeOptions}
|
||||||
loadOptions={loadSalesInvoiceOptions}
|
defaultOptions={employee ? [employee] : []}
|
||||||
defaultOptions={invoices}
|
|
||||||
renderLabel={relationLabel}
|
renderLabel={relationLabel}
|
||||||
/>
|
/>
|
||||||
) : (
|
<FieldDatePicker control={formControl.control} name="date" label={t('common:fields.date')} required />
|
||||||
<FieldAsyncSelect<LookupEntity>
|
<FieldAsyncSelect<BranchEntity>
|
||||||
control={formControl.control}
|
control={formControl.control}
|
||||||
name="packingSlips"
|
name="startBranch"
|
||||||
label={t('common:fields.packingSlips')}
|
label={t('common:fields.startBranch')}
|
||||||
valueKey="id"
|
valueKey="id"
|
||||||
labelKey="code"
|
labelKey="name"
|
||||||
|
required
|
||||||
searchable
|
searchable
|
||||||
multiple
|
loadOptions={loadBranchOptions}
|
||||||
loadOptions={loadPackingSlipOptions}
|
defaultOptions={startBranch ? [startBranch] : []}
|
||||||
defaultOptions={packingSlips}
|
|
||||||
renderLabel={relationLabel}
|
renderLabel={relationLabel}
|
||||||
/>
|
/>
|
||||||
)}
|
<FieldAsyncSelect<BranchEntity>
|
||||||
|
control={formControl.control}
|
||||||
|
name="endBranch"
|
||||||
|
label={t('common:fields.endBranch')}
|
||||||
|
valueKey="id"
|
||||||
|
labelKey="name"
|
||||||
|
required
|
||||||
|
searchable
|
||||||
|
loadOptions={loadBranchOptions}
|
||||||
|
defaultOptions={endBranch ? [endBranch] : []}
|
||||||
|
renderLabel={relationLabel}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
<Box mt="md">
|
||||||
|
<FieldAsyncSelect<CustomerEntity>
|
||||||
|
control={formControl.control}
|
||||||
|
name="customers"
|
||||||
|
label={t('common:fields.customers')}
|
||||||
|
valueKey="id"
|
||||||
|
labelKey="name"
|
||||||
|
required
|
||||||
|
searchable
|
||||||
|
multiple
|
||||||
|
loadOptions={loadCustomerOptions}
|
||||||
|
defaultOptions={customers}
|
||||||
|
renderLabel={relationLabel}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box mt="md">
|
||||||
|
{purpose === 'sales' ? (
|
||||||
|
<FieldAsyncSelect<SalesInvoiceEntity>
|
||||||
|
control={formControl.control}
|
||||||
|
name="invoices"
|
||||||
|
label={t('common:fields.invoices')}
|
||||||
|
valueKey="id"
|
||||||
|
labelKey="code"
|
||||||
|
searchable
|
||||||
|
multiple
|
||||||
|
loadOptions={loadInvoiceOptions}
|
||||||
|
defaultOptions={invoices}
|
||||||
|
renderLabel={documentOptionLabel}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FieldAsyncSelect<PackingSlipEntity>
|
||||||
|
control={formControl.control}
|
||||||
|
name="packingSlips"
|
||||||
|
label={t('common:fields.packingSlips')}
|
||||||
|
valueKey="id"
|
||||||
|
labelKey="code"
|
||||||
|
searchable
|
||||||
|
multiple
|
||||||
|
loadOptions={loadSlipOptions}
|
||||||
|
defaultOptions={packingSlips}
|
||||||
|
renderLabel={documentOptionLabel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Paper>
|
||||||
</Paper>
|
|
||||||
|
<FormCustomersPreview customers={customers} startBranch={startBranch} endBranch={endBranch} />
|
||||||
|
{purpose === 'sales' ? (
|
||||||
|
<FormDocumentsPreview kind="invoice" items={invoices} />
|
||||||
|
) : (
|
||||||
|
<FormDocumentsPreview kind="packingSlip" items={packingSlips} />
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+108
@@ -0,0 +1,108 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
documentOptionLabel,
|
||||||
|
mergeHydrated,
|
||||||
|
needsCustomerHydration,
|
||||||
|
salesLinesTotal,
|
||||||
|
selectionIds,
|
||||||
|
toPlanTrackGeometry,
|
||||||
|
unwrapEntity,
|
||||||
|
} from './plan-form-preview';
|
||||||
|
|
||||||
|
describe('selectionIds', () => {
|
||||||
|
it('returns string ids and drops empty values', () => {
|
||||||
|
expect(selectionIds([{ id: 'a' }, { id: 2 }, {}, { id: '' }])).toEqual(['a', '2']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mergeHydrated', () => {
|
||||||
|
it('replaces selected stubs with fetched details by id', () => {
|
||||||
|
const merged = mergeHydrated(
|
||||||
|
[{ id: 'cus-1', name: 'Stub' }, { id: 'cus-2', name: 'Keep' }],
|
||||||
|
{ 'cus-1': { id: 'cus-1', name: 'Acme', address: 'Jl Sudirman' } },
|
||||||
|
);
|
||||||
|
expect(merged).toEqual([
|
||||||
|
{ id: 'cus-1', name: 'Acme', address: 'Jl Sudirman' },
|
||||||
|
{ id: 'cus-2', name: 'Keep' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('needsCustomerHydration', () => {
|
||||||
|
it('is true when only an id is present', () => {
|
||||||
|
expect(needsCustomerHydration({})).toBe(true);
|
||||||
|
expect(needsCustomerHydration({ latitude: null, longitude: null })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is false when address, phone, or coordinates are present', () => {
|
||||||
|
expect(needsCustomerHydration({ address: 'Jl Sudirman' })).toBe(false);
|
||||||
|
expect(needsCustomerHydration({ phone: '+62811' })).toBe(false);
|
||||||
|
expect(needsCustomerHydration({ latitude: -6.2, longitude: 106.8 })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('unwrapEntity', () => {
|
||||||
|
it('reads nested data envelopes from getOne', () => {
|
||||||
|
expect(unwrapEntity({ data: { data: { id: 'inv-1' } } })).toEqual({ id: 'inv-1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when the envelope is empty', () => {
|
||||||
|
expect(unwrapEntity({ data: { data: undefined } })).toBeNull();
|
||||||
|
expect(unwrapEntity(null)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('salesLinesTotal', () => {
|
||||||
|
it('sums quantity times price and ignores invalid lines', () => {
|
||||||
|
expect(
|
||||||
|
salesLinesTotal([
|
||||||
|
{ quantity: '2', price: '1000' },
|
||||||
|
{ quantity: '1', price: '500.5' },
|
||||||
|
{ quantity: 'x', price: '10' },
|
||||||
|
]),
|
||||||
|
).toBe(2500.5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toPlanTrackGeometry', () => {
|
||||||
|
it('builds a LineString from start branch, customers, and end branch', () => {
|
||||||
|
expect(
|
||||||
|
toPlanTrackGeometry(
|
||||||
|
{ latitude: -6.1, longitude: 106.7 },
|
||||||
|
[
|
||||||
|
{ latitude: -6.2, longitude: 106.8 },
|
||||||
|
{ latitude: -6.3, longitude: 106.9 },
|
||||||
|
],
|
||||||
|
{ latitude: -6.4, longitude: 107.0 },
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
type: 'LineString',
|
||||||
|
coordinates: [
|
||||||
|
[106.7, -6.1],
|
||||||
|
[106.8, -6.2],
|
||||||
|
[106.9, -6.3],
|
||||||
|
[107.0, -6.4],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips missing coordinates and returns null when nothing is plottable', () => {
|
||||||
|
expect(toPlanTrackGeometry(null, [{ latitude: null, longitude: null }], undefined)).toBeNull();
|
||||||
|
expect(toPlanTrackGeometry(null, [{ latitude: -6.2, longitude: 106.8 }])).toEqual({
|
||||||
|
type: 'LineString',
|
||||||
|
coordinates: [[106.8, -6.2]],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('documentOptionLabel', () => {
|
||||||
|
it('joins document code with customer when both exist', () => {
|
||||||
|
expect(
|
||||||
|
documentOptionLabel({
|
||||||
|
id: 'inv-1',
|
||||||
|
code: 'INV-1',
|
||||||
|
customer: { id: 'cus-1', code: 'C1', name: 'Acme' },
|
||||||
|
}),
|
||||||
|
).toBe('INV-1 · C1 - Acme');
|
||||||
|
});
|
||||||
|
});
|
||||||
+82
@@ -0,0 +1,82 @@
|
|||||||
|
import type { RouteGeometry } from '../../../../cycles/domain/entities';
|
||||||
|
import { relationLabel } from '../../../../shared/relation-label';
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeHydrated<T extends { id?: string | number }>(selected: T[], details: Record<string, T>): T[] {
|
||||||
|
return selected.map((item) => {
|
||||||
|
const id = item.id == null ? '' : String(item.id);
|
||||||
|
const hydrated = details[id];
|
||||||
|
return hydrated ? { ...item, ...hydrated } : item;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function needsCustomerHydration(customer: {
|
||||||
|
address?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
latitude?: number | null;
|
||||||
|
longitude?: number | null;
|
||||||
|
}): boolean {
|
||||||
|
const hasLocation =
|
||||||
|
customer.latitude != null &&
|
||||||
|
customer.longitude != null &&
|
||||||
|
Number.isFinite(Number(customer.latitude)) &&
|
||||||
|
Number.isFinite(Number(customer.longitude));
|
||||||
|
return !customer.address && !customer.phone && !hasLocation;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unwrapEntity<T>(result: { data?: { data?: T } | T } | null | undefined): T | null {
|
||||||
|
if (!result?.data) return null;
|
||||||
|
const payload = result.data;
|
||||||
|
if (typeof payload === 'object' && payload !== null && 'data' in payload) {
|
||||||
|
return (payload as { data?: T }).data ?? null;
|
||||||
|
}
|
||||||
|
return payload as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function salesLinesTotal(lines: Array<{ quantity?: string; price?: string | null }> | undefined): number {
|
||||||
|
return (lines ?? []).reduce((sum, line) => {
|
||||||
|
const qty = Number(line.quantity);
|
||||||
|
const price = Number(line.price);
|
||||||
|
if (!Number.isFinite(qty) || !Number.isFinite(price)) return sum;
|
||||||
|
return sum + qty * price;
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toPlanTrackGeometry(
|
||||||
|
startBranch?: GeoPoint | null,
|
||||||
|
customers: GeoPoint[] = [],
|
||||||
|
endBranch?: GeoPoint | null,
|
||||||
|
): RouteGeometry | null {
|
||||||
|
const points: Array<[number, number]> = [];
|
||||||
|
for (const point of [startBranch, ...customers, endBranch]) {
|
||||||
|
if (point?.latitude == null || point?.longitude == null) continue;
|
||||||
|
const lat = Number(point.latitude);
|
||||||
|
const lng = Number(point.longitude);
|
||||||
|
if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue;
|
||||||
|
points.push([lng, lat]);
|
||||||
|
}
|
||||||
|
if (points.length === 0) return null;
|
||||||
|
return { type: 'LineString', coordinates: points };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function documentOptionLabel(item: {
|
||||||
|
code?: string | null;
|
||||||
|
name?: string;
|
||||||
|
id?: string | number;
|
||||||
|
customer?: { code?: string | null; name?: string; id?: string | number } | null;
|
||||||
|
}) {
|
||||||
|
const base = relationLabel(item);
|
||||||
|
const customer = relationLabel(item.customer);
|
||||||
|
if (base && customer) return `${base} · ${customer}`;
|
||||||
|
return base || customer;
|
||||||
|
}
|
||||||
+59
@@ -0,0 +1,59 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { mergeHydrated, selectionIds } from './plan-form-preview';
|
||||||
|
|
||||||
|
export function useHydratedRecords<T extends { id?: string | number }>(
|
||||||
|
selected: T[] | undefined,
|
||||||
|
fetchOne: (id: string) => Promise<T | null>,
|
||||||
|
shouldFetch: (item: T) => boolean = () => true,
|
||||||
|
): { records: T[]; pending: boolean } {
|
||||||
|
const items = selected ?? [];
|
||||||
|
const ids = selectionIds(items);
|
||||||
|
const idsKey = ids.join(',');
|
||||||
|
const [details, setDetails] = useState<Record<string, T>>({});
|
||||||
|
const itemsRef = useRef(items);
|
||||||
|
const fetchOneRef = useRef(fetchOne);
|
||||||
|
const shouldFetchRef = useRef(shouldFetch);
|
||||||
|
itemsRef.current = items;
|
||||||
|
fetchOneRef.current = fetchOne;
|
||||||
|
shouldFetchRef.current = shouldFetch;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const toLoad = itemsRef.current.filter((item) => {
|
||||||
|
const id = item.id == null ? '' : String(item.id);
|
||||||
|
return Boolean(id) && shouldFetchRef.current(item);
|
||||||
|
});
|
||||||
|
if (toLoad.length === 0) return undefined;
|
||||||
|
|
||||||
|
void Promise.all(
|
||||||
|
toLoad.map(async (item) => {
|
||||||
|
const id = String(item.id);
|
||||||
|
try {
|
||||||
|
return { id, entity: await fetchOneRef.current(id) };
|
||||||
|
} catch {
|
||||||
|
return { id, entity: null };
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
).then((rows) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setDetails((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.entity) next[row.id] = row.entity;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [idsKey]);
|
||||||
|
|
||||||
|
const pending = items.some((item) => {
|
||||||
|
const id = item.id == null ? '' : String(item.id);
|
||||||
|
return Boolean(id) && shouldFetch(item) && !details[id];
|
||||||
|
});
|
||||||
|
|
||||||
|
return { records: mergeHydrated(items, details), pending };
|
||||||
|
}
|
||||||
@@ -13,13 +13,18 @@
|
|||||||
"section_route": "Route",
|
"section_route": "Route",
|
||||||
"section_destinations": "Destinations",
|
"section_destinations": "Destinations",
|
||||||
"section_attachments": "Attachments",
|
"section_attachments": "Attachments",
|
||||||
|
"section_preview_customers": "Customer preview",
|
||||||
|
"section_preview_track": "Track preview",
|
||||||
|
"section_preview_invoices": "Invoice preview",
|
||||||
|
"section_preview_packing_slips": "Packing slip preview",
|
||||||
"generate": "Generate",
|
"generate": "Generate",
|
||||||
"generate_title": "Generate plans",
|
"generate_title": "Generate plans",
|
||||||
"generate_success": "Created {{created}} plan(s), skipped {{skipped}}.",
|
"generate_success": "Created {{created}} plan(s), skipped {{skipped}}.",
|
||||||
"add_destination": "Add destination",
|
"add_destination": "Add destination",
|
||||||
"remove_destination": "Remove destination",
|
"remove_destination": "Remove destination",
|
||||||
"empty_route": "No route geometry",
|
"empty_route": "No route geometry",
|
||||||
"section_attachments": "Attachments",
|
"empty_products": "No products on this document",
|
||||||
|
"preview_loading": "Loading document details…",
|
||||||
"purpose_sales": "Sales",
|
"purpose_sales": "Sales",
|
||||||
"purpose_logistics": "Logistics",
|
"purpose_logistics": "Logistics",
|
||||||
"status_draft": "Draft",
|
"status_draft": "Draft",
|
||||||
|
|||||||
@@ -13,13 +13,18 @@
|
|||||||
"section_route": "Rute",
|
"section_route": "Rute",
|
||||||
"section_destinations": "Destinasi",
|
"section_destinations": "Destinasi",
|
||||||
"section_attachments": "Lampiran",
|
"section_attachments": "Lampiran",
|
||||||
|
"section_preview_customers": "Pratinjau pelanggan",
|
||||||
|
"section_preview_track": "Pratinjau rute",
|
||||||
|
"section_preview_invoices": "Pratinjau faktur",
|
||||||
|
"section_preview_packing_slips": "Pratinjau surat jalan",
|
||||||
"generate": "Generate",
|
"generate": "Generate",
|
||||||
"generate_title": "Generate rencana",
|
"generate_title": "Generate rencana",
|
||||||
"generate_success": "Berhasil membuat {{created}} rencana, {{skipped}} dilewati.",
|
"generate_success": "Berhasil membuat {{created}} rencana, {{skipped}} dilewati.",
|
||||||
"add_destination": "Tambah destinasi",
|
"add_destination": "Tambah destinasi",
|
||||||
"remove_destination": "Hapus destinasi",
|
"remove_destination": "Hapus destinasi",
|
||||||
"empty_route": "Tidak ada geometri rute",
|
"empty_route": "Tidak ada geometri rute",
|
||||||
"section_attachments": "Lampiran",
|
"empty_products": "Tidak ada produk pada dokumen ini",
|
||||||
|
"preview_loading": "Memuat detail dokumen…",
|
||||||
"purpose_sales": "Penjualan",
|
"purpose_sales": "Penjualan",
|
||||||
"purpose_logistics": "Logistik",
|
"purpose_logistics": "Logistik",
|
||||||
"status_draft": "Draft",
|
"status_draft": "Draft",
|
||||||
|
|||||||
Reference in New Issue
Block a user