Merge branch 'dev' into freight/feat/fixes-v1

This commit is contained in:
Nathnael
2026-07-10 12:22:41 +00:00
41 changed files with 3067 additions and 677 deletions

View File

@@ -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
View File

@@ -28,3 +28,4 @@ coverage/
*~ *~
\#*\# \#*\#
.\#* .\#*
docker-compose.override.yml

View File

@@ -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.
}
}

View File

@@ -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 } }> = [];
let ops: string[] = [];
let y = 0;
const drawFullHeader = () => {
ops.push(lineOp(M, page.height - 28, right, page.height - 28, PdfColor.teal, 2.4)); 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("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)); 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 (subtitle) ops.push(textOp(clipText(subtitle, 96), M, page.height - 82, 9, "F1", PdfColor.gray));
if (metaRef) { if (metaRef) {
ops.push(textOpRight("TRAIN / SCHEDULE", right, page.height - 42, 7.5, "F2", PdfColor.gray)); 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)); ops.push(textOpRight(clipText(metaRef, 28), right, page.height - 58, 12, "F2", PdfColor.dark));
} }
if (generated) { if (generated) {
ops.push(textOpRight(clipText(`Generated ${generated}`, 40), right, page.height - 72, 8, "F1", PdfColor.gray)); 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)); ops.push(lineOp(M, page.height - 92, right, page.height - 92, PdfColor.line, 1));
y = page.height - 100;
};
// Summary tiles const drawContinuationHeader = (pageNo: number) => {
let y = page.height - 100; 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));
const bottomReserve = 46; // keep clear of the page edge on row-only pages
const drawTableHeader = () => {
ops.push(rectOp(M, y - headerH, contentW, headerH, PdfColor.tint, PdfColor.line, 0.6)); ops.push(rectOp(M, y - headerH, contentW, headerH, PdfColor.tint, PdfColor.line, 0.6));
headers.forEach((h, c) => headers.forEach((h, c) =>
ops.push(textOp(clipText(h, cellChars), M + c * colW + 4, y - 11, 7, "F2", PdfColor.teal)), ops.push(textOp(clipText(h, cellChars), M + c * colW + 4, y - 11, 7, "F2", PdfColor.teal)),
); );
y -= headerH; y -= headerH;
};
let shown = 0; drawTableHeader();
for (const row of rows) { let truncated = 0;
if (y < 96) break; 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");
}

View File

@@ -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 },
}); });
} }

View File

@@ -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> {

View File

@@ -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,

View File

@@ -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();
} }

View File

@@ -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()

View File

