From 30f48ea37fd6ecf1e8e32b835b7e6e1de6b504bb Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 17 Jul 2026 11:17:45 +0000 Subject: [PATCH 01/20] feat(intercity): show intercity cargo across every train MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Intercity bookings never get their own train — they ride whichever import/export train passes through their corridor — so the work is scattered across other people's schedules and there was nowhere to see it as a whole. The per-schedule ride-along panel answers "what can THIS train carry"; this answers "what is happening to intercity cargo". Purely additive: the existing ride-along panel and the schedule detail page are untouched, and loading/unloading still happens there, where the train's position is confirmed. This is a read-only view that points back to it. Each row carries both ends' facility status, because a booking whose origin or destination has no equipment can never be worked there — the operator should see that while the train is still coming, not when the load is refused. Those bookings are counted and called out. New GET /train-scheduling/intercity/bookings; the type is IntercityRideAlongRow, not IntercityBookingRow, which already means the per-schedule candidate row. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../train-scheduling/intercity.service.ts | 62 ++++ .../train-scheduling.controller.ts | 10 + apps/edr-freight-web/backoffice/src/App.tsx | 15 + .../backoffice/src/constants/URLS.ts | 1 + .../src/pages/warehouses/IntercityPage.tsx | 291 ++++++++++++++++++ .../backoffice/src/services/api.ts | 10 + .../src/services/trainScheduling.service.ts | 9 + .../backoffice/src/types/trainScheduling.ts | 24 ++ 8 files changed, 422 insertions(+) create mode 100644 apps/edr-freight-web/backoffice/src/pages/warehouses/IntercityPage.tsx diff --git a/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts index d2f1dd7b1..f54dc138e 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts @@ -40,6 +40,68 @@ export class IntercityService { * remaining capacity along all three axes (wagons, weight, length) and each * booking's need, so staff can pick what fits. */ + /** + * Every intercity booking and where it is in its ride-along, across all trains. + * + * The per-schedule candidate list answers "what can THIS train carry"; this + * answers "what is happening to intercity cargo" — which is what a yard + * operator needs when the work is spread over whichever trains happen to pass. + * + * Carries each end's facility state, because a booking whose origin or + * destination has no facility can never be loaded or unloaded there and the + * operator should see that before the train arrives, not when the load is + * refused. + */ + async listBookings() { + return this.dataSource.query( + `SELECT b.id AS "bookingId", + b.reference AS "reference", + b.status AS "status", + b.freight_type AS "freightType", + b.cargo_total_weight_vgm AS "weightTons", + b.loaded_at AS "loadedAt", + b.arrived_at AS "arrivedAt", + company.name AS "customer", + b.train_schedule_id AS "trainScheduleId", + ts.train_number AS "trainNumber", + ts.status AS "scheduleStatus", + oy.id AS "originYardId", + COALESCE(oy.label, oy.code) AS "origin", + oy.has_facility AS "originHasFacility", + dy.id AS "destinationYardId", + COALESCE(dy.label, dy.code) AS "destination", + dy.has_facility AS "destinationHasFacility", + -- Where the train actually is, so the operator knows if the cargo + -- can be worked right now. + cp.yard_id AS "trainAtYardId", + -- Most recent GRN raised for this booking at a facility. + fh.grn_number AS "grnNumber" + FROM freight.bookings b + LEFT JOIN freight.companies company ON company.id = b.company_id + LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id + LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id + LEFT JOIN freight.train_schedules ts + ON ts.id = b.train_schedule_id AND ts.deleted_at IS NULL + LEFT JOIN LATERAL ( + SELECT c.yard_id + FROM freight.train_checkpoint_events c + WHERE c.train_schedule_id = b.train_schedule_id + ORDER BY c.occurred_at DESC, c.created_at DESC + LIMIT 1 + ) cp ON true + LEFT JOIN LATERAL ( + SELECT e.grn_number + FROM freight.facility_handling_events e + WHERE e.booking_id = b.id AND e.deleted_at IS NULL + ORDER BY e.occurred_at DESC + LIMIT 1 + ) fh ON true + WHERE b.deleted_at IS NULL + AND b.trade_direction = 'DOMESTIC' + ORDER BY b.created_at DESC`, + ); + } + async listCandidates(scheduleId: string) { const schedule = await this.getSchedule(scheduleId); const milestoneSeq = await this.routeMilestoneSequence(schedule); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index bbb19dbee..6bc36c4fc 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -459,6 +459,16 @@ export class TrainSchedulingController { return this.trainSchedulingService.dispatchSchedule(id); } + @Get("intercity/bookings") + @TrainSchedulingView() + @ApiOperation({ + summary: + "Every intercity booking with its ride-along state, both yards' facility status, and where its train is", + }) + listIntercityBookings() { + return this.intercityService.listBookings(); + } + @Get("schedules/:id/intercity-candidates") @TrainSchedulingView() @ApiOperation({ diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 960d0036e..16cbd8507 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -25,6 +25,7 @@ import { Users, Wallet, LifeBuoy, + TrainFront, } from "lucide-react"; import { useEffect } from "react"; import { @@ -122,6 +123,7 @@ import InterchangeDocumentsPage from "./pages/warehouses/InterchangeDocumentsPag import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage"; import LoadedInventoryPage from "./pages/warehouses/LoadedInventoryPage"; import LoadingQueuePage from "./pages/warehouses/LoadingQueuePage"; +import IntercityPage from "./pages/warehouses/IntercityPage"; import WarehouseDashboardPage from "./pages/warehouses/WarehouseDashboardPage"; import WarehouseDetailPage from "./pages/warehouses/WarehouseDetailPage"; import WarehouseInventoryPage from "./pages/warehouses/WarehouseInventoryPage"; @@ -441,6 +443,18 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ }, ], }, + { + label: "Intercity", + href: "/dashboard/intercity", + icon: , + children: [ + { + label: "Intercity Cargo", + href: "/dashboard/intercity", + icon: , + }, + ], + }, { label: "Warehouse Management", icon: , @@ -933,6 +947,7 @@ const App = () => { } /> } /> } /> + } /> } /> } /> `/train-scheduling/schedules/${id}/bookings/${bookingId}/unload`, + INTERCITY_BOOKINGS: "/train-scheduling/intercity/bookings", INTERCITY_CANDIDATES: (id: string) => `/train-scheduling/schedules/${id}/intercity-candidates`, INTERCITY_ACCEPT: (id: string) => diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/IntercityPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/IntercityPage.tsx new file mode 100644 index 000000000..6a45be0f2 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/IntercityPage.tsx @@ -0,0 +1,291 @@ +import { useMemo, useState } from "react"; +import { + Alert, + Badge, + Card, + Center, + Group, + Loader, + SimpleGrid, + Table, + Tabs, + Text, + Tooltip, +} from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; +import { AlertTriangle, PackageCheck, TrainFront, Warehouse } from "lucide-react"; + +import { PageContainer, PageHeader } from "@/components/page"; +import { api } from "@/services/api"; +import type { IntercityRideAlongRow } from "@/types/trainScheduling"; + +/** + * Intercity cargo across every train. + * + * Intercity bookings never get their own train — they ride whichever + * import/export train passes through their corridor — so the work is spread over + * other people's schedules. This is the one place it's all visible. + */ + +const fmtTons = (t: number | null) => (t == null ? "—" : `${t} t`); + +/** A booking can only be worked where the train actually is. */ +const atOrigin = (r: IntercityRideAlongRow) => + Boolean(r.trainAtYardId) && r.trainAtYardId === r.originYardId; +const atDestination = (r: IntercityRideAlongRow) => + Boolean(r.trainAtYardId) && r.trainAtYardId === r.destinationYardId; + +const isWaiting = (r: IntercityRideAlongRow) => + !r.loadedAt && r.status !== "IN_TRANSIT" && r.status !== "COMPLETED"; +const isRiding = (r: IntercityRideAlongRow) => r.status === "IN_TRANSIT"; +const isDone = (r: IntercityRideAlongRow) => r.status === "COMPLETED"; + +/** Yards with no equipment can never load/unload — surface it before the train arrives. */ +function FacilityCell({ yard, has }: { yard: string | null; has: boolean | null }) { + if (!yard) return ; + if (has) return {yard}; + return ( + + + + + {yard} + + + + ); +} + +function Rows({ rows }: { rows: IntercityRideAlongRow[] }) { + if (rows.length === 0) { + return ( + + Nothing here. + + ); + } + return ( + + + + + Booking + Customer + Load at + Unload at + Train + Weight + GRN + Status + + + + {rows.map((r) => ( + + + + {r.reference ?? r.bookingId.slice(0, 8)} + + + {r.customer ?? "—"} + + + + {atOrigin(r) && isWaiting(r) && ( + + train here + + )} + + + + + + {atDestination(r) && isRiding(r) && ( + + train here + + )} + + + + {r.trainNumber ? ( + + + {r.trainNumber} + + ) : ( + + not on a train + + )} + + + {fmtTons(r.weightTons)} + + + + {r.grnNumber ?? "—"} + + + + + {r.status} + + + + ))} + +
+
+ ); +} + +function Stat({ + icon, + label, + value, + color, +}: { + icon: React.ReactNode; + label: string; + value: React.ReactNode; + color?: string; +}) { + return ( + + + {icon} +
+ + {label} + + + {value} + +
+
+
+ ); +} + +export default function IntercityPage() { + const [tab, setTab] = useState("waiting"); + const { data: rows = [], isLoading } = useQuery( + api.trainScheduling.intercityBookings.queryOptions({ input: undefined }), + ); + + const waiting = useMemo(() => rows.filter(isWaiting), [rows]); + const riding = useMemo(() => rows.filter(isRiding), [rows]); + const done = useMemo(() => rows.filter(isDone), [rows]); + // A booking whose end has no equipment is stuck until someone flags the yard. + const blocked = useMemo( + () => + rows.filter( + (r) => !isDone(r) && (!r.originHasFacility || !r.destinationHasFacility), + ), + [rows], + ); + + return ( + + + + {isLoading ? ( +
+ +
+ ) : ( + <> + + } + label="Waiting to load" + value={waiting.length} + /> + } label="On a train" value={riding.length} /> + } label="Completed" value={done.length} /> + } + label="No facility" + value={blocked.length} + color={blocked.length > 0 ? "red" : undefined} + /> + + + {blocked.length > 0 && ( + } + title={`${blocked.length} booking${blocked.length === 1 ? "" : "s"} cannot be handled`} + mb="md" + > + Their origin or destination yard has no load/unload facility. Mark the yard as + a facility in Configuration → Yards, or the cargo can never be worked there. + + )} + + + setTab(v ?? "waiting")}> + + + {waiting.length} + + } + > + Waiting to load + + + {riding.length} + + } + > + On a train + + + {done.length} + + } + > + Completed + + + + + + + + + + + + + + + Loading and unloading happen on the train's schedule page, where the ride-along + panel confirms the train is at the yard. + + + + )} +
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index 71db16fb5..fe323a472 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -702,6 +702,16 @@ export const api = { () => TRAIN_SCHEDULING_INVALIDATIONS, ), + intercityBookings: endpoint< + void, + import("@/types/trainScheduling").IntercityRideAlongRow[] + >( + "train-scheduling", + "intercity-bookings", + () => trainSchedulingService.listIntercityBookings(), + () => ["train-scheduling", "intercity-bookings"], + ), + intercityCandidates: endpoint< { scheduleId: string }, import("@/types/trainScheduling").IntercityCandidatesResult diff --git a/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts b/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts index 76f87a008..1eb43a048 100644 --- a/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts @@ -393,6 +393,15 @@ export const trainSchedulingService = { return unwrap(response.data); }, + listIntercityBookings: async (): Promise< + import("@/types/trainScheduling").IntercityRideAlongRow[] + > => { + const response = await client.get< + import("@/types/trainScheduling").IntercityRideAlongRow[] + >(URL_CONSTANTS.TRAIN_SCHEDULING.INTERCITY_BOOKINGS); + return response.data ?? []; + }, + getIntercityCandidates: async ( scheduleId: string, ): Promise => { diff --git a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts index 270b13636..74c6f7f68 100644 --- a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts +++ b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts @@ -956,3 +956,27 @@ export interface BookingUnloadResult { status: string; arrivedAt: string; } + +/** An intercity booking's ride-along state, with both ends' facility status. */ +export interface IntercityRideAlongRow { + bookingId: string; + reference: string | null; + status: string; + freightType: string | null; + weightTons: number | null; + loadedAt: string | null; + arrivedAt: string | null; + customer: string | null; + trainScheduleId: string | null; + trainNumber: string | null; + scheduleStatus: string | null; + originYardId: string | null; + origin: string | null; + originHasFacility: boolean | null; + destinationYardId: string | null; + destination: string | null; + destinationHasFacility: boolean | null; + /** Yard the train was last recorded at — cargo can only be worked there. */ + trainAtYardId: string | null; + grnNumber: string | null; +} From 149e4105f462233e38cb13d7a864c7bfb7425cba Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 17 Jul 2026 11:30:25 +0000 Subject: [PATCH 02/20] seed vehicle --- apps/edr-freight-api/package.json | 1 + .../src/scripts/seed-edr-trucks.ts | 42 +++++++++++++++++++ .../src/seed/edr-truck-fleet.seeder.ts | 16 ++++++- 3 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 apps/edr-freight-api/src/scripts/seed-edr-trucks.ts diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index bca74475e..66909a037 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -14,6 +14,7 @@ "test": "jest", "test:e2e": "jest --config ./test/jest-e2e.json", "seed:wagons": "ts-node -r tsconfig-paths/register src/scripts/seed-edr-wagons.ts", + "seed:trucks": "ts-node -r tsconfig-paths/register src/scripts/seed-edr-trucks.ts", "type-check": "tsc --noEmit", "seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts", "seed:freight-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-freight-demo.ts", diff --git a/apps/edr-freight-api/src/scripts/seed-edr-trucks.ts b/apps/edr-freight-api/src/scripts/seed-edr-trucks.ts new file mode 100644 index 000000000..45f579f18 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/seed-edr-trucks.ts @@ -0,0 +1,42 @@ +import { AppDataSource } from '../data-source'; +import { EdrTruckFleetSeeder } from '../seed/edr-truck-fleet.seeder'; + +/** + * Seeds the 62-truck EDR fleet used by first-mile / last-mile. + * + * The seeder is idempotent (`ON CONFLICT (plate_number) DO NOTHING`), so a + * re-run will NOT overwrite a truck whose rate was corrected by hand — the + * seeded rate is a placeholder and is meant to be replaced. + */ +async function seedEdrTrucks() { + await AppDataSource.initialize(); + + try { + await new EdrTruckFleetSeeder(AppDataSource).run(); + + const summary = await AppDataSource.query(` + SELECT + COUNT(*)::int AS trucks, + COUNT(*) FILTER (WHERE status = 'ACTIVE')::int AS active, + COUNT(*) FILTER (WHERE availability = 'FREE')::int AS free, + COUNT(*) FILTER (WHERE price_per_km > 0)::int AS priced, + COUNT(*) FILTER (WHERE price_per_km IS NULL OR price_per_km <= 0)::int AS unpriced, + MIN(price_per_km)::text AS min_rate, + MAX(price_per_km)::text AS max_rate + FROM freight.vehicles + WHERE vehicle_type = 'TRUCK'; + `); + + console.table(summary); + console.log( + 'Seeded EDR truck fleet. NOTE: price_per_km is DEMO DATA — replace with the real tariff before billing.', + ); + } finally { + await AppDataSource.destroy(); + } +} + +seedEdrTrucks().catch((error) => { + console.error('Failed to seed EDR truck fleet:', error); + process.exit(1); +}); diff --git a/apps/edr-freight-api/src/seed/edr-truck-fleet.seeder.ts b/apps/edr-freight-api/src/seed/edr-truck-fleet.seeder.ts index e8349c806..9a831f3a6 100644 --- a/apps/edr-freight-api/src/seed/edr-truck-fleet.seeder.ts +++ b/apps/edr-freight-api/src/seed/edr-truck-fleet.seeder.ts @@ -34,6 +34,16 @@ const EDR_TRUCK_FLEET: ReadonlyArray = [ /** Fleet sequence numbers (1-based) that are 20ft-only. 6 of 62 — fill once confirmed. */ const TWENTY_FT_SEQS = new Set(); +/** + * PLACEHOLDER haulage rate, ETB per km — NOT a real EDR tariff. + * + * First/last-mile billing is `distance × pricePerKm`, and a truck with no rate + * is refused at assignment, which would leave the whole demo flow unusable. This + * exists so the flow is clickable end to end; every amount it produces is + * fiction. Replace per truck in the fleet UI before anything bills for real. + */ +const PLACEHOLDER_PRICE_PER_KM = 50; + @Injectable() export class EdrTruckFleetSeeder { private readonly logger = new Logger(EdrTruckFleetSeeder.name); @@ -50,7 +60,7 @@ export class EdrTruckFleetSeeder { const columns = [ 'code', 'plate_number', 'registration_number', 'power_plate_no', 'trailer_plate_no', 'vehicle_type', 'manufacturer', 'model', 'year', 'fuel_type', 'capacity', - 'status', 'ownership', 'currency', 'description', + 'status', 'ownership', 'currency', 'price_per_km', 'description', ]; const rows: unknown[][] = EDR_TRUCK_FLEET.map(([power, trailer], i) => { @@ -71,7 +81,9 @@ export class EdrTruckFleetSeeder { 'ACTIVE', 'EDR', 'ETB', - `EDR-owned container truck configured for ${ft} containers.`, + PLACEHOLDER_PRICE_PER_KM, + `EDR-owned container truck configured for ${ft} containers. ` + + `Rate ${PLACEHOLDER_PRICE_PER_KM} ETB/km is DEMO DATA — replace with the real tariff.`, ]; }); From 221c49fcda4acb7a62037d402116787e11944247 Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 17 Jul 2026 11:38:54 +0000 Subject: [PATCH 03/20] fix issue --- .../detail/ClearanceReviewSection.tsx | 41 ++-- .../contracts/ClearanceWorkflowFilesPanel.tsx | 9 +- .../ContractClearanceReviewSection.tsx | 48 +++-- .../contracts/GlCreateBookingForm.tsx | 5 +- .../contracts/PhasedUploadedFileRow.tsx | 9 +- .../gl-booking-form/container-excel.ts | 12 +- .../contracts/gl-booking-form/total.ts | 21 +- .../customers/ChangeRequestReview.tsx | 25 ++- .../contracts/ContractRequestDetailPage.tsx | 13 +- .../pages/customers/CustomerDetailPage.tsx | 61 ++---- .../src/pages/fleet/DriverDetailPage.tsx | 24 ++- .../TrainSchedulingGlobalRulesPage.tsx | 4 +- .../backoffice/src/services/files.service.ts | 17 ++ .../contracts/ClearanceDocumentUploadCard.tsx | 19 +- .../components/onboarding/RoleLicenseStep.tsx | 12 +- .../BookingClearanceWorkflowBanner.tsx | 20 +- .../BookingDetailPage/DraftBookingView.tsx | 18 +- .../components/DocumentsTab.tsx | 21 +- .../src/pages/bookings/EditBookingPage.tsx | 10 +- .../bookings/clearance/ClearanceFlow.tsx | 16 +- .../bookings/new-booking-form/shared.tsx | 64 ++++++ .../new-booking-form/step5-cargo-details.tsx | 65 +----- .../bookings/resubmit/ResubmitDocuments.tsx | 4 +- .../contracts/ContractClearancePanel.tsx | 23 +-- .../ContractClearanceWorkflowBanner.tsx | 26 +-- .../pages/contracts/ContractDetailPage.tsx | 28 +-- .../src/pages/contracts/NewShipmentPage.tsx | 193 ++++++++++++------ .../src/pages/contracts/contract-ui.tsx | 17 +- .../new-contract-form/ContractDocsEditor.tsx | 7 +- .../contracts/new-contract-form/shared.tsx | 5 +- .../new-shipment-form/container-excel.ts | 12 +- .../contracts/new-shipment-form/total.test.ts | 150 ++++++++++++++ .../contracts/new-shipment-form/total.ts | 17 ++ .../src/pages/settings/TabDocuments.tsx | 14 +- .../src/pages/settings/TabPowerOfAttorney.tsx | 8 +- .../portal/src/services/files.service.ts | 43 ++++ 36 files changed, 726 insertions(+), 355 deletions(-) create mode 100644 apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/total.test.ts create mode 100644 apps/edr-freight-web/portal/src/services/files.service.ts diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx index 1b2c46bac..3dcf1ea9e 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx @@ -32,7 +32,10 @@ import { isViewable } from "@edr/ui-common"; import { SectionCard } from "./SectionCard"; import { bookingsService } from "@/services/bookings.service"; -import { fileViewUrl } from "@/constants/apiConfig"; +import { + downloadBookingFile, + fetchViewableFile, +} from "@/services/files.service"; import { useFileViewer } from "@/hooks/useFileViewer"; export interface ClearanceReviewSectionProps { @@ -272,17 +275,17 @@ export function ClearanceReviewSection({ <> {isViewable({ name: doc.file.name, - url: fileViewUrl(doc.file.id), + url: "", }) && ( - view({ - name: doc.file!.name, - url: fileViewUrl(doc.file!.id), - }) + void fetchViewableFile( + doc.file!.id, + doc.file!.name, + ).then(view) } c="edr-green" style={{ @@ -298,10 +301,21 @@ export function ClearanceReviewSection({ )} + void downloadBookingFile( + doc.file!.id, + doc.file!.name, + ) + } c="edr-green" - style={{ display: "flex" }} + style={{ + display: "flex", + background: "transparent", + border: "none", + cursor: "pointer", + }} > @@ -544,7 +558,7 @@ function DocReviewCard({ {hasFile && isViewable({ name: doc.file!.name, - url: fileViewUrl(doc.file!.id), + url: "", }) && ( diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ContractClearanceReviewSection.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ContractClearanceReviewSection.tsx index f2383b649..c85dc0b07 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ContractClearanceReviewSection.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ContractClearanceReviewSection.tsx @@ -34,7 +34,10 @@ import { isViewable } from "@edr/ui-common"; import { SectionCard } from "@/components/bookings/detail/SectionCard"; import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; import { contractsService } from "@/services/contracts.service"; -import { fileViewUrl } from "@/constants/apiConfig"; +import { + downloadBookingFile, + fetchViewableFile, +} from "@/services/files.service"; import { useContractClearanceMutations } from "@/hooks/contracts/useContracts"; import { useFileViewer } from "@/hooks/useFileViewer"; @@ -293,17 +296,17 @@ export function ContractClearanceReviewSection({ <> {isViewable({ name: doc.file.name, - url: fileViewUrl(doc.file.id), + url: "", }) && ( - view({ - name: doc.file!.name, - url: fileViewUrl(doc.file!.id), - }) + void fetchViewableFile( + doc.file!.id, + doc.file!.name, + ).then(view) } c="edr-green" style={{ @@ -319,10 +322,21 @@ export function ContractClearanceReviewSection({ )} + void downloadBookingFile( + doc.file!.id, + doc.file!.name, + ) + } c="edr-green" - style={{ display: "flex" }} + style={{ + display: "flex", + background: "transparent", + border: "none", + cursor: "pointer", + }} > @@ -611,7 +625,7 @@ function DocReviewCard({ {hasFile && isViewable({ name: doc.file!.name, - url: fileViewUrl(doc.file!.id), + url: "", }) && ( diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/container-excel.ts b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/container-excel.ts index bbd3acf90..c00091bfc 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/container-excel.ts +++ b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/container-excel.ts @@ -2,7 +2,7 @@ import * as XLSX from "xlsx"; // Excel import for container shipments: one spreadsheet row per physical // container, mirroring the manual per-unit fields (number, seal, VGM) plus the -// hazardous/reefer flags when the contract allows them. The parser is +// hazardous/reefer/return flags when the contract allows them. The parser is // all-or-nothing — any bad row rejects the file with row-numbered errors so a // partial import can never silently drop containers. @@ -14,6 +14,8 @@ export interface ContainerExcelOptions { allowedSizes: string[]; includeHazardous: boolean; includeReefer: boolean; + /** Contract was created WITH_RETURN — offer the empty-return column. */ + includeReturn?: boolean; } export interface ImportedContainerRow { @@ -23,6 +25,7 @@ export interface ImportedContainerRow { vgmTons: string; hazardous: boolean; reefer: boolean; + withReturn: boolean; } export interface ContainerExcelResult { @@ -36,7 +39,8 @@ type ColumnKey = | "sealNumber" | "vgmTons" | "hazardous" - | "reefer"; + | "reefer" + | "withReturn"; /** Match a header cell to a known column, tolerant of casing/spacing/units. */ function headerKey(raw: string): ColumnKey | null { @@ -47,6 +51,7 @@ function headerKey(raw: string): ColumnKey | null { if (h.includes("vgm") || h.includes("weight")) return "vgmTons"; if (h.includes("hazard")) return "hazardous"; if (h.includes("reefer") || h.includes("refrigerat")) return "reefer"; + if (h.includes("return")) return "withReturn"; // After the more specific matches: "Container Number", "Container No", … if (h.includes("container") || h.includes("number")) return "containerNumber"; return null; @@ -159,6 +164,7 @@ export async function parseContainerExcel( vgmTons: vgmRaw, hazardous: opts.includeHazardous && parseFlag(cell("hazardous")), reefer: opts.includeReefer && parseFlag(cell("reefer")), + withReturn: Boolean(opts.includeReturn) && parseFlag(cell("withReturn")), }); } @@ -178,6 +184,7 @@ export function downloadContainerImportTemplate(opts: ContainerExcelOptions) { const headers = ["Container Size", "Container Number", "Seal Number", "VGM (Tons)"]; if (opts.includeHazardous) headers.push("Hazardous (YES/NO)"); if (opts.includeReefer) headers.push("Reefer (YES/NO)"); + if (opts.includeReturn) headers.push("With Return (YES/NO)"); const sizes = opts.allowedSizes.length > 0 ? opts.allowedSizes : ["20ft"]; const sampleRows = sizes.map((size, i) => { @@ -189,6 +196,7 @@ export function downloadContainerImportTemplate(opts: ContainerExcelOptions) { ]; if (opts.includeHazardous) row.push("NO"); if (opts.includeReefer) row.push("NO"); + if (opts.includeReturn) row.push("NO"); return row; }); diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/total.ts b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/total.ts index a734c4f06..8c3e6f071 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/total.ts +++ b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/total.ts @@ -17,12 +17,14 @@ export interface GlShipmentTotal { /** A normalized view of the form quantities, freight-shape agnostic. */ export interface GlShipmentQuantities { isContainer: boolean; - /** Container lines: size + total qty + hazardous/reefer qty. */ + /** Container lines: size + total qty + hazardous/reefer/return qty. */ containers: Array<{ containerSize: string; quantity: number; hazardousQuantity: number; reeferQuantity: number; + /** Containers EDR takes back empty — only on WITH_RETURN contracts. */ + returnQuantity: number; }>; /** Bulk: tons (or item count) + hazardous/reefer qty. */ bulkQuantity: number; @@ -53,6 +55,7 @@ export function computeGlShipmentTotal( if (q.isContainer) { let hazardTotalQty = 0; let reeferTotalQty = 0; + let returnTotalQty = 0; for (const line of q.containers) { const qty = line.quantity; @@ -75,6 +78,7 @@ export function computeGlShipmentTotal( } hazardTotalQty += line.hazardousQuantity; reeferTotalQty += line.reeferQuantity; + returnTotalQty += line.returnQuantity; } if (contract.isHazardous && hazardTotalQty > 0) { @@ -101,6 +105,21 @@ export function computeGlShipmentTotal( }); } } + // Empty-container return is a container-only surcharge, priced per returning + // container rather than per line (contract-pricing.service emits the + // `with_return` rate only for WITH_RETURN contracts). + if (contract.equipmentReturn === "WITH_RETURN" && returnTotalQty > 0) { + const wr = rateFor((i) => i.conditionalOn === "with_return"); + if (wr) { + lines.push({ + label: wr.label, + unitPrice: wr.unitPrice, + unit: wr.unit, + quantity: returnTotalQty, + amount: wr.unitPrice * returnTotalQty, + }); + } + } } else { const qty = q.bulkQuantity; const rate = diff --git a/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx b/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx index 371a83ccf..c7893659b 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx @@ -23,7 +23,7 @@ import { import { useState } from "react"; import { useFileViewer } from "@edr/ui-common"; -import { fileViewUrl } from "@/constants/apiConfig"; +import { fetchViewableFile } from "@/services/files.service"; import { api } from "@/services/api"; import type { Company, CompanyChangeRequest } from "@/types/customer"; import { formatDate, humanize } from "./format"; @@ -236,10 +236,10 @@ export function ChangeRequestReview({ company }: { company: Company }) { type="button" size="sm" onClick={() => - view({ - name: c.fileName ?? humanize(c.code), - url: fileViewUrl(c.fileId), - }) + void fetchViewableFile( + c.fileId, + c.fileName ?? humanize(c.code), + ).then(view) } style={{ textDecoration: @@ -269,10 +269,9 @@ export function ChangeRequestReview({ company }: { company: Company }) { type="button" size="sm" onClick={() => - view({ - name: `Document ${i + 1}`, - url: fileViewUrl(fileId), - }) + void fetchViewableFile(fileId, `Document ${i + 1}`).then( + view, + ) } > Document {i + 1} @@ -307,10 +306,10 @@ export function ChangeRequestReview({ company }: { company: Company }) { type="button" size="sm" onClick={() => - view({ - name: c.fileName ?? "License document", - url: fileViewUrl(c.fileId), - }) + void fetchViewableFile( + c.fileId, + c.fileName ?? "License document", + ).then(view) } style={{ textDecoration: diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx index 99f29b985..313ad1836 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx @@ -63,8 +63,10 @@ import { import { contractsService } from "@/services/contracts.service"; import { api } from "@/services/api"; import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; -import { fileViewUrl } from "@/constants/apiConfig"; -import { downloadBookingFile } from "@/services/files.service"; +import { + downloadBookingFile, + fetchViewableFile, +} from "@/services/files.service"; import type { CustomerDocument } from "@/types/customer"; import type { Freight } from "@edr/types"; @@ -396,10 +398,9 @@ export default function ContractRequestDetailPage() { radius="lg" leftSection={} onClick={() => - handleViewFile({ - ...contractPdf, - url: fileViewUrl(contractPdf.id), - }) + void fetchViewableFile(contractPdf.id, contractPdf.name).then( + view, + ) } > View contract diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx index 9ae5a771b..b03a54835 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx @@ -54,7 +54,10 @@ import { humanize, } from "@/components/customers"; import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; -import { fileViewUrl } from "@/constants/apiConfig"; +import { + downloadBookingFile, + fetchViewableFile, +} from "@/services/files.service"; import { api } from "@/services/api"; import type { CompanyProfile, @@ -212,13 +215,7 @@ export default function CustomerDetailPage() { variant="subtle" color="gray" aria-label={`View ${f.name}`} - onClick={() => - view({ - name: f.name, - url: fileViewUrl(f.id), - mimeType: f.mimeType, - }) - } + onClick={() => void fetchViewableFile(f.id, f.name).then(view)} > @@ -227,13 +224,7 @@ export default function CustomerDetailPage() { type="button" size="xs" lineClamp={1} - onClick={() => - view({ - name: f.name, - url: fileViewUrl(f.id), - mimeType: f.mimeType, - }) - } + onClick={() => void fetchViewableFile(f.id, f.name).then(view)} style={{ maxWidth: 170, textAlign: "left", @@ -412,18 +403,19 @@ export default function CustomerDetailPage() { aria-label="View" data-stop-row-click onClick={() => - view({ - name: row.original.name, - url: fileViewUrl(row.original.id), - mimeType: row.original.mimeType, - }) + void fetchViewableFile(row.original.id, row.original.name).then( + view, + ) } > + void downloadBookingFile(row.original.id, row.original.name) + } variant="subtle" color="gray" aria-label="Download" @@ -839,11 +831,7 @@ export default function CustomerDetailPage() { size="sm" lineClamp={1} onClick={() => - view({ - name: doc.name, - url: fileViewUrl(doc.id), - mimeType: doc.mimeType, - }) + void fetchViewableFile(doc.id, doc.name).then(view) } > {doc.name} @@ -869,18 +857,17 @@ export default function CustomerDetailPage() { color="gray" aria-label={`Preview ${doc.name}`} onClick={() => - view({ - name: doc.name, - url: fileViewUrl(doc.id), - mimeType: doc.mimeType, - }) + void fetchViewableFile(doc.id, doc.name).then(view) } > + void downloadBookingFile(doc.id, doc.name) + } variant="subtle" color="gray" aria-label={`Download ${doc.name}`} @@ -980,11 +967,7 @@ export default function CustomerDetailPage() { component="button" type="button" onClick={() => - view({ - name: f.name, - url: fileViewUrl(f.id), - mimeType: f.mimeType, - }) + void fetchViewableFile(f.id, f.name).then(view) } size="xs" style={{ diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/DriverDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/DriverDetailPage.tsx index c97d7c418..3633b1942 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/DriverDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/DriverDetailPage.tsx @@ -38,6 +38,10 @@ import { driversService } from "@/services/drivers.service"; import { vehiclesService } from "@/services/vehicles.service"; import { fleetHistoryService, type FleetHistoryEvent } from "@/services/fleet-history.service"; import { fileUploadSettingsService } from "@/services/fileUploadSettings.service"; +import { + downloadBookingFile, + fetchViewableFile, +} from "@/services/files.service"; import { useToast } from "@/hooks/use-toast"; const fmtDate = (iso?: string | null) => { @@ -155,10 +159,6 @@ const DriverDocuments = ({ driverId }: { driverId: string }) => { onError: () => toast({ title: "Delete failed", variant: "destructive" }), }); - // /files/:id is a public inline-serving route; open directly for preview/download. - const fileUrl = (fileId: string, download = false) => - `${import.meta.env.VITE_API_URL}/files/${fileId}${download ? "?download=1" : ""}`; - return ( @@ -211,10 +211,22 @@ const DriverDocuments = ({ driverId }: { driverId: string }) => { {fmtDate(doc.createdAt)} - window.open(fileUrl(doc.id), "_blank")}> + + void fetchViewableFile(doc.id, doc.name).then((f) => + window.open(f.url, "_blank"), + ) + } + > - window.open(fileUrl(doc.id, true), "_blank")}> + void downloadBookingFile(doc.id, doc.name)} + > - + {/* - + */} diff --git a/apps/edr-freight-web/backoffice/src/services/files.service.ts b/apps/edr-freight-web/backoffice/src/services/files.service.ts index d9b4369c3..038b5a86d 100644 --- a/apps/edr-freight-web/backoffice/src/services/files.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/files.service.ts @@ -24,3 +24,20 @@ export async function downloadBookingFile( a.click(); URL.revokeObjectURL(url); } + +/** + * GET /files/:id is authenticated (global JwtGuard) — raw browser loads + * (/