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 f29778e..50ee45f 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 @@ -23,4 +23,18 @@ describe('createPlanSchema', () => { it('rejects empty customers', () => { 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, + }); + }); }); 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 457c5de..0169a26 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,9 +1,12 @@ import { z } from 'zod'; -const relationSchema = z.object({ - id: z.string(), - code: z.string().optional(), - name: z.string().optional(), -}); + +const relationSchema = z + .object({ + id: z.string(), + code: z.string().optional(), + name: z.string().optional(), + }) + .passthrough(); export const createPlanSchema = (t: (key: string) => string) => { return z diff --git a/apps/web/src/apps/main/modules/field/plans/presentation/components/form-component/form-customers-preview.tsx b/apps/web/src/apps/main/modules/field/plans/presentation/components/form-component/form-customers-preview.tsx new file mode 100644 index 0000000..8a5f0b3 --- /dev/null +++ b/apps/web/src/apps/main/modules/field/plans/presentation/components/form-component/form-customers-preview.tsx @@ -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 { + return unwrapEntity(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 ( + + + + {t('section_preview_customers')} + + + + + + {t('common:fields.code')} + {t('common:fields.name')} + {t('common:fields.phone')} + {t('common:fields.address')} + + + + {hydrated.map((customer, index) => ( + + {customer.code || '-'} + {customer.name || '-'} + {customer.phone || '-'} + {customer.address || '-'} + + ))} + +
+
+
+ + + + {t('section_preview_track')} + + {geometry ? ( + + ) : ( + + {t('empty_route')} + + )} + +
+ ); +} 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 new file mode 100644 index 0000000..6382be5 --- /dev/null +++ b/apps/web/src/apps/main/modules/field/plans/presentation/components/form-component/form-documents-preview.tsx @@ -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 { + return unwrapEntity(await salesInvoicesDataService.getOne(id)); +} + +async function fetchPackingSlip(id: string): Promise { + return unwrapEntity(await packingSlipsModuleDataService.getOne(id)); +} + +export function FormDocumentsPreview({ + kind, + items, +}: { + kind: 'invoice' | 'packingSlip'; + items: Array; +}) { + 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 ( + + + {kind === 'invoice' ? t('section_preview_invoices') : t('section_preview_packing_slips')} + + + {hydrated.map((document, index) => ( + + ))} + + + ); +} + +function DocumentPreviewCard({ + document, + showBalance, + pending, +}: { + document: PreviewDocument; + showBalance: boolean; + pending: boolean; +}) { + const { t } = useEnterpriseModuleTranslationContext(); + const lines = document.products ?? []; + const total = salesLinesTotal(lines); + + return ( + + + {document.code || relationLabel(document) || document.id} + + + } + /> + + } + /> + {showBalance ? ( + } + /> + ) : ( + + )} + + {showBalance && document.salesOrder ? ( + + + {document.packingSlip ? ( + + ) : null} + + ) : null} + + + ); +} + +function DocumentProductsTable({ + lines, + total, + pending, +}: { + lines: SalesLineEntity[]; + total: number; + pending: boolean; +}) { + const { t } = useEnterpriseModuleTranslationContext(); + return ( + + + + + {t('common:fields.product')} + {t('common:fields.quantity')} + {t('common:fields.price')} + {t('common:fields.lineTotal')} + + + + {lines.length === 0 ? ( + + + + {pending ? t('preview_loading') : t('empty_products')} + + + + ) : ( + 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 ( + + {relationLabel(line.product) || line.productId} + {line.quantity} + {line.price ? formatRupiah(line.price) : '-'} + {formatRupiah(lineTotal)} + + ); + }) + )} + +
+ {!pending || lines.length > 0 ? ( + + {t('common:fields.total')}: {formatRupiah(total)} + + ) : null} +
+ ); +} 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 dbe5f50..671f8eb 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,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 { useEnterpriseModuleTranslationContext, useFormPageContext, @@ -8,12 +8,23 @@ import { purposeFromModuleKey } from '../../../../../../../../core/domain/field- import { loadLogisticsEmployeeOptions, loadSalesEmployeeOptions } from '../../../../shared/load-employee-options'; import { loadBranchOptions } from '../../../../shared/load-branch-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 { salesInvoicesDataService } from '../../../../../sales/invoices/domain/factories'; +import { packingSlipsModuleDataService } from '../../../../packing-slips/domain/factories'; import type { EmployeeEntity } from '../../../../../configuration/employees/domain/entities'; import type { BranchEntity } from '../../../../../configuration/branches/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((config) => + salesInvoicesDataService.getMany(config), +); +const loadSlipOptions = createOptionLoader((config) => packingSlipsModuleDataService.getMany(config)); export function FormGeneral() { const { formControl } = useFormPageContext(); @@ -21,102 +32,111 @@ export function FormGeneral() { const { config } = useEnterpriseModuleConfigContext(); const purpose = purposeFromModuleKey(config.moduleKey); const employee = formControl.watch('employee'); - const startBranch = formControl.watch('startBranch'); - const endBranch = formControl.watch('endBranch'); - const customers = formControl.watch('customers') ?? []; - const invoices = formControl.watch('invoices') ?? []; - const packingSlips = formControl.watch('packingSlips') ?? []; + const startBranch = formControl.watch('startBranch') as BranchEntity | null | undefined; + const endBranch = formControl.watch('endBranch') as BranchEntity | null | undefined; + const customers = (formControl.watch('customers') ?? []) as CustomerEntity[]; + const invoices = (formControl.watch('invoices') ?? []) as SalesInvoiceEntity[]; + const packingSlips = (formControl.watch('packingSlips') ?? []) as PackingSlipEntity[]; return ( - - - {t('section_general')} - - - - - control={formControl.control} - name="employee" - label={t('common:fields.employee')} - valueKey="id" - labelKey="name" - required - searchable - loadOptions={purpose === 'sales' ? loadSalesEmployeeOptions : loadLogisticsEmployeeOptions} - defaultOptions={employee ? [employee] : []} - renderLabel={relationLabel} - /> - - - control={formControl.control} - name="startBranch" - label={t('common:fields.startBranch')} - valueKey="id" - labelKey="name" - required - searchable - loadOptions={loadBranchOptions} - defaultOptions={startBranch ? [startBranch] : []} - renderLabel={relationLabel} - /> - - control={formControl.control} - name="endBranch" - label={t('common:fields.endBranch')} - valueKey="id" - labelKey="name" - required - searchable - loadOptions={loadBranchOptions} - defaultOptions={endBranch ? [endBranch] : []} - renderLabel={relationLabel} - /> - - - - control={formControl.control} - name="customers" - label={t('common:fields.customers')} - valueKey="id" - labelKey="name" - required - searchable - multiple - loadOptions={loadCustomerOptions} - defaultOptions={customers} - renderLabel={relationLabel} - /> - - - {purpose === 'sales' ? ( - + + + + {t('section_general')} + + + + control={formControl.control} - name="invoices" - label={t('common:fields.invoices')} + name="employee" + label={t('common:fields.employee')} valueKey="id" - labelKey="code" + labelKey="name" + required searchable - multiple - loadOptions={loadSalesInvoiceOptions} - defaultOptions={invoices} + loadOptions={purpose === 'sales' ? loadSalesEmployeeOptions : loadLogisticsEmployeeOptions} + defaultOptions={employee ? [employee] : []} renderLabel={relationLabel} /> - ) : ( - + + control={formControl.control} - name="packingSlips" - label={t('common:fields.packingSlips')} + name="startBranch" + label={t('common:fields.startBranch')} valueKey="id" - labelKey="code" + labelKey="name" + required searchable - multiple - loadOptions={loadPackingSlipOptions} - defaultOptions={packingSlips} + loadOptions={loadBranchOptions} + defaultOptions={startBranch ? [startBranch] : []} renderLabel={relationLabel} /> - )} + + control={formControl.control} + name="endBranch" + label={t('common:fields.endBranch')} + valueKey="id" + labelKey="name" + required + searchable + loadOptions={loadBranchOptions} + defaultOptions={endBranch ? [endBranch] : []} + renderLabel={relationLabel} + /> + + + + control={formControl.control} + name="customers" + label={t('common:fields.customers')} + valueKey="id" + labelKey="name" + required + searchable + multiple + loadOptions={loadCustomerOptions} + defaultOptions={customers} + renderLabel={relationLabel} + /> + + + {purpose === 'sales' ? ( + + control={formControl.control} + name="invoices" + label={t('common:fields.invoices')} + valueKey="id" + labelKey="code" + searchable + multiple + loadOptions={loadInvoiceOptions} + defaultOptions={invoices} + renderLabel={documentOptionLabel} + /> + ) : ( + + control={formControl.control} + name="packingSlips" + label={t('common:fields.packingSlips')} + valueKey="id" + labelKey="code" + searchable + multiple + loadOptions={loadSlipOptions} + defaultOptions={packingSlips} + renderLabel={documentOptionLabel} + /> + )} + - - + + + + {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 new file mode 100644 index 0000000..ff2155e --- /dev/null +++ b/apps/web/src/apps/main/modules/field/plans/presentation/components/form-component/plan-form-preview.test.ts @@ -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'); + }); +}); 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 new file mode 100644 index 0000000..8cee271 --- /dev/null +++ b/apps/web/src/apps/main/modules/field/plans/presentation/components/form-component/plan-form-preview.ts @@ -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(selected: T[], details: Record): 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(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; +} diff --git a/apps/web/src/apps/main/modules/field/plans/presentation/components/form-component/use-hydrated-records.ts b/apps/web/src/apps/main/modules/field/plans/presentation/components/form-component/use-hydrated-records.ts new file mode 100644 index 0000000..4bc0732 --- /dev/null +++ b/apps/web/src/apps/main/modules/field/plans/presentation/components/form-component/use-hydrated-records.ts @@ -0,0 +1,59 @@ +import { useEffect, useRef, useState } from 'react'; +import { mergeHydrated, selectionIds } from './plan-form-preview'; + +export function useHydratedRecords( + selected: T[] | undefined, + fetchOne: (id: string) => Promise, + shouldFetch: (item: T) => boolean = () => true, +): { records: T[]; pending: boolean } { + const items = selected ?? []; + const ids = selectionIds(items); + const idsKey = ids.join(','); + const [details, setDetails] = useState>({}); + 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 }; +} diff --git a/apps/web/src/apps/main/modules/field/plans/presentation/languages/en/plans.json b/apps/web/src/apps/main/modules/field/plans/presentation/languages/en/plans.json index d18fe54..357055f 100644 --- a/apps/web/src/apps/main/modules/field/plans/presentation/languages/en/plans.json +++ b/apps/web/src/apps/main/modules/field/plans/presentation/languages/en/plans.json @@ -13,13 +13,18 @@ "section_route": "Route", "section_destinations": "Destinations", "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_title": "Generate plans", "generate_success": "Created {{created}} plan(s), skipped {{skipped}}.", "add_destination": "Add destination", "remove_destination": "Remove destination", "empty_route": "No route geometry", - "section_attachments": "Attachments", + "empty_products": "No products on this document", + "preview_loading": "Loading document details…", "purpose_sales": "Sales", "purpose_logistics": "Logistics", "status_draft": "Draft", diff --git a/apps/web/src/apps/main/modules/field/plans/presentation/languages/id/plans.json b/apps/web/src/apps/main/modules/field/plans/presentation/languages/id/plans.json index 54d8c92..88f96ca 100644 --- a/apps/web/src/apps/main/modules/field/plans/presentation/languages/id/plans.json +++ b/apps/web/src/apps/main/modules/field/plans/presentation/languages/id/plans.json @@ -13,13 +13,18 @@ "section_route": "Rute", "section_destinations": "Destinasi", "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_title": "Generate rencana", "generate_success": "Berhasil membuat {{created}} rencana, {{skipped}} dilewati.", "add_destination": "Tambah destinasi", "remove_destination": "Hapus destinasi", "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_logistics": "Logistik", "status_draft": "Draft",