mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 03:05:42 +00:00
Merge pull request #599 from Tria-plc/Truckdetantion
Truckdetantion Duplicate container number fix, Remove two GRN button and UUID
This commit is contained in:
37
.github/workflows/deploy.yml
vendored
37
.github/workflows/deploy.yml
vendored
@@ -17,10 +17,23 @@ jobs:
|
|||||||
outputs:
|
outputs:
|
||||||
matrix: ${{ steps.filter.outputs.matrix }}
|
matrix: ${{ steps.filter.outputs.matrix }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
# Plain git instead of actions/checkout: self-hosted runners on this
|
||||||
uses: actions/checkout@v4
|
# network intermittently time out downloading action tarballs from
|
||||||
with:
|
# codeload.github.com (100s HttpClient limit x3 = dead job). git fetch
|
||||||
fetch-depth: 2
|
# 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
|
- name: Determine changed services
|
||||||
id: filter
|
id: filter
|
||||||
@@ -103,8 +116,20 @@ jobs:
|
|||||||
COMPOSE_DOCKER_CLI_BUILD: "1"
|
COMPOSE_DOCKER_CLI_BUILD: "1"
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
# Same rationale as detect-changes: no action download on this network.
|
||||||
uses: actions/checkout@v4
|
- 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
|
- name: Resolve project and build env file
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -143,6 +143,20 @@ export function htmlToText(html: string): string {
|
|||||||
.trim();
|
.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 <table> + notice + signature lines" document (the
|
* Parse a "summary tiles + one <table> + notice + signature lines" document (the
|
||||||
* marshalling / load-list layout the train-scheduling builders emit) and draw it as a
|
* 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.
|
* document, not a flat text dump. Switches to landscape when the table is wide.
|
||||||
*/
|
*/
|
||||||
export function buildTabularFallbackPdf(html: string): Buffer {
|
export function buildTabularFallbackPdf(html: string): Buffer {
|
||||||
|
// Documents printed in duplicate wrap each copy in <section class="copy">
|
||||||
|
// (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(/<section class="copy">([\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 pick = (re: RegExp) => html.match(re)?.[1];
|
||||||
const title = htmlToText(pick(/<h1[^>]*>([\s\S]*?)<\/h1>/i) ?? "Document");
|
const title = htmlToText(pick(/<h1[^>]*>([\s\S]*?)<\/h1>/i) ?? "Document");
|
||||||
const subtitle = htmlToText(pick(/class="subtitle"[^>]*>([\s\S]*?)<\/div>/i) ?? "");
|
const subtitle = htmlToText(pick(/class="subtitle"[^>]*>([\s\S]*?)<\/div>/i) ?? "");
|
||||||
const metaRef = htmlToText(pick(/class="meta"[\s\S]*?<strong>([\s\S]*?)<\/strong>/i) ?? "");
|
const metaRef = htmlToText(pick(/class="meta"[\s\S]*?<strong>([\s\S]*?)<\/strong>/i) ?? "");
|
||||||
|
const metaLabel =
|
||||||
|
htmlToText(pick(/class="meta"[^>]*>([\s\S]*?)<strong/i) ?? "").toUpperCase() || "REFERENCE";
|
||||||
const generated = htmlToText(pick(/Generated:\s*([^<]+)/i) ?? "");
|
const generated = htmlToText(pick(/Generated:\s*([^<]+)/i) ?? "");
|
||||||
|
const watermark = htmlToText(pick(/class="watermark"[^>]*>([\s\S]*?)<\/div>/i) ?? "");
|
||||||
|
|
||||||
const tiles: Array<[string, string]> = [];
|
const tiles: Array<[string, string]> = [];
|
||||||
for (const m of html.matchAll(
|
for (const m of html.matchAll(
|
||||||
@@ -180,24 +209,49 @@ export function buildTabularFallbackPdf(html: string): Buffer {
|
|||||||
const M = 32;
|
const M = 32;
|
||||||
const contentW = page.width - M * 2;
|
const contentW = page.width - M * 2;
|
||||||
const right = page.width - M;
|
const right = page.width - M;
|
||||||
const ops: string[] = [];
|
const MAX_PAGES = 12;
|
||||||
|
|
||||||
// Header
|
const pagesOut: Array<{ ops: string[]; page: { width: number; height: number } }> = [];
|
||||||
ops.push(lineOp(M, page.height - 28, right, page.height - 28, PdfColor.teal, 2.4));
|
let ops: string[] = [];
|
||||||
ops.push(textOp("ETHIO-DJIBOUTI RAILWAY S.C.", M, page.height - 44, 8.5, "F2", PdfColor.gray));
|
let y = 0;
|
||||||
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));
|
|
||||||
|
|
||||||
// Summary tiles
|
const drawFullHeader = () => {
|
||||||
let y = page.height - 100;
|
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) {
|
if (tiles.length) {
|
||||||
const cols = landscape ? 6 : 4;
|
const cols = landscape ? 6 : 4;
|
||||||
const tileW = contentW / cols;
|
const tileW = contentW / cols;
|
||||||
@@ -213,21 +267,34 @@ export function buildTabularFallbackPdf(html: string): Buffer {
|
|||||||
y -= tileH + 12;
|
y -= tileH + 12;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Table
|
// Table, paginated across as many pages as the rows need.
|
||||||
if (headers.length) {
|
if (headers.length) {
|
||||||
const colW = contentW / headers.length;
|
const colW = contentW / headers.length;
|
||||||
const headerH = 16;
|
const headerH = 16;
|
||||||
const rowH = 14;
|
const rowH = 14;
|
||||||
const cellChars = Math.max(4, Math.floor(colW / 3.9));
|
const cellChars = Math.max(4, Math.floor(colW / 3.9));
|
||||||
ops.push(rectOp(M, y - headerH, contentW, headerH, PdfColor.tint, PdfColor.line, 0.6));
|
const bottomReserve = 46; // keep clear of the page edge on row-only pages
|
||||||
headers.forEach((h, c) =>
|
|
||||||
ops.push(textOp(clipText(h, cellChars), M + c * colW + 4, y - 11, 7, "F2", PdfColor.teal)),
|
|
||||||
);
|
|
||||||
y -= headerH;
|
|
||||||
|
|
||||||
let shown = 0;
|
const drawTableHeader = () => {
|
||||||
for (const row of rows) {
|
ops.push(rectOp(M, y - headerH, contentW, headerH, PdfColor.tint, PdfColor.line, 0.6));
|
||||||
if (y < 96) break;
|
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));
|
ops.push(rectOp(M, y - rowH, contentW, rowH, "1 1 1", PdfColor.line, 0.4));
|
||||||
headers.forEach((_h, c) => {
|
headers.forEach((_h, c) => {
|
||||||
if (c > 0) ops.push(lineOp(M + c * colW, y - rowH, M + c * colW, y, PdfColor.line, 0.3));
|
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));
|
if (cell) ops.push(textOp(clipText(cell, cellChars), M + c * colW + 4, y - 10, 6.8, "F1", PdfColor.dark));
|
||||||
});
|
});
|
||||||
y -= rowH;
|
y -= rowH;
|
||||||
shown += 1;
|
|
||||||
}
|
}
|
||||||
if (shown < rows.length) {
|
if (truncated > 0) {
|
||||||
ops.push(textOp(`... ${rows.length - shown} more row(s) not shown`, M, y - 10, 7, "F1", PdfColor.gray));
|
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) {
|
if (notice) {
|
||||||
ops.push(lineOp(M, 78, M, 54, PdfColor.teal, 2));
|
ops.push(lineOp(M, 78, M, 54, PdfColor.teal, 2));
|
||||||
wrapText(notice, landscape ? 155 : 104)
|
wrapText(notice, landscape ? 155 : 104)
|
||||||
.slice(0, 2)
|
.slice(0, 2)
|
||||||
.forEach((ln, i) => ops.push(textOp(ln, M + 8, 72 - i * 11, 7.5, "F1", PdfColor.gray)));
|
.forEach((ln, i) => ops.push(textOp(ln, M + 8, 72 - i * 11, 7.5, "F1", PdfColor.gray)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Signatures
|
|
||||||
const sigW = contentW / signatures.length;
|
const sigW = contentW / signatures.length;
|
||||||
signatures.forEach((s, i) => {
|
signatures.forEach((sig, i) => {
|
||||||
const x = M + i * sigW;
|
const x = M + i * sigW;
|
||||||
ops.push(lineOp(x, 40, x + sigW - 18, 40, PdfColor.dark, 0.7));
|
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. */
|
/** 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`;
|
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
|
||||||
return Buffer.from(pdf, "latin1");
|
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");
|
||||||
|
}
|
||||||
|
|||||||
@@ -1597,6 +1597,8 @@ export class WarehouseInventoryService {
|
|||||||
status: 'UNLOADED',
|
status: 'UNLOADED',
|
||||||
unloadedAt: now,
|
unloadedAt: now,
|
||||||
arrivedAt: existing.arrivedAt ?? 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({
|
await this.activityLog.record({
|
||||||
activityType: 'INVENTORY_UNLOADED',
|
activityType: 'INVENTORY_UNLOADED',
|
||||||
@@ -1630,6 +1632,7 @@ export class WarehouseInventoryService {
|
|||||||
quantity: 1,
|
quantity: 1,
|
||||||
weight: Number(booking.weight) || 0,
|
weight: Number(booking.weight) || 0,
|
||||||
status: 'UNLOADED',
|
status: 'UNLOADED',
|
||||||
|
grnNumber: this.generateGrnNumber('IMPORT', booking.id, now),
|
||||||
arrivedAt: now,
|
arrivedAt: now,
|
||||||
unloadedAt: now,
|
unloadedAt: now,
|
||||||
notes: allocated?.rule ? `Unloaded → ${allocated.path}` : 'Unloaded from arrived import train',
|
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 }> =
|
const rows: Array<{ containerNumber: string; weightTons: string }> =
|
||||||
await this.dataSource.query(
|
await this.dataSource.query(
|
||||||
`SELECT bcu.container_number AS "containerNumber",
|
`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
|
FROM freight.booking_container_units bcu
|
||||||
JOIN freight.booking_container bc
|
JOIN freight.booking_container bc
|
||||||
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||||||
WHERE bc.booking_id = $1 AND bcu.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`,
|
ORDER BY bcu.container_number`,
|
||||||
[bookingId],
|
[bookingId],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import { warehouseService } from '@/services/warehouse.service';
|
|||||||
import type {
|
import type {
|
||||||
EligibleBooking,
|
EligibleBooking,
|
||||||
InventoryInquiryFilter,
|
InventoryInquiryFilter,
|
||||||
|
InventoryStatus,
|
||||||
InventoryInquiryResult,
|
InventoryInquiryResult,
|
||||||
ImportTrain,
|
ImportTrain,
|
||||||
ImportTrainItem,
|
ImportTrainItem,
|
||||||
@@ -63,6 +64,7 @@ import type {
|
|||||||
WarehouseYard,
|
WarehouseYard,
|
||||||
WarehouseZone,
|
WarehouseZone,
|
||||||
} from '@/types/warehouse';
|
} from '@/types/warehouse';
|
||||||
|
import { InventoryStatusBadge } from './badges';
|
||||||
import { BookingSelect } from './BookingSelect';
|
import { BookingSelect } from './BookingSelect';
|
||||||
import { DeliverInventoryModal } from './DeliverInventoryModal';
|
import { DeliverInventoryModal } from './DeliverInventoryModal';
|
||||||
import { ContainerItemsModal } from './ContainerItemsModal';
|
import { ContainerItemsModal } from './ContainerItemsModal';
|
||||||
@@ -253,6 +255,127 @@ const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayl
|
|||||||
warehouseManagerName: form.warehouseManagerName.trim() || undefined,
|
warehouseManagerName: form.warehouseManagerName.trim() || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const SUB_STAGE_COLOR: Record<string, string> = {
|
||||||
|
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 (
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td colSpan={colSpan} bg="var(--mantine-color-gray-0)">
|
||||||
|
{isLoading ? (
|
||||||
|
<Group justify="center" py="sm">
|
||||||
|
<Loader size="xs" />
|
||||||
|
</Group>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<Text size="xs" c="dimmed" py={6}>
|
||||||
|
{bulkFallback ?? 'No container units recorded on this booking.'}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Table verticalSpacing={4} fz="xs" withTableBorder>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Container #</Table.Th>
|
||||||
|
<Table.Th>Goods</Table.Th>
|
||||||
|
<Table.Th>Stage</Table.Th>
|
||||||
|
<Table.Th>Truck</Table.Th>
|
||||||
|
<Table.Th>GRN</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{items.map((i) => (
|
||||||
|
<Table.Tr key={i.containerNumber}>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="xs" fw={600}>{i.containerNumber}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>{i.goods ?? '—'}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge size="xs" variant="light" color={SUB_STAGE_COLOR[i.stage] ?? 'gray'}>
|
||||||
|
{i.stage}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>{i.truckPlate ?? '—'}</Table.Td>
|
||||||
|
<Table.Td>{i.grnNumber ?? '—'}</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Modal opened={Boolean(action)} onClose={onClose} title={action?.title ?? ''} centered size="sm">
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text size="sm">{action?.message}</Text>
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button variant="default" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
action?.run();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{action?.confirmLabel}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "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<string | null | undefined>) => {
|
const commonNonEmptyValue = (values: Array<string | null | undefined>) => {
|
||||||
const unique = [...new Set(values.map((value) => value?.trim()).filter(Boolean))] as string[];
|
const unique = [...new Set(values.map((value) => value?.trim()).filter(Boolean))] as string[];
|
||||||
return unique.length === 1 ? unique[0] : '';
|
return unique.length === 1 ? unique[0] : '';
|
||||||
@@ -860,7 +983,7 @@ function EligibleTab({
|
|||||||
});
|
});
|
||||||
toast({
|
toast({
|
||||||
title: `${r.receivedCount} received at ${direction === 'EXPORT' ? 'facility' : 'warehouse'}`,
|
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);
|
const firstGrn = r.results.find((item) => item.inventoryId && item.grnNumber);
|
||||||
if (focusedBookingId && firstGrn?.inventoryId && firstGrn.grnNumber) {
|
if (focusedBookingId && firstGrn?.inventoryId && firstGrn.grnNumber) {
|
||||||
@@ -1030,7 +1153,7 @@ function EligibleTab({
|
|||||||
: `No eligible PAID ${direction.toLowerCase()} bookings to receive.`}
|
: `No eligible PAID ${direction.toLowerCase()} bookings to receive.`}
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<Table.ScrollContainer minWidth={1700}>
|
<Table.ScrollContainer minWidth={1350}>
|
||||||
<Table highlightOnHover verticalSpacing="xs" striped>
|
<Table highlightOnHover verticalSpacing="xs" striped>
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
@@ -1043,8 +1166,6 @@ function EligibleTab({
|
|||||||
/>
|
/>
|
||||||
</Table.Th>
|
</Table.Th>
|
||||||
<Table.Th>Booking Ref</Table.Th>
|
<Table.Th>Booking Ref</Table.Th>
|
||||||
<Table.Th>Booking ID</Table.Th>
|
|
||||||
<Table.Th>Customer ID</Table.Th>
|
|
||||||
<Table.Th>Customer Name</Table.Th>
|
<Table.Th>Customer Name</Table.Th>
|
||||||
<Table.Th>Origin</Table.Th>
|
<Table.Th>Origin</Table.Th>
|
||||||
<Table.Th>Destination</Table.Th>
|
<Table.Th>Destination</Table.Th>
|
||||||
@@ -1077,12 +1198,6 @@ function EligibleTab({
|
|||||||
{r.reference}
|
{r.reference}
|
||||||
</Text>
|
</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
|
||||||
<Text size="xs" c="dimmed">{r.id.slice(0, 8)}…</Text>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Text size="xs" c="dimmed">{r.customerId ? `${r.customerId.slice(0, 8)}…` : '—'}</Text>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>{r.customer ?? '—'}</Table.Td>
|
<Table.Td>{r.customer ?? '—'}</Table.Td>
|
||||||
<Table.Td>{r.origin ?? '—'}</Table.Td>
|
<Table.Td>{r.origin ?? '—'}</Table.Td>
|
||||||
<Table.Td>{r.destination ?? '—'}</Table.Td>
|
<Table.Td>{r.destination ?? '—'}</Table.Td>
|
||||||
@@ -1256,6 +1371,8 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
|
|||||||
api.warehouses.bulkMarkInspected.mutationOptions(),
|
api.warehouses.bulkMarkInspected.mutationOptions(),
|
||||||
);
|
);
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
|
||||||
|
const [expandedRow, setExpandedRow] = useState<string | null>(null);
|
||||||
const [inspectId, setInspectId] = useState<string | null>(null);
|
const [inspectId, setInspectId] = useState<string | null>(null);
|
||||||
|
|
||||||
const pendingRows = rows.filter((r) => r.inspectionStatus !== 'PASSED');
|
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] });
|
const r = await inspectMutation.mutateAsync({ inventoryIds: [...selected] });
|
||||||
toast({
|
toast({
|
||||||
title: `${r.inspectedCount} marked inspected`,
|
title: `${r.inspectedCount} marked inspected`,
|
||||||
description: r.skippedCount ? `${r.skippedCount} skipped` : undefined,
|
description: skippedSummary(r.skippedCount, r.results),
|
||||||
});
|
});
|
||||||
setSelected(new Set());
|
setSelected(new Set());
|
||||||
onChanged?.();
|
onChanged?.();
|
||||||
@@ -1300,7 +1417,14 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
|
|||||||
leftSection={<ClipboardCheck size={14} />}
|
leftSection={<ClipboardCheck size={14} />}
|
||||||
disabled={selected.size === 0}
|
disabled={selected.size === 0}
|
||||||
loading={inspectMutation.isPending}
|
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
|
Mark Selected as Inspected
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1315,10 +1439,11 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
|
|||||||
No received export items awaiting inspection.
|
No received export items awaiting inspection.
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<Table.ScrollContainer minWidth={1700}>
|
<Table.ScrollContainer minWidth={1350}>
|
||||||
<Table highlightOnHover verticalSpacing="xs" striped>
|
<Table highlightOnHover verticalSpacing="xs" striped>
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
|
<Table.Th w={34} />
|
||||||
<Table.Th w={40}>
|
<Table.Th w={40}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
aria-label="Select all"
|
aria-label="Select all"
|
||||||
@@ -1329,8 +1454,6 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
|
|||||||
</Table.Th>
|
</Table.Th>
|
||||||
<Table.Th>Booking Ref</Table.Th>
|
<Table.Th>Booking Ref</Table.Th>
|
||||||
<Table.Th>GRN</Table.Th>
|
<Table.Th>GRN</Table.Th>
|
||||||
<Table.Th>Booking ID</Table.Th>
|
|
||||||
<Table.Th>Customer ID</Table.Th>
|
|
||||||
<Table.Th>Customer Name</Table.Th>
|
<Table.Th>Customer Name</Table.Th>
|
||||||
<Table.Th>Container / Cargo Items</Table.Th>
|
<Table.Th>Container / Cargo Items</Table.Th>
|
||||||
<Table.Th>Cargo Type</Table.Th>
|
<Table.Th>Cargo Type</Table.Th>
|
||||||
@@ -1345,7 +1468,18 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
|
|||||||
{rows.map((r: ReadyToLoadRow) => {
|
{rows.map((r: ReadyToLoadRow) => {
|
||||||
const selectable = r.inspectionStatus !== 'PASSED';
|
const selectable = r.inspectionStatus !== 'PASSED';
|
||||||
return (
|
return (
|
||||||
<Table.Tr key={r.id}>
|
<Fragment key={r.id}>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
aria-label="Show containers"
|
||||||
|
onClick={() => setExpandedRow(expandedRow === r.id ? null : r.id)}
|
||||||
|
>
|
||||||
|
{expandedRow === r.id ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||||
|
</ActionIcon>
|
||||||
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
aria-label={`Select ${r.bookingReference ?? r.id}`}
|
aria-label={`Select ${r.bookingReference ?? r.id}`}
|
||||||
@@ -1355,20 +1489,11 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
|
|||||||
/>
|
/>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Stack gap={2}>
|
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
|
||||||
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
|
|
||||||
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
|
|
||||||
</Stack>
|
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
|
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
|
||||||
<Text size="xs" c="dimmed">{r.bookingId ? `${r.bookingId.slice(0, 8)}…` : '—'}</Text>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Text size="xs" c="dimmed">{r.customerId ? `${r.customerId.slice(0, 8)}…` : '—'}</Text>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>{r.customerName ?? '—'}</Table.Td>
|
<Table.Td>{r.customerName ?? '—'}</Table.Td>
|
||||||
<Table.Td>{r.containerNumber ?? '—'}</Table.Td>
|
<Table.Td>{r.containerNumber ?? '—'}</Table.Td>
|
||||||
<Table.Td>{r.cargoType ?? '—'}</Table.Td>
|
<Table.Td>{r.cargoType ?? '—'}</Table.Td>
|
||||||
@@ -1382,9 +1507,7 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
|
|||||||
</Badge>
|
</Badge>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Badge color="blue" variant="light" size="sm">
|
<InventoryStatusBadge status={r.status as InventoryStatus} />
|
||||||
{r.status}
|
|
||||||
</Badge>
|
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td ta="right">
|
<Table.Td ta="right">
|
||||||
<Button size="compact-xs" variant="light" color="orange" onClick={() => setInspectId(r.id)}>
|
<Button size="compact-xs" variant="light" color="orange" onClick={() => setInspectId(r.id)}>
|
||||||
@@ -1392,6 +1515,14 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
|
|||||||
</Button>
|
</Button>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
|
{expandedRow === r.id && (
|
||||||
|
<BookingItemsExpansion
|
||||||
|
bookingId={r.bookingId}
|
||||||
|
colSpan={18}
|
||||||
|
bulkFallback={r.cargoType ? `Bulk cargo — ${r.cargoType}, ${formatNumber(Number(r.weight))} t` : undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Table.Tbody>
|
</Table.Tbody>
|
||||||
@@ -1404,6 +1535,7 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
|
|||||||
opened={Boolean(inspectId)}
|
opened={Boolean(inspectId)}
|
||||||
onClose={() => setInspectId(null)}
|
onClose={() => setInspectId(null)}
|
||||||
/>
|
/>
|
||||||
|
<ConfirmActionModal action={confirmAction} onClose={() => setConfirmAction(null)} />
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1415,8 +1547,8 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
|
|||||||
api.warehouses.readyToLoadExport.queryOptions({ enabled }),
|
api.warehouses.readyToLoadExport.queryOptions({ enabled }),
|
||||||
);
|
);
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
||||||
const [trainPickerOpen, setTrainPickerOpen] = useState(false);
|
const [trainPickerOpen, setTrainPickerOpen] = useState(false);
|
||||||
|
const [expandedRow, setExpandedRow] = useState<string | null>(null);
|
||||||
const [targetScheduleId, setTargetScheduleId] = useState<string | null>(null);
|
const [targetScheduleId, setTargetScheduleId] = useState<string | null>(null);
|
||||||
// Loading is always onto a SPECIFIC pre-dispatch train. No train -> no auto-load.
|
// Loading is always onto a SPECIFIC pre-dispatch train. No train -> no auto-load.
|
||||||
const { data: trains = [], isLoading: trainsLoading } = useQuery({
|
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 () => {
|
const confirmLoad = async () => {
|
||||||
if (!targetScheduleId) {
|
if (!targetScheduleId) {
|
||||||
@@ -1465,7 +1588,6 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
|
|||||||
});
|
});
|
||||||
setTrainPickerOpen(false);
|
setTrainPickerOpen(false);
|
||||||
setTargetScheduleId(null);
|
setTargetScheduleId(null);
|
||||||
setSelected(new Set());
|
|
||||||
onChanged?.();
|
onChanged?.();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(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.
|
No EXPORT items with inspection PASSED waiting to be loaded.
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<Table.ScrollContainer minWidth={1600}>
|
<Table.ScrollContainer minWidth={1200}>
|
||||||
<Table highlightOnHover verticalSpacing="xs" striped>
|
<Table highlightOnHover verticalSpacing="xs" striped>
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Th w={40}>
|
<Table.Th w={34} />
|
||||||
<Checkbox
|
|
||||||
aria-label="Select all"
|
|
||||||
checked={allSelected}
|
|
||||||
indeterminate={someSelected}
|
|
||||||
onChange={toggleAll}
|
|
||||||
/>
|
|
||||||
</Table.Th>
|
|
||||||
<Table.Th>Booking Ref</Table.Th>
|
<Table.Th>Booking Ref</Table.Th>
|
||||||
<Table.Th>GRN</Table.Th>
|
<Table.Th>GRN</Table.Th>
|
||||||
<Table.Th>Booking ID</Table.Th>
|
|
||||||
<Table.Th>Customer ID</Table.Th>
|
|
||||||
<Table.Th>Customer Name</Table.Th>
|
<Table.Th>Customer Name</Table.Th>
|
||||||
<Table.Th>Container #</Table.Th>
|
<Table.Th>Container #</Table.Th>
|
||||||
<Table.Th>Cargo Type</Table.Th>
|
<Table.Th>Cargo Type</Table.Th>
|
||||||
@@ -1571,29 +1684,24 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
|
|||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{rows.map((r: ReadyToLoadRow) => (
|
{rows.map((r: ReadyToLoadRow) => (
|
||||||
<Table.Tr key={r.id}>
|
<Fragment key={r.id}>
|
||||||
|
<Table.Tr>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Checkbox
|
<ActionIcon
|
||||||
aria-label={`Select ${r.bookingReference ?? r.id}`}
|
variant="subtle"
|
||||||
checked={selected.has(r.id)}
|
color="gray"
|
||||||
onChange={() => toggleOne(r.id)}
|
aria-label="Show containers"
|
||||||
/>
|
onClick={() => setExpandedRow(expandedRow === r.id ? null : r.id)}
|
||||||
|
>
|
||||||
|
{expandedRow === r.id ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||||
|
</ActionIcon>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Stack gap={2}>
|
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
|
||||||
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
|
|
||||||
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
|
|
||||||
</Stack>
|
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
|
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
|
||||||
<Text size="xs" c="dimmed">{r.bookingId ? `${r.bookingId.slice(0, 8)}…` : '—'}</Text>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Text size="xs" c="dimmed">{r.customerId ? `${r.customerId.slice(0, 8)}…` : '—'}</Text>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>{r.customerName ?? '—'}</Table.Td>
|
<Table.Td>{r.customerName ?? '—'}</Table.Td>
|
||||||
<Table.Td>{r.containerNumber ?? '—'}</Table.Td>
|
<Table.Td>{r.containerNumber ?? '—'}</Table.Td>
|
||||||
<Table.Td>{r.cargoType ?? '—'}</Table.Td>
|
<Table.Td>{r.cargoType ?? '—'}</Table.Td>
|
||||||
@@ -1607,11 +1715,17 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
|
|||||||
</Badge>
|
</Badge>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Badge color="teal" variant="light" size="sm">
|
<InventoryStatusBadge status={r.status as InventoryStatus} />
|
||||||
{r.status}
|
|
||||||
</Badge>
|
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
|
{expandedRow === r.id && (
|
||||||
|
<BookingItemsExpansion
|
||||||
|
bookingId={r.bookingId}
|
||||||
|
colSpan={11}
|
||||||
|
bulkFallback={r.cargoType ? `Bulk cargo — ${r.cargoType}, ${formatNumber(Number(r.weight))} t` : undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</Table.Tbody>
|
</Table.Tbody>
|
||||||
</Table>
|
</Table>
|
||||||
@@ -1638,6 +1752,8 @@ function LoadedExportTab({
|
|||||||
const { data: rows = [], isLoading } = useQuery(
|
const { data: rows = [], isLoading } = useQuery(
|
||||||
api.warehouses.loadedExport.queryOptions({ enabled }),
|
api.warehouses.loadedExport.queryOptions({ enabled }),
|
||||||
);
|
);
|
||||||
|
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
|
||||||
|
const [expandedRow, setExpandedRow] = useState<string | null>(null);
|
||||||
const bulkDispatch = useMutation(
|
const bulkDispatch = useMutation(
|
||||||
api.warehouses.bulkDispatchExport.mutationOptions(),
|
api.warehouses.bulkDispatchExport.mutationOptions(),
|
||||||
);
|
);
|
||||||
@@ -1662,7 +1778,7 @@ function LoadedExportTab({
|
|||||||
const r = await bulkDispatch.mutateAsync(inventoryIds);
|
const r = await bulkDispatch.mutateAsync(inventoryIds);
|
||||||
toast({
|
toast({
|
||||||
title: `${r.dispatchedCount} dispatched`,
|
title: `${r.dispatchedCount} dispatched`,
|
||||||
description: r.skippedCount ? `${r.skippedCount} skipped` : undefined,
|
description: skippedSummary(r.skippedCount, r.results),
|
||||||
});
|
});
|
||||||
setSelected(new Set());
|
setSelected(new Set());
|
||||||
onChanged?.();
|
onChanged?.();
|
||||||
@@ -1692,7 +1808,14 @@ function LoadedExportTab({
|
|||||||
variant="default"
|
variant="default"
|
||||||
disabled={rows.length === 0}
|
disabled={rows.length === 0}
|
||||||
loading={bulkDispatch.isPending}
|
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
|
Dispatch All
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1702,7 +1825,14 @@ function LoadedExportTab({
|
|||||||
leftSection={<Truck size={14} />}
|
leftSection={<Truck size={14} />}
|
||||||
disabled={selected.size === 0}
|
disabled={selected.size === 0}
|
||||||
loading={bulkDispatch.isPending}
|
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
|
Dispatch Selected
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1719,7 +1849,7 @@ function LoadedExportTab({
|
|||||||
No LOADED export items {dispatchable ? 'waiting to dispatch' : 'yet'}.
|
No LOADED export items {dispatchable ? 'waiting to dispatch' : 'yet'}.
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<Table.ScrollContainer minWidth={1600}>
|
<Table.ScrollContainer minWidth={1200}>
|
||||||
<Table highlightOnHover verticalSpacing="xs" striped>
|
<Table highlightOnHover verticalSpacing="xs" striped>
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
@@ -1733,10 +1863,9 @@ function LoadedExportTab({
|
|||||||
/>
|
/>
|
||||||
</Table.Th>
|
</Table.Th>
|
||||||
)}
|
)}
|
||||||
|
<Table.Th w={34} />
|
||||||
<Table.Th>Booking Ref</Table.Th>
|
<Table.Th>Booking Ref</Table.Th>
|
||||||
<Table.Th>GRN</Table.Th>
|
<Table.Th>GRN</Table.Th>
|
||||||
<Table.Th>Booking ID</Table.Th>
|
|
||||||
<Table.Th>Customer ID</Table.Th>
|
|
||||||
<Table.Th>Customer Name</Table.Th>
|
<Table.Th>Customer Name</Table.Th>
|
||||||
<Table.Th>Container #</Table.Th>
|
<Table.Th>Container #</Table.Th>
|
||||||
<Table.Th>Cargo Type</Table.Th>
|
<Table.Th>Cargo Type</Table.Th>
|
||||||
@@ -1747,7 +1876,8 @@ function LoadedExportTab({
|
|||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{rows.map((r: ReadyToLoadRow) => (
|
{rows.map((r: ReadyToLoadRow) => (
|
||||||
<Table.Tr key={r.id}>
|
<Fragment key={r.id}>
|
||||||
|
<Table.Tr>
|
||||||
{dispatchable && (
|
{dispatchable && (
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -1758,20 +1888,21 @@ function LoadedExportTab({
|
|||||||
</Table.Td>
|
</Table.Td>
|
||||||
)}
|
)}
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Stack gap={2}>
|
<ActionIcon
|
||||||
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
|
variant="subtle"
|
||||||
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
|
color="gray"
|
||||||
</Stack>
|
aria-label="Show containers"
|
||||||
|
onClick={() => setExpandedRow(expandedRow === r.id ? null : r.id)}
|
||||||
|
>
|
||||||
|
{expandedRow === r.id ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||||
|
</ActionIcon>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
|
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
|
||||||
<Text size="xs" c="dimmed">{r.bookingId ? `${r.bookingId.slice(0, 8)}…` : '—'}</Text>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Text size="xs" c="dimmed">{r.customerId ? `${r.customerId.slice(0, 8)}…` : '—'}</Text>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>{r.customerName ?? '—'}</Table.Td>
|
<Table.Td>{r.customerName ?? '—'}</Table.Td>
|
||||||
<Table.Td>{r.containerNumber ?? '—'}</Table.Td>
|
<Table.Td>{r.containerNumber ?? '—'}</Table.Td>
|
||||||
<Table.Td>{r.cargoType ?? '—'}</Table.Td>
|
<Table.Td>{r.cargoType ?? '—'}</Table.Td>
|
||||||
@@ -1780,16 +1911,23 @@ function LoadedExportTab({
|
|||||||
{r.origin || r.destination ? `${r.origin ?? '?'} → ${r.destination ?? '?'}` : '—'}
|
{r.origin || r.destination ? `${r.origin ?? '?'} → ${r.destination ?? '?'}` : '—'}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Badge color="blue" variant="light" size="sm">
|
<InventoryStatusBadge status={r.status as InventoryStatus} />
|
||||||
{r.status}
|
|
||||||
</Badge>
|
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
|
{expandedRow === r.id && (
|
||||||
|
<BookingItemsExpansion
|
||||||
|
bookingId={r.bookingId}
|
||||||
|
colSpan={11}
|
||||||
|
bulkFallback={r.cargoType ? `Bulk cargo — ${r.cargoType}, ${formatNumber(Number(r.weight))} t` : undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</Table.Tbody>
|
</Table.Tbody>
|
||||||
</Table>
|
</Table>
|
||||||
</Table.ScrollContainer>
|
</Table.ScrollContainer>
|
||||||
)}
|
)}
|
||||||
|
<ConfirmActionModal action={confirmAction} onClose={() => setConfirmAction(null)} />
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1891,9 +2029,7 @@ function ImportTrainDetailTable({
|
|||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Th>Wagon</Table.Th>
|
<Table.Th>Wagon</Table.Th>
|
||||||
<Table.Th>Booking ID</Table.Th>
|
|
||||||
<Table.Th>Booking Ref</Table.Th>
|
<Table.Th>Booking Ref</Table.Th>
|
||||||
<Table.Th>Customer ID</Table.Th>
|
|
||||||
<Table.Th>Customer Name</Table.Th>
|
<Table.Th>Customer Name</Table.Th>
|
||||||
<Table.Th>Container #</Table.Th>
|
<Table.Th>Container #</Table.Th>
|
||||||
<Table.Th>Cargo Type</Table.Th>
|
<Table.Th>Cargo Type</Table.Th>
|
||||||
@@ -1927,15 +2063,9 @@ function ImportTrainDetailTable({
|
|||||||
{it.sequenceNo ? `#${it.sequenceNo}` : '-'} {it.wagonNumber ?? ''}
|
{it.sequenceNo ? `#${it.sequenceNo}` : '-'} {it.wagonNumber ?? ''}
|
||||||
</Text>
|
</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
|
||||||
<Text size="xs" c="dimmed">{it.bookingId.slice(0, 8)}…</Text>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Text size="xs" fw={600}>{it.bookingReference ?? '—'}</Text>
|
<Text size="xs" fw={600}>{it.bookingReference ?? '—'}</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
|
||||||
<Text size="xs" c="dimmed">{it.customerId ? `${it.customerId.slice(0, 8)}…` : '—'}</Text>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>{it.customerName ?? '—'}</Table.Td>
|
<Table.Td>{it.customerName ?? '—'}</Table.Td>
|
||||||
<Table.Td>{it.containerNumber ?? '—'}</Table.Td>
|
<Table.Td>{it.containerNumber ?? '—'}</Table.Td>
|
||||||
<Table.Td>{it.cargoType ?? '—'}</Table.Td>
|
<Table.Td>{it.cargoType ?? '—'}</Table.Td>
|
||||||
@@ -2025,6 +2155,7 @@ function ImportArriveQueueTab({
|
|||||||
api.warehouses.autoUnloadArrivedBookings.mutationOptions(),
|
api.warehouses.autoUnloadArrivedBookings.mutationOptions(),
|
||||||
);
|
);
|
||||||
const [openId, setOpenId] = useState<string | null>(null);
|
const [openId, setOpenId] = useState<string | null>(null);
|
||||||
|
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
|
||||||
const [busyId, setBusyId] = useState<string | null>(null);
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
const [assignmentsBySchedule, setAssignmentsBySchedule] = useState<
|
const [assignmentsBySchedule, setAssignmentsBySchedule] = useState<
|
||||||
Record<string, Record<string, ImportUnloadAssignmentDraft>>
|
Record<string, Record<string, ImportUnloadAssignmentDraft>>
|
||||||
@@ -2066,7 +2197,7 @@ function ImportArriveQueueTab({
|
|||||||
const alreadyUnloaded = r.unloadedCount === 0 && r.skippedCount > 0 && r.failedCount === 0;
|
const alreadyUnloaded = r.unloadedCount === 0 && r.skippedCount > 0 && r.failedCount === 0;
|
||||||
const firstReason = r.results.find((item) => item.reason)?.reason;
|
const firstReason = r.results.find((item) => item.reason)?.reason;
|
||||||
const extra = [
|
const extra = [
|
||||||
r.skippedCount ? `${r.skippedCount} skipped` : '',
|
skippedSummary(r.skippedCount, r.results) ?? '',
|
||||||
r.failedCount ? `${r.failedCount} failed` : '',
|
r.failedCount ? `${r.failedCount} failed` : '',
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
@@ -2162,7 +2293,14 @@ function ImportArriveQueueTab({
|
|||||||
leftSection={<Truck size={14} />}
|
leftSection={<Truck size={14} />}
|
||||||
loading={busyId === t.scheduleId}
|
loading={busyId === t.scheduleId}
|
||||||
disabled={fullyUnloaded || t.totalBookings === 0 || !readyBySchedule[t.scheduleId] || warehousesLoading}
|
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'}
|
{fullyUnloaded ? 'Already Unloaded' : 'Auto Unload Arrived Bookings'}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -2201,6 +2339,7 @@ function ImportArriveQueueTab({
|
|||||||
</Table>
|
</Table>
|
||||||
</Table.ScrollContainer>
|
</Table.ScrollContainer>
|
||||||
)}
|
)}
|
||||||
|
<ConfirmActionModal action={confirmAction} onClose={() => setConfirmAction(null)} />
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2221,6 +2360,8 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
|||||||
);
|
);
|
||||||
const readyMutation = useMutation(api.warehouses.markReadyForPickup.mutationOptions());
|
const readyMutation = useMutation(api.warehouses.markReadyForPickup.mutationOptions());
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
|
||||||
|
const [expandedRow, setExpandedRow] = useState<string | null>(null);
|
||||||
const [inspectId, setInspectId] = useState<string | null>(null);
|
const [inspectId, setInspectId] = useState<string | null>(null);
|
||||||
const [busyId, setBusyId] = useState<string | null>(null);
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
const [viewItem, setViewItem] = useState<WarehouseInventoryItem | null>(null);
|
const [viewItem, setViewItem] = useState<WarehouseInventoryItem | null>(null);
|
||||||
@@ -2252,7 +2393,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
|||||||
const r = await inspectMutation.mutateAsync({ inventoryIds: [...selected] });
|
const r = await inspectMutation.mutateAsync({ inventoryIds: [...selected] });
|
||||||
toast({
|
toast({
|
||||||
title: `${r.inspectedCount} marked inspected`,
|
title: `${r.inspectedCount} marked inspected`,
|
||||||
description: r.skippedCount ? `${r.skippedCount} skipped` : undefined,
|
description: skippedSummary(r.skippedCount, r.results),
|
||||||
});
|
});
|
||||||
setSelected(new Set());
|
setSelected(new Set());
|
||||||
void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
|
void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
|
||||||
@@ -2356,7 +2497,14 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
|||||||
leftSection={<ClipboardCheck size={14} />}
|
leftSection={<ClipboardCheck size={14} />}
|
||||||
disabled={selected.size === 0}
|
disabled={selected.size === 0}
|
||||||
loading={inspectMutation.isPending}
|
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
|
Mark Selected as Inspected
|
||||||
</Button>
|
</Button>
|
||||||
@@ -2372,10 +2520,11 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
|||||||
No unloaded import items. Items appear here after Auto Unload on an arrived train.
|
No unloaded import items. Items appear here after Auto Unload on an arrived train.
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<Table.ScrollContainer minWidth={2000}>
|
<Table.ScrollContainer minWidth={1650}>
|
||||||
<Table highlightOnHover verticalSpacing="xs" striped>
|
<Table highlightOnHover verticalSpacing="xs" striped>
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
|
<Table.Th w={34} />
|
||||||
<Table.Th w={40}>
|
<Table.Th w={40}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
aria-label="Select all"
|
aria-label="Select all"
|
||||||
@@ -2384,10 +2533,8 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
|||||||
onChange={() => (allSelected ? unselectAll() : selectAll())}
|
onChange={() => (allSelected ? unselectAll() : selectAll())}
|
||||||
/>
|
/>
|
||||||
</Table.Th>
|
</Table.Th>
|
||||||
<Table.Th>Booking ID</Table.Th>
|
|
||||||
<Table.Th>Booking Ref</Table.Th>
|
<Table.Th>Booking Ref</Table.Th>
|
||||||
<Table.Th>GRN</Table.Th>
|
<Table.Th>GRN</Table.Th>
|
||||||
<Table.Th>Customer ID</Table.Th>
|
|
||||||
<Table.Th>Customer Name</Table.Th>
|
<Table.Th>Customer Name</Table.Th>
|
||||||
<Table.Th>Arrival Time</Table.Th>
|
<Table.Th>Arrival Time</Table.Th>
|
||||||
<Table.Th>Container #</Table.Th>
|
<Table.Th>Container #</Table.Th>
|
||||||
@@ -2403,7 +2550,18 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
|||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{rows.map((r: ImportUnloadedItem) => (
|
{rows.map((r: ImportUnloadedItem) => (
|
||||||
<Table.Tr key={r.id}>
|
<Fragment key={r.id}>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
aria-label="Show containers"
|
||||||
|
onClick={() => setExpandedRow(expandedRow === r.id ? null : r.id)}
|
||||||
|
>
|
||||||
|
{expandedRow === r.id ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||||
|
</ActionIcon>
|
||||||
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
aria-label={`Select ${r.bookingReference ?? r.id}`}
|
aria-label={`Select ${r.bookingReference ?? r.id}`}
|
||||||
@@ -2412,20 +2570,11 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
|||||||
/>
|
/>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Text size="xs" c="dimmed">{r.bookingId ? `${r.bookingId.slice(0, 8)}…` : '—'}</Text>
|
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Stack gap={2}>
|
|
||||||
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
|
|
||||||
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
|
|
||||||
</Stack>
|
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
|
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
|
||||||
<Text size="xs" c="dimmed">{r.customerId ? `${r.customerId.slice(0, 8)}…` : '—'}</Text>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>{r.customerName ?? '—'}</Table.Td>
|
<Table.Td>{r.customerName ?? '—'}</Table.Td>
|
||||||
<Table.Td>{formatDate(r.arrivalTime)}</Table.Td>
|
<Table.Td>{formatDate(r.arrivalTime)}</Table.Td>
|
||||||
<Table.Td>{r.containerNumber ?? '—'}</Table.Td>
|
<Table.Td>{r.containerNumber ?? '—'}</Table.Td>
|
||||||
@@ -2444,7 +2593,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
|||||||
</Badge>
|
</Badge>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Badge color="indigo" variant="light" size="sm">{r.currentStatus}</Badge>
|
<InventoryStatusBadge status={r.currentStatus as InventoryStatus} />
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td ta="right">
|
<Table.Td ta="right">
|
||||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||||
@@ -2538,6 +2687,14 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
|||||||
</Group>
|
</Group>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
|
{expandedRow === r.id && (
|
||||||
|
<BookingItemsExpansion
|
||||||
|
bookingId={r.bookingId}
|
||||||
|
colSpan={16}
|
||||||
|
bulkFallback={r.cargoType ? `Bulk cargo — ${r.cargoType}, ${formatNumber(Number(r.weight))} t` : undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</Table.Tbody>
|
</Table.Tbody>
|
||||||
</Table>
|
</Table>
|
||||||
@@ -2566,6 +2723,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
|||||||
bookingId={containerItemsItem?.booking?.id ?? null}
|
bookingId={containerItemsItem?.booking?.id ?? null}
|
||||||
bookingReference={containerItemsItem?.booking?.reference ?? null}
|
bookingReference={containerItemsItem?.booking?.reference ?? null}
|
||||||
/>
|
/>
|
||||||
|
<ConfirmActionModal action={confirmAction} onClose={() => setConfirmAction(null)} />
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -202,8 +202,11 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
];
|
];
|
||||||
// Only trucks actually assigned to THIS booking (last-mile prefill or customer
|
// Only trucks actually assigned to THIS booking (last-mile prefill or customer
|
||||||
// portal) are selectable. No global fleet list — if nothing is assigned, the
|
// portal) are selectable. No global fleet list — if nothing is assigned, the
|
||||||
// operator types the plate manually in the field below.
|
// operator types the plate manually in the field below. Deduped by plate:
|
||||||
const truckSelectOptions = assignedTruckOptions;
|
// 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.
|
// Neither a last-mile truck nor a customer truck has been assigned yet.
|
||||||
const noTruckAssigned = assignedTruckOptions.length === 0 && !isCustomerAssignedTruck;
|
const noTruckAssigned = assignedTruckOptions.length === 0 && !isCustomerAssignedTruck;
|
||||||
const isTruckIdentityLocked = isEntranceLocked || isCustomerAssignedTruck || hasLastMileTruckPrefill;
|
const isTruckIdentityLocked = isEntranceLocked || isCustomerAssignedTruck || hasLastMileTruckPrefill;
|
||||||
@@ -216,10 +219,19 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
const containerWeightByNumber = new Map(
|
const containerWeightByNumber = new Map(
|
||||||
containerWeights.map((c) => [c.containerNumber.toUpperCase(), Number(c.weightTons) || 0]),
|
containerWeights.map((c) => [c.containerNumber.toUpperCase(), Number(c.weightTons) || 0]),
|
||||||
);
|
);
|
||||||
const containerSelectData = containerWeights.map((c) => ({
|
// Mantine Selects throw on duplicate option values — legacy bookings can carry
|
||||||
value: c.containerNumber,
|
// the same container number on two lines, so dedupe defensively.
|
||||||
label: `${c.containerNumber} · ${(Number(c.weightTons) || 0).toLocaleString()} t`,
|
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 selectedContainerNumbers = containerNumbers.map((n) => n.trim()).filter(Boolean);
|
||||||
const selectedCargoWeight = Number(
|
const selectedCargoWeight = Number(
|
||||||
selectedContainerNumbers
|
selectedContainerNumbers
|
||||||
|
|||||||
Reference in New Issue
Block a user