@@ -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}`;

View File

@@ -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 },

View File

@@ -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));
} }

View File

@@ -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,
}); });
} }

View File

@@ -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 {

View File

@@ -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()

View File

@@ -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;

View File

@@ -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' })

View File

@@ -243,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;
@@ -1098,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(
@@ -1319,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>();
@@ -1335,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);
@@ -1614,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',
@@ -1647,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',
@@ -2774,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],
); );
@@ -3403,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,
@@ -3441,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(
@@ -3452,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 }),
); );
} }

View File

@@ -196,12 +196,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",

View File

@@ -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} />}

View File

@@ -51,6 +51,7 @@ import { warehouseService } from '@/services/warehouse.service';
import type { import type {
EligibleBooking, EligibleBooking,
InventoryInquiryFilter, InventoryInquiryFilter,
InventoryStatus,
InventoryInquiryResult, InventoryInquiryResult,
ImportTrain, ImportTrain,
ImportTrainItem, ImportTrainItem,
@@ -63,6 +64,7 @@ import type {
WarehouseYard, WarehouseYard,
WarehouseZone, WarehouseZone,
} from '@/types/warehouse'; } from '@/types/warehouse';
import { InventoryStatusBadge } from './badges';
import { BookingSelect } from './BookingSelect'; import { BookingSelect } from './BookingSelect';
import { DeliverInventoryModal } from './DeliverInventoryModal'; import { DeliverInventoryModal } from './DeliverInventoryModal';
import { ContainerItemsModal } from './ContainerItemsModal'; import { ContainerItemsModal } from './ContainerItemsModal';
@@ -253,6 +255,127 @@ const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayl
warehouseManagerName: form.warehouseManagerName.trim() || undefined, warehouseManagerName: form.warehouseManagerName.trim() || undefined,
}); });
const SUB_STAGE_COLOR: Record<string, string> = {
PENDING: 'gray',
RECEIVED: 'blue',
GRN: 'teal',
ASSIGNED: 'indigo',
LOADED: 'grape',
LEFT: 'orange',
DELIVERED: 'green',
};
/**
* Expanded booking row: the booking's containers / bulk items with their
* lifecycle stage. Shares the ['container-items', bookingId] cache with
* ContainerItemsModal, so expanding after using the modal is instant.
*/
function BookingItemsExpansion({
bookingId,
colSpan,
bulkFallback,
}: {
bookingId: string | null;
colSpan: number;
bulkFallback?: string;
}) {
const { data: items = [], isLoading } = useQuery({
queryKey: ['container-items', bookingId],
queryFn: () => warehouseService.getContainerItems(bookingId as string),
enabled: Boolean(bookingId),
});
return (
<Table.Tr>
<Table.Td colSpan={colSpan} bg="var(--mantine-color-gray-0)">
{isLoading ? (
<Group justify="center" py="sm">
<Loader size="xs" />
</Group>
) : items.length === 0 ? (
<Text size="xs" c="dimmed" py={6}>
{bulkFallback ?? 'No container units recorded on this booking.'}
</Text>
) : (
<Table verticalSpacing={4} fz="xs" withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>Container #</Table.Th>
<Table.Th>Goods</Table.Th>
<Table.Th>Stage</Table.Th>
<Table.Th>Truck</Table.Th>
<Table.Th>GRN</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{items.map((i) => (
<Table.Tr key={i.containerNumber}>
<Table.Td>
<Text size="xs" fw={600}>{i.containerNumber}</Text>
</Table.Td>
<Table.Td>{i.goods ?? '—'}</Table.Td>
<Table.Td>
<Badge size="xs" variant="light" color={SUB_STAGE_COLOR[i.stage] ?? 'gray'}>
{i.stage}
</Badge>
</Table.Td>
<Table.Td>{i.truckPlate ?? '—'}</Table.Td>
<Table.Td>{i.grnNumber ?? '—'}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Table.Td>
</Table.Tr>
);
}
type ConfirmAction = { title: string; message: string; confirmLabel: string; run: () => void };
/** One-click bulk actions are irreversible — make the click deliberate. */
function ConfirmActionModal({
action,
onClose,
}: {
action: ConfirmAction | null;
onClose: () => void;
}) {
return (
<Modal opened={Boolean(action)} onClose={onClose} title={action?.title ?? ''} centered size="sm">
<Stack gap="md">
<Text size="sm">{action?.message}</Text>
<Group justify="flex-end">
<Button variant="default" onClick={onClose}>
Cancel
</Button>
<Button
onClick={() => {
action?.run();
onClose();
}}
>
{action?.confirmLabel}
</Button>
</Group>
</Stack>
</Modal>
);
}
/** "3 skipped — Booking not PAID" instead of a bare count. */
const skippedSummary = (
skippedCount: number,
results: Array<{ reason?: string; message?: string }>,
): string | undefined => {
if (!skippedCount) return undefined;
const reason = results.find((x) => x.reason || x.message);
return `${skippedCount} skipped${reason ? `${reason.reason ?? reason.message}` : ''}`;
};
const commonNonEmptyValue = (values: Array<string | null | undefined>) => { const commonNonEmptyValue = (values: Array<string | null | undefined>) => {
const unique = [...new Set(values.map((value) => value?.trim()).filter(Boolean))] as string[]; const unique = [...new Set(values.map((value) => value?.trim()).filter(Boolean))] as string[];
return unique.length === 1 ? unique[0] : ''; return unique.length === 1 ? unique[0] : '';
@@ -860,7 +983,7 @@ function EligibleTab({
}); });
toast({ toast({
title: `${r.receivedCount} received at ${direction === 'EXPORT' ? 'facility' : 'warehouse'}`, title: `${r.receivedCount} received at ${direction === 'EXPORT' ? 'facility' : 'warehouse'}`,
description: r.results.find((item) => item.grnNumber)?.grnNumber ?? (r.skippedCount ? `${r.skippedCount} skipped` : undefined), description: r.results.find((item) => item.grnNumber)?.grnNumber ?? skippedSummary(r.skippedCount, r.results),
}); });
const firstGrn = r.results.find((item) => item.inventoryId && item.grnNumber); const firstGrn = r.results.find((item) => item.inventoryId && item.grnNumber);
if (focusedBookingId && firstGrn?.inventoryId && firstGrn.grnNumber) { if (focusedBookingId && firstGrn?.inventoryId && firstGrn.grnNumber) {
@@ -1030,7 +1153,7 @@ function EligibleTab({
: `No eligible PAID ${direction.toLowerCase()} bookings to receive.`} : `No eligible PAID ${direction.toLowerCase()} bookings to receive.`}
</Text> </Text>
) : ( ) : (
<Table.ScrollContainer minWidth={1700}> <Table.ScrollContainer minWidth={1350}>
<Table highlightOnHover verticalSpacing="xs" striped> <Table highlightOnHover verticalSpacing="xs" striped>
<Table.Thead> <Table.Thead>
<Table.Tr> <Table.Tr>
@@ -1043,8 +1166,6 @@ function EligibleTab({
/> />
</Table.Th> </Table.Th>
<Table.Th>Booking Ref</Table.Th> <Table.Th>Booking Ref</Table.Th>
<Table.Th>Booking ID</Table.Th>
<Table.Th>Customer ID</Table.Th>
<Table.Th>Customer Name</Table.Th> <Table.Th>Customer Name</Table.Th>
<Table.Th>Origin</Table.Th> <Table.Th>Origin</Table.Th>
<Table.Th>Destination</Table.Th> <Table.Th>Destination</Table.Th>
@@ -1077,12 +1198,6 @@ function EligibleTab({
{r.reference} {r.reference}
</Text> </Text>
</Table.Td> </Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">{r.id.slice(0, 8)}</Text>
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">{r.customerId ? `${r.customerId.slice(0, 8)}` : '—'}</Text>
</Table.Td>
<Table.Td>{r.customer ?? '—'}</Table.Td> <Table.Td>{r.customer ?? '—'}</Table.Td>
<Table.Td>{r.origin ?? '—'}</Table.Td> <Table.Td>{r.origin ?? '—'}</Table.Td>
<Table.Td>{r.destination ?? '—'}</Table.Td> <Table.Td>{r.destination ?? '—'}</Table.Td>
@@ -1256,6 +1371,8 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
api.warehouses.bulkMarkInspected.mutationOptions(), api.warehouses.bulkMarkInspected.mutationOptions(),
); );
const [selected, setSelected] = useState<Set<string>>(new Set()); const [selected, setSelected] = useState<Set<string>>(new Set());
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
const [expandedRow, setExpandedRow] = useState<string | null>(null);
const [inspectId, setInspectId] = useState<string | null>(null); const [inspectId, setInspectId] = useState<string | null>(null);
const pendingRows = rows.filter((r) => r.inspectionStatus !== 'PASSED'); const pendingRows = rows.filter((r) => r.inspectionStatus !== 'PASSED');
@@ -1279,7 +1396,7 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
const r = await inspectMutation.mutateAsync({ inventoryIds: [...selected] }); const r = await inspectMutation.mutateAsync({ inventoryIds: [...selected] });
toast({ toast({
title: `${r.inspectedCount} marked inspected`, title: `${r.inspectedCount} marked inspected`,
description: r.skippedCount ? `${r.skippedCount} skipped` : undefined, description: skippedSummary(r.skippedCount, r.results),
}); });
setSelected(new Set()); setSelected(new Set());
onChanged?.(); onChanged?.();
@@ -1300,7 +1417,14 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
leftSection={<ClipboardCheck size={14} />} leftSection={<ClipboardCheck size={14} />}
disabled={selected.size === 0} disabled={selected.size === 0}
loading={inspectMutation.isPending} loading={inspectMutation.isPending}
onClick={markInspected} onClick={() =>
setConfirmAction({
title: 'Mark inspected',
message: `Mark ${selected.size} selected item(s) as inspection PASSED?`,
confirmLabel: `Mark ${selected.size} inspected`,
run: markInspected,
})
}
> >
Mark Selected as Inspected Mark Selected as Inspected
</Button> </Button>
@@ -1315,10 +1439,11 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
No received export items awaiting inspection. No received export items awaiting inspection.
</Text> </Text>
) : ( ) : (
<Table.ScrollContainer minWidth={1700}> <Table.ScrollContainer minWidth={1350}>
<Table highlightOnHover verticalSpacing="xs" striped> <Table highlightOnHover verticalSpacing="xs" striped>
<Table.Thead> <Table.Thead>
<Table.Tr> <Table.Tr>
<Table.Th w={34} />
<Table.Th w={40}> <Table.Th w={40}>
<Checkbox <Checkbox
aria-label="Select all" aria-label="Select all"
@@ -1329,8 +1454,6 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
</Table.Th> </Table.Th>
<Table.Th>Booking Ref</Table.Th> <Table.Th>Booking Ref</Table.Th>
<Table.Th>GRN</Table.Th> <Table.Th>GRN</Table.Th>
<Table.Th>Booking ID</Table.Th>
<Table.Th>Customer ID</Table.Th>
<Table.Th>Customer Name</Table.Th> <Table.Th>Customer Name</Table.Th>
<Table.Th>Container / Cargo Items</Table.Th> <Table.Th>Container / Cargo Items</Table.Th>
<Table.Th>Cargo Type</Table.Th> <Table.Th>Cargo Type</Table.Th>
@@ -1345,7 +1468,18 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
{rows.map((r: ReadyToLoadRow) => { {rows.map((r: ReadyToLoadRow) => {
const selectable = r.inspectionStatus !== 'PASSED'; const selectable = r.inspectionStatus !== 'PASSED';
return ( return (
<Table.Tr key={r.id}> <Fragment key={r.id}>
<Table.Tr>
<Table.Td>
<ActionIcon
variant="subtle"
color="gray"
aria-label="Show containers"
onClick={() => setExpandedRow(expandedRow === r.id ? null : r.id)}
>
{expandedRow === r.id ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</ActionIcon>
</Table.Td>
<Table.Td> <Table.Td>
<Checkbox <Checkbox
aria-label={`Select ${r.bookingReference ?? r.id}`} aria-label={`Select ${r.bookingReference ?? r.id}`}
@@ -1355,20 +1489,11 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
/> />
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Stack gap={2}>
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text> <Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
</Stack>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} /> <GrnDocumentButton inventoryId={r.id} grnNumber={r.grnNumber} />
</Table.Td> </Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">{r.bookingId ? `${r.bookingId.slice(0, 8)}` : '—'}</Text>
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">{r.customerId ? `${r.customerId.slice(0, 8)}` : '—'}</Text>
</Table.Td>
<Table.Td>{r.customerName ?? '—'}</Table.Td> <Table.Td>{r.customerName ?? '—'}</Table.Td>
<Table.Td>{r.containerNumber ?? '—'}</Table.Td> <Table.Td>{r.containerNumber ?? '—'}</Table.Td>
<Table.Td>{r.cargoType ?? '—'}</Table.Td> <Table.Td>{r.cargoType ?? '—'}</Table.Td>
@@ -1382,9 +1507,7 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
</Badge> </Badge>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Badge color="blue" variant="light" size="sm"> <InventoryStatusBadge status={r.status as InventoryStatus} />
{r.status}
</Badge>
</Table.Td> </Table.Td>
<Table.Td ta="right"> <Table.Td ta="right">
<Button size="compact-xs" variant="light" color="orange" onClick={() => setInspectId(r.id)}> <Button size="compact-xs" variant="light" color="orange" onClick={() => setInspectId(r.id)}>
@@ -1392,6 +1515,14 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
</Button> </Button>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
{expandedRow === r.id && (
<BookingItemsExpansion
bookingId={r.bookingId}
colSpan={18}
bulkFallback={r.cargoType ? `Bulk cargo — ${r.cargoType}, ${formatNumber(Number(r.weight))} t` : undefined}
/>
)}
</Fragment>
); );
})} })}
</Table.Tbody> </Table.Tbody>
@@ -1404,6 +1535,7 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
opened={Boolean(inspectId)} opened={Boolean(inspectId)}
onClose={() => setInspectId(null)} onClose={() => setInspectId(null)}
/> />
<ConfirmActionModal action={confirmAction} onClose={() => setConfirmAction(null)} />
</Stack> </Stack>
); );
} }
@@ -1414,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 allSelected = rows.length > 0 && selected.size === rows.length; const [targetScheduleId, setTargetScheduleId] = useState<string | null>(null);
const someSelected = selected.size > 0 && !allSelected; // Loading is always onto a SPECIFIC pre-dispatch train. No train -> no auto-load.
const toggleAll = () => setSelected(allSelected ? new Set() : new Set(rows.map((r) => r.id))); const { data: trains = [], isLoading: trainsLoading } = useQuery({
const toggleOne = (id: string) => queryKey: ['warehouse-inventory', 'loadable-trains'],
setSelected((prev) => { queryFn: () => warehouseService.getLoadableTrains(),
const next = new Set(prev); enabled: enabled && trainPickerOpen,
next.has(id) ? next.delete(id) : next.add(id); });
return next; 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 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) });
@@ -1452,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" />
@@ -1469,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>
@@ -1496,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>
@@ -1532,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>
@@ -1563,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(),
); );
@@ -1587,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?.();
@@ -1617,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>
@@ -1627,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>
@@ -1644,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>
@@ -1658,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>
@@ -1672,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
@@ -1683,20 +1888,21 @@ function LoadedExportTab({
</Table.Td> </Table.Td>
)} )}
<Table.Td> <Table.Td>
<Stack gap={2}> <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>
<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>
@@ -1705,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>
); );
} }
@@ -1816,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>
@@ -1852,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>
@@ -1950,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>>
@@ -1991,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)
@@ -2087,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>
@@ -2126,6 +2339,7 @@ function ImportArriveQueueTab({
</Table> </Table>
</Table.ScrollContainer> </Table.ScrollContainer>
)} )}
<ConfirmActionModal action={confirmAction} onClose={() => setConfirmAction(null)} />
</Stack> </Stack>
); );
} }
@@ -2146,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);
@@ -2177,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'] });
@@ -2281,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>
@@ -2297,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"
@@ -2309,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>
@@ -2328,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}`}
@@ -2337,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>
</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.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>
@@ -2369,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">
@@ -2463,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>
@@ -2491,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>
); );
} }

