From 27efbe6bb396e007fef95dad68661a02ddb9791b Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 10 Jul 2026 09:14:49 +0000 Subject: [PATCH 1/9] fix(warehouses): drop truncated UUID columns and duplicate GRN buttons Queue tables showed "Booking ID" / "Customer ID" as dimmed 8-char UUID hashes next to the human-readable Booking Ref and Customer Name - unusable columns that forced extra horizontal scrolling. Removed across all six tables (eligible, received, ready-to-load, loaded/dispatch, import unloaded, import train detail). The GRN document button also rendered twice per row (inside the Booking Ref cell and again in the GRN column) in four tables - kept the GRN column only. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../warehouses/ReceiveInventoryModal.tsx | 68 ++----------------- 1 file changed, 4 insertions(+), 64 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx index ff8dc0abd..8e39ea53e 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -1043,8 +1043,6 @@ function EligibleTab({ /> Booking Ref - Booking ID - Customer ID Customer Name Origin Destination @@ -1077,12 +1075,6 @@ function EligibleTab({ {r.reference} - - {r.id.slice(0, 8)}… - - - {r.customerId ? `${r.customerId.slice(0, 8)}…` : '—'} - {r.customer ?? '—'} {r.origin ?? '—'} {r.destination ?? '—'} @@ -1329,8 +1321,6 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged Booking Ref GRN - Booking ID - Customer ID Customer Name Container / Cargo Items Cargo Type @@ -1355,20 +1345,11 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged /> - - {r.bookingReference ?? '—'} - - + {r.bookingReference ?? '—'} - - {r.bookingId ? `${r.bookingId.slice(0, 8)}…` : '—'} - - - {r.customerId ? `${r.customerId.slice(0, 8)}…` : '—'} - {r.customerName ?? '—'} {r.containerNumber ?? '—'} {r.cargoType ?? '—'} @@ -1558,8 +1539,6 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: Booking Ref GRN - Booking ID - Customer ID Customer Name Container # Cargo Type @@ -1580,20 +1559,11 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: /> - - {r.bookingReference ?? '—'} - - + {r.bookingReference ?? '—'} - - {r.bookingId ? `${r.bookingId.slice(0, 8)}…` : '—'} - - - {r.customerId ? `${r.customerId.slice(0, 8)}…` : '—'} - {r.customerName ?? '—'} {r.containerNumber ?? '—'} {r.cargoType ?? '—'} @@ -1735,8 +1705,6 @@ function LoadedExportTab({ )} Booking Ref GRN - Booking ID - Customer ID Customer Name Container # Cargo Type @@ -1758,20 +1726,11 @@ function LoadedExportTab({ )} - - {r.bookingReference ?? '—'} - - + {r.bookingReference ?? '—'} - - {r.bookingId ? `${r.bookingId.slice(0, 8)}…` : '—'} - - - {r.customerId ? `${r.customerId.slice(0, 8)}…` : '—'} - {r.customerName ?? '—'} {r.containerNumber ?? '—'} {r.cargoType ?? '—'} @@ -1891,9 +1850,7 @@ function ImportTrainDetailTable({ Wagon - Booking ID Booking Ref - Customer ID Customer Name Container # Cargo Type @@ -1927,15 +1884,9 @@ function ImportTrainDetailTable({ {it.sequenceNo ? `#${it.sequenceNo}` : '-'} {it.wagonNumber ?? ''} - - {it.bookingId.slice(0, 8)}… - {it.bookingReference ?? '—'} - - {it.customerId ? `${it.customerId.slice(0, 8)}…` : '—'} - {it.customerName ?? '—'} {it.containerNumber ?? '—'} {it.cargoType ?? '—'} @@ -2384,10 +2335,8 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { onChange={() => (allSelected ? unselectAll() : selectAll())} /> - Booking ID Booking Ref GRN - Customer ID Customer Name Arrival Time Container # @@ -2412,20 +2361,11 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { /> - {r.bookingId ? `${r.bookingId.slice(0, 8)}…` : '—'} - - - - {r.bookingReference ?? '—'} - - + {r.bookingReference ?? '—'} - - {r.customerId ? `${r.customerId.slice(0, 8)}…` : '—'} - {r.customerName ?? '—'} {formatDate(r.arrivalTime)} {r.containerNumber ?? '—'} From 9e30c0c07128357c06ff4f86f0a1ea4941b52f24 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 10 Jul 2026 09:22:23 +0000 Subject: [PATCH 2/9] ci: replace actions/checkout with plain git on self-hosted runners Runners on this network intermittently time out downloading the action tarball from codeload.github.com (HttpClient 100s limit, 3 attempts, job dead before the first step). git fetch talks to github.com directly and needs no action download at all. - detect-changes: fetch --depth 2 (keeps the HEAD~1 diff working) - deploy: fetch --depth 1 - token passed via env for the fetch, then scrubbed from .git/config so it doesn't persist in the runner workspace; git clean keeps checkout@v4's clean-workspace behaviour Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/deploy.yml | 37 ++++++++++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 62530611c..5e1f46ad0 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -17,10 +17,23 @@ jobs: outputs: matrix: ${{ steps.filter.outputs.matrix }} steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 2 + # Plain git instead of actions/checkout: self-hosted runners on this + # network intermittently time out downloading action tarballs from + # codeload.github.com (100s HttpClient limit x3 = dead job). git fetch + # talks to github.com directly and needs no action download at all. + - name: Checkout (plain git, depth 2) + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + git init -q . + git remote remove origin 2>/dev/null || true + git remote add origin "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" + git fetch -q --depth 2 origin "${{ github.sha }}" + git checkout -q --force "${{ github.sha }}" + git clean -ffdq + # Don't leave the token in .git/config on the persistent runner workspace. + git remote set-url origin "https://github.com/${{ github.repository }}.git" - name: Determine changed services id: filter @@ -103,8 +116,20 @@ jobs: COMPOSE_DOCKER_CLI_BUILD: "1" steps: - - name: Checkout - uses: actions/checkout@v4 + # Same rationale as detect-changes: no action download on this network. + - name: Checkout (plain git) + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + git init -q . + git remote remove origin 2>/dev/null || true + git remote add origin "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" + git fetch -q --depth 1 origin "${{ github.sha }}" + git checkout -q --force "${{ github.sha }}" + git clean -ffdq + # Don't leave the token in .git/config on the persistent runner workspace. + git remote set-url origin "https://github.com/${{ github.repository }}.git" - name: Resolve project and build env file run: | From aa3674041d4e240df0124117ab87635e3586b328 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 10 Jul 2026 09:14:49 +0000 Subject: [PATCH 3/9] fix(warehouses): drop truncated UUID columns and duplicate GRN buttons Queue tables showed "Booking ID" / "Customer ID" as dimmed 8-char UUID hashes next to the human-readable Booking Ref and Customer Name - unusable columns that forced extra horizontal scrolling. Removed across all six tables (eligible, received, ready-to-load, loaded/dispatch, import unloaded, import train detail). The GRN document button also rendered twice per row (inside the Booking Ref cell and again in the GRN column) in four tables - kept the GRN column only. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../warehouses/ReceiveInventoryModal.tsx | 68 ++----------------- 1 file changed, 4 insertions(+), 64 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx index ff8dc0abd..8e39ea53e 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -1043,8 +1043,6 @@ function EligibleTab({ /> Booking Ref - Booking ID - Customer ID Customer Name Origin Destination @@ -1077,12 +1075,6 @@ function EligibleTab({ {r.reference} - - {r.id.slice(0, 8)}… - - - {r.customerId ? `${r.customerId.slice(0, 8)}…` : '—'} - {r.customer ?? '—'} {r.origin ?? '—'} {r.destination ?? '—'} @@ -1329,8 +1321,6 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged Booking Ref GRN - Booking ID - Customer ID Customer Name Container / Cargo Items Cargo Type @@ -1355,20 +1345,11 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged /> - - {r.bookingReference ?? '—'} - - + {r.bookingReference ?? '—'} - - {r.bookingId ? `${r.bookingId.slice(0, 8)}…` : '—'} - - - {r.customerId ? `${r.customerId.slice(0, 8)}…` : '—'} - {r.customerName ?? '—'} {r.containerNumber ?? '—'} {r.cargoType ?? '—'} @@ -1558,8 +1539,6 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: Booking Ref GRN - Booking ID - Customer ID Customer Name Container # Cargo Type @@ -1580,20 +1559,11 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: /> - - {r.bookingReference ?? '—'} - - + {r.bookingReference ?? '—'} - - {r.bookingId ? `${r.bookingId.slice(0, 8)}…` : '—'} - - - {r.customerId ? `${r.customerId.slice(0, 8)}…` : '—'} - {r.customerName ?? '—'} {r.containerNumber ?? '—'} {r.cargoType ?? '—'} @@ -1735,8 +1705,6 @@ function LoadedExportTab({ )} Booking Ref GRN - Booking ID - Customer ID Customer Name Container # Cargo Type @@ -1758,20 +1726,11 @@ function LoadedExportTab({ )} - - {r.bookingReference ?? '—'} - - + {r.bookingReference ?? '—'} - - {r.bookingId ? `${r.bookingId.slice(0, 8)}…` : '—'} - - - {r.customerId ? `${r.customerId.slice(0, 8)}…` : '—'} - {r.customerName ?? '—'} {r.containerNumber ?? '—'} {r.cargoType ?? '—'} @@ -1891,9 +1850,7 @@ function ImportTrainDetailTable({ Wagon - Booking ID Booking Ref - Customer ID Customer Name Container # Cargo Type @@ -1927,15 +1884,9 @@ function ImportTrainDetailTable({ {it.sequenceNo ? `#${it.sequenceNo}` : '-'} {it.wagonNumber ?? ''} - - {it.bookingId.slice(0, 8)}… - {it.bookingReference ?? '—'} - - {it.customerId ? `${it.customerId.slice(0, 8)}…` : '—'} - {it.customerName ?? '—'} {it.containerNumber ?? '—'} {it.cargoType ?? '—'} @@ -2384,10 +2335,8 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { onChange={() => (allSelected ? unselectAll() : selectAll())} /> - Booking ID Booking Ref GRN - Customer ID Customer Name Arrival Time Container # @@ -2412,20 +2361,11 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { /> - {r.bookingId ? `${r.bookingId.slice(0, 8)}…` : '—'} - - - - {r.bookingReference ?? '—'} - - + {r.bookingReference ?? '—'} - - {r.customerId ? `${r.customerId.slice(0, 8)}…` : '—'} - {r.customerName ?? '—'} {formatDate(r.arrivalTime)} {r.containerNumber ?? '—'} From 0339b89b54f1d8b717f1e47609aba17930eb0cac Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 10 Jul 2026 09:22:23 +0000 Subject: [PATCH 4/9] ci: replace actions/checkout with plain git on self-hosted runners Runners on this network intermittently time out downloading the action tarball from codeload.github.com (HttpClient 100s limit, 3 attempts, job dead before the first step). git fetch talks to github.com directly and needs no action download at all. - detect-changes: fetch --depth 2 (keeps the HEAD~1 diff working) - deploy: fetch --depth 1 - token passed via env for the fetch, then scrubbed from .git/config so it doesn't persist in the runner workspace; git clean keeps checkout@v4's clean-workspace behaviour Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/deploy.yml | 37 ++++++++++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 62530611c..5e1f46ad0 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -17,10 +17,23 @@ jobs: outputs: matrix: ${{ steps.filter.outputs.matrix }} steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 2 + # Plain git instead of actions/checkout: self-hosted runners on this + # network intermittently time out downloading action tarballs from + # codeload.github.com (100s HttpClient limit x3 = dead job). git fetch + # talks to github.com directly and needs no action download at all. + - name: Checkout (plain git, depth 2) + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + git init -q . + git remote remove origin 2>/dev/null || true + git remote add origin "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" + git fetch -q --depth 2 origin "${{ github.sha }}" + git checkout -q --force "${{ github.sha }}" + git clean -ffdq + # Don't leave the token in .git/config on the persistent runner workspace. + git remote set-url origin "https://github.com/${{ github.repository }}.git" - name: Determine changed services id: filter @@ -103,8 +116,20 @@ jobs: COMPOSE_DOCKER_CLI_BUILD: "1" steps: - - name: Checkout - uses: actions/checkout@v4 + # Same rationale as detect-changes: no action download on this network. + - name: Checkout (plain git) + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + git init -q . + git remote remove origin 2>/dev/null || true + git remote add origin "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" + git fetch -q --depth 1 origin "${{ github.sha }}" + git checkout -q --force "${{ github.sha }}" + git clean -ffdq + # Don't leave the token in .git/config on the persistent runner workspace. + git remote set-url origin "https://github.com/${{ github.repository }}.git" - name: Resolve project and build env file run: | From 3ff4b8bf945d30f4deaf1822c0c8700a20bf5818 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 10 Jul 2026 09:31:54 +0000 Subject: [PATCH 5/9] fix(warehouses): finish queue-table ergonomics batch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - statuses render through the shared InventoryStatusBadge (humanized label, consistent per-status color) instead of raw enums in ad-hoc badges - Ready-To-Load drops its selection checkboxes: nothing consumed the selection since auto-load became train-scoped - bulk toasts now say WHY items were skipped ("3 skipped — Booking not PAID") via a shared skippedSummary helper - one-click irreversible bulk actions (Dispatch All/Selected, Mark Selected as Inspected x2, Auto Unload train) now ask for confirmation through a small local ConfirmActionModal - no @mantine/modals dependency added. Receive All stays unconfirmed: it already funnels through the truck-entrance modal with an explicit Save. - table minWidths retuned for the slimmed column sets (1700->1350, 1600->1200, 2000->1650) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../warehouses/ReceiveInventoryModal.tsx | 159 ++++++++++++------ 1 file changed, 108 insertions(+), 51 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx index 8e39ea53e..465b59aca 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -51,6 +51,7 @@ import { warehouseService } from '@/services/warehouse.service'; import type { EligibleBooking, InventoryInquiryFilter, + InventoryStatus, InventoryInquiryResult, ImportTrain, ImportTrainItem, @@ -63,6 +64,7 @@ import type { WarehouseYard, WarehouseZone, } from '@/types/warehouse'; +import { InventoryStatusBadge } from './badges'; import { BookingSelect } from './BookingSelect'; import { DeliverInventoryModal } from './DeliverInventoryModal'; import { ContainerItemsModal } from './ContainerItemsModal'; @@ -253,6 +255,50 @@ const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayl warehouseManagerName: form.warehouseManagerName.trim() || undefined, }); + + +type ConfirmAction = { title: string; message: string; confirmLabel: string; run: () => void }; + +/** One-click bulk actions are irreversible — make the click deliberate. */ +function ConfirmActionModal({ + action, + onClose, +}: { + action: ConfirmAction | null; + onClose: () => void; +}) { + return ( + + + {action?.message} + + + + + + + ); +} + +/** "3 skipped — Booking not PAID" instead of a bare count. */ +const skippedSummary = ( + skippedCount: number, + results: Array<{ reason?: string; message?: string }>, +): string | undefined => { + if (!skippedCount) return undefined; + const reason = results.find((x) => x.reason || x.message); + return `${skippedCount} skipped${reason ? ` — ${reason.reason ?? reason.message}` : ''}`; +}; + const commonNonEmptyValue = (values: Array) => { const unique = [...new Set(values.map((value) => value?.trim()).filter(Boolean))] as string[]; return unique.length === 1 ? unique[0] : ''; @@ -860,7 +906,7 @@ function EligibleTab({ }); toast({ title: `${r.receivedCount} received at ${direction === 'EXPORT' ? 'facility' : 'warehouse'}`, - description: r.results.find((item) => item.grnNumber)?.grnNumber ?? (r.skippedCount ? `${r.skippedCount} skipped` : undefined), + description: r.results.find((item) => item.grnNumber)?.grnNumber ?? skippedSummary(r.skippedCount, r.results), }); const firstGrn = r.results.find((item) => item.inventoryId && item.grnNumber); if (focusedBookingId && firstGrn?.inventoryId && firstGrn.grnNumber) { @@ -1030,7 +1076,7 @@ function EligibleTab({ : `No eligible PAID ${direction.toLowerCase()} bookings to receive.`} ) : ( - + @@ -1248,6 +1294,7 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged api.warehouses.bulkMarkInspected.mutationOptions(), ); const [selected, setSelected] = useState>(new Set()); + const [confirmAction, setConfirmAction] = useState(null); const [inspectId, setInspectId] = useState(null); const pendingRows = rows.filter((r) => r.inspectionStatus !== 'PASSED'); @@ -1271,7 +1318,7 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged const r = await inspectMutation.mutateAsync({ inventoryIds: [...selected] }); toast({ title: `${r.inspectedCount} marked inspected`, - description: r.skippedCount ? `${r.skippedCount} skipped` : undefined, + description: skippedSummary(r.skippedCount, r.results), }); setSelected(new Set()); onChanged?.(); @@ -1292,7 +1339,14 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged leftSection={} disabled={selected.size === 0} loading={inspectMutation.isPending} - onClick={markInspected} + onClick={() => + setConfirmAction({ + title: 'Mark inspected', + message: `Mark ${selected.size} selected item(s) as inspection PASSED?`, + confirmLabel: `Mark ${selected.size} inspected`, + run: markInspected, + }) + } > Mark Selected as Inspected @@ -1307,7 +1361,7 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged No received export items awaiting inspection. ) : ( - +
@@ -1363,9 +1417,7 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged - - {r.status} - +
- - - Booking Ref GRN Customer Name @@ -1551,13 +1585,6 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: {rows.map((r: ReadyToLoadRow) => ( - - toggleOne(r.id)} - /> - {r.bookingReference ?? '—'} @@ -1577,9 +1604,7 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: - - {r.status} - + ))} @@ -1608,6 +1633,7 @@ function LoadedExportTab({ const { data: rows = [], isLoading } = useQuery( api.warehouses.loadedExport.queryOptions({ enabled }), ); + const [confirmAction, setConfirmAction] = useState(null); const bulkDispatch = useMutation( api.warehouses.bulkDispatchExport.mutationOptions(), ); @@ -1632,7 +1658,7 @@ function LoadedExportTab({ const r = await bulkDispatch.mutateAsync(inventoryIds); toast({ title: `${r.dispatchedCount} dispatched`, - description: r.skippedCount ? `${r.skippedCount} skipped` : undefined, + description: skippedSummary(r.skippedCount, r.results), }); setSelected(new Set()); onChanged?.(); @@ -1662,7 +1688,14 @@ function LoadedExportTab({ variant="default" disabled={rows.length === 0} loading={bulkDispatch.isPending} - onClick={() => dispatch(rows.map((r) => r.id))} + onClick={() => + setConfirmAction({ + title: 'Dispatch all', + message: `Dispatch all ${rows.length} loaded item(s)? They leave warehouse inventory for the train.`, + confirmLabel: `Dispatch ${rows.length}`, + run: () => dispatch(rows.map((r) => r.id)), + }) + } > Dispatch All @@ -1672,7 +1705,14 @@ function LoadedExportTab({ leftSection={} disabled={selected.size === 0} loading={bulkDispatch.isPending} - onClick={() => dispatch([...selected])} + onClick={() => + setConfirmAction({ + title: 'Dispatch selected', + message: `Dispatch ${selected.size} selected item(s)? They leave warehouse inventory for the train.`, + confirmLabel: `Dispatch ${selected.size}`, + run: () => dispatch([...selected]), + }) + } > Dispatch Selected @@ -1689,7 +1729,7 @@ function LoadedExportTab({ No LOADED export items {dispatchable ? 'waiting to dispatch' : 'yet'}. ) : ( - +
@@ -1739,9 +1779,7 @@ function LoadedExportTab({ {r.origin || r.destination ? `${r.origin ?? '?'} → ${r.destination ?? '?'}` : '—'} - - {r.status} - + ))} @@ -1749,6 +1787,7 @@ function LoadedExportTab({
)} + setConfirmAction(null)} /> ); } @@ -1976,6 +2015,7 @@ function ImportArriveQueueTab({ api.warehouses.autoUnloadArrivedBookings.mutationOptions(), ); const [openId, setOpenId] = useState(null); + const [confirmAction, setConfirmAction] = useState(null); const [busyId, setBusyId] = useState(null); const [assignmentsBySchedule, setAssignmentsBySchedule] = useState< Record> @@ -2017,7 +2057,7 @@ function ImportArriveQueueTab({ const alreadyUnloaded = r.unloadedCount === 0 && r.skippedCount > 0 && r.failedCount === 0; const firstReason = r.results.find((item) => item.reason)?.reason; const extra = [ - r.skippedCount ? `${r.skippedCount} skipped` : '', + skippedSummary(r.skippedCount, r.results) ?? '', r.failedCount ? `${r.failedCount} failed` : '', ] .filter(Boolean) @@ -2113,7 +2153,14 @@ function ImportArriveQueueTab({ leftSection={} loading={busyId === t.scheduleId} disabled={fullyUnloaded || t.totalBookings === 0 || !readyBySchedule[t.scheduleId] || warehousesLoading} - onClick={() => autoUnload(t)} + onClick={() => + setConfirmAction({ + title: 'Auto unload train', + message: `Unload all arrived bookings from train ${t.trainNumber ?? t.scheduleId.slice(0, 8)} into their assigned warehouse locations?`, + confirmLabel: 'Unload train', + run: () => autoUnload(t), + }) + } > {fullyUnloaded ? 'Already Unloaded' : 'Auto Unload Arrived Bookings'} @@ -2152,6 +2199,7 @@ function ImportArriveQueueTab({
)} + setConfirmAction(null)} /> ); } @@ -2172,6 +2220,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { ); const readyMutation = useMutation(api.warehouses.markReadyForPickup.mutationOptions()); const [selected, setSelected] = useState>(new Set()); + const [confirmAction, setConfirmAction] = useState(null); const [inspectId, setInspectId] = useState(null); const [busyId, setBusyId] = useState(null); const [viewItem, setViewItem] = useState(null); @@ -2203,7 +2252,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { const r = await inspectMutation.mutateAsync({ inventoryIds: [...selected] }); toast({ title: `${r.inspectedCount} marked inspected`, - description: r.skippedCount ? `${r.skippedCount} skipped` : undefined, + description: skippedSummary(r.skippedCount, r.results), }); setSelected(new Set()); void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] }); @@ -2307,7 +2356,14 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { leftSection={} disabled={selected.size === 0} loading={inspectMutation.isPending} - onClick={markInspected} + onClick={() => + setConfirmAction({ + title: 'Mark inspected', + message: `Mark ${selected.size} selected item(s) as inspection PASSED? Passed import items become ready for pickup.`, + confirmLabel: `Mark ${selected.size} inspected`, + run: markInspected, + }) + } > Mark Selected as Inspected @@ -2323,7 +2379,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { No unloaded import items. Items appear here after Auto Unload on an arrived train. ) : ( - + @@ -2384,7 +2440,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { - {r.currentStatus} + @@ -2506,6 +2562,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { bookingId={containerItemsItem?.booking?.id ?? null} bookingReference={containerItemsItem?.booking?.reference ?? null} /> + setConfirmAction(null)} /> ); } From 707923b603b7f3a54daea107a1c2c5d9e423669a Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 10 Jul 2026 09:51:36 +0000 Subject: [PATCH 6/9] feat(warehouses): expandable booking rows list containers / cargo inline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every booking row in the queue tables (export received, ready-to-load, loaded/dispatch, import unloaded) gets a chevron that expands to the booking's items: container number, goods, lifecycle stage badge (PENDING..DELIVERED), carrying truck, and GRN. Bulk bookings (no container units) show a one-line summary instead ("Bulk cargo — Coffee export cargo, 3,200 t"). The expansion reuses the ['container-items', bookingId] query the container modal already uses, so data is shared and instant after either has loaded it. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../warehouses/ReceiveInventoryModal.tsx | 169 +++++++++++++++++- 1 file changed, 165 insertions(+), 4 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx index 465b59aca..55722f04a 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -257,6 +257,83 @@ const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayl + +const SUB_STAGE_COLOR: Record = { + PENDING: 'gray', + RECEIVED: 'blue', + GRN: 'teal', + ASSIGNED: 'indigo', + LOADED: 'grape', + LEFT: 'orange', + DELIVERED: 'green', +}; + +/** + * Expanded booking row: the booking's containers / bulk items with their + * lifecycle stage. Shares the ['container-items', bookingId] cache with + * ContainerItemsModal, so expanding after using the modal is instant. + */ +function BookingItemsExpansion({ + bookingId, + colSpan, + bulkFallback, +}: { + bookingId: string | null; + colSpan: number; + bulkFallback?: string; +}) { + const { data: items = [], isLoading } = useQuery({ + queryKey: ['container-items', bookingId], + queryFn: () => warehouseService.getContainerItems(bookingId as string), + enabled: Boolean(bookingId), + }); + + return ( + + + {isLoading ? ( + + + + ) : items.length === 0 ? ( + + {bulkFallback ?? 'No container units recorded on this booking.'} + + ) : ( +
+ + + Container # + Goods + Stage + Truck + GRN + + + + {items.map((i) => ( + + + {i.containerNumber} + + {i.goods ?? '—'} + + + {i.stage} + + + {i.truckPlate ?? '—'} + {i.grnNumber ?? '—'} + + ))} + +
+ )} + +
+ ); +} + type ConfirmAction = { title: string; message: string; confirmLabel: string; run: () => void }; /** One-click bulk actions are irreversible — make the click deliberate. */ @@ -1295,6 +1372,7 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged ); const [selected, setSelected] = useState>(new Set()); const [confirmAction, setConfirmAction] = useState(null); + const [expandedRow, setExpandedRow] = useState(null); const [inspectId, setInspectId] = useState(null); const pendingRows = rows.filter((r) => r.inspectionStatus !== 'PASSED'); @@ -1365,6 +1443,7 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged + { const selectable = r.inspectionStatus !== 'PASSED'; return ( - + + + + setExpandedRow(expandedRow === r.id ? null : r.id)} + > + {expandedRow === r.id ? : } + + + {expandedRow === r.id && ( + + )} + ); })} @@ -1450,6 +1548,7 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: ); const qc = useQueryClient(); const [trainPickerOpen, setTrainPickerOpen] = useState(false); + const [expandedRow, setExpandedRow] = useState(null); const [targetScheduleId, setTargetScheduleId] = useState(null); // Loading is always onto a SPECIFIC pre-dispatch train. No train -> no auto-load. const { data: trains = [], isLoading: trainsLoading } = useQuery({ @@ -1571,6 +1670,7 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
+ Booking Ref GRN Customer Name @@ -1584,7 +1684,18 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: {rows.map((r: ReadyToLoadRow) => ( - + + + + setExpandedRow(expandedRow === r.id ? null : r.id)} + > + {expandedRow === r.id ? : } + + {r.bookingReference ?? '—'} @@ -1607,6 +1718,14 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: + {expandedRow === r.id && ( + + )} + ))}
@@ -1634,6 +1753,7 @@ function LoadedExportTab({ api.warehouses.loadedExport.queryOptions({ enabled }), ); const [confirmAction, setConfirmAction] = useState(null); + const [expandedRow, setExpandedRow] = useState(null); const bulkDispatch = useMutation( api.warehouses.bulkDispatchExport.mutationOptions(), ); @@ -1743,6 +1863,7 @@ function LoadedExportTab({ /> )} + Booking Ref GRN Customer Name @@ -1755,7 +1876,8 @@ function LoadedExportTab({
{rows.map((r: ReadyToLoadRow) => ( - + + {dispatchable && ( )} + + setExpandedRow(expandedRow === r.id ? null : r.id)} + > + {expandedRow === r.id ? : } + + {r.bookingReference ?? '—'} @@ -1782,6 +1914,14 @@ function LoadedExportTab({ + {expandedRow === r.id && ( + + )} + ))} @@ -2221,6 +2361,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { const readyMutation = useMutation(api.warehouses.markReadyForPickup.mutationOptions()); const [selected, setSelected] = useState>(new Set()); const [confirmAction, setConfirmAction] = useState(null); + const [expandedRow, setExpandedRow] = useState(null); const [inspectId, setInspectId] = useState(null); const [busyId, setBusyId] = useState(null); const [viewItem, setViewItem] = useState(null); @@ -2383,6 +2524,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { + {rows.map((r: ImportUnloadedItem) => ( - + + + + setExpandedRow(expandedRow === r.id ? null : r.id)} + > + {expandedRow === r.id ? : } + + + {expandedRow === r.id && ( + + )} + ))}
From 4ca436348919d957af0c8ad3fcf16a1b2fe44d1c Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 10 Jul 2026 09:56:20 +0000 Subject: [PATCH 7/9] fix(billing): fallback PDF renders duplicate-copy documents as watermarked pages buildTabularFallbackPdf parsed the whole HTML at once, so a two-copy document (freight order: Port Operations copy + Gate Security & Carrier copy) came out as ONE page with every tile duplicated and no watermarks. - documents wrapped in
now render one page per copy, each parsed independently (no more merged/duplicated tiles) - each page carries its copy label as a large rotated light-gray watermark, drawn beneath the content (new watermarkOp, 30-degree text matrix) - new assemblePdf() multi-page assembler; assembleSinglePagePdf untouched for its existing callers - the meta label is parsed from the document ("Booking") instead of the hardcoded "TRAIN / SCHEDULE" Verified by compiling the util standalone and rendering a two-copy freight order: 2 pages, both watermarks present, tiles once per page, valid xref/EOF; copy-less documents (marshalling) still render a single unwatermarked page. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../billing/documents/styled-pdf.util.ts | 72 ++++++++++++++++++- 1 file changed, 70 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-api/src/modules/billing/documents/styled-pdf.util.ts b/apps/edr-freight-api/src/modules/billing/documents/styled-pdf.util.ts index b4f168e4e..f75a284a4 100644 --- a/apps/edr-freight-api/src/modules/billing/documents/styled-pdf.util.ts +++ b/apps/edr-freight-api/src/modules/billing/documents/styled-pdf.util.ts @@ -143,6 +143,20 @@ export function htmlToText(html: string): string { .trim(); } +/** + * Large rotated light-gray copy label (e.g. "Copy 1: Port Operations Copy"), + * drawn FIRST so the page content sits on top of it. 30-degree rotation via a + * text matrix; roughly centered on the page. + */ +export function watermarkOp(text: string, page: { width: number; height: number }): string { + const label = clipText(text, 46); + const size = 34; + const w = textWidth(label, size); + const x = page.width / 2 - (w * 0.866) / 2; + const y = page.height / 2 - (w * 0.5) / 2; + return `q BT 0.93 0.93 0.93 rg /F2 ${size} Tf 0.866 0.5 -0.5 0.866 ${x.toFixed(1)} ${y.toFixed(1)} Tm (${escapePdfText(label)}) Tj ET Q`; +} + /** * Parse a "summary tiles + one + notice + signature lines" document (the * marshalling / load-list layout the train-scheduling builders emit) and draw it as a @@ -150,6 +164,16 @@ export function htmlToText(html: string): string { * document, not a flat text dump. Switches to landscape when the table is wide. */ export function buildTabularFallbackPdf(html: string): Buffer { + // Documents printed in duplicate wrap each copy in
+ // (freight order: Port Operations copy + Gate Security copy). Render one + // page per copy, each with its own watermark and tile set — parsing the + // whole HTML at once would merge both copies' tiles and drop the watermarks. + const copies = [...html.matchAll(/
([\s\S]*?)<\/section>/gi)].map((m) => m[1]); + const fragments = copies.length ? copies : [html]; + return assemblePdf(fragments.map((fragment) => buildTabularPageOps(fragment))); +} + +function buildTabularPageOps(html: string): { ops: string[]; page: { width: number; height: number } } { const pick = (re: RegExp) => html.match(re)?.[1]; const title = htmlToText(pick(/]*>([\s\S]*?)<\/h1>/i) ?? "Document"); const subtitle = htmlToText(pick(/class="subtitle"[^>]*>([\s\S]*?)<\/div>/i) ?? ""); @@ -182,13 +206,19 @@ export function buildTabularFallbackPdf(html: string): Buffer { const right = page.width - M; const ops: string[] = []; + // Copy watermark, underneath everything else. + const watermark = htmlToText(pick(/class="watermark"[^>]*>([\s\S]*?)<\/div>/i) ?? ""); + if (watermark) ops.push(watermarkOp(watermark, page)); + // Header ops.push(lineOp(M, page.height - 28, right, page.height - 28, PdfColor.teal, 2.4)); ops.push(textOp("ETHIO-DJIBOUTI RAILWAY S.C.", M, page.height - 44, 8.5, "F2", PdfColor.gray)); ops.push(textOp(clipText(title, landscape ? 82 : 52), M, page.height - 68, 19, "F2", PdfColor.dark)); if (subtitle) ops.push(textOp(clipText(subtitle, 96), M, page.height - 82, 9, "F1", PdfColor.gray)); + const metaLabel = + htmlToText(pick(/class="meta"[^>]*>([\s\S]*?)>\nstartxref\n${xrefOffset}\n%%EOF\n`; return Buffer.from(pdf, "latin1"); } + +/** Assemble a multi-page PDF; one content stream per page, shared Helvetica fonts. */ +export function assemblePdf( + pages: Array<{ ops: string[]; page: { width: number; height: number } }>, +): Buffer { + const kids = pages.map((_, i) => `${5 + i * 2} 0 R`).join(" "); + const objects: string[] = [ + "<< /Type /Catalog /Pages 2 0 R >>", + `<< /Type /Pages /Kids [${kids}] /Count ${pages.length} >>`, + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>", + ]; + for (const [i, p] of pages.entries()) { + const stream = p.ops.join("\n"); + objects.push( + `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${p.page.width} ${p.page.height}] /Resources << /Font << /F1 3 0 R /F2 4 0 R >> >> /Contents ${6 + i * 2} 0 R >>`, + ); + objects.push(`<< /Length ${Buffer.byteLength(stream, "latin1")} >>\nstream\n${stream}\nendstream`); + } + + let pdf = "%PDF-1.4\n"; + const offsets: number[] = [0]; + objects.forEach((object, index) => { + offsets.push(Buffer.byteLength(pdf, "latin1")); + pdf += `${index + 1} 0 obj\n${object}\nendobj\n`; + }); + while (Buffer.byteLength(pdf, "latin1") < MIN_VALID_PDF_BYTES) { + pdf += "% fallback padding\n"; + } + const xrefOffset = Buffer.byteLength(pdf, "latin1"); + pdf += `xref\n0 ${objects.length + 1}\n`; + pdf += "0000000000 65535 f \n"; + for (const offset of offsets.slice(1)) { + pdf += `${String(offset).padStart(10, "0")} 00000 n \n`; + } + pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`; + return Buffer.from(pdf, "latin1"); +} From 00875dd2f44e936acb60d02054bcf25ffad673a5 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 10 Jul 2026 10:05:00 +0000 Subject: [PATCH 8/9] fix(billing): fallback PDF paginates long tables instead of truncating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The marshalling manifest cut off at one page ("... 10 more row(s) not shown") because the fallback drew rows only until it hit the bottom band. Now the table flows across as many pages as it needs: - page 1 keeps the full header + summary tiles; continuation pages get a slim "(continued — page N)" header and a re-drawn table header - the verification notice and signature lines stay pinned to the final page, moving to a fresh page when rows run too deep for the bottom band - the copy watermark repeats on every page of its copy - a 12-page safety cap keeps the old truncation note as a last resort Verified by standalone render: 50-row manifest -> 2 pages, all 50 rows, no truncation; 5-row doc stays 1 page; two-copy freight order still renders 2 watermarked pages. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../billing/documents/styled-pdf.util.ts | 122 ++++++++++++------ 1 file changed, 81 insertions(+), 41 deletions(-) diff --git a/apps/edr-freight-api/src/modules/billing/documents/styled-pdf.util.ts b/apps/edr-freight-api/src/modules/billing/documents/styled-pdf.util.ts index f75a284a4..d2a88b683 100644 --- a/apps/edr-freight-api/src/modules/billing/documents/styled-pdf.util.ts +++ b/apps/edr-freight-api/src/modules/billing/documents/styled-pdf.util.ts @@ -170,15 +170,20 @@ export function buildTabularFallbackPdf(html: string): Buffer { // whole HTML at once would merge both copies' tiles and drop the watermarks. const copies = [...html.matchAll(/
([\s\S]*?)<\/section>/gi)].map((m) => m[1]); const fragments = copies.length ? copies : [html]; - return assemblePdf(fragments.map((fragment) => buildTabularPageOps(fragment))); + return assemblePdf(fragments.flatMap((fragment) => buildTabularPageOps(fragment))); } -function buildTabularPageOps(html: string): { ops: string[]; page: { width: number; height: number } } { +function buildTabularPageOps( + html: string, +): Array<{ ops: string[]; page: { width: number; height: number } }> { const pick = (re: RegExp) => html.match(re)?.[1]; const title = htmlToText(pick(/]*>([\s\S]*?)<\/h1>/i) ?? "Document"); const subtitle = htmlToText(pick(/class="subtitle"[^>]*>([\s\S]*?)<\/div>/i) ?? ""); const metaRef = htmlToText(pick(/class="meta"[\s\S]*?([\s\S]*?)<\/strong>/i) ?? ""); + const metaLabel = + htmlToText(pick(/class="meta"[^>]*>([\s\S]*?)]*>([\s\S]*?)<\/div>/i) ?? ""); const tiles: Array<[string, string]> = []; for (const m of html.matchAll( @@ -204,30 +209,49 @@ function buildTabularPageOps(html: string): { ops: string[]; page: { width: numb const M = 32; const contentW = page.width - M * 2; const right = page.width - M; - const ops: string[] = []; + const MAX_PAGES = 12; - // Copy watermark, underneath everything else. - const watermark = htmlToText(pick(/class="watermark"[^>]*>([\s\S]*?)<\/div>/i) ?? ""); - if (watermark) ops.push(watermarkOp(watermark, page)); + const pagesOut: Array<{ ops: string[]; page: { width: number; height: number } }> = []; + let ops: string[] = []; + let y = 0; - // Header - ops.push(lineOp(M, page.height - 28, right, page.height - 28, PdfColor.teal, 2.4)); - ops.push(textOp("ETHIO-DJIBOUTI RAILWAY S.C.", M, page.height - 44, 8.5, "F2", PdfColor.gray)); - ops.push(textOp(clipText(title, landscape ? 82 : 52), M, page.height - 68, 19, "F2", PdfColor.dark)); - if (subtitle) ops.push(textOp(clipText(subtitle, 96), M, page.height - 82, 9, "F1", PdfColor.gray)); - const metaLabel = - htmlToText(pick(/class="meta"[^>]*>([\s\S]*?) { + ops.push(lineOp(M, page.height - 28, right, page.height - 28, PdfColor.teal, 2.4)); + ops.push(textOp("ETHIO-DJIBOUTI RAILWAY S.C.", M, page.height - 44, 8.5, "F2", PdfColor.gray)); + ops.push(textOp(clipText(title, landscape ? 82 : 52), M, page.height - 68, 19, "F2", PdfColor.dark)); + if (subtitle) ops.push(textOp(clipText(subtitle, 96), M, page.height - 82, 9, "F1", PdfColor.gray)); + if (metaRef) { + ops.push(textOpRight(clipText(metaLabel, 26), right, page.height - 42, 7.5, "F2", PdfColor.gray)); + ops.push(textOpRight(clipText(metaRef, 28), right, page.height - 58, 12, "F2", PdfColor.dark)); + } + if (generated) { + ops.push(textOpRight(clipText(`Generated ${generated}`, 40), right, page.height - 72, 8, "F1", PdfColor.gray)); + } + ops.push(lineOp(M, page.height - 92, right, page.height - 92, PdfColor.line, 1)); + y = page.height - 100; + }; - // Summary tiles - let y = page.height - 100; + const drawContinuationHeader = (pageNo: number) => { + ops.push(lineOp(M, page.height - 24, right, page.height - 24, PdfColor.teal, 1.6)); + ops.push( + textOp(clipText(`${title} (continued — page ${pageNo})`, landscape ? 100 : 68), M, page.height - 42, 11, "F2", PdfColor.dark), + ); + if (metaRef) ops.push(textOpRight(clipText(metaRef, 28), right, page.height - 42, 10, "F2", PdfColor.gray)); + y = page.height - 54; + }; + + const startPage = (first: boolean) => { + ops = []; + if (watermark) ops.push(watermarkOp(watermark, page)); + if (first) drawFullHeader(); + else drawContinuationHeader(pagesOut.length + 1); + }; + + const finishPage = () => pagesOut.push({ ops, page }); + + startPage(true); + + // Summary tiles (first page only) if (tiles.length) { const cols = landscape ? 6 : 4; const tileW = contentW / cols; @@ -243,21 +267,34 @@ function buildTabularPageOps(html: string): { ops: string[]; page: { width: numb y -= tileH + 12; } - // Table + // Table, paginated across as many pages as the rows need. if (headers.length) { const colW = contentW / headers.length; const headerH = 16; const rowH = 14; const cellChars = Math.max(4, Math.floor(colW / 3.9)); - ops.push(rectOp(M, y - headerH, contentW, headerH, PdfColor.tint, PdfColor.line, 0.6)); - headers.forEach((h, c) => - ops.push(textOp(clipText(h, cellChars), M + c * colW + 4, y - 11, 7, "F2", PdfColor.teal)), - ); - y -= headerH; + const bottomReserve = 46; // keep clear of the page edge on row-only pages - let shown = 0; - for (const row of rows) { - if (y < 96) break; + const drawTableHeader = () => { + ops.push(rectOp(M, y - headerH, contentW, headerH, PdfColor.tint, PdfColor.line, 0.6)); + headers.forEach((h, c) => + ops.push(textOp(clipText(h, cellChars), M + c * colW + 4, y - 11, 7, "F2", PdfColor.teal)), + ); + y -= headerH; + }; + + drawTableHeader(); + let truncated = 0; + for (const [index, row] of rows.entries()) { + if (y - rowH < bottomReserve) { + if (pagesOut.length + 1 >= MAX_PAGES) { + truncated = rows.length - index; + break; + } + finishPage(); + startPage(false); + drawTableHeader(); + } ops.push(rectOp(M, y - rowH, contentW, rowH, "1 1 1", PdfColor.line, 0.4)); headers.forEach((_h, c) => { if (c > 0) ops.push(lineOp(M + c * colW, y - rowH, M + c * colW, y, PdfColor.line, 0.3)); @@ -265,30 +302,33 @@ function buildTabularPageOps(html: string): { ops: string[]; page: { width: numb if (cell) ops.push(textOp(clipText(cell, cellChars), M + c * colW + 4, y - 10, 6.8, "F1", PdfColor.dark)); }); y -= rowH; - shown += 1; } - if (shown < rows.length) { - ops.push(textOp(`... ${rows.length - shown} more row(s) not shown`, M, y - 10, 7, "F1", PdfColor.gray)); + if (truncated > 0) { + ops.push(textOp(`... ${truncated} more row(s) not shown`, M, y - 10, 7, "F1", PdfColor.gray)); } } - // Notice (verification clause) + // Notice + signatures live on the final page; give them a fresh page when the + // rows ran too deep for the fixed bottom band. + if (y < 110 && (notice || signatures.length)) { + finishPage(); + startPage(false); + } if (notice) { ops.push(lineOp(M, 78, M, 54, PdfColor.teal, 2)); wrapText(notice, landscape ? 155 : 104) .slice(0, 2) .forEach((ln, i) => ops.push(textOp(ln, M + 8, 72 - i * 11, 7.5, "F1", PdfColor.gray))); } - - // Signatures const sigW = contentW / signatures.length; - signatures.forEach((s, i) => { + signatures.forEach((sig, i) => { const x = M + i * sigW; ops.push(lineOp(x, 40, x + sigW - 18, 40, PdfColor.dark, 0.7)); - ops.push(textOp(clipText(s, Math.floor((sigW - 18) / 3.6)), x, 30, 7, "F1", PdfColor.gray)); + ops.push(textOp(clipText(sig, Math.floor((sigW - 18) / 3.6)), x, 30, 7, "F1", PdfColor.gray)); }); + finishPage(); - return { ops, page }; + return pagesOut; } /** Greedy word-wrap to a maximum character width. */ From 9d147fbe2646d590cfbbd908b6be4e70603fe41b Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 10 Jul 2026 10:13:46 +0000 Subject: [PATCH 9/9] fix(warehouses): truck-arrival crash on duplicate containers; auto-GRN on import unload Truck Arrival died with "[@mantine/core] Duplicate options are not supported" when a booking carried the same container number on two container lines (the unit uniqueness is per line, not per booking): - bookingContainerWeights now GROUPs BY container number (MAX weight) - the weighing modal dedupes both the containers MultiSelect and the assigned-trucks Select defensively, so bad data degrades instead of crashing the whole page Import GRN is now issued automatically when the arrived train is unloaded: autoUnloadArrivedBookings stamps GRN-IMPORT-- on both the create and update paths (never overwrites an existing GRN). The per-row GRN button in the import queue lights up with no extra clicks. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../warehouses/warehouse-inventory.service.ts | 6 ++++- .../warehouses/ReleaseOrderModal.tsx | 24 ++++++++++++++----- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 8f7a71fc1..281d3f620 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -1597,6 +1597,8 @@ export class WarehouseInventoryService { status: 'UNLOADED', unloadedAt: now, arrivedAt: existing.arrivedAt ?? now, + // Import GRN is issued automatically at train unload. + ...(existing.grnNumber ? {} : { grnNumber: this.generateGrnNumber('IMPORT', booking.id, now) }), }); await this.activityLog.record({ activityType: 'INVENTORY_UNLOADED', @@ -1630,6 +1632,7 @@ export class WarehouseInventoryService { quantity: 1, weight: Number(booking.weight) || 0, status: 'UNLOADED', + grnNumber: this.generateGrnNumber('IMPORT', booking.id, now), arrivedAt: now, unloadedAt: now, notes: allocated?.rule ? `Unloaded → ${allocated.path}` : 'Unloaded from arrived import train', @@ -2757,11 +2760,12 @@ export class WarehouseInventoryService { const rows: Array<{ containerNumber: string; weightTons: string }> = await this.dataSource.query( `SELECT bcu.container_number AS "containerNumber", - COALESCE(bcu.vgm_tons, 0) AS "weightTons" + MAX(COALESCE(bcu.vgm_tons, 0)) AS "weightTons" FROM freight.booking_container_units bcu JOIN freight.booking_container bc ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL + GROUP BY bcu.container_number ORDER BY bcu.container_number`, [bookingId], ); diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx index cfa271076..6833167f7 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx @@ -202,8 +202,11 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea ]; // Only trucks actually assigned to THIS booking (last-mile prefill or customer // portal) are selectable. No global fleet list — if nothing is assigned, the - // operator types the plate manually in the field below. - const truckSelectOptions = assignedTruckOptions; + // operator types the plate manually in the field below. Deduped by plate: + // duplicate option values crash Mantine's Select. + const truckSelectOptions = [ + ...new Map(assignedTruckOptions.map((t) => [t.value, t])).values(), + ]; // Neither a last-mile truck nor a customer truck has been assigned yet. const noTruckAssigned = assignedTruckOptions.length === 0 && !isCustomerAssignedTruck; const isTruckIdentityLocked = isEntranceLocked || isCustomerAssignedTruck || hasLastMileTruckPrefill; @@ -216,10 +219,19 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea const containerWeightByNumber = new Map( containerWeights.map((c) => [c.containerNumber.toUpperCase(), Number(c.weightTons) || 0]), ); - const containerSelectData = containerWeights.map((c) => ({ - value: c.containerNumber, - label: `${c.containerNumber} · ${(Number(c.weightTons) || 0).toLocaleString()} t`, - })); + // Mantine Selects throw on duplicate option values — legacy bookings can carry + // the same container number on two lines, so dedupe defensively. + const containerSelectData = [ + ...new Map( + containerWeights.map((c) => [ + c.containerNumber, + { + value: c.containerNumber, + label: `${c.containerNumber} · ${(Number(c.weightTons) || 0).toLocaleString()} t`, + }, + ]), + ).values(), + ]; const selectedContainerNumbers = containerNumbers.map((n) => n.trim()).filter(Boolean); const selectedCargoWeight = Number( selectedContainerNumbers