diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 62530611c..5e1f46ad0 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -17,10 +17,23 @@ jobs: outputs: matrix: ${{ steps.filter.outputs.matrix }} steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 2 + # Plain git instead of actions/checkout: self-hosted runners on this + # network intermittently time out downloading action tarballs from + # codeload.github.com (100s HttpClient limit x3 = dead job). git fetch + # talks to github.com directly and needs no action download at all. + - name: Checkout (plain git, depth 2) + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + git init -q . + git remote remove origin 2>/dev/null || true + git remote add origin "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" + git fetch -q --depth 2 origin "${{ github.sha }}" + git checkout -q --force "${{ github.sha }}" + git clean -ffdq + # Don't leave the token in .git/config on the persistent runner workspace. + git remote set-url origin "https://github.com/${{ github.repository }}.git" - name: Determine changed services id: filter @@ -103,8 +116,20 @@ jobs: COMPOSE_DOCKER_CLI_BUILD: "1" steps: - - name: Checkout - uses: actions/checkout@v4 + # Same rationale as detect-changes: no action download on this network. + - name: Checkout (plain git) + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + git init -q . + git remote remove origin 2>/dev/null || true + git remote add origin "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" + git fetch -q --depth 1 origin "${{ github.sha }}" + git checkout -q --force "${{ github.sha }}" + git clean -ffdq + # Don't leave the token in .git/config on the persistent runner workspace. + git remote set-url origin "https://github.com/${{ github.repository }}.git" - name: Resolve project and build env file run: | diff --git a/.gitignore b/.gitignore index ffdc4b78b..ca2a5b7af 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ coverage/ *~ \#*\# .\#* +docker-compose.override.yml diff --git a/apps/edr-freight-api/src/migrations/2100000000000-WarehouseLoadingTrainAssociation.ts b/apps/edr-freight-api/src/migrations/2100000000000-WarehouseLoadingTrainAssociation.ts new file mode 100644 index 000000000..26afdf82a --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2100000000000-WarehouseLoadingTrainAssociation.ts @@ -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 { + 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 { + 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. + } +} diff --git a/apps/edr-freight-api/src/modules/billing/documents/styled-pdf.util.ts b/apps/edr-freight-api/src/modules/billing/documents/styled-pdf.util.ts index b4f168e4e..d2a88b683 100644 --- a/apps/edr-freight-api/src/modules/billing/documents/styled-pdf.util.ts +++ b/apps/edr-freight-api/src/modules/billing/documents/styled-pdf.util.ts @@ -143,6 +143,20 @@ export function htmlToText(html: string): string { .trim(); } +/** + * Large rotated light-gray copy label (e.g. "Copy 1: Port Operations Copy"), + * drawn FIRST so the page content sits on top of it. 30-degree rotation via a + * text matrix; roughly centered on the page. + */ +export function watermarkOp(text: string, page: { width: number; height: number }): string { + const label = clipText(text, 46); + const size = 34; + const w = textWidth(label, size); + const x = page.width / 2 - (w * 0.866) / 2; + const y = page.height / 2 - (w * 0.5) / 2; + return `q BT 0.93 0.93 0.93 rg /F2 ${size} Tf 0.866 0.5 -0.5 0.866 ${x.toFixed(1)} ${y.toFixed(1)} Tm (${escapePdfText(label)}) Tj ET Q`; +} + /** * Parse a "summary tiles + one + notice + signature lines" document (the * marshalling / load-list layout the train-scheduling builders emit) and draw it as a @@ -150,11 +164,26 @@ export function htmlToText(html: string): string { * document, not a flat text dump. Switches to landscape when the table is wide. */ export function buildTabularFallbackPdf(html: string): Buffer { + // Documents printed in duplicate wrap each copy in
+ // (freight order: Port Operations copy + Gate Security copy). Render one + // page per copy, each with its own watermark and tile set — parsing the + // whole HTML at once would merge both copies' tiles and drop the watermarks. + const copies = [...html.matchAll(/
([\s\S]*?)<\/section>/gi)].map((m) => m[1]); + const fragments = copies.length ? copies : [html]; + return assemblePdf(fragments.flatMap((fragment) => buildTabularPageOps(fragment))); +} + +function buildTabularPageOps( + html: string, +): Array<{ ops: string[]; page: { width: number; height: number } }> { const pick = (re: RegExp) => html.match(re)?.[1]; const title = htmlToText(pick(/]*>([\s\S]*?)<\/h1>/i) ?? "Document"); const subtitle = htmlToText(pick(/class="subtitle"[^>]*>([\s\S]*?)<\/div>/i) ?? ""); const metaRef = htmlToText(pick(/class="meta"[\s\S]*?([\s\S]*?)<\/strong>/i) ?? ""); + const metaLabel = + htmlToText(pick(/class="meta"[^>]*>([\s\S]*?)]*>([\s\S]*?)<\/div>/i) ?? ""); const tiles: Array<[string, string]> = []; for (const m of html.matchAll( @@ -180,24 +209,49 @@ export function buildTabularFallbackPdf(html: string): Buffer { const M = 32; const contentW = page.width - M * 2; const right = page.width - M; - const ops: string[] = []; + const MAX_PAGES = 12; - // Header - ops.push(lineOp(M, page.height - 28, right, page.height - 28, PdfColor.teal, 2.4)); - ops.push(textOp("ETHIO-DJIBOUTI RAILWAY S.C.", M, page.height - 44, 8.5, "F2", PdfColor.gray)); - ops.push(textOp(clipText(title, landscape ? 82 : 52), M, page.height - 68, 19, "F2", PdfColor.dark)); - if (subtitle) ops.push(textOp(clipText(subtitle, 96), M, page.height - 82, 9, "F1", PdfColor.gray)); - if (metaRef) { - ops.push(textOpRight("TRAIN / SCHEDULE", right, page.height - 42, 7.5, "F2", PdfColor.gray)); - ops.push(textOpRight(clipText(metaRef, 28), right, page.height - 58, 12, "F2", PdfColor.dark)); - } - if (generated) { - ops.push(textOpRight(clipText(`Generated ${generated}`, 40), right, page.height - 72, 8, "F1", PdfColor.gray)); - } - ops.push(lineOp(M, page.height - 92, right, page.height - 92, PdfColor.line, 1)); + const pagesOut: Array<{ ops: string[]; page: { width: number; height: number } }> = []; + let ops: string[] = []; + let y = 0; - // Summary tiles - let y = page.height - 100; + const drawFullHeader = () => { + ops.push(lineOp(M, page.height - 28, right, page.height - 28, PdfColor.teal, 2.4)); + ops.push(textOp("ETHIO-DJIBOUTI RAILWAY S.C.", M, page.height - 44, 8.5, "F2", PdfColor.gray)); + ops.push(textOp(clipText(title, landscape ? 82 : 52), M, page.height - 68, 19, "F2", PdfColor.dark)); + if (subtitle) ops.push(textOp(clipText(subtitle, 96), M, page.height - 82, 9, "F1", PdfColor.gray)); + if (metaRef) { + ops.push(textOpRight(clipText(metaLabel, 26), right, page.height - 42, 7.5, "F2", PdfColor.gray)); + ops.push(textOpRight(clipText(metaRef, 28), right, page.height - 58, 12, "F2", PdfColor.dark)); + } + if (generated) { + ops.push(textOpRight(clipText(`Generated ${generated}`, 40), right, page.height - 72, 8, "F1", PdfColor.gray)); + } + ops.push(lineOp(M, page.height - 92, right, page.height - 92, PdfColor.line, 1)); + y = page.height - 100; + }; + + const drawContinuationHeader = (pageNo: number) => { + ops.push(lineOp(M, page.height - 24, right, page.height - 24, PdfColor.teal, 1.6)); + ops.push( + textOp(clipText(`${title} (continued — page ${pageNo})`, landscape ? 100 : 68), M, page.height - 42, 11, "F2", PdfColor.dark), + ); + if (metaRef) ops.push(textOpRight(clipText(metaRef, 28), right, page.height - 42, 10, "F2", PdfColor.gray)); + y = page.height - 54; + }; + + const startPage = (first: boolean) => { + ops = []; + if (watermark) ops.push(watermarkOp(watermark, page)); + if (first) drawFullHeader(); + else drawContinuationHeader(pagesOut.length + 1); + }; + + const finishPage = () => pagesOut.push({ ops, page }); + + startPage(true); + + // Summary tiles (first page only) if (tiles.length) { const cols = landscape ? 6 : 4; const tileW = contentW / cols; @@ -213,21 +267,34 @@ export function buildTabularFallbackPdf(html: string): Buffer { y -= tileH + 12; } - // Table + // Table, paginated across as many pages as the rows need. if (headers.length) { const colW = contentW / headers.length; const headerH = 16; const rowH = 14; const cellChars = Math.max(4, Math.floor(colW / 3.9)); - ops.push(rectOp(M, y - headerH, contentW, headerH, PdfColor.tint, PdfColor.line, 0.6)); - headers.forEach((h, c) => - ops.push(textOp(clipText(h, cellChars), M + c * colW + 4, y - 11, 7, "F2", PdfColor.teal)), - ); - y -= headerH; + const bottomReserve = 46; // keep clear of the page edge on row-only pages - let shown = 0; - for (const row of rows) { - if (y < 96) break; + const drawTableHeader = () => { + ops.push(rectOp(M, y - headerH, contentW, headerH, PdfColor.tint, PdfColor.line, 0.6)); + headers.forEach((h, c) => + ops.push(textOp(clipText(h, cellChars), M + c * colW + 4, y - 11, 7, "F2", PdfColor.teal)), + ); + y -= headerH; + }; + + drawTableHeader(); + let truncated = 0; + for (const [index, row] of rows.entries()) { + if (y - rowH < bottomReserve) { + if (pagesOut.length + 1 >= MAX_PAGES) { + truncated = rows.length - index; + break; + } + finishPage(); + startPage(false); + drawTableHeader(); + } ops.push(rectOp(M, y - rowH, contentW, rowH, "1 1 1", PdfColor.line, 0.4)); headers.forEach((_h, c) => { if (c > 0) ops.push(lineOp(M + c * colW, y - rowH, M + c * colW, y, PdfColor.line, 0.3)); @@ -235,30 +302,33 @@ export function buildTabularFallbackPdf(html: string): Buffer { if (cell) ops.push(textOp(clipText(cell, cellChars), M + c * colW + 4, y - 10, 6.8, "F1", PdfColor.dark)); }); y -= rowH; - shown += 1; } - if (shown < rows.length) { - ops.push(textOp(`... ${rows.length - shown} more row(s) not shown`, M, y - 10, 7, "F1", PdfColor.gray)); + if (truncated > 0) { + ops.push(textOp(`... ${truncated} more row(s) not shown`, M, y - 10, 7, "F1", PdfColor.gray)); } } - // Notice (verification clause) + // Notice + signatures live on the final page; give them a fresh page when the + // rows ran too deep for the fixed bottom band. + if (y < 110 && (notice || signatures.length)) { + finishPage(); + startPage(false); + } if (notice) { ops.push(lineOp(M, 78, M, 54, PdfColor.teal, 2)); wrapText(notice, landscape ? 155 : 104) .slice(0, 2) .forEach((ln, i) => ops.push(textOp(ln, M + 8, 72 - i * 11, 7.5, "F1", PdfColor.gray))); } - - // Signatures const sigW = contentW / signatures.length; - signatures.forEach((s, i) => { + signatures.forEach((sig, i) => { const x = M + i * sigW; ops.push(lineOp(x, 40, x + sigW - 18, 40, PdfColor.dark, 0.7)); - ops.push(textOp(clipText(s, Math.floor((sigW - 18) / 3.6)), x, 30, 7, "F1", PdfColor.gray)); + ops.push(textOp(clipText(sig, Math.floor((sigW - 18) / 3.6)), x, 30, 7, "F1", PdfColor.gray)); }); + finishPage(); - return assembleSinglePagePdf(ops, page); + return pagesOut; } /** Greedy word-wrap to a maximum character width. */ @@ -320,3 +390,41 @@ export function assembleSinglePagePdf( pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`; return Buffer.from(pdf, "latin1"); } + +/** Assemble a multi-page PDF; one content stream per page, shared Helvetica fonts. */ +export function assemblePdf( + pages: Array<{ ops: string[]; page: { width: number; height: number } }>, +): Buffer { + const kids = pages.map((_, i) => `${5 + i * 2} 0 R`).join(" "); + const objects: string[] = [ + "<< /Type /Catalog /Pages 2 0 R >>", + `<< /Type /Pages /Kids [${kids}] /Count ${pages.length} >>`, + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>", + ]; + for (const [i, p] of pages.entries()) { + const stream = p.ops.join("\n"); + objects.push( + `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${p.page.width} ${p.page.height}] /Resources << /Font << /F1 3 0 R /F2 4 0 R >> >> /Contents ${6 + i * 2} 0 R >>`, + ); + objects.push(`<< /Length ${Buffer.byteLength(stream, "latin1")} >>\nstream\n${stream}\nendstream`); + } + + let pdf = "%PDF-1.4\n"; + const offsets: number[] = [0]; + objects.forEach((object, index) => { + offsets.push(Buffer.byteLength(pdf, "latin1")); + pdf += `${index + 1} 0 obj\n${object}\nendobj\n`; + }); + while (Buffer.byteLength(pdf, "latin1") < MIN_VALID_PDF_BYTES) { + pdf += "% fallback padding\n"; + } + const xrefOffset = Buffer.byteLength(pdf, "latin1"); + pdf += `xref\n0 ${objects.length + 1}\n`; + pdf += "0000000000 65535 f \n"; + for (const offset of offsets.slice(1)) { + pdf += `${String(offset).padStart(10, "0")} 00000 n \n`; + } + pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`; + return Buffer.from(pdf, "latin1"); +} diff --git a/apps/edr-freight-api/src/modules/contracts/booking-request.repository.ts b/apps/edr-freight-api/src/modules/contracts/booking-request.repository.ts index 0ae829529..d0705bfb1 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-request.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-request.repository.ts @@ -22,12 +22,15 @@ export class BookingRequestRepository extends BaseRepository { }); } - /** GL queue: pending requests across all contracts, oldest first. */ - async findPending(): Promise { + /** + * 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 { return this.repository.find({ - where: { status: 'PENDING' }, - order: { createdAt: 'ASC' }, - relations: { contract: true }, + order: { createdAt: 'DESC' }, + relations: { contract: { company: true } }, }); } diff --git a/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts index e038c7bff..17270c738 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts @@ -139,7 +139,7 @@ export class BookingRequestService { } queue(): Promise { - return this.repo.findPending(); + return this.repo.findQueue(); } private async findPending(requestId: string): Promise { diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index 70083a2ae..9a03bfe8a 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -218,7 +218,7 @@ export class ContractBookingService { contractType: 'NEW', customsClearingEnabled: contract.customsClearingEnabled, customsClearingAgent: contract.customsClearingAgent ?? null, - equipmentReturn: contract.equipmentReturn ?? 'WITHOUT_RETURN', + equipmentReturn: dto.equipmentReturn ?? contract.equipmentReturn ?? 'WITHOUT_RETURN', originYardId: route?.originYardId ?? null, destinationYardId: route?.destinationYardId ?? null, tradeDirection: contract.tradeDirection, diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index 4ea7634b6..7f75b12c8 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -107,7 +107,7 @@ export class ContractsController { @Get('booking-requests/queue') @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() { return this.bookingRequestService.queue(); } diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts index f220cae0d..870817365 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts @@ -4,6 +4,7 @@ import { IsArray, IsBoolean, IsDateString, + IsIn, IsInt, IsNumber, IsOptional, @@ -14,6 +15,9 @@ import { ValidateNested, } 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. */ export class CreateContainerUnitDto { @ApiProperty({ description: 'ISO 6346 container number, e.g. ABCD1234567' }) @@ -134,6 +138,15 @@ export class CreateBookingUnderContractDto { @IsDateString() 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] }) @IsOptional() @IsArray() diff --git a/apps/edr-freight-api/src/modules/routes/entities/route.entity.ts b/apps/edr-freight-api/src/modules/routes/entities/route.entity.ts index 23399bb4d..238129a1d 100644 --- a/apps/edr-freight-api/src/modules/routes/entities/route.entity.ts +++ b/apps/edr-freight-api/src/modules/routes/entities/route.entity.ts @@ -43,11 +43,26 @@ export class Route extends BaseEntity { * 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 * 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: { originYard?: { code?: string; label?: string } | null; destinationYard?: { code?: string; label?: string } | null; + milestones?: Array<{ + sequenceNo: number; + yard?: { code?: string; label?: string } | null; + }> | null; }): 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 dest = route.destinationYard?.label ?? route.destinationYard?.code ?? 'Destination'; return `${origin} → ${dest}`; diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts index a64faabbe..6a8aced53 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts @@ -23,8 +23,9 @@ export class TrainSchedulesRepository extends BaseRepository { where: { id }, relations: { // Yards carry the route's display name; without them formatRouteLabel - // degrades to the literal "Origin → Destination". - route: { originYard: true, destinationYard: true }, + // degrades to the literal "Origin → Destination". Milestones (with + // their yards) give it the full corridor path. + route: { originYard: true, destinationYard: true, milestones: { yard: true } }, trainSet: { locomotive: true, locomotives: { locomotive: true }, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 7dd78d167..d51108043 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -31,7 +31,6 @@ import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity'; import { TrainSchedulesRepository } from '../train-schedules/train-schedules.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 { TrainSchedulingService } from './train-scheduling.service'; 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 required = need ?? this.needFor(booking, wagonDims); let corridorMatched = false; @@ -578,7 +576,7 @@ export class BookingBatchService implements OnModuleInit { ); const locomotive = schedule?.trainSet?.locomotive; 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 leg = budget.legOf(booking.originYardId, booking.destinationYardId); 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 }, originStation: true, destinationStation: true, - // Yards supply the route's display name for `routeName` below. - route: { originYard: true, destinationYard: true }, + // Yards supply the route's display name for `routeName` below; + // milestones (with yards) give it the full corridor path. + route: { originYard: true, destinationYard: true, milestones: { yard: true } }, }, order: { [sortBy]: sortOrder } as never, skip: (page - 1) * pageSize, @@ -757,7 +756,6 @@ export class BookingBatchService implements OnModuleInit { }); const wagonDims = await this.loadWagonDims(); - const rules = await this.loadGlobalRules(); const linkRepo = this.dataSource.getRepository(TrainScheduleBooking); 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 { @@ -817,7 +815,6 @@ export class BookingBatchService implements OnModuleInit { } const wagonDims = await this.loadWagonDims(); - const rules = await this.loadGlobalRules(); const linkRepo = this.dataSource.getRepository(TrainScheduleBooking); const links = await linkRepo.find({ where: { trainScheduleId: s.id } }); const linkedIds = new Set(links.map((l) => l.bookingId)); @@ -1011,7 +1008,7 @@ export class BookingBatchService implements OnModuleInit { maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), } : null, - capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null, rules), + capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null), counts: { allocated: items.filter((i) => i.state === "ALLOCATED").length, 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 * 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 - * by the global rule caps and widened by its overage tolerance. Reading the raw - * `loco.maxPullWeightTons` here showed staff a ceiling the batch engine did not use. + * against must be the same one the fill loop spends from: the locomotive's own + * limits widened by its overage tolerance (global rule caps do not apply, same + * as {@link capacityLimits}). Reading the raw `loco.maxPullWeightTons` here + * showed staff a ceiling the batch engine did not use. */ private computeBoardCapacity( items: Array<{ @@ -1058,29 +1056,18 @@ export class BookingBatchService implements OnModuleInit { }>, loco: Locomotive | null, maxWagons: number | null, - rules: TrainSchedulingGlobalRules | null, ): BatchBoardSchedule["capacity"] { const allocated = items.filter((i) => i.state === "ALLOCATED"); const committed = items.filter( (i) => i.state === "ALLOCATED" || i.state === "SELECTED_FOR_BATCH", ); const caps = loco - ? trainHardCaps( - { - maxPullWeightTons: Number(loco.maxPullWeightTons), - maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), - overageToleranceTons: Number(loco.overageToleranceTons) || 0, - overageToleranceMeters: Number(loco.overageToleranceMeters) || 0, - }, - { - maxTrainWeightTons: rules?.maxTrainWeightTons - ? Number(rules.maxTrainWeightTons) - : undefined, - maxTrainLengthMeters: rules?.maxTrainLengthMeters - ? Number(rules.maxTrainLengthMeters) - : undefined, - }, - ) + ? trainHardCaps({ + maxPullWeightTons: Number(loco.maxPullWeightTons), + maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), + overageToleranceTons: Number(loco.overageToleranceTons) || 0, + overageToleranceMeters: Number(loco.overageToleranceMeters) || 0, + }) : null; const round2 = (value: number) => Math.round(value * 100) / 100; @@ -1099,7 +1086,6 @@ export class BookingBatchService implements OnModuleInit { private buildScheduleSummary( s: TrainSchedule, items: BatchBoardBooking[], - rules: TrainSchedulingGlobalRules | null, ): BatchBoardSchedule { const loco = s.trainSet?.locomotive ?? null; @@ -1134,7 +1120,7 @@ export class BookingBatchService implements OnModuleInit { maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), } : null, - capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null, rules), + capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null), counts: { allocated: items.filter((i) => i.state === "ALLOCATED").length, selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH") @@ -1203,10 +1189,9 @@ export class BookingBatchService implements OnModuleInit { return 0; } - const rules = await this.loadGlobalRules(); const wagonDims = await this.loadWagonDims(); - const limits = await this.capacityLimits(locomotive, rules); - await this.syncScheduleMaxWagons(schedule, locomotive, rules); + const limits = await this.capacityLimits(locomotive); + await this.syncScheduleMaxWagons(schedule, locomotive); const budget = await this.remainingBudget(schedule, limits, wagonDims); const minPerWagon = this.minPerWagonNeed(wagonDims); if (budget.isExhausted(minPerWagon)) { @@ -1405,7 +1390,6 @@ export class BookingBatchService implements OnModuleInit { return { scheduleIds: [], commercialReserved: 0 }; } - const rules = await this.loadGlobalRules(); const wagonDims = await this.loadWagonDims(); // Live per-schedule corridor budget + arm flag, in departure order. @@ -1420,8 +1404,8 @@ export class BookingBatchService implements OnModuleInit { ); continue; } - const limits = await this.capacityLimits(locomotive, rules); - await this.syncScheduleMaxWagons(schedule, locomotive, rules); + const limits = await this.capacityLimits(locomotive); + await this.syncScheduleMaxWagons(schedule, locomotive); const budget = await this.remainingBudget(schedule, limits, wagonDims); trains.push({ id, budget, armed: false }); } @@ -1973,9 +1957,8 @@ export class BookingBatchService implements OnModuleInit { await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); const locomotive = schedule?.trainSet?.locomotive; if (!schedule || !locomotive) return null; - const rules = await this.loadGlobalRules(); 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); 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 * overage tolerance is returned separately — the corridor budget spends it * 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( - locomotive: Locomotive, - rules: TrainSchedulingGlobalRules | null, - ): Promise { + private async capacityLimits(locomotive: Locomotive): Promise { const wagonTypes = await this.loadWagonTypeDimensions(); const derived = deriveTrainCapacityFromLocomotive( { @@ -2534,14 +2518,6 @@ export class BookingBatchService implements OnModuleInit { overageToleranceMeters: Number(locomotive.overageToleranceMeters) || 0, }, wagonTypes, - { - maxTrainWeightTons: rules?.maxTrainWeightTons - ? Number(rules.maxTrainWeightTons) - : undefined, - maxTrainLengthMeters: rules?.maxTrainLengthMeters - ? Number(rules.maxTrainLengthMeters) - : undefined, - }, ); return { 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( schedule: TrainSchedule, locomotive: Locomotive, - rules: TrainSchedulingGlobalRules | null, ): Promise { - const limits = await this.capacityLimits(locomotive, rules); - if ((schedule.maxWagons ?? 0) !== limits.base.wagons) { + const limits = await this.capacityLimits(locomotive); + const maxWagons = limits.base.wagons; + if ((schedule.maxWagons ?? 0) !== maxWagons) { await this.dataSource .getRepository(TrainSchedule) - .update(schedule.id, { maxWagons: limits.base.wagons }); - schedule.maxWagons = limits.base.wagons; + .update(schedule.id, { maxWagons }); + schedule.maxWagons = maxWagons; } } @@ -2655,12 +2636,6 @@ export class BookingBatchService implements OnModuleInit { }; } - private async loadGlobalRules(): Promise { - return this.dataSource - .getRepository(TrainSchedulingGlobalRules) - .findOne({ where: {} }); - } - /** * Ordered stop yards of the schedule's route (origin → milestones → * 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 + * reserved bookings already use ON THEIR OWN LEGS. A booking riding only * 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( schedule: TrainSchedule, @@ -2795,9 +2775,8 @@ export class BookingBatchService implements OnModuleInit { if ((await this.remainingWagons(schedule)) <= 0) return true; const locomotive = schedule.trainSet?.locomotive; if (!locomotive) return false; // no weight/length limits to bind against - const rules = await this.loadGlobalRules(); 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); return budget.isExhausted(this.minPerWagonNeed(wagonDims)); } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts index 48985bdf0..c189825fd 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts @@ -1,6 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { NotificationAudience, + NotificationPriority, NotificationType, NotifyInput, } from '@edr/types'; @@ -114,11 +115,15 @@ export class BookingNotifierService { const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' }); const msg = `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 ` + - `(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.`; + `Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to accept and ship ${offeredWagons} wagon${offeredWagons === 1 ? '' : 's'} now. ` + + `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)'); + // 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, { type: NotificationType.INVOICE_ISSUED, + priority: NotificationPriority.HIGH, }); } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index f53af2a93..eee880a6b 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -2645,8 +2645,9 @@ export class TrainSchedulingService { const schedules = await this.trainSchedulesRepository.findAll({ relations: { trainSet: { locomotive: true, locomotives: { locomotive: true } }, - // Yards carry the route's display name used by mapScheduleListItem. - route: { originYard: true, destinationYard: true }, + // Yards carry the route's display name used by mapScheduleListItem; + // milestones (with yards) let it show the full corridor path. + route: { originYard: true, destinationYard: true, milestones: { yard: true } }, originStation: true, destinationStation: true, scheduleBookings: { booking: true }, @@ -3111,6 +3112,10 @@ export class TrainSchedulingService { const wagonTypes = await this.loadSchedulingWagonTypeDimensions(); 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( { maxPullWeightTons: Number(locomotive.maxPullWeightTons), @@ -3120,8 +3125,8 @@ export class TrainSchedulingService { }, wagonTypes, { - maxTrainWeightTons: ruleWeightCap, - maxTrainLengthMeters: ruleLengthCap, + maxTrainWeightTons: dto?.maxTrainWeightTons, + maxTrainLengthMeters: dto?.maxTrainLengthMeters, }, ); return { diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/load-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/load-inventory.dto.ts index c81550cd0..3c9c6c85f 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/load-inventory.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/load-inventory.dto.ts @@ -6,6 +6,11 @@ export class LoadInventoryDto { @IsUUID() 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)' }) @IsOptional() @IsNumber() diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-loading.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-loading.entity.ts index 5f6952aec..1698b8a5e 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-loading.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-loading.entity.ts @@ -28,9 +28,17 @@ export class WarehouseLoading extends BaseEntity { @JoinColumn({ name: 'booking_id' }) booking?: Booking | null; - /** Physical wagon the item was loaded onto. References freight.wagons (read-only link). */ - @Column({ name: 'wagon_id', type: 'uuid' }) - wagonId!: string; + /** + * Physical wagon the item was loaded onto. References freight.wagons + * (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' }) loadedAt!: Date; diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index 242cecc76..070a267a4 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -77,11 +77,6 @@ export class WarehouseInventoryController { 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') @ApiOperation({ summary: 'EXPORT inventory that passed inspection and is READY_FOR_LOADING' }) diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 92617db6d..281d3f620 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -243,11 +243,6 @@ export interface BulkReceiveResult { 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 { inspectedCount: number; @@ -1098,46 +1093,6 @@ export class WarehouseInventoryService { return result; } - /** Bulk-load all EXPORT inventory that passed inspection and is READY_FOR_LOADING. */ - async loadPassedExport(performedBy?: string): Promise { - 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. */ private async exportInventoryByStatus( @@ -1319,6 +1274,29 @@ export class WarehouseInventoryService { performedBy?: string, ): Promise { 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 byId = new Map(items.map((i) => [i.id, i])); const affectedBookingIds = new Set(); @@ -1335,7 +1313,12 @@ export class WarehouseInventoryService { if (!item.wagonId) { skip('No wagon allocated — allocate a wagon first'); continue; } 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.results.push({ inventoryId, status: 'LOADED' }); if (item.bookingId) affectedBookingIds.add(item.bookingId); @@ -1614,6 +1597,8 @@ export class WarehouseInventoryService { status: 'UNLOADED', unloadedAt: now, arrivedAt: existing.arrivedAt ?? now, + // Import GRN is issued automatically at train unload. + ...(existing.grnNumber ? {} : { grnNumber: this.generateGrnNumber('IMPORT', booking.id, now) }), }); await this.activityLog.record({ activityType: 'INVENTORY_UNLOADED', @@ -1647,6 +1632,7 @@ export class WarehouseInventoryService { quantity: 1, weight: Number(booking.weight) || 0, status: 'UNLOADED', + grnNumber: this.generateGrnNumber('IMPORT', booking.id, now), arrivedAt: now, unloadedAt: now, notes: allocated?.rule ? `Unloaded → ${allocated.path}` : 'Unloaded from arrived import train', @@ -2774,11 +2760,12 @@ export class WarehouseInventoryService { const rows: Array<{ containerNumber: string; weightTons: string }> = await this.dataSource.query( `SELECT bcu.container_number AS "containerNumber", - COALESCE(bcu.vgm_tons, 0) AS "weightTons" + MAX(COALESCE(bcu.vgm_tons, 0)) AS "weightTons" FROM freight.booking_container_units bcu JOIN freight.booking_container bc ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL + GROUP BY bcu.container_number ORDER BY bcu.container_number`, [bookingId], ); @@ -3403,6 +3390,8 @@ export class WarehouseInventoryService { warehouseInventoryId: id, bookingId: item.bookingId ?? null, wagonId: dto.wagonId, + // Which train this load belongs to — durable even if wagons reshuffle. + trainScheduleId: dto.trainScheduleId ?? null, loadedAt: now, loadedBy: dto.loadedBy ?? null, loadedWeight, @@ -3441,7 +3430,7 @@ export class WarehouseInventoryService { }); // 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(); if (wagonIds.length > 0) { const rows: Array<{ id: string; wagon_number: string }> = await this.dataSource.query( @@ -3452,7 +3441,7 @@ export class WarehouseInventoryService { } return loadings.map((loading) => - Object.assign(loading, { wagonNumber: wagonNumbers.get(loading.wagonId) ?? null }), + Object.assign(loading, { wagonNumber: (loading.wagonId && wagonNumbers.get(loading.wagonId)) ?? null }), ); } diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 15c8cf19c..d9a326d91 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -196,12 +196,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.contracts.createBooking, }, - { - label: "Self-Clearance Review", - href: "/dashboard/contracts/ops-clearance", - icon: , - permission: FREIGHT_PERMS.contracts.opsClearanceReview, - }, + // { + // label: "Self-Clearance Review", + // href: "/dashboard/contracts/ops-clearance", + // icon: , + // permission: FREIGHT_PERMS.contracts.opsClearanceReview, + // }, { label: "GL Djibouti Clearance", href: "/dashboard/gl-djibouti/clearance", diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index 33254d3ce..32ec2c511 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -38,6 +38,7 @@ import { MapPin, Package, Receipt, + Repeat, X, } from "lucide-react"; import type { Freight } from "@edr/types"; @@ -192,9 +193,19 @@ export default function GlCreateBookingForm() { const [notes, setNotes] = useState(""); const [containerLines, setContainerLines] = useState([]); const [bulkLines, setBulkLines] = useState([]); + const [withReturn, setWithReturn] = useState(false); const [prefilled, setPrefilled] = useState(false); const [priceOpen, setPriceOpen] = useState(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 routes = useMemo( @@ -527,6 +538,10 @@ export default function GlCreateBookingForm() { scheduledDate, ...(contractRouteId ? { contractRouteId } : {}), ...(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) { @@ -1091,6 +1106,67 @@ export default function GlCreateBookingForm() { )} + {isContainer ? ( + + } + title="Equipment Return" + description="Choose whether the empty container(s) come back to EDR after unloading." + /> + setWithReturn((v) => !v)} + > + + + + + + + + With return + + + {withReturn + ? "Container(s) returned to EDR after unloading." + : "Container(s) retained by the customer after delivery."} + + + + setWithReturn(e.currentTarget.checked)} + onClick={(e) => e.stopPropagation()} + style={{ flexShrink: 0 }} + /> + + + + ) : null} + } diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx index de41c1a82..55722f04a 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -51,6 +51,7 @@ import { warehouseService } from '@/services/warehouse.service'; import type { EligibleBooking, InventoryInquiryFilter, + InventoryStatus, InventoryInquiryResult, ImportTrain, ImportTrainItem, @@ -63,6 +64,7 @@ import type { WarehouseYard, WarehouseZone, } from '@/types/warehouse'; +import { InventoryStatusBadge } from './badges'; import { BookingSelect } from './BookingSelect'; import { DeliverInventoryModal } from './DeliverInventoryModal'; import { ContainerItemsModal } from './ContainerItemsModal'; @@ -253,6 +255,127 @@ const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayl warehouseManagerName: form.warehouseManagerName.trim() || undefined, }); + + + +const SUB_STAGE_COLOR: Record = { + PENDING: 'gray', + RECEIVED: 'blue', + GRN: 'teal', + ASSIGNED: 'indigo', + LOADED: 'grape', + LEFT: 'orange', + DELIVERED: 'green', +}; + +/** + * Expanded booking row: the booking's containers / bulk items with their + * lifecycle stage. Shares the ['container-items', bookingId] cache with + * ContainerItemsModal, so expanding after using the modal is instant. + */ +function BookingItemsExpansion({ + bookingId, + colSpan, + bulkFallback, +}: { + bookingId: string | null; + colSpan: number; + bulkFallback?: string; +}) { + const { data: items = [], isLoading } = useQuery({ + queryKey: ['container-items', bookingId], + queryFn: () => warehouseService.getContainerItems(bookingId as string), + enabled: Boolean(bookingId), + }); + + return ( + + + {isLoading ? ( + + + + ) : items.length === 0 ? ( + + {bulkFallback ?? 'No container units recorded on this booking.'} + + ) : ( +
+ + + Container # + Goods + Stage + Truck + GRN + + + + {items.map((i) => ( + + + {i.containerNumber} + + {i.goods ?? '—'} + + + {i.stage} + + + {i.truckPlate ?? '—'} + {i.grnNumber ?? '—'} + + ))} + +
+ )} + + + ); +} + +type ConfirmAction = { title: string; message: string; confirmLabel: string; run: () => void }; + +/** One-click bulk actions are irreversible — make the click deliberate. */ +function ConfirmActionModal({ + action, + onClose, +}: { + action: ConfirmAction | null; + onClose: () => void; +}) { + return ( + + + {action?.message} + + + + + + + ); +} + +/** "3 skipped — Booking not PAID" instead of a bare count. */ +const skippedSummary = ( + skippedCount: number, + results: Array<{ reason?: string; message?: string }>, +): string | undefined => { + if (!skippedCount) return undefined; + const reason = results.find((x) => x.reason || x.message); + return `${skippedCount} skipped${reason ? ` — ${reason.reason ?? reason.message}` : ''}`; +}; + const commonNonEmptyValue = (values: Array) => { const unique = [...new Set(values.map((value) => value?.trim()).filter(Boolean))] as string[]; return unique.length === 1 ? unique[0] : ''; @@ -860,7 +983,7 @@ function EligibleTab({ }); toast({ title: `${r.receivedCount} received at ${direction === 'EXPORT' ? 'facility' : 'warehouse'}`, - description: r.results.find((item) => item.grnNumber)?.grnNumber ?? (r.skippedCount ? `${r.skippedCount} skipped` : undefined), + description: r.results.find((item) => item.grnNumber)?.grnNumber ?? skippedSummary(r.skippedCount, r.results), }); const firstGrn = r.results.find((item) => item.inventoryId && item.grnNumber); if (focusedBookingId && firstGrn?.inventoryId && firstGrn.grnNumber) { @@ -1030,7 +1153,7 @@ function EligibleTab({ : `No eligible PAID ${direction.toLowerCase()} bookings to receive.`} ) : ( - + @@ -1043,8 +1166,6 @@ function EligibleTab({ /> Booking Ref - Booking ID - Customer ID Customer Name Origin Destination @@ -1077,12 +1198,6 @@ function EligibleTab({ {r.reference} - - {r.id.slice(0, 8)}… - - - {r.customerId ? `${r.customerId.slice(0, 8)}…` : '—'} - {r.customer ?? '—'} {r.origin ?? '—'} {r.destination ?? '—'} @@ -1256,6 +1371,8 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged api.warehouses.bulkMarkInspected.mutationOptions(), ); const [selected, setSelected] = useState>(new Set()); + const [confirmAction, setConfirmAction] = useState(null); + const [expandedRow, setExpandedRow] = useState(null); const [inspectId, setInspectId] = useState(null); const pendingRows = rows.filter((r) => r.inspectionStatus !== 'PASSED'); @@ -1279,7 +1396,7 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged const r = await inspectMutation.mutateAsync({ inventoryIds: [...selected] }); toast({ title: `${r.inspectedCount} marked inspected`, - description: r.skippedCount ? `${r.skippedCount} skipped` : undefined, + description: skippedSummary(r.skippedCount, r.results), }); setSelected(new Set()); onChanged?.(); @@ -1300,7 +1417,14 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged leftSection={} disabled={selected.size === 0} loading={inspectMutation.isPending} - onClick={markInspected} + onClick={() => + setConfirmAction({ + title: 'Mark inspected', + message: `Mark ${selected.size} selected item(s) as inspection PASSED?`, + confirmLabel: `Mark ${selected.size} inspected`, + run: markInspected, + }) + } > Mark Selected as Inspected @@ -1315,10 +1439,11 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged No received export items awaiting inspection. ) : ( - +
+ Booking Ref GRN - Booking ID - Customer ID Customer Name Container / Cargo Items Cargo Type @@ -1345,7 +1468,18 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged {rows.map((r: ReadyToLoadRow) => { const selectable = r.inspectionStatus !== 'PASSED'; return ( - + + + + setExpandedRow(expandedRow === r.id ? null : r.id)} + > + {expandedRow === r.id ? : } + + - - {r.bookingReference ?? '—'} - - + {r.bookingReference ?? '—'} - - {r.bookingId ? `${r.bookingId.slice(0, 8)}…` : '—'} - - - {r.customerId ? `${r.customerId.slice(0, 8)}…` : '—'} - {r.customerName ?? '—'} {r.containerNumber ?? '—'} {r.cargoType ?? '—'} @@ -1382,9 +1507,7 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged - - {r.status} - + + {expandedRow === r.id && ( + + )} + ); })} @@ -1404,6 +1535,7 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged opened={Boolean(inspectId)} onClose={() => setInspectId(null)} /> + setConfirmAction(null)} /> ); } @@ -1414,27 +1546,48 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: const { data: rows = [], isLoading } = useQuery( api.warehouses.readyToLoadExport.queryOptions({ enabled }), ); - const loadPassed = useMutation(api.warehouses.loadPassedExport.mutationOptions()); - const [selected, setSelected] = useState>(new Set()); + const qc = useQueryClient(); + const [trainPickerOpen, setTrainPickerOpen] = useState(false); + const [expandedRow, setExpandedRow] = useState(null); + const [targetScheduleId, setTargetScheduleId] = useState(null); + // Loading is always onto a SPECIFIC pre-dispatch train. No train -> no auto-load. + const { data: trains = [], isLoading: trainsLoading } = useQuery({ + queryKey: ['warehouse-inventory', 'loadable-trains'], + queryFn: () => warehouseService.getLoadableTrains(), + enabled: enabled && trainPickerOpen, + }); + const loadOntoTrain = useMutation({ + mutationFn: async (scheduleId: string) => { + const items = await warehouseService.getTrainLoadableItems(scheduleId); + const loadableIds = items.filter((i) => i.loadable).map((i) => i.id); + if (!loadableIds.length) { + throw new Error('No ready items with an allocated wagon on this train'); + } + return warehouseService.loadItemsOntoTrain(scheduleId, loadableIds); + }, + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] }); + void qc.invalidateQueries({ queryKey: ['warehouse-loadings'] }); + }, + }); - const allSelected = rows.length > 0 && selected.size === rows.length; - const someSelected = selected.size > 0 && !allSelected; - const toggleAll = () => setSelected(allSelected ? new Set() : new Set(rows.map((r) => r.id))); - const toggleOne = (id: string) => - setSelected((prev) => { - const next = new Set(prev); - next.has(id) ? next.delete(id) : next.add(id); - return next; - }); - const autoLoad = async () => { + const confirmLoad = async () => { + if (!targetScheduleId) { + toast({ variant: 'destructive', title: 'Select a train to load onto' }); + return; + } try { - const r = await loadPassed.mutateAsync(undefined); + const r = await loadOntoTrain.mutateAsync(targetScheduleId); + const train = trains.find((t) => t.scheduleId === targetScheduleId); toast({ - title: `${r.loadedCount} items loaded`, - description: r.skippedCount ? `${r.skippedCount} skipped` : undefined, + title: `${r.loadedCount} item(s) loaded onto train ${train?.trainNumber ?? ''}`.trim(), + description: r.skippedCount + ? `${r.skippedCount} skipped — ${r.results.find((x) => x.reason)?.reason ?? 'see results'}` + : undefined, }); - setSelected(new Set()); + setTrainPickerOpen(false); + setTargetScheduleId(null); onChanged?.(); } catch (error) { toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(error) }); @@ -1452,14 +1605,58 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: variant="filled" color="teal" leftSection={} - loading={loadPassed.isPending} disabled={rows.length === 0} - onClick={autoLoad} + onClick={() => setTrainPickerOpen(true)} > Auto Load Ready Items + setTrainPickerOpen(false)} + title="Load ready items onto a train" + centered + size="lg" + > + + {trainsLoading ? ( + + ) : trains.length === 0 ? ( + }> + No train available. Auto-loading needs a scheduled (not yet dispatched) train with + these bookings assigned — schedule the train and allocate wagons first. + + ) : ( +
- - - + Booking Ref GRN - Booking ID - Customer ID Customer Name Container # Cargo Type @@ -1496,29 +1684,24 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: {rows.map((r: ReadyToLoadRow) => ( - + + - toggleOne(r.id)} - /> + setExpandedRow(expandedRow === r.id ? null : r.id)} + > + {expandedRow === r.id ? : } + - - {r.bookingReference ?? '—'} - - + {r.bookingReference ?? '—'} - - {r.bookingId ? `${r.bookingId.slice(0, 8)}…` : '—'} - - - {r.customerId ? `${r.customerId.slice(0, 8)}…` : '—'} - {r.customerName ?? '—'} {r.containerNumber ?? '—'} {r.cargoType ?? '—'} @@ -1532,11 +1715,17 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: - - {r.status} - + + {expandedRow === r.id && ( + + )} + ))}
@@ -1563,6 +1752,8 @@ function LoadedExportTab({ const { data: rows = [], isLoading } = useQuery( api.warehouses.loadedExport.queryOptions({ enabled }), ); + const [confirmAction, setConfirmAction] = useState(null); + const [expandedRow, setExpandedRow] = useState(null); const bulkDispatch = useMutation( api.warehouses.bulkDispatchExport.mutationOptions(), ); @@ -1587,7 +1778,7 @@ function LoadedExportTab({ const r = await bulkDispatch.mutateAsync(inventoryIds); toast({ title: `${r.dispatchedCount} dispatched`, - description: r.skippedCount ? `${r.skippedCount} skipped` : undefined, + description: skippedSummary(r.skippedCount, r.results), }); setSelected(new Set()); onChanged?.(); @@ -1617,7 +1808,14 @@ function LoadedExportTab({ variant="default" disabled={rows.length === 0} loading={bulkDispatch.isPending} - onClick={() => dispatch(rows.map((r) => r.id))} + onClick={() => + setConfirmAction({ + title: 'Dispatch all', + message: `Dispatch all ${rows.length} loaded item(s)? They leave warehouse inventory for the train.`, + confirmLabel: `Dispatch ${rows.length}`, + run: () => dispatch(rows.map((r) => r.id)), + }) + } > Dispatch All @@ -1627,7 +1825,14 @@ function LoadedExportTab({ leftSection={} disabled={selected.size === 0} loading={bulkDispatch.isPending} - onClick={() => dispatch([...selected])} + onClick={() => + setConfirmAction({ + title: 'Dispatch selected', + message: `Dispatch ${selected.size} selected item(s)? They leave warehouse inventory for the train.`, + confirmLabel: `Dispatch ${selected.size}`, + run: () => dispatch([...selected]), + }) + } > Dispatch Selected @@ -1644,7 +1849,7 @@ function LoadedExportTab({ No LOADED export items {dispatchable ? 'waiting to dispatch' : 'yet'}. ) : ( - + @@ -1658,10 +1863,9 @@ function LoadedExportTab({ /> )} + Booking Ref GRN - Booking ID - Customer ID Customer Name Container # Cargo Type @@ -1672,7 +1876,8 @@ function LoadedExportTab({ {rows.map((r: ReadyToLoadRow) => ( - + + {dispatchable && ( )} - - {r.bookingReference ?? '—'} - - + setExpandedRow(expandedRow === r.id ? null : r.id)} + > + {expandedRow === r.id ? : } + + + + {r.bookingReference ?? '—'} - - {r.bookingId ? `${r.bookingId.slice(0, 8)}…` : '—'} - - - {r.customerId ? `${r.customerId.slice(0, 8)}…` : '—'} - {r.customerName ?? '—'} {r.containerNumber ?? '—'} {r.cargoType ?? '—'} @@ -1705,16 +1911,23 @@ function LoadedExportTab({ {r.origin || r.destination ? `${r.origin ?? '?'} → ${r.destination ?? '?'}` : '—'} - - {r.status} - + + {expandedRow === r.id && ( + + )} + ))}
)} + setConfirmAction(null)} /> ); } @@ -1816,9 +2029,7 @@ function ImportTrainDetailTable({ Wagon - Booking ID Booking Ref - Customer ID Customer Name Container # Cargo Type @@ -1852,15 +2063,9 @@ function ImportTrainDetailTable({ {it.sequenceNo ? `#${it.sequenceNo}` : '-'} {it.wagonNumber ?? ''} - - {it.bookingId.slice(0, 8)}… - {it.bookingReference ?? '—'} - - {it.customerId ? `${it.customerId.slice(0, 8)}…` : '—'} - {it.customerName ?? '—'} {it.containerNumber ?? '—'} {it.cargoType ?? '—'} @@ -1950,6 +2155,7 @@ function ImportArriveQueueTab({ api.warehouses.autoUnloadArrivedBookings.mutationOptions(), ); const [openId, setOpenId] = useState(null); + const [confirmAction, setConfirmAction] = useState(null); const [busyId, setBusyId] = useState(null); const [assignmentsBySchedule, setAssignmentsBySchedule] = useState< Record> @@ -1991,7 +2197,7 @@ function ImportArriveQueueTab({ const alreadyUnloaded = r.unloadedCount === 0 && r.skippedCount > 0 && r.failedCount === 0; const firstReason = r.results.find((item) => item.reason)?.reason; const extra = [ - r.skippedCount ? `${r.skippedCount} skipped` : '', + skippedSummary(r.skippedCount, r.results) ?? '', r.failedCount ? `${r.failedCount} failed` : '', ] .filter(Boolean) @@ -2087,7 +2293,14 @@ function ImportArriveQueueTab({ leftSection={} loading={busyId === t.scheduleId} disabled={fullyUnloaded || t.totalBookings === 0 || !readyBySchedule[t.scheduleId] || warehousesLoading} - onClick={() => autoUnload(t)} + onClick={() => + setConfirmAction({ + title: 'Auto unload train', + message: `Unload all arrived bookings from train ${t.trainNumber ?? t.scheduleId.slice(0, 8)} into their assigned warehouse locations?`, + confirmLabel: 'Unload train', + run: () => autoUnload(t), + }) + } > {fullyUnloaded ? 'Already Unloaded' : 'Auto Unload Arrived Bookings'} @@ -2126,6 +2339,7 @@ function ImportArriveQueueTab({
)} + setConfirmAction(null)} /> ); } @@ -2146,6 +2360,8 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { ); const readyMutation = useMutation(api.warehouses.markReadyForPickup.mutationOptions()); const [selected, setSelected] = useState>(new Set()); + const [confirmAction, setConfirmAction] = useState(null); + const [expandedRow, setExpandedRow] = useState(null); const [inspectId, setInspectId] = useState(null); const [busyId, setBusyId] = useState(null); const [viewItem, setViewItem] = useState(null); @@ -2177,7 +2393,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { const r = await inspectMutation.mutateAsync({ inventoryIds: [...selected] }); toast({ title: `${r.inspectedCount} marked inspected`, - description: r.skippedCount ? `${r.skippedCount} skipped` : undefined, + description: skippedSummary(r.skippedCount, r.results), }); setSelected(new Set()); void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] }); @@ -2281,7 +2497,14 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { leftSection={} disabled={selected.size === 0} loading={inspectMutation.isPending} - onClick={markInspected} + onClick={() => + setConfirmAction({ + title: 'Mark inspected', + message: `Mark ${selected.size} selected item(s) as inspection PASSED? Passed import items become ready for pickup.`, + confirmLabel: `Mark ${selected.size} inspected`, + run: markInspected, + }) + } > Mark Selected as Inspected @@ -2297,10 +2520,11 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { No unloaded import items. Items appear here after Auto Unload on an arrived train. ) : ( - + + (allSelected ? unselectAll() : selectAll())} /> - Booking ID Booking Ref GRN - Customer ID Customer Name Arrival Time Container # @@ -2328,7 +2550,18 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { {rows.map((r: ImportUnloadedItem) => ( - + + + + setExpandedRow(expandedRow === r.id ? null : r.id)} + > + {expandedRow === r.id ? : } + + - {r.bookingId ? `${r.bookingId.slice(0, 8)}…` : '—'} - - - - {r.bookingReference ?? '—'} - - + {r.bookingReference ?? '—'} - - {r.customerId ? `${r.customerId.slice(0, 8)}…` : '—'} - {r.customerName ?? '—'} {formatDate(r.arrivalTime)} {r.containerNumber ?? '—'} @@ -2369,7 +2593,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { - {r.currentStatus} + @@ -2463,6 +2687,14 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { + {expandedRow === r.id && ( + + )} + ))}
@@ -2491,6 +2723,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { bookingId={containerItemsItem?.booking?.id ?? null} bookingReference={containerItemsItem?.booking?.reference ?? null} /> + setConfirmAction(null)} /> ); } diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx index d986e2aba..6833167f7 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx @@ -112,7 +112,9 @@ const parseInspectionNote = (notes: string | null | undefined) => { export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: ReleaseOrderModalProps) { const { toast } = useToast(); 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. const { data: customerTrucks = [] } = useQuery({ 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 // portal) are selectable. No global fleet list — if nothing is assigned, the - // operator types the plate manually in the field below. - const truckSelectOptions = assignedTruckOptions; + // operator types the plate manually in the field below. Deduped by plate: + // duplicate option values crash Mantine's Select. + const truckSelectOptions = [ + ...new Map(assignedTruckOptions.map((t) => [t.value, t])).values(), + ]; // Neither a last-mile truck nor a customer truck has been assigned yet. const noTruckAssigned = assignedTruckOptions.length === 0 && !isCustomerAssignedTruck; const isTruckIdentityLocked = isEntranceLocked || isCustomerAssignedTruck || hasLastMileTruckPrefill; @@ -214,10 +219,19 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea const containerWeightByNumber = new Map( containerWeights.map((c) => [c.containerNumber.toUpperCase(), Number(c.weightTons) || 0]), ); - const containerSelectData = containerWeights.map((c) => ({ - value: c.containerNumber, - label: `${c.containerNumber} · ${(Number(c.weightTons) || 0).toLocaleString()} t`, - })); + // Mantine Selects throw on duplicate option values — legacy bookings can carry + // the same container number on two lines, so dedupe defensively. + const containerSelectData = [ + ...new Map( + containerWeights.map((c) => [ + c.containerNumber, + { + value: c.containerNumber, + label: `${c.containerNumber} · ${(Number(c.weightTons) || 0).toLocaleString()} t`, + }, + ]), + ).values(), + ]; const selectedContainerNumbers = containerNumbers.map((n) => n.trim()).filter(Boolean); const selectedCargoWeight = Number( selectedContainerNumbers diff --git a/apps/edr-freight-web/backoffice/src/features/contracts/mapShipmentListRow.ts b/apps/edr-freight-web/backoffice/src/features/contracts/mapShipmentListRow.ts index 766c04ee1..9e58e84bc 100644 --- a/apps/edr-freight-web/backoffice/src/features/contracts/mapShipmentListRow.ts +++ b/apps/edr-freight-web/backoffice/src/features/contracts/mapShipmentListRow.ts @@ -9,6 +9,12 @@ export interface ShipmentListRow { summary: string; status: Freight.BookingRequestStatus; 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 = diff --git a/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts b/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts index 2c5af46ab..5ed88d5ac 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/useWarehouses.ts @@ -236,8 +236,6 @@ export function useEligibleBookings(enabled = true) { } export const useBulkReceive = () => useInventoryMutation((payload: BulkReceivePayload) => warehouseService.receiveBulk(payload)); -export const useLoadPassedExport = () => - useInventoryMutation(() => warehouseService.loadPassedExport()); export const useBulkMarkInspected = () => useInventoryMutation((payload: BulkInspectPayload) => warehouseService.bulkMarkInspected(payload)); diff --git a/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplateEditorPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplateEditorPage.tsx index 0ad7ef301..2d635fe58 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplateEditorPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplateEditorPage.tsx @@ -1,15 +1,18 @@ -import { useMemo, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import { useParams } from "react-router-dom"; import { ActionIcon, Badge, + Box, Button, Card, Center, Group, Loader, + Menu, Modal, Paper, + ScrollArea, Stack, Switch, Text, @@ -19,13 +22,29 @@ import { Tooltip, } from "@mantine/core"; import { + AlertTriangle, ArrowDown, ArrowUp, + Banknote, + Building2, + CalendarClock, + CalendarDays, + CalendarRange, + ChevronDown, + Coins, + Hash, + ListOrdered, + ListPlus, + Mail, + MapPin, + Package, Pencil, + Phone, Plus, RefreshCw, Settings2, Trash2, + Weight, } from "lucide-react"; import { PageContainer, PageHeader } from "@/components/page"; @@ -41,7 +60,7 @@ import { import type { ContractTemplateArticle } from "@/services/contract-templates.service"; 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 { id?: string; @@ -49,6 +68,254 @@ interface ArticleDraft { 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(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) ? ( + + {part} + + ) : ( + {part} + ), + )} + + ); +} + export default function ContractTemplateEditorPage() { const { code } = useParams<{ code: string }>(); 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.id) { - updateArticle.mutate({ - articleId: articleDraft.id, - payload: { title: articleDraft.title, body: articleDraft.body }, - }); + updateArticle.mutate({ articleId: articleDraft.id, payload: values }); } else { - addArticle.mutate({ title: articleDraft.title, body: articleDraft.body }); + addArticle.mutate(values); } setArticleDraft(null); }; @@ -264,55 +528,14 @@ export default function ContractTemplateEditorPage() { {/* ── Add / edit article modal ───────────────────────────────────── */} - setArticleDraft(null)} - title={articleDraft?.id ? "Edit article" : "Add article"} - size="xl" - > - {articleDraft && ( - - - setArticleDraft({ ...articleDraft, title: event.currentTarget.value }) - } - required - /> -