View File

@@ -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
// the same container number on two lines, so dedupe defensively.
const containerSelectData = [
...new Map(
containerWeights.map((c) => [
c.containerNumber,
{
value: c.containerNumber, value: c.containerNumber,
label: `${c.containerNumber} · ${(Number(c.weightTons) || 0).toLocaleString()} t`, 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

View File

@@ -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 =

View File

@@ -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));

View File

@@ -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
opened={Boolean(articleDraft)}
onClose={() => setArticleDraft(null)}
title={articleDraft?.id ? "Edit article" : "Add article"}
size="xl"
>
{articleDraft && ( {articleDraft && (
<Stack gap="sm"> <ArticleEditorModal
<TextInput initial={articleDraft}
label="Article title" saving={addArticle.isPending || updateArticle.isPending}
placeholder="e.g. Obligations of the Client" onClose={() => setArticleDraft(null)}
value={articleDraft.title} onSave={saveArticle}
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;

View File

@@ -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 ? (
<Center h={320}>
<Loader color="edr-green" />
</Center>
) : (
<SimpleGrid cols={{ base: 1, md: 2, xl: 3 }} spacing="lg"> <SimpleGrid cols={{ base: 1, md: 2, xl: 3 }} spacing="lg">
{(templates ?? []).map((template) => { {isLoading
const direction = templateDirection(template.code); ? Array.from({ length: 6 }, (_, i) => <TemplateCardSkeleton key={i} />)
return ( : (templates ?? []).map((template) => (
<Card key={template.code} withBorder radius="xl" padding="lg"> <TemplateCard
<Stack gap="sm" h="100%"> key={template.code}
<Group justify="space-between" align="flex-start"> template={template}
<ThemeIcon onPreview={() => setPreviewCode(template.code)}
size={44} onEdit={() =>
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}`) navigate(`/dashboard/contract-templates/${template.code}`)
} }
> />
Edit articles ))}
</Button>
</Group>
</Stack>
</Card>
);
})}
</SimpleGrid> </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>
);
}

View File

@@ -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 AZ" },
];
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 rows = useMemo<ShipmentListRow[]>(() => { const allRows = useMemo<ShipmentListRow[]>(
const all = (data ?? []).map((r) => ({ () =>
(data ?? []).map((r) => {
const lines = r.requestedLines ?? {};
return {
id: r.id, id: r.id,
reference: r.reference || r.id.slice(0, 8), reference: r.reference || r.id.slice(0, 8),
contractId: r.contractId, contractId: r.contractId,
contractReference: r.contract?.reference ?? r.contractId, contractReference: r.contract?.reference ?? r.contractId,
scheduledDate: r.scheduledDate, scheduledDate: r.scheduledDate,
summary: summarizeLines(r.requestedLines ?? {}), summary: summarizeLines(lines),
status: r.status, status: r.status,
createdBookingId: r.createdBookingId, 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[]>(() => {
let out = allRows;
if (status !== "ALL") out = out.filter((r) => r.status === status);
if (cargo !== "ALL") out = out.filter((r) => r.freightKind === cargo);
// Preferred-date range: rows without a preferred day drop out once a bound
// is set — a date filter that keeps dateless rows reads as broken.
if (preferredFrom || preferredTo) {
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.customerName ?? "").toLowerCase().includes(q) ||
r.summary.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 }) => (
<Box>
<Text size="sm" fw={700} c="dark.5"> <Text size="sm" fw={700} c="dark.5">
{row.original.reference} {row.original.reference}
</Text> </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 }) => (
<Box>
<Text size="sm" c="gray.7"> <Text size="sm" c="gray.7">
{row.original.contractReference} {row.original.contractReference}
</Text> </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 }) => (
<Group gap={6} wrap="wrap">
<Badge variant="light" color="edr-green" radius="sm"> <Badge variant="light" color="edr-green" radius="sm">
{row.original.summary} {row.original.summary}
</Badge> </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() {
} }
/> />
<Paper withBorder radius="lg" p="md" style={{ borderColor: "#E6ECF2" }}>
<Stack gap="sm">
<Group gap="sm" wrap="wrap">
<TextInput <TextInput
radius="md" radius="md"
maw={360} style={{ flex: 1, minWidth: 220 }}
placeholder="Search request, contract, cargo…" placeholder="Search request, contract, customer, cargo…"
leftSection={<Search size={15} />} leftSection={<Search size={15} />}
rightSection={
query ? (
<CloseButton
size="sm"
aria-label="Clear search"
onClick={() => setQuery("")}
/>
) : null
}
value={query} value={query}
onChange={(e) => setQuery(e.currentTarget.value)} 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" />
{hasAnyRequests ? (
<>
<Text c="dimmed" mt="sm"> <Text c="dimmed" mt="sm">
No pending shipment requests. No requests match the current filters.
</Text> </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

View File

@@ -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",

View File

@@ -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 =

View File

@@ -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: () =>

View File

@@ -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 };

View File

@@ -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

View File

@@ -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>

View File

@@ -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,6 +109,7 @@ 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">
{!hasCustoms && (
<DatePickerInput <DatePickerInput
label="Preferred shipment date" label="Preferred shipment date"
placeholder="Pick a date" placeholder="Pick a date"
@@ -114,9 +120,15 @@ export default function NewShipmentRequestPage() {
radius="md" radius="md"
popoverProps={{ withinPortal: true }} 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}

View File

@@ -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"],

View File

@@ -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' })

View File

@@ -184,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];
@@ -210,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 },
}; };
} }

View File

@@ -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>
<div className="relative">
<input <input
type="password" type={showPassword ? 'text' : 'password'}
{...register('password')} {...register('password')}
className="input-field" className="input-field pr-10"
placeholder="••••••••" 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>
)} )}

View File

@@ -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')}

View File

@@ -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;