mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 05:25:41 +00:00
Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight/feature/user_management_UI
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: |
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -28,3 +28,4 @@ coverage/
|
|||||||
*~
|
*~
|
||||||
\#*\#
|
\#*\#
|
||||||
.\#*
|
.\#*
|
||||||
|
docker-compose.override.yml
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `company_profiles.status` defaulted to 'active', so any insert that omitted
|
||||||
|
* the column produced an operational role that was approved without ever being
|
||||||
|
* reviewed. Every live write path already passes 'pending' explicitly; this
|
||||||
|
* closes the hole at the schema level.
|
||||||
|
*
|
||||||
|
* Deliberately no data backfill. A role approved through setCompanyProfileStatus
|
||||||
|
* always stamps `reviewed_at`, so `status = 'active' AND reviewed_at IS NULL`
|
||||||
|
* flags a role that skipped review — but it also matches rows approved before
|
||||||
|
* `reviewed_at` existed (migration 2000000000001). Auditing that set is a
|
||||||
|
* judgement call about real customers, not something to automate here.
|
||||||
|
*/
|
||||||
|
export class CompanyProfileDefaultPending2100000000000
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = 'CompanyProfileDefaultPending2100000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "freight"."company_profiles" ALTER COLUMN "status" SET DEFAULT 'pending'`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "freight"."company_profiles" ALTER COLUMN "status" SET DEFAULT 'active'`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-load onto a selected train: a warehouse_loadings row now records WHICH
|
||||||
|
* train the item was loaded onto (train_schedule_id), and wagon_id becomes
|
||||||
|
* nullable because a schedule-level load may not resolve to a single wagon.
|
||||||
|
*/
|
||||||
|
export class WarehouseLoadingTrainAssociation2100000000000 implements MigrationInterface {
|
||||||
|
name = 'WarehouseLoadingTrainAssociation2100000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.warehouse_loadings
|
||||||
|
ADD COLUMN IF NOT EXISTS train_schedule_id UUID NULL
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.warehouse_loadings
|
||||||
|
ALTER COLUMN wagon_id DROP NOT NULL
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_warehouse_loadings_train_schedule
|
||||||
|
ON freight.warehouse_loadings(train_schedule_id)
|
||||||
|
WHERE train_schedule_id IS NOT NULL
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_warehouse_loadings_train_schedule`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.warehouse_loadings DROP COLUMN IF EXISTS train_schedule_id
|
||||||
|
`);
|
||||||
|
// wagon_id stays nullable on revert: restoring NOT NULL would fail on rows
|
||||||
|
// recorded without a wagon and re-introduce the outage this fixes.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { RuleEngineModule } from '../rule-engine/rule-engine.module';
|
|||||||
import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-settings.module';
|
import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-settings.module';
|
||||||
import { SignaturesModule } from '../signatures/signatures.module';
|
import { SignaturesModule } from '../signatures/signatures.module';
|
||||||
import { BillingModule } from '../billing/billing.module';
|
import { BillingModule } from '../billing/billing.module';
|
||||||
|
import { DocumentsModule } from '../billing/documents/documents.module';
|
||||||
import { FirstMileModule } from '../first-mile/first-mile.module';
|
import { FirstMileModule } from '../first-mile/first-mile.module';
|
||||||
import { LastMileModule } from '../last-mile/last-mile.module';
|
import { LastMileModule } from '../last-mile/last-mile.module';
|
||||||
import { BookingContractService } from './booking-contract.service';
|
import { BookingContractService } from './booking-contract.service';
|
||||||
@@ -68,6 +69,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
|||||||
CustomerTruckContainer,
|
CustomerTruckContainer,
|
||||||
]),
|
]),
|
||||||
BillingModule,
|
BillingModule,
|
||||||
|
DocumentsModule,
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
NotificationInboxModule,
|
NotificationInboxModule,
|
||||||
forwardRef(() => FirstMileModule),
|
forwardRef(() => FirstMileModule),
|
||||||
|
|||||||
@@ -50,7 +50,8 @@ import { Booking } from './entities/booking.entity';
|
|||||||
import { BookingContainerAllocation } from './entities/booking-container-allocation.entity';
|
import { BookingContainerAllocation } from './entities/booking-container-allocation.entity';
|
||||||
import { FileRecord } from '../files/entities/file.entity';
|
import { FileRecord } from '../files/entities/file.entity';
|
||||||
import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto';
|
import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto';
|
||||||
import { ContractPdfService } from '../../contracts/contract-pdf.service';
|
import { PdfRenderService } from '../billing/documents/pdf-render.service';
|
||||||
|
import { buildTabularFallbackPdf } from '../billing/documents/styled-pdf.util';
|
||||||
|
|
||||||
/** Paginated booking list: flat `total` (backoffice) + `meta` block (portal). */
|
/** Paginated booking list: flat `total` (backoffice) + `meta` block (portal). */
|
||||||
export interface PaginatedBookings {
|
export interface PaginatedBookings {
|
||||||
@@ -99,7 +100,7 @@ export class BookingsService {
|
|||||||
private readonly containerTypesService: ContainerTypesService,
|
private readonly containerTypesService: ContainerTypesService,
|
||||||
private readonly consolidationService: ConsolidationService,
|
private readonly consolidationService: ConsolidationService,
|
||||||
private readonly vehiclesService: VehiclesService,
|
private readonly vehiclesService: VehiclesService,
|
||||||
private readonly contractPdfService: ContractPdfService,
|
private readonly pdfRender: PdfRenderService,
|
||||||
private readonly events: EventEmitter2,
|
private readonly events: EventEmitter2,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -170,7 +171,12 @@ export class BookingsService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const html = this.buildCustomerTruckFreightOrderHtml(booking, trucks);
|
const html = this.buildCustomerTruckFreightOrderHtml(booking, trucks);
|
||||||
const buffer = await this.contractPdfService.htmlToPdfBuffer(html);
|
// Chromium when available; otherwise the styled tabular fallback (never the
|
||||||
|
// generic text dump — the freight order is an outward-facing gate document).
|
||||||
|
const buffer = await this.pdfRender.htmlToPdfBuffer(html, {
|
||||||
|
label: 'freight order',
|
||||||
|
fallback: (prepared) => buildTabularFallbackPdf(prepared),
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
filename: `freight-order-${booking.reference.replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
|
filename: `freight-order-${booking.reference.replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
|
||||||
buffer,
|
buffer,
|
||||||
@@ -262,21 +268,10 @@ export class BookingsService {
|
|||||||
containers: string | null;
|
containers: string | null;
|
||||||
}>,
|
}>,
|
||||||
): string {
|
): string {
|
||||||
|
const esc = (v: unknown) => this.escapeHtml(String(v ?? '-'));
|
||||||
const assignedAt = booking.customerTruckAssignedAt
|
const assignedAt = booking.customerTruckAssignedAt
|
||||||
? new Date(booking.customerTruckAssignedAt).toLocaleString('en-GB')
|
? new Date(booking.customerTruckAssignedAt).toLocaleString('en-GB')
|
||||||
: '-';
|
: '-';
|
||||||
const bookingRows: Array<[string, string | null | undefined]> = [
|
|
||||||
['Booking Reference', booking.reference],
|
|
||||||
['Client Name', booking.company?.name],
|
|
||||||
['Client ID', booking.companyId],
|
|
||||||
['Trade Direction', booking.tradeDirection],
|
|
||||||
['Freight Type', booking.freightType],
|
|
||||||
['Assigned At', assignedAt],
|
|
||||||
['Booking Status', booking.status],
|
|
||||||
];
|
|
||||||
const bookingRowHtml = bookingRows
|
|
||||||
.map(([label, value]) => `<tr><th>${this.escapeHtml(label)}</th><td>${this.escapeHtml(value || '-')}</td></tr>`)
|
|
||||||
.join('');
|
|
||||||
|
|
||||||
// Fall back to the legacy single-truck booking columns when there are no
|
// Fall back to the legacy single-truck booking columns when there are no
|
||||||
// multi-truck rows (bookings assigned before the multi-truck feature).
|
// multi-truck rows (bookings assigned before the multi-truck feature).
|
||||||
@@ -297,44 +292,63 @@ export class BookingsService {
|
|||||||
]
|
]
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const truckBlocks = truckList
|
const truckRows = truckList
|
||||||
.map((t, i) => {
|
.map(
|
||||||
const rows: Array<[string, string | null | undefined]> = [
|
(t, i) => `<tr>
|
||||||
['Truck Plate Number', t.plateNumber],
|
<td class="num">${i + 1}</td>
|
||||||
['Driver Name', t.driverName],
|
<td>${esc(t.plateNumber)}</td>
|
||||||
['Truck Type', t.truckType],
|
<td>${esc(t.driverName)}</td>
|
||||||
['Containers Loaded', t.containers],
|
<td>${esc(t.truckType)}</td>
|
||||||
[
|
<td>${esc(t.containers)}</td>
|
||||||
'Arrival',
|
<td>${t.arrivedAt ? esc(new Date(t.arrivedAt).toLocaleString('en-GB')) : 'Awaiting arrival'}</td>
|
||||||
t.arrivedAt ? new Date(t.arrivedAt).toLocaleString('en-GB') : 'Awaiting arrival',
|
</tr>`,
|
||||||
],
|
)
|
||||||
];
|
|
||||||
const html = rows
|
|
||||||
.map(
|
|
||||||
([label, value]) =>
|
|
||||||
`<tr><th>${this.escapeHtml(label)}</th><td>${this.escapeHtml(value || '-')}</td></tr>`,
|
|
||||||
)
|
|
||||||
.join('');
|
|
||||||
return `<div class="truck"><h2>Truck ${i + 1}</h2><table>${html}</table></div>`;
|
|
||||||
})
|
|
||||||
.join('');
|
.join('');
|
||||||
|
|
||||||
const copy = (watermark: string) => `
|
const copy = (watermark: string) => `
|
||||||
<section class="copy">
|
<section class="copy">
|
||||||
<div class="watermark">${this.escapeHtml(watermark)}</div>
|
<div class="watermark">${esc(watermark)}</div>
|
||||||
<header>
|
<div class="top">
|
||||||
<div>
|
<div>
|
||||||
|
<div class="brand">Ethio-Djibouti Railway S.C.</div>
|
||||||
<h1>Freight Order</h1>
|
<h1>Freight Order</h1>
|
||||||
<p>Customer external truck assignment — ${truckList.length} truck${truckList.length !== 1 ? 's' : ''}</p>
|
<div class="subtitle">Customer external truck assignment — ${truckList.length} truck${truckList.length !== 1 ? 's' : ''}</div>
|
||||||
</div>
|
</div>
|
||||||
<strong>${this.escapeHtml(booking.reference)}</strong>
|
<div class="meta">
|
||||||
</header>
|
Booking
|
||||||
<table>${bookingRowHtml}</table>
|
<strong>${esc(booking.reference)}</strong>
|
||||||
${truckBlocks}
|
Generated: ${esc(new Date().toLocaleString('en-GB'))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary">
|
||||||
|
<div class="tile"><span>Client</span><strong>${esc(booking.company?.name)}</strong></div>
|
||||||
|
<div class="tile"><span>Client ID</span><strong>${esc(booking.companyId)}</strong></div>
|
||||||
|
<div class="tile"><span>Trade direction</span><strong>${esc(booking.tradeDirection)}</strong></div>
|
||||||
|
<div class="tile"><span>Freight type</span><strong>${esc(booking.freightType)}</strong></div>
|
||||||
|
<div class="tile"><span>Assigned at</span><strong>${esc(assignedAt)}</strong></div>
|
||||||
|
<div class="tile"><span>Booking status</span><strong>${esc(booking.status)}</strong></div>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="num">#</th>
|
||||||
|
<th>Truck plate</th>
|
||||||
|
<th>Driver</th>
|
||||||
|
<th>Truck type</th>
|
||||||
|
<th>Containers loaded</th>
|
||||||
|
<th>Arrival</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>${truckRows}</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="notice">
|
||||||
|
Present this freight order at the warehouse gate. Each truck may only collect the
|
||||||
|
containers listed against it; the handover must be signed before any truck leaves.
|
||||||
|
</div>
|
||||||
<div class="signatures">
|
<div class="signatures">
|
||||||
<div>Customer / Carrier Signature</div>
|
<div class="line">Customer / Carrier signature — date</div>
|
||||||
<div>Port Operations Verification</div>
|
<div class="line">Port operations verification — date</div>
|
||||||
<div>Gate Security Verification</div>
|
<div class="line">Gate security verification — date</div>
|
||||||
</div>
|
</div>
|
||||||
</section>`;
|
</section>`;
|
||||||
|
|
||||||
@@ -342,21 +356,30 @@ export class BookingsService {
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
|
<title>Freight Order</title>
|
||||||
<style>
|
<style>
|
||||||
body { font-family: Arial, sans-serif; color: #10202f; margin: 0; }
|
@page { size: A4 portrait; margin: 10mm; }
|
||||||
.copy { position: relative; min-height: 46vh; padding: 28px 32px; page-break-inside: avoid; border-bottom: 1px dashed #94a3b8; }
|
* { box-sizing: border-box; }
|
||||||
.watermark { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 34px; font-weight: 800; color: rgba(16, 32, 47, 0.08); transform: rotate(-18deg); pointer-events: none; }
|
body { margin: 0; color: #0f172a; font-family: Arial, sans-serif; }
|
||||||
header { display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 3px solid #0a9f6a; padding-bottom: 14px; margin-bottom: 18px; }
|
.copy { position: relative; padding: 24px 28px; page-break-inside: avoid; border-bottom: 1px dashed #94a3b8; }
|
||||||
h1 { margin: 0; font-size: 28px; letter-spacing: 0; }
|
.watermark { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 30px; font-weight: 800; color: rgba(15, 23, 42, 0.07); transform: rotate(-18deg); pointer-events: none; }
|
||||||
h2 { margin: 18px 0 8px; font-size: 14px; color: #0a6f4d; }
|
.top { display: flex; justify-content: space-between; border-bottom: 3px solid #0f766e; padding-bottom: 10px; gap: 24px; }
|
||||||
p { margin: 4px 0 0; color: #64748b; }
|
.brand { font-size: 11px; color: #475569; text-transform: uppercase; letter-spacing: .08em; font-weight: 700; }
|
||||||
strong { font-size: 16px; color: #0a9f6a; }
|
h1 { margin: 6px 0 0; font-size: 25px; line-height: 1.05; }
|
||||||
table { width: 100%; border-collapse: collapse; position: relative; z-index: 1; margin-bottom: 6px; }
|
.subtitle { margin-top: 4px; color: #64748b; font-size: 12px; }
|
||||||
th, td { border: 1px solid #cbd5e1; padding: 9px 10px; text-align: left; font-size: 12px; }
|
.meta { text-align: right; font-size: 11px; color: #475569; min-width: 210px; }
|
||||||
th { width: 34%; background: #f1f5f9; }
|
.meta strong { display: block; margin: 4px 0; color: #0f172a; font-size: 15px; }
|
||||||
.truck { page-break-inside: avoid; }
|
.summary { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin: 14px 0; }
|
||||||
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-top: 34px; font-size: 11px; color: #475569; position: relative; z-index: 1; }
|
.tile { border: 1px solid #cbd5e1; padding: 8px; min-height: 48px; }
|
||||||
.signatures div { border-top: 1px solid #334155; padding-top: 8px; min-height: 28px; }
|
.tile span { display: block; color: #64748b; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 4px; }
|
||||||
|
.tile strong { font-size: 11px; }
|
||||||
|
table { width: 100%; border-collapse: collapse; position: relative; z-index: 1; }
|
||||||
|
th { background: #f8fafc; color: #475569; text-align: left; }
|
||||||
|
th, td { border: 1px solid #cbd5e1; padding: 6px 7px; font-size: 10.5px; vertical-align: top; }
|
||||||
|
.num { text-align: right; width: 26px; }
|
||||||
|
.notice { margin-top: 10px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 8px 10px; font-size: 10px; color: #134e4a; }
|
||||||
|
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 30px; position: relative; z-index: 1; }
|
||||||
|
.line { border-top: 1px solid #334155; padding-top: 7px; font-size: 9px; color: #475569; min-height: 30px; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -34,7 +34,10 @@ import {
|
|||||||
ResponseCompanyDto,
|
ResponseCompanyDto,
|
||||||
ResponseCompanyProfileDto,
|
ResponseCompanyProfileDto,
|
||||||
} from "./dto/response-company.dto";
|
} from "./dto/response-company.dto";
|
||||||
import { ProfileLicenseFileView } from "./entities/company-profile.entity";
|
import {
|
||||||
|
CompanyDocumentFileView,
|
||||||
|
ProfileLicenseFileView,
|
||||||
|
} from "./entities/company-profile.entity";
|
||||||
import { ResponseExternalProfileDto } from "./dto/response-external-profile.dto";
|
import { ResponseExternalProfileDto } from "./dto/response-external-profile.dto";
|
||||||
import { CompanyInfoResponseDto } from "./dto/company-info-response.dto";
|
import { CompanyInfoResponseDto } from "./dto/company-info-response.dto";
|
||||||
import { UpdateProfileDto } from "./dto/update-profile.dto";
|
import { UpdateProfileDto } from "./dto/update-profile.dto";
|
||||||
@@ -218,7 +221,7 @@ export class CompaniesController {
|
|||||||
@Post("company-profile")
|
@Post("company-profile")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
summary:
|
||||||
"Create a single operational profile for the current user's company and make it the active mode",
|
"Create a single operational profile for the current user's company. The role starts pending and does not become the active mode",
|
||||||
})
|
})
|
||||||
async createCompanyProfile(
|
async createCompanyProfile(
|
||||||
@CurrentUser() user: CurrentIamUser,
|
@CurrentUser() user: CurrentIamUser,
|
||||||
@@ -306,6 +309,49 @@ export class CompaniesController {
|
|||||||
return this.companiesService.listProfileLicenseFiles(user.id, profileId);
|
return this.companiesService.listProfileLicenseFiles(user.id, profileId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get("poa-delegation")
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"List the Power of Attorney delegation letter (with review state) for the current user's company",
|
||||||
|
})
|
||||||
|
async listPoaDelegation(
|
||||||
|
@CurrentUser() user: CurrentIamUser,
|
||||||
|
): Promise<CompanyDocumentFileView[]> {
|
||||||
|
return this.companiesService.listPoaDelegationFiles(user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("poa-delegation")
|
||||||
|
@UseInterceptors(AnyFilesInterceptor())
|
||||||
|
@ApiConsumes("multipart/form-data")
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Upload the Power of Attorney delegation letter, replacing any existing one. " +
|
||||||
|
"For an approved company the upload is staged for backoffice review; during " +
|
||||||
|
"onboarding it goes live.",
|
||||||
|
})
|
||||||
|
async uploadPoaDelegation(
|
||||||
|
@CurrentUser() user: CurrentIamUser,
|
||||||
|
@UploadedFiles() files: Array<Express.Multer.File>,
|
||||||
|
): Promise<CompanyDocumentFileView[]> {
|
||||||
|
const file = files?.[0];
|
||||||
|
if (!file) {
|
||||||
|
throw new BadRequestException("A delegation letter file is required");
|
||||||
|
}
|
||||||
|
return this.companiesService.uploadPoaDelegationLetter(user.id, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete("poa-delegation/:fileId")
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Remove the Power of Attorney delegation letter (staged for review on an approved company).",
|
||||||
|
})
|
||||||
|
async removePoaDelegation(
|
||||||
|
@CurrentUser() user: CurrentIamUser,
|
||||||
|
@Param("fileId", ParseUUIDPipe) fileId: string,
|
||||||
|
): Promise<CompanyDocumentFileView[]> {
|
||||||
|
return this.companiesService.removePoaDelegationLetter(user.id, fileId);
|
||||||
|
}
|
||||||
|
|
||||||
@Patch("active-mode")
|
@Patch("active-mode")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Switch the current user's active operational mode (importer/exporter)",
|
summary: "Switch the current user's active operational mode (importer/exporter)",
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module, forwardRef } from "@nestjs/common";
|
||||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||||
import { HttpModule } from "@nestjs/axios";
|
import { HttpModule } from "@nestjs/axios";
|
||||||
import { FilesModule } from "../files/files.module";
|
import { FilesModule } from "../files/files.module";
|
||||||
import { FileUploadSettingsModule } from "../file-upload-settings/file-upload-settings.module";
|
import { FileUploadSettingsModule } from "../file-upload-settings/file-upload-settings.module";
|
||||||
import { MinioModule } from "../minio/minio.module";
|
import { MinioModule } from "../minio/minio.module";
|
||||||
|
import { NotificationsModule } from "../notifications/notifications.module";
|
||||||
|
import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module";
|
||||||
import { CompaniesController } from "./companies.controller";
|
import { CompaniesController } from "./companies.controller";
|
||||||
import { CompaniesService } from "./companies.service";
|
import { CompaniesService } from "./companies.service";
|
||||||
import { CompaniesRepository } from "./companies.repository";
|
import { CompaniesRepository } from "./companies.repository";
|
||||||
@@ -17,6 +19,7 @@ import { Booking } from "../bookings/entities/booking.entity";
|
|||||||
import { CompanyProfileRepository } from "./company-profile.repository";
|
import { CompanyProfileRepository } from "./company-profile.repository";
|
||||||
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
|
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
|
||||||
import { ETradeService } from "./services/etrade.service";
|
import { ETradeService } from "./services/etrade.service";
|
||||||
|
import { CompanyNotifierService } from "./company-notifier.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -31,6 +34,10 @@ import { ETradeService } from "./services/etrade.service";
|
|||||||
FilesModule,
|
FilesModule,
|
||||||
FileUploadSettingsModule,
|
FileUploadSettingsModule,
|
||||||
MinioModule,
|
MinioModule,
|
||||||
|
// Account-status notifications (CompanyNotifierService). The inbox module
|
||||||
|
// imports this module back for portal recipient targeting, hence forwardRef.
|
||||||
|
NotificationsModule,
|
||||||
|
forwardRef(() => NotificationInboxModule),
|
||||||
],
|
],
|
||||||
controllers: [CompaniesController],
|
controllers: [CompaniesController],
|
||||||
providers: [
|
providers: [
|
||||||
@@ -41,6 +48,7 @@ import { ETradeService } from "./services/etrade.service";
|
|||||||
CompanyChangeRequestRepository,
|
CompanyChangeRequestRepository,
|
||||||
CompanyDashboardRepository,
|
CompanyDashboardRepository,
|
||||||
ETradeService,
|
ETradeService,
|
||||||
|
CompanyNotifierService,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
CompaniesService,
|
CompaniesService,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { FilesService } from "../files/files.service";
|
|||||||
import { FileRecord } from "../files/entities/file.entity";
|
import { FileRecord } from "../files/entities/file.entity";
|
||||||
import { FileUploadSettingsService } from "../file-upload-settings/file-upload-settings.service";
|
import { FileUploadSettingsService } from "../file-upload-settings/file-upload-settings.service";
|
||||||
import { ETradeService } from "./services/etrade.service";
|
import { ETradeService } from "./services/etrade.service";
|
||||||
|
import { CompanyNotifierService } from "./company-notifier.service";
|
||||||
import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto";
|
import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto";
|
||||||
import { normalizeE164 } from "../../common/validators/is-phone-number.validator";
|
import { normalizeE164 } from "../../common/validators/is-phone-number.validator";
|
||||||
import { CreateCompanyDto } from "./dto/create-company.dto";
|
import { CreateCompanyDto } from "./dto/create-company.dto";
|
||||||
@@ -37,6 +38,7 @@ import {
|
|||||||
import { ExternalProfile } from "./entities/external-profile.entity";
|
import { ExternalProfile } from "./entities/external-profile.entity";
|
||||||
import {
|
import {
|
||||||
BusinessLicenseFile,
|
BusinessLicenseFile,
|
||||||
|
CompanyDocumentFileView,
|
||||||
CompanyProfile,
|
CompanyProfile,
|
||||||
ProfileLicenseFileView,
|
ProfileLicenseFileView,
|
||||||
ProfileType,
|
ProfileType,
|
||||||
@@ -45,6 +47,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
ChangeRequestStatus,
|
ChangeRequestStatus,
|
||||||
CompanyChangeRequest,
|
CompanyChangeRequest,
|
||||||
|
DocumentChangeIntent,
|
||||||
LicenseChangeIntent,
|
LicenseChangeIntent,
|
||||||
} from "./entities/company-change-request.entity";
|
} from "./entities/company-change-request.entity";
|
||||||
|
|
||||||
@@ -54,6 +57,27 @@ const LICENSE_CODE = "business_license";
|
|||||||
/** Code for a license file staged in an open change request (not yet live). */
|
/** Code for a license file staged in an open change request (not yet live). */
|
||||||
const LICENSE_PENDING_CODE = "business_license_pending";
|
const LICENSE_PENDING_CODE = "business_license_pending";
|
||||||
|
|
||||||
|
/** Mirrors the field seeded in seed/file-upload-settings.seeder.ts. */
|
||||||
|
const POA_DELEGATION_FILE_KEY = "poa_delegation_letter";
|
||||||
|
/** Code for a PoA letter staged in an open change request (not yet live). */
|
||||||
|
const POA_DELEGATION_PENDING_CODE = "poa_delegation_letter_pending";
|
||||||
|
/** FileRecord resource that company-level documents are stored under. */
|
||||||
|
const COMPANY_RESOURCE = "companies";
|
||||||
|
/** company.attributes keys that together mean "a PoA was entered". */
|
||||||
|
const POA_ATTRIBUTES = [
|
||||||
|
"poaName",
|
||||||
|
"poaPhone",
|
||||||
|
"poaEmail",
|
||||||
|
"poaLocation",
|
||||||
|
"poaAddress",
|
||||||
|
] as const;
|
||||||
|
/** Mandatory once the company operates as a freight forwarder. */
|
||||||
|
const REQUIRED_POA_FIELDS: { key: string; label: string }[] = [
|
||||||
|
{ key: "poaName", label: "PoA name" },
|
||||||
|
{ key: "poaEmail", label: "PoA email" },
|
||||||
|
{ key: "poaPhone", label: "PoA phone" },
|
||||||
|
];
|
||||||
|
|
||||||
export interface UserIdentity {
|
export interface UserIdentity {
|
||||||
userId: string;
|
userId: string;
|
||||||
firstName: string;
|
firstName: string;
|
||||||
@@ -73,6 +97,7 @@ export class CompaniesService {
|
|||||||
private readonly filesService: FilesService,
|
private readonly filesService: FilesService,
|
||||||
private readonly fileUploadSettingsService: FileUploadSettingsService,
|
private readonly fileUploadSettingsService: FileUploadSettingsService,
|
||||||
private readonly etradeService: ETradeService,
|
private readonly etradeService: ETradeService,
|
||||||
|
private readonly companyNotifier: CompanyNotifierService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -562,9 +587,13 @@ export class CompaniesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async updateCompany(id: string, dto: UpdateCompanyDto): Promise<Company> {
|
async updateCompany(id: string, dto: UpdateCompanyDto): Promise<Company> {
|
||||||
await this.findCompanyById(id);
|
const before = await this.findCompanyById(id);
|
||||||
const updated = await this.companiesRepo.update(id, dto);
|
const updated = await this.companiesRepo.update(id, dto);
|
||||||
if (!updated) throw new NotFoundException(`Company ${id} not found`);
|
if (!updated) throw new NotFoundException(`Company ${id} not found`);
|
||||||
|
|
||||||
|
// Suspending or blacklisting locks the customer out, so they must be told.
|
||||||
|
// This is the only path that writes those statuses.
|
||||||
|
this.companyNotifier.statusChanged(updated, before.status);
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -765,6 +794,7 @@ export class CompaniesService {
|
|||||||
const companyUpdates = this.mapProfileDtoToCompanyUpdates(company, snapshot);
|
const companyUpdates = this.mapProfileDtoToCompanyUpdates(company, snapshot);
|
||||||
await this.companiesRepo.update(company.id, companyUpdates);
|
await this.companiesRepo.update(company.id, companyUpdates);
|
||||||
await this.applyLicenseChanges(request);
|
await this.applyLicenseChanges(request);
|
||||||
|
await this.applyDocumentChanges(request);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
(await this.changeRequestRepo.update(id, {
|
(await this.changeRequestRepo.update(id, {
|
||||||
@@ -817,7 +847,12 @@ export class CompaniesService {
|
|||||||
if (existing) {
|
if (existing) {
|
||||||
const prev = existing.documents?.documentFileIds ?? [];
|
const prev = existing.documents?.documentFileIds ?? [];
|
||||||
await this.changeRequestRepo.update(existing.id, {
|
await this.changeRequestRepo.update(existing.id, {
|
||||||
documents: { documentFileIds: [...prev, ...fileIds] },
|
// Spread the existing documents blob: a bare object would drop any
|
||||||
|
// licenseChanges/documentChanges already staged on this request.
|
||||||
|
documents: {
|
||||||
|
...existing.documents,
|
||||||
|
documentFileIds: [...prev, ...fileIds],
|
||||||
|
},
|
||||||
submittedBy: submittedBy ?? existing.submittedBy ?? null,
|
submittedBy: submittedBy ?? existing.submittedBy ?? null,
|
||||||
submittedAt: now,
|
submittedAt: now,
|
||||||
note: null,
|
note: null,
|
||||||
@@ -849,12 +884,17 @@ export class CompaniesService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
await this.discardLicenseChanges(request);
|
await this.discardLicenseChanges(request);
|
||||||
|
await this.discardDocumentChanges(request);
|
||||||
return (
|
return (
|
||||||
(await this.changeRequestRepo.update(id, {
|
(await this.changeRequestRepo.update(id, {
|
||||||
status: ChangeRequestStatus.Rejected,
|
status: ChangeRequestStatus.Rejected,
|
||||||
// Staged license uploads were just discarded; drop their intents so an
|
// Staged license/document uploads were just discarded; drop their intents
|
||||||
// amended resubmit never re-references deleted files.
|
// so an amended resubmit never re-references deleted files.
|
||||||
documents: { ...request.documents, licenseChanges: [] },
|
documents: {
|
||||||
|
...request.documents,
|
||||||
|
licenseChanges: [],
|
||||||
|
documentChanges: [],
|
||||||
|
},
|
||||||
note,
|
note,
|
||||||
reviewedBy: reviewerId ?? null,
|
reviewedBy: reviewerId ?? null,
|
||||||
reviewedAt: new Date(),
|
reviewedAt: new Date(),
|
||||||
@@ -1017,13 +1057,12 @@ export class CompaniesService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const reference = await this.companyProfilesRepo.generateReference(type);
|
// No reference is minted here: it is issued by setCompanyProfileStatus when
|
||||||
|
// a reviewer approves the role. Creating it Active would bypass that review.
|
||||||
return this.companyProfilesRepo.create({
|
return this.companyProfilesRepo.create({
|
||||||
companyId,
|
companyId,
|
||||||
type,
|
type,
|
||||||
reference,
|
status: ProfileStatus.Pending,
|
||||||
status: ProfileStatus.Active,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1097,9 +1136,11 @@ export class CompaniesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a single operational profile for the current user's company and
|
* Create a single operational profile for the current user's company. The new
|
||||||
* make it the active mode in the same call. Powers the header "Switch to
|
* role starts Pending, so it deliberately does NOT become the active mode:
|
||||||
* Exporter/Importer" flow when the target profile doesn't exist yet.
|
* switching onto an unapproved profile would strip the user of `canBook` and
|
||||||
|
* block them from creating contracts under the role they already had approved.
|
||||||
|
* Callers switch explicitly via {@link setActiveMode} once the role is Active.
|
||||||
*/
|
*/
|
||||||
async createCompanyProfileForUser(
|
async createCompanyProfileForUser(
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -1122,8 +1163,7 @@ export class CompaniesService {
|
|||||||
let created = await this.companyProfilesRepo.findByType(companyId, type);
|
let created = await this.companyProfilesRepo.findByType(companyId, type);
|
||||||
if (!created) {
|
if (!created) {
|
||||||
// New self-service roles start Pending (awaiting backoffice approval) and
|
// New self-service roles start Pending (awaiting backoffice approval) and
|
||||||
// carry no reference until approved. The customer can select this mode but
|
// carry no reference until approved.
|
||||||
// can't book under it until it's cleared.
|
|
||||||
created = await this.companyProfilesRepo.create({
|
created = await this.companyProfilesRepo.create({
|
||||||
companyId,
|
companyId,
|
||||||
type,
|
type,
|
||||||
@@ -1132,8 +1172,6 @@ export class CompaniesService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.profilesRepo.update(profile.id, { activeProfileType: type });
|
|
||||||
|
|
||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1240,6 +1278,29 @@ export class CompaniesService {
|
|||||||
);
|
);
|
||||||
const missingLicenses = licenseProfiles.filter((p) => !p.uploaded);
|
const missingLicenses = licenseProfiles.filter((p) => !p.uploaded);
|
||||||
|
|
||||||
|
// 4. Power of Attorney. Optional in general, but a freight forwarder acts on
|
||||||
|
// other companies' behalf so its PoA is mandatory. Either way, a PoA that
|
||||||
|
// has been entered must be evidenced by the delegation letter.
|
||||||
|
const poaRequired = (company.companyProfiles ?? []).some(
|
||||||
|
(p) => p.type === ProfileType.freightForwarder,
|
||||||
|
);
|
||||||
|
const poaProvided = POA_ATTRIBUTES.some((k) =>
|
||||||
|
(company.attributes?.[k] as string | undefined)?.trim(),
|
||||||
|
);
|
||||||
|
const missingPoaFields = poaRequired
|
||||||
|
? REQUIRED_POA_FIELDS.filter(
|
||||||
|
(f) => !(company.attributes?.[f.key] as string | undefined)?.trim(),
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
// Only gate on the letter once the document set actually carries the field.
|
||||||
|
const delegationField = (setting?.fields ?? []).find(
|
||||||
|
(f) => f.fileKey === POA_DELEGATION_FILE_KEY,
|
||||||
|
);
|
||||||
|
const missingDelegation =
|
||||||
|
Boolean(delegationField) &&
|
||||||
|
(poaRequired || poaProvided) &&
|
||||||
|
!uploadedCodes.has(POA_DELEGATION_FILE_KEY);
|
||||||
|
|
||||||
const outstanding = [
|
const outstanding = [
|
||||||
...missingInfo.map((f) => `Add your ${f.label.toLowerCase()}`),
|
...missingInfo.map((f) => `Add your ${f.label.toLowerCase()}`),
|
||||||
...missingDocs.map((d) => `Upload your ${d.fileLabel}`),
|
...missingDocs.map((d) => `Upload your ${d.fileLabel}`),
|
||||||
@@ -1247,18 +1308,31 @@ export class CompaniesService {
|
|||||||
(p) =>
|
(p) =>
|
||||||
`Upload a business license for your ${p.type.replace(/_/g, " ")} profile`,
|
`Upload a business license for your ${p.type.replace(/_/g, " ")} profile`,
|
||||||
),
|
),
|
||||||
|
...missingPoaFields.map((f) => `Add your ${f.label.toLowerCase()}`),
|
||||||
|
...(missingDelegation
|
||||||
|
? ["Upload the delegation letter for your Power of Attorney"]
|
||||||
|
: []),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Progress spans every required item the user has to satisfy: company-info
|
// Progress spans every required item the user has to satisfy: company-info
|
||||||
// fields, required documents and one license per operational profile.
|
// fields, required documents, one license per operational profile, and the
|
||||||
|
// PoA details/letter whenever those are mandatory.
|
||||||
const requiredDocCount = documents.filter((d) => d.isRequired).length;
|
const requiredDocCount = documents.filter((d) => d.isRequired).length;
|
||||||
|
const poaItemCount =
|
||||||
|
(poaRequired ? REQUIRED_POA_FIELDS.length : 0) +
|
||||||
|
(delegationField && (poaRequired || poaProvided) ? 1 : 0);
|
||||||
const total =
|
const total =
|
||||||
this.REQUIRED_COMPANY_INFO.length +
|
this.REQUIRED_COMPANY_INFO.length +
|
||||||
requiredDocCount +
|
requiredDocCount +
|
||||||
licenseProfiles.length;
|
licenseProfiles.length +
|
||||||
|
poaItemCount;
|
||||||
const completed =
|
const completed =
|
||||||
total -
|
total -
|
||||||
(missingInfo.length + missingDocs.length + missingLicenses.length);
|
(missingInfo.length +
|
||||||
|
missingDocs.length +
|
||||||
|
missingLicenses.length +
|
||||||
|
missingPoaFields.length +
|
||||||
|
(missingDelegation ? 1 : 0));
|
||||||
|
|
||||||
return new OnboardingRequirementsResponseDto({
|
return new OnboardingRequirementsResponseDto({
|
||||||
documentSettingCode,
|
documentSettingCode,
|
||||||
@@ -1266,6 +1340,13 @@ export class CompaniesService {
|
|||||||
companyInfo: { complete: missingInfo.length === 0, missingFields: missingInfo },
|
companyInfo: { complete: missingInfo.length === 0, missingFields: missingInfo },
|
||||||
documents,
|
documents,
|
||||||
licenseProfiles,
|
licenseProfiles,
|
||||||
|
poa: {
|
||||||
|
required: poaRequired,
|
||||||
|
provided: poaProvided,
|
||||||
|
delegationLetterUploaded: uploadedCodes.has(POA_DELEGATION_FILE_KEY),
|
||||||
|
missingFields: missingPoaFields,
|
||||||
|
complete: missingPoaFields.length === 0 && !missingDelegation,
|
||||||
|
},
|
||||||
progress: { completed, total },
|
progress: { completed, total },
|
||||||
isComplete: outstanding.length === 0,
|
isComplete: outstanding.length === 0,
|
||||||
onboardingCompleted: profile.onboardingCompleted,
|
onboardingCompleted: profile.onboardingCompleted,
|
||||||
@@ -1365,10 +1446,12 @@ export class CompaniesService {
|
|||||||
// browser (which fails on the internal bucket endpoint).
|
// browser (which fails on the internal bucket endpoint).
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upload business-license file(s) for one of the user's profiles. During
|
* Upload business-license file(s) for one of the user's profiles. For a role
|
||||||
* onboarding (company not yet Active) they go live immediately; for an Active
|
* not yet approved (a fresh onboarding profile, or a newly added service on an
|
||||||
* company they're staged under the pending code and recorded as `add` intents
|
* already-active company) they go live immediately and are reviewed together
|
||||||
* on a pending change request for backoffice review. Returns the updated view.
|
* with the role itself. Only for an already-approved role are they staged under
|
||||||
|
* the pending code and recorded as `add` intents on a pending change request —
|
||||||
|
* a licence swap on a live role is a change; a licence on a new role is not.
|
||||||
*/
|
*/
|
||||||
async addProfileLicenseFiles(
|
async addProfileLicenseFiles(
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -1377,7 +1460,7 @@ export class CompaniesService {
|
|||||||
): Promise<ProfileLicenseFileView[]> {
|
): Promise<ProfileLicenseFileView[]> {
|
||||||
const profile = await this.resolveOwnedProfile(userId, profileId);
|
const profile = await this.resolveOwnedProfile(userId, profileId);
|
||||||
const company = await this.findCompanyById(profile.companyId);
|
const company = await this.findCompanyById(profile.companyId);
|
||||||
const gated = company.status === CompanyStatus.Active;
|
const gated = profile.status === ProfileStatus.Active;
|
||||||
const code = gated ? LICENSE_PENDING_CODE : LICENSE_CODE;
|
const code = gated ? LICENSE_PENDING_CODE : LICENSE_CODE;
|
||||||
|
|
||||||
const uploaded = await Promise.all(
|
const uploaded = await Promise.all(
|
||||||
@@ -1409,9 +1492,9 @@ export class CompaniesService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove a license file. A staged (pending) file is withdrawn outright
|
* Remove a license file. A staged (pending) file is withdrawn outright
|
||||||
* (soft-deleted, its `add` intent dropped). A live file on an Active company
|
* (soft-deleted, its `add` intent dropped). A live file on an already-approved
|
||||||
* is kept and recorded as a `remove` intent for review; during onboarding it
|
* role is kept and recorded as a `remove` intent for review; on a role still
|
||||||
* is deleted immediately.
|
* awaiting approval it is deleted immediately.
|
||||||
*/
|
*/
|
||||||
async removeProfileLicenseFile(
|
async removeProfileLicenseFile(
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -1427,7 +1510,7 @@ export class CompaniesService {
|
|||||||
throw new NotFoundException(`License file ${fileId} not found`);
|
throw new NotFoundException(`License file ${fileId} not found`);
|
||||||
}
|
}
|
||||||
const company = await this.findCompanyById(profile.companyId);
|
const company = await this.findCompanyById(profile.companyId);
|
||||||
const gated = company.status === CompanyStatus.Active;
|
const gated = profile.status === ProfileStatus.Active;
|
||||||
|
|
||||||
if (record.code === LICENSE_PENDING_CODE) {
|
if (record.code === LICENSE_PENDING_CODE) {
|
||||||
// Withdraw a not-yet-approved upload: delete it and drop its add intent.
|
// Withdraw a not-yet-approved upload: delete it and drop its add intent.
|
||||||
@@ -1449,7 +1532,7 @@ export class CompaniesService {
|
|||||||
/**
|
/**
|
||||||
* Replace a live license file with a freshly uploaded one — recorded as a
|
* Replace a live license file with a freshly uploaded one — recorded as a
|
||||||
* `remove` of the old file plus an `add` of the new, so approval swaps them
|
* `remove` of the old file plus an `add` of the new, so approval swaps them
|
||||||
* atomically. During onboarding the swap is applied immediately.
|
* atomically. On a role still awaiting approval the swap is applied immediately.
|
||||||
*/
|
*/
|
||||||
async replaceProfileLicenseFile(
|
async replaceProfileLicenseFile(
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -1463,7 +1546,7 @@ export class CompaniesService {
|
|||||||
throw new NotFoundException(`License file ${fileId} not found`);
|
throw new NotFoundException(`License file ${fileId} not found`);
|
||||||
}
|
}
|
||||||
const company = await this.findCompanyById(profile.companyId);
|
const company = await this.findCompanyById(profile.companyId);
|
||||||
const gated = company.status === CompanyStatus.Active;
|
const gated = profile.status === ProfileStatus.Active;
|
||||||
|
|
||||||
const created = await this.filesService.upload({
|
const created = await this.filesService.upload({
|
||||||
resourceId: profileId,
|
resourceId: profileId,
|
||||||
@@ -1671,6 +1754,254 @@ export class CompaniesService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Power of Attorney delegation letter
|
||||||
|
//
|
||||||
|
// A company-level document that follows the same staged-review model as the
|
||||||
|
// business license: on an approved (Active) company an upload lands under the
|
||||||
|
// pending code and the live letter is flagged for removal, so the reviewer
|
||||||
|
// sees both and approval swaps them atomically. During onboarding it goes live.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** The company's PoA letter(s), with each file's review status resolved. */
|
||||||
|
async listPoaDelegationFiles(
|
||||||
|
userId: string,
|
||||||
|
): Promise<CompanyDocumentFileView[]> {
|
||||||
|
const { company } = await this.getCompanyInfoByUserId(userId);
|
||||||
|
return this.getPoaDelegationView(company.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload the PoA delegation letter, replacing whatever is already on file.
|
||||||
|
* On an Active company this stages an `add` for the new file plus a `remove`
|
||||||
|
* for each live one; a letter still awaiting approval is withdrawn outright
|
||||||
|
* rather than stacking a second pending upload.
|
||||||
|
*/
|
||||||
|
async uploadPoaDelegationLetter(
|
||||||
|
userId: string,
|
||||||
|
file: Express.Multer.File,
|
||||||
|
): Promise<CompanyDocumentFileView[]> {
|
||||||
|
const { company } = await this.getCompanyInfoByUserId(userId);
|
||||||
|
const gated = company.status === CompanyStatus.Active;
|
||||||
|
|
||||||
|
const records = await this.filesService.findByResource(
|
||||||
|
company.id,
|
||||||
|
COMPANY_RESOURCE,
|
||||||
|
);
|
||||||
|
const live = records.filter((r) => r.code === POA_DELEGATION_FILE_KEY);
|
||||||
|
const staged = records.filter(
|
||||||
|
(r) => r.code === POA_DELEGATION_PENDING_CODE,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Supersede an unreviewed upload instead of queueing another one.
|
||||||
|
for (const r of staged) {
|
||||||
|
await this.filesService.remove(r.id);
|
||||||
|
await this.withdrawDocumentIntent(company.id, r.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await this.filesService.upload({
|
||||||
|
resourceId: company.id,
|
||||||
|
resource: COMPANY_RESOURCE,
|
||||||
|
code: gated ? POA_DELEGATION_PENDING_CODE : POA_DELEGATION_FILE_KEY,
|
||||||
|
file,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (gated) {
|
||||||
|
await this.stageDocumentIntent(
|
||||||
|
company.id,
|
||||||
|
[
|
||||||
|
...live.map((r) => ({
|
||||||
|
op: "remove" as const,
|
||||||
|
fileId: r.id,
|
||||||
|
code: POA_DELEGATION_FILE_KEY,
|
||||||
|
fileName: r.name,
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
op: "add" as const,
|
||||||
|
fileId: created.id,
|
||||||
|
code: POA_DELEGATION_FILE_KEY,
|
||||||
|
fileName: created.name,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Onboarding: no review, so the old letter is simply replaced.
|
||||||
|
for (const r of live) await this.filesService.remove(r.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.getPoaDelegationView(company.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the PoA letter. A staged upload is withdrawn outright; a live file on
|
||||||
|
* an Active company is kept and flagged for deletion on approval; during
|
||||||
|
* onboarding it is deleted immediately.
|
||||||
|
*/
|
||||||
|
async removePoaDelegationLetter(
|
||||||
|
userId: string,
|
||||||
|
fileId: string,
|
||||||
|
): Promise<CompanyDocumentFileView[]> {
|
||||||
|
const { company } = await this.getCompanyInfoByUserId(userId);
|
||||||
|
const record = await this.filesService.findById(fileId);
|
||||||
|
if (
|
||||||
|
record.resource !== COMPANY_RESOURCE ||
|
||||||
|
record.resourceId !== company.id ||
|
||||||
|
(record.code !== POA_DELEGATION_FILE_KEY &&
|
||||||
|
record.code !== POA_DELEGATION_PENDING_CODE)
|
||||||
|
) {
|
||||||
|
throw new NotFoundException(`Delegation letter ${fileId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record.code === POA_DELEGATION_PENDING_CODE) {
|
||||||
|
await this.filesService.remove(fileId);
|
||||||
|
await this.withdrawDocumentIntent(company.id, fileId);
|
||||||
|
} else if (company.status === CompanyStatus.Active) {
|
||||||
|
await this.stageDocumentIntent(
|
||||||
|
company.id,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
op: "remove",
|
||||||
|
fileId,
|
||||||
|
code: POA_DELEGATION_FILE_KEY,
|
||||||
|
fileName: record.name,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await this.filesService.remove(fileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.getPoaDelegationView(company.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getPoaDelegationView(
|
||||||
|
companyId: string,
|
||||||
|
): Promise<CompanyDocumentFileView[]> {
|
||||||
|
const pending =
|
||||||
|
await this.changeRequestRepo.findPendingByCompanyId(companyId);
|
||||||
|
const removeIds = new Set(
|
||||||
|
(pending?.documents?.documentChanges ?? [])
|
||||||
|
.filter((c) => c.op === "remove")
|
||||||
|
.map((c) => c.fileId),
|
||||||
|
);
|
||||||
|
const records = await this.filesService.findByResource(
|
||||||
|
companyId,
|
||||||
|
COMPANY_RESOURCE,
|
||||||
|
);
|
||||||
|
return records
|
||||||
|
.filter(
|
||||||
|
(r) =>
|
||||||
|
r.code === POA_DELEGATION_FILE_KEY ||
|
||||||
|
r.code === POA_DELEGATION_PENDING_CODE,
|
||||||
|
)
|
||||||
|
.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
size: r.size,
|
||||||
|
mimeType: r.mimeType,
|
||||||
|
status:
|
||||||
|
r.code === POA_DELEGATION_PENDING_CODE
|
||||||
|
? ("pending_add" as const)
|
||||||
|
: removeIds.has(r.id)
|
||||||
|
? ("pending_remove" as const)
|
||||||
|
: ("live" as const),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open or append a pending change request recording document add/remove intents. */
|
||||||
|
private async stageDocumentIntent(
|
||||||
|
companyId: string,
|
||||||
|
changes: DocumentChangeIntent[],
|
||||||
|
submittedBy?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (changes.length === 0) return;
|
||||||
|
const now = new Date();
|
||||||
|
const existing =
|
||||||
|
await this.changeRequestRepo.findPendingByCompanyId(companyId);
|
||||||
|
if (existing) {
|
||||||
|
const prev = existing.documents?.documentChanges ?? [];
|
||||||
|
// Re-uploading twice before review would otherwise stage a second `remove`
|
||||||
|
// for the same live file, and the duplicate would fail on approval.
|
||||||
|
const seen = new Set(prev.map((c) => `${c.op}:${c.fileId}`));
|
||||||
|
const fresh = changes.filter((c) => !seen.has(`${c.op}:${c.fileId}`));
|
||||||
|
if (fresh.length === 0) return;
|
||||||
|
await this.changeRequestRepo.update(existing.id, {
|
||||||
|
documents: {
|
||||||
|
...existing.documents,
|
||||||
|
documentChanges: [...prev, ...fresh],
|
||||||
|
},
|
||||||
|
submittedBy: submittedBy ?? existing.submittedBy ?? null,
|
||||||
|
submittedAt: now,
|
||||||
|
note: null,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await this.changeRequestRepo.create({
|
||||||
|
companyId,
|
||||||
|
snapshot: {},
|
||||||
|
documents: { documentChanges: changes },
|
||||||
|
status: ChangeRequestStatus.Pending,
|
||||||
|
submittedBy: submittedBy ?? null,
|
||||||
|
submittedAt: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop a staged document intent referencing `fileId`. If that empties the
|
||||||
|
* request entirely, delete it so the customer's settings page unlocks.
|
||||||
|
*/
|
||||||
|
private async withdrawDocumentIntent(
|
||||||
|
companyId: string,
|
||||||
|
fileId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const existing =
|
||||||
|
await this.changeRequestRepo.findPendingByCompanyId(companyId);
|
||||||
|
if (!existing) return;
|
||||||
|
const remaining = (existing.documents?.documentChanges ?? []).filter(
|
||||||
|
(c) => c.fileId !== fileId,
|
||||||
|
);
|
||||||
|
const docs = existing.documents ?? {};
|
||||||
|
const stillHasWork =
|
||||||
|
remaining.length > 0 ||
|
||||||
|
(docs.licenseChanges?.length ?? 0) > 0 ||
|
||||||
|
(docs.documentFileIds?.length ?? 0) > 0 ||
|
||||||
|
Object.keys(existing.snapshot ?? {}).length > 0;
|
||||||
|
|
||||||
|
if (stillHasWork) {
|
||||||
|
await this.changeRequestRepo.update(existing.id, {
|
||||||
|
documents: { ...docs, documentChanges: remaining },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await this.changeRequestRepo.softDelete(existing.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply a request's staged document changes: promote adds, delete removes. */
|
||||||
|
private async applyDocumentChanges(
|
||||||
|
request: CompanyChangeRequest,
|
||||||
|
): Promise<void> {
|
||||||
|
for (const change of request.documents?.documentChanges ?? []) {
|
||||||
|
if (change.op === "add") {
|
||||||
|
await this.filesService.setCode(change.fileId, change.code);
|
||||||
|
} else {
|
||||||
|
await this.filesService.remove(change.fileId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Discard a rejected request's staged document uploads (adds only). */
|
||||||
|
private async discardDocumentChanges(
|
||||||
|
request: CompanyChangeRequest,
|
||||||
|
): Promise<void> {
|
||||||
|
for (const change of request.documents?.documentChanges ?? []) {
|
||||||
|
if (change.op === "add") {
|
||||||
|
await this.filesService.remove(change.fileId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve which company_profile a new booking belongs to, from the company
|
* Resolve which company_profile a new booking belongs to, from the company
|
||||||
* and the booking's trade direction. IMPORT → importer profile, EXPORT →
|
* and the booking's trade direction. IMPORT → importer profile, EXPORT →
|
||||||
@@ -1719,13 +2050,17 @@ export class CompaniesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fetchETradeData(tin: string) {
|
async fetchETradeData(tin: string) {
|
||||||
const { businessInfo } = await this.etradeService.resolveCompanyData(tin);
|
const { businessInfo, companyInfo } =
|
||||||
|
await this.etradeService.resolveCompanyData(tin);
|
||||||
if (!businessInfo) {
|
if (!businessInfo) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"We couldn't find a business license for this TIN with eTrade. Please double-check the number and try again.",
|
"We couldn't find a business license for this TIN with eTrade. Please double-check the number and try again.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const registrationData = this.etradeService.extractRegistrationData(businessInfo);
|
const registrationData = this.etradeService.extractRegistrationData(
|
||||||
|
businessInfo,
|
||||||
|
companyInfo,
|
||||||
|
);
|
||||||
const tinTaken = await this.companiesRepo.existsByTin(tin);
|
const tinTaken = await this.companiesRepo.existsByTin(tin);
|
||||||
return { ...registrationData, tinTaken };
|
return { ...registrationData, tinTaken };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
|
import {
|
||||||
|
NotificationAudience,
|
||||||
|
NotificationPriority,
|
||||||
|
NotificationType,
|
||||||
|
} from "@edr/types";
|
||||||
|
|
||||||
|
import { Company, CompanyStatus } from "./entities/company.entity";
|
||||||
|
import { NotificationsService } from "../notifications/notifications.service";
|
||||||
|
import { NotificationInboxService } from "../notification-inbox/notification-inbox.service";
|
||||||
|
|
||||||
|
/** Account statuses that lock the customer out and therefore must be told to them. */
|
||||||
|
const PUNITIVE_STATUSES: readonly CompanyStatus[] = [
|
||||||
|
CompanyStatus.Suspended,
|
||||||
|
CompanyStatus.Blacklisted,
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customer notifications for company account-status changes. Mirrors
|
||||||
|
* {@link ContractNotifierService}: SMS + email direct to the company contact,
|
||||||
|
* plus a persisted in-app item. Every send is fire-and-forget and never throws —
|
||||||
|
* a notification failure must not roll back the status change itself.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class CompanyNotifierService {
|
||||||
|
private readonly logger = new Logger(CompanyNotifierService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly notifications: NotificationsService,
|
||||||
|
private readonly inbox: NotificationInboxService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Send SMS + email to the company contact; log-only on failure. */
|
||||||
|
private async notifyContact(company: Company, message: string): Promise<void> {
|
||||||
|
const phone = company.contactPersonPhone ?? company.phone ?? null;
|
||||||
|
const email = company.email ?? company.generalManagerEmail ?? null;
|
||||||
|
|
||||||
|
if (phone) {
|
||||||
|
try {
|
||||||
|
await this.notifications.directSend("sms", phone, message);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`SMS failed for ${company.id}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (email) {
|
||||||
|
try {
|
||||||
|
await this.notifications.directSend("email", email, message);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Email failed for ${company.id}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!phone && !email) {
|
||||||
|
this.logger.warn(`No contact on file for ${company.id} — not notified`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tell the customer their account was suspended or blacklisted. Called only on
|
||||||
|
* a real transition into one of those statuses; other status writes are silent.
|
||||||
|
*/
|
||||||
|
statusChanged(company: Company, previous: CompanyStatus): void {
|
||||||
|
const status = company.status;
|
||||||
|
if (status === previous) return;
|
||||||
|
if (!PUNITIVE_STATUSES.includes(status)) return;
|
||||||
|
|
||||||
|
const label = status === CompanyStatus.Suspended ? "suspended" : "blacklisted";
|
||||||
|
const title = `Account ${label}`;
|
||||||
|
const body =
|
||||||
|
`Your company account has been ${label}. ` +
|
||||||
|
`You will not be able to submit new contracts or bookings. ` +
|
||||||
|
`Please contact EDR support for assistance.`;
|
||||||
|
|
||||||
|
this.logger.log(`ACCOUNT_${label.toUpperCase()} — ${company.id}`);
|
||||||
|
void this.notifyContact(company, `${title}. ${body}`);
|
||||||
|
void this.inbox.notify({
|
||||||
|
recipients: { companyId: company.id },
|
||||||
|
audience: NotificationAudience.PORTAL,
|
||||||
|
type: NotificationType.ACCOUNT_STATUS,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
link: "/settings",
|
||||||
|
data: { companyId: company.id, status },
|
||||||
|
priority: NotificationPriority.HIGH,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
ChangeRequestStatus,
|
ChangeRequestStatus,
|
||||||
CompanyChangeRequest,
|
CompanyChangeRequest,
|
||||||
|
DocumentChangeIntent,
|
||||||
LicenseChangeIntent,
|
LicenseChangeIntent,
|
||||||
} from "../entities/company-change-request.entity";
|
} from "../entities/company-change-request.entity";
|
||||||
|
|
||||||
@@ -18,6 +19,8 @@ export class ChangeRequestResponseDto {
|
|||||||
documentFileIds: string[];
|
documentFileIds: string[];
|
||||||
/** Staged business-license add/remove intents attached to this request. */
|
/** Staged business-license add/remove intents attached to this request. */
|
||||||
licenseChanges: LicenseChangeIntent[];
|
licenseChanges: LicenseChangeIntent[];
|
||||||
|
/** Staged company-document add/remove intents (e.g. the PoA letter). */
|
||||||
|
documentChanges: DocumentChangeIntent[];
|
||||||
note: string | null;
|
note: string | null;
|
||||||
submittedBy: string | null;
|
submittedBy: string | null;
|
||||||
submittedAt: Date | null;
|
submittedAt: Date | null;
|
||||||
@@ -33,6 +36,7 @@ export class ChangeRequestResponseDto {
|
|||||||
this.snapshot = req.snapshot ?? {};
|
this.snapshot = req.snapshot ?? {};
|
||||||
this.documentFileIds = req.documents?.documentFileIds ?? [];
|
this.documentFileIds = req.documents?.documentFileIds ?? [];
|
||||||
this.licenseChanges = req.documents?.licenseChanges ?? [];
|
this.licenseChanges = req.documents?.licenseChanges ?? [];
|
||||||
|
this.documentChanges = req.documents?.documentChanges ?? [];
|
||||||
this.note = req.note ?? null;
|
this.note = req.note ?? null;
|
||||||
this.submittedBy = req.submittedBy ?? null;
|
this.submittedBy = req.submittedBy ?? null;
|
||||||
this.submittedAt = req.submittedAt ?? null;
|
this.submittedAt = req.submittedAt ?? null;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { CompanyRegistrationData } from "@edr/types";
|
import { CompanyRegistrationData } from "@edr/types";
|
||||||
|
|
||||||
export class ETradeResponseDto implements CompanyRegistrationData {
|
export class ETradeResponseDto implements CompanyRegistrationData {
|
||||||
|
companyName!: string;
|
||||||
licenceNumber!: string;
|
licenceNumber!: string;
|
||||||
statusDescription!: string;
|
statusDescription!: string;
|
||||||
dateRegistered!: string;
|
dateRegistered!: string;
|
||||||
@@ -20,6 +21,7 @@ export class ETradeResponseDto implements CompanyRegistrationData {
|
|||||||
tinTaken?: boolean;
|
tinTaken?: boolean;
|
||||||
|
|
||||||
constructor(data: CompanyRegistrationData) {
|
constructor(data: CompanyRegistrationData) {
|
||||||
|
this.companyName = data.companyName;
|
||||||
this.licenceNumber = data.licenceNumber;
|
this.licenceNumber = data.licenceNumber;
|
||||||
this.statusDescription = data.statusDescription;
|
this.statusDescription = data.statusDescription;
|
||||||
this.dateRegistered = data.dateRegistered;
|
this.dateRegistered = data.dateRegistered;
|
||||||
|
|||||||
@@ -35,6 +35,19 @@ export interface OnboardingLicenseProfile {
|
|||||||
uploaded: boolean;
|
uploaded: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OnboardingPoaState {
|
||||||
|
/** True when the company operates as a freight forwarder — PoA is mandatory. */
|
||||||
|
required: boolean;
|
||||||
|
/** True once any PoA detail has been entered. */
|
||||||
|
provided: boolean;
|
||||||
|
/** True when the delegation letter is stored for the company. */
|
||||||
|
delegationLetterUploaded: boolean;
|
||||||
|
/** PoA details still missing (only populated when `required`). */
|
||||||
|
missingFields: OnboardingInfoField[];
|
||||||
|
/** False while the PoA step still owes details or a delegation letter. */
|
||||||
|
complete: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export class OnboardingRequirementsResponseDto {
|
export class OnboardingRequirementsResponseDto {
|
||||||
/** Resolved document setting code (by nationality) the docs were drawn from. */
|
/** Resolved document setting code (by nationality) the docs were drawn from. */
|
||||||
documentSettingCode: string;
|
documentSettingCode: string;
|
||||||
@@ -52,6 +65,9 @@ export class OnboardingRequirementsResponseDto {
|
|||||||
/** Per-operational-profile business-license requirements. */
|
/** Per-operational-profile business-license requirements. */
|
||||||
licenseProfiles: OnboardingLicenseProfile[];
|
licenseProfiles: OnboardingLicenseProfile[];
|
||||||
|
|
||||||
|
/** Power of Attorney state, so the wizard needn't re-derive the rule. */
|
||||||
|
poa: OnboardingPoaState;
|
||||||
|
|
||||||
/** Overall setup progress across fields + documents + licenses. */
|
/** Overall setup progress across fields + documents + licenses. */
|
||||||
progress: { completed: number; total: number };
|
progress: { completed: number; total: number };
|
||||||
|
|
||||||
@@ -70,6 +86,7 @@ export class OnboardingRequirementsResponseDto {
|
|||||||
this.companyInfo = init.companyInfo;
|
this.companyInfo = init.companyInfo;
|
||||||
this.documents = init.documents;
|
this.documents = init.documents;
|
||||||
this.licenseProfiles = init.licenseProfiles;
|
this.licenseProfiles = init.licenseProfiles;
|
||||||
|
this.poa = init.poa;
|
||||||
this.progress = init.progress;
|
this.progress = init.progress;
|
||||||
this.isComplete = init.isComplete;
|
this.isComplete = init.isComplete;
|
||||||
this.onboardingCompleted = init.onboardingCompleted;
|
this.onboardingCompleted = init.onboardingCompleted;
|
||||||
|
|||||||
@@ -30,12 +30,34 @@ export interface LicenseChangeIntent {
|
|||||||
fileName?: string;
|
fileName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A staged change to a company-level document, awaiting review. Same semantics
|
||||||
|
* as {@link LicenseChangeIntent} but keyed by the document's FileRecord `code`
|
||||||
|
* (e.g. `poa_delegation_letter`) rather than a profile: `add` → uploaded under
|
||||||
|
* the pending code, promoted to `code` on approval; `remove` → a live file that
|
||||||
|
* is deleted on approval. A replace is a `remove` plus an `add`.
|
||||||
|
*/
|
||||||
|
export interface DocumentChangeIntent {
|
||||||
|
op: "add" | "remove";
|
||||||
|
fileId: string;
|
||||||
|
/** The live FileRecord code this op targets (the upload setting's fileKey). */
|
||||||
|
code: string;
|
||||||
|
/** File name, snapshotted for the backoffice review screen. */
|
||||||
|
fileName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** File references staged alongside a change request (documents/licenses). */
|
/** File references staged alongside a change request (documents/licenses). */
|
||||||
export interface ChangeRequestDocuments {
|
export interface ChangeRequestDocuments {
|
||||||
/** FileRecord ids uploaded against the company while this request was open. */
|
/**
|
||||||
|
* FileRecord ids uploaded against the company while this request was open.
|
||||||
|
* These go live immediately — only their ids are recorded, for the reviewer.
|
||||||
|
* Contrast `documentChanges`, which stages the file behind the pending code.
|
||||||
|
*/
|
||||||
documentFileIds?: string[];
|
documentFileIds?: string[];
|
||||||
/** Staged per-profile business-license add/remove intents. */
|
/** Staged per-profile business-license add/remove intents. */
|
||||||
licenseChanges?: LicenseChangeIntent[];
|
licenseChanges?: LicenseChangeIntent[];
|
||||||
|
/** Staged company-level document add/remove intents (e.g. the PoA letter). */
|
||||||
|
documentChanges?: DocumentChangeIntent[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@Entity({ schema: "freight", name: "company_change_request" })
|
@Entity({ schema: "freight", name: "company_change_request" })
|
||||||
|
|||||||
@@ -31,17 +31,28 @@ export interface BusinessLicenseFile {
|
|||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `live` — approved & in effect; `pending_add` — uploaded, awaiting approval;
|
||||||
|
* `pending_remove` — live but flagged for deletion on approval.
|
||||||
|
*/
|
||||||
|
export type StagedFileStatus = "live" | "pending_add" | "pending_remove";
|
||||||
|
|
||||||
/** A business-license file plus its change-review state, surfaced to clients. */
|
/** A business-license file plus its change-review state, surfaced to clients. */
|
||||||
export interface ProfileLicenseFileView {
|
export interface ProfileLicenseFileView {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
size: number;
|
size: number;
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
/**
|
status: StagedFileStatus;
|
||||||
* `live` — approved & in effect; `pending_add` — uploaded, awaiting approval;
|
}
|
||||||
* `pending_remove` — live but flagged for deletion on approval.
|
|
||||||
*/
|
/** A company-level document (e.g. the PoA letter) with its change-review state. */
|
||||||
status: "live" | "pending_add" | "pending_remove";
|
export interface CompanyDocumentFileView {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
mimeType: string;
|
||||||
|
status: StagedFileStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Entity({ schema: "freight", name: "company_profiles" })
|
@Entity({ schema: "freight", name: "company_profiles" })
|
||||||
@@ -73,11 +84,17 @@ export class CompanyProfile extends BaseEntity {
|
|||||||
})
|
})
|
||||||
reference!: string | null;
|
reference!: string | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A newly requested operational role is unreviewed, so it defaults to Pending.
|
||||||
|
* Only {@link CompaniesService.setCompanyProfileStatus} may promote it to
|
||||||
|
* Active — an approved-by-default role would let a customer self-grant a
|
||||||
|
* service (e.g. importer) without any documentation review.
|
||||||
|
*/
|
||||||
@Column({
|
@Column({
|
||||||
name: "status",
|
name: "status",
|
||||||
type: "varchar",
|
type: "varchar",
|
||||||
length: 32,
|
length: 32,
|
||||||
default: ProfileStatus.Active,
|
default: ProfileStatus.Pending,
|
||||||
})
|
})
|
||||||
status!: ProfileStatus;
|
status!: ProfileStatus;
|
||||||
|
|
||||||
|
|||||||
@@ -87,12 +87,21 @@ export class ETradeService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `companyInfo` carries the registered organization name (`BusinessName`);
|
||||||
|
* `businessInfo` only carries the licence's `TradeName`. Pass both so the
|
||||||
|
* company name resolves to the legal entity rather than the trade name — and
|
||||||
|
* never to `ManagerNameEng`, which is the manager's personal name.
|
||||||
|
*/
|
||||||
extractRegistrationData(
|
extractRegistrationData(
|
||||||
businessInfo: ETradeBusinessInfo,
|
businessInfo: ETradeBusinessInfo,
|
||||||
|
companyInfo?: ETradeCompanyInfo,
|
||||||
): CompanyRegistrationData {
|
): CompanyRegistrationData {
|
||||||
const primaryManager = businessInfo.AssociateShortInfos?.[0];
|
const primaryManager = businessInfo.AssociateShortInfos?.[0];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
companyName:
|
||||||
|
companyInfo?.BusinessName?.trim() || businessInfo.TradeName?.trim() || "",
|
||||||
licenceNumber: businessInfo.LicenceNumber,
|
licenceNumber: businessInfo.LicenceNumber,
|
||||||
statusDescription: businessInfo.StatusDescription,
|
statusDescription: businessInfo.StatusDescription,
|
||||||
dateRegistered: businessInfo.DateRegistered,
|
dateRegistered: businessInfo.DateRegistered,
|
||||||
|
|||||||
@@ -22,12 +22,15 @@ export class BookingRequestRepository extends BaseRepository<BookingRequest> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** GL queue: pending requests across all contracts, oldest first. */
|
/**
|
||||||
async findPending(): Promise<BookingRequest[]> {
|
* GL queue: every request across all contracts, newest first. The queue page
|
||||||
|
* filters by status client-side (pending work vs accepted/rejected history),
|
||||||
|
* and surfaces the customer — so the contract's company rides along.
|
||||||
|
*/
|
||||||
|
async findQueue(): Promise<BookingRequest[]> {
|
||||||
return this.repository.find({
|
return this.repository.find({
|
||||||
where: { status: 'PENDING' },
|
order: { createdAt: 'DESC' },
|
||||||
order: { createdAt: 'ASC' },
|
relations: { contract: { company: true } },
|
||||||
relations: { contract: true },
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ export class BookingRequestService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
queue(): Promise<BookingRequest[]> {
|
queue(): Promise<BookingRequest[]> {
|
||||||
return this.repo.findPending();
|
return this.repo.findQueue();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async findPending(requestId: string): Promise<BookingRequest> {
|
private async findPending(requestId: string): Promise<BookingRequest> {
|
||||||
|
|||||||
@@ -218,7 +218,7 @@ export class ContractBookingService {
|
|||||||
contractType: 'NEW',
|
contractType: 'NEW',
|
||||||
customsClearingEnabled: contract.customsClearingEnabled,
|
customsClearingEnabled: contract.customsClearingEnabled,
|
||||||
customsClearingAgent: contract.customsClearingAgent ?? null,
|
customsClearingAgent: contract.customsClearingAgent ?? null,
|
||||||
equipmentReturn: contract.equipmentReturn ?? 'WITHOUT_RETURN',
|
equipmentReturn: dto.equipmentReturn ?? contract.equipmentReturn ?? 'WITHOUT_RETURN',
|
||||||
originYardId: route?.originYardId ?? null,
|
originYardId: route?.originYardId ?? null,
|
||||||
destinationYardId: route?.destinationYardId ?? null,
|
destinationYardId: route?.destinationYardId ?? null,
|
||||||
tradeDirection: contract.tradeDirection,
|
tradeDirection: contract.tradeDirection,
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export class ContractsController {
|
|||||||
|
|
||||||
@Get('booking-requests/queue')
|
@Get('booking-requests/queue')
|
||||||
@BookingStaff(FREIGHT_PERMS.contracts.createBooking)
|
@BookingStaff(FREIGHT_PERMS.contracts.createBooking)
|
||||||
@ApiOperation({ summary: 'GL queue: pending shipment requests across contracts' })
|
@ApiOperation({ summary: 'GL queue: shipment requests across contracts (all statuses, newest first)' })
|
||||||
bookingRequestQueue() {
|
bookingRequestQueue() {
|
||||||
return this.bookingRequestService.queue();
|
return this.bookingRequestService.queue();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
IsArray,
|
IsArray,
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsDateString,
|
IsDateString,
|
||||||
|
IsIn,
|
||||||
IsInt,
|
IsInt,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
@@ -14,6 +15,9 @@ import {
|
|||||||
ValidateNested,
|
ValidateNested,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
|
/** Per-shipment equipment return — "NA" stays contract-level only. */
|
||||||
|
const SHIPMENT_EQUIPMENT_RETURNS = ['WITH_RETURN', 'WITHOUT_RETURN'] as const;
|
||||||
|
|
||||||
/** One physical container under a booking line — entered at booking time. */
|
/** One physical container under a booking line — entered at booking time. */
|
||||||
export class CreateContainerUnitDto {
|
export class CreateContainerUnitDto {
|
||||||
@ApiProperty({ description: 'ISO 6346 container number, e.g. ABCD1234567' })
|
@ApiProperty({ description: 'ISO 6346 container number, e.g. ABCD1234567' })
|
||||||
@@ -134,6 +138,15 @@ export class CreateBookingUnderContractDto {
|
|||||||
@IsDateString()
|
@IsDateString()
|
||||||
scheduledDate?: string;
|
scheduledDate?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
enum: SHIPMENT_EQUIPMENT_RETURNS,
|
||||||
|
description:
|
||||||
|
'Per-shipment equipment return override; omitted → the contract default applies.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn([...SHIPMENT_EQUIPMENT_RETURNS])
|
||||||
|
equipmentReturn?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ type: [CreateBookingContainerLineDto] })
|
@ApiPropertyOptional({ type: [CreateBookingContainerLineDto] })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsArray()
|
@IsArray()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module, forwardRef } from "@nestjs/common";
|
||||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||||
import { Session } from "@tria-plc/iamapi-common/entities/iam/user/session.entity";
|
import { Session } from "@tria-plc/iamapi-common/entities/iam/user/session.entity";
|
||||||
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
|
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
|
||||||
@@ -17,8 +17,9 @@ import { WsAuthService } from "./ws-auth.service";
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([Notification, User, Session]),
|
TypeOrmModule.forFeature([Notification, User, Session]),
|
||||||
// ExternalProfileRepository + CompanyProfileRepository (portal targeting)
|
// ExternalProfileRepository + CompanyProfileRepository (portal targeting).
|
||||||
CompaniesModule,
|
// CompaniesModule imports this module back for CompanyNotifierService.
|
||||||
|
forwardRef(() => CompaniesModule),
|
||||||
// BackofficeService.getOrganizationEmployees (staff targeting)
|
// BackofficeService.getOrganizationEmployees (staff targeting)
|
||||||
BackofficeModule,
|
BackofficeModule,
|
||||||
// EmailClientService + SmsClientService (HIGH-priority fan-out)
|
// EmailClientService + SmsClientService (HIGH-priority fan-out)
|
||||||
|
|||||||
@@ -43,11 +43,26 @@ export class Route extends BaseEntity {
|
|||||||
* Human-readable route label: yard names, not yard codes — "Addis Ababa → Dire Dawa",
|
* Human-readable route label: yard names, not yard codes — "Addis Ababa → Dire Dawa",
|
||||||
* not "ADDIS_ABABA → DIRE_DAWA". A yard's display name is its `label`; `code` is the
|
* not "ADDIS_ABABA → DIRE_DAWA". A yard's display name is its `label`; `code` is the
|
||||||
* machine identifier and is only a fallback for a yard missing one.
|
* machine identifier and is only a fallback for a yard missing one.
|
||||||
|
*
|
||||||
|
* When the route's milestones are loaded (with their yards), the label is the FULL
|
||||||
|
* ordered corridor — "Addis Ababa → Adama → Dire Dawa" — since milestones already
|
||||||
|
* include the origin (first) and destination (last). Without milestones it falls
|
||||||
|
* back to origin → destination.
|
||||||
*/
|
*/
|
||||||
export function formatRouteLabel(route: {
|
export function formatRouteLabel(route: {
|
||||||
originYard?: { code?: string; label?: string } | null;
|
originYard?: { code?: string; label?: string } | null;
|
||||||
destinationYard?: { code?: string; label?: string } | null;
|
destinationYard?: { code?: string; label?: string } | null;
|
||||||
|
milestones?: Array<{
|
||||||
|
sequenceNo: number;
|
||||||
|
yard?: { code?: string; label?: string } | null;
|
||||||
|
}> | null;
|
||||||
}): string {
|
}): string {
|
||||||
|
const stops = [...(route.milestones ?? [])]
|
||||||
|
.sort((a, b) => a.sequenceNo - b.sequenceNo)
|
||||||
|
.map((m) => m.yard?.label ?? m.yard?.code)
|
||||||
|
.filter((name): name is string => Boolean(name));
|
||||||
|
if (stops.length >= 2) return stops.join(' → ');
|
||||||
|
|
||||||
const origin = route.originYard?.label ?? route.originYard?.code ?? 'Origin';
|
const origin = route.originYard?.label ?? route.originYard?.code ?? 'Origin';
|
||||||
const dest = route.destinationYard?.label ?? route.destinationYard?.code ?? 'Destination';
|
const dest = route.destinationYard?.label ?? route.destinationYard?.code ?? 'Destination';
|
||||||
return `${origin} → ${dest}`;
|
return `${origin} → ${dest}`;
|
||||||
|
|||||||
@@ -23,8 +23,9 @@ export class TrainSchedulesRepository extends BaseRepository<TrainSchedule> {
|
|||||||
where: { id },
|
where: { id },
|
||||||
relations: {
|
relations: {
|
||||||
// Yards carry the route's display name; without them formatRouteLabel
|
// Yards carry the route's display name; without them formatRouteLabel
|
||||||
// degrades to the literal "Origin → Destination".
|
// degrades to the literal "Origin → Destination". Milestones (with
|
||||||
route: { originYard: true, destinationYard: true },
|
// their yards) give it the full corridor path.
|
||||||
|
route: { originYard: true, destinationYard: true, milestones: { yard: true } },
|
||||||
trainSet: {
|
trainSet: {
|
||||||
locomotive: true,
|
locomotive: true,
|
||||||
locomotives: { locomotive: true },
|
locomotives: { locomotive: true },
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity
|
|||||||
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
||||||
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
||||||
import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository';
|
import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository';
|
||||||
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
|
|
||||||
import { BookingNotifierService } from './booking-notifier.service';
|
import { BookingNotifierService } from './booking-notifier.service';
|
||||||
import { TrainSchedulingService } from './train-scheduling.service';
|
import { TrainSchedulingService } from './train-scheduling.service';
|
||||||
import { eatDay, groupBookingsIntoBoardWindows } from './batch-window.util';
|
import { eatDay, groupBookingsIntoBoardWindows } from './batch-window.util';
|
||||||
@@ -568,7 +567,6 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rules = await this.loadGlobalRules();
|
|
||||||
const wagonDims = await this.loadWagonDims();
|
const wagonDims = await this.loadWagonDims();
|
||||||
const required = need ?? this.needFor(booking, wagonDims);
|
const required = need ?? this.needFor(booking, wagonDims);
|
||||||
let corridorMatched = false;
|
let corridorMatched = false;
|
||||||
@@ -578,7 +576,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
);
|
);
|
||||||
const locomotive = schedule?.trainSet?.locomotive;
|
const locomotive = schedule?.trainSet?.locomotive;
|
||||||
if (!schedule || !locomotive) continue;
|
if (!schedule || !locomotive) continue;
|
||||||
const limits = await this.capacityLimits(locomotive, rules);
|
const limits = await this.capacityLimits(locomotive);
|
||||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||||
const leg = budget.legOf(booking.originYardId, booking.destinationYardId);
|
const leg = budget.legOf(booking.originYardId, booking.destinationYardId);
|
||||||
if (!leg) continue; // this train's route doesn't carry the booking's leg
|
if (!leg) continue; // this train's route doesn't carry the booking's leg
|
||||||
@@ -748,8 +746,9 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
trainSet: { locomotive: true },
|
trainSet: { locomotive: true },
|
||||||
originStation: true,
|
originStation: true,
|
||||||
destinationStation: true,
|
destinationStation: true,
|
||||||
// Yards supply the route's display name for `routeName` below.
|
// Yards supply the route's display name for `routeName` below;
|
||||||
route: { originYard: true, destinationYard: true },
|
// milestones (with yards) give it the full corridor path.
|
||||||
|
route: { originYard: true, destinationYard: true, milestones: { yard: true } },
|
||||||
},
|
},
|
||||||
order: { [sortBy]: sortOrder } as never,
|
order: { [sortBy]: sortOrder } as never,
|
||||||
skip: (page - 1) * pageSize,
|
skip: (page - 1) * pageSize,
|
||||||
@@ -757,7 +756,6 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const wagonDims = await this.loadWagonDims();
|
const wagonDims = await this.loadWagonDims();
|
||||||
const rules = await this.loadGlobalRules();
|
|
||||||
const linkRepo = this.dataSource.getRepository(TrainScheduleBooking);
|
const linkRepo = this.dataSource.getRepository(TrainScheduleBooking);
|
||||||
|
|
||||||
const board: BatchBoardSchedule[] = [];
|
const board: BatchBoardSchedule[] = [];
|
||||||
@@ -787,7 +785,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
board.push(this.buildScheduleSummary(s, items, rules));
|
board.push(this.buildScheduleSummary(s, items));
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -817,7 +815,6 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const wagonDims = await this.loadWagonDims();
|
const wagonDims = await this.loadWagonDims();
|
||||||
const rules = await this.loadGlobalRules();
|
|
||||||
const linkRepo = this.dataSource.getRepository(TrainScheduleBooking);
|
const linkRepo = this.dataSource.getRepository(TrainScheduleBooking);
|
||||||
const links = await linkRepo.find({ where: { trainScheduleId: s.id } });
|
const links = await linkRepo.find({ where: { trainScheduleId: s.id } });
|
||||||
const linkedIds = new Set(links.map((l) => l.bookingId));
|
const linkedIds = new Set(links.map((l) => l.bookingId));
|
||||||
@@ -1011,7 +1008,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
|
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null, rules),
|
capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null),
|
||||||
counts: {
|
counts: {
|
||||||
allocated: items.filter((i) => i.state === "ALLOCATED").length,
|
allocated: items.filter((i) => i.state === "ALLOCATED").length,
|
||||||
selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH")
|
selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH")
|
||||||
@@ -1045,9 +1042,10 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
/**
|
/**
|
||||||
* Board capacity figures. `usedWeightTons` is GROSS (each item's weight already
|
* Board capacity figures. `usedWeightTons` is GROSS (each item's weight already
|
||||||
* includes the tare of the wagons it occupies), so the ceiling it is measured
|
* includes the tare of the wagons it occupies), so the ceiling it is measured
|
||||||
* against must be the same one the fill loop spends from: the locomotive floored
|
* against must be the same one the fill loop spends from: the locomotive's own
|
||||||
* by the global rule caps and widened by its overage tolerance. Reading the raw
|
* limits widened by its overage tolerance (global rule caps do not apply, same
|
||||||
* `loco.maxPullWeightTons` here showed staff a ceiling the batch engine did not use.
|
* as {@link capacityLimits}). Reading the raw `loco.maxPullWeightTons` here
|
||||||
|
* showed staff a ceiling the batch engine did not use.
|
||||||
*/
|
*/
|
||||||
private computeBoardCapacity(
|
private computeBoardCapacity(
|
||||||
items: Array<{
|
items: Array<{
|
||||||
@@ -1058,29 +1056,18 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
}>,
|
}>,
|
||||||
loco: Locomotive | null,
|
loco: Locomotive | null,
|
||||||
maxWagons: number | null,
|
maxWagons: number | null,
|
||||||
rules: TrainSchedulingGlobalRules | null,
|
|
||||||
): BatchBoardSchedule["capacity"] {
|
): BatchBoardSchedule["capacity"] {
|
||||||
const allocated = items.filter((i) => i.state === "ALLOCATED");
|
const allocated = items.filter((i) => i.state === "ALLOCATED");
|
||||||
const committed = items.filter(
|
const committed = items.filter(
|
||||||
(i) => i.state === "ALLOCATED" || i.state === "SELECTED_FOR_BATCH",
|
(i) => i.state === "ALLOCATED" || i.state === "SELECTED_FOR_BATCH",
|
||||||
);
|
);
|
||||||
const caps = loco
|
const caps = loco
|
||||||
? trainHardCaps(
|
? trainHardCaps({
|
||||||
{
|
maxPullWeightTons: Number(loco.maxPullWeightTons),
|
||||||
maxPullWeightTons: Number(loco.maxPullWeightTons),
|
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
|
||||||
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
|
overageToleranceTons: Number(loco.overageToleranceTons) || 0,
|
||||||
overageToleranceTons: Number(loco.overageToleranceTons) || 0,
|
overageToleranceMeters: Number(loco.overageToleranceMeters) || 0,
|
||||||
overageToleranceMeters: Number(loco.overageToleranceMeters) || 0,
|
})
|
||||||
},
|
|
||||||
{
|
|
||||||
maxTrainWeightTons: rules?.maxTrainWeightTons
|
|
||||||
? Number(rules.maxTrainWeightTons)
|
|
||||||
: undefined,
|
|
||||||
maxTrainLengthMeters: rules?.maxTrainLengthMeters
|
|
||||||
? Number(rules.maxTrainLengthMeters)
|
|
||||||
: undefined,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
: null;
|
: null;
|
||||||
const round2 = (value: number) => Math.round(value * 100) / 100;
|
const round2 = (value: number) => Math.round(value * 100) / 100;
|
||||||
|
|
||||||
@@ -1099,7 +1086,6 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
private buildScheduleSummary(
|
private buildScheduleSummary(
|
||||||
s: TrainSchedule,
|
s: TrainSchedule,
|
||||||
items: BatchBoardBooking[],
|
items: BatchBoardBooking[],
|
||||||
rules: TrainSchedulingGlobalRules | null,
|
|
||||||
): BatchBoardSchedule {
|
): BatchBoardSchedule {
|
||||||
const loco = s.trainSet?.locomotive ?? null;
|
const loco = s.trainSet?.locomotive ?? null;
|
||||||
|
|
||||||
@@ -1134,7 +1120,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
|
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null, rules),
|
capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null),
|
||||||
counts: {
|
counts: {
|
||||||
allocated: items.filter((i) => i.state === "ALLOCATED").length,
|
allocated: items.filter((i) => i.state === "ALLOCATED").length,
|
||||||
selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH")
|
selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH")
|
||||||
@@ -1203,10 +1189,9 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rules = await this.loadGlobalRules();
|
|
||||||
const wagonDims = await this.loadWagonDims();
|
const wagonDims = await this.loadWagonDims();
|
||||||
const limits = await this.capacityLimits(locomotive, rules);
|
const limits = await this.capacityLimits(locomotive);
|
||||||
await this.syncScheduleMaxWagons(schedule, locomotive, rules);
|
await this.syncScheduleMaxWagons(schedule, locomotive);
|
||||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||||
const minPerWagon = this.minPerWagonNeed(wagonDims);
|
const minPerWagon = this.minPerWagonNeed(wagonDims);
|
||||||
if (budget.isExhausted(minPerWagon)) {
|
if (budget.isExhausted(minPerWagon)) {
|
||||||
@@ -1405,7 +1390,6 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
return { scheduleIds: [], commercialReserved: 0 };
|
return { scheduleIds: [], commercialReserved: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
const rules = await this.loadGlobalRules();
|
|
||||||
const wagonDims = await this.loadWagonDims();
|
const wagonDims = await this.loadWagonDims();
|
||||||
|
|
||||||
// Live per-schedule corridor budget + arm flag, in departure order.
|
// Live per-schedule corridor budget + arm flag, in departure order.
|
||||||
@@ -1420,8 +1404,8 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const limits = await this.capacityLimits(locomotive, rules);
|
const limits = await this.capacityLimits(locomotive);
|
||||||
await this.syncScheduleMaxWagons(schedule, locomotive, rules);
|
await this.syncScheduleMaxWagons(schedule, locomotive);
|
||||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||||
trains.push({ id, budget, armed: false });
|
trains.push({ id, budget, armed: false });
|
||||||
}
|
}
|
||||||
@@ -1973,9 +1957,8 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||||
const locomotive = schedule?.trainSet?.locomotive;
|
const locomotive = schedule?.trainSet?.locomotive;
|
||||||
if (!schedule || !locomotive) return null;
|
if (!schedule || !locomotive) return null;
|
||||||
const rules = await this.loadGlobalRules();
|
|
||||||
const wagonDims = await this.loadWagonDims();
|
const wagonDims = await this.loadWagonDims();
|
||||||
const limits = await this.capacityLimits(locomotive, rules);
|
const limits = await this.capacityLimits(locomotive);
|
||||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||||
return { budget, needFor: (booking) => this.needFor(booking, wagonDims) };
|
return { budget, needFor: (booking) => this.needFor(booking, wagonDims) };
|
||||||
}
|
}
|
||||||
@@ -2520,11 +2503,12 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
* `base` via {@link needFor}, whose weight axis is gross. The locomotive's
|
* `base` via {@link needFor}, whose weight axis is gross. The locomotive's
|
||||||
* overage tolerance is returned separately — the corridor budget spends it
|
* overage tolerance is returned separately — the corridor budget spends it
|
||||||
* only to admit a booking whole, never to size a split.
|
* only to admit a booking whole, never to size a split.
|
||||||
|
*
|
||||||
|
* Limits come from the LOCOMOTIVE ALONE — the global-rules weight/length
|
||||||
|
* caps deliberately do not apply here (a mis-set global row once capped
|
||||||
|
* every train at 14m and no export booking could board).
|
||||||
*/
|
*/
|
||||||
private async capacityLimits(
|
private async capacityLimits(locomotive: Locomotive): Promise<TrainLimits> {
|
||||||
locomotive: Locomotive,
|
|
||||||
rules: TrainSchedulingGlobalRules | null,
|
|
||||||
): Promise<TrainLimits> {
|
|
||||||
const wagonTypes = await this.loadWagonTypeDimensions();
|
const wagonTypes = await this.loadWagonTypeDimensions();
|
||||||
const derived = deriveTrainCapacityFromLocomotive(
|
const derived = deriveTrainCapacityFromLocomotive(
|
||||||
{
|
{
|
||||||
@@ -2534,14 +2518,6 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
overageToleranceMeters: Number(locomotive.overageToleranceMeters) || 0,
|
overageToleranceMeters: Number(locomotive.overageToleranceMeters) || 0,
|
||||||
},
|
},
|
||||||
wagonTypes,
|
wagonTypes,
|
||||||
{
|
|
||||||
maxTrainWeightTons: rules?.maxTrainWeightTons
|
|
||||||
? Number(rules.maxTrainWeightTons)
|
|
||||||
: undefined,
|
|
||||||
maxTrainLengthMeters: rules?.maxTrainLengthMeters
|
|
||||||
? Number(rules.maxTrainLengthMeters)
|
|
||||||
: undefined,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
base: {
|
base: {
|
||||||
@@ -2556,18 +2532,23 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Keep schedule.max_wagons aligned with locomotive physical limits. */
|
/**
|
||||||
|
* Keep schedule.max_wagons aligned with the train's boarding limit: the
|
||||||
|
* locomotive's length-derived slot count. The physical wagons currently in
|
||||||
|
* the train set do NOT cap this — bookings are admitted on length/weight
|
||||||
|
* alone and yard staff attach the wagons manually before departure.
|
||||||
|
*/
|
||||||
private async syncScheduleMaxWagons(
|
private async syncScheduleMaxWagons(
|
||||||
schedule: TrainSchedule,
|
schedule: TrainSchedule,
|
||||||
locomotive: Locomotive,
|
locomotive: Locomotive,
|
||||||
rules: TrainSchedulingGlobalRules | null,
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const limits = await this.capacityLimits(locomotive, rules);
|
const limits = await this.capacityLimits(locomotive);
|
||||||
if ((schedule.maxWagons ?? 0) !== limits.base.wagons) {
|
const maxWagons = limits.base.wagons;
|
||||||
|
if ((schedule.maxWagons ?? 0) !== maxWagons) {
|
||||||
await this.dataSource
|
await this.dataSource
|
||||||
.getRepository(TrainSchedule)
|
.getRepository(TrainSchedule)
|
||||||
.update(schedule.id, { maxWagons: limits.base.wagons });
|
.update(schedule.id, { maxWagons });
|
||||||
schedule.maxWagons = limits.base.wagons;
|
schedule.maxWagons = maxWagons;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2655,12 +2636,6 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadGlobalRules(): Promise<TrainSchedulingGlobalRules | null> {
|
|
||||||
return this.dataSource
|
|
||||||
.getRepository(TrainSchedulingGlobalRules)
|
|
||||||
.findOne({ where: {} });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ordered stop yards of the schedule's route (origin → milestones →
|
* Ordered stop yards of the schedule's route (origin → milestones →
|
||||||
* destination); the legacy two-stop pseudo-route when milestones are absent.
|
* destination); the legacy two-stop pseudo-route when milestones are absent.
|
||||||
@@ -2684,6 +2659,11 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
* Remaining capacity per corridor edge = hard caps minus what allocated +
|
* Remaining capacity per corridor edge = hard caps minus what allocated +
|
||||||
* reserved bookings already use ON THEIR OWN LEGS. A booking riding only
|
* reserved bookings already use ON THEIR OWN LEGS. A booking riding only
|
||||||
* Dire→Djibouti leaves the Addis→Dire edges untouched.
|
* Dire→Djibouti leaves the Addis→Dire edges untouched.
|
||||||
|
*
|
||||||
|
* The wagon axis is the locomotive's length-derived slot count only — the
|
||||||
|
* physical wagons currently marshalled in the train set do NOT cap it.
|
||||||
|
* Bookings are admitted on length/weight capacity and yard staff attach
|
||||||
|
* the missing wagons manually before wagon assignment.
|
||||||
*/
|
*/
|
||||||
private async remainingBudget(
|
private async remainingBudget(
|
||||||
schedule: TrainSchedule,
|
schedule: TrainSchedule,
|
||||||
@@ -2795,9 +2775,8 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
if ((await this.remainingWagons(schedule)) <= 0) return true;
|
if ((await this.remainingWagons(schedule)) <= 0) return true;
|
||||||
const locomotive = schedule.trainSet?.locomotive;
|
const locomotive = schedule.trainSet?.locomotive;
|
||||||
if (!locomotive) return false; // no weight/length limits to bind against
|
if (!locomotive) return false; // no weight/length limits to bind against
|
||||||
const rules = await this.loadGlobalRules();
|
|
||||||
const wagonDims = await this.loadWagonDims();
|
const wagonDims = await this.loadWagonDims();
|
||||||
const limits = await this.capacityLimits(locomotive, rules);
|
const limits = await this.capacityLimits(locomotive);
|
||||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||||
return budget.isExhausted(this.minPerWagonNeed(wagonDims));
|
return budget.isExhausted(this.minPerWagonNeed(wagonDims));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
NotificationAudience,
|
NotificationAudience,
|
||||||
|
NotificationPriority,
|
||||||
NotificationType,
|
NotificationType,
|
||||||
NotifyInput,
|
NotifyInput,
|
||||||
} from '@edr/types';
|
} from '@edr/types';
|
||||||
@@ -114,11 +115,15 @@ export class BookingNotifierService {
|
|||||||
const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' });
|
const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' });
|
||||||
const msg =
|
const msg =
|
||||||
`Only ${offeredWagons} of ${totalWagons} wagons fit the train for booking ${b.reference ?? b.id}. ` +
|
`Only ${offeredWagons} of ${totalWagons} wagons fit the train for booking ${b.reference ?? b.id}. ` +
|
||||||
`Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to accept and ship ${offeredWagons} wagon${offeredWagons === 1 ? '' : 's'} now ` +
|
`Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to accept and ship ${offeredWagons} wagon${offeredWagons === 1 ? '' : 's'} now. ` +
|
||||||
`(the rest returns to your contract to book later). If you do not pay, the booking stays whole and you can rebook in the next window. Deadline: ${eat} EAT.`;
|
`The remaining ${totalWagons - offeredWagons} return${totalWagons - offeredWagons === 1 ? 's' : ''} to your contract — book them yourself in a later window. ` +
|
||||||
|
`If you do not pay, the booking stays whole and you can rebook in the next window. Deadline: ${eat} EAT.`;
|
||||||
await this.notifyContact(b, msg, 'PAY NOW (PARTIAL)');
|
await this.notifyContact(b, msg, 'PAY NOW (PARTIAL)');
|
||||||
|
// HIGH: a split is a change to what the customer ordered AND a live payment
|
||||||
|
// deadline — it must reach email/SMS, not just the portal inbox.
|
||||||
this.inApp(b, 'Partial allocation offer', msg, {
|
this.inApp(b, 'Partial allocation offer', msg, {
|
||||||
type: NotificationType.INVOICE_ISSUED,
|
type: NotificationType.INVOICE_ISSUED,
|
||||||
|
priority: NotificationPriority.HIGH,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2645,8 +2645,9 @@ export class TrainSchedulingService {
|
|||||||
const schedules = await this.trainSchedulesRepository.findAll({
|
const schedules = await this.trainSchedulesRepository.findAll({
|
||||||
relations: {
|
relations: {
|
||||||
trainSet: { locomotive: true, locomotives: { locomotive: true } },
|
trainSet: { locomotive: true, locomotives: { locomotive: true } },
|
||||||
// Yards carry the route's display name used by mapScheduleListItem.
|
// Yards carry the route's display name used by mapScheduleListItem;
|
||||||
route: { originYard: true, destinationYard: true },
|
// milestones (with yards) let it show the full corridor path.
|
||||||
|
route: { originYard: true, destinationYard: true, milestones: { yard: true } },
|
||||||
originStation: true,
|
originStation: true,
|
||||||
destinationStation: true,
|
destinationStation: true,
|
||||||
scheduleBookings: { booking: true },
|
scheduleBookings: { booking: true },
|
||||||
@@ -3111,6 +3112,10 @@ export class TrainSchedulingService {
|
|||||||
const wagonTypes = await this.loadSchedulingWagonTypeDimensions();
|
const wagonTypes = await this.loadSchedulingWagonTypeDimensions();
|
||||||
|
|
||||||
if (locomotive) {
|
if (locomotive) {
|
||||||
|
// With a locomotive assigned its own limits are the single source of
|
||||||
|
// truth — global-rules / env caps do not floor them (a mis-set global
|
||||||
|
// row once capped every train at 14m). Only an explicit per-request dto
|
||||||
|
// override still applies.
|
||||||
const derived = deriveTrainCapacityFromLocomotive(
|
const derived = deriveTrainCapacityFromLocomotive(
|
||||||
{
|
{
|
||||||
maxPullWeightTons: Number(locomotive.maxPullWeightTons),
|
maxPullWeightTons: Number(locomotive.maxPullWeightTons),
|
||||||
@@ -3120,8 +3125,8 @@ export class TrainSchedulingService {
|
|||||||
},
|
},
|
||||||
wagonTypes,
|
wagonTypes,
|
||||||
{
|
{
|
||||||
maxTrainWeightTons: ruleWeightCap,
|
maxTrainWeightTons: dto?.maxTrainWeightTons,
|
||||||
maxTrainLengthMeters: ruleLengthCap,
|
maxTrainLengthMeters: dto?.maxTrainLengthMeters,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ export class LoadInventoryDto {
|
|||||||
@IsUUID()
|
@IsUUID()
|
||||||
wagonId!: string;
|
wagonId!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ format: 'uuid', description: 'Train schedule this load belongs to (recorded on the loading).' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
trainScheduleId?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Weight loaded onto the wagon (t)' })
|
@ApiPropertyOptional({ description: 'Weight loaded onto the wagon (t)' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
|
|||||||
@@ -28,9 +28,17 @@ export class WarehouseLoading extends BaseEntity {
|
|||||||
@JoinColumn({ name: 'booking_id' })
|
@JoinColumn({ name: 'booking_id' })
|
||||||
booking?: Booking | null;
|
booking?: Booking | null;
|
||||||
|
|
||||||
/** Physical wagon the item was loaded onto. References freight.wagons (read-only link). */
|
/**
|
||||||
@Column({ name: 'wagon_id', type: 'uuid' })
|
* Physical wagon the item was loaded onto. References freight.wagons
|
||||||
wagonId!: string;
|
* (read-only link). Nullable: a schedule-level auto-load may not resolve to
|
||||||
|
* one wagon — the train association then lives in trainScheduleId.
|
||||||
|
*/
|
||||||
|
@Column({ name: 'wagon_id', type: 'uuid', nullable: true })
|
||||||
|
wagonId?: string | null;
|
||||||
|
|
||||||
|
/** Train schedule the item was loaded onto (read-only link to scheduling). */
|
||||||
|
@Column({ name: 'train_schedule_id', type: 'uuid', nullable: true })
|
||||||
|
trainScheduleId?: string | null;
|
||||||
|
|
||||||
@Column({ name: 'loaded_at', type: 'timestamptz' })
|
@Column({ name: 'loaded_at', type: 'timestamptz' })
|
||||||
loadedAt!: Date;
|
loadedAt!: Date;
|
||||||
|
|||||||
@@ -77,11 +77,6 @@ export class WarehouseInventoryController {
|
|||||||
return this.inventoryService.bulkReceive(dto);
|
return this.inventoryService.bulkReceive(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('load-passed-export')
|
|
||||||
@ApiOperation({ summary: 'Bulk-load all EXPORT inventory that passed inspection (READY_FOR_LOADING)' })
|
|
||||||
loadPassedExport(@Body('performedBy') performedBy?: string) {
|
|
||||||
return this.inventoryService.loadPassedExport(performedBy);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('ready-to-load-export')
|
@Get('ready-to-load-export')
|
||||||
@ApiOperation({ summary: 'EXPORT inventory that passed inspection and is READY_FOR_LOADING' })
|
@ApiOperation({ summary: 'EXPORT inventory that passed inspection and is READY_FOR_LOADING' })
|
||||||
|
|||||||
@@ -207,6 +207,7 @@ export interface EligibleBookingRow {
|
|||||||
customerTin: string | null;
|
customerTin: string | null;
|
||||||
customerPhone: string | null;
|
customerPhone: string | null;
|
||||||
containerNumber: string | null;
|
containerNumber: string | null;
|
||||||
|
sealNumbers: string | null;
|
||||||
containerQuantity: number | null;
|
containerQuantity: number | null;
|
||||||
containerPackagingType: string | null;
|
containerPackagingType: string | null;
|
||||||
cargoDescription: string | null;
|
cargoDescription: string | null;
|
||||||
@@ -242,11 +243,6 @@ export interface BulkReceiveResult {
|
|||||||
results: { bookingId: string; status: string; inventoryId?: string; grnNumber?: string; reason?: string }[];
|
results: { bookingId: string; status: string; inventoryId?: string; grnNumber?: string; reason?: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoadPassedExportResult {
|
|
||||||
loadedCount: number;
|
|
||||||
skippedCount: number;
|
|
||||||
results: { inventoryId: string; status: string; reason?: string }[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BulkInspectResult {
|
export interface BulkInspectResult {
|
||||||
inspectedCount: number;
|
inspectedCount: number;
|
||||||
@@ -774,7 +770,8 @@ export class WarehouseInventoryService {
|
|||||||
company.name AS "customer",
|
company.name AS "customer",
|
||||||
company.tin AS "customerTin",
|
company.tin AS "customerTin",
|
||||||
COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) AS "customerPhone",
|
COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) AS "customerPhone",
|
||||||
bc.container_numbers AS "containerNumber",
|
COALESCE(bcu.unit_numbers, bc.container_numbers) AS "containerNumber",
|
||||||
|
bcu.seal_numbers AS "sealNumbers",
|
||||||
bc.container_quantity AS "containerQuantity",
|
bc.container_quantity AS "containerQuantity",
|
||||||
bc.container_packaging_type AS "containerPackagingType",
|
bc.container_packaging_type AS "containerPackagingType",
|
||||||
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL
|
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL
|
||||||
@@ -831,6 +828,14 @@ export class WarehouseInventoryService {
|
|||||||
LEFT JOIN freight.container_types container_type ON container_type.id = booking_container.container_type_id
|
LEFT JOIN freight.container_types container_type ON container_type.id = booking_container.container_type_id
|
||||||
WHERE booking_container.booking_id = b.id AND booking_container.deleted_at IS NULL
|
WHERE booking_container.booking_id = b.id AND booking_container.deleted_at IS NULL
|
||||||
) bc ON true
|
) bc ON true
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT string_agg(NULLIF(unit.container_number, ''), ', ' ORDER BY unit.container_number) AS unit_numbers,
|
||||||
|
string_agg(DISTINCT NULLIF(unit.seal_number, ''), ', ') AS seal_numbers
|
||||||
|
FROM freight.booking_container_units unit
|
||||||
|
JOIN freight.booking_container line
|
||||||
|
ON line.id = unit.booking_container_id AND line.deleted_at IS NULL
|
||||||
|
WHERE line.booking_id = b.id AND unit.deleted_at IS NULL
|
||||||
|
) bcu ON true
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT first_mile.id, first_mile.status, first_mile.vehicle_id
|
SELECT first_mile.id, first_mile.status, first_mile.vehicle_id
|
||||||
FROM freight.first_mile first_mile
|
FROM freight.first_mile first_mile
|
||||||
@@ -881,11 +886,18 @@ export class WarehouseInventoryService {
|
|||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
await this.dataSource.transaction(async (manager) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
await this.validateLocation(manager, {
|
const { warehouse, yard, zone } = await this.validateLocation(manager, {
|
||||||
warehouseId: dto.warehouseId,
|
warehouseId: dto.warehouseId,
|
||||||
yardId: dto.yardId,
|
yardId: dto.yardId,
|
||||||
zoneId: dto.zoneId,
|
zoneId: dto.zoneId,
|
||||||
});
|
});
|
||||||
|
// The receive location is whatever the operator selected above — never a
|
||||||
|
// hand-typed string. Stamp it on the truck entrance for the GRN/notes.
|
||||||
|
if (dto.truckEntrance && !dto.truckEntrance.warehouseCodeLocation) {
|
||||||
|
dto.truckEntrance.warehouseCodeLocation = [warehouse.code, yard.code, zone.code]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' / ');
|
||||||
|
}
|
||||||
|
|
||||||
for (const bookingId of dto.bookingIds) {
|
for (const bookingId of dto.bookingIds) {
|
||||||
const skip = (reason: string) => {
|
const skip = (reason: string) => {
|
||||||
@@ -1081,46 +1093,6 @@ export class WarehouseInventoryService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Bulk-load all EXPORT inventory that passed inspection and is READY_FOR_LOADING. */
|
|
||||||
async loadPassedExport(performedBy?: string): Promise<LoadPassedExportResult> {
|
|
||||||
const ready = await this.inventoryRepository.findAll({ where: { status: 'READY_FOR_LOADING' } });
|
|
||||||
const result: LoadPassedExportResult = { loadedCount: 0, skippedCount: 0, results: [] };
|
|
||||||
|
|
||||||
for (const item of ready) {
|
|
||||||
const skip = (reason: string) => {
|
|
||||||
result.skippedCount += 1;
|
|
||||||
result.results.push({ inventoryId: item.id, status: 'SKIPPED', reason });
|
|
||||||
};
|
|
||||||
|
|
||||||
if (item.inspectionStatus !== 'PASSED') { skip('Inspection not PASSED'); continue; }
|
|
||||||
const direction = item.bookingId ? await this.getBookingDirection(item.bookingId) : null;
|
|
||||||
if (direction !== 'EXPORT') { skip('Not an EXPORT item'); continue; }
|
|
||||||
const bookingStatus = item.bookingId ? await this.getBookingStatus(item.bookingId) : null;
|
|
||||||
if (bookingStatus !== 'PAID') { skip('Booking not PAID'); continue; }
|
|
||||||
|
|
||||||
await this.dataSource.transaction(async (manager) => {
|
|
||||||
await manager.getRepository(WarehouseInventory).update(item.id, {
|
|
||||||
status: 'LOADED',
|
|
||||||
loadedAt: new Date(),
|
|
||||||
});
|
|
||||||
await this.activityLog.record(
|
|
||||||
{
|
|
||||||
activityType: 'INVENTORY_LOADED',
|
|
||||||
inventoryId: item.id,
|
|
||||||
warehouseId: item.warehouseId,
|
|
||||||
description: 'Bulk loaded (passed export)',
|
|
||||||
performedBy,
|
|
||||||
},
|
|
||||||
manager,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
result.loadedCount += 1;
|
|
||||||
result.results.push({ inventoryId: item.id, status: 'LOADED' });
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** EXPORT inventory rows at a given status (route-derived direction), with booking detail. */
|
/** EXPORT inventory rows at a given status (route-derived direction), with booking detail. */
|
||||||
private async exportInventoryByStatus(
|
private async exportInventoryByStatus(
|
||||||
@@ -1302,6 +1274,29 @@ export class WarehouseInventoryService {
|
|||||||
performedBy?: string,
|
performedBy?: string,
|
||||||
): Promise<TrainLoadResult> {
|
): Promise<TrainLoadResult> {
|
||||||
const result: TrainLoadResult = { loadedCount: 0, skippedCount: 0, results: [] };
|
const result: TrainLoadResult = { loadedCount: 0, skippedCount: 0, results: [] };
|
||||||
|
const [schedule]: Array<{
|
||||||
|
trainNumber: string | null;
|
||||||
|
origin: string | null;
|
||||||
|
destination: string | null;
|
||||||
|
departure: string | null;
|
||||||
|
}> = await this.dataSource.query(
|
||||||
|
`SELECT ts.train_number AS "trainNumber",
|
||||||
|
COALESCE(oy.label, oy.code) AS "origin",
|
||||||
|
COALESCE(dy.label, dy.code) AS "destination",
|
||||||
|
ts.scheduled_departure_date AS "departure"
|
||||||
|
FROM freight.train_schedules ts
|
||||||
|
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
|
||||||
|
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
|
||||||
|
WHERE ts.id = $1 AND ts.deleted_at IS NULL`,
|
||||||
|
[scheduleId],
|
||||||
|
);
|
||||||
|
const trainNote = schedule
|
||||||
|
? `Loaded onto train ${schedule.trainNumber ?? scheduleId.slice(0, 8)}` +
|
||||||
|
(schedule.origin || schedule.destination
|
||||||
|
? ` (${schedule.origin ?? '?'} -> ${schedule.destination ?? '?'})`
|
||||||
|
: '') +
|
||||||
|
(schedule.departure ? `, departure ${new Date(schedule.departure).toISOString()}` : '')
|
||||||
|
: undefined;
|
||||||
const items = await this.trainLoadableItems(scheduleId);
|
const items = await this.trainLoadableItems(scheduleId);
|
||||||
const byId = new Map(items.map((i) => [i.id, i]));
|
const byId = new Map(items.map((i) => [i.id, i]));
|
||||||
const affectedBookingIds = new Set<string>();
|
const affectedBookingIds = new Set<string>();
|
||||||
@@ -1318,7 +1313,12 @@ export class WarehouseInventoryService {
|
|||||||
if (!item.wagonId) { skip('No wagon allocated — allocate a wagon first'); continue; }
|
if (!item.wagonId) { skip('No wagon allocated — allocate a wagon first'); continue; }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.load(inventoryId, { wagonId: item.wagonId, loadedBy: performedBy });
|
await this.load(inventoryId, {
|
||||||
|
wagonId: item.wagonId,
|
||||||
|
loadedBy: performedBy,
|
||||||
|
trainScheduleId: scheduleId,
|
||||||
|
notes: trainNote,
|
||||||
|
});
|
||||||
result.loadedCount += 1;
|
result.loadedCount += 1;
|
||||||
result.results.push({ inventoryId, status: 'LOADED' });
|
result.results.push({ inventoryId, status: 'LOADED' });
|
||||||
if (item.bookingId) affectedBookingIds.add(item.bookingId);
|
if (item.bookingId) affectedBookingIds.add(item.bookingId);
|
||||||
@@ -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],
|
||||||
);
|
);
|
||||||
@@ -3386,6 +3390,8 @@ export class WarehouseInventoryService {
|
|||||||
warehouseInventoryId: id,
|
warehouseInventoryId: id,
|
||||||
bookingId: item.bookingId ?? null,
|
bookingId: item.bookingId ?? null,
|
||||||
wagonId: dto.wagonId,
|
wagonId: dto.wagonId,
|
||||||
|
// Which train this load belongs to — durable even if wagons reshuffle.
|
||||||
|
trainScheduleId: dto.trainScheduleId ?? null,
|
||||||
loadedAt: now,
|
loadedAt: now,
|
||||||
loadedBy: dto.loadedBy ?? null,
|
loadedBy: dto.loadedBy ?? null,
|
||||||
loadedWeight,
|
loadedWeight,
|
||||||
@@ -3424,7 +3430,7 @@ export class WarehouseInventoryService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Enrich with wagon numbers (read-only lookup into the scheduling domain).
|
// Enrich with wagon numbers (read-only lookup into the scheduling domain).
|
||||||
const wagonIds = [...new Set(loadings.map((l) => l.wagonId))];
|
const wagonIds = [...new Set(loadings.map((l) => l.wagonId).filter((id): id is string => Boolean(id)))];
|
||||||
const wagonNumbers = new Map<string, string>();
|
const wagonNumbers = new Map<string, string>();
|
||||||
if (wagonIds.length > 0) {
|
if (wagonIds.length > 0) {
|
||||||
const rows: Array<{ id: string; wagon_number: string }> = await this.dataSource.query(
|
const rows: Array<{ id: string; wagon_number: string }> = await this.dataSource.query(
|
||||||
@@ -3435,7 +3441,7 @@ export class WarehouseInventoryService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return loadings.map((loading) =>
|
return loadings.map((loading) =>
|
||||||
Object.assign(loading, { wagonNumber: wagonNumbers.get(loading.wagonId) ?? null }),
|
Object.assign(loading, { wagonNumber: (loading.wagonId && wagonNumbers.get(loading.wagonId)) ?? null }),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,28 @@ interface OnboardingField {
|
|||||||
|
|
||||||
const DOC_EXTENSIONS = ["pdf", "jpg", "jpeg", "png"];
|
const DOC_EXTENSIONS = ["pdf", "jpg", "jpeg", "png"];
|
||||||
|
|
||||||
|
/** fileKey of the delegation letter attached to the Power of Attorney step. */
|
||||||
|
export const POA_DELEGATION_FILE_KEY = "poa_delegation_letter";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seeded as optional: the delegation letter is only mandatory once a PoA has
|
||||||
|
* been entered, or when the company operates as a freight forwarder. That rule
|
||||||
|
* spans form fields as well as files, so it lives in the onboarding gate
|
||||||
|
* (companies.service.getOnboardingRequirements) rather than in `isRequired`.
|
||||||
|
*/
|
||||||
|
const poaDelegationField = (displayOrder: number): OnboardingField => ({
|
||||||
|
fileKey: POA_DELEGATION_FILE_KEY,
|
||||||
|
fileLabel: "PoA Delegation Letter",
|
||||||
|
helpText:
|
||||||
|
"Signed letter in which the General Manager delegates the representative named above.",
|
||||||
|
isRequired: false,
|
||||||
|
isMultiple: false,
|
||||||
|
maxFiles: 1,
|
||||||
|
allowedExtensions: DOC_EXTENSIONS,
|
||||||
|
maxSizeMb: 10,
|
||||||
|
displayOrder,
|
||||||
|
});
|
||||||
|
|
||||||
/** Documents required from an Ethiopian company at onboarding. */
|
/** Documents required from an Ethiopian company at onboarding. */
|
||||||
const ETHIOPIAN_ONBOARDING_FIELDS: OnboardingField[] = [
|
const ETHIOPIAN_ONBOARDING_FIELDS: OnboardingField[] = [
|
||||||
{
|
{
|
||||||
@@ -54,6 +76,7 @@ const ETHIOPIAN_ONBOARDING_FIELDS: OnboardingField[] = [
|
|||||||
maxSizeMb: 10,
|
maxSizeMb: 10,
|
||||||
displayOrder: 3,
|
displayOrder: 3,
|
||||||
},
|
},
|
||||||
|
poaDelegationField(4),
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Documents required from a Foreign company at onboarding. */
|
/** Documents required from a Foreign company at onboarding. */
|
||||||
@@ -102,6 +125,7 @@ const FOREIGN_ONBOARDING_FIELDS: OnboardingField[] = [
|
|||||||
maxSizeMb: 10,
|
maxSizeMb: 10,
|
||||||
displayOrder: 4,
|
displayOrder: 4,
|
||||||
},
|
},
|
||||||
|
poaDelegationField(5),
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Legacy combined set, kept for the older per-company-type codes. */
|
/** Legacy combined set, kept for the older per-company-type codes. */
|
||||||
|
|||||||
@@ -197,12 +197,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
icon: <Send />,
|
icon: <Send />,
|
||||||
permission: FREIGHT_PERMS.contracts.createBooking,
|
permission: FREIGHT_PERMS.contracts.createBooking,
|
||||||
},
|
},
|
||||||
{
|
// {
|
||||||
label: "Self-Clearance Review",
|
// label: "Self-Clearance Review",
|
||||||
href: "/dashboard/contracts/ops-clearance",
|
// href: "/dashboard/contracts/ops-clearance",
|
||||||
icon: <ShieldCheck />,
|
// icon: <ShieldCheck />,
|
||||||
permission: FREIGHT_PERMS.contracts.opsClearanceReview,
|
// permission: FREIGHT_PERMS.contracts.opsClearanceReview,
|
||||||
},
|
// },
|
||||||
{
|
{
|
||||||
label: "GL Djibouti Clearance",
|
label: "GL Djibouti Clearance",
|
||||||
href: "/dashboard/gl-djibouti/clearance",
|
href: "/dashboard/gl-djibouti/clearance",
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
MapPin,
|
MapPin,
|
||||||
Package,
|
Package,
|
||||||
Receipt,
|
Receipt,
|
||||||
|
Repeat,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
@@ -192,9 +193,19 @@ export default function GlCreateBookingForm() {
|
|||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
const [containerLines, setContainerLines] = useState<ContainerLineDraft[]>([]);
|
const [containerLines, setContainerLines] = useState<ContainerLineDraft[]>([]);
|
||||||
const [bulkLines, setBulkLines] = useState<BulkLineDraft[]>([]);
|
const [bulkLines, setBulkLines] = useState<BulkLineDraft[]>([]);
|
||||||
|
const [withReturn, setWithReturn] = useState(false);
|
||||||
const [prefilled, setPrefilled] = useState(false);
|
const [prefilled, setPrefilled] = useState(false);
|
||||||
const [priceOpen, setPriceOpen] = useState(false);
|
const [priceOpen, setPriceOpen] = useState(false);
|
||||||
const seededRef = useRef(false);
|
const seededRef = useRef(false);
|
||||||
|
const returnSeededRef = useRef(false);
|
||||||
|
|
||||||
|
// Seed the equipment-return toggle from the contract exactly once (also when
|
||||||
|
// the form is prefilled from a shipment request); GL can flip it per shipment.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!contract || returnSeededRef.current) return;
|
||||||
|
returnSeededRef.current = true;
|
||||||
|
setWithReturn(contract.equipmentReturn === "WITH_RETURN");
|
||||||
|
}, [contract]);
|
||||||
|
|
||||||
const isContainer = contract?.freightType === "CONTAINER";
|
const isContainer = contract?.freightType === "CONTAINER";
|
||||||
const routes = useMemo(
|
const routes = useMemo(
|
||||||
@@ -527,6 +538,10 @@ export default function GlCreateBookingForm() {
|
|||||||
scheduledDate,
|
scheduledDate,
|
||||||
...(contractRouteId ? { contractRouteId } : {}),
|
...(contractRouteId ? { contractRouteId } : {}),
|
||||||
...(notes.trim() ? { notes: notes.trim() } : {}),
|
...(notes.trim() ? { notes: notes.trim() } : {}),
|
||||||
|
// Equipment return is a container concern — bulk keeps the contract default.
|
||||||
|
...(isContainer
|
||||||
|
? { equipmentReturn: withReturn ? "WITH_RETURN" : "WITHOUT_RETURN" }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isContainer) {
|
if (isContainer) {
|
||||||
@@ -1091,6 +1106,67 @@ export default function GlCreateBookingForm() {
|
|||||||
</StepCard>
|
</StepCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isContainer ? (
|
||||||
|
<StepCard>
|
||||||
|
<StepHeader
|
||||||
|
icon={<Repeat size={22} />}
|
||||||
|
title="Equipment Return"
|
||||||
|
description="Choose whether the empty container(s) come back to EDR after unloading."
|
||||||
|
/>
|
||||||
|
<Paper
|
||||||
|
withBorder
|
||||||
|
radius="md"
|
||||||
|
p="md"
|
||||||
|
style={{
|
||||||
|
borderColor: withReturn ? "#CDEBDD" : "#E6ECF2",
|
||||||
|
background: withReturn ? "#F6FBF8" : "white",
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "border-color 150ms ease, background 150ms ease",
|
||||||
|
}}
|
||||||
|
onClick={() => setWithReturn((v) => !v)}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" wrap="nowrap" gap="md">
|
||||||
|
<Group gap={13} wrap="nowrap" align="flex-start">
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
width: 38,
|
||||||
|
height: 38,
|
||||||
|
flexShrink: 0,
|
||||||
|
borderRadius: 11,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
background: withReturn ? "#ECF6F1" : "#F1F4F7",
|
||||||
|
color: withReturn ? "#0A6F4D" : "#6B7C8E",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Repeat size={18} />
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text fz={14} fw={700}>
|
||||||
|
With return
|
||||||
|
</Text>
|
||||||
|
<Text fz={12} c="dimmed" style={{ lineHeight: 1.4 }}>
|
||||||
|
{withReturn
|
||||||
|
? "Container(s) returned to EDR after unloading."
|
||||||
|
: "Container(s) retained by the customer after delivery."}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
<Switch
|
||||||
|
size="md"
|
||||||
|
color="edr-green"
|
||||||
|
aria-label="With return"
|
||||||
|
checked={withReturn}
|
||||||
|
onChange={(e) => setWithReturn(e.currentTarget.checked)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
</StepCard>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<StepCard>
|
<StepCard>
|
||||||
<StepHeader
|
<StepHeader
|
||||||
icon={<CalendarDays size={22} />}
|
icon={<CalendarDays size={22} />}
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ export interface ActionShellProps {
|
|||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
/** When true the action is already done — children are hidden, a done badge shows. */
|
/** When true the action is already done — children are hidden, a done badge shows. */
|
||||||
done?: boolean;
|
done?: boolean;
|
||||||
|
/**
|
||||||
|
* Keep the input controls mounted alongside the done badge. For actions whose
|
||||||
|
* value stays correctable after completion (e.g. customs risk), rather than
|
||||||
|
* the default one-and-done actions.
|
||||||
|
*/
|
||||||
|
keepChildrenWhenDone?: boolean;
|
||||||
doneLabel?: ReactNode;
|
doneLabel?: ReactNode;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
@@ -22,6 +28,7 @@ export function ActionShell({
|
|||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
done,
|
done,
|
||||||
|
keepChildrenWhenDone,
|
||||||
doneLabel,
|
doneLabel,
|
||||||
children,
|
children,
|
||||||
}: ActionShellProps) {
|
}: ActionShellProps) {
|
||||||
@@ -64,7 +71,7 @@ export function ActionShell({
|
|||||||
)
|
)
|
||||||
) : null}
|
) : null}
|
||||||
</Group>
|
</Group>
|
||||||
{!done ? children : null}
|
{!done || keepChildrenWhenDone ? children : null}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Badge, Box, Button, Group, SegmentedControl, Text } from "@mantine/core";
|
import { Badge, Box, Button, Group, SegmentedControl, Text } from "@mantine/core";
|
||||||
import { ShieldAlert } from "lucide-react";
|
import { ShieldAlert } from "lucide-react";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
@@ -15,22 +15,42 @@ const RISK_COLOR: Record<Freight.CustomsRiskLevel, string> = {
|
|||||||
export function AssignRiskCard({
|
export function AssignRiskCard({
|
||||||
bookingId,
|
bookingId,
|
||||||
milestone,
|
milestone,
|
||||||
|
locked = false,
|
||||||
}: {
|
}: {
|
||||||
bookingId: string;
|
bookingId: string;
|
||||||
milestone: Freight.IClearanceMilestone;
|
milestone: Freight.IClearanceMilestone;
|
||||||
|
/**
|
||||||
|
* Duty has already been advised off this risk level, so the decision is now
|
||||||
|
* final. Until then a mis-assigned level must stay correctable — the server
|
||||||
|
* accepts reassignment and overwrites the milestone metadata.
|
||||||
|
*/
|
||||||
|
locked?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const assign = useAssignRisk(bookingId);
|
const assign = useAssignRisk(bookingId);
|
||||||
const [level, setLevel] = useState<Freight.CustomsRiskLevel>("GREEN");
|
|
||||||
|
|
||||||
const assigned = milestone.status === "COMPLETED";
|
const assigned = milestone.status === "COMPLETED";
|
||||||
const current = milestone.metadata?.riskLevel;
|
const current = milestone.metadata?.riskLevel;
|
||||||
|
const [level, setLevel] = useState<Freight.CustomsRiskLevel>(
|
||||||
|
current ?? "GREEN",
|
||||||
|
);
|
||||||
|
|
||||||
|
// The milestone loads (and refetches after a reassignment) after first render,
|
||||||
|
// so mirror the persisted level onto the control whenever it changes.
|
||||||
|
useEffect(() => {
|
||||||
|
if (current) setLevel(current);
|
||||||
|
}, [current]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ActionShell
|
<ActionShell
|
||||||
icon={ShieldAlert}
|
icon={ShieldAlert}
|
||||||
title="Customs risk"
|
title="Customs risk"
|
||||||
subtitle="Assign the customs examination risk level."
|
subtitle={
|
||||||
|
assigned && !locked
|
||||||
|
? "Reassign the customs examination risk level."
|
||||||
|
: "Assign the customs examination risk level."
|
||||||
|
}
|
||||||
done={assigned}
|
done={assigned}
|
||||||
|
keepChildrenWhenDone={!locked}
|
||||||
doneLabel={
|
doneLabel={
|
||||||
current ? (
|
current ? (
|
||||||
<Badge color={RISK_COLOR[current]} variant="filled" radius="sm">
|
<Badge color={RISK_COLOR[current]} variant="filled" radius="sm">
|
||||||
@@ -60,9 +80,10 @@ export function AssignRiskCard({
|
|||||||
size="compact-sm"
|
size="compact-sm"
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
loading={assign.isPending}
|
loading={assign.isPending}
|
||||||
|
disabled={assigned && level === current}
|
||||||
onClick={() => assign.mutate({ riskLevel: level })}
|
onClick={() => assign.mutate({ riskLevel: level })}
|
||||||
>
|
>
|
||||||
Assign risk
|
{assigned ? "Reassign risk" : "Assign risk"}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -70,7 +70,11 @@ export function GlActionsPanel({ bookingId, milestones }: GlActionsPanelProps) {
|
|||||||
{showTransport ? <TransportDocumentCard bookingId={bookingId} /> : null}
|
{showTransport ? <TransportDocumentCard bookingId={bookingId} /> : null}
|
||||||
|
|
||||||
{riskMs ? (
|
{riskMs ? (
|
||||||
<AssignRiskCard bookingId={bookingId} milestone={riskMs} />
|
<AssignRiskCard
|
||||||
|
bookingId={bookingId}
|
||||||
|
milestone={riskMs}
|
||||||
|
locked={dutyMs?.status === "COMPLETED"}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<IncidentReportCard bookingId={bookingId} />
|
<IncidentReportCard bookingId={bookingId} />
|
||||||
|
|||||||
@@ -153,6 +153,7 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
|||||||
: ([] as string[]);
|
: ([] as string[]);
|
||||||
const docCount = pending?.documentFileIds?.length ?? 0;
|
const docCount = pending?.documentFileIds?.length ?? 0;
|
||||||
const licenseChanges = pending?.licenseChanges ?? [];
|
const licenseChanges = pending?.licenseChanges ?? [];
|
||||||
|
const documentChanges = pending?.documentChanges ?? [];
|
||||||
|
|
||||||
const confirmReject = () => {
|
const confirmReject = () => {
|
||||||
if (!rejectId) return;
|
if (!rejectId) return;
|
||||||
@@ -210,11 +211,75 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{documentChanges.length > 0 && (
|
||||||
|
<Stack gap={8}>
|
||||||
|
<Text size="sm" fw={600} c="edr-text">
|
||||||
|
Document changes
|
||||||
|
</Text>
|
||||||
|
{documentChanges.map((c, i) => (
|
||||||
|
<Group key={`${c.fileId}-${i}`} gap={8} wrap="nowrap">
|
||||||
|
{c.op === "add" ? (
|
||||||
|
<FilePlus2 size={15} className="text-edr-muted" />
|
||||||
|
) : (
|
||||||
|
<FileX2 size={15} className="text-edr-muted" />
|
||||||
|
)}
|
||||||
|
<Badge
|
||||||
|
size="sm"
|
||||||
|
radius="sm"
|
||||||
|
variant="light"
|
||||||
|
color={c.op === "add" ? "green" : "red"}
|
||||||
|
>
|
||||||
|
{c.op === "add" ? "Add" : "Remove"}
|
||||||
|
</Badge>
|
||||||
|
<Anchor
|
||||||
|
component="button"
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
view({
|
||||||
|
name: c.fileName ?? humanize(c.code),
|
||||||
|
url: fileViewUrl(c.fileId),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
textDecoration:
|
||||||
|
c.op === "remove" ? "line-through" : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{c.fileName ?? humanize(c.code)}
|
||||||
|
</Anchor>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{humanize(c.code)}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
{docCount > 0 && (
|
{docCount > 0 && (
|
||||||
<Text size="sm" c="dimmed">
|
<Stack gap={8}>
|
||||||
{docCount} document{docCount === 1 ? "" : "s"} uploaded with this
|
<Text size="sm" fw={600} c="edr-text">
|
||||||
request — review them in the Documents tab.
|
Documents uploaded with this request
|
||||||
</Text>
|
</Text>
|
||||||
|
{pending!.documentFileIds.map((fileId, i) => (
|
||||||
|
<Group key={fileId} gap={8} wrap="nowrap">
|
||||||
|
<FilePlus2 size={15} className="text-edr-muted" />
|
||||||
|
<Anchor
|
||||||
|
component="button"
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
view({
|
||||||
|
name: `Document ${i + 1}`,
|
||||||
|
url: fileViewUrl(fileId),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Document {i + 1}
|
||||||
|
</Anchor>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{licenseChanges.length > 0 && (
|
{licenseChanges.length > 0 && (
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -150,9 +152,6 @@ interface TruckEntranceFormState {
|
|||||||
assignedEquipmentNumber: string;
|
assignedEquipmentNumber: string;
|
||||||
customsSealNumber: string;
|
customsSealNumber: string;
|
||||||
declarationNumber: string;
|
declarationNumber: string;
|
||||||
incoterms: string;
|
|
||||||
hsCodes: string;
|
|
||||||
itemCode: string;
|
|
||||||
itemDescription: string;
|
itemDescription: string;
|
||||||
packagingType: string;
|
packagingType: string;
|
||||||
unitCount: number | '';
|
unitCount: number | '';
|
||||||
@@ -162,7 +161,6 @@ interface TruckEntranceFormState {
|
|||||||
volumeDimensions: string;
|
volumeDimensions: string;
|
||||||
conditionAtReceipt: string;
|
conditionAtReceipt: string;
|
||||||
damagedRejectedQuantity: number | '';
|
damagedRejectedQuantity: number | '';
|
||||||
warehouseCodeLocation: string;
|
|
||||||
driverName: string;
|
driverName: string;
|
||||||
driverPhone: string;
|
driverPhone: string;
|
||||||
driverLicenseNumber: string;
|
driverLicenseNumber: string;
|
||||||
@@ -205,9 +203,6 @@ const emptyTruckEntrance = (): TruckEntranceFormState => ({
|
|||||||
assignedEquipmentNumber: '',
|
assignedEquipmentNumber: '',
|
||||||
customsSealNumber: '',
|
customsSealNumber: '',
|
||||||
declarationNumber: '',
|
declarationNumber: '',
|
||||||
incoterms: '',
|
|
||||||
hsCodes: '',
|
|
||||||
itemCode: '',
|
|
||||||
itemDescription: '',
|
itemDescription: '',
|
||||||
packagingType: '',
|
packagingType: '',
|
||||||
unitCount: '',
|
unitCount: '',
|
||||||
@@ -217,7 +212,6 @@ const emptyTruckEntrance = (): TruckEntranceFormState => ({
|
|||||||
volumeDimensions: '',
|
volumeDimensions: '',
|
||||||
conditionAtReceipt: '',
|
conditionAtReceipt: '',
|
||||||
damagedRejectedQuantity: '',
|
damagedRejectedQuantity: '',
|
||||||
warehouseCodeLocation: '',
|
|
||||||
driverName: '',
|
driverName: '',
|
||||||
driverPhone: '',
|
driverPhone: '',
|
||||||
driverLicenseNumber: '',
|
driverLicenseNumber: '',
|
||||||
@@ -239,9 +233,6 @@ const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayl
|
|||||||
assignedEquipmentNumber: form.assignedEquipmentNumber.trim() || undefined,
|
assignedEquipmentNumber: form.assignedEquipmentNumber.trim() || undefined,
|
||||||
customsSealNumber: form.customsSealNumber.trim() || undefined,
|
customsSealNumber: form.customsSealNumber.trim() || undefined,
|
||||||
declarationNumber: form.declarationNumber.trim() || undefined,
|
declarationNumber: form.declarationNumber.trim() || undefined,
|
||||||
incoterms: form.incoterms.trim() || undefined,
|
|
||||||
hsCodes: form.hsCodes.trim() || undefined,
|
|
||||||
itemCode: form.itemCode.trim() || undefined,
|
|
||||||
itemDescription: form.itemDescription.trim() || undefined,
|
itemDescription: form.itemDescription.trim() || undefined,
|
||||||
packagingType: form.packagingType.trim() || undefined,
|
packagingType: form.packagingType.trim() || undefined,
|
||||||
unitCount: form.unitCount === '' ? undefined : Number(form.unitCount),
|
unitCount: form.unitCount === '' ? undefined : Number(form.unitCount),
|
||||||
@@ -251,7 +242,6 @@ const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayl
|
|||||||
volumeDimensions: form.volumeDimensions.trim() || undefined,
|
volumeDimensions: form.volumeDimensions.trim() || undefined,
|
||||||
conditionAtReceipt: form.conditionAtReceipt.trim() || undefined,
|
conditionAtReceipt: form.conditionAtReceipt.trim() || undefined,
|
||||||
damagedRejectedQuantity: form.damagedRejectedQuantity === '' ? undefined : Number(form.damagedRejectedQuantity),
|
damagedRejectedQuantity: form.damagedRejectedQuantity === '' ? undefined : Number(form.damagedRejectedQuantity),
|
||||||
warehouseCodeLocation: form.warehouseCodeLocation.trim() || undefined,
|
|
||||||
driverName: form.driverName.trim(),
|
driverName: form.driverName.trim(),
|
||||||
driverPhone: form.driverPhone.trim(),
|
driverPhone: form.driverPhone.trim(),
|
||||||
driverLicenseNumber: form.driverLicenseNumber.trim() || undefined,
|
driverLicenseNumber: form.driverLicenseNumber.trim() || undefined,
|
||||||
@@ -265,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] : '';
|
||||||
@@ -296,6 +407,10 @@ const truckEntranceFromBookings = (bookings: EligibleBooking[]): {
|
|||||||
const truckType = commonNonEmptyValue(
|
const truckType = commonNonEmptyValue(
|
||||||
bookings.map((booking) => booking.firstMileTruckType || booking.customerTruckType),
|
bookings.map((booking) => booking.firstMileTruckType || booking.customerTruckType),
|
||||||
);
|
);
|
||||||
|
const customsSealNumber = commonNonEmptyValue(bookings.map((booking) => booking.sealNumbers));
|
||||||
|
// Booking's declared cargo weight (tonnes) — the receive-time net until re-weighed.
|
||||||
|
const bookingWeight = bookings.length === 1 ? Number(bookings[0]?.weight ?? '') : NaN;
|
||||||
|
const netWeightKg: number | '' = Number.isFinite(bookingWeight) && bookingWeight > 0 ? bookingWeight : '';
|
||||||
const edrDigitalBookingId =
|
const edrDigitalBookingId =
|
||||||
bookings.length === 1
|
bookings.length === 1
|
||||||
? bookings[0]?.reference ?? bookings[0]?.id ?? ''
|
? bookings[0]?.reference ?? bookings[0]?.id ?? ''
|
||||||
@@ -321,9 +436,11 @@ const truckEntranceFromBookings = (bookings: EligibleBooking[]): {
|
|||||||
customerPhone,
|
customerPhone,
|
||||||
edrDigitalBookingId,
|
edrDigitalBookingId,
|
||||||
assignedEquipmentNumber,
|
assignedEquipmentNumber,
|
||||||
|
customsSealNumber,
|
||||||
itemDescription,
|
itemDescription,
|
||||||
packagingType,
|
packagingType,
|
||||||
unitCount,
|
unitCount,
|
||||||
|
netWeightKg,
|
||||||
grossWeightKg: '',
|
grossWeightKg: '',
|
||||||
truckPlateNumber,
|
truckPlateNumber,
|
||||||
trailerPlateNumber,
|
trailerPlateNumber,
|
||||||
@@ -331,6 +448,7 @@ const truckEntranceFromBookings = (bookings: EligibleBooking[]): {
|
|||||||
driverPhone,
|
driverPhone,
|
||||||
driverLicenseNumber,
|
driverLicenseNumber,
|
||||||
truckType,
|
truckType,
|
||||||
|
driverSignatoryName: driverName,
|
||||||
},
|
},
|
||||||
lockedFields: {
|
lockedFields: {
|
||||||
ownerName: Boolean(ownerName),
|
ownerName: Boolean(ownerName),
|
||||||
@@ -550,38 +668,19 @@ function TruckEntranceFields({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Text size="sm" fw={600} mt="xs">Customs and compliance</Text>
|
<Text size="sm" fw={600} mt="xs">Customs and compliance</Text>
|
||||||
<Group grow>
|
|
||||||
<TextInput
|
|
||||||
label="Declaration / Bill of Entry number"
|
|
||||||
value={value.declarationNumber}
|
|
||||||
onChange={(e) => onChange({ ...value, declarationNumber: e.currentTarget.value })}
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
label="Incoterms"
|
|
||||||
value={value.incoterms}
|
|
||||||
onChange={(e) => onChange({ ...value, incoterms: e.currentTarget.value })}
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
<TextInput
|
<TextInput
|
||||||
label="HS codes"
|
label="Declaration / Bill of Entry number"
|
||||||
value={value.hsCodes}
|
value={value.declarationNumber}
|
||||||
onChange={(e) => onChange({ ...value, hsCodes: e.currentTarget.value })}
|
onChange={(e) => onChange({ ...value, declarationNumber: e.currentTarget.value })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Text size="sm" fw={600} mt="xs">Physical cargo specifications</Text>
|
<Text size="sm" fw={600} mt="xs">Physical cargo specifications</Text>
|
||||||
<Group grow>
|
<TextInput
|
||||||
<TextInput
|
label="Item description"
|
||||||
label="Item code"
|
value={value.itemDescription}
|
||||||
value={value.itemCode}
|
readOnly={lockedFields?.itemDescription}
|
||||||
onChange={(e) => onChange({ ...value, itemCode: e.currentTarget.value })}
|
onChange={(e) => onChange({ ...value, itemDescription: e.currentTarget.value })}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
|
||||||
label="Item description"
|
|
||||||
value={value.itemDescription}
|
|
||||||
readOnly={lockedFields?.itemDescription}
|
|
||||||
onChange={(e) => onChange({ ...value, itemDescription: e.currentTarget.value })}
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
<Group grow>
|
<Group grow>
|
||||||
<Select
|
<Select
|
||||||
label="Packaging type"
|
label="Packaging type"
|
||||||
@@ -626,11 +725,6 @@ function TruckEntranceFields({
|
|||||||
onChange={(v) => onChange({ ...value, damagedRejectedQuantity: v === '' ? '' : Number(v) })}
|
onChange={(v) => onChange({ ...value, damagedRejectedQuantity: v === '' ? '' : Number(v) })}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
<TextInput
|
|
||||||
label="Warehouse code and location"
|
|
||||||
value={value.warehouseCodeLocation}
|
|
||||||
onChange={(e) => onChange({ ...value, warehouseCodeLocation: e.currentTarget.value })}
|
|
||||||
/>
|
|
||||||
<Group grow>
|
<Group grow>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Driver signatory"
|
label="Driver signatory"
|
||||||
@@ -889,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) {
|
||||||
@@ -1059,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>
|
||||||
@@ -1072,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>
|
||||||
@@ -1106,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>
|
||||||
@@ -1285,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');
|
||||||
@@ -1308,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?.();
|
||||||
@@ -1329,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>
|
||||||
@@ -1344,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"
|
||||||
@@ -1358,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>
|
||||||
@@ -1374,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}`}
|
||||||
@@ -1384,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>
|
||||||
@@ -1411,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)}>
|
||||||
@@ -1421,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>
|
||||||
@@ -1433,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1443,27 +1546,48 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
|
|||||||
const { data: rows = [], isLoading } = useQuery(
|
const { data: rows = [], isLoading } = useQuery(
|
||||||
api.warehouses.readyToLoadExport.queryOptions({ enabled }),
|
api.warehouses.readyToLoadExport.queryOptions({ enabled }),
|
||||||
);
|
);
|
||||||
const loadPassed = useMutation(api.warehouses.loadPassedExport.mutationOptions());
|
const qc = useQueryClient();
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
const [trainPickerOpen, setTrainPickerOpen] = useState(false);
|
||||||
|
const [expandedRow, setExpandedRow] = 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.
|
||||||
|
const { data: trains = [], isLoading: trainsLoading } = useQuery({
|
||||||
|
queryKey: ['warehouse-inventory', 'loadable-trains'],
|
||||||
|
queryFn: () => warehouseService.getLoadableTrains(),
|
||||||
|
enabled: enabled && trainPickerOpen,
|
||||||
|
});
|
||||||
|
const loadOntoTrain = useMutation({
|
||||||
|
mutationFn: async (scheduleId: string) => {
|
||||||
|
const items = await warehouseService.getTrainLoadableItems(scheduleId);
|
||||||
|
const loadableIds = items.filter((i) => i.loadable).map((i) => i.id);
|
||||||
|
if (!loadableIds.length) {
|
||||||
|
throw new Error('No ready items with an allocated wagon on this train');
|
||||||
|
}
|
||||||
|
return warehouseService.loadItemsOntoTrain(scheduleId, loadableIds);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
|
||||||
|
void qc.invalidateQueries({ queryKey: ['warehouse-loadings'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
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 autoLoad = async () => {
|
const confirmLoad = async () => {
|
||||||
|
if (!targetScheduleId) {
|
||||||
|
toast({ variant: 'destructive', title: 'Select a train to load onto' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const r = await loadPassed.mutateAsync(undefined);
|
const r = await loadOntoTrain.mutateAsync(targetScheduleId);
|
||||||
|
const train = trains.find((t) => t.scheduleId === targetScheduleId);
|
||||||
toast({
|
toast({
|
||||||
title: `${r.loadedCount} items loaded`,
|
title: `${r.loadedCount} item(s) loaded onto train ${train?.trainNumber ?? ''}`.trim(),
|
||||||
description: r.skippedCount ? `${r.skippedCount} skipped` : undefined,
|
description: r.skippedCount
|
||||||
|
? `${r.skippedCount} skipped — ${r.results.find((x) => x.reason)?.reason ?? 'see results'}`
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
setSelected(new Set());
|
setTrainPickerOpen(false);
|
||||||
|
setTargetScheduleId(null);
|
||||||
onChanged?.();
|
onChanged?.();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(error) });
|
toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(error) });
|
||||||
@@ -1481,14 +1605,58 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
|
|||||||
variant="filled"
|
variant="filled"
|
||||||
color="teal"
|
color="teal"
|
||||||
leftSection={<Truck size={14} />}
|
leftSection={<Truck size={14} />}
|
||||||
loading={loadPassed.isPending}
|
|
||||||
disabled={rows.length === 0}
|
disabled={rows.length === 0}
|
||||||
onClick={autoLoad}
|
onClick={() => setTrainPickerOpen(true)}
|
||||||
>
|
>
|
||||||
Auto Load Ready Items
|
Auto Load Ready Items
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={trainPickerOpen}
|
||||||
|
onClose={() => setTrainPickerOpen(false)}
|
||||||
|
title="Load ready items onto a train"
|
||||||
|
centered
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
{trainsLoading ? (
|
||||||
|
<Group justify="center" py="md"><Loader size="sm" /></Group>
|
||||||
|
) : trains.length === 0 ? (
|
||||||
|
<Alert color="yellow" variant="light" icon={<Info size={16} />}>
|
||||||
|
No train available. Auto-loading needs a scheduled (not yet dispatched) train with
|
||||||
|
these bookings assigned — schedule the train and allocate wagons first.
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<Select
|
||||||
|
label="Available trains"
|
||||||
|
placeholder="Select the train to load onto"
|
||||||
|
data={trains.map((t) => ({
|
||||||
|
value: t.scheduleId,
|
||||||
|
label: `${t.trainNumber ?? t.scheduleId.slice(0, 8)} · ${t.origin ?? '?'} → ${t.destination ?? '?'} · dep ${t.departureTime ? formatDate(t.departureTime) : '—'} · ${t.readyCount} ready`,
|
||||||
|
}))}
|
||||||
|
value={targetScheduleId}
|
||||||
|
onChange={setTargetScheduleId}
|
||||||
|
searchable
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button variant="default" onClick={() => setTrainPickerOpen(false)} disabled={loadOntoTrain.isPending}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="teal"
|
||||||
|
leftSection={<Truck size={14} />}
|
||||||
|
loading={loadOntoTrain.isPending}
|
||||||
|
disabled={!targetScheduleId}
|
||||||
|
onClick={confirmLoad}
|
||||||
|
>
|
||||||
|
Load onto this train
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Group justify="center" py="lg">
|
<Group justify="center" py="lg">
|
||||||
<Loader size="sm" />
|
<Loader size="sm" />
|
||||||
@@ -1498,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>
|
||||||
@@ -1525,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>
|
||||||
@@ -1561,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>
|
||||||
@@ -1592,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(),
|
||||||
);
|
);
|
||||||
@@ -1616,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?.();
|
||||||
@@ -1646,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>
|
||||||
@@ -1656,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>
|
||||||
@@ -1673,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>
|
||||||
@@ -1687,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>
|
||||||
@@ -1701,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
|
||||||
@@ -1712,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>
|
||||||
@@ -1734,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1845,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>
|
||||||
@@ -1881,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>
|
||||||
@@ -1979,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>>
|
||||||
@@ -2020,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)
|
||||||
@@ -2116,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>
|
||||||
@@ -2155,6 +2339,7 @@ function ImportArriveQueueTab({
|
|||||||
</Table>
|
</Table>
|
||||||
</Table.ScrollContainer>
|
</Table.ScrollContainer>
|
||||||
)}
|
)}
|
||||||
|
<ConfirmActionModal action={confirmAction} onClose={() => setConfirmAction(null)} />
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2175,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);
|
||||||
@@ -2206,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'] });
|
||||||
@@ -2310,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>
|
||||||
@@ -2326,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"
|
||||||
@@ -2338,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>
|
||||||
@@ -2357,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}`}
|
||||||
@@ -2366,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>
|
||||||
@@ -2398,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">
|
||||||
@@ -2492,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>
|
||||||
@@ -2520,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,7 +112,9 @@ const parseInspectionNote = (notes: string | null | undefined) => {
|
|||||||
export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: ReleaseOrderModalProps) {
|
export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: ReleaseOrderModalProps) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const releaseMutation = useMutation(api.warehouses.release.mutationOptions());
|
const releaseMutation = useMutation(api.warehouses.release.mutationOptions());
|
||||||
const bookingId = item?.booking?.id;
|
// Some openers (inventory workbench) supply bookingId without the booking
|
||||||
|
// relation — fall back to it, or the truck/container-weight queries never run.
|
||||||
|
const bookingId = item?.booking?.id ?? item?.bookingId ?? undefined;
|
||||||
// Customer self-haul trucks assigned to this booking via the portal.
|
// Customer self-haul trucks assigned to this booking via the portal.
|
||||||
const { data: customerTrucks = [] } = useQuery({
|
const { data: customerTrucks = [] } = useQuery({
|
||||||
queryKey: ['release-customer-trucks', bookingId],
|
queryKey: ['release-customer-trucks', bookingId],
|
||||||
@@ -200,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;
|
||||||
@@ -214,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
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ export interface ShipmentListRow {
|
|||||||
summary: string;
|
summary: string;
|
||||||
status: Freight.BookingRequestStatus;
|
status: Freight.BookingRequestStatus;
|
||||||
createdBookingId?: string | null;
|
createdBookingId?: string | null;
|
||||||
|
/** When the customer submitted the request — the queue's default sort key. */
|
||||||
|
createdAt?: string | null;
|
||||||
|
customerName?: string | null;
|
||||||
|
freightKind?: "CONTAINER" | "BULK";
|
||||||
|
hazardous?: boolean;
|
||||||
|
reefer?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ShipmentRowAction =
|
export type ShipmentRowAction =
|
||||||
|
|||||||
@@ -236,8 +236,6 @@ export function useEligibleBookings(enabled = true) {
|
|||||||
}
|
}
|
||||||
export const useBulkReceive = () =>
|
export const useBulkReceive = () =>
|
||||||
useInventoryMutation((payload: BulkReceivePayload) => warehouseService.receiveBulk(payload));
|
useInventoryMutation((payload: BulkReceivePayload) => warehouseService.receiveBulk(payload));
|
||||||
export const useLoadPassedExport = () =>
|
|
||||||
useInventoryMutation(() => warehouseService.loadPassedExport());
|
|
||||||
export const useBulkMarkInspected = () =>
|
export const useBulkMarkInspected = () =>
|
||||||
useInventoryMutation((payload: BulkInspectPayload) => warehouseService.bulkMarkInspected(payload));
|
useInventoryMutation((payload: BulkInspectPayload) => warehouseService.bulkMarkInspected(payload));
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useRef, useState } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Badge,
|
Badge,
|
||||||
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Center,
|
Center,
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
|
Menu,
|
||||||
Modal,
|
Modal,
|
||||||
Paper,
|
Paper,
|
||||||
|
ScrollArea,
|
||||||
Stack,
|
Stack,
|
||||||
Switch,
|
Switch,
|
||||||
Text,
|
Text,
|
||||||
@@ -19,13 +22,29 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
|
AlertTriangle,
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
|
Banknote,
|
||||||
|
Building2,
|
||||||
|
CalendarClock,
|
||||||
|
CalendarDays,
|
||||||
|
CalendarRange,
|
||||||
|
ChevronDown,
|
||||||
|
Coins,
|
||||||
|
Hash,
|
||||||
|
ListOrdered,
|
||||||
|
ListPlus,
|
||||||
|
Mail,
|
||||||
|
MapPin,
|
||||||
|
Package,
|
||||||
Pencil,
|
Pencil,
|
||||||
|
Phone,
|
||||||
Plus,
|
Plus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Settings2,
|
Settings2,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
Weight,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { PageContainer, PageHeader } from "@/components/page";
|
import { PageContainer, PageHeader } from "@/components/page";
|
||||||
@@ -41,7 +60,7 @@ import {
|
|||||||
import type { ContractTemplateArticle } from "@/services/contract-templates.service";
|
import type { ContractTemplateArticle } from "@/services/contract-templates.service";
|
||||||
|
|
||||||
const BODY_HINT =
|
const BODY_HINT =
|
||||||
'One clause per line — clauses are numbered automatically. Prefix a line with "- " to nest it as a bullet under the previous clause. Placeholders like {{client.companyName}}, {{contractDate}}, {{contractYear}} and {{reference}} are filled from the contract.';
|
'One clause per line — clauses are numbered automatically. Prefix a line with "- " to nest it as a bullet under the previous clause. Use the buttons above to drop a placeholder at the cursor — it is filled from the contract when the document is generated.';
|
||||||
|
|
||||||
interface ArticleDraft {
|
interface ArticleDraft {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -49,6 +68,254 @@ interface ArticleDraft {
|
|||||||
body: string;
|
body: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PlaceholderDef {
|
||||||
|
token: string;
|
||||||
|
label: string;
|
||||||
|
icon: typeof Building2;
|
||||||
|
hint: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Placeholders the renderer fills from the contract view model
|
||||||
|
* (contract-view-model.builder.ts). Quick row = the ones template authors
|
||||||
|
* reach for constantly; the rest live in the grouped "More" menu.
|
||||||
|
*/
|
||||||
|
const QUICK_PLACEHOLDERS: PlaceholderDef[] = [
|
||||||
|
{
|
||||||
|
token: "{{client.companyName}}",
|
||||||
|
label: "Client name",
|
||||||
|
icon: Building2,
|
||||||
|
hint: "Company name of the contracting client",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: "{{reference}}",
|
||||||
|
label: "Reference",
|
||||||
|
icon: Hash,
|
||||||
|
hint: "Contract reference number",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: "{{contractDate}}",
|
||||||
|
label: "Contract date",
|
||||||
|
icon: CalendarDays,
|
||||||
|
hint: "Full signature date of the contract",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: "{{contractYear}}",
|
||||||
|
label: "Contract year",
|
||||||
|
icon: CalendarRange,
|
||||||
|
hint: "Year the contract is signed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: "{{pricing.totalAmount}}",
|
||||||
|
label: "Total price",
|
||||||
|
icon: Banknote,
|
||||||
|
hint: "Total contract price from the pricing schedule",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const MORE_PLACEHOLDER_GROUPS: { label: string; items: PlaceholderDef[] }[] = [
|
||||||
|
{
|
||||||
|
label: "Client",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
token: "{{client.companyAddress}}",
|
||||||
|
label: "Client address",
|
||||||
|
icon: MapPin,
|
||||||
|
hint: "Street address of the client",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: "{{client.companyLocation}}",
|
||||||
|
label: "Client location",
|
||||||
|
icon: MapPin,
|
||||||
|
hint: "Region / city of the client",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: "{{client.phone}}",
|
||||||
|
label: "Client phone",
|
||||||
|
icon: Phone,
|
||||||
|
hint: "Client phone number",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: "{{client.email}}",
|
||||||
|
label: "Client email",
|
||||||
|
icon: Mail,
|
||||||
|
hint: "Client email address",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: "{{client.tinNumber}}",
|
||||||
|
label: "Client TIN",
|
||||||
|
icon: Hash,
|
||||||
|
hint: "Client tax identification number",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Route & cargo",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
token: "{{schedule.originLabel}}",
|
||||||
|
label: "Origin",
|
||||||
|
icon: MapPin,
|
||||||
|
hint: "Origin yard / station",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: "{{schedule.destinationLabel}}",
|
||||||
|
label: "Destination",
|
||||||
|
icon: MapPin,
|
||||||
|
hint: "Destination yard / station",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: "{{schedule.serviceType}}",
|
||||||
|
label: "Service type",
|
||||||
|
icon: Settings2,
|
||||||
|
hint: "Contracted service type name",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: "{{schedule.cargoDescription}}",
|
||||||
|
label: "Cargo description",
|
||||||
|
icon: Package,
|
||||||
|
hint: "Description of the cargo",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: "{{schedule.totalWeightVgm}}",
|
||||||
|
label: "Total weight",
|
||||||
|
icon: Weight,
|
||||||
|
hint: "Total verified gross mass",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: "{{schedule.equipmentReturn}}",
|
||||||
|
label: "Equipment return",
|
||||||
|
icon: RefreshCw,
|
||||||
|
hint: "Empty-equipment return terms",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: "{{schedule.scheduledDate}}",
|
||||||
|
label: "Scheduled date",
|
||||||
|
icon: CalendarClock,
|
||||||
|
hint: "Scheduled shipment date",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Pricing",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
token: "{{pricing.currency}}",
|
||||||
|
label: "Currency",
|
||||||
|
icon: Coins,
|
||||||
|
hint: "Payment currency (e.g. USD)",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Service provider (EDR)",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
token: "{{provider.name}}",
|
||||||
|
label: "Provider name",
|
||||||
|
icon: Building2,
|
||||||
|
hint: "EDR legal company name",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: "{{provider.address}}",
|
||||||
|
label: "Provider address",
|
||||||
|
icon: MapPin,
|
||||||
|
hint: "EDR principal place of business",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: "{{provider.phone}}",
|
||||||
|
label: "Provider phone",
|
||||||
|
icon: Phone,
|
||||||
|
hint: "EDR phone number",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: "{{provider.email}}",
|
||||||
|
label: "Provider email",
|
||||||
|
icon: Mail,
|
||||||
|
hint: "EDR email address",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const ALL_PLACEHOLDERS: PlaceholderDef[] = [
|
||||||
|
...QUICK_PLACEHOLDERS,
|
||||||
|
...MORE_PLACEHOLDER_GROUPS.flatMap((g) => g.items),
|
||||||
|
];
|
||||||
|
|
||||||
|
const KNOWN_TOKENS = new Set<string>(ALL_PLACEHOLDERS.map((p) => p.token));
|
||||||
|
|
||||||
|
/** Any {{…}} tokens in the text the renderer does not know how to fill. */
|
||||||
|
function unknownTokens(text: string): string[] {
|
||||||
|
const found = text.match(/\{\{[^{}]+\}\}/g) ?? [];
|
||||||
|
return [...new Set(found.filter((t) => !KNOWN_TOKENS.has(t)))];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParsedClause {
|
||||||
|
text: string;
|
||||||
|
bullets: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParsedBody {
|
||||||
|
/** Set (instead of clauses) when the body is one plain paragraph. */
|
||||||
|
paragraph?: string;
|
||||||
|
clauses: ParsedClause[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirror of the API renderer's rules (contract-article.util.ts): one clause per
|
||||||
|
* line, "- " nests a bullet under the previous clause, and a single bullet-less
|
||||||
|
* clause renders as a plain paragraph instead of a numbered list of one.
|
||||||
|
*/
|
||||||
|
function parseArticleBody(body: string): ParsedBody {
|
||||||
|
const clauses: ParsedClause[] = [];
|
||||||
|
for (const raw of body.split("\n")) {
|
||||||
|
const line = raw.trim();
|
||||||
|
if (!line) continue;
|
||||||
|
if (line.startsWith("- ") && clauses.length > 0) {
|
||||||
|
clauses[clauses.length - 1].bullets.push(line.slice(2).trim());
|
||||||
|
} else {
|
||||||
|
clauses.push({ text: line.replace(/^- /, ""), bullets: [] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (clauses.length === 1 && clauses[0].bullets.length === 0) {
|
||||||
|
return { paragraph: clauses[0].text, clauses: [] };
|
||||||
|
}
|
||||||
|
return { clauses };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render clause text with {{placeholders}} highlighted as green chips. */
|
||||||
|
function HighlightedText({ text }: { text: string }) {
|
||||||
|
const parts = text.split(/(\{\{[^{}]+\}\})/g);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{parts.map((part, i) =>
|
||||||
|
/^\{\{[^{}]+\}\}$/.test(part) ? (
|
||||||
|
<Text
|
||||||
|
key={i}
|
||||||
|
component="span"
|
||||||
|
size="xs"
|
||||||
|
fw={600}
|
||||||
|
c={KNOWN_TOKENS.has(part) ? "edr-green.8" : "red.7"}
|
||||||
|
px={4}
|
||||||
|
style={{
|
||||||
|
borderRadius: 4,
|
||||||
|
background: KNOWN_TOKENS.has(part)
|
||||||
|
? "var(--mantine-color-edr-green-0)"
|
||||||
|
: "var(--mantine-color-red-0)",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{part}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<span key={i}>{part}</span>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function ContractTemplateEditorPage() {
|
export default function ContractTemplateEditorPage() {
|
||||||
const { code } = useParams<{ code: string }>();
|
const { code } = useParams<{ code: string }>();
|
||||||
const { data: template, isLoading } = useContractTemplate(code);
|
const { data: template, isLoading } = useContractTemplate(code);
|
||||||
@@ -79,15 +346,12 @@ export default function ContractTemplateEditorPage() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveArticle = () => {
|
const saveArticle = (values: { title: string; body: string }) => {
|
||||||
if (!articleDraft) return;
|
if (!articleDraft) return;
|
||||||
if (articleDraft.id) {
|
if (articleDraft.id) {
|
||||||
updateArticle.mutate({
|
updateArticle.mutate({ articleId: articleDraft.id, payload: values });
|
||||||
articleId: articleDraft.id,
|
|
||||||
payload: { title: articleDraft.title, body: articleDraft.body },
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
addArticle.mutate({ title: articleDraft.title, body: articleDraft.body });
|
addArticle.mutate(values);
|
||||||
}
|
}
|
||||||
setArticleDraft(null);
|
setArticleDraft(null);
|
||||||
};
|
};
|
||||||
@@ -264,55 +528,14 @@ export default function ContractTemplateEditorPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Add / edit article modal ───────────────────────────────────── */}
|
{/* ── Add / edit article modal ───────────────────────────────────── */}
|
||||||
<Modal
|
{articleDraft && (
|
||||||
opened={Boolean(articleDraft)}
|
<ArticleEditorModal
|
||||||
onClose={() => setArticleDraft(null)}
|
initial={articleDraft}
|
||||||
title={articleDraft?.id ? "Edit article" : "Add article"}
|
saving={addArticle.isPending || updateArticle.isPending}
|
||||||
size="xl"
|
onClose={() => setArticleDraft(null)}
|
||||||
>
|
onSave={saveArticle}
|
||||||
{articleDraft && (
|
/>
|
||||||
<Stack gap="sm">
|
)}
|
||||||
<TextInput
|
|
||||||
label="Article title"
|
|
||||||
placeholder="e.g. Obligations of the Client"
|
|
||||||
value={articleDraft.title}
|
|
||||||
onChange={(event) =>
|
|
||||||
setArticleDraft({ ...articleDraft, title: event.currentTarget.value })
|
|
||||||
}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<Textarea
|
|
||||||
label="Article body"
|
|
||||||
description={BODY_HINT}
|
|
||||||
value={articleDraft.body}
|
|
||||||
onChange={(event) =>
|
|
||||||
setArticleDraft({ ...articleDraft, body: event.currentTarget.value })
|
|
||||||
}
|
|
||||||
autosize
|
|
||||||
minRows={12}
|
|
||||||
maxRows={24}
|
|
||||||
styles={{ input: { fontFamily: "ui-monospace, monospace", fontSize: 13 } }}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<Group justify="flex-end">
|
|
||||||
<Button variant="default" onClick={() => setArticleDraft(null)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color="edr-green"
|
|
||||||
disabled={
|
|
||||||
articleDraft.title.trim().length < 2 ||
|
|
||||||
articleDraft.body.trim().length < 2
|
|
||||||
}
|
|
||||||
loading={addArticle.isPending || updateArticle.isPending}
|
|
||||||
onClick={saveArticle}
|
|
||||||
>
|
|
||||||
{articleDraft.id ? "Save changes" : "Add article"}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{/* ── Delete confirm ─────────────────────────────────────────────── */}
|
{/* ── Delete confirm ─────────────────────────────────────────────── */}
|
||||||
<Modal
|
<Modal
|
||||||
@@ -364,6 +587,269 @@ export default function ContractTemplateEditorPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ArticleEditorModalProps {
|
||||||
|
initial: ArticleDraft;
|
||||||
|
saving: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (values: { title: string; body: string }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rich add/edit article editor: placeholder buttons insert at the text cursor
|
||||||
|
* of whichever field (title or body) was focused last, with a live preview of
|
||||||
|
* the numbered clauses exactly as the renderer lays them out.
|
||||||
|
*/
|
||||||
|
function ArticleEditorModal({
|
||||||
|
initial,
|
||||||
|
saving,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
}: ArticleEditorModalProps) {
|
||||||
|
const [title, setTitle] = useState(initial.title);
|
||||||
|
const [body, setBody] = useState(initial.body);
|
||||||
|
|
||||||
|
const titleRef = useRef<HTMLInputElement>(null);
|
||||||
|
const bodyRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
// Placeholders drop into whichever field held the cursor last (body default).
|
||||||
|
const lastFocused = useRef<"title" | "body">("body");
|
||||||
|
|
||||||
|
const insertAtCursor = (snippet: string) => {
|
||||||
|
const isTitle = lastFocused.current === "title";
|
||||||
|
const el = isTitle ? titleRef.current : bodyRef.current;
|
||||||
|
const value = isTitle ? title : body;
|
||||||
|
const start = el?.selectionStart ?? value.length;
|
||||||
|
const end = el?.selectionEnd ?? start;
|
||||||
|
const next = value.slice(0, start) + snippet + value.slice(end);
|
||||||
|
if (isTitle) setTitle(next);
|
||||||
|
else setBody(next);
|
||||||
|
// Refocus and place the caret right after the inserted snippet once the
|
||||||
|
// controlled re-render has flushed.
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (!el) return;
|
||||||
|
el.focus();
|
||||||
|
const caret = start + snippet.length;
|
||||||
|
el.setSelectionRange(caret, caret);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const insertLinePrefix = (prefix: string) => {
|
||||||
|
const el = bodyRef.current;
|
||||||
|
const start = el?.selectionStart ?? body.length;
|
||||||
|
// Start the snippet on its own line unless the caret already is.
|
||||||
|
const needsNewline = start > 0 && body[start - 1] !== "\n";
|
||||||
|
lastFocused.current = "body";
|
||||||
|
insertAtCursor(`${needsNewline ? "\n" : ""}${prefix}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const parsed = useMemo(() => parseArticleBody(body), [body]);
|
||||||
|
const clauseCount = parsed.paragraph ? 1 : parsed.clauses.length;
|
||||||
|
const unknown = useMemo(
|
||||||
|
() => unknownTokens(`${title}\n${body}`),
|
||||||
|
[title, body],
|
||||||
|
);
|
||||||
|
const canSave = title.trim().length >= 2 && body.trim().length >= 2;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened
|
||||||
|
onClose={onClose}
|
||||||
|
title={initial.id ? "Edit article" : "Add article"}
|
||||||
|
size="min(1120px, 95vw)"
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
{/* ── Editor ─────────────────────────────────────────────────── */}
|
||||||
|
<Stack gap="sm">
|
||||||
|
<TextInput
|
||||||
|
ref={titleRef}
|
||||||
|
label="Article title"
|
||||||
|
placeholder="e.g. Obligations of the Client"
|
||||||
|
value={title}
|
||||||
|
onChange={(event) => setTitle(event.currentTarget.value)}
|
||||||
|
onFocus={() => (lastFocused.current = "title")}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Text size="sm" fw={500} mb={4}>
|
||||||
|
Insert placeholder
|
||||||
|
</Text>
|
||||||
|
<Group gap={6} wrap="wrap">
|
||||||
|
{QUICK_PLACEHOLDERS.map(({ token, label, icon: Icon, hint }) => (
|
||||||
|
<Tooltip key={token} label={hint} withArrow>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="edr-green"
|
||||||
|
size="compact-sm"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<Icon size={13} />}
|
||||||
|
// Keep the field's focus/caret alive so insertion lands
|
||||||
|
// where the user was typing.
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => insertAtCursor(token)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
|
<Menu shadow="md" width={300} position="bottom-start">
|
||||||
|
<Menu.Target>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="compact-sm"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<Plus size={13} />}
|
||||||
|
rightSection={<ChevronDown size={13} />}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
More
|
||||||
|
</Button>
|
||||||
|
</Menu.Target>
|
||||||
|
<Menu.Dropdown mah={340} style={{ overflowY: "auto" }}>
|
||||||
|
{MORE_PLACEHOLDER_GROUPS.map((group) => (
|
||||||
|
<Box key={group.label}>
|
||||||
|
<Menu.Label>{group.label}</Menu.Label>
|
||||||
|
{group.items.map(({ token, label, icon: Icon, hint }) => (
|
||||||
|
<Menu.Item
|
||||||
|
key={token}
|
||||||
|
leftSection={<Icon size={14} />}
|
||||||
|
onClick={() => insertAtCursor(token)}
|
||||||
|
>
|
||||||
|
<Text size="sm">{label}</Text>
|
||||||
|
<Text size="xs" c="dimmed" title={hint}>
|
||||||
|
{token}
|
||||||
|
</Text>
|
||||||
|
</Menu.Item>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
<Tooltip label="Start a new numbered clause" withArrow>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="compact-sm"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<ListOrdered size={13} />}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => insertLinePrefix("")}
|
||||||
|
>
|
||||||
|
New clause
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip label="Nest a bullet under the previous clause" withArrow>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="compact-sm"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<ListPlus size={13} />}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => insertLinePrefix("- ")}
|
||||||
|
>
|
||||||
|
Bullet
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
ref={bodyRef}
|
||||||
|
label="Article body"
|
||||||
|
description={BODY_HINT}
|
||||||
|
value={body}
|
||||||
|
onChange={(event) => setBody(event.currentTarget.value)}
|
||||||
|
onFocus={() => (lastFocused.current = "body")}
|
||||||
|
autosize
|
||||||
|
minRows={12}
|
||||||
|
maxRows={22}
|
||||||
|
styles={{
|
||||||
|
input: { fontFamily: "ui-monospace, monospace", fontSize: 13 },
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
{unknown.length > 0 && (
|
||||||
|
<Group gap={6} wrap="nowrap" align="flex-start">
|
||||||
|
<AlertTriangle size={14} className="mt-0.5 shrink-0 text-red-600" />
|
||||||
|
<Text size="xs" c="red.7">
|
||||||
|
Unknown placeholder{unknown.length > 1 ? "s" : ""}{" "}
|
||||||
|
{unknown.join(", ")} — the generator won't fill{" "}
|
||||||
|
{unknown.length > 1 ? "these" : "this"}. Pick from the Insert
|
||||||
|
placeholder buttons instead.
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* ── Live preview ───────────────────────────────────────────── */}
|
||||||
|
<Paper withBorder radius="md" p="md" className="self-start lg:sticky lg:top-0">
|
||||||
|
<Group justify="space-between" mb="xs">
|
||||||
|
<Text fw={600} size="sm">
|
||||||
|
Live preview
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{clauseCount} clause{clauseCount !== 1 ? "s" : ""}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<ScrollArea.Autosize mah="60vh">
|
||||||
|
{title.trim() || clauseCount > 0 ? (
|
||||||
|
<Stack gap="xs">
|
||||||
|
{title.trim() && (
|
||||||
|
<Title order={5}>
|
||||||
|
<HighlightedText text={title} />
|
||||||
|
</Title>
|
||||||
|
)}
|
||||||
|
{parsed.paragraph && (
|
||||||
|
<Text size="sm">
|
||||||
|
<HighlightedText text={parsed.paragraph} />
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{parsed.clauses.map((clause, i) => (
|
||||||
|
<Box key={i}>
|
||||||
|
<Text size="sm">
|
||||||
|
<Text component="span" fw={600} c="edr-green.7">
|
||||||
|
{i + 1}.{" "}
|
||||||
|
</Text>
|
||||||
|
<HighlightedText text={clause.text} />
|
||||||
|
</Text>
|
||||||
|
{clause.bullets.length > 0 && (
|
||||||
|
<Stack gap={2} mt={2} pl="lg">
|
||||||
|
{clause.bullets.map((bullet, j) => (
|
||||||
|
<Text key={j} size="sm" c="dimmed">
|
||||||
|
• <HighlightedText text={bullet} />
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Text size="sm" c="dimmed" ta="center" py="xl">
|
||||||
|
Start typing — the article renders here exactly as it will
|
||||||
|
appear in the contract.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</ScrollArea.Autosize>
|
||||||
|
</Paper>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Group justify="flex-end" mt="md">
|
||||||
|
<Button variant="default" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
disabled={!canSave}
|
||||||
|
loading={saving}
|
||||||
|
onClick={() => onSave({ title: title.trim(), body: body.trim() })}
|
||||||
|
>
|
||||||
|
{initial.id ? "Save changes" : "Add article"}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface DocumentDetailsModalProps {
|
interface DocumentDetailsModalProps {
|
||||||
opened: boolean;
|
opened: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
|||||||
@@ -2,17 +2,25 @@ import { useState } from "react";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Center,
|
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
|
Skeleton,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { Boxes, Container, Eye, FileSignature, Pencil } from "lucide-react";
|
import {
|
||||||
|
Boxes,
|
||||||
|
Clock,
|
||||||
|
Container,
|
||||||
|
Eye,
|
||||||
|
FileText,
|
||||||
|
Pencil,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
import { PageContainer, PageHeader } from "@/components/page";
|
import { PageContainer, PageHeader } from "@/components/page";
|
||||||
import { useContractTemplates } from "@/hooks/contract-templates/useContractTemplates";
|
import { useContractTemplates } from "@/hooks/contract-templates/useContractTemplates";
|
||||||
@@ -25,10 +33,10 @@ const DIRECTION_LABEL: Record<string, string> = {
|
|||||||
INTERCITY: "Intercity",
|
INTERCITY: "Intercity",
|
||||||
};
|
};
|
||||||
|
|
||||||
const DIRECTION_COLOR: Record<string, string> = {
|
const DIRECTION_DOT: Record<string, string> = {
|
||||||
IMPORT: "edr-green",
|
IMPORT: "var(--mantine-color-blue-5)",
|
||||||
EXPORT: "teal",
|
EXPORT: "var(--mantine-color-violet-5)",
|
||||||
INTERCITY: "lime",
|
INTERCITY: "var(--mantine-color-orange-5)",
|
||||||
};
|
};
|
||||||
|
|
||||||
function templateDirection(code: ContractTemplate["code"]): string {
|
function templateDirection(code: ContractTemplate["code"]): string {
|
||||||
@@ -39,6 +47,14 @@ function isBulk(code: ContractTemplate["code"]): boolean {
|
|||||||
return code.endsWith("_BULK");
|
return code.endsWith("_BULK");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatUpdated(value: string): string {
|
||||||
|
return new Date(value).toLocaleDateString("en-GB", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export default function ContractTemplatesPage() {
|
export default function ContractTemplatesPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { data: templates, isLoading } = useContractTemplates();
|
const { data: templates, isLoading } = useContractTemplates();
|
||||||
@@ -53,94 +69,20 @@ export default function ContractTemplatesPage() {
|
|||||||
subtitle="The six contract documents generated when a contract is approved — one per trade direction and freight type. Articles are fully editable."
|
subtitle="The six contract documents generated when a contract is approved — one per trade direction and freight type. Articles are fully editable."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isLoading ? (
|
<SimpleGrid cols={{ base: 1, md: 2, xl: 3 }} spacing="lg">
|
||||||
<Center h={320}>
|
{isLoading
|
||||||
<Loader color="edr-green" />
|
? Array.from({ length: 6 }, (_, i) => <TemplateCardSkeleton key={i} />)
|
||||||
</Center>
|
: (templates ?? []).map((template) => (
|
||||||
) : (
|
<TemplateCard
|
||||||
<SimpleGrid cols={{ base: 1, md: 2, xl: 3 }} spacing="lg">
|
key={template.code}
|
||||||
{(templates ?? []).map((template) => {
|
template={template}
|
||||||
const direction = templateDirection(template.code);
|
onPreview={() => setPreviewCode(template.code)}
|
||||||
return (
|
onEdit={() =>
|
||||||
<Card key={template.code} withBorder radius="xl" padding="lg">
|
navigate(`/dashboard/contract-templates/${template.code}`)
|
||||||
<Stack gap="sm" h="100%">
|
}
|
||||||
<Group justify="space-between" align="flex-start">
|
/>
|
||||||
<ThemeIcon
|
))}
|
||||||
size={44}
|
</SimpleGrid>
|
||||||
radius="md"
|
|
||||||
variant="light"
|
|
||||||
color="edr-green"
|
|
||||||
>
|
|
||||||
{isBulk(template.code) ? (
|
|
||||||
<Boxes size={24} />
|
|
||||||
) : (
|
|
||||||
<Container size={24} />
|
|
||||||
)}
|
|
||||||
</ThemeIcon>
|
|
||||||
<Group gap={6}>
|
|
||||||
<Badge
|
|
||||||
variant="light"
|
|
||||||
color={DIRECTION_COLOR[direction] ?? "edr-green"}
|
|
||||||
>
|
|
||||||
{DIRECTION_LABEL[direction] ?? direction}
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="outline" color="gray">
|
|
||||||
{isBulk(template.code) ? "Bulk" : "Container"}
|
|
||||||
</Badge>
|
|
||||||
{!template.isActive && (
|
|
||||||
<Badge variant="light" color="red">
|
|
||||||
Inactive
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Text fw={700} size="lg">
|
|
||||||
{template.name}
|
|
||||||
</Text>
|
|
||||||
<Text size="sm" c="dimmed" lineClamp={3}>
|
|
||||||
{template.description || template.documentTitle}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Group gap="xs" mt="auto">
|
|
||||||
<FileSignature size={14} className="text-edr-primary" />
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{template.articles.length} articles · updated{" "}
|
|
||||||
{new Date(template.updatedAt).toLocaleDateString("en-GB", {
|
|
||||||
day: "numeric",
|
|
||||||
month: "short",
|
|
||||||
year: "numeric",
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Group grow>
|
|
||||||
<Button
|
|
||||||
variant="light"
|
|
||||||
color="edr-green"
|
|
||||||
leftSection={<Eye size={16} />}
|
|
||||||
onClick={() => setPreviewCode(template.code)}
|
|
||||||
>
|
|
||||||
Preview
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color="edr-green"
|
|
||||||
leftSection={<Pencil size={16} />}
|
|
||||||
onClick={() =>
|
|
||||||
navigate(`/dashboard/contract-templates/${template.code}`)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Edit articles
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</SimpleGrid>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<TemplatePreviewModal
|
<TemplatePreviewModal
|
||||||
code={previewCode}
|
code={previewCode}
|
||||||
@@ -150,3 +92,151 @@ export default function ContractTemplatesPage() {
|
|||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TemplateCard({
|
||||||
|
template,
|
||||||
|
onPreview,
|
||||||
|
onEdit,
|
||||||
|
}: {
|
||||||
|
template: ContractTemplate;
|
||||||
|
onPreview: () => void;
|
||||||
|
onEdit: () => void;
|
||||||
|
}) {
|
||||||
|
const direction = templateDirection(template.code);
|
||||||
|
const bulk = isBulk(template.code);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
withBorder
|
||||||
|
radius="lg"
|
||||||
|
padding={0}
|
||||||
|
className="group flex flex-col overflow-hidden transition-all duration-150 hover:-translate-y-0.5 hover:shadow-md"
|
||||||
|
>
|
||||||
|
<Stack gap="md" p="lg" style={{ flex: 1 }}>
|
||||||
|
{/* Kicker row: muted icon well + category label + state */}
|
||||||
|
<Group justify="space-between" align="center" wrap="nowrap">
|
||||||
|
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||||
|
<ThemeIcon size={40} radius="md" variant="light" color="gray" c="gray.6">
|
||||||
|
{bulk ? (
|
||||||
|
<Boxes size={20} strokeWidth={1.75} />
|
||||||
|
) : (
|
||||||
|
<Container size={20} strokeWidth={1.75} />
|
||||||
|
)}
|
||||||
|
</ThemeIcon>
|
||||||
|
<Group gap={7} wrap="nowrap">
|
||||||
|
<Box
|
||||||
|
w={7}
|
||||||
|
h={7}
|
||||||
|
style={{
|
||||||
|
borderRadius: 999,
|
||||||
|
flexShrink: 0,
|
||||||
|
background: DIRECTION_DOT[direction] ?? "var(--mantine-color-gray-5)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text size="xs" fw={600} tt="uppercase" lts="0.06em" c="dimmed">
|
||||||
|
{DIRECTION_LABEL[direction] ?? direction} ·{" "}
|
||||||
|
{bulk ? "Bulk" : "Container"}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
{!template.isActive && (
|
||||||
|
<Tooltip label="Not used for new contracts" withArrow>
|
||||||
|
<Badge size="sm" variant="light" color="red">
|
||||||
|
Inactive
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Name + description */}
|
||||||
|
<div>
|
||||||
|
<Text fw={600} size="md" lh={1.35}>
|
||||||
|
{template.name}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c="dimmed" lineClamp={2} mt={4} lh={1.5}>
|
||||||
|
{template.description || template.documentTitle}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Meta stats */}
|
||||||
|
<Group gap="lg" mt="auto">
|
||||||
|
<Group gap={5} wrap="nowrap">
|
||||||
|
<FileText size={13} className="text-gray-400" />
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{template.articles.length} article
|
||||||
|
{template.articles.length !== 1 ? "s" : ""}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Group gap={5} wrap="nowrap">
|
||||||
|
<Clock size={13} className="text-gray-400" />
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Updated {formatUpdated(template.updatedAt)}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* Footer actions, separated by a hairline */}
|
||||||
|
<Box
|
||||||
|
px="md"
|
||||||
|
py="xs"
|
||||||
|
style={{ borderTop: "1px solid var(--mantine-color-default-border)" }}
|
||||||
|
>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
size="compact-sm"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<Eye size={14} />}
|
||||||
|
onClick={onPreview}
|
||||||
|
>
|
||||||
|
Preview
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="edr-green"
|
||||||
|
size="compact-sm"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<Pencil size={14} />}
|
||||||
|
onClick={onEdit}
|
||||||
|
>
|
||||||
|
Edit articles
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TemplateCardSkeleton() {
|
||||||
|
return (
|
||||||
|
<Card withBorder radius="lg" padding={0} className="overflow-hidden">
|
||||||
|
<Stack gap="md" p="lg">
|
||||||
|
<Group gap="sm">
|
||||||
|
<Skeleton height={40} width={40} radius="md" />
|
||||||
|
<Skeleton height={10} width={120} radius="xl" />
|
||||||
|
</Group>
|
||||||
|
<div>
|
||||||
|
<Skeleton height={14} width="70%" radius="xl" />
|
||||||
|
<Skeleton height={10} width="95%" radius="xl" mt={10} />
|
||||||
|
<Skeleton height={10} width="60%" radius="xl" mt={6} />
|
||||||
|
</div>
|
||||||
|
<Group gap="lg">
|
||||||
|
<Skeleton height={10} width={70} radius="xl" />
|
||||||
|
<Skeleton height={10} width={110} radius="xl" />
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
<Box
|
||||||
|
px="md"
|
||||||
|
py="xs"
|
||||||
|
style={{ borderTop: "1px solid var(--mantine-color-default-border)" }}
|
||||||
|
>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Skeleton height={26} width={90} radius="md" />
|
||||||
|
<Skeleton height={26} width={110} radius="md" />
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -6,14 +6,26 @@ import {
|
|||||||
Badge,
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
|
CloseButton,
|
||||||
Group,
|
Group,
|
||||||
Modal,
|
Modal,
|
||||||
|
Paper,
|
||||||
|
SegmentedControl,
|
||||||
|
Select,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
Textarea,
|
Textarea,
|
||||||
TextInput,
|
TextInput,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { Inbox, PackageSearch, RefreshCw, Search } from "lucide-react";
|
import { DateInput } from "@mantine/dates";
|
||||||
|
import {
|
||||||
|
ArrowUpDown,
|
||||||
|
FilterX,
|
||||||
|
Inbox,
|
||||||
|
PackageSearch,
|
||||||
|
RefreshCw,
|
||||||
|
Search,
|
||||||
|
} from "lucide-react";
|
||||||
import { DataTable, type ColumnDef } from "@edr/ui-common";
|
import { DataTable, type ColumnDef } from "@edr/ui-common";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
@@ -41,6 +53,17 @@ const fmtDate = (iso?: string | null) =>
|
|||||||
}).format(new Date(iso))
|
}).format(new Date(iso))
|
||||||
: "—";
|
: "—";
|
||||||
|
|
||||||
|
const fmtDateTime = (iso?: string | null) =>
|
||||||
|
iso
|
||||||
|
? new Intl.DateTimeFormat("en-GB", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
}).format(new Date(iso))
|
||||||
|
: "—";
|
||||||
|
|
||||||
function summarizeLines(lines: Freight.RequestedShipmentLines): string {
|
function summarizeLines(lines: Freight.RequestedShipmentLines): string {
|
||||||
if (lines.containers?.length) {
|
if (lines.containers?.length) {
|
||||||
return lines.containers
|
return lines.containers
|
||||||
@@ -56,10 +79,46 @@ function summarizeLines(lines: Freight.RequestedShipmentLines): string {
|
|||||||
return "—";
|
return "—";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const STATUS_META: Record<
|
||||||
|
Freight.BookingRequestStatus,
|
||||||
|
{ label: string; color: string }
|
||||||
|
> = {
|
||||||
|
PENDING: { label: "Pending", color: "yellow" },
|
||||||
|
ACCEPTED: { label: "Accepted", color: "edr-green" },
|
||||||
|
REJECTED: { label: "Rejected", color: "red" },
|
||||||
|
CANCELLED: { label: "Cancelled", color: "gray" },
|
||||||
|
};
|
||||||
|
|
||||||
|
type StatusFilter = "ALL" | Freight.BookingRequestStatus;
|
||||||
|
type CargoFilter = "ALL" | "CONTAINER" | "BULK";
|
||||||
|
type SortKey =
|
||||||
|
| "submitted-desc"
|
||||||
|
| "submitted-asc"
|
||||||
|
| "preferred-asc"
|
||||||
|
| "preferred-desc"
|
||||||
|
| "reference";
|
||||||
|
|
||||||
|
const SORT_OPTIONS: Array<{ value: SortKey; label: string }> = [
|
||||||
|
{ value: "submitted-desc", label: "Newest first" },
|
||||||
|
{ value: "submitted-asc", label: "Oldest first" },
|
||||||
|
{ value: "preferred-asc", label: "Preferred date (soonest)" },
|
||||||
|
{ value: "preferred-desc", label: "Preferred date (latest)" },
|
||||||
|
{ value: "reference", label: "Reference A–Z" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const time = (iso?: string | null) => (iso ? new Date(iso).getTime() : 0);
|
||||||
|
|
||||||
export default function ShipmentRequestsPage() {
|
export default function ShipmentRequestsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
|
const [status, setStatus] = useState<StatusFilter>("PENDING");
|
||||||
|
const [cargo, setCargo] = useState<CargoFilter>("ALL");
|
||||||
|
const [preferredFrom, setPreferredFrom] = useState<Date | null>(null);
|
||||||
|
const [preferredTo, setPreferredTo] = useState<Date | null>(null);
|
||||||
|
const [sort, setSort] = useState<SortKey>("submitted-desc");
|
||||||
|
|
||||||
const [rejectTarget, setRejectTarget] = useState<ShipmentListRow | null>(null);
|
const [rejectTarget, setRejectTarget] = useState<ShipmentListRow | null>(null);
|
||||||
const [rejectNote, setRejectNote] = useState("");
|
const [rejectNote, setRejectNote] = useState("");
|
||||||
const [acceptTarget, setAcceptTarget] = useState<ShipmentListRow | null>(null);
|
const [acceptTarget, setAcceptTarget] = useState<ShipmentListRow | null>(null);
|
||||||
@@ -80,26 +139,132 @@ export default function ShipmentRequestsPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const allRows = useMemo<ShipmentListRow[]>(
|
||||||
|
() =>
|
||||||
|
(data ?? []).map((r) => {
|
||||||
|
const lines = r.requestedLines ?? {};
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
reference: r.reference || r.id.slice(0, 8),
|
||||||
|
contractId: r.contractId,
|
||||||
|
contractReference: r.contract?.reference ?? r.contractId,
|
||||||
|
scheduledDate: r.scheduledDate,
|
||||||
|
summary: summarizeLines(lines),
|
||||||
|
status: r.status,
|
||||||
|
createdBookingId: r.createdBookingId,
|
||||||
|
createdAt: r.createdAt,
|
||||||
|
customerName: r.contract?.company?.name ?? null,
|
||||||
|
freightKind: lines.containers?.length
|
||||||
|
? "CONTAINER"
|
||||||
|
: lines.bulk
|
||||||
|
? "BULK"
|
||||||
|
: r.contract?.freightType === "BULK"
|
||||||
|
? "BULK"
|
||||||
|
: "CONTAINER",
|
||||||
|
hazardous:
|
||||||
|
(lines.containers ?? []).some((c) => (c.hazardousQuantity ?? 0) > 0) ||
|
||||||
|
(lines.bulk?.hazardousQuantity ?? 0) > 0,
|
||||||
|
reefer: (lines.containers ?? []).some(
|
||||||
|
(c) => (c.reeferQuantity ?? 0) > 0,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
[data],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Status counts always reflect the whole queue so the segmented control
|
||||||
|
// reads as a live overview, independent of the other filters.
|
||||||
|
const counts = useMemo(() => {
|
||||||
|
const c: Record<StatusFilter, number> = {
|
||||||
|
ALL: allRows.length,
|
||||||
|
PENDING: 0,
|
||||||
|
ACCEPTED: 0,
|
||||||
|
REJECTED: 0,
|
||||||
|
CANCELLED: 0,
|
||||||
|
};
|
||||||
|
allRows.forEach((r) => {
|
||||||
|
c[r.status] += 1;
|
||||||
|
});
|
||||||
|
return c;
|
||||||
|
}, [allRows]);
|
||||||
|
|
||||||
const rows = useMemo<ShipmentListRow[]>(() => {
|
const rows = useMemo<ShipmentListRow[]>(() => {
|
||||||
const all = (data ?? []).map((r) => ({
|
let out = allRows;
|
||||||
id: r.id,
|
|
||||||
reference: r.reference || r.id.slice(0, 8),
|
if (status !== "ALL") out = out.filter((r) => r.status === status);
|
||||||
contractId: r.contractId,
|
if (cargo !== "ALL") out = out.filter((r) => r.freightKind === cargo);
|
||||||
contractReference: r.contract?.reference ?? r.contractId,
|
|
||||||
scheduledDate: r.scheduledDate,
|
// Preferred-date range: rows without a preferred day drop out once a bound
|
||||||
summary: summarizeLines(r.requestedLines ?? {}),
|
// is set — a date filter that keeps dateless rows reads as broken.
|
||||||
status: r.status,
|
if (preferredFrom || preferredTo) {
|
||||||
createdBookingId: r.createdBookingId,
|
const from = preferredFrom ? preferredFrom.getTime() : -Infinity;
|
||||||
}));
|
const to = preferredTo
|
||||||
|
? preferredTo.getTime() + 24 * 60 * 60 * 1000 - 1
|
||||||
|
: Infinity;
|
||||||
|
out = out.filter((r) => {
|
||||||
|
if (!r.scheduledDate) return false;
|
||||||
|
const t = time(r.scheduledDate);
|
||||||
|
return t >= from && t <= to;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
if (!q) return all;
|
if (q) {
|
||||||
return all.filter(
|
out = out.filter(
|
||||||
(r) =>
|
(r) =>
|
||||||
r.reference.toLowerCase().includes(q) ||
|
r.reference.toLowerCase().includes(q) ||
|
||||||
r.contractReference.toLowerCase().includes(q) ||
|
r.contractReference.toLowerCase().includes(q) ||
|
||||||
r.summary.toLowerCase().includes(q),
|
(r.customerName ?? "").toLowerCase().includes(q) ||
|
||||||
);
|
r.summary.toLowerCase().includes(q),
|
||||||
}, [data, query]);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = [...out];
|
||||||
|
switch (sort) {
|
||||||
|
case "submitted-asc":
|
||||||
|
sorted.sort((a, b) => time(a.createdAt) - time(b.createdAt));
|
||||||
|
break;
|
||||||
|
case "preferred-asc":
|
||||||
|
// Requests without a preferred day sink to the bottom in both orders.
|
||||||
|
sorted.sort(
|
||||||
|
(a, b) =>
|
||||||
|
(a.scheduledDate ? time(a.scheduledDate) : Infinity) -
|
||||||
|
(b.scheduledDate ? time(b.scheduledDate) : Infinity),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "preferred-desc":
|
||||||
|
sorted.sort(
|
||||||
|
(a, b) =>
|
||||||
|
(b.scheduledDate ? time(b.scheduledDate) : -Infinity) -
|
||||||
|
(a.scheduledDate ? time(a.scheduledDate) : -Infinity),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "reference":
|
||||||
|
sorted.sort((a, b) => a.reference.localeCompare(b.reference));
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// Newest submitted on top.
|
||||||
|
sorted.sort((a, b) => time(b.createdAt) - time(a.createdAt));
|
||||||
|
}
|
||||||
|
return sorted;
|
||||||
|
}, [allRows, status, cargo, preferredFrom, preferredTo, query, sort]);
|
||||||
|
|
||||||
|
const filtersActive =
|
||||||
|
query.trim() !== "" ||
|
||||||
|
status !== "PENDING" ||
|
||||||
|
cargo !== "ALL" ||
|
||||||
|
preferredFrom !== null ||
|
||||||
|
preferredTo !== null ||
|
||||||
|
sort !== "submitted-desc";
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setQuery("");
|
||||||
|
setStatus("PENDING");
|
||||||
|
setCargo("ALL");
|
||||||
|
setPreferredFrom(null);
|
||||||
|
setPreferredTo(null);
|
||||||
|
setSort("submitted-desc");
|
||||||
|
};
|
||||||
|
|
||||||
const columns = useMemo<ColumnDef<ShipmentListRow>[]>(
|
const columns = useMemo<ColumnDef<ShipmentListRow>[]>(
|
||||||
() => [
|
() => [
|
||||||
@@ -108,9 +273,14 @@ export default function ShipmentRequestsPage() {
|
|||||||
header: "Request",
|
header: "Request",
|
||||||
meta: cellMeta,
|
meta: cellMeta,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Text size="sm" fw={700} c="dark.5">
|
<Box>
|
||||||
{row.original.reference}
|
<Text size="sm" fw={700} c="dark.5">
|
||||||
</Text>
|
{row.original.reference}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" mt={2}>
|
||||||
|
Submitted {fmtDateTime(row.original.createdAt)}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -118,9 +288,16 @@ export default function ShipmentRequestsPage() {
|
|||||||
header: "Contract",
|
header: "Contract",
|
||||||
meta: cellMeta,
|
meta: cellMeta,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Text size="sm" c="gray.7">
|
<Box>
|
||||||
{row.original.contractReference}
|
<Text size="sm" c="gray.7">
|
||||||
</Text>
|
{row.original.contractReference}
|
||||||
|
</Text>
|
||||||
|
{row.original.customerName ? (
|
||||||
|
<Text size="xs" c="dimmed" mt={2}>
|
||||||
|
{row.original.customerName}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -128,9 +305,21 @@ export default function ShipmentRequestsPage() {
|
|||||||
header: "Requested",
|
header: "Requested",
|
||||||
meta: cellMeta,
|
meta: cellMeta,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Badge variant="light" color="edr-green" radius="sm">
|
<Group gap={6} wrap="wrap">
|
||||||
{row.original.summary}
|
<Badge variant="light" color="edr-green" radius="sm">
|
||||||
</Badge>
|
{row.original.summary}
|
||||||
|
</Badge>
|
||||||
|
{row.original.hazardous ? (
|
||||||
|
<Badge variant="light" color="red" radius="sm">
|
||||||
|
Hazardous
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
{row.original.reefer ? (
|
||||||
|
<Badge variant="light" color="blue" radius="sm">
|
||||||
|
Reefer
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -141,6 +330,19 @@ export default function ShipmentRequestsPage() {
|
|||||||
<Text size="sm">{fmtDate(row.original.scheduledDate)}</Text>
|
<Text size="sm">{fmtDate(row.original.scheduledDate)}</Text>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "status",
|
||||||
|
header: "Status",
|
||||||
|
meta: cellMeta,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const meta = STATUS_META[row.original.status];
|
||||||
|
return (
|
||||||
|
<Badge variant="light" color={meta.color} radius="sm">
|
||||||
|
{meta.label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
header: () => <span className={ruleEngineTable.headerCell}>Action</span>,
|
header: () => <span className={ruleEngineTable.headerCell}>Action</span>,
|
||||||
@@ -193,6 +395,8 @@ export default function ShipmentRequestsPage() {
|
|||||||
[navigate],
|
[navigate],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const hasAnyRequests = allRows.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
@@ -206,7 +410,7 @@ export default function ShipmentRequestsPage() {
|
|||||||
radius="sm"
|
radius="sm"
|
||||||
leftSection={<PackageSearch size={13} />}
|
leftSection={<PackageSearch size={13} />}
|
||||||
>
|
>
|
||||||
{rows.length} pending
|
{counts.PENDING} pending
|
||||||
</Badge>
|
</Badge>
|
||||||
}
|
}
|
||||||
action={
|
action={
|
||||||
@@ -223,16 +427,108 @@ export default function ShipmentRequestsPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextInput
|
<Paper withBorder radius="lg" p="md" style={{ borderColor: "#E6ECF2" }}>
|
||||||
radius="md"
|
<Stack gap="sm">
|
||||||
maw={360}
|
<Group gap="sm" wrap="wrap">
|
||||||
placeholder="Search request, contract, cargo…"
|
<TextInput
|
||||||
leftSection={<Search size={15} />}
|
radius="md"
|
||||||
value={query}
|
style={{ flex: 1, minWidth: 220 }}
|
||||||
onChange={(e) => setQuery(e.currentTarget.value)}
|
placeholder="Search request, contract, customer, cargo…"
|
||||||
/>
|
leftSection={<Search size={15} />}
|
||||||
|
rightSection={
|
||||||
|
query ? (
|
||||||
|
<CloseButton
|
||||||
|
size="sm"
|
||||||
|
aria-label="Clear search"
|
||||||
|
onClick={() => setQuery("")}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
radius="md"
|
||||||
|
w={150}
|
||||||
|
value={cargo}
|
||||||
|
onChange={(v) => setCargo((v as CargoFilter) ?? "ALL")}
|
||||||
|
data={[
|
||||||
|
{ value: "ALL", label: "All cargo" },
|
||||||
|
{ value: "CONTAINER", label: "Containers" },
|
||||||
|
{ value: "BULK", label: "Bulk" },
|
||||||
|
]}
|
||||||
|
allowDeselect={false}
|
||||||
|
aria-label="Cargo type"
|
||||||
|
/>
|
||||||
|
<DateInput
|
||||||
|
radius="md"
|
||||||
|
w={150}
|
||||||
|
placeholder="Preferred from"
|
||||||
|
value={preferredFrom}
|
||||||
|
onChange={(v) => setPreferredFrom(v ? new Date(v) : null)}
|
||||||
|
maxDate={preferredTo ?? undefined}
|
||||||
|
clearable
|
||||||
|
aria-label="Preferred date from"
|
||||||
|
/>
|
||||||
|
<DateInput
|
||||||
|
radius="md"
|
||||||
|
w={150}
|
||||||
|
placeholder="Preferred to"
|
||||||
|
value={preferredTo}
|
||||||
|
onChange={(v) => setPreferredTo(v ? new Date(v) : null)}
|
||||||
|
minDate={preferredFrom ?? undefined}
|
||||||
|
clearable
|
||||||
|
aria-label="Preferred date to"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
radius="md"
|
||||||
|
w={215}
|
||||||
|
leftSection={<ArrowUpDown size={14} />}
|
||||||
|
value={sort}
|
||||||
|
onChange={(v) => setSort((v as SortKey) ?? "submitted-desc")}
|
||||||
|
data={SORT_OPTIONS}
|
||||||
|
allowDeselect={false}
|
||||||
|
aria-label="Sort by"
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
{rows.length === 0 && !isLoading ? (
|
<Group justify="space-between" gap="sm" wrap="wrap">
|
||||||
|
<SegmentedControl
|
||||||
|
radius="md"
|
||||||
|
size="xs"
|
||||||
|
value={status}
|
||||||
|
onChange={(v) => setStatus(v as StatusFilter)}
|
||||||
|
data={[
|
||||||
|
{ value: "ALL", label: `All · ${counts.ALL}` },
|
||||||
|
{ value: "PENDING", label: `Pending · ${counts.PENDING}` },
|
||||||
|
{ value: "ACCEPTED", label: `Accepted · ${counts.ACCEPTED}` },
|
||||||
|
{ value: "REJECTED", label: `Rejected · ${counts.REJECTED}` },
|
||||||
|
{ value: "CANCELLED", label: `Cancelled · ${counts.CANCELLED}` },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Group gap="sm">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{rows.length} of {allRows.length} request
|
||||||
|
{allRows.length === 1 ? "" : "s"}
|
||||||
|
</Text>
|
||||||
|
{filtersActive ? (
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
size="compact-sm"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<FilterX size={14} />}
|
||||||
|
onClick={clearFilters}
|
||||||
|
>
|
||||||
|
Clear filters
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{rows.length === 0 && !isLoading && !isError ? (
|
||||||
<Box
|
<Box
|
||||||
py={56}
|
py={56}
|
||||||
style={{
|
style={{
|
||||||
@@ -242,9 +538,28 @@ export default function ShipmentRequestsPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Inbox size={26} className="text-muted-foreground" />
|
<Inbox size={26} className="text-muted-foreground" />
|
||||||
<Text c="dimmed" mt="sm">
|
{hasAnyRequests ? (
|
||||||
No pending shipment requests.
|
<>
|
||||||
</Text>
|
<Text c="dimmed" mt="sm">
|
||||||
|
No requests match the current filters.
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
size="compact-sm"
|
||||||
|
radius="md"
|
||||||
|
mt="xs"
|
||||||
|
leftSection={<FilterX size={14} />}
|
||||||
|
onClick={clearFilters}
|
||||||
|
>
|
||||||
|
Clear filters
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Text c="dimmed" mt="sm">
|
||||||
|
No shipment requests yet.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<DataTable
|
<DataTable
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Card,
|
Card,
|
||||||
Center,
|
Center,
|
||||||
Container,
|
Container,
|
||||||
|
Divider,
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
@@ -90,6 +91,11 @@ function tableStatus(query: { isLoading: boolean; isError: boolean }) {
|
|||||||
return query.isLoading ? "loading" : query.isError ? "error" : "success";
|
return query.isLoading ? "loading" : query.isError ? "error" : "success";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Matches the fileKey seeded in the API's file-upload-settings seeder. */
|
||||||
|
const POA_DELEGATION_CODE = "poa_delegation_letter";
|
||||||
|
/** A letter uploaded by an approved customer, awaiting this reviewer's approval. */
|
||||||
|
const POA_DELEGATION_PENDING_CODE = "poa_delegation_letter_pending";
|
||||||
|
|
||||||
export default function CustomerDetailPage() {
|
export default function CustomerDetailPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -531,6 +537,32 @@ export default function CustomerDetailPage() {
|
|||||||
(p) => p.licenseFiles && p.licenseFiles.length > 0,
|
(p) => p.licenseFiles && p.licenseFiles.length > 0,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const poaDocuments = useMemo(
|
||||||
|
() =>
|
||||||
|
documents.filter(
|
||||||
|
(d) =>
|
||||||
|
d.code === POA_DELEGATION_CODE ||
|
||||||
|
d.code === POA_DELEGATION_PENDING_CODE,
|
||||||
|
),
|
||||||
|
[documents],
|
||||||
|
);
|
||||||
|
const poaLive = poaDocuments.filter((d) => d.code === POA_DELEGATION_CODE);
|
||||||
|
const poaFields = [
|
||||||
|
{ label: "PoA name", value: company?.poaName },
|
||||||
|
{ label: "PoA email", value: company?.poaEmail },
|
||||||
|
{ label: "PoA phone", value: company?.poaPhone },
|
||||||
|
{ label: "PoA location", value: company?.poaLocation },
|
||||||
|
{ label: "PoA address", value: company?.poaAddress },
|
||||||
|
];
|
||||||
|
const hasPoaDetails = poaFields.some((f) => f.value?.trim());
|
||||||
|
// A freight forwarder acts on other companies' behalf, so its PoA — details
|
||||||
|
// and delegation letter both — is mandatory rather than optional.
|
||||||
|
const poaMandatory = (company?.companyProfiles ?? []).some(
|
||||||
|
(p) => p.type === "freight_forwarder",
|
||||||
|
);
|
||||||
|
const delegationMissing =
|
||||||
|
(hasPoaDetails || poaMandatory) && poaLive.length === 0;
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<Center mih="60vh">
|
<Center mih="60vh">
|
||||||
@@ -672,6 +704,159 @@ export default function CustomerDetailPage() {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Stack gap="lg">
|
||||||
|
<Group justify="space-between" wrap="nowrap">
|
||||||
|
<Group gap="xs" wrap="nowrap">
|
||||||
|
<Text fw={600} c="edr-text">
|
||||||
|
Power of Attorney
|
||||||
|
</Text>
|
||||||
|
{poaMandatory && (
|
||||||
|
<Badge size="xs" color="blue" variant="light">
|
||||||
|
Required for freight forwarder
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
{delegationMissing ? (
|
||||||
|
<Badge size="sm" color="red" variant="light">
|
||||||
|
Delegation letter missing
|
||||||
|
</Badge>
|
||||||
|
) : poaLive.length > 0 ? (
|
||||||
|
<Badge size="sm" color="edr-green" variant="light">
|
||||||
|
Delegation letter on file
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge size="sm" color="gray" variant="light">
|
||||||
|
Not provided
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{hasPoaDetails ? (
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
|
||||||
|
{poaFields.map((f) => (
|
||||||
|
<InfoField
|
||||||
|
key={f.label}
|
||||||
|
label={f.label}
|
||||||
|
value={f.value}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
) : (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
No Power of Attorney representative recorded for this
|
||||||
|
customer.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Text
|
||||||
|
size="xs"
|
||||||
|
fw={600}
|
||||||
|
c="edr-muted"
|
||||||
|
tt="uppercase"
|
||||||
|
style={{ letterSpacing: "0.04em" }}
|
||||||
|
>
|
||||||
|
Delegation letter
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{documentsQuery.isLoading ? (
|
||||||
|
<Group gap="xs">
|
||||||
|
<Loader size="xs" />
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Loading documents…
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
) : documentsQuery.isError ? (
|
||||||
|
<Group gap="sm">
|
||||||
|
<Text size="sm" c="red">
|
||||||
|
Failed to load documents.
|
||||||
|
</Text>
|
||||||
|
<Anchor
|
||||||
|
component="button"
|
||||||
|
type="button"
|
||||||
|
size="xs"
|
||||||
|
onClick={() => void documentsQuery.refetch()}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</Anchor>
|
||||||
|
</Group>
|
||||||
|
) : poaDocuments.length === 0 ? (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
No delegation letter uploaded.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
poaDocuments.map((doc) => (
|
||||||
|
<Group key={doc.id} justify="space-between" wrap="nowrap">
|
||||||
|
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||||
|
<Paperclip
|
||||||
|
size={14}
|
||||||
|
className="shrink-0 text-edr-muted"
|
||||||
|
/>
|
||||||
|
<Anchor
|
||||||
|
component="button"
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
lineClamp={1}
|
||||||
|
onClick={() =>
|
||||||
|
view({
|
||||||
|
name: doc.name,
|
||||||
|
url: fileViewUrl(doc.id),
|
||||||
|
mimeType: doc.mimeType,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{doc.name}
|
||||||
|
</Anchor>
|
||||||
|
<Text size="xs" c="dimmed" className="shrink-0">
|
||||||
|
{formatBytes(doc.size)} ·{" "}
|
||||||
|
{formatDate(doc.uploadedAt)}
|
||||||
|
</Text>
|
||||||
|
{doc.code === POA_DELEGATION_PENDING_CODE && (
|
||||||
|
<Badge
|
||||||
|
size="xs"
|
||||||
|
color="yellow"
|
||||||
|
variant="light"
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
Pending approval
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
<Group gap={4} wrap="nowrap">
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
aria-label={`Preview ${doc.name}`}
|
||||||
|
onClick={() =>
|
||||||
|
view({
|
||||||
|
name: doc.name,
|
||||||
|
url: fileViewUrl(doc.id),
|
||||||
|
mimeType: doc.mimeType,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Eye size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
<ActionIcon
|
||||||
|
component="a"
|
||||||
|
href={fileViewUrl(doc.id, true)}
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
aria-label={`Download ${doc.name}`}
|
||||||
|
>
|
||||||
|
<Download size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
|
|||||||
@@ -101,7 +101,6 @@ import type {
|
|||||||
InitiateWarehouseInvoicePaymentPayload,
|
InitiateWarehouseInvoicePaymentPayload,
|
||||||
LoadableWagon,
|
LoadableWagon,
|
||||||
LoadInventoryPayload,
|
LoadInventoryPayload,
|
||||||
LoadPassedExportResult,
|
|
||||||
MoveInventoryPayload,
|
MoveInventoryPayload,
|
||||||
StoreInventoryPayload,
|
StoreInventoryPayload,
|
||||||
PayInvoicePayload,
|
PayInvoicePayload,
|
||||||
@@ -1158,14 +1157,6 @@ export const api = {
|
|||||||
() => INVENTORY_INVALIDATIONS,
|
() => INVENTORY_INVALIDATIONS,
|
||||||
),
|
),
|
||||||
|
|
||||||
loadPassedExport: endpoint<void, LoadPassedExportResult>(
|
|
||||||
"warehouse-inventory",
|
|
||||||
"load-passed-export",
|
|
||||||
() => warehouseService.loadPassedExport().then((r) => r.data),
|
|
||||||
undefined,
|
|
||||||
() => INVENTORY_INVALIDATIONS,
|
|
||||||
),
|
|
||||||
|
|
||||||
bulkMarkInspected: endpoint<BulkInspectPayload, BulkInspectResult>(
|
bulkMarkInspected: endpoint<BulkInspectPayload, BulkInspectResult>(
|
||||||
"warehouse-inventory",
|
"warehouse-inventory",
|
||||||
"bulk-mark-inspected",
|
"bulk-mark-inspected",
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ function mapCompany(dto: Record<string, unknown>): Company {
|
|||||||
generalManagerName: (attrs.generalManagerName as string | null) ?? null,
|
generalManagerName: (attrs.generalManagerName as string | null) ?? null,
|
||||||
generalManagerEmail: (attrs.generalManagerEmail as string | null) ?? null,
|
generalManagerEmail: (attrs.generalManagerEmail as string | null) ?? null,
|
||||||
generalManagerPhone: (attrs.generalManagerPhone as string | null) ?? null,
|
generalManagerPhone: (attrs.generalManagerPhone as string | null) ?? null,
|
||||||
|
poaName: (attrs.poaName as string | null) ?? null,
|
||||||
|
poaEmail: (attrs.poaEmail as string | null) ?? null,
|
||||||
|
poaPhone: (attrs.poaPhone as string | null) ?? null,
|
||||||
|
poaLocation: (attrs.poaLocation as string | null) ?? null,
|
||||||
|
poaAddress: (attrs.poaAddress as string | null) ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,8 +43,18 @@ export interface SaveRoutePayload {
|
|||||||
* Human-readable route label: yard names, not yard codes. Staff read
|
* Human-readable route label: yard names, not yard codes. Staff read
|
||||||
* "Addis Ababa → Dire Dawa", not "ADDIS_ABABA → DIRE_DAWA". Falls back to the
|
* "Addis Ababa → Dire Dawa", not "ADDIS_ABABA → DIRE_DAWA". Falls back to the
|
||||||
* code only when a yard has no label.
|
* code only when a yard has no label.
|
||||||
|
*
|
||||||
|
* When milestones are present they ARE the full ordered corridor (origin first,
|
||||||
|
* destination last), so the label shows every stop:
|
||||||
|
* "Addis Ababa → Adama → Dire Dawa".
|
||||||
*/
|
*/
|
||||||
export function formatRouteLabel(route: RouteRecord): string {
|
export function formatRouteLabel(route: RouteRecord): string {
|
||||||
|
const stops = [...(route.milestones ?? [])]
|
||||||
|
.sort((a, b) => a.sequenceNo - b.sequenceNo)
|
||||||
|
.map((m) => m.yard?.label ?? m.yard?.code)
|
||||||
|
.filter((name): name is string => Boolean(name));
|
||||||
|
if (stops.length >= 2) return stops.join(' → ');
|
||||||
|
|
||||||
const origin =
|
const origin =
|
||||||
route.originYard?.label ?? route.originYard?.code ?? 'Origin';
|
route.originYard?.label ?? route.originYard?.code ?? 'Origin';
|
||||||
const dest =
|
const dest =
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ import type {
|
|||||||
EligibleBooking,
|
EligibleBooking,
|
||||||
BulkReceivePayload,
|
BulkReceivePayload,
|
||||||
BulkReceiveResult,
|
BulkReceiveResult,
|
||||||
LoadPassedExportResult,
|
|
||||||
BulkInspectPayload,
|
BulkInspectPayload,
|
||||||
BulkInspectResult,
|
BulkInspectResult,
|
||||||
ReadyToLoadRow,
|
ReadyToLoadRow,
|
||||||
@@ -300,8 +299,6 @@ export const warehouseService = {
|
|||||||
apiClient.get<EligibleBooking[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.ELIGIBLE_BOOKINGS(direction)),
|
apiClient.get<EligibleBooking[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.ELIGIBLE_BOOKINGS(direction)),
|
||||||
receiveBulk: (payload: BulkReceivePayload) =>
|
receiveBulk: (payload: BulkReceivePayload) =>
|
||||||
apiClient.post<BulkReceiveResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.RECEIVE_BULK, payload),
|
apiClient.post<BulkReceiveResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.RECEIVE_BULK, payload),
|
||||||
loadPassedExport: () =>
|
|
||||||
apiClient.post<LoadPassedExportResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.LOAD_PASSED_EXPORT, {}),
|
|
||||||
bulkMarkInspected: (payload: BulkInspectPayload) =>
|
bulkMarkInspected: (payload: BulkInspectPayload) =>
|
||||||
apiClient.post<BulkInspectResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.BULK_MARK_INSPECTED, payload),
|
apiClient.post<BulkInspectResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.BULK_MARK_INSPECTED, payload),
|
||||||
receivedExport: () =>
|
receivedExport: () =>
|
||||||
|
|||||||
@@ -201,6 +201,11 @@ export interface BookingDetail {
|
|||||||
latestChangeRequestNote?: string | null;
|
latestChangeRequestNote?: string | null;
|
||||||
nextStep?: BookingNextStep | null;
|
nextStep?: BookingNextStep | null;
|
||||||
paymentReceipt?: InAppPaymentReceipt;
|
paymentReceipt?: InAppPaymentReceipt;
|
||||||
|
/** Phased-clearance fields the ET/DJ queue rows surface (GENERAL customs bookings). */
|
||||||
|
clearanceCurrentPhase?: string | null;
|
||||||
|
roHoldReason?: string | null;
|
||||||
|
roAmendmentRequestedAt?: string | null;
|
||||||
|
preClearanceFinalizedAt?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
// customer?: BookingNamedRef & { companyName?: string };
|
// customer?: BookingNamedRef & { companyName?: string };
|
||||||
|
|||||||
@@ -79,6 +79,15 @@ export interface LicenseChangeIntent {
|
|||||||
fileName?: string;
|
fileName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A staged company-level document add/remove (e.g. the PoA delegation letter). */
|
||||||
|
export interface DocumentChangeIntent {
|
||||||
|
op: "add" | "remove";
|
||||||
|
fileId: string;
|
||||||
|
/** The live FileRecord code this op targets (the upload setting's fileKey). */
|
||||||
|
code: string;
|
||||||
|
fileName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A staged profile-edit change request. The customer's settings edits land here
|
* A staged profile-edit change request. The customer's settings edits land here
|
||||||
* (pending) until a reviewer approves (applies them) or rejects (with a note).
|
* (pending) until a reviewer approves (applies them) or rejects (with a note).
|
||||||
@@ -92,6 +101,8 @@ export interface CompanyChangeRequest {
|
|||||||
documentFileIds: string[];
|
documentFileIds: string[];
|
||||||
/** Staged business-license add/remove intents attached to this request. */
|
/** Staged business-license add/remove intents attached to this request. */
|
||||||
licenseChanges: LicenseChangeIntent[];
|
licenseChanges: LicenseChangeIntent[];
|
||||||
|
/** Staged company-document add/remove intents (e.g. the PoA letter). */
|
||||||
|
documentChanges: DocumentChangeIntent[];
|
||||||
note: string | null;
|
note: string | null;
|
||||||
submittedAt: string | null;
|
submittedAt: string | null;
|
||||||
reviewedAt: string | null;
|
reviewedAt: string | null;
|
||||||
@@ -127,6 +138,11 @@ export interface Company {
|
|||||||
generalManagerName?: string | null;
|
generalManagerName?: string | null;
|
||||||
generalManagerEmail?: string | null;
|
generalManagerEmail?: string | null;
|
||||||
generalManagerPhone?: string | null;
|
generalManagerPhone?: string | null;
|
||||||
|
poaName?: string | null;
|
||||||
|
poaEmail?: string | null;
|
||||||
|
poaPhone?: string | null;
|
||||||
|
poaLocation?: string | null;
|
||||||
|
poaAddress?: string | null;
|
||||||
website?: string | null;
|
website?: string | null;
|
||||||
attributes?: Record<string, unknown> | null;
|
attributes?: Record<string, unknown> | null;
|
||||||
companyProfiles: CompanyProfile[];
|
companyProfiles: CompanyProfile[];
|
||||||
|
|||||||
@@ -388,6 +388,8 @@ export interface EligibleBooking {
|
|||||||
customerTin: string | null;
|
customerTin: string | null;
|
||||||
customerPhone: string | null;
|
customerPhone: string | null;
|
||||||
containerNumber: string | null;
|
containerNumber: string | null;
|
||||||
|
/** Distinct seal numbers from the booking's container units, comma-joined. */
|
||||||
|
sealNumbers: string | null;
|
||||||
containerQuantity: number | null;
|
containerQuantity: number | null;
|
||||||
containerPackagingType: string | null;
|
containerPackagingType: string | null;
|
||||||
cargoDescription: string | null;
|
cargoDescription: string | null;
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ const sidebarItems: SidebarItem[] = [
|
|||||||
const App = () => {
|
const App = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { user, company, companyType, createProfileAndSwitch, isAuthenticated } =
|
const { user, company, companyType, createProfile, isAuthenticated } =
|
||||||
useAuth();
|
useAuth();
|
||||||
|
|
||||||
// Keep the server session alive while a user is logged in. Runs after
|
// Keep the server session alive while a user is logged in. Runs after
|
||||||
@@ -274,7 +274,7 @@ const App = () => {
|
|||||||
userEmail={userEmail}
|
userEmail={userEmail}
|
||||||
companyProfiles={companyProfiles}
|
companyProfiles={companyProfiles}
|
||||||
companyType={companyType}
|
companyType={companyType}
|
||||||
onCreateProfile={createProfileAndSwitch}
|
onCreateProfile={createProfile}
|
||||||
>
|
>
|
||||||
<OnboardingGate />
|
<OnboardingGate />
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
|
|||||||
@@ -344,6 +344,15 @@ export default function OnboardingWizardDialog({
|
|||||||
[finishMutation],
|
[finishMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Closing from the congratulations panel also clears the completed flag so a
|
||||||
|
// future reopen (shouldn't happen once onboarded) starts clean.
|
||||||
|
// Must stay above the `!user` early return: `user` flips to null while
|
||||||
|
// useAuth refetches, and skipping a hook on that render breaks hook order.
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
if (completed) setCompleted(false);
|
||||||
|
onClose();
|
||||||
|
}, [completed, onClose]);
|
||||||
|
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
// Any non-empty combination of importer/exporter/freight-forwarder is valid.
|
// Any non-empty combination of importer/exporter/freight-forwarder is valid.
|
||||||
@@ -365,13 +374,6 @@ export default function OnboardingWizardDialog({
|
|||||||
const stepMeta = STEP_META[activeStep];
|
const stepMeta = STEP_META[activeStep];
|
||||||
const activeIdx = WIZARD_STEPS.indexOf(activeStep);
|
const activeIdx = WIZARD_STEPS.indexOf(activeStep);
|
||||||
|
|
||||||
// Closing from the congratulations panel also clears the completed flag so a
|
|
||||||
// future reopen (shouldn't happen once onboarded) starts clean.
|
|
||||||
const handleClose = useCallback(() => {
|
|
||||||
if (completed) setCompleted(false);
|
|
||||||
onClose();
|
|
||||||
}, [completed, onClose]);
|
|
||||||
|
|
||||||
// Prefer the backend-resolved document code; fall back to the local mapping
|
// Prefer the backend-resolved document code; fall back to the local mapping
|
||||||
// only until the requirements query lands (the documents step is reached well
|
// only until the requirements query lands (the documents step is reached well
|
||||||
// after the draft — and thus the requirements — exist).
|
// after the draft — and thus the requirements — exist).
|
||||||
@@ -392,11 +394,17 @@ export default function OnboardingWizardDialog({
|
|||||||
const requiredDocsMissing = requirementDocuments.some(
|
const requiredDocsMissing = requirementDocuments.some(
|
||||||
(d) => d.isRequired && !d.uploaded,
|
(d) => d.isRequired && !d.uploaded,
|
||||||
);
|
);
|
||||||
|
// The PoA gets the same treatment: a resumed draft that predates the
|
||||||
|
// delegation-letter requirement (or a forwarder whose PoA is blank) must land
|
||||||
|
// back on the PoA step, where both the details and the letter are entered.
|
||||||
|
const poaIncomplete = requirementsQuery.data?.poa?.complete === false;
|
||||||
|
// Each unmet requirement lowers the ceiling; resume never moves forward.
|
||||||
|
let ceiling = FORM_STEPS.length - 1;
|
||||||
|
if (requiredDocsMissing)
|
||||||
|
ceiling = Math.min(ceiling, FORM_STEPS.indexOf("documents"));
|
||||||
|
if (poaIncomplete) ceiling = Math.min(ceiling, FORM_STEPS.indexOf("poa"));
|
||||||
const effectiveResumeStep: FormStep =
|
const effectiveResumeStep: FormStep =
|
||||||
requiredDocsMissing &&
|
FORM_STEPS[Math.min(Math.max(FORM_STEPS.indexOf(resumeFormStep), 0), ceiling)];
|
||||||
FORM_STEPS.indexOf(resumeFormStep) > FORM_STEPS.indexOf("documents")
|
|
||||||
? "documents"
|
|
||||||
: resumeFormStep;
|
|
||||||
|
|
||||||
const formProps = {
|
const formProps = {
|
||||||
documentSettingCode: resolvedDocumentSettingCode,
|
documentSettingCode: resolvedDocumentSettingCode,
|
||||||
|
|||||||
@@ -102,6 +102,9 @@ export const URL_CONSTANTS = {
|
|||||||
`/api/companies/company-profiles/${profileId}/license/${fileId}`,
|
`/api/companies/company-profiles/${profileId}/license/${fileId}`,
|
||||||
PROFILE_LICENSE_REPLACE: (profileId: string, fileId: string) =>
|
PROFILE_LICENSE_REPLACE: (profileId: string, fileId: string) =>
|
||||||
`/api/companies/company-profiles/${profileId}/license/${fileId}/replace`,
|
`/api/companies/company-profiles/${profileId}/license/${fileId}/replace`,
|
||||||
|
POA_DELEGATION: "/api/companies/poa-delegation",
|
||||||
|
POA_DELEGATION_FILE: (fileId: string) =>
|
||||||
|
`/api/companies/poa-delegation/${fileId}`,
|
||||||
PROFILE_CHANGE_REQUEST: "/api/companies/profile/change-request",
|
PROFILE_CHANGE_REQUEST: "/api/companies/profile/change-request",
|
||||||
PROFILE_REAPPLY: (profileId: string) =>
|
PROFILE_REAPPLY: (profileId: string) =>
|
||||||
`/api/companies/company-profiles/${profileId}/reapply`,
|
`/api/companies/company-profiles/${profileId}/reapply`,
|
||||||
|
|||||||
@@ -211,7 +211,11 @@ const useAuth = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const createProfileAndSwitch = async (
|
/**
|
||||||
|
* Add an operational role. The new role starts pending review, so the active
|
||||||
|
* mode is left untouched — the user keeps working under their approved role.
|
||||||
|
*/
|
||||||
|
const createProfile = async (
|
||||||
type: ProfileTypeValue,
|
type: ProfileTypeValue,
|
||||||
licenseFiles: File[],
|
licenseFiles: File[],
|
||||||
): Promise<Result<void>> => {
|
): Promise<Result<void>> => {
|
||||||
@@ -278,7 +282,7 @@ const useAuth = () => {
|
|||||||
onboardingCompleted,
|
onboardingCompleted,
|
||||||
onboardingStep,
|
onboardingStep,
|
||||||
switchMode,
|
switchMode,
|
||||||
createProfileAndSwitch,
|
createProfile,
|
||||||
reapplyProfile,
|
reapplyProfile,
|
||||||
login,
|
login,
|
||||||
signup,
|
signup,
|
||||||
|
|||||||
@@ -83,7 +83,11 @@ const TABS: { id: SettingsTab; label: string; icon: React.ReactNode }[] = [
|
|||||||
function ProfileHeader({ profile }: { profile: ProfileResponse }) {
|
function ProfileHeader({ profile }: { profile: ProfileResponse }) {
|
||||||
const refByType = new Map(profile.companyProfiles.map((p) => [p.type, p]));
|
const refByType = new Map(profile.companyProfiles.map((p) => [p.type, p]));
|
||||||
const roleOptions = rolesForCompanyType(profile.companyType);
|
const roleOptions = rolesForCompanyType(profile.companyType);
|
||||||
const activeRoles = roleOptions.filter((o) => refByType.has(o.type));
|
// Only an approved role is a role the company actually operates as. A pending
|
||||||
|
// one carries no reference yet, and must not read as granted.
|
||||||
|
const activeRoles = roleOptions.filter(
|
||||||
|
(o) => refByType.get(o.type)?.status === "active",
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { AlertCircle, ArrowLeft, ArrowRight } from "lucide-react";
|
import { AlertCircle, ArrowLeft, ArrowRight } from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
|
||||||
import type { AuthUser } from "@/types/auth";
|
import type { AuthUser } from "@/types/auth";
|
||||||
@@ -28,9 +28,11 @@ import RoleLicenseStep, {
|
|||||||
} from "@/components/onboarding/RoleLicenseStep";
|
} from "@/components/onboarding/RoleLicenseStep";
|
||||||
import ETradeInfo from "@/components/onboarding/ETradeInfo";
|
import ETradeInfo from "@/components/onboarding/ETradeInfo";
|
||||||
import {
|
import {
|
||||||
|
buildOnboardingSchema,
|
||||||
type CompanyStep,
|
type CompanyStep,
|
||||||
type FormData,
|
type FormData,
|
||||||
onboardingSchema,
|
hasPoaDetails,
|
||||||
|
POA_DELEGATION_FILE_KEY,
|
||||||
stepFields,
|
stepFields,
|
||||||
} from "./companyProfileForm/schema";
|
} from "./companyProfileForm/schema";
|
||||||
import {
|
import {
|
||||||
@@ -155,6 +157,12 @@ export default function CompanyProfileForm({
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// A freight forwarder signs on other companies' behalf, so its Power of
|
||||||
|
// Attorney (details + delegation letter) is mandatory rather than optional.
|
||||||
|
const requirePoa = (roleProfiles ?? []).some(
|
||||||
|
(p) => p.type === "freight_forwarder",
|
||||||
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
control,
|
control,
|
||||||
@@ -164,7 +172,7 @@ export default function CompanyProfileForm({
|
|||||||
setValue,
|
setValue,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm<FormData>({
|
} = useForm<FormData>({
|
||||||
resolver: zodResolver(onboardingSchema),
|
resolver: zodResolver(buildOnboardingSchema(requirePoa)),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
companyName: "",
|
companyName: "",
|
||||||
companyEmail: "",
|
companyEmail: "",
|
||||||
@@ -240,9 +248,8 @@ export default function CompanyProfileForm({
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
const handleETradeDataLoaded = (data: CompanyRegistrationData) => {
|
const handleETradeDataLoaded = (data: CompanyRegistrationData) => {
|
||||||
// Company name comes from the eTrade manager/owner name on the license.
|
if (data.companyName) {
|
||||||
if (data.managerName) {
|
setValue("companyName", data.companyName, { shouldValidate: true });
|
||||||
setValue("companyName", data.managerName, { shouldValidate: true });
|
|
||||||
}
|
}
|
||||||
setValue("licenceNumber", data.licenceNumber);
|
setValue("licenceNumber", data.licenceNumber);
|
||||||
setValue("statusDescription", data.statusDescription);
|
setValue("statusDescription", data.statusDescription);
|
||||||
@@ -354,7 +361,34 @@ export default function CompanyProfileForm({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasDocuments = Boolean(uploadSetting?.fields?.length);
|
// The delegation letter is seeded into the same nationality document set as
|
||||||
|
// the rest, but belongs on the PoA step next to the details it evidences —
|
||||||
|
// so it's split out here and the Documents step renders the remainder. Both
|
||||||
|
// halves share `documentFiles`, so the existing bulk upload still carries it.
|
||||||
|
const poaDocumentField = uploadSetting?.fields?.find(
|
||||||
|
(f) => f.fileKey === POA_DELEGATION_FILE_KEY,
|
||||||
|
);
|
||||||
|
const documentsSetting = useMemo(
|
||||||
|
() =>
|
||||||
|
uploadSetting
|
||||||
|
? {
|
||||||
|
...uploadSetting,
|
||||||
|
fields: uploadSetting.fields.filter(
|
||||||
|
(f) => f.fileKey !== POA_DELEGATION_FILE_KEY,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
[uploadSetting],
|
||||||
|
);
|
||||||
|
const poaDocumentSetting = useMemo(
|
||||||
|
() =>
|
||||||
|
uploadSetting && poaDocumentField
|
||||||
|
? { ...uploadSetting, fields: [poaDocumentField] }
|
||||||
|
: undefined,
|
||||||
|
[uploadSetting, poaDocumentField],
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasDocuments = Boolean(documentsSetting?.fields?.length);
|
||||||
|
|
||||||
// Hard verification for the documents step: required company-level
|
// Hard verification for the documents step: required company-level
|
||||||
// documents and a business license per operational profile must both be
|
// documents and a business license per operational profile must both be
|
||||||
@@ -368,7 +402,7 @@ export default function CompanyProfileForm({
|
|||||||
|
|
||||||
const validateRequiredDocuments = (): Record<string, string> => {
|
const validateRequiredDocuments = (): Record<string, string> => {
|
||||||
const errs: Record<string, string> = {};
|
const errs: Record<string, string> = {};
|
||||||
for (const field of uploadSetting?.fields ?? []) {
|
for (const field of documentsSetting?.fields ?? []) {
|
||||||
const min = getMinFiles(field);
|
const min = getMinFiles(field);
|
||||||
if (min <= 0) continue;
|
if (min <= 0) continue;
|
||||||
if ((uploadedDocumentKeys ?? []).includes(field.fileKey)) continue;
|
if ((uploadedDocumentKeys ?? []).includes(field.fileKey)) continue;
|
||||||
@@ -447,6 +481,20 @@ export default function CompanyProfileForm({
|
|||||||
];
|
];
|
||||||
const currentIdx = stepOrder.indexOf(step);
|
const currentIdx = stepOrder.indexOf(step);
|
||||||
|
|
||||||
|
// The delegation letter is what proves the representative was actually
|
||||||
|
// delegated, so it's required the moment a PoA exists — and unconditionally
|
||||||
|
// for a freight forwarder, whose PoA itself is mandatory. Skipped entirely
|
||||||
|
// when the document set predates the field (seeder not yet re-run).
|
||||||
|
const poaProvided = hasPoaDetails(watch());
|
||||||
|
const delegationRequired =
|
||||||
|
Boolean(poaDocumentField) && (requirePoa || poaProvided);
|
||||||
|
const delegationPresent =
|
||||||
|
(uploadedDocumentKeys ?? []).includes(POA_DELEGATION_FILE_KEY) ||
|
||||||
|
(() => {
|
||||||
|
const v = documentFiles[POA_DELEGATION_FILE_KEY];
|
||||||
|
return Array.isArray(v) ? v.length > 0 : v != null;
|
||||||
|
})();
|
||||||
|
|
||||||
/** Validate + persist the current step, returning whether we may advance. */
|
/** Validate + persist the current step, returning whether we may advance. */
|
||||||
const saveCurrentStep = async (): Promise<boolean> => {
|
const saveCurrentStep = async (): Promise<boolean> => {
|
||||||
setSaveError(null);
|
setSaveError(null);
|
||||||
@@ -498,6 +546,20 @@ export default function CompanyProfileForm({
|
|||||||
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
|
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// The PoA step also gates on a file, which lives outside the form state.
|
||||||
|
if (step === "poa" && delegationRequired && !delegationPresent) {
|
||||||
|
setDocumentFieldErrors({
|
||||||
|
[POA_DELEGATION_FILE_KEY]: "Delegation letter is required",
|
||||||
|
});
|
||||||
|
setSaveError(
|
||||||
|
requirePoa
|
||||||
|
? "Freight forwarders must provide Power of Attorney details and a delegation letter."
|
||||||
|
: "Upload the delegation letter for the Power of Attorney you entered, or clear the PoA details to skip.",
|
||||||
|
);
|
||||||
|
// Fall through to validate the text fields too, so every problem shows at once.
|
||||||
|
await trigger(stepFields.poa);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Field steps validate + save before advancing.
|
// Field steps validate + save before advancing.
|
||||||
const ok = await saveCurrentStep();
|
const ok = await saveCurrentStep();
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
@@ -743,8 +805,9 @@ export default function CompanyProfileForm({
|
|||||||
{step === "poa" && (
|
{step === "poa" && (
|
||||||
<>
|
<>
|
||||||
<Text size="sm" c="edr-muted">
|
<Text size="sm" c="edr-muted">
|
||||||
Power of Attorney details are optional. Fill them in if you have
|
{requirePoa
|
||||||
them, or skip to continue.
|
? "As a freight forwarder you act on other companies' behalf, so Power of Attorney details and a delegation letter are required."
|
||||||
|
: "Power of Attorney details are optional. Fill them in if you have them, or skip to continue. If you do enter a representative, upload the delegation letter authorising them."}
|
||||||
</Text>
|
</Text>
|
||||||
{watch("contactPersonName") && (
|
{watch("contactPersonName") && (
|
||||||
<LinkCheckboxCard
|
<LinkCheckboxCard
|
||||||
@@ -788,6 +851,19 @@ export default function CompanyProfileForm({
|
|||||||
{...register("poaAddress")}
|
{...register("poaAddress")}
|
||||||
/>
|
/>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
|
{poaDocumentSetting && (
|
||||||
|
<>
|
||||||
|
<Divider my="sm" />
|
||||||
|
<SmartFileInput
|
||||||
|
file={poaDocumentSetting}
|
||||||
|
value={documentFiles}
|
||||||
|
uploadedKeys={uploadedDocumentKeys}
|
||||||
|
errors={documentFieldErrors}
|
||||||
|
onChange={handleDocumentFilesChange}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -797,13 +873,13 @@ export default function CompanyProfileForm({
|
|||||||
<Group justify="center" py="xl">
|
<Group justify="center" py="xl">
|
||||||
<Loader size="sm" color="edr-green" />
|
<Loader size="sm" color="edr-green" />
|
||||||
</Group>
|
</Group>
|
||||||
) : !uploadSetting ? (
|
) : !documentsSetting ? (
|
||||||
<Text size="sm" c="edr-muted" ta="center" py="md">
|
<Text size="sm" c="edr-muted" ta="center" py="md">
|
||||||
No document requirements found for your account type.
|
No document requirements found for your account type.
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<SmartFileInput
|
<SmartFileInput
|
||||||
file={uploadSetting}
|
file={documentsSetting}
|
||||||
value={documentFiles}
|
value={documentFiles}
|
||||||
uploadedKeys={uploadedDocumentKeys}
|
uploadedKeys={uploadedDocumentKeys}
|
||||||
errors={documentFieldErrors}
|
errors={documentFieldErrors}
|
||||||
|
|||||||
@@ -63,12 +63,56 @@ export const onboardingSchema = z.object({
|
|||||||
.optional()
|
.optional()
|
||||||
.refine((v) => !v || isValidPhone(v), "Enter a valid phone number"),
|
.refine((v) => !v || isValidPhone(v), "Enter a valid phone number"),
|
||||||
poaAddress: z.string().optional(),
|
poaAddress: z.string().optional(),
|
||||||
poaEmail: z.string().optional(),
|
poaEmail: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.refine(
|
||||||
|
(v) => !v || z.string().email().safeParse(v).success,
|
||||||
|
"Invalid email address",
|
||||||
|
),
|
||||||
poaLocation: z.string().optional(),
|
poaLocation: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type FormData = z.infer<typeof onboardingSchema>;
|
export type FormData = z.infer<typeof onboardingSchema>;
|
||||||
|
|
||||||
|
/** fileKey of the delegation letter uploaded on the Power of Attorney step. */
|
||||||
|
export const POA_DELEGATION_FILE_KEY = "poa_delegation_letter";
|
||||||
|
|
||||||
|
export const POA_FIELDS = [
|
||||||
|
"poaName",
|
||||||
|
"poaPhone",
|
||||||
|
"poaEmail",
|
||||||
|
"poaLocation",
|
||||||
|
"poaAddress",
|
||||||
|
] as const satisfies readonly (keyof FormData)[];
|
||||||
|
|
||||||
|
/** True once the customer has entered any Power of Attorney detail. */
|
||||||
|
export const hasPoaDetails = (d: Partial<FormData>) =>
|
||||||
|
POA_FIELDS.some((f) => d[f]?.trim());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A freight forwarder acts on other companies' behalf, so its PoA is mandatory
|
||||||
|
* rather than optional. Everyone else keeps the optional PoA — but once they
|
||||||
|
* start filling it in, the identifying fields have to be complete (the
|
||||||
|
* delegation-letter upload is enforced alongside this, in CompanyProfileForm,
|
||||||
|
* since files live outside the form state).
|
||||||
|
*/
|
||||||
|
export function buildOnboardingSchema(requirePoa: boolean) {
|
||||||
|
if (!requirePoa) return onboardingSchema;
|
||||||
|
return onboardingSchema.superRefine((d, ctx) => {
|
||||||
|
const required: [keyof FormData, string][] = [
|
||||||
|
["poaName", "PoA name is required for freight forwarders"],
|
||||||
|
["poaEmail", "PoA email is required for freight forwarders"],
|
||||||
|
["poaPhone", "PoA phone is required for freight forwarders"],
|
||||||
|
];
|
||||||
|
for (const [path, message] of required) {
|
||||||
|
if (!d[path]?.trim()) {
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, path: [path], message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
|
export const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
|
||||||
company: [
|
company: [
|
||||||
"companyName",
|
"companyName",
|
||||||
@@ -102,7 +146,7 @@ export const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
|
|||||||
"contactPersonEmail",
|
"contactPersonEmail",
|
||||||
"contactPersonPhone",
|
"contactPersonPhone",
|
||||||
],
|
],
|
||||||
poa: [],
|
poa: [...POA_FIELDS],
|
||||||
documents: [],
|
documents: [],
|
||||||
additional: [],
|
additional: [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -241,11 +241,10 @@ export default function ContractDetailPage() {
|
|||||||
});
|
});
|
||||||
// Intercity contracts are never window-gated: the shipment rides a passing
|
// Intercity contracts are never window-gated: the shipment rides a passing
|
||||||
// import/export train that staff assign later, so booking is always open.
|
// import/export train that staff assign later, so booking is always open.
|
||||||
// GENERAL contracts are also not gated at creation — the booking enters the
|
// ONE_TIME and GENERAL contracts are both gated — booking is only possible
|
||||||
// per-booking clearance gate first and picks its shipment day at proceed time.
|
// while a window on the contract's lane is open.
|
||||||
const bookingWindowOpen =
|
const bookingWindowOpen =
|
||||||
contract?.tradeDirection === "DOMESTIC" ||
|
contract?.tradeDirection === "DOMESTIC" ||
|
||||||
contract?.contractKind === "GENERAL" ||
|
|
||||||
hasOpenWindow(bookingWindows);
|
hasOpenWindow(bookingWindows);
|
||||||
|
|
||||||
// Draw-down capacity per cargo line (GENERAL contracts only). The backend
|
// Draw-down capacity per cargo line (GENERAL contracts only). The backend
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ import {
|
|||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
// import { PROFILE_TYPE_LABELS } from "@/constants/profileMode";
|
|
||||||
import useAuth from "@/hooks/useAuth";
|
import useAuth from "@/hooks/useAuth";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import { ContractCustomerAction } from "@/components/customer-actions/ContractCustomerAction";
|
import { ContractCustomerAction } from "@/components/customer-actions/ContractCustomerAction";
|
||||||
|
|||||||
@@ -392,7 +392,7 @@ export default function NewContractPage({
|
|||||||
[profileStatusByType, profileTypes],
|
[profileStatusByType, profileTypes],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create-profile modal state (license upload → createProfileAndSwitch).
|
// Create-profile modal state (license upload → createProfile).
|
||||||
const [createTarget, setCreateTarget] = useState<ProfileTypeValue | null>(
|
const [createTarget, setCreateTarget] = useState<ProfileTypeValue | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
@@ -416,7 +416,7 @@ export default function NewContractPage({
|
|||||||
type: ProfileTypeValue;
|
type: ProfileTypeValue;
|
||||||
files: File[];
|
files: File[];
|
||||||
}) => {
|
}) => {
|
||||||
const res = await auth.createProfileAndSwitch(type, files);
|
const res = await auth.createProfile(type, files);
|
||||||
if (!res.success) {
|
if (!res.success) {
|
||||||
throw new Error(res.error?.message ?? "Failed to create profile");
|
throw new Error(res.error?.message ?? "Failed to create profile");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
Modal,
|
Modal,
|
||||||
Paper,
|
Paper,
|
||||||
Stack,
|
Stack,
|
||||||
|
Switch,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
Textarea,
|
Textarea,
|
||||||
@@ -32,6 +33,7 @@ import {
|
|||||||
MapPin,
|
MapPin,
|
||||||
Package,
|
Package,
|
||||||
Receipt,
|
Receipt,
|
||||||
|
Repeat,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
@@ -144,14 +146,12 @@ export default function NewShipmentPage() {
|
|||||||
|
|
||||||
// Coarse gate: if the customer deep-links here while no booking window is
|
// Coarse gate: if the customer deep-links here while no booking window is
|
||||||
// open, show the same closed-state notice as the contract page instead of the
|
// open, show the same closed-state notice as the contract page instead of the
|
||||||
// form. Still allowed the moment any window isOpenNow. Intercity contracts
|
// form. Still allowed the moment any window isOpenNow. Applies to ONE_TIME
|
||||||
// are never window-gated — the shipment rides a passing train that staff
|
// and GENERAL alike. Intercity contracts are never window-gated — the
|
||||||
// pick at finalize time, so booking is always open. GENERAL contracts are not
|
// shipment rides a passing train that staff pick at finalize time, so
|
||||||
// gated at creation either: the booking enters per-booking clearance first
|
// booking is always open.
|
||||||
// and picks its shipment day at proceed time.
|
|
||||||
if (
|
if (
|
||||||
contract.tradeDirection !== "DOMESTIC" &&
|
contract.tradeDirection !== "DOMESTIC" &&
|
||||||
contract.contractKind !== "GENERAL" &&
|
|
||||||
!hasOpenWindow(bookingWindows)
|
!hasOpenWindow(bookingWindows)
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
@@ -230,7 +230,12 @@ function NewShipmentBookingForm({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const form = useForm<ShipmentFormInputValues, any, ShipmentFormValues>({
|
const form = useForm<ShipmentFormInputValues, any, ShipmentFormValues>({
|
||||||
defaultValues: initialShipmentFormValues,
|
defaultValues: {
|
||||||
|
...initialShipmentFormValues,
|
||||||
|
// Seed the equipment-return toggle from the contract; the customer can
|
||||||
|
// still flip it per shipment.
|
||||||
|
withReturn: contract.equipmentReturn === "WITH_RETURN",
|
||||||
|
},
|
||||||
resolver: zodResolver(
|
resolver: zodResolver(
|
||||||
createShipmentFormSchema({
|
createShipmentFormSchema({
|
||||||
isContainer: contract.freightType === "CONTAINER",
|
isContainer: contract.freightType === "CONTAINER",
|
||||||
@@ -276,8 +281,10 @@ function NewShipmentBookingForm({
|
|||||||
...(values.scheduledDate
|
...(values.scheduledDate
|
||||||
? { scheduledDate: new Date(values.scheduledDate).toISOString() }
|
? { scheduledDate: new Date(values.scheduledDate).toISOString() }
|
||||||
: {}),
|
: {}),
|
||||||
|
// Equipment return is a container concern — bulk keeps the contract default.
|
||||||
...(isContainer
|
...(isContainer
|
||||||
? {
|
? {
|
||||||
|
equipmentReturn: values.withReturn ? "WITH_RETURN" : "WITHOUT_RETURN",
|
||||||
containers: values.containers
|
containers: values.containers
|
||||||
.filter((l) => Number(l.quantity) >= 1)
|
.filter((l) => Number(l.quantity) >= 1)
|
||||||
.map((l) => ({
|
.map((l) => ({
|
||||||
@@ -405,6 +412,9 @@ function NewShipmentBookingForm({
|
|||||||
<Stack gap="lg" className="mx-auto max-w-4xl">
|
<Stack gap="lg" className="mx-auto max-w-4xl">
|
||||||
<RouteStep form={form} contract={contract} routes={routes} />
|
<RouteStep form={form} contract={contract} routes={routes} />
|
||||||
<CargoStep form={form} contract={contract} />
|
<CargoStep form={form} contract={contract} />
|
||||||
|
{contract.freightType === "CONTAINER" && (
|
||||||
|
<EquipmentReturnStep form={form} />
|
||||||
|
)}
|
||||||
<ScheduleStep form={form} contract={contract} routes={routes} />
|
<ScheduleStep form={form} contract={contract} routes={routes} />
|
||||||
<NotesSection form={form} />
|
<NotesSection form={form} />
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -1187,6 +1197,78 @@ function CargoStep({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function EquipmentReturnStep({ form }: { form: ShipmentForm }) {
|
||||||
|
return (
|
||||||
|
<StepCard>
|
||||||
|
<StepHeader
|
||||||
|
icon={<Repeat size={22} />}
|
||||||
|
title="Equipment Return"
|
||||||
|
description="Choose whether the empty container(s) come back to EDR after unloading."
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="withReturn"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => {
|
||||||
|
const on = field.value ?? false;
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
withBorder
|
||||||
|
radius="md"
|
||||||
|
p="md"
|
||||||
|
style={{
|
||||||
|
borderColor: on ? "#CDEBDD" : "#E6ECF2",
|
||||||
|
background: on ? "#F6FBF8" : "white",
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "border-color 150ms ease, background 150ms ease",
|
||||||
|
}}
|
||||||
|
onClick={() => field.onChange(!on)}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" wrap="nowrap" gap="md">
|
||||||
|
<Group gap={13} wrap="nowrap" align="flex-start">
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
width: 38,
|
||||||
|
height: 38,
|
||||||
|
flexShrink: 0,
|
||||||
|
borderRadius: 11,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
background: on ? "#ECF6F1" : "#F1F4F7",
|
||||||
|
color: on ? "#0A6F4D" : "#6B7C8E",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Repeat size={18} />
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text fz={14} fw={700} c="#10202F">
|
||||||
|
With return
|
||||||
|
</Text>
|
||||||
|
<Text fz={12} c="dimmed" style={{ lineHeight: 1.4 }}>
|
||||||
|
{on
|
||||||
|
? "Container(s) returned to EDR after unloading."
|
||||||
|
: "Container(s) retained by you after delivery."}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
<Switch
|
||||||
|
size="md"
|
||||||
|
color="edr-green"
|
||||||
|
aria-label="With return"
|
||||||
|
checked={on}
|
||||||
|
onChange={(e) => field.onChange(e.currentTarget.checked)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</StepCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function NotesSection({ form }: { form: ShipmentForm }) {
|
function NotesSection({ form }: { form: ShipmentForm }) {
|
||||||
return (
|
return (
|
||||||
<StepCard>
|
<StepCard>
|
||||||
|
|||||||
@@ -61,11 +61,16 @@ export default function NewShipmentRequestPage() {
|
|||||||
|
|
||||||
const isContainer = contract.freightType === "CONTAINER";
|
const isContainer = contract.freightType === "CONTAINER";
|
||||||
const route = contract.routes?.[0];
|
const route = contract.routes?.[0];
|
||||||
|
// GENERAL customs contracts: GL schedules the shipment during clearance —
|
||||||
|
// the customer only states the quantity, never picks a date.
|
||||||
|
const hasCustoms =
|
||||||
|
contract.contractKind === "GENERAL" &&
|
||||||
|
(contract.serviceType?.includesCustoms ?? contract.customsClearingEnabled);
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
const dto: Freight.CreateBookingRequestDto = {
|
const dto: Freight.CreateBookingRequestDto = {
|
||||||
contractRouteId: route?.id,
|
contractRouteId: route?.id,
|
||||||
scheduledDate: scheduledDate || undefined,
|
scheduledDate: hasCustoms ? undefined : scheduledDate || undefined,
|
||||||
notes: notes.trim() || undefined,
|
notes: notes.trim() || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -104,19 +109,26 @@ export default function NewShipmentRequestPage() {
|
|||||||
|
|
||||||
<Paper withBorder radius="lg" p="xl" style={{ borderColor: BORDER }}>
|
<Paper withBorder radius="lg" p="xl" style={{ borderColor: BORDER }}>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<DatePickerInput
|
{!hasCustoms && (
|
||||||
label="Preferred shipment date"
|
<DatePickerInput
|
||||||
placeholder="Pick a date"
|
label="Preferred shipment date"
|
||||||
leftSection={<CalendarDays size={16} />}
|
placeholder="Pick a date"
|
||||||
minDate={new Date().toISOString().slice(0, 10)}
|
leftSection={<CalendarDays size={16} />}
|
||||||
value={scheduledDate || null}
|
minDate={new Date().toISOString().slice(0, 10)}
|
||||||
onChange={(v) => setScheduledDate(v ?? "")}
|
value={scheduledDate || null}
|
||||||
radius="md"
|
onChange={(v) => setScheduledDate(v ?? "")}
|
||||||
popoverProps={{ withinPortal: true }}
|
radius="md"
|
||||||
/>
|
popoverProps={{ withinPortal: true }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<NumberInput
|
<NumberInput
|
||||||
label={isContainer ? "Number of containers" : "Cargo weight (tons)"}
|
label={isContainer ? "Number of containers" : "Cargo weight (tons)"}
|
||||||
|
description={
|
||||||
|
hasCustoms
|
||||||
|
? "Global Logistics schedules the shipment date during customs clearance — you only state the quantity."
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
value={quantity}
|
value={quantity}
|
||||||
onChange={setQuantity}
|
onChange={setQuantity}
|
||||||
min={1}
|
min={1}
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ const containerLineSchema = z.object({
|
|||||||
const shipmentFormBase = z.object({
|
const shipmentFormBase = z.object({
|
||||||
contractRouteId: z.string().default(""),
|
contractRouteId: z.string().default(""),
|
||||||
scheduledDate: z.string().default(""),
|
scheduledDate: z.string().default(""),
|
||||||
|
// Container contracts only: return the empty container(s) to EDR after
|
||||||
|
// unloading. Seeded from the contract's equipment return; bulk ignores it.
|
||||||
|
withReturn: z.boolean().default(false),
|
||||||
containers: z.array(containerLineSchema).default([]),
|
containers: z.array(containerLineSchema).default([]),
|
||||||
cargoWeightTons: z.string().default(""),
|
cargoWeightTons: z.string().default(""),
|
||||||
itemCount: z.string().default(""),
|
itemCount: z.string().default(""),
|
||||||
@@ -210,6 +213,7 @@ export type ShipmentFormInputValues = z.input<typeof shipmentFormSchema>;
|
|||||||
export const initialShipmentFormValues: DeepPartial<ShipmentFormValues> = {
|
export const initialShipmentFormValues: DeepPartial<ShipmentFormValues> = {
|
||||||
contractRouteId: "",
|
contractRouteId: "",
|
||||||
scheduledDate: "",
|
scheduledDate: "",
|
||||||
|
withReturn: false,
|
||||||
containers: [],
|
containers: [],
|
||||||
cargoWeightTons: "",
|
cargoWeightTons: "",
|
||||||
itemCount: "",
|
itemCount: "",
|
||||||
@@ -229,6 +233,7 @@ export const shipmentStepFields: Record<
|
|||||||
"itemCount",
|
"itemCount",
|
||||||
"bulkHazardousQuantity",
|
"bulkHazardousQuantity",
|
||||||
"bulkReeferQuantity",
|
"bulkReeferQuantity",
|
||||||
|
"withReturn",
|
||||||
],
|
],
|
||||||
2: ["scheduledDate"],
|
2: ["scheduledDate"],
|
||||||
3: ["notes"],
|
3: ["notes"],
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
Title,
|
Title,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
|
import type { CompanyProfileResponse } from "@/services/companies.service";
|
||||||
import type { ProfileResponse } from "@/types/profile";
|
import type { ProfileResponse } from "@/types/profile";
|
||||||
import RoleCard from "./RoleCard";
|
import RoleCard from "./RoleCard";
|
||||||
import { rolesForCompanyType } from "./companyRoles";
|
import { rolesForCompanyType } from "./companyRoles";
|
||||||
@@ -18,6 +19,32 @@ interface CompanyRolesCardProps {
|
|||||||
profile: ProfileResponse;
|
profile: ProfileResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How an existing role reads on the card. Only an `active` role is "approved" —
|
||||||
|
* anything else is locked (a profile already exists, so it cannot be re-added)
|
||||||
|
* but must never be presented as granted.
|
||||||
|
*/
|
||||||
|
function roleStatusView(p: CompanyProfileResponse): {
|
||||||
|
note: string;
|
||||||
|
color: string;
|
||||||
|
approved: boolean;
|
||||||
|
} {
|
||||||
|
switch (p.status) {
|
||||||
|
case "active":
|
||||||
|
return { note: `Active · ${p.reference}`, color: "edr-green", approved: true };
|
||||||
|
case "pending":
|
||||||
|
return { note: "Pending review", color: "yellow.7", approved: false };
|
||||||
|
case "rejected":
|
||||||
|
return { note: "Rejected", color: "red.7", approved: false };
|
||||||
|
case "suspended":
|
||||||
|
return { note: "Suspended", color: "orange.7", approved: false };
|
||||||
|
case "blacklisted":
|
||||||
|
return { note: "Blacklisted", color: "red.7", approved: false };
|
||||||
|
default:
|
||||||
|
return { note: p.status, color: "edr-muted", approved: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
|
export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
@@ -26,17 +53,18 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
|
|||||||
[profile.companyType],
|
[profile.companyType],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Roles already persisted (active + locked), keyed by type -> reference.
|
// Roles already persisted, keyed by type. Existence locks the card; the
|
||||||
const activeByType = useMemo(() => {
|
// profile's own status decides how it is labelled.
|
||||||
const map = new Map<string, string>();
|
const profileByType = useMemo(() => {
|
||||||
for (const p of profile.companyProfiles) map.set(p.type, p.reference);
|
const map = new Map<string, CompanyProfileResponse>();
|
||||||
|
for (const p of profile.companyProfiles) map.set(p.type, p);
|
||||||
return map;
|
return map;
|
||||||
}, [profile.companyProfiles]);
|
}, [profile.companyProfiles]);
|
||||||
|
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
const toggle = (type: string) => {
|
const toggle = (type: string) => {
|
||||||
if (activeByType.has(type)) return; // add-only: active roles are locked
|
if (profileByType.has(type)) return; // add-only: existing roles are locked
|
||||||
setSelected((prev) => {
|
setSelected((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
if (next.has(type)) next.delete(type);
|
if (next.has(type)) next.delete(type);
|
||||||
@@ -83,7 +111,8 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
|
|||||||
) : (
|
) : (
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||||
{options.map((opt) => {
|
{options.map((opt) => {
|
||||||
const isActive = activeByType.has(opt.type);
|
const existing = profileByType.get(opt.type);
|
||||||
|
const view = existing ? roleStatusView(existing) : undefined;
|
||||||
return (
|
return (
|
||||||
<RoleCard
|
<RoleCard
|
||||||
key={opt.type}
|
key={opt.type}
|
||||||
@@ -91,10 +120,10 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
|
|||||||
description={opt.description}
|
description={opt.description}
|
||||||
icon={opt.icon}
|
icon={opt.icon}
|
||||||
selected={selected.has(opt.type)}
|
selected={selected.has(opt.type)}
|
||||||
locked={isActive}
|
locked={Boolean(existing)}
|
||||||
lockedNote={
|
approved={view?.approved}
|
||||||
isActive ? `Active · ${activeByType.get(opt.type)}` : undefined
|
lockedNote={view?.note}
|
||||||
}
|
lockedNoteColor={view?.color}
|
||||||
onClick={() => toggle(opt.type)}
|
onClick={() => toggle(opt.type)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,10 +7,17 @@ export interface RoleCardProps {
|
|||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
/** Highlighted because the user just selected it (toggleable). */
|
/** Highlighted because the user just selected it (toggleable). */
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
/** Highlighted and non-interactive because it is already persisted. */
|
/** Non-interactive because a profile for this role already exists. */
|
||||||
locked?: boolean;
|
locked?: boolean;
|
||||||
|
/**
|
||||||
|
* Approved by a reviewer. Drives the green "granted" treatment, which a
|
||||||
|
* merely-locked role (e.g. still pending review) must not receive.
|
||||||
|
*/
|
||||||
|
approved?: boolean;
|
||||||
/** Small note under the description, e.g. "Active · IM-00001". */
|
/** Small note under the description, e.g. "Active · IM-00001". */
|
||||||
lockedNote?: string;
|
lockedNote?: string;
|
||||||
|
/** Mantine color for {@link lockedNote}; matches the role's status. */
|
||||||
|
lockedNoteColor?: string;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,10 +32,12 @@ export default function RoleCard({
|
|||||||
icon,
|
icon,
|
||||||
selected = false,
|
selected = false,
|
||||||
locked = false,
|
locked = false,
|
||||||
|
approved = false,
|
||||||
lockedNote,
|
lockedNote,
|
||||||
|
lockedNoteColor = "edr-green",
|
||||||
onClick,
|
onClick,
|
||||||
}: RoleCardProps) {
|
}: RoleCardProps) {
|
||||||
const highlighted = selected || locked;
|
const highlighted = selected || approved;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UnstyledButton
|
<UnstyledButton
|
||||||
@@ -37,8 +46,12 @@ export default function RoleCard({
|
|||||||
className={`group block rounded-lg border! p-5! text-left transition-all duration-200 ${
|
className={`group block rounded-lg border! p-5! text-left transition-all duration-200 ${
|
||||||
highlighted
|
highlighted
|
||||||
? "border-[var(--mantine-color-edr-green-5)]! bg-edr-soft!"
|
? "border-[var(--mantine-color-edr-green-5)]! bg-edr-soft!"
|
||||||
: "border-edr-border! bg-edr-card! hover:-translate-y-0.5 hover:border-[var(--mantine-color-edr-green-5)]! hover:bg-edr-soft"
|
: "border-edr-border! bg-edr-card!"
|
||||||
} ${locked ? "cursor-default" : ""}`}
|
} ${
|
||||||
|
locked
|
||||||
|
? "cursor-default"
|
||||||
|
: "hover:-translate-y-0.5 hover:border-[var(--mantine-color-edr-green-5)]! hover:bg-edr-soft"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<Group gap="md" wrap="nowrap" align="start">
|
<Group gap="md" wrap="nowrap" align="start">
|
||||||
<ThemeIcon
|
<ThemeIcon
|
||||||
@@ -58,7 +71,7 @@ export default function RoleCard({
|
|||||||
{description}
|
{description}
|
||||||
</Text>
|
</Text>
|
||||||
{lockedNote && (
|
{lockedNote && (
|
||||||
<Text size="xs" c="edr-green" mt={6} fw={600}>
|
<Text size="xs" c={lockedNoteColor} mt={6} fw={600}>
|
||||||
{lockedNote}
|
{lockedNote}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -61,6 +61,13 @@ function documentSettingCode(nationality: string | null | undefined): string {
|
|||||||
: "company_onboarding_documents_ethiopian";
|
: "company_onboarding_documents_ethiopian";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The delegation letter ships in the same nationality document set, but it is
|
||||||
|
* edited on the Power of Attorney tab (where it is staged for review alongside
|
||||||
|
* the PoA details), so it is excluded from this tab's uploader.
|
||||||
|
*/
|
||||||
|
const POA_DELEGATION_FILE_KEY = "poa_delegation_letter";
|
||||||
|
|
||||||
export default function TabDocuments({
|
export default function TabDocuments({
|
||||||
profile,
|
profile,
|
||||||
mode = "edit",
|
mode = "edit",
|
||||||
@@ -78,6 +85,17 @@ export default function TabDocuments({
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const docSetting = useMemo(() => {
|
||||||
|
const setting = docSettingQuery.data;
|
||||||
|
if (!setting) return setting;
|
||||||
|
return {
|
||||||
|
...setting,
|
||||||
|
fields: setting.fields.filter(
|
||||||
|
(f) => f.fileKey !== POA_DELEGATION_FILE_KEY,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}, [docSettingQuery.data]);
|
||||||
|
|
||||||
const docsQuery = useQuery(
|
const docsQuery = useQuery(
|
||||||
api.companies.documents.queryOptions({
|
api.companies.documents.queryOptions({
|
||||||
input: { companyId: profile.companyId },
|
input: { companyId: profile.companyId },
|
||||||
@@ -139,7 +157,7 @@ export default function TabDocuments({
|
|||||||
|
|
||||||
const validateRequired = (): Record<string, string> => {
|
const validateRequired = (): Record<string, string> => {
|
||||||
const errs: Record<string, string> = {};
|
const errs: Record<string, string> = {};
|
||||||
for (const field of docSettingQuery.data?.fields ?? []) {
|
for (const field of docSetting?.fields ?? []) {
|
||||||
const min = getMinFiles(field);
|
const min = getMinFiles(field);
|
||||||
if (min <= 0) continue;
|
if (min <= 0) continue;
|
||||||
if (uploadedKeys.includes(field.fileKey)) continue;
|
if (uploadedKeys.includes(field.fileKey)) continue;
|
||||||
@@ -169,13 +187,13 @@ export default function TabDocuments({
|
|||||||
<Center py="xl">
|
<Center py="xl">
|
||||||
<Loader2 size={24} className="animate-spin" />
|
<Loader2 size={24} className="animate-spin" />
|
||||||
</Center>
|
</Center>
|
||||||
) : !docSettingQuery.data ? (
|
) : !docSetting ? (
|
||||||
<Text c="edr-muted" size="sm" ta="center" py="md">
|
<Text c="edr-muted" size="sm" ta="center" py="md">
|
||||||
No document requirements configured for your account.
|
No document requirements configured for your account.
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<SmartFileInput
|
<SmartFileInput
|
||||||
file={docSettingQuery.data}
|
file={docSetting}
|
||||||
value={documentFiles}
|
value={documentFiles}
|
||||||
onChange={handleFilesChange}
|
onChange={handleFilesChange}
|
||||||
errors={fieldErrors}
|
errors={fieldErrors}
|
||||||
@@ -185,7 +203,7 @@ export default function TabDocuments({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{docSettingQuery.data && (
|
{docSetting && (
|
||||||
<Group
|
<Group
|
||||||
justify="space-between"
|
justify="space-between"
|
||||||
mt="lg"
|
mt="lg"
|
||||||
@@ -367,7 +385,8 @@ function ProfileLicenseRow({
|
|||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Group justify="space-between" align="center">
|
<Group justify="space-between" align="center">
|
||||||
<Text size="sm" fw={700} c="edr-text">
|
<Text size="sm" fw={700} c="edr-text">
|
||||||
{ROLE_LABELS[profile.type] ?? profile.type} · {profile.reference}
|
{ROLE_LABELS[profile.type] ?? profile.type}
|
||||||
|
{profile.reference ? ` · ${profile.reference}` : ""}
|
||||||
</Text>
|
</Text>
|
||||||
<Button
|
<Button
|
||||||
variant="light"
|
variant="light"
|
||||||
|
|||||||
@@ -1,20 +1,44 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo, useRef, useState } from "react";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { CheckCircle2, Save, UserCheck, XCircle } from "lucide-react";
|
|
||||||
import {
|
import {
|
||||||
|
CheckCircle2,
|
||||||
|
Clock,
|
||||||
|
FileText,
|
||||||
|
RefreshCw,
|
||||||
|
Save,
|
||||||
|
Trash2,
|
||||||
|
Undo2,
|
||||||
|
UploadCloud,
|
||||||
|
UserCheck,
|
||||||
|
XCircle,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Alert,
|
||||||
|
Anchor,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Group,
|
Group,
|
||||||
|
Loader,
|
||||||
Stack,
|
Stack,
|
||||||
Title,
|
Title,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
Button,
|
Tooltip,
|
||||||
Grid,
|
Grid,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
|
import { useFileViewer, type ViewableFile } from "@edr/ui-common";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
|
import { fileViewUrl } from "@/constants/apiConfig";
|
||||||
|
import {
|
||||||
|
companiesService,
|
||||||
|
type LicenseFile,
|
||||||
|
type LicenseFileStatus,
|
||||||
|
} from "@/services/companies.service";
|
||||||
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
|
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
|
||||||
import type { ProfileResponse } from "@/types/profile";
|
import type { ProfileResponse } from "@/types/profile";
|
||||||
|
|
||||||
@@ -31,6 +55,32 @@ const schema = z.object({
|
|||||||
|
|
||||||
type FormData = z.infer<typeof schema>;
|
type FormData = z.infer<typeof schema>;
|
||||||
|
|
||||||
|
const LETTER_ACCEPT = ".pdf,.png,.jpg,.jpeg";
|
||||||
|
|
||||||
|
const STATUS_BADGE: Record<
|
||||||
|
LicenseFileStatus,
|
||||||
|
{ label: string; bg: string; fg: string } | null
|
||||||
|
> = {
|
||||||
|
live: null,
|
||||||
|
pending_add: {
|
||||||
|
label: "Pending approval",
|
||||||
|
bg: "var(--mantine-color-edr-amber-soft-0)",
|
||||||
|
fg: "var(--mantine-color-edr-amber-text-0)",
|
||||||
|
},
|
||||||
|
pending_remove: {
|
||||||
|
label: "Removal pending",
|
||||||
|
bg: "var(--mantine-color-edr-red-soft-0)",
|
||||||
|
fg: "var(--mantine-color-edr-red-0)",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (!bytes) return "";
|
||||||
|
const units = ["B", "KB", "MB", "GB"];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||||
|
return `${parseFloat((bytes / Math.pow(1024, i)).toFixed(1))} ${units[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
interface TabPowerOfAttorneyProps {
|
interface TabPowerOfAttorneyProps {
|
||||||
profile: ProfileResponse;
|
profile: ProfileResponse;
|
||||||
mode?: "edit" | "onboarding";
|
mode?: "edit" | "onboarding";
|
||||||
@@ -43,6 +93,8 @@ export default function TabPowerOfAttorney({
|
|||||||
onContinue,
|
onContinue,
|
||||||
}: TabPowerOfAttorneyProps) {
|
}: TabPowerOfAttorneyProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const { view, viewer } = useFileViewer();
|
||||||
|
const uploadInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const defaultValues = useMemo((): FormData => {
|
const defaultValues = useMemo((): FormData => {
|
||||||
return {
|
return {
|
||||||
@@ -59,22 +111,73 @@ export default function TabPowerOfAttorney({
|
|||||||
control,
|
control,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
reset,
|
reset,
|
||||||
|
watch,
|
||||||
formState: { errors, isDirty },
|
formState: { errors, isDirty },
|
||||||
} = useForm<FormData>({
|
} = useForm<FormData>({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
values: defaultValues,
|
values: defaultValues,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const letterQuery = useQuery(api.companies.poaDelegation.queryOptions({}));
|
||||||
|
const letters = useMemo(() => letterQuery.data ?? [], [letterQuery.data]);
|
||||||
|
|
||||||
|
// The letter is staged locally, not uploaded on pick. Uploading immediately
|
||||||
|
// would open a change request, which locks the whole settings page (see
|
||||||
|
// SettingsPage's `locked` fieldset) before the text fields could be saved.
|
||||||
|
// Save submits the file and the fields together, into one change request.
|
||||||
|
const [pickedFile, setPickedFile] = useState<File | null>(null);
|
||||||
|
const [removeIds, setRemoveIds] = useState<string[]>([]);
|
||||||
|
const [saveBlocked, setSaveBlocked] = useState(false);
|
||||||
|
|
||||||
|
/** Letters that will still be on file once the staged edits are applied. */
|
||||||
|
const remainingLetters = letters.filter(
|
||||||
|
(f) => f.status !== "pending_remove" && !removeIds.includes(f.id),
|
||||||
|
);
|
||||||
|
const hasLetterAfterSave = Boolean(pickedFile) || remainingLetters.length > 0;
|
||||||
|
|
||||||
|
// A freight forwarder signs on other companies' behalf, so its PoA — details
|
||||||
|
// and delegation letter both — is mandatory rather than optional.
|
||||||
|
const requirePoa = profile.companyProfiles.some(
|
||||||
|
(p) => p.type === "freight_forwarder",
|
||||||
|
);
|
||||||
|
const poaValues = watch([
|
||||||
|
"poaName",
|
||||||
|
"poaEmail",
|
||||||
|
"poaPhone",
|
||||||
|
"poaLocation",
|
||||||
|
"poaAddress",
|
||||||
|
]);
|
||||||
|
const poaProvided = poaValues.some((v) => v?.trim());
|
||||||
|
const letterRequired = requirePoa || poaProvided;
|
||||||
|
const letterMissing = letterRequired && !hasLetterAfterSave;
|
||||||
|
|
||||||
|
const fileDirty = Boolean(pickedFile) || removeIds.length > 0;
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: (data: FormData) =>
|
mutationFn: async (data: FormData) => {
|
||||||
api.companies.updateProfile.call({
|
// A fresh upload already stages the removal of every live letter, so the
|
||||||
|
// explicit removals only need applying when no replacement was picked.
|
||||||
|
if (pickedFile) {
|
||||||
|
await companiesService.uploadPoaDelegation(pickedFile);
|
||||||
|
} else {
|
||||||
|
for (const fileId of removeIds) {
|
||||||
|
await companiesService.removePoaDelegation(fileId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return api.companies.updateProfile.call({
|
||||||
poaName: data.poaName || undefined,
|
poaName: data.poaName || undefined,
|
||||||
poaPhone: data.poaPhone || undefined,
|
poaPhone: data.poaPhone || undefined,
|
||||||
poaEmail: data.poaEmail || undefined,
|
poaEmail: data.poaEmail || undefined,
|
||||||
poaLocation: data.poaLocation || undefined,
|
poaLocation: data.poaLocation || undefined,
|
||||||
poaAddress: data.poaAddress || undefined,
|
poaAddress: data.poaAddress || undefined,
|
||||||
}),
|
});
|
||||||
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
setPickedFile(null);
|
||||||
|
setRemoveIds([]);
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: api.companies.poaDelegation.queryKey(),
|
||||||
|
});
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: api.companies.getProfile.queryKey(),
|
queryKey: api.companies.getProfile.queryKey(),
|
||||||
});
|
});
|
||||||
@@ -82,112 +185,423 @@ export default function TabPowerOfAttorney({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = (data: FormData) => mutation.mutate(data);
|
const onSubmit = (data: FormData) => {
|
||||||
|
// The letter lives outside the form state, so it's gated here rather than
|
||||||
|
// in the zod resolver.
|
||||||
|
if (letterMissing) {
|
||||||
|
setSaveBlocked(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaveBlocked(false);
|
||||||
|
mutation.mutate(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetAll = () => {
|
||||||
|
reset();
|
||||||
|
setPickedFile(null);
|
||||||
|
setRemoveIds([]);
|
||||||
|
setSaveBlocked(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickFile = (file: File) => {
|
||||||
|
setSaveBlocked(false);
|
||||||
|
setPickedFile(file);
|
||||||
|
// An upload already supersedes every letter on file, so a pending explicit
|
||||||
|
// removal would be a no-op — drop it rather than mislabel the row.
|
||||||
|
setRemoveIds([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleRemove = (fileId: string) => {
|
||||||
|
setSaveBlocked(false);
|
||||||
|
setRemoveIds((prev) =>
|
||||||
|
prev.includes(fileId)
|
||||||
|
? prev.filter((id) => id !== fileId)
|
||||||
|
: [...prev, fileId],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card padding="lg">
|
<>
|
||||||
<Group gap="sm" mb="xs">
|
<Card padding="lg">
|
||||||
<UserCheck size={20} />
|
<Group gap="sm" mb="xs">
|
||||||
<Title order={3}>Power of Attorney</Title>
|
<UserCheck size={20} />
|
||||||
</Group>
|
<Title order={3}>Power of Attorney</Title>
|
||||||
<Text c="edr-muted" size="sm" mb="lg">
|
{requirePoa && (
|
||||||
Power of Attorney details are optional. Fill them in if you have an
|
<Badge size="sm" variant="light" color="blue">
|
||||||
authorized representative, or leave blank.
|
Required for freight forwarder
|
||||||
</Text>
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
<Text c="edr-muted" size="sm" mb="lg">
|
||||||
|
{requirePoa
|
||||||
|
? "As a freight forwarder you act on other companies' behalf, so a Power of Attorney and its delegation letter are required."
|
||||||
|
: "Power of Attorney details are optional. If you name a representative, upload the delegation letter authorising them."}
|
||||||
|
</Text>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<TextInput
|
<TextInput
|
||||||
label="PoA Full Name"
|
label="PoA Full Name"
|
||||||
placeholder="Authorized Representative Name"
|
placeholder="Authorized Representative Name"
|
||||||
error={errors.poaName?.message}
|
error={errors.poaName?.message}
|
||||||
{...register("poaName")}
|
{...register("poaName")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Grid>
|
<Grid>
|
||||||
<Grid.Col span={6}>
|
<Grid.Col span={6}>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="PoA Email"
|
label="PoA Email"
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="poa@company.com"
|
placeholder="poa@company.com"
|
||||||
error={errors.poaEmail?.message}
|
error={errors.poaEmail?.message}
|
||||||
{...register("poaEmail")}
|
{...register("poaEmail")}
|
||||||
/>
|
/>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
<Grid.Col span={6}>
|
<Grid.Col span={6}>
|
||||||
<ControlledPhoneField
|
<ControlledPhoneField
|
||||||
control={control}
|
control={control}
|
||||||
name="poaPhone"
|
name="poaPhone"
|
||||||
label="PoA Phone"
|
label="PoA Phone"
|
||||||
/>
|
/>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Grid>
|
<Grid>
|
||||||
<Grid.Col span={6}>
|
<Grid.Col span={6}>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="PoA Location"
|
label="PoA Location"
|
||||||
placeholder="City, Country"
|
placeholder="City, Country"
|
||||||
error={errors.poaLocation?.message}
|
error={errors.poaLocation?.message}
|
||||||
{...register("poaLocation")}
|
{...register("poaLocation")}
|
||||||
/>
|
/>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
<Grid.Col span={6}>
|
<Grid.Col span={6}>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="PoA Address"
|
label="PoA Address"
|
||||||
placeholder="Full Address"
|
placeholder="Full Address"
|
||||||
error={errors.poaAddress?.message}
|
error={errors.poaAddress?.message}
|
||||||
{...register("poaAddress")}
|
{...register("poaAddress")}
|
||||||
/>
|
/>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Group
|
{/* ------------------------ Delegation letter ------------------------ */}
|
||||||
justify="space-between"
|
<Stack gap="sm" mt="xl">
|
||||||
mt="xl"
|
<Group justify="space-between" align="center">
|
||||||
pt="md"
|
<Group gap="sm">
|
||||||
style={{ borderTop: "1px solid var(--mantine-color-edr-border-0)" }}
|
<FileText size={18} />
|
||||||
>
|
<Text fw={600} c="edr-text">
|
||||||
<Group gap="xs">
|
Delegation letter
|
||||||
{mutation.isSuccess && (
|
|
||||||
<Group gap={6} c="green">
|
|
||||||
<CheckCircle2 size={16} />
|
|
||||||
<Text size="sm" fw={500}>
|
|
||||||
Saved successfully
|
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
|
||||||
{mutation.isError && (
|
|
||||||
<Group gap={6} c="red">
|
|
||||||
<XCircle size={16} />
|
|
||||||
<Text size="sm" fw={500}>
|
|
||||||
Save failed
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
<Group gap="md">
|
|
||||||
{mode === "edit" && (
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="light"
|
||||||
disabled={mutation.isPending || !isDirty}
|
size="xs"
|
||||||
onClick={() => reset()}
|
leftSection={
|
||||||
|
hasLetterAfterSave ? (
|
||||||
|
<RefreshCw size={14} />
|
||||||
|
) : (
|
||||||
|
<UploadCloud size={14} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
onClick={() => uploadInputRef.current?.click()}
|
||||||
>
|
>
|
||||||
Reset
|
{hasLetterAfterSave ? "Replace letter" : "Upload letter"}
|
||||||
</Button>
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Text c="edr-muted" size="xs">
|
||||||
|
The signed letter in which the General Manager delegates the
|
||||||
|
representative above. Submitted to EDR for review together with the
|
||||||
|
details; it takes effect once approved.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{saveBlocked && letterMissing && (
|
||||||
|
<Alert
|
||||||
|
color="red"
|
||||||
|
variant="light"
|
||||||
|
icon={<XCircle size={18} />}
|
||||||
|
>
|
||||||
|
{requirePoa
|
||||||
|
? "Upload the delegation letter before saving — it is required for freight forwarders."
|
||||||
|
: "Upload the delegation letter for the representative you named, or clear the PoA details."}
|
||||||
|
</Alert>
|
||||||
)}
|
)}
|
||||||
<Button
|
|
||||||
type="submit"
|
{letterQuery.isLoading ? (
|
||||||
leftSection={<Save size={16} />}
|
<Group justify="center" py="md">
|
||||||
loading={mutation.isPending}
|
<Loader size="sm" color="edr-green" />
|
||||||
>
|
</Group>
|
||||||
{mode === "onboarding" ? "Continue" : "Save Changes"}
|
) : letters.length === 0 && !pickedFile ? (
|
||||||
</Button>
|
<Card
|
||||||
|
padding="md"
|
||||||
|
radius="md"
|
||||||
|
style={{
|
||||||
|
borderStyle: "dashed",
|
||||||
|
backgroundColor: "var(--mantine-color-edr-bg-0)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="sm" c="edr-muted" ta="center">
|
||||||
|
No delegation letter uploaded.
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Stack gap="xs">
|
||||||
|
{letters.map((f) => (
|
||||||
|
<LetterRow
|
||||||
|
key={f.id}
|
||||||
|
file={f}
|
||||||
|
markedForRemoval={removeIds.includes(f.id)}
|
||||||
|
supersededBy={pickedFile}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
onToggleRemove={() => toggleRemove(f.id)}
|
||||||
|
onViewFile={view}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{pickedFile && (
|
||||||
|
<Card
|
||||||
|
padding="sm"
|
||||||
|
radius="md"
|
||||||
|
withBorder
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--mantine-color-edr-card-0)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group gap="sm" wrap="nowrap">
|
||||||
|
<FileText
|
||||||
|
size={18}
|
||||||
|
className="text-edr-muted"
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
<Stack gap={0} style={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<Text size="sm" fw={600} c="edr-text" lineClamp={1}>
|
||||||
|
{pickedFile.name}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="edr-muted">
|
||||||
|
{formatBytes(pickedFile.size)}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Badge
|
||||||
|
size="sm"
|
||||||
|
radius="sm"
|
||||||
|
variant="light"
|
||||||
|
color="blue"
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
Submitted on save
|
||||||
|
</Badge>
|
||||||
|
<Tooltip label="Discard" withArrow>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="red"
|
||||||
|
aria-label="Discard selected letter"
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
onClick={() => setPickedFile(null)}
|
||||||
|
>
|
||||||
|
<Trash2 size={15} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{profile.reviewStatus === "pending" && (
|
||||||
|
<Group gap={6} c="edr-amber-text">
|
||||||
|
<Clock size={13} />
|
||||||
|
<Text size="xs" fw={500}>
|
||||||
|
Awaiting EDR review — this letter takes effect once approved.
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
ref={uploadInputRef}
|
||||||
|
type="file"
|
||||||
|
accept={LETTER_ACCEPT}
|
||||||
|
style={{ display: "none" }}
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) pickFile(file);
|
||||||
|
e.target.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Group
|
||||||
|
justify="space-between"
|
||||||
|
mt="xl"
|
||||||
|
pt="md"
|
||||||
|
style={{ borderTop: "1px solid var(--mantine-color-edr-border-0)" }}
|
||||||
|
>
|
||||||
|
<Group gap="xs">
|
||||||
|
{mutation.isSuccess && (
|
||||||
|
<Group gap={6} c="green">
|
||||||
|
<CheckCircle2 size={16} />
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
Saved successfully
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
{mutation.isError && (
|
||||||
|
<Group gap={6} c="red">
|
||||||
|
<XCircle size={16} />
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
Save failed
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
<Group gap="md">
|
||||||
|
{mode === "edit" && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={mutation.isPending || (!isDirty && !fileDirty)}
|
||||||
|
onClick={resetAll}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
leftSection={<Save size={16} />}
|
||||||
|
loading={mutation.isPending}
|
||||||
|
>
|
||||||
|
{mode === "onboarding" ? "Continue" : "Save Changes"}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</form>
|
||||||
</form>
|
</Card>
|
||||||
|
|
||||||
|
{viewer}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One letter already on file. `pending_add` / `pending_remove` reflect a change
|
||||||
|
* request the backoffice hasn't ruled on yet; `markedForRemoval` and
|
||||||
|
* `supersededBy` are this session's unsaved edits.
|
||||||
|
*/
|
||||||
|
function LetterRow({
|
||||||
|
file,
|
||||||
|
markedForRemoval,
|
||||||
|
supersededBy,
|
||||||
|
disabled,
|
||||||
|
onToggleRemove,
|
||||||
|
onViewFile,
|
||||||
|
}: {
|
||||||
|
file: LicenseFile;
|
||||||
|
markedForRemoval: boolean;
|
||||||
|
supersededBy: File | null;
|
||||||
|
disabled: boolean;
|
||||||
|
onToggleRemove: () => void;
|
||||||
|
onViewFile: (file: ViewableFile) => void;
|
||||||
|
}) {
|
||||||
|
const badge = STATUS_BADGE[file.status];
|
||||||
|
const superseded = Boolean(supersededBy) && file.status !== "pending_remove";
|
||||||
|
const struck =
|
||||||
|
file.status === "pending_remove" || markedForRemoval || superseded;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
padding="sm"
|
||||||
|
radius="md"
|
||||||
|
withBorder
|
||||||
|
style={{ backgroundColor: "var(--mantine-color-edr-card-0)" }}
|
||||||
|
>
|
||||||
|
<Group gap="sm" wrap="nowrap">
|
||||||
|
<FileText
|
||||||
|
size={18}
|
||||||
|
className="text-edr-muted"
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
<Stack gap={0} style={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<Anchor
|
||||||
|
component="button"
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
fw={600}
|
||||||
|
onClick={() =>
|
||||||
|
onViewFile({
|
||||||
|
name: file.name,
|
||||||
|
url: fileViewUrl(file.id),
|
||||||
|
mimeType: file.mimeType,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
textAlign: "left",
|
||||||
|
textDecoration: struck ? "line-through" : undefined,
|
||||||
|
}}
|
||||||
|
lineClamp={1}
|
||||||
|
>
|
||||||
|
{file.name}
|
||||||
|
</Anchor>
|
||||||
|
{file.size > 0 && (
|
||||||
|
<Text size="xs" c="edr-muted">
|
||||||
|
{formatBytes(file.size)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{badge && (
|
||||||
|
<Badge
|
||||||
|
size="sm"
|
||||||
|
radius="sm"
|
||||||
|
variant="light"
|
||||||
|
leftSection={<Clock size={11} />}
|
||||||
|
style={{ backgroundColor: badge.bg, color: badge.fg, flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
{badge.label}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{superseded && !markedForRemoval && (
|
||||||
|
<Badge
|
||||||
|
size="sm"
|
||||||
|
radius="sm"
|
||||||
|
variant="light"
|
||||||
|
color="gray"
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
Replaced on save
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{markedForRemoval && (
|
||||||
|
<Badge
|
||||||
|
size="sm"
|
||||||
|
radius="sm"
|
||||||
|
variant="light"
|
||||||
|
color="red"
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
Removed on save
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{file.status !== "pending_remove" && !superseded && (
|
||||||
|
<Tooltip
|
||||||
|
label={markedForRemoval ? "Keep" : "Remove"}
|
||||||
|
withArrow
|
||||||
|
>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color={markedForRemoval ? "gray" : "red"}
|
||||||
|
aria-label={
|
||||||
|
markedForRemoval ? `Keep ${file.name}` : `Remove ${file.name}`
|
||||||
|
}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onToggleRemove}
|
||||||
|
>
|
||||||
|
{markedForRemoval ? <Undo2 size={15} /> : <Trash2 size={15} />}
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ import type {
|
|||||||
CompanyInfoResponse,
|
CompanyInfoResponse,
|
||||||
CompanyNationality,
|
CompanyNationality,
|
||||||
CompanyProfileResponse,
|
CompanyProfileResponse,
|
||||||
|
LicenseFile,
|
||||||
CreateCompanyPayload,
|
CreateCompanyPayload,
|
||||||
DashboardSummary,
|
DashboardSummary,
|
||||||
OnboardingRequirements,
|
OnboardingRequirements,
|
||||||
@@ -231,6 +232,12 @@ export const api = {
|
|||||||
({ companyId }) => companiesService.getDocuments(companyId),
|
({ companyId }) => companiesService.getDocuments(companyId),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
poaDelegation: endpoint<void, LicenseFile[]>(
|
||||||
|
"companies",
|
||||||
|
"poaDelegation",
|
||||||
|
companiesService.getPoaDelegation,
|
||||||
|
),
|
||||||
|
|
||||||
changeRequest: endpoint<void, ChangeRequestResponse | null>(
|
changeRequest: endpoint<void, ChangeRequestResponse | null>(
|
||||||
"companies",
|
"companies",
|
||||||
"changeRequest",
|
"changeRequest",
|
||||||
|
|||||||
@@ -144,6 +144,15 @@ export interface OnboardingLicenseProfile {
|
|||||||
uploaded: boolean;
|
uploaded: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Power of Attorney state — mandatory for freight forwarders, optional otherwise. */
|
||||||
|
export interface OnboardingPoaState {
|
||||||
|
required: boolean;
|
||||||
|
provided: boolean;
|
||||||
|
delegationLetterUploaded: boolean;
|
||||||
|
missingFields: { key: string; label: string }[];
|
||||||
|
complete: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Server-driven onboarding requirements. The portal renders this verbatim: the
|
* Server-driven onboarding requirements. The portal renders this verbatim: the
|
||||||
* backend decides which documents apply (by nationality) and what is still
|
* backend decides which documents apply (by nationality) and what is still
|
||||||
@@ -158,6 +167,7 @@ export interface OnboardingRequirements {
|
|||||||
};
|
};
|
||||||
documents: OnboardingDocumentField[];
|
documents: OnboardingDocumentField[];
|
||||||
licenseProfiles: OnboardingLicenseProfile[];
|
licenseProfiles: OnboardingLicenseProfile[];
|
||||||
|
poa: OnboardingPoaState;
|
||||||
progress: { completed: number; total: number };
|
progress: { completed: number; total: number };
|
||||||
isComplete: boolean;
|
isComplete: boolean;
|
||||||
onboardingCompleted: boolean;
|
onboardingCompleted: boolean;
|
||||||
@@ -402,6 +412,37 @@ export const companiesService = {
|
|||||||
return unwrap(response.data);
|
return unwrap(response.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** The PoA delegation letter on file, with its review state. */
|
||||||
|
getPoaDelegation: async (): Promise<LicenseFile[]> => {
|
||||||
|
const response = await client.get<ApiResponse<LicenseFile[]>>(
|
||||||
|
URL_CONSTANTS.COMPANIES_API.POA_DELEGATION,
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload the PoA delegation letter, replacing any existing one. On an approved
|
||||||
|
* company the upload is staged for backoffice review; during onboarding it
|
||||||
|
* goes live immediately.
|
||||||
|
*/
|
||||||
|
uploadPoaDelegation: async (file: File): Promise<LicenseFile[]> => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("poa_delegation_letter", file);
|
||||||
|
const response = await client.post<ApiResponse<LicenseFile[]>>(
|
||||||
|
URL_CONSTANTS.COMPANIES_API.POA_DELEGATION,
|
||||||
|
formData,
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Remove the PoA delegation letter (staged for review on an approved company). */
|
||||||
|
removePoaDelegation: async (fileId: string): Promise<LicenseFile[]> => {
|
||||||
|
const response = await client.delete<ApiResponse<LicenseFile[]>>(
|
||||||
|
URL_CONSTANTS.COMPANIES_API.POA_DELEGATION_FILE(fileId),
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
/** List business-license document(s) (with review state) for a company profile. */
|
/** List business-license document(s) (with review state) for a company profile. */
|
||||||
getProfileLicense: async (profileId: string): Promise<LicenseFile[]> => {
|
getProfileLicense: async (profileId: string): Promise<LicenseFile[]> => {
|
||||||
const response = await client.get<ApiResponse<LicenseFile[]>>(
|
const response = await client.get<ApiResponse<LicenseFile[]>>(
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Booking" ADD COLUMN "destinationStationId" TEXT,
|
||||||
|
ADD COLUMN "originStationId" TEXT;
|
||||||
@@ -537,6 +537,8 @@ model Booking {
|
|||||||
returnLeg2OriginStationId String?
|
returnLeg2OriginStationId String?
|
||||||
returnLeg2DestStationId String?
|
returnLeg2DestStationId String?
|
||||||
returnLeg2SeatClassId String?
|
returnLeg2SeatClassId String?
|
||||||
|
originStationId String?
|
||||||
|
destinationStationId String?
|
||||||
outboundBoardedAt DateTime?
|
outboundBoardedAt DateTime?
|
||||||
returnBoardedAt DateTime?
|
returnBoardedAt DateTime?
|
||||||
contactEmail String?
|
contactEmail String?
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsEmail, IsString, ValidateNested } from 'class-validator';
|
import { IsEmail, IsNotEmpty, IsString, ValidateNested } from 'class-validator';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
@@ -13,8 +13,13 @@ export class NameDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class RegisterDto {
|
export class RegisterDto {
|
||||||
@ApiProperty({ example: 'kelemu@email.com' })
|
// Accepts an email OR a phone number. When a passenger signs up without an email,
|
||||||
@IsEmail()
|
// the portal passes the phone number here (and as `username`) — the IAM only requires
|
||||||
|
// a non-empty string, so a phone value is a valid account identifier. Kept as
|
||||||
|
// @IsString/@IsNotEmpty (not @IsEmail) so that phone-as-email passes the ValidationPipe.
|
||||||
|
@ApiProperty({ example: 'kelemu@email.com', description: 'Email or, when the user has no email, their phone number' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
email: string;
|
email: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'kelemu.ketsela' })
|
@ApiProperty({ example: 'kelemu.ketsela' })
|
||||||
@@ -42,8 +47,12 @@ export class ResendRegistrationCodeDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class LoginDto {
|
export class LoginDto {
|
||||||
@ApiProperty({ example: 'kelemu@email.com' })
|
// Accepts an email OR a phone number in the same field. Passengers who registered
|
||||||
@IsEmail()
|
// without an email log in with their phone number, which the IAM matches. Kept as
|
||||||
|
// @IsString/@IsNotEmpty (not @IsEmail) so a phone value passes the ValidationPipe.
|
||||||
|
@ApiProperty({ example: 'kelemu@email.com', description: 'Email or phone number' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
email: string;
|
email: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'password123', format: 'password' })
|
@ApiProperty({ example: 'password123', format: 'password' })
|
||||||
|
|||||||
@@ -24,6 +24,14 @@ type IamUserRow = {
|
|||||||
verified_by: string | null;
|
verified_by: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function resolvePreferredCurrency(nationality: string | null | undefined, faydaVerified: boolean): string {
|
||||||
|
if (faydaVerified) return 'ETB';
|
||||||
|
const n = (nationality ?? '').toLowerCase();
|
||||||
|
if (n.includes('ethiopi')) return 'ETB';
|
||||||
|
if (n.includes('djibout')) return 'DJF';
|
||||||
|
return 'USD';
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PassengerAuthService {
|
export class PassengerAuthService {
|
||||||
private readonly logger = new Logger(PassengerAuthService.name);
|
private readonly logger = new Logger(PassengerAuthService.name);
|
||||||
@@ -176,8 +184,11 @@ export class PassengerAuthService {
|
|||||||
|
|
||||||
const { token, refreshToken } = iamResult as { token: string; refreshToken: string };
|
const { token, refreshToken } = iamResult as { token: string; refreshToken: string };
|
||||||
|
|
||||||
|
// `dto.email` may hold an email OR a phone number (passengers without an email log in
|
||||||
|
// with their phone). Match on either so the post-auth lookup works regardless of which
|
||||||
|
// identifier was used.
|
||||||
const iamRows = await this.dataSource.query<IamUserRow[]>(
|
const iamRows = await this.dataSource.query<IamUserRow[]>(
|
||||||
`SELECT id, email, name, phone_number, metadata FROM iam.users WHERE email = $1 LIMIT 1`,
|
`SELECT id, email, name, phone_number, metadata FROM iam.users WHERE email = $1 OR phone_number = $1 LIMIT 1`,
|
||||||
[dto.email],
|
[dto.email],
|
||||||
);
|
);
|
||||||
const iamUser = iamRows[0];
|
const iamUser = iamRows[0];
|
||||||
@@ -202,7 +213,7 @@ export class PassengerAuthService {
|
|||||||
return {
|
return {
|
||||||
token,
|
token,
|
||||||
refreshToken,
|
refreshToken,
|
||||||
user: { id: iamUser.id, iamUserId: iamUser.id, email: dto.email, passengerId: passenger.id },
|
user: { id: iamUser.id, iamUserId: iamUser.id, email: iamUser.email, passengerId: passenger.id },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,6 +261,8 @@ export class PassengerAuthService {
|
|||||||
|
|
||||||
if (!passenger) throw new Error('Passenger not found');
|
if (!passenger) throw new Error('Passenger not found');
|
||||||
const iam = iamRows[0];
|
const iam = iamRows[0];
|
||||||
|
const faydaVerified = iam?.verified_by === 'fayda';
|
||||||
|
const nationality = iam?.metadata?.nationality ?? null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
iamUserId,
|
iamUserId,
|
||||||
@@ -259,7 +272,9 @@ export class PassengerAuthService {
|
|||||||
email: iam?.email ?? null,
|
email: iam?.email ?? null,
|
||||||
phone: iam?.phone_number ?? null,
|
phone: iam?.phone_number ?? null,
|
||||||
fullName: iam?.name?.en ?? iam?.name?.am ?? null,
|
fullName: iam?.name?.en ?? iam?.name?.am ?? null,
|
||||||
faydaVerified: iam?.verified_by === 'fayda',
|
nationality,
|
||||||
|
faydaVerified,
|
||||||
|
preferredCurrency: resolvePreferredCurrency(nationality, faydaVerified),
|
||||||
createdAt: passenger.createdAt,
|
createdAt: passenger.createdAt,
|
||||||
passenger: {
|
passenger: {
|
||||||
id: passenger.id,
|
id: passenger.id,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { VerifaydaService } from '../verifayda/verifayda.service';
|
|||||||
import { CurrencyService } from '../currency/currency.service';
|
import { CurrencyService } from '../currency/currency.service';
|
||||||
import { FareEngineService } from '../fare-engine/fare-engine.service';
|
import { FareEngineService } from '../fare-engine/fare-engine.service';
|
||||||
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
|
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
|
||||||
|
import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto';
|
||||||
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
|
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
|
||||||
|
|
||||||
function generateRef(): string {
|
function generateRef(): string {
|
||||||
@@ -396,7 +397,7 @@ export class BookingsService {
|
|||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
include: {
|
include: {
|
||||||
passenger: { select: { id: true, iamUserId: true } },
|
passenger: { select: { id: true, iamUserId: true } },
|
||||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
|
||||||
paymentIntent: true,
|
paymentIntent: true,
|
||||||
seats: { include: { seat: true } },
|
seats: { include: { seat: true } },
|
||||||
package: { select: { id: true, name: true, code: true } },
|
package: { select: { id: true, name: true, code: true } },
|
||||||
@@ -453,13 +454,18 @@ export class BookingsService {
|
|||||||
adultCount: booking.adultCount,
|
adultCount: booking.adultCount,
|
||||||
childCount: booking.childCount,
|
childCount: booking.childCount,
|
||||||
createdAt: booking.createdAt,
|
createdAt: booking.createdAt,
|
||||||
|
originStationId: (booking as any).originStationId ?? null,
|
||||||
passenger: iam ? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number } : null,
|
passenger: iam ? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number } : null,
|
||||||
passengerNames: [...new Set(booking.seats.map((s: any) => s.passengerName))],
|
passengerNames: [...new Set(booking.seats.map((s: any) => s.passengerName))],
|
||||||
passengers: uniquePassengers,
|
passengers: uniquePassengers,
|
||||||
schedule: {
|
schedule: {
|
||||||
train: booking.schedule.train,
|
train: booking.schedule.train,
|
||||||
originStation: booking.schedule.originStation,
|
originStation: (booking as any).originStationId
|
||||||
destinationStation: booking.schedule.destinationStation,
|
? ((booking.schedule as any).stopTimes?.find((s: any) => s.stationId === (booking as any).originStationId)?.station ?? booking.schedule.originStation)
|
||||||
|
: booking.schedule.originStation,
|
||||||
|
destinationStation: (booking as any).destinationStationId
|
||||||
|
? ((booking.schedule as any).stopTimes?.find((s: any) => s.stationId === (booking as any).destinationStationId)?.station ?? booking.schedule.destinationStation)
|
||||||
|
: booking.schedule.destinationStation,
|
||||||
departureAt: booking.schedule.departureAt,
|
departureAt: booking.schedule.departureAt,
|
||||||
},
|
},
|
||||||
paymentIntent: booking.paymentIntent,
|
paymentIntent: booking.paymentIntent,
|
||||||
@@ -547,7 +553,7 @@ export class BookingsService {
|
|||||||
? await this.calculatePackageFare(dto.priceTierId, adultCount, childCount)
|
? await this.calculatePackageFare(dto.priceTierId, adultCount, childCount)
|
||||||
: await this.calculateFare(dto.scheduleId, dto.seatClassId, originStop, destStop, passengersData[0]?.nationality, adultCount, childCount, dto.promoCode, dto.loyaltyRedemptionPoints);
|
: await this.calculateFare(dto.scheduleId, dto.seatClassId, originStop, destStop, passengersData[0]?.nationality, adultCount, childCount, dto.promoCode, dto.loyaltyRedemptionPoints);
|
||||||
|
|
||||||
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
const displayCurrency = dto.displayCurrency || resolveCurrencyFromNationality(passengersData[0]?.nationality);
|
||||||
|
|
||||||
// Track per-seat fare. Use the client-supplied seatFareMinor when present (berth-specific
|
// Track per-seat fare. Use the client-supplied seatFareMinor when present (berth-specific
|
||||||
// pricing for Upper/Middle/Lower beds). Fall back to the fare engine's baseFareMinor.
|
// pricing for Upper/Middle/Lower beds). Fall back to the fare engine's baseFareMinor.
|
||||||
@@ -589,6 +595,8 @@ export class BookingsService {
|
|||||||
bookingRef: generateRef(),
|
bookingRef: generateRef(),
|
||||||
passengerId: dto.passengerId,
|
passengerId: dto.passengerId,
|
||||||
scheduleId: dto.scheduleId,
|
scheduleId: dto.scheduleId,
|
||||||
|
originStationId: dto.originStationId,
|
||||||
|
destinationStationId: dto.destinationStationId,
|
||||||
status: 'PENDING_PAYMENT',
|
status: 'PENDING_PAYMENT',
|
||||||
bookingType: 'ONE_WAY',
|
bookingType: 'ONE_WAY',
|
||||||
totalMinor: resolvedTotalMinor / 100,
|
totalMinor: resolvedTotalMinor / 100,
|
||||||
@@ -705,7 +713,7 @@ export class BookingsService {
|
|||||||
}
|
}
|
||||||
const taxesMinor = 0;
|
const taxesMinor = 0;
|
||||||
|
|
||||||
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
const displayCurrency = dto.displayCurrency || resolveCurrencyFromNationality(passengersData[0]?.nationality);
|
||||||
let displayTotalMinor = totalMinor;
|
let displayTotalMinor = totalMinor;
|
||||||
if (displayCurrency !== Currency.ETB) {
|
if (displayCurrency !== Currency.ETB) {
|
||||||
displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency);
|
displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency);
|
||||||
@@ -759,6 +767,8 @@ export class BookingsService {
|
|||||||
bookingRef: generateRef(),
|
bookingRef: generateRef(),
|
||||||
passengerId: dto.passengerId,
|
passengerId: dto.passengerId,
|
||||||
scheduleId: dto.scheduleId,
|
scheduleId: dto.scheduleId,
|
||||||
|
originStationId: dto.originStationId,
|
||||||
|
destinationStationId: dto.destinationStationId,
|
||||||
status: 'PENDING_PAYMENT',
|
status: 'PENDING_PAYMENT',
|
||||||
bookingType: 'ROUND_TRIP',
|
bookingType: 'ROUND_TRIP',
|
||||||
totalMinor,
|
totalMinor,
|
||||||
@@ -901,7 +911,7 @@ export class BookingsService {
|
|||||||
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
|
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
|
||||||
const taxesMinor = 0;
|
const taxesMinor = 0;
|
||||||
const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor);
|
const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor);
|
||||||
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
const displayCurrency = dto.displayCurrency || resolveCurrencyFromNationality(passengersData[0]?.nationality);
|
||||||
const displayTotalMinor = displayCurrency !== Currency.ETB
|
const displayTotalMinor = displayCurrency !== Currency.ETB
|
||||||
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
|
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
|
||||||
: totalMinor;
|
: totalMinor;
|
||||||
@@ -943,6 +953,8 @@ export class BookingsService {
|
|||||||
bookingRef: generateRef(),
|
bookingRef: generateRef(),
|
||||||
passengerId: dto.passengerId,
|
passengerId: dto.passengerId,
|
||||||
scheduleId: dto.scheduleId,
|
scheduleId: dto.scheduleId,
|
||||||
|
originStationId: dto.originStationId,
|
||||||
|
destinationStationId: dto.transitStationId,
|
||||||
status: 'PENDING_PAYMENT',
|
status: 'PENDING_PAYMENT',
|
||||||
bookingType: 'TRANSIT',
|
bookingType: 'TRANSIT',
|
||||||
totalMinor,
|
totalMinor,
|
||||||
@@ -1095,7 +1107,7 @@ export class BookingsService {
|
|||||||
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
|
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
|
||||||
const taxesMinor = 0;
|
const taxesMinor = 0;
|
||||||
const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor);
|
const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor);
|
||||||
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
const displayCurrency = dto.displayCurrency || resolveCurrencyFromNationality(nat);
|
||||||
const displayTotalMinor = displayCurrency !== Currency.ETB
|
const displayTotalMinor = displayCurrency !== Currency.ETB
|
||||||
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
|
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
|
||||||
: totalMinor;
|
: totalMinor;
|
||||||
@@ -1146,6 +1158,8 @@ export class BookingsService {
|
|||||||
bookingRef: generateRef(),
|
bookingRef: generateRef(),
|
||||||
passengerId: dto.passengerId,
|
passengerId: dto.passengerId,
|
||||||
scheduleId: dto.scheduleId,
|
scheduleId: dto.scheduleId,
|
||||||
|
originStationId: dto.originStationId,
|
||||||
|
destinationStationId: dto.leg2DestinationStationId,
|
||||||
status: 'PENDING_PAYMENT',
|
status: 'PENDING_PAYMENT',
|
||||||
bookingType: 'ROUND_TRIP_TRANSIT',
|
bookingType: 'ROUND_TRIP_TRANSIT',
|
||||||
totalMinor, adultCount, childCount, displayCurrency, displayTotalMinor,
|
totalMinor, adultCount, childCount, displayCurrency, displayTotalMinor,
|
||||||
|
|||||||
@@ -249,6 +249,8 @@ export class GuestBookingService {
|
|||||||
bookingRef: generateRef(),
|
bookingRef: generateRef(),
|
||||||
passengerId: guestPassengerId,
|
passengerId: guestPassengerId,
|
||||||
scheduleId: dto.scheduleId,
|
scheduleId: dto.scheduleId,
|
||||||
|
originStationId: dto.originStationId,
|
||||||
|
destinationStationId: dto.destinationStationId,
|
||||||
status: 'PENDING_PAYMENT',
|
status: 'PENDING_PAYMENT',
|
||||||
totalMinor: resolvedTotalMinor,
|
totalMinor: resolvedTotalMinor,
|
||||||
adultCount,
|
adultCount,
|
||||||
@@ -506,6 +508,8 @@ export class GuestBookingService {
|
|||||||
bookingRef: generateRef(),
|
bookingRef: generateRef(),
|
||||||
passengerId: guestPassengerId,
|
passengerId: guestPassengerId,
|
||||||
scheduleId: dto.scheduleId,
|
scheduleId: dto.scheduleId,
|
||||||
|
originStationId: dto.originStationId,
|
||||||
|
destinationStationId: dto.destinationStationId,
|
||||||
status: 'PENDING_PAYMENT',
|
status: 'PENDING_PAYMENT',
|
||||||
bookingType: 'ROUND_TRIP',
|
bookingType: 'ROUND_TRIP',
|
||||||
totalMinor,
|
totalMinor,
|
||||||
@@ -707,6 +711,8 @@ export class GuestBookingService {
|
|||||||
bookingRef: generateRef(),
|
bookingRef: generateRef(),
|
||||||
passengerId: guestPassengerId,
|
passengerId: guestPassengerId,
|
||||||
scheduleId: dto.scheduleId,
|
scheduleId: dto.scheduleId,
|
||||||
|
originStationId: dto.originStationId,
|
||||||
|
destinationStationId: dto.leg2DestinationStationId,
|
||||||
status: 'PENDING_PAYMENT',
|
status: 'PENDING_PAYMENT',
|
||||||
bookingType: 'TRANSIT',
|
bookingType: 'TRANSIT',
|
||||||
totalMinor,
|
totalMinor,
|
||||||
@@ -921,6 +927,8 @@ export class GuestBookingService {
|
|||||||
bookingRef: generateRef(),
|
bookingRef: generateRef(),
|
||||||
passengerId: guestPassengerId,
|
passengerId: guestPassengerId,
|
||||||
scheduleId: dto.scheduleId,
|
scheduleId: dto.scheduleId,
|
||||||
|
originStationId: dto.originStationId,
|
||||||
|
destinationStationId: dto.returnLeg2DestinationStationId,
|
||||||
status: 'PENDING_PAYMENT',
|
status: 'PENDING_PAYMENT',
|
||||||
bookingType: 'ROUND_TRIP_TRANSIT',
|
bookingType: 'ROUND_TRIP_TRANSIT',
|
||||||
totalMinor, adultCount, childCount, displayCurrency, displayTotalMinor,
|
totalMinor, adultCount, childCount, displayCurrency, displayTotalMinor,
|
||||||
|
|||||||
@@ -16,7 +16,19 @@ export class CurrenciesService {
|
|||||||
orderBy: { toCurrency: 'asc' },
|
orderBy: { toCurrency: 'asc' },
|
||||||
});
|
});
|
||||||
|
|
||||||
return rates.map(rate => ({
|
const base = {
|
||||||
|
id: 'etb-base',
|
||||||
|
code: 'ETB',
|
||||||
|
name: 'Ethiopian Birr',
|
||||||
|
symbol: 'Br',
|
||||||
|
baseCurrencyCode: 'ETB',
|
||||||
|
exchangeRate: 1,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return [base, ...rates.map(rate => ({
|
||||||
id: rate.id,
|
id: rate.id,
|
||||||
code: rate.toCurrency,
|
code: rate.toCurrency,
|
||||||
name: this.getCurrencyName(rate.toCurrency),
|
name: this.getCurrencyName(rate.toCurrency),
|
||||||
@@ -26,7 +38,7 @@ export class CurrenciesService {
|
|||||||
isActive: true,
|
isActive: true,
|
||||||
createdAt: rate.createdAt,
|
createdAt: rate.createdAt,
|
||||||
updatedAt: rate.createdAt,
|
updatedAt: rate.createdAt,
|
||||||
}));
|
}))];
|
||||||
}
|
}
|
||||||
|
|
||||||
async createCurrency(dto: CreateCurrencyDto) {
|
async createCurrency(dto: CreateCurrencyDto) {
|
||||||
|
|||||||
@@ -491,7 +491,7 @@ export class SearchService {
|
|||||||
const totalMinor = Math.max(0, fare.totalMinor - loyaltyMinor);
|
const totalMinor = Math.max(0, fare.totalMinor - loyaltyMinor);
|
||||||
|
|
||||||
const segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
|
const segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
|
||||||
const displayCurrency = dto.displayCurrency ?? (fare.billingCurrency as Currency);
|
const displayCurrency = dto.displayCurrency ?? resolveCurrencyFromNationality(dto.nationality);
|
||||||
const displayTotalMinor = displayCurrency !== Currency.ETB
|
const displayTotalMinor = displayCurrency !== Currency.ETB
|
||||||
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
|
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
|
||||||
: totalMinor;
|
: totalMinor;
|
||||||
|
|||||||
@@ -190,7 +190,10 @@ function BookingsPageContent() {
|
|||||||
render: (booking: any) => {
|
render: (booking: any) => {
|
||||||
const isRoundTrip = booking?.bookingType === 'ROUND_TRIP' || booking?.bookingType === 'ROUND_TRIP_TRANSIT';
|
const isRoundTrip = booking?.bookingType === 'ROUND_TRIP' || booking?.bookingType === 'ROUND_TRIP_TRANSIT';
|
||||||
const returnDeparture = booking?.returnSchedule?.departureAt;
|
const returnDeparture = booking?.returnSchedule?.departureAt;
|
||||||
console.log(JSON.stringify(booking.packageId));
|
const hasActualStops = booking.schedule?.originStation && booking.schedule?.destinationStation;
|
||||||
|
const isFullRoute =
|
||||||
|
!booking.originStationId &&
|
||||||
|
booking.schedule?.originStation?.id === booking.schedule?.fullOriginStationId;
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium">
|
<div className="font-medium">
|
||||||
|
|||||||
@@ -148,13 +148,6 @@ export default function ClassesPage() {
|
|||||||
<span className="font-mono text-sm">{(cls.baseFareMinor / 100).toFixed(2)} ETB</span>
|
<span className="font-mono text-sm">{(cls.baseFareMinor / 100).toFixed(2)} ETB</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'premiumMinor',
|
|
||||||
label: 'Premium',
|
|
||||||
render: (cls: any) => (
|
|
||||||
<span className="font-mono text-sm">{cls.premiumMinor ? (cls.premiumMinor / 100).toFixed(2) : '0.00'} ETB</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'insuranceFeeMinor',
|
key: 'insuranceFeeMinor',
|
||||||
label: 'Insurance',
|
label: 'Insurance',
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ export default function TariffRatesPage() {
|
|||||||
nationalityType: selectedNationalityType,
|
nationalityType: selectedNationalityType,
|
||||||
bedPosition: selectedBedPosition || null,
|
bedPosition: selectedBedPosition || null,
|
||||||
basePrice: Math.round(Number(fd.get('baseFareMinor') as string) * 100) || 0,
|
basePrice: Math.round(Number(fd.get('baseFareMinor') as string) * 100) || 0,
|
||||||
|
insuranceFeeMinor: Math.round(Number(fd.get('insuranceFeeMinor') as string) * 100) || 0,
|
||||||
isActive: fd.get('isActive') === 'true',
|
isActive: fd.get('isActive') === 'true',
|
||||||
};
|
};
|
||||||
if (editingClass) {
|
if (editingClass) {
|
||||||
@@ -136,6 +137,9 @@ export default function TariffRatesPage() {
|
|||||||
c.bedPosition?.toLowerCase().includes(s) ||
|
c.bedPosition?.toLowerCase().includes(s) ||
|
||||||
c.coachType?.name?.toLowerCase().includes(s)
|
c.coachType?.name?.toLowerCase().includes(s)
|
||||||
);
|
);
|
||||||
|
}).sort((a: any, b: any) => {
|
||||||
|
if (a.nationalityType === b.nationalityType) return 0;
|
||||||
|
return a.nationalityType === 'LOCAL' ? -1 : 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
const suggestName = () => {
|
const suggestName = () => {
|
||||||
@@ -171,12 +175,6 @@ export default function TariffRatesPage() {
|
|||||||
return <span className="text-sm">{ct ? `${ct.code} — ${ct.name}` : c.coachTypeId}</span>;
|
return <span className="text-sm">{ct ? `${ct.code} — ${ct.name}` : c.coachTypeId}</span>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'bedPosition', label: 'Bed Position',
|
|
||||||
render: (c: any) => c.bedPosition
|
|
||||||
? <span className="font-mono text-sm">{c.bedPosition}</span>
|
|
||||||
: <span className="text-muted-foreground text-xs">Standard</span>,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'name', label: 'Class Name',
|
key: 'name', label: 'Class Name',
|
||||||
render: (c: any) => <span className="font-medium">{c.name}</span>,
|
render: (c: any) => <span className="font-medium">{c.name}</span>,
|
||||||
@@ -200,6 +198,12 @@ export default function TariffRatesPage() {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'insuranceFeeMinor', label: 'Insurance Fee',
|
||||||
|
render: (c: any) => (
|
||||||
|
<span className="font-mono text-sm">{c.insuranceFeeMinor ? (c.insuranceFeeMinor / 100).toFixed(2) : '0.00'} ETB</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'isActive', label: 'Status',
|
key: 'isActive', label: 'Status',
|
||||||
render: (c: any) => (
|
render: (c: any) => (
|
||||||
@@ -241,7 +245,7 @@ export default function TariffRatesPage() {
|
|||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search by name, nationality, bed position..."
|
placeholder="Search by name, nationality, etc."
|
||||||
className="input pl-10 w-full"
|
className="input pl-10 w-full"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
@@ -395,6 +399,20 @@ export default function TariffRatesPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="label">Insurance Fee (ETB)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="insuranceFeeMinor"
|
||||||
|
className="input"
|
||||||
|
defaultValue={editingClass ? (editingClass.insuranceFeeMinor / 100).toFixed(2) : '0.00'}
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
placeholder="e.g. 25.00"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">Flat fee per passenger (e.g., travel insurance)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Status</label>
|
<label className="label">Status</label>
|
||||||
<select name="isActive" className="input" defaultValue={editingClass?.isActive !== false ? 'true' : 'false'}>
|
<select name="isActive" className="input" defaultValue={editingClass?.isActive !== false ? 'true' : 'false'}>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { gregorianToEthiopian, ethiopianToGregorian, ETHIOPIAN_MONTHS, getDaysIn
|
|||||||
const GC_MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December'];
|
const GC_MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December'];
|
||||||
|
|
||||||
const COUNTRIES = [
|
const COUNTRIES = [
|
||||||
'Afghanistan','Albania','Algeria','Andorra','Angola','Antigua and Barbuda','Argentina','Armenia','Australia','Austria',
|
'Djibouti', 'Afghanistan','Albania','Algeria','Andorra','Angola','Antigua and Barbuda','Argentina','Armenia','Australia','Austria',
|
||||||
'Azerbaijan','Bahamas','Bahrain','Bangladesh','Barbados','Belarus','Belgium','Belize','Benin','Bhutan',
|
'Azerbaijan','Bahamas','Bahrain','Bangladesh','Barbados','Belarus','Belgium','Belize','Benin','Bhutan',
|
||||||
'Bolivia','Bosnia and Herzegovina','Botswana','Brazil','Brunei','Bulgaria','Burkina Faso','Burundi','Cabo Verde','Cambodia',
|
'Bolivia','Bosnia and Herzegovina','Botswana','Brazil','Brunei','Bulgaria','Burkina Faso','Burundi','Cabo Verde','Cambodia',
|
||||||
'Cameroon','Canada','Central African Republic','Chad','Chile','China','Colombia','Comoros','Congo','Costa Rica',
|
'Cameroon','Canada','Central African Republic','Chad','Chile','China','Colombia','Comoros','Congo','Costa Rica',
|
||||||
@@ -572,12 +572,23 @@ const passengerSchema = z.object({
|
|||||||
}
|
}
|
||||||
const isNonEthiopian = data.nationality !== 'ETHIOPIAN' && data.nationality !== 'Ethiopian';
|
const isNonEthiopian = data.nationality !== 'ETHIOPIAN' && data.nationality !== 'Ethiopian';
|
||||||
if (isNonEthiopian) {
|
if (isNonEthiopian) {
|
||||||
if (!data.passportNumber || data.passportNumber.trim().length === 0) {
|
const passportNum = data.passportNumber?.trim() ?? '';
|
||||||
|
if (!passportNum) {
|
||||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Passport number is required', path: ['passportNumber'] });
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Passport number is required', path: ['passportNumber'] });
|
||||||
|
} else if (/[^A-Za-z0-9]/.test(passportNum)) {
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Passport number must not contain special characters', path: ['passportNumber'] });
|
||||||
|
} else if (passportNum.length < 6 || passportNum.length > 12) {
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Passport number must be between 6 and 12 characters', path: ['passportNumber'] });
|
||||||
}
|
}
|
||||||
if (!data.passportCountry || data.passportCountry.trim().length === 0) {
|
if (!data.passportCountry || data.passportCountry.trim().length === 0) {
|
||||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Issuing country is required', path: ['passportCountry'] });
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Issuing country is required', path: ['passportCountry'] });
|
||||||
}
|
}
|
||||||
|
if (data.passportIssueDate) {
|
||||||
|
const issue = new Date(data.passportIssueDate);
|
||||||
|
if (!isNaN(issue.getTime()) && issue > new Date()) {
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Passport issue date cannot be in the future', path: ['passportIssueDate'] });
|
||||||
|
}
|
||||||
|
}
|
||||||
if (data.passportExpiryDate) {
|
if (data.passportExpiryDate) {
|
||||||
const expiry = new Date(data.passportExpiryDate);
|
const expiry = new Date(data.passportExpiryDate);
|
||||||
if (!isNaN(expiry.getTime()) && expiry <= new Date()) {
|
if (!isNaN(expiry.getTime()) && expiry <= new Date()) {
|
||||||
@@ -662,7 +673,7 @@ export default function PassengersPage() {
|
|||||||
email: (i >= adultCount ? storedPassengers[0]?.email : stored.email) || '',
|
email: (i >= adultCount ? storedPassengers[0]?.email : stored.email) || '',
|
||||||
nationalId: stored.nationalId || '',
|
nationalId: stored.nationalId || '',
|
||||||
passportNumber: stored.passportNumber || '',
|
passportNumber: stored.passportNumber || '',
|
||||||
passportCountry: stored.passportCountry || '',
|
passportCountry: stored.passportCountry || (searchCriteria?.nationality === 'DJIBOUTIAN' ? 'Djibouti' : ''),
|
||||||
passportIssueDate: stored.passportIssueDate || '',
|
passportIssueDate: stored.passportIssueDate || '',
|
||||||
passportExpiryDate: stored.passportExpiryDate || '',
|
passportExpiryDate: stored.passportExpiryDate || '',
|
||||||
passportIssuingAuthority: stored.passportIssuingAuthority || '',
|
passportIssuingAuthority: stored.passportIssuingAuthority || '',
|
||||||
@@ -681,7 +692,7 @@ export default function PassengersPage() {
|
|||||||
email: (i >= adultCount ? storedPassengers[0]?.email : '') || '',
|
email: (i >= adultCount ? storedPassengers[0]?.email : '') || '',
|
||||||
nationalId: '',
|
nationalId: '',
|
||||||
passportNumber: '',
|
passportNumber: '',
|
||||||
passportCountry: '',
|
passportCountry: searchCriteria?.nationality === 'DJIBOUTIAN' ? 'Djibouti' : '',
|
||||||
passportIssueDate: '',
|
passportIssueDate: '',
|
||||||
passportExpiryDate: '',
|
passportExpiryDate: '',
|
||||||
passportIssuingAuthority: '',
|
passportIssuingAuthority: '',
|
||||||
@@ -835,7 +846,7 @@ export default function PassengersPage() {
|
|||||||
if (passengerData?.phone || user.phone) setValue('passengers.0.phone', passengerData?.phone || user.phone || '');
|
if (passengerData?.phone || user.phone) setValue('passengers.0.phone', passengerData?.phone || user.phone || '');
|
||||||
if (passengerData?.email || user.email) setValue('passengers.0.email', passengerData?.email || user.email || '');
|
if (passengerData?.email || user.email) setValue('passengers.0.email', passengerData?.email || user.email || '');
|
||||||
if (passengerData?.passportNumber) setValue('passengers.0.passportNumber', passengerData.passportNumber);
|
if (passengerData?.passportNumber) setValue('passengers.0.passportNumber', passengerData.passportNumber);
|
||||||
if (passengerData?.passportCountry) setValue('passengers.0.passportCountry', passengerData.passportCountry);
|
setValue('passengers.0.passportCountry', passengerData?.passportCountry || (searchCriteria?.nationality === 'DJIBOUTIAN' ? 'Djibouti' : ''));
|
||||||
if (passengerData?.passportIssueDate) setValue('passengers.0.passportIssueDate', passengerData.passportIssueDate);
|
if (passengerData?.passportIssueDate) setValue('passengers.0.passportIssueDate', passengerData.passportIssueDate);
|
||||||
if (passengerData?.passportExpiryDate) setValue('passengers.0.passportExpiryDate', passengerData.passportExpiryDate);
|
if (passengerData?.passportExpiryDate) setValue('passengers.0.passportExpiryDate', passengerData.passportExpiryDate);
|
||||||
if (passengerData?.passportIssuingAuthority) setValue('passengers.0.passportIssuingAuthority', passengerData.passportIssuingAuthority);
|
if (passengerData?.passportIssuingAuthority) setValue('passengers.0.passportIssuingAuthority', passengerData.passportIssuingAuthority);
|
||||||
@@ -1361,8 +1372,12 @@ export default function PassengersPage() {
|
|||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
{...register(`passengers.${index}.passportIssueDate`)}
|
{...register(`passengers.${index}.passportIssueDate`)}
|
||||||
className="input-field"
|
className={`input-field ${errors.passengers?.[index]?.passportIssueDate ? 'border-red-500' : ''}`}
|
||||||
|
max={new Date().toISOString().split('T')[0]}
|
||||||
/>
|
/>
|
||||||
|
{errors.passengers?.[index]?.passportIssueDate && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.passportIssueDate?.message}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { formatTime, getTimePeriod } from "@/utils/format";
|
import { formatTime, getTimePeriod } from "@/utils/format";
|
||||||
|
import { formatFare } from "@/utils/fare-utils";
|
||||||
|
import { useCurrencySymbol } from "@/lib/useCurrencies";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
export default function ResultsPage() {
|
export default function ResultsPage() {
|
||||||
@@ -35,6 +37,8 @@ export default function ResultsPage() {
|
|||||||
const [outboundScheduleData, setOutboundScheduleData] = useState<any>(
|
const [outboundScheduleData, setOutboundScheduleData] = useState<any>(
|
||||||
() => useBookingStore.getState().outboundSchedule,
|
() => useBookingStore.getState().outboundSchedule,
|
||||||
);
|
);
|
||||||
|
const [effectiveDepartureDate, setEffectiveDepartureDate] = useState<string>('');
|
||||||
|
const [effectiveReturnDate, setEffectiveReturnDate] = useState<string>('');
|
||||||
const [classModal, setClassModal] = useState<Schedule | null>(null);
|
const [classModal, setClassModal] = useState<Schedule | null>(null);
|
||||||
const [promoData, setPromoData] = useState<{
|
const [promoData, setPromoData] = useState<{
|
||||||
code: string;
|
code: string;
|
||||||
@@ -84,6 +88,17 @@ export default function ResultsPage() {
|
|||||||
promoCode: searchParams.get("promoCode") || searchCriteria?.promoCode || "",
|
promoCode: searchParams.get("promoCode") || searchCriteria?.promoCode || "",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Initialise effective dates from URL/store once searchData is stable
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchData.date && !effectiveDepartureDate) setEffectiveDepartureDate(searchData.date);
|
||||||
|
if (searchData.returnDate && !effectiveReturnDate) setEffectiveReturnDate(searchData.returnDate);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [searchData.date, searchData.returnDate]);
|
||||||
|
|
||||||
|
const nat = (searchData.nationality ?? '').toUpperCase();
|
||||||
|
const displayCurrencyCode = nat === 'DJIBOUTIAN' ? 'DJF' : nat === 'ETHIOPIAN' ? 'ETB' : 'USD';
|
||||||
|
const displayCurrencySymbol = useCurrencySymbol(displayCurrencyCode);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (searchParams.get("origin")) {
|
if (searchParams.get("origin")) {
|
||||||
setSearchCriteria({
|
setSearchCriteria({
|
||||||
@@ -249,7 +264,7 @@ export default function ResultsPage() {
|
|||||||
const minFare = coachType?.classes.length
|
const minFare = coachType?.classes.length
|
||||||
? Math.min(...coachType.classes.map((c) => c.baseFareMinor))
|
? Math.min(...coachType.classes.map((c) => c.baseFareMinor))
|
||||||
: 0;
|
: 0;
|
||||||
const fareCurrency = "ETB";
|
const fareCurrency = displayCurrencyCode;
|
||||||
|
|
||||||
const hours = Math.floor((schedule.durationMinutes || 0) / 60);
|
const hours = Math.floor((schedule.durationMinutes || 0) / 60);
|
||||||
const minutes = (schedule.durationMinutes || 0) % 60;
|
const minutes = (schedule.durationMinutes || 0) % 60;
|
||||||
@@ -281,10 +296,21 @@ export default function ResultsPage() {
|
|||||||
coachTypes: schedule.coachTypes || [],
|
coachTypes: schedule.coachTypes || [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Extract the actual date from the schedule (YYYY-MM-DD)
|
||||||
|
const scheduleDate = schedule.departureAt
|
||||||
|
? schedule.departureAt.slice(0, 10)
|
||||||
|
: null;
|
||||||
|
|
||||||
// For round trip, store outbound and advance to inbound step
|
// For round trip, store outbound and advance to inbound step
|
||||||
if (isRoundTrip && isOutbound) {
|
if (isRoundTrip && isOutbound) {
|
||||||
setOutboundScheduleData(scheduleData);
|
setOutboundScheduleData(scheduleData);
|
||||||
setOutboundSchedule(scheduleData);
|
setOutboundSchedule(scheduleData);
|
||||||
|
if (scheduleDate) {
|
||||||
|
setEffectiveDepartureDate(scheduleDate);
|
||||||
|
if (searchCriteria && scheduleDate !== searchCriteria.departureDate) {
|
||||||
|
setSearchCriteria({ ...searchCriteria, departureDate: scheduleDate });
|
||||||
|
}
|
||||||
|
}
|
||||||
setClassModal(null);
|
setClassModal(null);
|
||||||
setRoundTripStep("inbound");
|
setRoundTripStep("inbound");
|
||||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
@@ -293,6 +319,12 @@ export default function ResultsPage() {
|
|||||||
|
|
||||||
// For round trip inbound, proceed with both schedules
|
// For round trip inbound, proceed with both schedules
|
||||||
if (isRoundTrip && !isOutbound) {
|
if (isRoundTrip && !isOutbound) {
|
||||||
|
if (scheduleDate) {
|
||||||
|
setEffectiveReturnDate(scheduleDate);
|
||||||
|
if (searchCriteria && scheduleDate !== searchCriteria.returnDate) {
|
||||||
|
setSearchCriteria({ ...searchCriteria, returnDate: scheduleDate });
|
||||||
|
}
|
||||||
|
}
|
||||||
// Mirror the outbound's coachTypes (fares) onto the inbound schedule so the
|
// Mirror the outbound's coachTypes (fares) onto the inbound schedule so the
|
||||||
// return seat selection page shows the same prices as the outbound leg.
|
// return seat selection page shows the same prices as the outbound leg.
|
||||||
const inboundScheduleData = outboundScheduleData
|
const inboundScheduleData = outboundScheduleData
|
||||||
@@ -307,6 +339,12 @@ export default function ResultsPage() {
|
|||||||
setSelectedSchedule(outboundScheduleData); // Set primary as outbound
|
setSelectedSchedule(outboundScheduleData); // Set primary as outbound
|
||||||
} else {
|
} else {
|
||||||
// For one-way
|
// For one-way
|
||||||
|
if (scheduleDate) {
|
||||||
|
setEffectiveDepartureDate(scheduleDate);
|
||||||
|
if (searchCriteria && scheduleDate !== searchCriteria.departureDate) {
|
||||||
|
setSearchCriteria({ ...searchCriteria, departureDate: scheduleDate });
|
||||||
|
}
|
||||||
|
}
|
||||||
setSelectedSchedule(scheduleData);
|
setSelectedSchedule(scheduleData);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,10 +416,10 @@ export default function ResultsPage() {
|
|||||||
selectedCoachType?.id === coachType.coachTypeId;
|
selectedCoachType?.id === coachType.coachTypeId;
|
||||||
const minPrice = coachType.classes.length
|
const minPrice = coachType.classes.length
|
||||||
? Math.min(
|
? Math.min(
|
||||||
...coachType.classes.map((c: any) => c.baseFareMinor),
|
...coachType.classes.map((c: any) => c.displayAmountMinor ?? c.baseFareMinor),
|
||||||
)
|
)
|
||||||
: 0;
|
: 0;
|
||||||
const coachCurrency = "ETB";
|
const coachCurrency = displayCurrencySymbol;
|
||||||
const CoachIcon = getCoachIcon(coachType.coachTypeName);
|
const CoachIcon = getCoachIcon(coachType.coachTypeName);
|
||||||
|
|
||||||
const selectThisCoach = () =>
|
const selectThisCoach = () =>
|
||||||
@@ -468,10 +506,7 @@ export default function ResultsPage() {
|
|||||||
: "text-gray-900 dark:text-white"
|
: "text-gray-900 dark:text-white"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{(minPrice / 100).toFixed(2)}
|
{formatFare(minPrice, coachCurrency)}
|
||||||
</span>
|
|
||||||
<span className="text-sm font-semibold text-gray-600 dark:text-gray-400">
|
|
||||||
{coachCurrency}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -503,7 +538,7 @@ export default function ResultsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline gap-1">
|
<div className="flex items-baseline gap-1">
|
||||||
<span className="text-base font-bold tabular-nums text-gray-900 dark:text-white">
|
<span className="text-base font-bold tabular-nums text-gray-900 dark:text-white">
|
||||||
{(cls.baseFareMinor / 100).toFixed(2)}
|
{((cls.displayAmountMinor ?? cls.baseFareMinor) / 100).toFixed(2)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">
|
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">
|
||||||
{coachCurrency}
|
{coachCurrency}
|
||||||
@@ -581,11 +616,11 @@ export default function ResultsPage() {
|
|||||||
// Calculate lowest fare and display currency from coach types / faresByClass.
|
// Calculate lowest fare and display currency from coach types / faresByClass.
|
||||||
// Prefer displayAmountMinor (passenger's own currency) over baseFareMinor (ETB internal).
|
// Prefer displayAmountMinor (passenger's own currency) over baseFareMinor (ETB internal).
|
||||||
let lowestFare = null;
|
let lowestFare = null;
|
||||||
const displayCurrency = "ETB";
|
const displayCurrency = displayCurrencySymbol;
|
||||||
if (schedule.coachTypes?.length) {
|
if (schedule.coachTypes?.length) {
|
||||||
const allClasses = schedule.coachTypes.flatMap((ct) => ct.classes);
|
const allClasses = schedule.coachTypes.flatMap((ct) => ct.classes);
|
||||||
const allFares = allClasses
|
const allFares = allClasses
|
||||||
.map((c) => c.baseFareMinor)
|
.map((c) => c.displayAmountMinor ?? c.baseFareMinor)
|
||||||
.filter((f) => f > 0);
|
.filter((f) => f > 0);
|
||||||
lowestFare = allFares.length ? Math.min(...allFares) : null;
|
lowestFare = allFares.length ? Math.min(...allFares) : null;
|
||||||
} else if (schedule.faresByClass?.length) {
|
} else if (schedule.faresByClass?.length) {
|
||||||
@@ -715,9 +750,7 @@ export default function ResultsPage() {
|
|||||||
Starting from
|
Starting from
|
||||||
</div>
|
</div>
|
||||||
<div className="text-3xl font-bold text-primary">
|
<div className="text-3xl font-bold text-primary">
|
||||||
{lowestFare
|
{lowestFare ? formatFare(lowestFare, displayCurrency) : "N/A"}
|
||||||
? `${displayCurrency} ${(lowestFare / 100).toFixed(2)}`
|
|
||||||
: "N/A"}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1 mb-4">
|
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1 mb-4">
|
||||||
per adult
|
per adult
|
||||||
@@ -1098,8 +1131,8 @@ export default function ResultsPage() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Calendar className="w-4 h-4" />
|
<Calendar className="w-4 h-4" />
|
||||||
<span>
|
<span>
|
||||||
{searchData.date
|
{effectiveDepartureDate
|
||||||
? format(new Date(searchData.date), "EEEE, MMMM d, yyyy")
|
? format(new Date(`${effectiveDepartureDate}T00:00:00`), "EEEE, MMMM d, yyyy")
|
||||||
: "Date not specified"}
|
: "Date not specified"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1129,11 +1162,8 @@ export default function ResultsPage() {
|
|||||||
Select Outbound Journey
|
Select Outbound Journey
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
{searchData.date
|
{effectiveDepartureDate
|
||||||
? format(
|
? format(new Date(`${effectiveDepartureDate}T00:00:00`), "EEEE, MMMM d, yyyy")
|
||||||
new Date(searchData.date),
|
|
||||||
"EEEE, MMMM d, yyyy",
|
|
||||||
)
|
|
||||||
: ""}
|
: ""}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -1182,6 +1212,9 @@ export default function ResultsPage() {
|
|||||||
<p className="text-xs text-green-700 dark:text-green-400 mt-0.5">
|
<p className="text-xs text-green-700 dark:text-green-400 mt-0.5">
|
||||||
{outboundScheduleData.origin} →{" "}
|
{outboundScheduleData.origin} →{" "}
|
||||||
{outboundScheduleData.destination}
|
{outboundScheduleData.destination}
|
||||||
|
{outboundScheduleData.departureTime
|
||||||
|
? ` · ${format(new Date(outboundScheduleData.departureTime), "EEE, MMM d, yyyy")}`
|
||||||
|
: ""}
|
||||||
{outboundScheduleData.selectedSeatClassName
|
{outboundScheduleData.selectedSeatClassName
|
||||||
? ` · ${outboundScheduleData.selectedSeatClassName}`
|
? ` · ${outboundScheduleData.selectedSeatClassName}`
|
||||||
: ""}
|
: ""}
|
||||||
@@ -1213,11 +1246,8 @@ export default function ResultsPage() {
|
|||||||
Select Return Journey
|
Select Return Journey
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
{searchData.returnDate
|
{effectiveReturnDate
|
||||||
? format(
|
? format(new Date(`${effectiveReturnDate}T00:00:00`), "EEEE, MMMM d, yyyy")
|
||||||
new Date(searchData.returnDate),
|
|
||||||
"EEEE, MMMM d, yyyy",
|
|
||||||
)
|
|
||||||
: ""}
|
: ""}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { formatTime, getTimePeriod } from '@/utils/format';
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { ChevronLeft } from 'lucide-react';
|
import { ChevronLeft } from 'lucide-react';
|
||||||
import { isChild, isFirstChild, formatFare } from '@/utils/fare-utils';
|
import { isChild, isFirstChild, formatFare } from '@/utils/fare-utils';
|
||||||
|
import { useCurrencySymbol } from '@/lib/useCurrencies';
|
||||||
|
|
||||||
// Helper function to decode JWT token and extract passengerId
|
// Helper function to decode JWT token and extract passengerId
|
||||||
function getPassengerIdFromToken(token: string): string | null {
|
function getPassengerIdFromToken(token: string): string | null {
|
||||||
@@ -56,9 +57,10 @@ export default function ReviewPage() {
|
|||||||
|
|
||||||
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
|
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
|
||||||
|
|
||||||
// Prefer the currency already stored on the selected schedule (set from search results).
|
// Derive display currency from nationality so fares show in the passenger's home currency.
|
||||||
// Fall back to deriving from nationality so the review page is never left with a stale value.
|
const nat = (searchCriteria?.nationality ?? '').toUpperCase();
|
||||||
const displayCurrency = 'ETB';
|
const displayCurrencyCode = nat === 'DJIBOUTIAN' ? 'DJF' : nat === 'ETHIOPIAN' ? 'ETB' : 'USD';
|
||||||
|
const displayCurrencySymbol = useCurrencySymbol(displayCurrencyCode);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!seatHold?.expiresAt) return;
|
if (!seatHold?.expiresAt) return;
|
||||||
@@ -329,7 +331,7 @@ export default function ReviewPage() {
|
|||||||
destinationStationId: searchCriteria.destinationStationId,
|
destinationStationId: searchCriteria.destinationStationId,
|
||||||
seatClassId: seatClassId,
|
seatClassId: seatClassId,
|
||||||
bookingType: isRoundTrip ? 'ROUND_TRIP' : 'ONE_WAY',
|
bookingType: isRoundTrip ? 'ROUND_TRIP' : 'ONE_WAY',
|
||||||
displayCurrency: displayCurrency,
|
displayCurrency: displayCurrencyCode,
|
||||||
passengers: bookingPassengers.map((p) => {
|
passengers: bookingPassengers.map((p) => {
|
||||||
const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian';
|
const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian';
|
||||||
const seatId = isRoundTrip ? (p as any).outboundSeatId : p.seatId;
|
const seatId = isRoundTrip ? (p as any).outboundSeatId : p.seatId;
|
||||||
@@ -386,7 +388,7 @@ export default function ReviewPage() {
|
|||||||
destinationStationId: searchCriteria.destinationStationId,
|
destinationStationId: searchCriteria.destinationStationId,
|
||||||
seatClassId: seatClassId,
|
seatClassId: seatClassId,
|
||||||
bookingType: isRoundTrip ? 'ROUND_TRIP' : 'ONE_WAY',
|
bookingType: isRoundTrip ? 'ROUND_TRIP' : 'ONE_WAY',
|
||||||
displayCurrency: displayCurrency,
|
displayCurrency: displayCurrencyCode,
|
||||||
passengers: guestBookingPassengers.map(p => {
|
passengers: guestBookingPassengers.map(p => {
|
||||||
const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian';
|
const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian';
|
||||||
const seatId = isRoundTrip ? (p as any).outboundSeatId : p.seatId;
|
const seatId = isRoundTrip ? (p as any).outboundSeatId : p.seatId;
|
||||||
@@ -510,7 +512,7 @@ export default function ReviewPage() {
|
|||||||
originStationId,
|
originStationId,
|
||||||
destinationStationId,
|
destinationStationId,
|
||||||
passengers: passengersParam,
|
passengers: passengersParam,
|
||||||
displayCurrency,
|
displayCurrency: displayCurrencyCode,
|
||||||
...(searchCriteria?.promoCode ? { promoCode: searchCriteria.promoCode } : {}),
|
...(searchCriteria?.promoCode ? { promoCode: searchCriteria.promoCode } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -518,7 +520,7 @@ export default function ReviewPage() {
|
|||||||
setFareBreakdown(result);
|
setFareBreakdown(result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
}
|
}
|
||||||
}, [isPackageBooking, passengers, selectedSchedule, outboundSchedule, isRoundTrip, searchCriteria, displayCurrency]);
|
}, [isPackageBooking, passengers, selectedSchedule, outboundSchedule, isRoundTrip, searchCriteria, displayCurrencyCode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!searchCriteria?.originStationId || !searchCriteria?.destinationStationId) return;
|
if (!searchCriteria?.originStationId || !searchCriteria?.destinationStationId) return;
|
||||||
@@ -589,7 +591,7 @@ export default function ReviewPage() {
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
{formatFare(passengerTotal, displayCurrency)}
|
{formatFare(passengerTotal, displayCurrencySymbol)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/* Round-trip: show outbound + inbound breakdown */}
|
{/* Round-trip: show outbound + inbound breakdown */}
|
||||||
@@ -597,11 +599,11 @@ export default function ReviewPage() {
|
|||||||
<div className="mt-1 space-y-0.5 pl-2">
|
<div className="mt-1 space-y-0.5 pl-2">
|
||||||
<div className="flex justify-between text-xs text-gray-500 dark:text-gray-400">
|
<div className="flex justify-between text-xs text-gray-500 dark:text-gray-400">
|
||||||
<span>↗ Outbound</span>
|
<span>↗ Outbound</span>
|
||||||
<span>{outboundFare != null ? formatFare(outboundFare, displayCurrency) : '—'}</span>
|
<span>{outboundFare != null ? formatFare(outboundFare, displayCurrencySymbol) : '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-xs text-gray-500 dark:text-gray-400">
|
<div className="flex justify-between text-xs text-gray-500 dark:text-gray-400">
|
||||||
<span>↙ Return</span>
|
<span>↙ Return</span>
|
||||||
<span>{inboundFare != null ? formatFare(inboundFare, displayCurrency) : '—'}</span>
|
<span>{inboundFare != null ? formatFare(inboundFare, displayCurrencySymbol) : '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -610,7 +612,7 @@ export default function ReviewPage() {
|
|||||||
})}
|
})}
|
||||||
<div className="flex justify-between items-center pt-1 border-t border-gray-200 dark:border-gray-700">
|
<div className="flex justify-between items-center pt-1 border-t border-gray-200 dark:border-gray-700">
|
||||||
<span className="font-bold text-gray-900 dark:text-gray-100">Total</span>
|
<span className="font-bold text-gray-900 dark:text-gray-100">Total</span>
|
||||||
<span className="text-xl font-bold text-primary">{displayCurrency} {(total / 100).toFixed(2)}</span>
|
<span className="text-xl font-bold text-primary">{formatFare(total, displayCurrencySymbol)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action buttons — visible only in desktop sidebar */}
|
{/* Action buttons — visible only in desktop sidebar */}
|
||||||
@@ -886,49 +888,51 @@ export default function ReviewPage() {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{passengers.map((p, i) => (
|
{passengers.map((p, i) => (
|
||||||
<div key={i} className="border-b border-gray-200 dark:border-gray-700 pb-3 last:border-0">
|
<div key={i} className="border-b border-gray-200 dark:border-gray-700 pb-3 last:border-0">
|
||||||
<div className="flex justify-between items-start mb-2">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div>
|
{/* Left — passenger info */}
|
||||||
<p className="font-medium text-gray-900 dark:text-gray-100">{p.name}</p>
|
<div className="min-w-0">
|
||||||
|
<p className="font-medium text-gray-900 dark:text-gray-100 truncate">{p.name}</p>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
{p.dateOfBirth ? format(new Date(p.dateOfBirth), 'PP') : 'N/A'} • {p.nationality}
|
{p.dateOfBirth ? format(new Date(p.dateOfBirth), 'PP') : 'N/A'} • {p.nationality}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Right — seat details */}
|
||||||
|
{isRoundTrip ? (
|
||||||
|
<div className="flex gap-2 flex-shrink-0">
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg px-3 py-2 text-right">
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">Outbound</p>
|
||||||
|
<p className="font-semibold text-sm text-gray-900 dark:text-gray-100">
|
||||||
|
{(p as any).outboundCoachNumber && <span className="text-gray-500 dark:text-gray-400">{(p as any).outboundCoachNumber} — </span>}
|
||||||
|
{(p as any).outboundSeatId ? (seatDetails[`outbound-${(p as any).outboundSeatId}`] || 'Loading...') : (isPkgFreeChild(i) || (!packageId && isChild(p) && isFirstChild(passengers, i)) ? '—' : 'Auto-assign')}
|
||||||
|
</p>
|
||||||
|
{(p as any).outboundSeatId && (
|
||||||
|
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-0.5">{formatSeatClass(outboundSchedule)}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg px-3 py-2 text-right">
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">Return</p>
|
||||||
|
<p className="font-semibold text-sm text-gray-900 dark:text-gray-100">
|
||||||
|
{(p as any).inboundCoachNumber && <span className="text-gray-500 dark:text-gray-400">{(p as any).inboundCoachNumber} — </span>}
|
||||||
|
{(p as any).inboundSeatId ? (seatDetails[`inbound-${(p as any).inboundSeatId}`] || 'Loading...') : (isPkgFreeChild(i) || (!packageId && isChild(p) && isFirstChild(passengers, i)) ? '—' : 'Auto-assign')}
|
||||||
|
</p>
|
||||||
|
{(p as any).inboundSeatId && (
|
||||||
|
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-0.5">{formatSeatClass(inboundSchedule)}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg px-3 py-2 text-right flex-shrink-0">
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">Seat</p>
|
||||||
|
<p className="font-semibold text-sm text-gray-900 dark:text-gray-100">
|
||||||
|
{p.coachNumber && <span className="text-gray-500 dark:text-gray-400">{p.coachNumber} — </span>}
|
||||||
|
{p.seatId ? (seatDetails[p.seatId] || 'Loading...') : (isPkgFreeChild(i) || (!packageId && isChild(p) && isFirstChild(passengers, i)) ? '—' : 'Auto-assign')}
|
||||||
|
</p>
|
||||||
|
{p.seatId && (
|
||||||
|
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-0.5">{formatSeatClass(selectedSchedule)}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isRoundTrip ? (
|
|
||||||
<div className="grid grid-cols-2 gap-3 mt-2">
|
|
||||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-2">
|
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">Outbound Seat</p>
|
|
||||||
<p className="font-semibold text-sm text-gray-900 dark:text-gray-100">
|
|
||||||
{(p as any).outboundCoachNumber && <span className="text-gray-500 dark:text-gray-400 ml-1">{(p as any).outboundCoachNumber} — </span>}
|
|
||||||
{(p as any).outboundSeatId ? (seatDetails[`outbound-${(p as any).outboundSeatId}`] || 'Loading...') : (isPkgFreeChild(i) || (!packageId && isChild(p) && isFirstChild(passengers, i)) ? '' : 'Auto-assign')}
|
|
||||||
</p>
|
|
||||||
{(p as any).outboundSeatId && (
|
|
||||||
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-0.5">{formatSeatClass(outboundSchedule)}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-2">
|
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">Return Seat</p>
|
|
||||||
<p className="font-semibold text-sm text-gray-900 dark:text-gray-100">
|
|
||||||
{(p as any).inboundCoachNumber && <span className="text-gray-500 dark:text-gray-400 ml-1">{(p as any).inboundCoachNumber} —</span>}
|
|
||||||
{(p as any).inboundSeatId ? (seatDetails[`inbound-${(p as any).inboundSeatId}`] || 'Loading...') : (isPkgFreeChild(i) || (!packageId && isChild(p) && isFirstChild(passengers, i)) ? '' : 'Auto-assign')}
|
|
||||||
</p>
|
|
||||||
{(p as any).inboundSeatId && (
|
|
||||||
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-0.5">{formatSeatClass(inboundSchedule)}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-right">
|
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">Seat</p>
|
|
||||||
<p className="font-medium text-gray-900 dark:text-gray-100">
|
|
||||||
{p.coachNumber && <span className="text-gray-500 dark:text-gray-400 ml-1">{p.coachNumber} — </span>}
|
|
||||||
{p.seatId ? (seatDetails[p.seatId] || 'Loading...') : (isPkgFreeChild(i) || (!packageId && isChild(p) && isFirstChild(passengers, i)) ? '' : 'Auto-assign')}
|
|
||||||
</p>
|
|
||||||
{p.seatId && (
|
|
||||||
<p className="text-[10px] text-gray-400 dark:text-gray-500 mt-0.5">{formatSeatClass(selectedSchedule)}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -956,7 +960,7 @@ export default function ReviewPage() {
|
|||||||
<div className="lg:hidden fixed bottom-0 inset-x-0 bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700 px-4 py-3 z-40 shadow-lg">
|
<div className="lg:hidden fixed bottom-0 inset-x-0 bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700 px-4 py-3 z-40 shadow-lg">
|
||||||
<div className="flex items-center justify-between mb-2.5">
|
<div className="flex items-center justify-between mb-2.5">
|
||||||
<span className="text-sm text-gray-600 dark:text-gray-400">Total</span>
|
<span className="text-sm text-gray-600 dark:text-gray-400">Total</span>
|
||||||
<span className="text-lg font-bold text-primary">{displayCurrency} {(total / 100).toFixed(2)}</span>
|
<span className="text-lg font-bold text-primary">{formatFare(total, displayCurrencySymbol)}</span>
|
||||||
</div>
|
</div>
|
||||||
{createBookingMutation.isError && (
|
{createBookingMutation.isError && (
|
||||||
<p className="text-red-600 dark:text-red-400 text-xs mb-2">
|
<p className="text-red-600 dark:text-red-400 text-xs mb-2">
|
||||||
|
|||||||
@@ -17,8 +17,6 @@ import {
|
|||||||
Search,
|
Search,
|
||||||
Users,
|
Users,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Gift,
|
|
||||||
Check,
|
|
||||||
X,
|
X,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
Clock,
|
Clock,
|
||||||
@@ -54,7 +52,6 @@ const searchSchema = z
|
|||||||
nationality: z.enum(["ETHIOPIAN", "DJIBOUTIAN", "OTHER"], {
|
nationality: z.enum(["ETHIOPIAN", "DJIBOUTIAN", "OTHER"], {
|
||||||
errorMap: () => ({ message: "Please select your nationality" }),
|
errorMap: () => ({ message: "Please select your nationality" }),
|
||||||
}),
|
}),
|
||||||
promoCode: z.string().optional(),
|
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(d) => {
|
(d) => {
|
||||||
@@ -373,8 +370,7 @@ function PassengerModal({
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="w-full py-3.5 bg-[rgb(20,113,76)] text-white font-bold text-sm rounded-xl"
|
className="w-full py-3.5 bg-[rgb(20,113,76)] text-white font-bold text-sm rounded-xl"
|
||||||
>
|
>
|
||||||
Done — {adultCount + childCount} Passenger
|
Continue
|
||||||
{adultCount + childCount !== 1 ? "s" : ""}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<style>{`@keyframes pax-slide-up{from{transform:translateY(100%);opacity:0}to{transform:translateY(0);opacity:1}}`}</style>
|
<style>{`@keyframes pax-slide-up{from{transform:translateY(100%);opacity:0}to{transform:translateY(0);opacity:1}}`}</style>
|
||||||
@@ -551,13 +547,6 @@ export default function SearchPage() {
|
|||||||
|
|
||||||
const dark = useDarkMode();
|
const dark = useDarkMode();
|
||||||
const [passengerModalOpen, setPassengerModalOpen] = useState(false);
|
const [passengerModalOpen, setPassengerModalOpen] = useState(false);
|
||||||
const [promoVisible, setPromoVisible] = useState(false);
|
|
||||||
const [promoCode, setPromoCode] = useState("");
|
|
||||||
const [promoValidation, setPromoValidation] = useState<{
|
|
||||||
valid: boolean;
|
|
||||||
message: string;
|
|
||||||
} | null>(null);
|
|
||||||
const [promoLoading, setPromoLoading] = useState(false);
|
|
||||||
const [swapping, setSwapping] = useState(false);
|
const [swapping, setSwapping] = useState(false);
|
||||||
const [stationModal, setStationModal] = useState<
|
const [stationModal, setStationModal] = useState<
|
||||||
"origin" | "destination" | null
|
"origin" | "destination" | null
|
||||||
@@ -621,7 +610,6 @@ export default function SearchPage() {
|
|||||||
// selecting it.
|
// selecting it.
|
||||||
nationality: "" as any,
|
nationality: "" as any,
|
||||||
departureDate: "",
|
departureDate: "",
|
||||||
promoCode: "",
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -699,33 +687,6 @@ export default function SearchPage() {
|
|||||||
}, 300);
|
}, 300);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleValidatePromo = async () => {
|
|
||||||
if (!promoCode.trim()) return setPromoValidation(null);
|
|
||||||
setPromoLoading(true);
|
|
||||||
try {
|
|
||||||
const res = (await apiClient.post("/promos/validate", {
|
|
||||||
code: promoCode,
|
|
||||||
})) as any;
|
|
||||||
const valid = res.applicable || res.valid;
|
|
||||||
setPromoValidation({
|
|
||||||
valid,
|
|
||||||
message:
|
|
||||||
res.message || (valid ? "Promo applied!" : "Invalid promo code"),
|
|
||||||
});
|
|
||||||
if (valid) setValue("promoCode", promoCode);
|
|
||||||
else setPromoCode("");
|
|
||||||
} catch (err: any) {
|
|
||||||
setPromoValidation({
|
|
||||||
valid: false,
|
|
||||||
message:
|
|
||||||
err?.response?.data?.message || "Promo code is invalid or expired",
|
|
||||||
});
|
|
||||||
setPromoCode("");
|
|
||||||
} finally {
|
|
||||||
setPromoLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSubmit = (data: SearchForm) => {
|
const onSubmit = (data: SearchForm) => {
|
||||||
setHasInteracted(true);
|
setHasInteracted(true);
|
||||||
// Clear previous booking selections and search cache before starting a new search
|
// Clear previous booking selections and search cache before starting a new search
|
||||||
@@ -744,7 +705,6 @@ export default function SearchPage() {
|
|||||||
nationality: data.nationality,
|
nationality: data.nationality,
|
||||||
...(data.tripType === "ROUND_TRIP" &&
|
...(data.tripType === "ROUND_TRIP" &&
|
||||||
data.returnDate && { returnDate: data.returnDate }),
|
data.returnDate && { returnDate: data.returnDate }),
|
||||||
...(data.promoCode && { promoCode: data.promoCode }),
|
|
||||||
});
|
});
|
||||||
router.push(`/booking/results?${params}`);
|
router.push(`/booking/results?${params}`);
|
||||||
};
|
};
|
||||||
@@ -833,21 +793,10 @@ export default function SearchPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── 90vh hero with banner image (desktop) / top-aligned widget only (mobile) ── */}
|
{/* ── 90vh hero with banner image ── */}
|
||||||
{/* Round trip stacks an extra Return Date field into the widget on desktop, which grows
|
<section className="relative h-[94vh] min-h-[560px]">
|
||||||
upward from its bottom-anchored position — give the hero extra height there so the
|
{/* Background image with zoom - fully isolated */}
|
||||||
widget's top edge doesn't creep up into the sticky header. On mobile the widget is
|
<div className="absolute inset-0 overflow-hidden">
|
||||||
in normal flow (not bottom-anchored), so this only applies at md: and up. */}
|
|
||||||
<section
|
|
||||||
className={`relative ${
|
|
||||||
tripType === "ROUND_TRIP"
|
|
||||||
? "md:h-[90vh] md:min-h-[560px]"
|
|
||||||
: "md:h-[94vh] md:min-h-[560px]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{/* Background image with zoom — desktop only; mobile drops the hero image entirely
|
|
||||||
so the booking widget can sit at the top and use the available space. */}
|
|
||||||
<div className="hidden md:block absolute inset-0 overflow-hidden">
|
|
||||||
<div
|
<div
|
||||||
className="w-full h-full bg-cover bg-center animate-bg-zoom"
|
className="w-full h-full bg-cover bg-center animate-bg-zoom"
|
||||||
style={{
|
style={{
|
||||||
@@ -1105,10 +1054,10 @@ export default function SearchPage() {
|
|||||||
>
|
>
|
||||||
<span className="flex items-center gap-2 text-sm font-medium" style={{ color: dark ? '#ffffff' : '#111827' }}>
|
<span className="flex items-center gap-2 text-sm font-medium" style={{ color: dark ? '#ffffff' : '#111827' }}>
|
||||||
<Users className="w-4 h-4 text-primary" />
|
<Users className="w-4 h-4 text-primary" />
|
||||||
{totalPassengers} Pax
|
{totalPassengers} {totalPassengers === 1 ? "Passenger" : "Passengers"}
|
||||||
{nationalityFlag(watch("nationality"))
|
{nationalityFlag(watch("nationality"))
|
||||||
? ` · ${nationalityFlag(watch("nationality"))}`
|
? ` · ${nationalityFlag(watch("nationality"))}`
|
||||||
: " · Select nationality"}
|
: " · Nationality"}
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown className="w-4 h-4 text-primary" />
|
<ChevronDown className="w-4 h-4 text-primary" />
|
||||||
</button>
|
</button>
|
||||||
@@ -1244,10 +1193,10 @@ export default function SearchPage() {
|
|||||||
>
|
>
|
||||||
<span className="flex items-center gap-1.5 text-sm font-medium text-gray-900 dark:text-white truncate">
|
<span className="flex items-center gap-1.5 text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||||
<Users className="w-4 h-4 text-primary flex-shrink-0" />
|
<Users className="w-4 h-4 text-primary flex-shrink-0" />
|
||||||
{totalPassengers} Pax
|
{totalPassengers} {totalPassengers === 1 ? "Passenger" : "Passengers"}
|
||||||
{nationalityFlag(watch("nationality"))
|
{nationalityFlag(watch("nationality"))
|
||||||
? ` · ${nationalityFlag(watch("nationality"))}`
|
? ` · ${nationalityFlag(watch("nationality"))}`
|
||||||
: " · Select nationality"}
|
: " · Nationality"}
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown className="w-4 h-4 text-primary flex-shrink-0" />
|
<ChevronDown className="w-4 h-4 text-primary flex-shrink-0" />
|
||||||
</button>
|
</button>
|
||||||
@@ -1266,342 +1215,129 @@ export default function SearchPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
// ROUND TRIP: Two row layout
|
// ROUND TRIP: Single row — From · Swap · To · Departure · Return · Passengers · Search
|
||||||
<div className="space-y-3">
|
<div className="flex items-end gap-2">
|
||||||
{/* Row 1: From, Swap, To, Departure Date, Return Date */}
|
{/* From */}
|
||||||
<div className="flex items-end gap-2">
|
<div className="flex-1 min-w-0 space-y-1">
|
||||||
{/* From */}
|
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">From</label>
|
||||||
<div className="flex-1 min-w-0 space-y-1">
|
<StationDropdown
|
||||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
stations={stations}
|
||||||
From
|
value={originId}
|
||||||
</label>
|
excludeId={destId}
|
||||||
<StationDropdown
|
placeholder="Departure"
|
||||||
stations={stations}
|
recentIds={recentStationIds}
|
||||||
value={originId}
|
onSelect={(s) => {
|
||||||
excludeId={destId}
|
setHasInteracted(true);
|
||||||
placeholder="Departure station"
|
setValue("originStationId", s.id);
|
||||||
recentIds={recentStationIds}
|
if (s.id) saveRecent(s.id);
|
||||||
onSelect={(s) => {
|
clearErrors("originStationId");
|
||||||
setHasInteracted(true);
|
clearErrors("destinationStationId");
|
||||||
setValue("originStationId", s.id);
|
}}
|
||||||
if (s.id) saveRecent(s.id);
|
error={hasInteracted ? errors.originStationId?.message : undefined}
|
||||||
clearErrors("originStationId");
|
onOpen={scrollWidgetIntoView}
|
||||||
clearErrors("destinationStationId");
|
/>
|
||||||
}}
|
{hasInteracted && errors.originStationId && (
|
||||||
error={
|
<p className="text-xs text-red-500">{errors.originStationId.message}</p>
|
||||||
hasInteracted
|
)}
|
||||||
? errors.originStationId?.message
|
</div>
|
||||||
: undefined
|
{/* Swap */}
|
||||||
}
|
<button
|
||||||
onOpen={scrollWidgetIntoView}
|
type="button"
|
||||||
/>
|
onClick={handleSwap}
|
||||||
{hasInteracted && errors.originStationId && (
|
disabled={!originId || !destId}
|
||||||
<p className="text-xs text-red-500">
|
className={`flex-shrink-0 mb-0.5 w-9 h-9 bg-gray-50 border-2 border-gray-200 rounded-full flex items-center justify-center hover:border-primary hover:bg-primary/5 transition-all disabled:opacity-30 ${swapping ? "rotate-180" : ""}`}
|
||||||
{errors.originStationId.message}
|
>
|
||||||
</p>
|
<ArrowLeftRight className="w-4 h-4 text-gray-500" />
|
||||||
)}
|
</button>
|
||||||
</div>
|
{/* To */}
|
||||||
{/* Swap */}
|
<div className="flex-1 min-w-0 space-y-1">
|
||||||
|
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">To</label>
|
||||||
|
<StationDropdown
|
||||||
|
stations={stations}
|
||||||
|
value={destId}
|
||||||
|
excludeId={originId}
|
||||||
|
placeholder="Destination"
|
||||||
|
recentIds={recentStationIds}
|
||||||
|
onSelect={(s) => {
|
||||||
|
setHasInteracted(true);
|
||||||
|
setValue("destinationStationId", s.id);
|
||||||
|
if (s.id) saveRecent(s.id);
|
||||||
|
clearErrors("destinationStationId");
|
||||||
|
}}
|
||||||
|
error={hasInteracted ? errors.destinationStationId?.message : undefined}
|
||||||
|
onOpen={scrollWidgetIntoView}
|
||||||
|
/>
|
||||||
|
{hasInteracted && errors.destinationStationId && (
|
||||||
|
<p className="text-xs text-red-500">{errors.destinationStationId.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Departure Date */}
|
||||||
|
<div className="w-40 flex-shrink-0 space-y-1">
|
||||||
|
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Departure</label>
|
||||||
|
<ModernDatePicker
|
||||||
|
value={departureDate ? new Date(departureDate + "T00:00:00") : undefined}
|
||||||
|
onChange={(date) => {
|
||||||
|
setValue("departureDate", `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`);
|
||||||
|
trigger("departureDate");
|
||||||
|
trigger("returnDate");
|
||||||
|
}}
|
||||||
|
minDate={new Date()}
|
||||||
|
placeholder="Select date"
|
||||||
|
/>
|
||||||
|
{errors.departureDate && (
|
||||||
|
<p className="text-xs text-red-500">{errors.departureDate.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Return Date */}
|
||||||
|
<div className="w-40 flex-shrink-0 space-y-1">
|
||||||
|
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Return</label>
|
||||||
|
<ModernDatePicker
|
||||||
|
value={returnDate ? new Date(returnDate + "T00:00:00") : undefined}
|
||||||
|
onChange={(date) => {
|
||||||
|
setValue("returnDate", `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`);
|
||||||
|
trigger("returnDate");
|
||||||
|
}}
|
||||||
|
minDate={departureDate ? new Date(departureDate + "T00:00:00") : new Date()}
|
||||||
|
placeholder="Select date"
|
||||||
|
/>
|
||||||
|
{errors.returnDate && (
|
||||||
|
<p className="text-xs text-red-500">{errors.returnDate.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Passengers */}
|
||||||
|
<div className="w-44 flex-shrink-0 space-y-1">
|
||||||
|
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Passengers</label>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleSwap}
|
onClick={() => setPassengerModalOpen(true)}
|
||||||
disabled={!originId || !destId}
|
className={`w-full flex items-center justify-between px-3 py-3.5 border-2 rounded-xl bg-white dark:bg-gray-800 hover:border-gray-300 dark:hover:border-gray-600 transition-all ${
|
||||||
className={`flex-shrink-0 mb-0.5 w-9 h-9 bg-gray-50 border-2 border-gray-200 rounded-full flex items-center justify-center hover:border-primary hover:bg-primary/5 transition-all disabled:opacity-30 ${swapping ? "rotate-180" : ""}`}
|
showNationalityError ? "border-red-400" : "border-gray-200 dark:border-gray-700"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<ArrowLeftRight className="w-4 h-4 text-gray-500" />
|
<span className="flex items-center gap-1.5 text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||||
|
<Users className="w-4 h-4 text-primary flex-shrink-0" />
|
||||||
|
{totalPassengers} {totalPassengers === 1 ? "Passenger" : "Passengers"}
|
||||||
|
{nationalityFlag(watch("nationality")) ? ` · ${nationalityFlag(watch("nationality"))}` : " · Nationality"}
|
||||||
|
</span>
|
||||||
|
<ChevronDown className="w-4 h-4 text-primary flex-shrink-0" />
|
||||||
</button>
|
</button>
|
||||||
{/* To */}
|
{showNationalityError && (
|
||||||
<div className="flex-1 min-w-0 space-y-1">
|
<p className="text-xs text-red-500">{errors.nationality?.message}</p>
|
||||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
)}
|
||||||
To
|
|
||||||
</label>
|
|
||||||
<StationDropdown
|
|
||||||
stations={stations}
|
|
||||||
value={destId}
|
|
||||||
excludeId={originId}
|
|
||||||
placeholder="Destination station"
|
|
||||||
recentIds={recentStationIds}
|
|
||||||
onSelect={(s) => {
|
|
||||||
setHasInteracted(true);
|
|
||||||
setValue("destinationStationId", s.id);
|
|
||||||
if (s.id) saveRecent(s.id);
|
|
||||||
clearErrors("destinationStationId");
|
|
||||||
}}
|
|
||||||
error={
|
|
||||||
hasInteracted
|
|
||||||
? errors.destinationStationId?.message
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onOpen={scrollWidgetIntoView}
|
|
||||||
/>
|
|
||||||
{hasInteracted && errors.destinationStationId && (
|
|
||||||
<p className="text-xs text-red-500">
|
|
||||||
{errors.destinationStationId.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{/* Divider */}
|
|
||||||
<div className="w-px h-10 bg-gray-200 mb-0.5 flex-shrink-0" />
|
|
||||||
{/* Departure Date */}
|
|
||||||
<div className="w-44 flex-shrink-0 space-y-1">
|
|
||||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
|
||||||
Departure
|
|
||||||
</label>
|
|
||||||
<div>
|
|
||||||
<ModernDatePicker
|
|
||||||
value={
|
|
||||||
departureDate
|
|
||||||
? new Date(departureDate + "T00:00:00")
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onChange={(date) => {
|
|
||||||
setValue(
|
|
||||||
"departureDate",
|
|
||||||
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`,
|
|
||||||
);
|
|
||||||
trigger("departureDate");
|
|
||||||
trigger("returnDate");
|
|
||||||
}}
|
|
||||||
minDate={new Date()}
|
|
||||||
placeholder="Select date"
|
|
||||||
error={!!errors.departureDate}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{errors.departureDate && (
|
|
||||||
<p className="text-xs text-red-500">
|
|
||||||
{errors.departureDate.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{/* Return Date */}
|
|
||||||
<div className="w-44 flex-shrink-0 space-y-1">
|
|
||||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
|
||||||
Return
|
|
||||||
</label>
|
|
||||||
<div>
|
|
||||||
<ModernDatePicker
|
|
||||||
value={
|
|
||||||
returnDate
|
|
||||||
? new Date(returnDate + "T00:00:00")
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onChange={(date) => {
|
|
||||||
setValue(
|
|
||||||
"returnDate",
|
|
||||||
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`,
|
|
||||||
);
|
|
||||||
trigger("returnDate");
|
|
||||||
}}
|
|
||||||
minDate={
|
|
||||||
departureDate
|
|
||||||
? new Date(departureDate + "T00:00:00")
|
|
||||||
: new Date()
|
|
||||||
}
|
|
||||||
placeholder="Select date"
|
|
||||||
error={!!errors.returnDate}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{errors.returnDate && (
|
|
||||||
<p className="text-xs text-red-500">
|
|
||||||
{errors.returnDate.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Row 2: Promo, Passengers, Search */}
|
|
||||||
<div className="flex items-end gap-2">
|
|
||||||
{/* Promo Code */}
|
|
||||||
<div className="flex-1 min-w-0 space-y-1">
|
|
||||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
|
||||||
{!promoVisible
|
|
||||||
? "Promo Code (Optional)"
|
|
||||||
: "Promo Code"}
|
|
||||||
</label>
|
|
||||||
{!promoVisible ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setPromoVisible(true)}
|
|
||||||
className="w-full flex items-center gap-1.5 px-3.5 py-3.5 border-2 border-gray-200 dark:border-gray-700 rounded-xl hover:border-primary transition-all bg-white dark:bg-gray-800 text-left"
|
|
||||||
>
|
|
||||||
<Gift className="w-4 h-4 text-primary" />
|
|
||||||
<span className="text-sm text-gray-400">
|
|
||||||
Click to add promo code
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<div className="flex-1 relative">
|
|
||||||
<Gift className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-primary" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={promoCode}
|
|
||||||
onChange={(e) => {
|
|
||||||
setPromoCode(
|
|
||||||
e.target.value.toUpperCase(),
|
|
||||||
);
|
|
||||||
if (promoValidation)
|
|
||||||
setPromoValidation(null);
|
|
||||||
}}
|
|
||||||
placeholder="Enter promo code"
|
|
||||||
onKeyDown={(e) =>
|
|
||||||
e.key === "Enter" &&
|
|
||||||
(e.preventDefault(),
|
|
||||||
handleValidatePromo())
|
|
||||||
}
|
|
||||||
className="w-full pl-9 pr-3 py-3.5 border-2 border-gray-200 dark:border-gray-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/30 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleValidatePromo}
|
|
||||||
disabled={!promoCode || promoLoading}
|
|
||||||
className="px-4 py-3.5 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded-xl hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-40 text-sm font-semibold transition-colors"
|
|
||||||
>
|
|
||||||
{promoLoading ? "..." : "Apply"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setPromoVisible(false);
|
|
||||||
setPromoCode("");
|
|
||||||
setPromoValidation(null);
|
|
||||||
}}
|
|
||||||
className="p-3.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{promoValidation && (
|
|
||||||
<div
|
|
||||||
className={`flex items-center gap-1.5 text-xs ${promoValidation.valid ? "text-green-600 dark:text-green-400" : "text-red-500 dark:text-red-400"}`}
|
|
||||||
>
|
|
||||||
{promoValidation.valid && (
|
|
||||||
<Check className="w-3.5 h-3.5" />
|
|
||||||
)}
|
|
||||||
{promoValidation.message}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{/* Divider */}
|
|
||||||
<div className="w-px h-10 bg-gray-200 dark:bg-gray-700 flex-shrink-0" />
|
|
||||||
{/* Pax + Nationality */}
|
|
||||||
<div className="w-44 flex-shrink-0 space-y-1">
|
|
||||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
|
||||||
Passengers
|
|
||||||
</label>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setPassengerModalOpen(true)}
|
|
||||||
className={`w-full flex items-center justify-between px-3 py-3.5 border-2 rounded-xl bg-white dark:bg-gray-800 hover:border-gray-300 dark:hover:border-gray-600 transition-all ${
|
|
||||||
showNationalityError
|
|
||||||
? "border-red-400"
|
|
||||||
: "border-gray-200 dark:border-gray-700"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="flex items-center gap-1.5 text-sm font-medium text-gray-900 dark:text-white truncate">
|
|
||||||
<Users className="w-4 h-4 text-primary flex-shrink-0" />
|
|
||||||
{totalPassengers} Pax
|
|
||||||
{nationalityFlag(watch("nationality"))
|
|
||||||
? ` · ${nationalityFlag(watch("nationality"))}`
|
|
||||||
: " · Select nationality"}
|
|
||||||
</span>
|
|
||||||
<ChevronDown className="w-4 h-4 text-primary flex-shrink-0" />
|
|
||||||
</button>
|
|
||||||
{showNationalityError && (
|
|
||||||
<p className="text-xs text-red-500">{errors.nationality?.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{/* Search Button */}
|
|
||||||
<div className="flex-shrink-0 space-y-1">
|
|
||||||
<label className="text-xs font-semibold text-gray-500 uppercase tracking-wide opacity-0 pointer-events-none">
|
|
||||||
Search
|
|
||||||
</label>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isLoading}
|
|
||||||
className="flex items-center justify-center gap-2 px-6 py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all transform hover:-translate-y-0.5 shadow-lg hover:shadow-xl disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<Search className="w-5 h-5" />
|
|
||||||
Search
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
{/* Search */}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex-shrink-0 flex items-center justify-center gap-2 px-5 py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all shadow-lg hover:shadow-xl disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Search className="w-5 h-5" />
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Promo - Only visible in ONE WAY mode on desktop */}
|
|
||||||
{tripType === "ONE_WAY" && (
|
|
||||||
<div className="mt-3">
|
|
||||||
{!promoVisible ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setPromoVisible(true)}
|
|
||||||
className="flex items-center gap-1.5 text-xs text-primary font-medium hover:underline"
|
|
||||||
>
|
|
||||||
<Gift className="w-3.5 h-3.5" />
|
|
||||||
Apply Promo Code
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<div className="flex-1 relative">
|
|
||||||
<Gift className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-primary" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={promoCode}
|
|
||||||
onChange={(e) => {
|
|
||||||
setPromoCode(e.target.value.toUpperCase());
|
|
||||||
if (promoValidation) setPromoValidation(null);
|
|
||||||
}}
|
|
||||||
placeholder="Enter promo code"
|
|
||||||
onKeyDown={(e) =>
|
|
||||||
e.key === "Enter" &&
|
|
||||||
(e.preventDefault(), handleValidatePromo())
|
|
||||||
}
|
|
||||||
className="w-full pl-9 pr-3 py-2.5 border-2 border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/30 text-sm bg-white placeholder-gray-400"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleValidatePromo}
|
|
||||||
disabled={!promoCode || promoLoading}
|
|
||||||
className="px-4 py-2.5 bg-gray-100 text-gray-700 rounded-xl hover:bg-gray-200 disabled:opacity-40 text-sm font-semibold"
|
|
||||||
>
|
|
||||||
{promoLoading ? "..." : "Apply"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setPromoVisible(false);
|
|
||||||
setPromoCode("");
|
|
||||||
setPromoValidation(null);
|
|
||||||
}}
|
|
||||||
className="p-2.5 text-gray-400 hover:text-gray-600 rounded-xl hover:bg-gray-100"
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{promoValidation && (
|
|
||||||
<div
|
|
||||||
className={`flex items-center gap-1.5 text-xs ${promoValidation.valid ? "text-green-600" : "text-red-500"}`}
|
|
||||||
>
|
|
||||||
{promoValidation.valid && (
|
|
||||||
<Check className="w-3.5 h-3.5" />
|
|
||||||
)}
|
|
||||||
{promoValidation.message}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -7,10 +7,13 @@ import { useRouter, useSearchParams } from 'next/navigation';
|
|||||||
import { useAuthStore } from '@/lib/auth-store';
|
import { useAuthStore } from '@/lib/auth-store';
|
||||||
import { useState, Suspense } from 'react';
|
import { useState, Suspense } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Train, ShieldCheck } from 'lucide-react';
|
import { Train, ShieldCheck, Eye, EyeOff } from 'lucide-react';
|
||||||
|
|
||||||
const loginSchema = z.object({
|
const loginSchema = z.object({
|
||||||
email: z.string().email('Invalid email address'),
|
// Accepts either an email or a phone number. Passengers who registered without an
|
||||||
|
// email sign in with their phone number, which is sent in the same `email` field —
|
||||||
|
// the IAM matches on either identifier.
|
||||||
|
email: z.string().min(1, 'Phone or email is required'),
|
||||||
password: z.string().min(6, 'Password must be at least 6 characters'),
|
password: z.string().min(6, 'Password must be at least 6 characters'),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -22,6 +25,7 @@ function LoginContent() {
|
|||||||
const login = useAuthStore((s) => s.login);
|
const login = useAuthStore((s) => s.login);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
const { register, handleSubmit, formState: { errors } } = useForm<LoginForm>({
|
const { register, handleSubmit, formState: { errors } } = useForm<LoginForm>({
|
||||||
resolver: zodResolver(loginSchema as any),
|
resolver: zodResolver(loginSchema as any),
|
||||||
@@ -63,12 +67,13 @@ function LoginContent() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Email</label>
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Phone or email</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="text"
|
||||||
{...register('email')}
|
{...register('email')}
|
||||||
className="input-field"
|
className="input-field"
|
||||||
placeholder="your@email.com"
|
placeholder="+251912345678 or your@email.com"
|
||||||
|
autoComplete="username"
|
||||||
/>
|
/>
|
||||||
{errors.email && (
|
{errors.email && (
|
||||||
<p className="text-red-500 text-sm mt-1">{errors.email.message}</p>
|
<p className="text-red-500 text-sm mt-1">{errors.email.message}</p>
|
||||||
@@ -77,12 +82,23 @@ function LoginContent() {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Password</label>
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Password</label>
|
||||||
<input
|
<div className="relative">
|
||||||
type="password"
|
<input
|
||||||
{...register('password')}
|
type={showPassword ? 'text' : 'password'}
|
||||||
className="input-field"
|
{...register('password')}
|
||||||
placeholder="••••••••"
|
className="input-field pr-10"
|
||||||
/>
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword((v) => !v)}
|
||||||
|
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
||||||
|
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{errors.password && (
|
{errors.password && (
|
||||||
<p className="text-red-500 text-sm mt-1">{errors.password.message}</p>
|
<p className="text-red-500 text-sm mt-1">{errors.password.message}</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -11,7 +11,13 @@ import { Train, ShieldCheck } from 'lucide-react';
|
|||||||
|
|
||||||
const registerSchema = z.object({
|
const registerSchema = z.object({
|
||||||
fullName: z.string().min(2, 'Full name is required'),
|
fullName: z.string().min(2, 'Full name is required'),
|
||||||
email: z.string().email('Invalid email address'),
|
// Email is optional. If provided it must be a valid address; if left blank we fall back
|
||||||
|
// to the phone number as the account identifier (see onSubmit).
|
||||||
|
email: z
|
||||||
|
.string()
|
||||||
|
.email('Invalid email address')
|
||||||
|
.optional()
|
||||||
|
.or(z.literal('')),
|
||||||
phone: z.string().min(9, 'Phone number is required'),
|
phone: z.string().min(9, 'Phone number is required'),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -31,9 +37,12 @@ export default function RegisterPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
|
// No email? Use the phone number as the account identifier. The IAM (and our
|
||||||
|
// relaxed RegisterDto) accept any non-empty string in the email field.
|
||||||
|
const email = data.email?.trim() ? data.email.trim() : data.phone;
|
||||||
const result = await registerUser({
|
const result = await registerUser({
|
||||||
fullName: data.fullName,
|
fullName: data.fullName,
|
||||||
email: data.email,
|
email,
|
||||||
phone: data.phone,
|
phone: data.phone,
|
||||||
});
|
});
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
@@ -89,7 +98,9 @@ export default function RegisterPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Email</label>
|
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
|
||||||
|
Email <span className="text-gray-400 font-normal">(optional)</span>
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
{...register('email')}
|
{...register('email')}
|
||||||
|
|||||||
28
apps/edr-passenger-web/portal/src/lib/useCurrencies.ts
Normal file
28
apps/edr-passenger-web/portal/src/lib/useCurrencies.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { apiClient } from './api-client';
|
||||||
|
|
||||||
|
interface Currency {
|
||||||
|
code: string;
|
||||||
|
symbol: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FALLBACK_SYMBOLS: Record<string, string> = {
|
||||||
|
ETB: 'Br',
|
||||||
|
DJF: 'Fdj',
|
||||||
|
USD: '$',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useCurrencies() {
|
||||||
|
return useQuery<Currency[]>({
|
||||||
|
queryKey: ['currencies'],
|
||||||
|
queryFn: () => apiClient.get<Currency[]>('/currencies'),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCurrencySymbol(code: string): string {
|
||||||
|
const { data, isLoading, isError } = useCurrencies();
|
||||||
|
if (isLoading || isError || !data) return FALLBACK_SYMBOLS[code] ?? code;
|
||||||
|
return data.find(c => c.code === code)?.symbol ?? FALLBACK_SYMBOLS[code] ?? code;
|
||||||
|
}
|
||||||
@@ -83,8 +83,8 @@ export function getPassengerCategory(passenger: PassengerWithAge): 'ADULT' | 'CH
|
|||||||
/**
|
/**
|
||||||
* Format fare amount for display
|
* Format fare amount for display
|
||||||
*/
|
*/
|
||||||
export function formatFare(amountMinor: number, currency: string = 'ETB'): string {
|
export function formatFare(amountMinor: number, currencyOrSymbol: string = 'ETB'): string {
|
||||||
return `${currency} ${(amountMinor / 100).toFixed(2)}`;
|
return `${currencyOrSymbol} ${(amountMinor / 100).toFixed(2)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -705,6 +705,8 @@ export interface CreateBookingUnderContractDto {
|
|||||||
contractRouteId?: string;
|
contractRouteId?: string;
|
||||||
/** Binding shipment day. Omitted for intercity (DOMESTIC) bookings — staff assign a passing train later. */
|
/** Binding shipment day. Omitted for intercity (DOMESTIC) bookings — staff assign a passing train later. */
|
||||||
scheduledDate?: string;
|
scheduledDate?: string;
|
||||||
|
/** "WITH_RETURN" | "WITHOUT_RETURN" — per-shipment override; falls back to the contract's equipment return. */
|
||||||
|
equipmentReturn?: string;
|
||||||
containers?: CreateBookingContainerLineDto[];
|
containers?: CreateBookingContainerLineDto[];
|
||||||
bulkLines?: CreateBulkLineDto[];
|
bulkLines?: CreateBulkLineDto[];
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
|||||||
@@ -60,6 +60,12 @@ export interface ETradeCompanyInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CompanyRegistrationData {
|
export interface CompanyRegistrationData {
|
||||||
|
/**
|
||||||
|
* The registered organization name — `ETradeCompanyInfo.BusinessName`, falling
|
||||||
|
* back to the licence's `TradeName`. Never the manager/owner's personal name;
|
||||||
|
* that is {@link managerName}.
|
||||||
|
*/
|
||||||
|
companyName: string;
|
||||||
licenceNumber: string;
|
licenceNumber: string;
|
||||||
statusDescription: string;
|
statusDescription: string;
|
||||||
dateRegistered: string;
|
dateRegistered: string;
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export enum NotificationPriority {
|
|||||||
export enum NotificationType {
|
export enum NotificationType {
|
||||||
GENERIC = "GENERIC",
|
GENERIC = "GENERIC",
|
||||||
// Portal-facing (customer)
|
// Portal-facing (customer)
|
||||||
|
ACCOUNT_STATUS = "ACCOUNT_STATUS",
|
||||||
CLEARANCE_DECISION = "CLEARANCE_DECISION",
|
CLEARANCE_DECISION = "CLEARANCE_DECISION",
|
||||||
DOCUMENT_ACTION = "DOCUMENT_ACTION",
|
DOCUMENT_ACTION = "DOCUMENT_ACTION",
|
||||||
BOOKING_STATUS = "BOOKING_STATUS",
|
BOOKING_STATUS = "BOOKING_STATUS",
|
||||||
|
|||||||
Reference in New Issue
Block a user