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: | 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..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 @@ -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,11 +164,26 @@ 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.flatMap((fragment) => buildTabularPageOps(fragment))); +} + +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( @@ -180,24 +209,49 @@ export function buildTabularFallbackPdf(html: string): Buffer { const M = 32; const contentW = page.width - M * 2; const right = page.width - M; - const ops: string[] = []; + const MAX_PAGES = 12; - // 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)); - if (metaRef) { - ops.push(textOpRight("TRAIN / SCHEDULE", 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)); + const pagesOut: Array<{ ops: string[]; page: { width: number; height: number } }> = []; + let ops: string[] = []; + let y = 0; - // Summary tiles - let y = page.height - 100; + const drawFullHeader = () => { + 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; + }; + + 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; @@ -213,21 +267,34 @@ export function buildTabularFallbackPdf(html: string): Buffer { 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)); @@ -235,30 +302,33 @@ export function buildTabularFallbackPdf(html: string): Buffer { 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 assembleSinglePagePdf(ops, page); + return pagesOut; } /** Greedy word-wrap to a maximum character width. */ @@ -320,3 +390,41 @@ export function assembleSinglePagePdf( pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\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"); +} 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/ReceiveInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx index ff8dc0abd..55722f04a 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,127 @@ const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayl warehouseManagerName: form.warehouseManagerName.trim() || undefined, }); + + + +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. */ +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 +983,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 +1153,7 @@ function EligibleTab({ : `No eligible PAID ${direction.toLowerCase()} bookings to receive.`} ) : ( - + @@ -1043,8 +1166,6 @@ function EligibleTab({ /> Booking Ref - Booking ID - Customer ID Customer Name Origin Destination @@ -1077,12 +1198,6 @@ function EligibleTab({ {r.reference} - - {r.id.slice(0, 8)}… - - - {r.customerId ? `${r.customerId.slice(0, 8)}…` : '—'} - {r.customer ?? '—'} {r.origin ?? '—'} {r.destination ?? '—'} @@ -1256,6 +1371,8 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged api.warehouses.bulkMarkInspected.mutationOptions(), ); 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'); @@ -1279,7 +1396,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?.(); @@ -1300,7 +1417,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 @@ -1315,10 +1439,11 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged No received export items awaiting inspection. ) : ( - +
+ Booking Ref GRN - Booking ID - Customer ID Customer Name Container / Cargo Items Cargo Type @@ -1345,7 +1468,18 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged {rows.map((r: ReadyToLoadRow) => { const selectable = r.inspectionStatus !== 'PASSED'; return ( - + + + + setExpandedRow(expandedRow === r.id ? null : r.id)} + > + {expandedRow === r.id ? : } + + - - {r.bookingReference ?? '—'} - - + {r.bookingReference ?? '—'} - - {r.bookingId ? `${r.bookingId.slice(0, 8)}…` : '—'} - - - {r.customerId ? `${r.customerId.slice(0, 8)}…` : '—'} - {r.customerName ?? '—'} {r.containerNumber ?? '—'} {r.cargoType ?? '—'} @@ -1382,9 +1507,7 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged - - {r.status} - + + {expandedRow === r.id && ( + + )} + ); })} @@ -1404,6 +1535,7 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged opened={Boolean(inspectId)} onClose={() => setInspectId(null)} /> + setConfirmAction(null)} /> ); } @@ -1415,8 +1547,8 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: api.warehouses.readyToLoadExport.queryOptions({ enabled }), ); const qc = useQueryClient(); - const [selected, setSelected] = useState>(new Set()); 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({ @@ -1439,15 +1571,6 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: }, }); - const allSelected = rows.length > 0 && selected.size === rows.length; - const someSelected = selected.size > 0 && !allSelected; - const toggleAll = () => setSelected(allSelected ? new Set() : new Set(rows.map((r) => r.id))); - const toggleOne = (id: string) => - setSelected((prev) => { - const next = new Set(prev); - next.has(id) ? next.delete(id) : next.add(id); - return next; - }); const confirmLoad = async () => { if (!targetScheduleId) { @@ -1465,7 +1588,6 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: }); setTrainPickerOpen(false); setTargetScheduleId(null); - setSelected(new Set()); onChanged?.(); } catch (error) { toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(error) }); @@ -1544,22 +1666,13 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: No EXPORT items with inspection PASSED waiting to be loaded. ) : ( - +
- - - + Booking Ref GRN - Booking ID - Customer ID Customer Name Container # Cargo Type @@ -1571,29 +1684,24 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: {rows.map((r: ReadyToLoadRow) => ( - + + - toggleOne(r.id)} - /> + setExpandedRow(expandedRow === r.id ? null : r.id)} + > + {expandedRow === r.id ? : } + - - {r.bookingReference ?? '—'} - - + {r.bookingReference ?? '—'} - - {r.bookingId ? `${r.bookingId.slice(0, 8)}…` : '—'} - - - {r.customerId ? `${r.customerId.slice(0, 8)}…` : '—'} - {r.customerName ?? '—'} {r.containerNumber ?? '—'} {r.cargoType ?? '—'} @@ -1607,11 +1715,17 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: - - {r.status} - + + {expandedRow === r.id && ( + + )} + ))}
@@ -1638,6 +1752,8 @@ function LoadedExportTab({ const { data: rows = [], isLoading } = useQuery( api.warehouses.loadedExport.queryOptions({ enabled }), ); + const [confirmAction, setConfirmAction] = useState(null); + const [expandedRow, setExpandedRow] = useState(null); const bulkDispatch = useMutation( api.warehouses.bulkDispatchExport.mutationOptions(), ); @@ -1662,7 +1778,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?.(); @@ -1692,7 +1808,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 @@ -1702,7 +1825,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 @@ -1719,7 +1849,7 @@ function LoadedExportTab({ No LOADED export items {dispatchable ? 'waiting to dispatch' : 'yet'}. ) : ( - + @@ -1733,10 +1863,9 @@ function LoadedExportTab({ /> )} + Booking Ref GRN - Booking ID - Customer ID Customer Name Container # Cargo Type @@ -1747,7 +1876,8 @@ function LoadedExportTab({ {rows.map((r: ReadyToLoadRow) => ( - + + {dispatchable && ( )} - - {r.bookingReference ?? '—'} - - + setExpandedRow(expandedRow === r.id ? null : r.id)} + > + {expandedRow === r.id ? : } + + + + {r.bookingReference ?? '—'} - - {r.bookingId ? `${r.bookingId.slice(0, 8)}…` : '—'} - - - {r.customerId ? `${r.customerId.slice(0, 8)}…` : '—'} - {r.customerName ?? '—'} {r.containerNumber ?? '—'} {r.cargoType ?? '—'} @@ -1780,16 +1911,23 @@ function LoadedExportTab({ {r.origin || r.destination ? `${r.origin ?? '?'} → ${r.destination ?? '?'}` : '—'} - - {r.status} - + + {expandedRow === r.id && ( + + )} + ))}
)} + setConfirmAction(null)} /> ); } @@ -1891,9 +2029,7 @@ function ImportTrainDetailTable({ Wagon - Booking ID Booking Ref - Customer ID Customer Name Container # Cargo Type @@ -1927,15 +2063,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 ?? '—'} @@ -2025,6 +2155,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> @@ -2066,7 +2197,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) @@ -2162,7 +2293,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'} @@ -2201,6 +2339,7 @@ function ImportArriveQueueTab({
)} + setConfirmAction(null)} /> ); } @@ -2221,6 +2360,8 @@ 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); @@ -2252,7 +2393,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'] }); @@ -2356,7 +2497,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 @@ -2372,10 +2520,11 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { No unloaded import items. Items appear here after Auto Unload on an arrived train. ) : ( - + + (allSelected ? unselectAll() : selectAll())} /> - Booking ID Booking Ref GRN - Customer ID Customer Name Arrival Time Container # @@ -2403,7 +2550,18 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { {rows.map((r: ImportUnloadedItem) => ( - + + + + setExpandedRow(expandedRow === r.id ? null : r.id)} + > + {expandedRow === r.id ? : } + + - {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 ?? '—'} @@ -2444,7 +2593,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { - {r.currentStatus} + @@ -2538,6 +2687,14 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { + {expandedRow === r.id && ( + + )} + ))}
@@ -2566,6 +2723,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { bookingId={containerItemsItem?.booking?.id ?? null} bookingReference={containerItemsItem?.booking?.reference ?? null} /> + setConfirmAction(null)} /> ); } 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