From 45629e436e7fef8d09e7c84c668284d1373bcc73 Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 10 Jul 2026 11:08:14 +0000 Subject: [PATCH 1/2] merge --- .../ContractTemplatesPage.tsx | 280 ++++++++++++------ .../backoffice/src/services/routes.service.ts | 10 + 2 files changed, 195 insertions(+), 95 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplatesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplatesPage.tsx index e13c8a741..4a3654aa3 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplatesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplatesPage.tsx @@ -2,17 +2,25 @@ import { useState } from "react"; import { useNavigate } from "react-router-dom"; import { Badge, + Box, Button, Card, - Center, Group, - Loader, SimpleGrid, + Skeleton, Stack, Text, ThemeIcon, + Tooltip, } 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 { useContractTemplates } from "@/hooks/contract-templates/useContractTemplates"; @@ -25,10 +33,10 @@ const DIRECTION_LABEL: Record = { INTERCITY: "Intercity", }; -const DIRECTION_COLOR: Record = { - IMPORT: "edr-green", - EXPORT: "teal", - INTERCITY: "lime", +const DIRECTION_DOT: Record = { + IMPORT: "var(--mantine-color-blue-5)", + EXPORT: "var(--mantine-color-violet-5)", + INTERCITY: "var(--mantine-color-orange-5)", }; function templateDirection(code: ContractTemplate["code"]): string { @@ -39,6 +47,14 @@ function isBulk(code: ContractTemplate["code"]): boolean { 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() { const navigate = useNavigate(); 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." /> - {isLoading ? ( -
- -
- ) : ( - - {(templates ?? []).map((template) => { - const direction = templateDirection(template.code); - return ( - - - - - {isBulk(template.code) ? ( - - ) : ( - - )} - - - - {DIRECTION_LABEL[direction] ?? direction} - - - {isBulk(template.code) ? "Bulk" : "Container"} - - {!template.isActive && ( - - Inactive - - )} - - - -
- - {template.name} - - - {template.description || template.documentTitle} - -
- - - - - {template.articles.length} articles · updated{" "} - {new Date(template.updatedAt).toLocaleDateString("en-GB", { - day: "numeric", - month: "short", - year: "numeric", - })} - - - - - - - -
-
- ); - })} -
- )} + + {isLoading + ? Array.from({ length: 6 }, (_, i) => ) + : (templates ?? []).map((template) => ( + setPreviewCode(template.code)} + onEdit={() => + navigate(`/dashboard/contract-templates/${template.code}`) + } + /> + ))} + ); } + +function TemplateCard({ + template, + onPreview, + onEdit, +}: { + template: ContractTemplate; + onPreview: () => void; + onEdit: () => void; +}) { + const direction = templateDirection(template.code); + const bulk = isBulk(template.code); + + return ( + + + {/* Kicker row: muted icon well + category label + state */} + + + + {bulk ? ( + + ) : ( + + )} + + + + + {DIRECTION_LABEL[direction] ?? direction} ·{" "} + {bulk ? "Bulk" : "Container"} + + + + {!template.isActive && ( + + + Inactive + + + )} + + + {/* Name + description */} +
+ + {template.name} + + + {template.description || template.documentTitle} + +
+ + {/* Meta stats */} + + + + + {template.articles.length} article + {template.articles.length !== 1 ? "s" : ""} + + + + + + Updated {formatUpdated(template.updatedAt)} + + + +
+ + {/* Footer actions, separated by a hairline */} + + + + + + +
+ ); +} + +function TemplateCardSkeleton() { + return ( + + + + + + +
+ + + +
+ + + + +
+ + + + + + +
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/services/routes.service.ts b/apps/edr-freight-web/backoffice/src/services/routes.service.ts index 9c59e31ad..2317c693d 100644 --- a/apps/edr-freight-web/backoffice/src/services/routes.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/routes.service.ts @@ -43,8 +43,18 @@ export interface SaveRoutePayload { * Human-readable route label: yard names, not yard codes. Staff read * "Addis Ababa → Dire Dawa", not "ADDIS_ABABA → DIRE_DAWA". Falls back to the * 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 { + 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 = From 10111c9e0120743791f789cfbb3086098dca1b2d Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 10 Jul 2026 11:08:30 +0000 Subject: [PATCH 2/2] merge --- .../src/modules/routes/entities/route.entity.ts | 15 +++++++++++++++ .../train-schedules/train-schedules.repository.ts | 5 +++-- .../train-scheduling/booking-batch.service.ts | 5 +++-- .../train-scheduling/train-scheduling.service.ts | 5 +++-- 4 files changed, 24 insertions(+), 6 deletions(-) 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 5609c8801..0e6f1fd8c 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 @@ -746,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, 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 0fbfd30ce..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 },