From d175514f85083eeefcf721038a62194c5f38a14c Mon Sep 17 00:00:00 2001 From: Muluhabt Date: Sat, 25 Jul 2026 12:28:07 +0300 Subject: [PATCH 01/21] Adding exact reason why train is not available to users search results --- .../modules/bookings/guest-booking.service.ts | 16 +- .../src/modules/search/search.service.ts | 126 +++++++++- .../reserve-seat-issue-booking.e2e-spec.ts | 31 +++ .../portal/src/app/booking/results/page.tsx | 233 +++++++++++++----- .../specs/portal/search-empty-reasons.spec.ts | 122 +++++++++ packages/types/src/passenger/index.ts | 28 +++ 6 files changed, 485 insertions(+), 71 deletions(-) create mode 100644 e2e-ui/specs/portal/search-empty-reasons.spec.ts diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts index 0e27eb989..29c9a6798 100644 --- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts @@ -590,8 +590,22 @@ export class GuestBookingService { // Release the reservation using the SAME scope it was created with (global vs // schedule-scoped) — unblockSeat already correctly resets Seat.status for a global - // block; reimplementing that here would risk missing that reset. + // block; reimplementing that here would risk missing that reset. This alone leaves a + // window where the seat has no SeatBlock, no SeatHold, and no JourneySegment (the + // latter is only created on payment success — see PaymentsService.createJourneySegments) + // — i.e. fully available to the public — the instant this returns, since confirmSeats() + // is a no-op with no existing hold to extend. holdSeats() immediately re-reserves the + // seat with the same createdBy segment-range metadata the search/hold-conflict checks + // already rely on (getSeatAvailabilityMap); confirmSeats() then extends that hold to the + // real payment deadline (same mechanism createGuestOneWayBooking uses), so the seat stays + // unavailable to everyone else until the passenger pays or the hold/booking expires. await this.seatsService.unblockSeat(seatId, seatBlock.scheduleId ?? undefined); + await this.seatsService.holdSeats({ + scheduleId: dto.scheduleId, + originStationId: dto.originStationId, + destinationStationId: dto.destinationStationId, + passengers: [{ passengerId: guestPassengerId, seatId }], + }); await this.seatsService.confirmSeats([seatId]); this.eventEmitter.emit('booking.created', { booking }); diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts index 0c905acdd..9c368a976 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -12,6 +12,7 @@ import { SegmentsService } from "../segments/segments.service"; import { resolveCurrencyFromNationality } from "../fare-engine/fare-engine.dto"; import { resolveCheckinCutoff } from "../../common/utils/checkin-cutoff.utils"; import { Currency, Prisma } from "@prisma/client"; +import { Passenger } from "@edr/types"; const POINTS_TO_MINOR = 10; @@ -102,19 +103,23 @@ export class SearchService { const outbound = [...direct, ...transit]; if (outbound.length === 0 && dto.journeyType !== "ROUND_TRIP") { - const alternativesOutbound = await this.searchAlternatives( - dto.originStationId, - dto.destinationStationId, - dto.date, - dto.adultCount, - dto.childCount, - dto.nationality, - ); + const [alternativesOutbound, outboundReason] = await Promise.all([ + this.searchAlternatives( + dto.originStationId, + dto.destinationStationId, + dto.date, + dto.adultCount, + dto.childCount, + dto.nationality, + ), + this.classifyEmptySearch(dto.originStationId, dto.destinationStationId, dto.date), + ]); return { journeyType: "ONE_WAY", outbound: [], alternativeOutbound: alternativesOutbound, requestedDate: dto.date, + outboundReason, }; } @@ -157,7 +162,7 @@ export class SearchService { const returnDate = dto.returnDate ?? dto.date; if (outbound.length === 0 || inbound.length === 0) { - const [alternativeOutbound, alternativeInbound] = await Promise.all([ + const [alternativeOutbound, alternativeInbound, outboundReason, inboundReason] = await Promise.all([ outbound.length === 0 ? this.searchAlternatives( dto.originStationId, @@ -178,6 +183,12 @@ export class SearchService { dto.nationality, ) : Promise.resolve([]), + outbound.length === 0 + ? this.classifyEmptySearch(dto.originStationId, dto.destinationStationId, dto.date) + : Promise.resolve(undefined), + inbound.length === 0 + ? this.classifyEmptySearch(dto.destinationStationId, dto.originStationId, returnDate) + : Promise.resolve(undefined), ]); return { journeyType: "ROUND_TRIP", @@ -187,6 +198,8 @@ export class SearchService { alternativeInbound, requestedDate: dto.date, requestedReturnDate: returnDate, + outboundReason, + inboundReason, }; } @@ -344,6 +357,101 @@ export class SearchService { ); } + /** + * Only called when searchSchedules/searchTransitOptions found zero bookable results for a + * leg — classifies WHY, cheaply, by re-querying without the filters that already excluded + * everything. Priority order (most specific/actionable first): a station pair EDR never + * connects at all beats "nothing on this exact date", which beats "something exists but + * every option is cancelled/package-only/past cutoff/full" — see SearchEmptyReasonCode. + */ + private async classifyEmptySearch( + originStationId: string, + destinationStationId: string, + dateStr: string, + ): Promise { + const [origin, destination] = await Promise.all([ + this.prisma.station.findUnique({ where: { id: originStationId }, select: { name: true } }), + this.prisma.station.findUnique({ where: { id: destinationStationId }, select: { name: true } }), + ]); + const originStationName = origin?.name ?? "the origin station"; + const destinationStationName = destination?.name ?? "the destination station"; + const withCode = (code: Passenger.SearchEmptyReasonCode) => ({ + code, + originStationName, + destinationStationName, + }); + + // 1. Does any active route connect these two stations, in this direction, at all — + // ignoring date entirely? + const candidateRoutes = await this.prisma.route.findMany({ + where: { active: true, stops: { some: { stationId: originStationId } } }, + select: { stops: { select: { stationId: true, sequence: true } } }, + }); + const routeExists = candidateRoutes.some((r) => { + const o = r.stops.find((s) => s.stationId === originStationId); + const d = r.stops.find((s) => s.stationId === destinationStationId); + return !!o && !!d && o.sequence < d.sequence; + }); + if (!routeExists) return withCode(Passenger.SearchEmptyReasonCode.NoRoute); + + // 2. A route exists — is there any schedule at all on the requested date for this pair + // (regardless of status/package/coach/cutoff — those are checked next)? + const [y, m, d] = dateStr.split("-").map(Number); + const date = new Date( + `${String(y)}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}T00:00:00+03:00`, + ); + const nextDay = new Date( + `${String(y)}-${String(m).padStart(2, "0")}-${String(d + 1).padStart(2, "0")}T00:00:00+03:00`, + ); + const dayCandidates = await this.prisma.trainSchedule.findMany({ + where: { departureAt: { gte: date, lt: nextDay }, stopTimes: { some: { stationId: originStationId } } }, + select: { + status: true, + isPackageOnly: true, + departureAt: true, + route: { + select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } }, + }, + stopTimes: { select: { stationId: true, sequence: true, plannedArrivalAt: true, plannedDepartureAt: true } }, + coachAssignments: { select: { id: true } }, + }, + }); + const sameDayForPair = dayCandidates.filter((s) => { + const o = s.stopTimes.find((st) => st.stationId === originStationId); + const dst = s.stopTimes.find((st) => st.stationId === destinationStationId); + return !!o && !!dst && o.sequence < dst.sequence; + }); + if (sameDayForPair.length === 0) return withCode(Passenger.SearchEmptyReasonCode.NoScheduleOnDate); + + // 3. Schedules exist that date — narrow to ones that would otherwise be bookable + // (right status, not package-only, has at least one coach assigned). + const bookable = sameDayForPair.filter( + (s) => + (["SCHEDULED", "BOARDING", "EN_ROUTE"] as string[]).includes(s.status) && + !s.isPackageOnly && + s.coachAssignments.length > 0, + ); + if (bookable.length === 0) { + if (sameDayForPair.every((s) => s.status === "CANCELLED")) + return withCode(Passenger.SearchEmptyReasonCode.Cancelled); + if (sameDayForPair.every((s) => s.isPackageOnly)) + return withCode(Passenger.SearchEmptyReasonCode.PackageOnly); + return withCode(Passenger.SearchEmptyReasonCode.NoScheduleOnDate); + } + + // 4. Bookable schedules exist — did every one of them already pass its check-in cutoff + // for this origin stop? + const allCutoffPassed = bookable.every((s) => { + const originStop = s.stopTimes.find((st) => st.stationId === originStationId) ?? null; + return Date.now() >= resolveCheckinCutoff(s, originStop, originStationId).cutoffAt.getTime(); + }); + if (allCutoffPassed) return withCode(Passenger.SearchEmptyReasonCode.CheckinClosed); + + // 5. A bookable, still-open schedule exists for this pair/date — the only remaining reason + // searchSchedules dropped it is zero/insufficient seat availability. + return withCode(Passenger.SearchEmptyReasonCode.FullyBooked); + } + // ── Transit search ───────────────────────────────────────────────────────── private readonly MIN_CONNECTION_MINUTES = 30; private readonly MAX_CONNECTION_MINUTES = 360; diff --git a/apps/edr-passenger-api/test/reserve-seat-issue-booking.e2e-spec.ts b/apps/edr-passenger-api/test/reserve-seat-issue-booking.e2e-spec.ts index cb347092a..75f822289 100644 --- a/apps/edr-passenger-api/test/reserve-seat-issue-booking.e2e-spec.ts +++ b/apps/edr-passenger-api/test/reserve-seat-issue-booking.e2e-spec.ts @@ -294,6 +294,37 @@ describe("Reserve seat — issue booking (STAFF / PASSENGER)", () => { expect(byToken.schedule.origin.id).toBe(IDS.stationA); }); + it("PASSENGER path: the seat stays reserved (not publicly available) after the payment link is sent", async () => { + // Regression for: unblockSeat() released the reservation's SeatBlock and confirmSeats() + // was a no-op with no SeatHold to extend, so the seat had no SeatBlock, no SeatHold, and + // no JourneySegment (only created on payment success) the instant the payment link went + // out — fully bookable by the general public before the traveler had even paid. + await resetAndSeedCore(harness.prisma); + const dep = new Date(Date.now() + 3 * 60 * 60_000); + const arr = new Date(dep.getTime() + 100 * 60_000); + const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-STAYS-BLOCKED-${Date.now()}`, departureAt: dep, arrivalAt: arr }); + + await seatsService.blockSeat(seats[0].id, "Reserved pending payment", schedule.id); + + const result: any = await guestBookingService.issueBookingFromReservation( + seats[0].id, + baseDto({ scheduleId: schedule.id, bookingKind: ReservationBookingKind.PASSENGER, phone: "+253771234567" }) as any, + "staff-user-4", + ); + expect(result.booking.status).toBe("PENDING_PAYMENT"); + + // A member of the public trying to hold the exact same seat/leg must be rejected — + // proves the seat is covered by a real SeatHold (or equivalent), not silently free. + await expect( + seatsService.holdSeats({ + scheduleId: schedule.id, + originStationId: IDS.stationA, + destinationStationId: IDS.stationB, + passengers: [{ passengerId: "someone-else", seatId: seats[0].id }], + } as any), + ).rejects.toThrow(/already (held|booked)/i); + }); + it("requires a phone number for a PASSENGER booking", async () => { await resetAndSeedCore(harness.prisma); const dep = new Date(Date.now() + 3 * 60 * 60_000); diff --git a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx index 2c7d37f0f..e26df33ed 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx @@ -25,6 +25,7 @@ import { formatTime, getTimePeriod, toZonedDate } from "@/utils/format"; import { formatFare } from "@/utils/fare-utils"; import { useState, useEffect } from "react"; import AlternativeDatesCalendar from "@/components/AlternativeDatesCalendar"; +import { Passenger } from "@edr/types"; // Shared by the compact schedule card's coach-type badges and the "Choose Your Coach" // modal, so both pick the same icon for a given coach type name. @@ -35,6 +36,73 @@ const getCoachIcon = (typeName: string) => { return Armchair; }; +function formatSearchDate(dateStr: string): string { + if (!dateStr) return "your selected date"; + try { + return format(new Date(`${dateStr}T00:00:00`), "MMM d, yyyy"); + } catch { + return dateStr; + } +} + +// Turns the /search response's outboundReason/inboundReason (absent on older responses, +// or when the emptiness cause couldn't be classified) into copy for the empty-state cards +// below. `showAlternatives` gates whether an alternative-dates calendar makes sense — a +// station pair EDR never serves can't be fixed by picking a different date. +function emptyReasonCopy( + reason: Passenger.ISearchEmptyReason | undefined, + dateStr: string, +): { title: string; message: string; showAlternatives: boolean } { + const date = formatSearchDate(dateStr); + if (!reason) { + return { + title: "No trains available", + message: `No trains available on ${date}. Please choose another date below.`, + showAlternatives: true, + }; + } + const { code, originStationName, destinationStationName } = reason; + switch (code) { + case Passenger.SearchEmptyReasonCode.NoRoute: + return { + title: "Route not available", + message: `EDR doesn't currently run trains between ${originStationName} and ${destinationStationName}.`, + showAlternatives: false, + }; + case Passenger.SearchEmptyReasonCode.FullyBooked: + return { + title: "Fully booked", + message: `This train is fully booked on ${date}. Please choose another date below.`, + showAlternatives: true, + }; + case Passenger.SearchEmptyReasonCode.Cancelled: + return { + title: "Departure cancelled", + message: `The ${date} departure between ${originStationName} and ${destinationStationName} was cancelled. Please choose another date below.`, + showAlternatives: true, + }; + case Passenger.SearchEmptyReasonCode.CheckinClosed: + return { + title: "Booking closed for today", + message: `Booking for the ${date} departure has closed. Please choose another date below.`, + showAlternatives: true, + }; + case Passenger.SearchEmptyReasonCode.PackageOnly: + return { + title: "Only a travel package is available", + message: `The only train between ${originStationName} and ${destinationStationName} on ${date} is bookable as part of a travel package, not as a standalone ticket. Please choose another date below.`, + showAlternatives: true, + }; + case Passenger.SearchEmptyReasonCode.NoScheduleOnDate: + default: + return { + title: "No trains available", + message: `No trains available on ${date}. Please choose another date below.`, + showAlternatives: true, + }; + } +} + export default function ResultsPage() { const router = useRouter(); const searchParams = useSearchParams(); @@ -1097,18 +1165,35 @@ export default function ResultsPage() { (results?.alternativeOutbound || []).length === 0 && (results?.alternativeInbound || []).length === 0; if (isRoundTripNoResults) { + const outboundCopy = emptyReasonCopy(results?.outboundReason, searchData.date); + const inboundCopy = emptyReasonCopy(results?.inboundReason, searchData.returnDate || searchData.date); + const sameReason = + !!results?.outboundReason && + results?.outboundReason?.code === results?.inboundReason?.code; return (
-
-
- - No trains found for your selected dates or route. +
+
+
+ + + {sameReason + ? outboundCopy.message + : "No trains found for your selected dates or route."} + +
+
- + {!sameReason && (results?.outboundReason || results?.inboundReason) && ( +
+

Outbound: {outboundCopy.message}

+

Return: {inboundCopy.message}

+
+ )}
@@ -1125,6 +1210,8 @@ export default function ResultsPage() { const bothCalendarsAvailable = alternativeOutbound.length > 0 && alternativeInbound.length > 0; const outboundValue = pendingOutboundDate ?? (searchData.date ? new Date(`${searchData.date}T00:00:00`) : undefined); const inboundValue = pendingInboundDate ?? (searchData.returnDate ? new Date(`${searchData.returnDate}T00:00:00`) : undefined); + const outboundCopy = emptyReasonCopy(results?.outboundReason, searchData.date); + const inboundCopy = emptyReasonCopy(results?.inboundReason, searchData.returnDate || searchData.date); return (
@@ -1137,50 +1224,62 @@ export default function ResultsPage() {

No trains available

-

- No trains available on your selected dates. Please choose another - date below. -

-
- {alternativeOutbound.length > 0 ? ( - { - if (!bothCalendarsAvailable) { - pushResultsWithDates({ date: format(date, "yyyy-MM-dd") }); - return; - } - setPendingOutboundDate(date); - if (pendingInboundDate && pendingInboundDate < date) setPendingInboundDate(undefined); - }} - /> - ) : ( -

- No alternative outbound dates found nearby. +

+
+

+ Outbound — {outboundCopy.title}

- )} - {alternativeInbound.length > 0 ? ( - { - if (!bothCalendarsAvailable) { - pushResultsWithDates({ returnDate: format(date, "yyyy-MM-dd") }); - return; - } - setPendingInboundDate(date); - }} - /> - ) : ( -

- No alternative return dates found nearby. +

{outboundCopy.message}

+ {outboundCopy.showAlternatives ? ( + alternativeOutbound.length > 0 ? ( + { + if (!bothCalendarsAvailable) { + pushResultsWithDates({ date: format(date, "yyyy-MM-dd") }); + return; + } + setPendingOutboundDate(date); + if (pendingInboundDate && pendingInboundDate < date) setPendingInboundDate(undefined); + }} + /> + ) : ( +

+ No alternative outbound dates found nearby. +

+ ) + ) : null} +
+
+

+ Return — {inboundCopy.title}

- )} +

{inboundCopy.message}

+ {inboundCopy.showAlternatives ? ( + alternativeInbound.length > 0 ? ( + { + if (!bothCalendarsAvailable) { + pushResultsWithDates({ returnDate: format(date, "yyyy-MM-dd") }); + return; + } + setPendingInboundDate(date); + }} + /> + ) : ( +

+ No alternative return dates found nearby. +

+ ) + ) : null} +
{bothCalendarsAvailable && pendingOutboundDate && !pendingInboundDate && (

@@ -1201,6 +1300,10 @@ export default function ResultsPage() { } if (isOneWayNoOutbound) { + const { title, message, showAlternatives } = emptyReasonCopy( + results?.outboundReason, + searchData.date, + ); return (

{renderClassModal()} @@ -1211,26 +1314,34 @@ export default function ResultsPage() {

- No trains available + {title}

- No trains available on your selected date. Please choose another - date below. + {message}

- {alternativeOutbound.length > 0 ? ( - pushResultsWithDates({ date: format(date, "yyyy-MM-dd") })} - /> + {showAlternatives ? ( + alternativeOutbound.length > 0 ? ( + pushResultsWithDates({ date: format(date, "yyyy-MM-dd") })} + /> + ) : ( + + ) ) : ( )}
diff --git a/e2e-ui/specs/portal/search-empty-reasons.spec.ts b/e2e-ui/specs/portal/search-empty-reasons.spec.ts new file mode 100644 index 000000000..1f68e2022 --- /dev/null +++ b/e2e-ui/specs/portal/search-empty-reasons.spec.ts @@ -0,0 +1,122 @@ +import { test, expect } from "@playwright/test"; +import { API_URL, STATIONS, resultsUrl, sampleDepartDate, staffToken } from "../../fixtures/data"; +import { UI_IDS } from "../../../apps/edr-passenger-api/test/fixtures/seed-ui"; + +/** + * Portal search empty-state — asserts /search's outboundReason drives a message specific to WHY + * zero results came back, instead of the old one-size-fits-all "No trains available" copy (see + * SearchService.classifyEmptySearch + results/page.tsx's emptyReasonCopy). Each test isolates its + * own route/station/schedule (never mutates the shared seed fixtures) so it can run alongside the + * rest of the portal suite without disturbing other specs' assumptions about the seeded trip. + */ + +function auth() { + return { Authorization: `Bearer ${staffToken()}` }; +} + +/** Days-from-now at 06:00Z, matching seed-ui's sampleDepartAt() convention (same-day in Addis TZ). */ +function daysFromNowDate(days: number): string { + const d = new Date(); + d.setUTCDate(d.getUTCDate() + days); + d.setUTCHours(6, 0, 0, 0); + return d.toISOString().slice(0, 10); +} + +/** resultsUrl() hardcodes STATIONS.A→C and sampleDepartDate() — these tests need other pairs/dates. */ +function customResultsUrl(originId: string, destinationId: string, date: string, adults = 1) { + const p = new URLSearchParams({ + origin: originId, + destination: destinationId, + date, + tripType: "ONE_WAY", + adults: String(adults), + children: "0", + nationality: "ETHIOPIAN", + }); + return `/booking/results?${p.toString()}`; +} + +test("empty-state: fully booked shows a fully-booked message, not a generic one", async ({ page }) => { + // The seeded trip (UI_IDS.schedule) has 48 seats total — asking for far more than that guarantees + // zero seat classes satisfy the party size, without needing to actually consume real seats. + await page.goto(resultsUrl({ adults: 500 }), { waitUntil: "domcontentloaded" }); + + await expect(page.getByRole("heading", { name: "Fully booked" })).toBeVisible({ timeout: 20_000 }); + await expect(page.getByText(/fully booked on/i)).toBeVisible(); +}); + +test("empty-state: no schedule that day shows the generic no-trains message", async ({ page }) => { + // ROUTE_ID/STATIONS A→C is a real, active route — just pick a date far enough out that no + // schedule was ever created for it. + const farDate = daysFromNowDate(300); + await page.goto(customResultsUrl(STATIONS.A, STATIONS.C, farDate), { waitUntil: "domcontentloaded" }); + + await expect(page.getByRole("heading", { name: "No trains available" })).toBeVisible({ timeout: 20_000 }); + await expect(page.getByText(/no trains available on/i)).toBeVisible(); +}); + +test("empty-state: a cancelled departure says so, not \"no trains available\"", async ({ page, request }) => { + const routeRes = await request.post(`${API_URL}/routes`, { + headers: auth(), + data: { + code: `E2E-EMPTY-CANCELLED-${Date.now()}`, + name: "E2E Empty Cancelled Route", + effectiveFrom: "2020-01-01T00:00:00Z", + stops: [ + { stationId: STATIONS.A, sequence: 1, distanceKm: 0 }, + { stationId: STATIONS.C, sequence: 2, distanceKm: 100 }, + ], + }, + }); + expect(routeRes.ok()).toBeTruthy(); + const route = (await routeRes.json())?.data; + + const templateRes = await request.put(`${API_URL}/routes/${route.id}/coaches`, { + headers: auth(), + data: { coaches: [{ coachId: UI_IDS.coach, positionNumber: 1 }] }, + }); + expect(templateRes.ok()).toBeTruthy(); + + const date = daysFromNowDate(60); + const dep = new Date(`${date}T06:00:00Z`); + const arr = new Date(dep.getTime() + 4 * 3600_000); + const scheduleRes = await request.post(`${API_URL}/schedules`, { + headers: auth(), + data: { trainId: UI_IDS.train, routeId: route.id, departureAt: dep.toISOString(), arrivalAt: arr.toISOString() }, + }); + expect(scheduleRes.ok()).toBeTruthy(); + const schedule = (await scheduleRes.json())?.data; + + const cancelRes = await request.patch(`${API_URL}/schedules/${schedule.id}/status`, { + headers: auth(), + data: { status: "CANCELLED" }, + }); + expect(cancelRes.ok()).toBeTruthy(); + + await page.goto(customResultsUrl(STATIONS.A, STATIONS.C, date), { waitUntil: "domcontentloaded" }); + + await expect(page.getByText("Departure cancelled")).toBeVisible({ timeout: 20_000 }); + await expect(page.getByText(/was cancelled/i)).toBeVisible(); +}); + +test("empty-state: an unconnected station pair says EDR doesn't run there, with no date picker", async ({ + page, + request, +}) => { + const stationRes = await request.post(`${API_URL}/stations`, { + headers: auth(), + data: { code: `E2E-ISO-${Date.now() % 100000}`, name: "E2E Isolated Station", city: "Nowhere" }, + }); + expect(stationRes.ok()).toBeTruthy(); + const isolatedStation = (await stationRes.json())?.data; + + await page.goto(customResultsUrl(STATIONS.A, isolatedStation.id, sampleDepartDate()), { + waitUntil: "domcontentloaded", + }); + + await expect(page.getByText("Route not available")).toBeVisible({ timeout: 20_000 }); + await expect(page.getByText(/doesn't currently run trains between/i)).toBeVisible(); + // NO_ROUTE is not fixable by picking another date — no calendar/"Change Date" affordance. + await expect(page.getByText(/change date/i)).toHaveCount(0); + await expect(page.getByRole("button", { name: /modify search/i })).toBeVisible(); +}); diff --git a/packages/types/src/passenger/index.ts b/packages/types/src/passenger/index.ts index e7f0d8ccd..dff769f00 100644 --- a/packages/types/src/passenger/index.ts +++ b/packages/types/src/passenger/index.ts @@ -34,6 +34,34 @@ export enum ScheduleStatus { Delayed = "DELAYED", } +/** + * Why a /search leg (outbound or inbound) came back with zero bookable schedules. + * Priority order applied by the API when classifying: NoRoute > NoScheduleOnDate > + * Cancelled > PackageOnly > CheckinClosed > FullyBooked (see search.service.ts + * classifyEmptySearch). The frontend uses this to show a specific empty-state + * message instead of a generic "no trains available". + */ +export enum SearchEmptyReasonCode { + /** No route (in either direction) ever connects these two stations. */ + NoRoute = "NO_ROUTE", + /** A route connects these stations, but no schedule lands on the requested date at all. */ + NoScheduleOnDate = "NO_SCHEDULE_ON_DATE", + /** Every schedule for this pair on this date was cancelled. */ + Cancelled = "CANCELLED", + /** Every schedule for this pair on this date is package-only (excluded from ticket search). */ + PackageOnly = "PACKAGE_ONLY", + /** A bookable schedule exists, but its check-in cutoff has already passed for every option. */ + CheckinClosed = "CHECKIN_CLOSED", + /** A bookable, still-open schedule exists but has no seats left for the requested party. */ + FullyBooked = "FULLY_BOOKED", +} + +export interface ISearchEmptyReason { + code: SearchEmptyReasonCode; + originStationName: string; + destinationStationName: string; +} + export enum PaymentStatus { Pending = "PENDING", Paid = "PAID", From a544bebc512d351c1394504027f90021ab37601a Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Sat, 25 Jul 2026 10:29:14 +0000 Subject: [PATCH 02/21] fix(operations): last-mile assign until load fully trucked + 40ft cap Assign was gated on any truck assigned, blocking multi-truck deliveries. Gate on remaining containers (or bulk tonnage) instead, show covered/total in the modal, cap a 40ft container to one truck with no size mixing (mirrors assertTruckLoad), and lock arrived/departed rows. --- .../2900000000000-LivestockPerItem.ts | 28 +++ .../facility-handling.service.ts | 17 +- .../warehouse-fee.bulk-quantity.spec.ts | 197 ++++++++++++++++++ .../warehouses/warehouse-fee.service.ts | 52 ++++- .../warehouses/warehouse-inventory.service.ts | 65 +++++- .../components/warehouses/FeePreviewModal.tsx | 15 +- .../src/pages/operations/LastMilePage.tsx | 130 ++++++++++-- .../src/pages/ruleEngine/config/resources.ts | 14 ++ .../pages/warehouses/WarehouseRulesPage.tsx | 6 + .../backoffice/src/types/warehouse.ts | 2 + 10 files changed, 492 insertions(+), 34 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/2900000000000-LivestockPerItem.ts create mode 100644 apps/edr-freight-api/src/modules/warehouses/warehouse-fee.bulk-quantity.spec.ts diff --git a/apps/edr-freight-api/src/migrations/2900000000000-LivestockPerItem.ts b/apps/edr-freight-api/src/migrations/2900000000000-LivestockPerItem.ts new file mode 100644 index 000000000..ce05a7ce1 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2900000000000-LivestockPerItem.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Livestock is billed and counted per head, not per ton — line it up with the + * other break-bulk cargo types (Machinery, Truck, Automobile) so bulk + * storage/demurrage fees charge per item instead of per ton for it. + */ +export class LivestockPerItem2900000000000 implements MigrationInterface { + name = "LivestockPerItem2900000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE freight.cargo_types + SET unit_of_measure = 'PER_ITEM' + WHERE code = 'LIVESTOCK' + AND unit_of_measure IS DISTINCT FROM 'PER_ITEM' + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE freight.cargo_types + SET unit_of_measure = 'PER_TON' + WHERE code = 'LIVESTOCK' + AND unit_of_measure IS DISTINCT FROM 'PER_TON' + `); + } +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/facility-handling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/facility-handling.service.ts index c0e0b7c5f..20ac4859a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/facility-handling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/facility-handling.service.ts @@ -67,6 +67,21 @@ export class FacilityHandlingService { inventoryId = inv?.id ?? null; } + // The handed-over weight: the booking's declared VGM, else what its + // containers actually carry. A GRN without a weight is not a receipt. + let weightTons = Number(booking.cargoTotalWeightVgm) || null; + if (!weightTons) { + const [sum]: Array<{ tons: string | null }> = await manager.query( + `SELECT SUM(bcu.vgm_tons) AS tons + 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`, + [booking.id], + ); + weightTons = Number(sum?.tons) || null; + } + const repo = manager.getRepository(FacilityHandlingEvent); await repo.save( repo.create({ @@ -75,7 +90,7 @@ export class FacilityHandlingService { trainScheduleId: input.trainScheduleId ?? null, eventType, grnNumber, - weightTons: Number(booking.cargoTotalWeightVgm) || null, + weightTons, inventoryId, performedBy: input.performedBy ?? null, occurredAt, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.bulk-quantity.spec.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.bulk-quantity.spec.ts new file mode 100644 index 000000000..aa4d5e7b1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.bulk-quantity.spec.ts @@ -0,0 +1,197 @@ +import { WarehouseFeeService } from './warehouse-fee.service'; +import { WarehouseFeeRule } from './entities/warehouse-fee-rule.entity'; + +// Bulk storage/demurrage used to bill a flat rate per day regardless of cargo +// quantity. It now scales by the cargo type's own unit of measure — tons for +// PER_TON cargo, item count for PER_ITEM cargo (Machinery, Truck, Automobile, +// Livestock…) — read from THIS inventory row, not the whole booking's total. +describe('WarehouseFeeService bulk quantity billing', () => { + const makeService = () => + // compute() only touches its own arguments plus this.convertAmount, which + // short-circuits when rule.currency === billingCurrency — none of the + // constructor deps are exercised. + new WarehouseFeeService({} as any, {} as any, {} as any, {} as any); + + const rule = (overrides: Partial = {}): WarehouseFeeRule => + ({ + id: 'rule-1', + name: 'Bulk storage', + ruleType: 'STORAGE_FEE', + freeDays: 0, + ratePerDay: 10, + currency: 'USD', + tiers: [], + ...overrides, + }) as WarehouseFeeRule; + + const baseItem = (overrides: Record = {}) => ({ + arrivedAt: new Date('2026-01-01T00:00:00Z'), + gateClearedAt: null, + releaseDate: null, + freightType: 'BULK', + tradeDirection: 'IMPORT', + cargoTypeCode: 'WHEAT', + containerTypeCode: null, + vehicleType: null, + inventoryQuantity: 3, + inventoryWeight: 25, + bookingContainerCount: 0, + cargoUnitOfMeasure: null, + facilityId: null, + warehouseId: null, + yardId: null, + zoneId: null, + ...overrides, + }); + + // 5 elapsed days, 0 free days -> 5 chargeable days throughout. + const now = new Date('2026-01-06T00:00:00Z'); + + it('bills PER_TON bulk cargo by this row\'s weight, not a flat day rate', async () => { + const service = makeService(); + const preview = await (service as any).compute( + 'STORAGE_FEE', + rule(), + baseItem({ cargoUnitOfMeasure: 'PER_TON', inventoryWeight: 25 }), + now, + 'USD', + ); + expect(preview.unitLabel).toBe('ton'); + expect(preview.containerCount).toBe(25); + expect(preview.billableUnits).toBe(5 * 25); + expect(preview.amount).toBe(5 * 25 * 10); + }); + + it('bills PER_ITEM bulk cargo (Machinery/Truck/Automobile/Livestock) by unit count', async () => { + const service = makeService(); + const preview = await (service as any).compute( + 'STORAGE_FEE', + rule(), + baseItem({ cargoUnitOfMeasure: 'PER_ITEM', inventoryQuantity: 3, cargoTypeCode: 'MACHINERY' }), + now, + 'USD', + ); + expect(preview.unitLabel).toBe('item'); + expect(preview.containerCount).toBe(3); + expect(preview.billableUnits).toBe(5 * 3); + expect(preview.amount).toBe(5 * 3 * 10); + }); + + it('defaults to PER_TON when the cargo type has no unit of measure set', async () => { + const service = makeService(); + const preview = await (service as any).compute( + 'STORAGE_FEE', + rule(), + baseItem({ cargoUnitOfMeasure: null, inventoryWeight: 12 }), + now, + 'USD', + ); + expect(preview.unitLabel).toBe('ton'); + expect(preview.containerCount).toBe(12); + }); + + it('charges nothing yet when the row has not been weighed/counted (0 is legitimate, not floored to 1)', async () => { + const service = makeService(); + const preview = await (service as any).compute( + 'STORAGE_FEE', + rule(), + baseItem({ cargoUnitOfMeasure: 'PER_TON', inventoryWeight: 0 }), + now, + 'USD', + ); + expect(preview.containerCount).toBe(0); + expect(preview.billableUnits).toBe(0); + expect(preview.amount).toBe(0); + }); + + it('leaves CONTAINER freight billing untouched by the new bulk fields', async () => { + const service = makeService(); + const preview = await (service as any).compute( + 'DEMURRAGE_FEE', + rule({ ruleType: 'DEMURRAGE_FEE' }), + baseItem({ + freightType: 'CONTAINER', + bookingContainerCount: 4, + cargoUnitOfMeasure: 'PER_ITEM', // must be ignored for container freight + inventoryWeight: 999, + }), + now, + 'USD', + ); + expect(preview.unitLabel).toBe('container'); + expect(preview.containerCount).toBe(4); + expect(preview.billableUnits).toBe(5 * 4); + }); + + // Double handling is a flat one-time charge, but previewForInventory() calls + // it once per warehouse_inventory ROW. Before this fix it read the whole + // booking's total on every row, so a booking split across N rows was billed + // N times against its full quantity. Reading each row's own weight/count + // fixes that: summing the rows now reproduces the booking total exactly once. + describe('double handling (row-level, not booking-wide)', () => { + const doubleHandlingRule = (basis: 'PER_CONTAINER' | 'PER_TON' | 'PER_ITEM') => + rule({ ruleType: 'DOUBLE_HANDLING_FEE', basis, ratePerDay: 20 }); + + it('bills PER_TON by this row\'s own weight', async () => { + const service = makeService(); + const preview = await (service as any).compute( + 'DOUBLE_HANDLING_FEE', + doubleHandlingRule('PER_TON'), + baseItem({ cargoUnitOfMeasure: 'PER_TON', inventoryWeight: 10 }), + now, + 'USD', + ); + expect(preview.unitLabel).toBe('ton'); + expect(preview.billableUnits).toBe(10); + expect(preview.amount).toBe(10 * 20); + }); + + it('bills PER_ITEM by this row\'s own unit count', async () => { + const service = makeService(); + const preview = await (service as any).compute( + 'DOUBLE_HANDLING_FEE', + doubleHandlingRule('PER_ITEM'), + baseItem({ cargoUnitOfMeasure: 'PER_ITEM', inventoryQuantity: 2, cargoTypeCode: 'TRUCK' }), + now, + 'USD', + ); + expect(preview.unitLabel).toBe('item'); + expect(preview.billableUnits).toBe(2); + expect(preview.amount).toBe(2 * 20); + }); + + it('two rows of one booking sum to the booking total exactly once (no N-times overcount)', async () => { + const service = makeService(); + const ruleDef = doubleHandlingRule('PER_TON'); + const rowA = await (service as any).compute( + 'DOUBLE_HANDLING_FEE', + ruleDef, + baseItem({ cargoUnitOfMeasure: 'PER_TON', inventoryWeight: 6 }), + now, + 'USD', + ); + const rowB = await (service as any).compute( + 'DOUBLE_HANDLING_FEE', + ruleDef, + baseItem({ cargoUnitOfMeasure: 'PER_TON', inventoryWeight: 4 }), + now, + 'USD', + ); + // Booking total is 10 tons across the two rows — billed once in total, + // not 10 tons charged against EACH row (which the old booking-wide read did). + expect(rowA.amount + rowB.amount).toBe(10 * 20); + }); + + it('no charge for export/domestic regardless of basis', async () => { + const service = makeService(); + const preview = await (service as any).compute( + 'DOUBLE_HANDLING_FEE', + doubleHandlingRule('PER_TON'), + baseItem({ tradeDirection: 'EXPORT', cargoUnitOfMeasure: 'PER_TON', inventoryWeight: 10 }), + now, + 'USD', + ); + expect(preview.amount).toBe(0); + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts index a60261c1e..ec1b90789 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts @@ -20,9 +20,11 @@ interface ItemAttributes { /** Vehicle type of the truck (truck detention scoping); null otherwise. */ vehicleType: string | null; inventoryQuantity: number; + /** This inventory row's own net weight (tonnes) — bulk STORAGE/DEMURRAGE for PER_TON cargo bills against this, not the booking-wide total. */ + inventoryWeight: number; bookingContainerCount: number; - /** Booking cargo total in the cargo's unit of measure: tonnes (PER_TON) or item count (PER_ITEM). */ - cargoQuantity: number; + /** This item's cargo type unit of measure (PER_TON | PER_ITEM); null defaults to PER_TON. Decides whether bulk day-based fees bill by weight or item count. */ + cargoUnitOfMeasure: string | null; facilityId: string | null; warehouseId: string | null; yardId: string | null; @@ -60,7 +62,7 @@ export interface AccrualDashboardRow { export interface FeePreview { ruleType: FeeRuleType; - /** Double-handling charge basis (PER_CONTAINER | PER_TON | PER_MACHINERY); null otherwise. */ + /** Double-handling charge basis (PER_CONTAINER | PER_TON | PER_ITEM); null otherwise. */ basis: FeeRuleBasis | null; ruleId: string | null; ruleName: string | null; @@ -75,6 +77,8 @@ export interface FeePreview { elapsedDays: number; chargeableDays: number; containerCount: number; + /** What `containerCount`/`billableUnits` are counted in — 'container' | 'truck' | 'ton' | 'item'. Bulk cargo bills by weight (ton) or item count depending on the cargo type's unit of measure. */ + unitLabel: string; billableUnits: number; amount: number; tiers: Array<{ @@ -242,6 +246,7 @@ export class WarehouseFeeService { inv.gate_cleared_at AS "gateClearedAt", inv.release_date AS "releaseDate", inv.quantity AS "inventoryQuantity", + inv.weight AS "inventoryWeight", inv.warehouse_id AS "warehouseId", inv.yard_id AS "yardId", inv.zone_id AS "zoneId", @@ -251,7 +256,7 @@ export class WarehouseFeeService { COALESCE(cgt.code, booking_cgt.code) AS "cargoTypeCode", COALESCE(ctt.code, booking_ctt.code) AS "containerTypeCode", COALESCE(container_lines.container_count, 0) AS "bookingContainerCount", - COALESCE(b.cargo_total_weight_vgm, 0) AS "cargoQuantity" + COALESCE(cgt.unit_of_measure, booking_cgt.unit_of_measure) AS "cargoUnitOfMeasure" FROM freight.warehouse_inventory inv LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id LEFT JOIN freight.bookings b ON b.id = inv.booking_id @@ -470,6 +475,22 @@ export class WarehouseFeeService { }; } + /** + * Bulk's own billing quantity for THIS inventory row — weight (tons) for + * PER_TON cargo, unit count for PER_ITEM cargo (Machinery, Truck, Automobile, + * Livestock…). Shared by every cargo-scoped fee type (storage, demurrage, + * double handling) so a booking split across several rows is never billed + * more than once against its full total. 0 is a legitimate charge (nothing + * weighed/counted yet), so no forced floor. + */ + private resolveBulkQuantity(item: ItemAttributes): { quantity: number; unitLabel: string } { + const cargoUnit = (item.cargoUnitOfMeasure ?? 'PER_TON').toUpperCase(); + if (cargoUnit === 'PER_ITEM') { + return { quantity: Math.max(0, Number(item.inventoryQuantity) || 0), unitLabel: 'item' }; + } + return { quantity: Math.max(0, Number(item.inventoryWeight) || 0), unitLabel: 'ton' }; + } + private async compute( ruleType: FeeRuleType, rule: WarehouseFeeRule | null, @@ -490,9 +511,11 @@ export class WarehouseFeeService { const targetCurrency = this.normalizeCurrency(billingCurrency); const isContainer = (item.freightType ?? '').toUpperCase() === 'CONTAINER'; const inventoryQuantity = Math.max(1, Math.round(Number(item.inventoryQuantity) || 1)); + const bulk = this.resolveBulkQuantity(item); const containerCount = isContainer ? Math.max(1, Math.round(Number(item.bookingContainerCount) || inventoryQuantity)) - : 1; + : bulk.quantity; + const unitLabel = isContainer ? 'container' : bulk.unitLabel; const elapsedDays = start ? Math.max(0, Math.ceil((new Date(endDate).getTime() - start.getTime()) / MS_PER_DAY)) @@ -533,6 +556,7 @@ export class WarehouseFeeService { elapsedDays, chargeableDays, containerCount, + unitLabel, billableUnits, amount, tiers: hasTiers ? convertedTiers : [], @@ -560,12 +584,15 @@ export class WarehouseFeeService { const containerCount = isContainer ? Math.max(1, Math.round(Number(item.bookingContainerCount) || inventoryQuantity)) : 1; - // PER_TON (tonnes) and PER_ITEM (piece count) both read the cargo total, - // which is stored in the cargo's own unit of measure. - const cargoQuantity = Math.max(0, Number(item.cargoQuantity) || 0); + // PER_TON (tonnes) and PER_ITEM (piece count) both read THIS row's own + // weight/count — never the whole booking's total. previewForInventory() + // computes double handling once per inventory row, so a booking-wide total + // would double- (or triple-) bill a booking split across several rows. + const bulk = this.resolveBulkQuantity(item); // Double handling applies to IMPORT only — no charge for export/domestic. const isImport = (item.tradeDirection ?? '').toUpperCase() === 'IMPORT'; - const quantity = !isImport ? 0 : basis === 'PER_CONTAINER' ? containerCount : cargoQuantity; + const quantity = !isImport ? 0 : basis === 'PER_CONTAINER' ? containerCount : bulk.quantity; + const unitLabel = basis === 'PER_CONTAINER' ? 'container' : bulk.unitLabel; const sourceAmount = Math.round(rate * quantity * 100) / 100; const amount = ruleCurrency ? await this.convertAmount(sourceAmount, ruleCurrency, targetCurrency) : 0; const convertedRate = ruleCurrency ? await this.convertAmount(rate, ruleCurrency, targetCurrency) : 0; @@ -586,6 +613,7 @@ export class WarehouseFeeService { elapsedDays: 0, chargeableDays: 0, containerCount, + unitLabel, billableUnits: quantity, amount, tiers: [], @@ -771,6 +799,7 @@ export class WarehouseFeeService { return { ruleType: 'TRUCK_DETENTION_FEE', basis: null, + unitLabel: 'truck', ruleId: null, ruleName: null, freeDays: 0, @@ -828,8 +857,9 @@ export class WarehouseFeeService { containerTypeCode: null, vehicleType: g.vehicleType ?? null, inventoryQuantity: 1, + inventoryWeight: 0, bookingContainerCount: 1, - cargoQuantity: 0, + cargoUnitOfMeasure: null, facilityId: null, warehouseId: null, yardId: null, @@ -856,6 +886,7 @@ export class WarehouseFeeService { return { ruleType: 'TRUCK_DETENTION_FEE', basis: null, + unitLabel: 'truck', ruleId: single?.ruleId ?? null, ruleName: single ? single.ruleName : computed.length > 1 && anyRuleName ? 'Per truck-type rules' : anyRuleName, freeDays: 0, @@ -927,6 +958,7 @@ export class WarehouseFeeService { return { ruleType: 'TRUCK_DETENTION_FEE', basis: null, + unitLabel: 'truck', ruleId: rule?.id ?? null, ruleName: rule?.name ?? null, freeDays: 0, 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 582face93..6bff0752f 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 @@ -1144,7 +1144,17 @@ export class WarehouseInventoryService { tradeDirection: string | null; cargoTypeCode: string | null; }[] = await this.dataSource.query( - `SELECT b.id, b.cargo_total_weight_vgm AS weight, + `SELECT b.id, + -- Received weight must land on the inventory row: a booking with no + -- declared VGM still has per-container VGM to record. + COALESCE( + NULLIF(b.cargo_total_weight_vgm, 0), + (SELECT SUM(bcu.vgm_tons) + FROM freight.booking_container_units bcu + JOIN freight.booking_container bc2 + ON bc2.id = bcu.booking_container_id AND bc2.deleted_at IS NULL + WHERE bc2.booking_id = b.id AND bcu.deleted_at IS NULL) + ) AS weight, b.freight_type AS "freightType", b.trade_direction AS "tradeDirection", cgt.code AS "cargoTypeCode" FROM freight.bookings b @@ -2140,7 +2150,17 @@ export class WarehouseInventoryService { // at an intermediate yard was already unloaded there by the checkpoint // auto-unload; without this filter it would be mis-located into the final // yard's inventory too. - `SELECT b.id, b.status, b.cargo_total_weight_vgm AS weight, + `SELECT b.id, b.status, + -- Same fallback as autoUnloadArrived: never land a 0 t receipt when + -- the booking's containers carry a VGM. + COALESCE( + NULLIF(b.cargo_total_weight_vgm, 0), + (SELECT SUM(bcu.vgm_tons) + FROM freight.booking_container_units bcu + JOIN freight.booking_container bc2 + ON bc2.id = bcu.booking_container_id AND bc2.deleted_at IS NULL + WHERE bc2.booking_id = b.id AND bcu.deleted_at IS NULL) + ) AS weight, b.freight_type AS "freightType", b.trade_direction AS "tradeDirection", cgt.code AS "cargoTypeCode" FROM freight.train_schedule_bookings tsb @@ -2203,6 +2223,11 @@ export class WarehouseInventoryService { zoneId: unloadLocation.zoneId, } : {}), + // Record the received weight on a row that never carried one — the + // GRN prints this, and an existing non-zero weight is left alone. + ...(Number(existing.weight) > 0 || !(Number(booking.weight) > 0) + ? {} + : { weight: Number(booking.weight) }), status: 'UNLOADED', unloadedAt: now, arrivedAt: existing.arrivedAt ?? now, @@ -2216,6 +2241,22 @@ export class WarehouseInventoryService { description: 'Unloaded from arrived import train', performedBy, }); + // Capacity follows the recorded weight: deliver() decrements by the + // item's weight, so a weight written here must be counted here too. + const addedWeight = Number(booking.weight) - Number(existing.weight ?? 0); + if (addedWeight > 0) { + await this.applyCapacityDelta( + this.dataSource.manager, + { + warehouseId: unloadLocation?.warehouseId ?? existing.warehouseId, + yardId: unloadLocation?.yardId ?? existing.yardId, + zoneId: unloadLocation?.zoneId ?? existing.zoneId, + }, + addedWeight, + 0, + 0, + ); + } result.unloadedCount += 1; result.results.push({ bookingId: booking.id, inventoryId: existing.id, status: 'UNLOADED' }); continue; @@ -2253,6 +2294,11 @@ export class WarehouseInventoryService { description: 'Unloaded from arrived import train', performedBy, }); + // New goods physically in the warehouse — count them, or deliver() would + // later free capacity that was never taken. + if (Number(saved.weight) > 0) { + await this.applyCapacityDelta(this.dataSource.manager, location, Number(saved.weight), 0, 0); + } result.unloadedCount += 1; result.results.push({ bookingId: booking.id, inventoryId: saved.id, status: 'UNLOADED' }); } catch (error) { @@ -3944,7 +3990,10 @@ export class WarehouseInventoryService { COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber", COALESCE(inv.arrived_at, inv.created_at) AS "receivedAt", inv.quantity, - inv.weight, + -- An unweighed item still reports the cargo weight it holds: fall + -- back to the item's container VGM, then the booking's declared + -- weight, so a GRN never prints "0 t" for goods that are present. + COALESCE(NULLIF(inv.weight, 0), item_vgm.tons, b.cargo_total_weight_vgm, 0) AS weight, inv.volume, inv.status, inv.notes, @@ -3992,6 +4041,16 @@ export class WarehouseInventoryService { WHERE bc.booking_id = b.id AND bc.deleted_at IS NULL ) booking_container ON true + LEFT JOIN LATERAL ( + SELECT SUM(bcu.vgm_tons) AS tons + FROM freight.booking_container_units bcu + JOIN freight.booking_container bc2 + ON bc2.id = bcu.booking_container_id AND bc2.deleted_at IS NULL + WHERE bc2.booking_id = b.id + AND bcu.deleted_at IS NULL + AND (container.container_number IS NULL + OR bcu.container_number = container.container_number) + ) item_vgm ON true LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id) WHERE inv.id = $1 AND inv.deleted_at IS NULL diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx index 5eeee7d13..d3c8e1897 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx @@ -37,6 +37,14 @@ function fmtDate(iso: string | null) { return new Date(iso).toLocaleDateString(); } +const UNIT_LABEL_PLURAL: Record = { + container: 'Containers', + truck: 'Trucks', + ton: 'Tons', + item: 'Items', +}; +const unitLabelPlural = (unitLabel?: string) => UNIT_LABEL_PLURAL[unitLabel ?? 'container'] ?? 'Containers'; + const money = (amount: number, currency: string) => `${Number(amount).toLocaleString()} ${currency === 'ETB' ? 'Birr (ETB)' : currency}`; @@ -72,8 +80,11 @@ function FeeCard({ fee }: { fee: FeePreview }) { - - + + {(fee.tiers ?? []).map((tier) => ( { return out.length ? out : activeRecord ? bookingContainerNumbers(activeRecord) : []; }, [assignBooking, activeRecord]); + // Container number → size ("20ft"/"40ft"), driving the per-truck cap: a 40ft + // fills the truck alone; two 20ft may share (no size mixing). + const sizeByNumber = useMemo(() => { + const map = new Map(); + const lines = assignBooking?.bookingContainers?.length + ? assignBooking.bookingContainers + : activeRecord?.booking?.bookingContainers ?? []; + for (const line of lines) { + // The two payload shapes differ: the list record carries `containerSize`, + // the booking detail exposes the size on its container type. + const c = line as { + containerSize?: string | null; + containerNumber?: string | null; + containerType?: { code?: string; label?: string; sizeFt?: number }; + units?: Array<{ containerNumber?: string | null }>; + }; + const size = String( + c.containerSize ?? c.containerType?.sizeFt ?? c.containerType?.code ?? c.containerType?.label ?? "", + ); + for (const u of c.units ?? []) { + if (u.containerNumber) map.set(u.containerNumber, size); + } + if (c.containerNumber) map.set(c.containerNumber, size); + } + return map; + }, [assignBooking, activeRecord]); + const is40 = (n: string) => (sizeByNumber.get(n) ?? "").includes("40"); + + // Trucks that already arrived/left keep their load locked — the API rejects + // changing or removing them; the modal greys those rows out. + const lockedVehicles = useMemo(() => { + const map = new Map(); + for (const a of activeRecord?.vehicleAssignments ?? []) { + if (a.departedAt) map.set(a.vehicleId, "left the warehouse"); + else if (a.arrivedAt) map.set(a.vehicleId, "arrived at the warehouse"); + } + return map; + }, [activeRecord]); + const pickupReadyByBooking = useMemo(() => { const map = new Map(); for (const row of pickupReadyRows) { @@ -1101,6 +1140,22 @@ const LastMilePage = () => { if (!targetIds.length) return; + // A 40ft container fills its truck — backstop for pre-filled reassignment + // rows the MultiSelect guard never saw. + const overloaded = vehicles.filter( + (v) => v.containerNumbers.length > 1 && v.containerNumbers.some(is40), + ); + if (overloaded.length) { + toast({ + title: "40ft fills the truck", + description: `${overloaded + .map((v) => vehicleLabelFor(v.vehicleId)) + .join("; ")} — a 40ft container travels alone.`, + variant: "destructive", + }); + return; + } + // Backstop for rows the Select guard never saw (pre-filled reassignments). const unpriced = vehicles .map((v) => ({ label: vehicleLabelFor(v.vehicleId), gap: pricingGapById.get(v.vehicleId) })) @@ -1385,7 +1440,21 @@ const LastMilePage = () => { status === "PAYMENT_PENDING" || (status === "READY_TO_TRANSIT" && assigned) || (status === "IN_TRANSIT" && hasDistance); - const canAssignStep = !assigned && status !== "DELIVERED"; + // Assign stays active until the whole load has trucks: container + // bookings until every container is on a truck; bulk until the + // tonnage is drawn down (trucks depart one by one). Already-departed + // trucks keep their rows locked in the modal. + const totalContainers = containerCount(row.original); + const assignedContainers = (row.original.vehicleAssignments ?? []).reduce( + (s, a) => s + (a.containers?.length ?? (a.containerNumber ? 1 : 0)), + 0, + ); + const containersRemain = totalContainers > 0 && assignedContainers < totalContainers; + const bulkCargo = totalContainers === 0; + const canAssignStep = + status !== "DELIVERED" && + !row.original.invoice && + (!assigned || containersRemain || (bulkCargo && status !== "IN_TRANSIT")); const canDistance = status === "IN_TRANSIT"; // Truck arrival/leaving are independent — each driven by its own // warehouse state — but both are done once the leg is IN_TRANSIT/DELIVERED. @@ -1822,16 +1891,22 @@ const LastMilePage = () => { ); } - const ok = picked === needed; + const coveredContainers = vehicleRows.reduce( + (s, r) => s + (r.vehicleId ? r.containerNumbers.length : 0), + 0, + ); + const ok = picked === needed && coveredContainers === containers; return ( - One truck (with trailer) carries {CONTAINERS_PER_VEHICLE} containers. - {picked > 0 && !ok && - ` You've selected ${picked} — ${picked < needed ? "add more" : "that's more than needed"}.`} + One 40ft container fills a truck; two 20ft share one (no size mixing). + {containers - coveredContainers > 0 && + ` ${containers - coveredContainers} container${containers - coveredContainers === 1 ? "" : "s"} still unassigned — keep adding trucks.`} + {picked > 0 && picked !== needed && + ` You've selected ${picked} vehicle${picked === 1 ? "" : "s"} — ${picked < needed ? "add more" : "that's more than needed"}.`} ); })()} @@ -1851,7 +1926,11 @@ const LastMilePage = () => { )} - {vehicleRows.map((row, i) => ( + {vehicleRows.map((row, i) => { + const lockReason = row.vehicleId ? lockedVehicles.get(row.vehicleId) : undefined; + const rowLocked = Boolean(lockReason); + const rowHas40 = row.containerNumbers.some(is40); + return ( - )} + )} */} + + setValidityStart(v ? new Date(v) : null)} + maxDate={validityEnd ?? undefined} + clearable + /> + setValidityEnd(v ? new Date(v) : null)} + minDate={validityStart ?? undefined} + clearable + /> + )} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/StampUpload.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/StampUpload.tsx new file mode 100644 index 000000000..1c1e4d87c --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/StampUpload.tsx @@ -0,0 +1,171 @@ +import { useRef, useState } from "react"; +import { Box, Button, Group, Image, Paper, Stack, Text } from "@mantine/core"; +import { RefreshCw, Stamp, X } from "lucide-react"; + +const MAX_STAMP_MB = 5; + +export interface StampUploadProps { + /** Stamp image as a data URL, or null when none is attached yet. */ + value: string | null; + onChange: (dataUrl: string | null) => void; + label?: string; + description?: string; +} + +/** + * Company stamp/seal attachment for the contract signing modal. Reads the + * picked image straight into a data URL because the signing endpoint takes + * base64 in JSON (same transport as the drawn signature), not multipart. + */ +export function StampUpload({ + value, + onChange, + label = "Company stamp", + description = "Attach your official company stamp or seal.", +}: StampUploadProps) { + const inputRef = useRef(null); + const [dragging, setDragging] = useState(false); + const [error, setError] = useState(null); + const [fileName, setFileName] = useState(null); + + const readFile = (file: File | undefined | null) => { + if (!file) return; + if (!file.type.startsWith("image/")) { + setError("The stamp must be an image file (PNG or JPG)."); + return; + } + if (file.size > MAX_STAMP_MB * 1024 * 1024) { + setError(`The stamp image must be under ${MAX_STAMP_MB} MB.`); + return; + } + const reader = new FileReader(); + reader.onload = () => { + setError(null); + setFileName(file.name); + onChange(typeof reader.result === "string" ? reader.result : null); + }; + reader.onerror = () => setError("Could not read that file. Try another."); + reader.readAsDataURL(file); + }; + + const openPicker = () => inputRef.current?.click(); + + const clear = () => { + setFileName(null); + setError(null); + onChange(null); + if (inputRef.current) inputRef.current.value = ""; + }; + + return ( + + + {label} + + + readFile(e.currentTarget.files?.[0])} + /> + + {value ? ( + + + + Company stamp + + + + {fileName ?? "Stamp attached"} + + + This stamp is applied next to your signature on the contract. + + + + + + + + + ) : ( + { + e.preventDefault(); + setDragging(true); + }} + onDragLeave={() => setDragging(false)} + onDrop={(e) => { + e.preventDefault(); + setDragging(false); + readFile(e.dataTransfer.files?.[0]); + }} + style={{ + borderColor: dragging + ? "var(--mantine-color-edr-green-6)" + : undefined, + borderStyle: "dashed", + backgroundColor: dragging + ? "var(--mantine-color-edr-green-0)" + : undefined, + cursor: "pointer", + }} + > + + + + Upload company stamp + + + {description} Drop an image here or click to browse — PNG or JPG, + up to {MAX_STAMP_MB} MB. + + + + )} + + {error && ( + + {error} + + )} + + ); +} 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 2b84d9483..95b677c1d 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 @@ -172,7 +172,7 @@ export function computeGlShipmentTotal( // it (the commodity needs lashing). Per-ton scales by tonnage; per-wagon // depends on the wagon capacity the train stocks — shown at real pricing. const lashing = items.find((i) => i.conditionalOn === "has_lashing"); - if (lashing && lashing.unit === "per_ton") { + if (lashing && (lashing.unit === "per_ton" || lashing.unit === "per_item")) { const tons = q.bulkQuantity; if (tons > 0) { lines.push({ @@ -199,7 +199,7 @@ export function computeGlShipmentTotal( cl.unit === "per_wagon" ? Math.ceil(boxes * (cl.containerSize === "40ft" ? 1 : 0.5)) : boxes; - } else if (cl.unit === "per_ton") { + } else if (cl.unit === "per_ton" || cl.unit === "per_item") { qty = q.bulkQuantity; } else if (cl.unit === "flat") { qty = 1; diff --git a/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx b/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx index 1e44c5268..2ad4e1f28 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx @@ -23,6 +23,7 @@ import { import { type ReactNode } from "react"; import { useNavigate } from "react-router-dom"; +import DocReviewAlertButton from "@/features/bookingWindows/DocReviewAlertButton"; import NotificationBellContainer from "@/features/notifications/NotificationBellContainer"; import type { PageMeta } from "./types"; @@ -114,8 +115,12 @@ const FreightDashboardHeader = ({ - {/* Right: actions + avatar */} + {/* Right: actions + avatar. The doc-review alarm leads the group — it + only renders in the last half of a review phase that still has + undecided requests, so it never competes for space otherwise. */} + + diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index d08cac603..032411b26 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -294,6 +294,7 @@ export const URL_CONSTANTS = { `/train-scheduling/schedules/${id}/run-allocation`, DOC_REVIEW_COMPLETE: (id: string) => `/train-scheduling/schedules/${id}/doc-review-complete`, + DOC_REVIEW_ALERT: "/train-scheduling/doc-review-alert", ASSIGN_UNASSIGNED_BOOKING: (id: string) => `/train-scheduling/schedules/${id}/assign-unassigned-booking`, BOOKING_WINDOW: (id: string) => diff --git a/apps/edr-freight-web/backoffice/src/features/bookingWindows/DocReviewAlertButton.tsx b/apps/edr-freight-web/backoffice/src/features/bookingWindows/DocReviewAlertButton.tsx new file mode 100644 index 000000000..16af5b6ab --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/features/bookingWindows/DocReviewAlertButton.tsx @@ -0,0 +1,125 @@ +import { Text, Tooltip, UnstyledButton } from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; +import { AlertTriangle, ChevronRight } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; + +import { useAuth } from "@/auth/useAuth"; +import { canSeeDocReviewAlert } from "@/lib/permissions"; +import { api } from "@/services/api"; + +/** + * Booking-request statuses that count as "nobody decided yet". These are the + * exact statuses the window engine expires when document review ends + * (findUnacceptedForRouteDay), so the deep-linked list shows precisely the + * requests the countdown is warning about. + */ +const UNDECIDED_STATUSES = [ + "OPERATION_REQUESTED", + "OPERATION_REQUEST_PENDING", + "OPERATION_CHANGES_REQUESTED", + "OPERATION_PRICE_PENDING_CONFIRM", +].join(","); + +const pendingRequestsHref = (tradeDirection: string) => + `/dashboard/booking-requests?statuses=${UNDECIDED_STATUSES}&tradeDirection=${tradeDirection}`; + +/** mm:ss (or h:mm:ss past an hour), fixed width so the pill never jitters. */ +function formatRemaining(ms: number): string { + const total = Math.max(0, Math.floor(ms / 1000)); + const hours = Math.floor(total / 3600); + const minutes = Math.floor((total % 3600) / 60); + const seconds = total % 60; + const mm = String(minutes).padStart(2, "0"); + const ss = String(seconds).padStart(2, "0"); + return hours > 0 ? `${hours}:${mm}:${ss}` : `${mm}:${ss}`; +} + +/** + * Header alarm for the document-review deadline. Appears only once the review + * phase is half spent AND requests are still undecided — everything still + * pending when the clock runs out is expired automatically, so this is the last + * call to accept or reject. Clicking opens the booking requests already + * filtered to those undecided import requests. + */ +export default function DocReviewAlertButton() { + const navigate = useNavigate(); + const { user } = useAuth(); + + const { data: alert } = useQuery({ + ...api.trainScheduling.docReviewAlert.queryOptions(), + // Dedicated permission: only the position types granted it are alarmed. + enabled: canSeeDocReviewAlert(user), + // The window engine ticks every 10s; a minute is close enough for a header + // chip — the countdown itself runs locally. + refetchInterval: 60_000, + }); + + const deadlineMs = alert ? new Date(alert.docReviewEndsAt).getTime() : 0; + const [remaining, setRemaining] = useState(() => deadlineMs - Date.now()); + + useEffect(() => { + if (!deadlineMs) return; + const tick = () => setRemaining(deadlineMs - Date.now()); + tick(); + const id = window.setInterval(tick, 1000); + return () => window.clearInterval(id); + }, [deadlineMs]); + + if (!alert) return null; + // Half the review phase has to be gone before staff are alarmed — a 30-minute + // review warns with 15 minutes left. + const halfMs = (Math.max(alert.docReviewMinutes, 1) * 60_000) / 2; + if (remaining <= 0 || remaining > halfMs) return null; + + const requestLabel = alert.pendingCount === 1 ? "request" : "requests"; + + return ( + + navigate(pendingRequestsHref(alert.tradeDirection))} + aria-label={`${alert.pendingCount} import booking ${requestLabel} awaiting a decision — document review ends in ${formatRemaining(remaining)}`} + className="group flex h-9 shrink-0 items-center gap-2 rounded-full border border-red-600/60 bg-red-600 pl-2.5 pr-2 text-white shadow-[0_2px_10px_rgba(220,38,38,0.35)] transition-transform hover:scale-[1.02] hover:bg-red-700" + > + {/* Live dot: a ping ring behind a solid core, so the pill reads as + active without animating the whole chip. */} + + + + + + + + + {alert.pendingCount} undecided + + + + {formatRemaining(remaining)} + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts b/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts index ae3b9d4b8..378ff1fb8 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts @@ -119,7 +119,13 @@ export const useCargoLeafOptions = (enabled = true) => const code = String(row.code ?? "").trim(); const label = name && code ? `${name} (${code})` : name || code || String(row.id); - return { label, value: String(row.id) }; + // The commodity's unit of measure rides along so the rate form can + // offer per-item units for counted (break-bulk) commodities. + return { + label, + value: String(row.id), + unitOfMeasure: String(row.unitOfMeasure ?? ""), + }; }); }, }); diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index 389719964..01bca1e11 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -26,6 +26,7 @@ export const FREIGHT_PERMS = { reviewDocuments: "edr_freight_app:bookings:review_documents", uploadClearanceOutput: "edr_freight_app:bookings:upload_clearance_output", finalizeClearance: "edr_freight_app:bookings:finalize_clearance", + docReviewAlert: "edr_freight_app:bookings:doc_review_alert", }, contracts: { view: "edr_freight_app:contracts:view", @@ -477,6 +478,17 @@ export function canAccessBookings(user: AuthUser | null | undefined): boolean { return hasPermission(user, FREIGHT_PERMS.bookings.view); } +/** + * Sees the header countdown warning that document review is about to end with + * requests still undecided. Its own permission — granted per position type, so + * only the desks that act on those requests get alarmed. + */ +export function canSeeDocReviewAlert( + user: AuthUser | null | undefined, +): boolean { + return hasPermission(user, FREIGHT_PERMS.bookings.docReviewAlert); +} + export function canAccessContracts(user: AuthUser | null | undefined): boolean { return hasPermission(user, FREIGHT_PERMS.contracts.view); } diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx index 8ca532819..4aee1e32a 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx @@ -27,8 +27,8 @@ import { User, X, } from "lucide-react"; -import { useCallback, useMemo, useRef, useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu"; @@ -123,14 +123,25 @@ function formatDate(value: string | null | undefined): string { export default function BookingRequestsPage() { const navigate = useNavigate(); + // Deep links land here pre-filtered (?statuses=A,B&tradeDirection=IMPORT) — + // the header's document-review alarm opens exactly the undecided requests it + // is counting down for. Read once as the initial state so staff can then + // change the filters like any other visit. + const [searchParams] = useSearchParams(); const { pagination, setPagination } = usePagination({ pageSize: 10 }); const [query, setQuery] = useState(""); const [debouncedQuery] = useDebouncedValue(query, 300); // Booking kind is a filter now — one list holds both kinds (null = "all"). const [kindFilter, setKindFilter] = useState(null); // Filter controls (empty/null = "all"). - const [statusFilter, setStatusFilter] = useState([]); - const [directionFilter, setDirectionFilter] = useState(null); + const paramStatuses = searchParams.get("statuses") ?? ""; + const paramDirection = searchParams.get("tradeDirection"); + const [statusFilter, setStatusFilter] = useState(() => + paramStatuses.split(",").filter(Boolean), + ); + const [directionFilter, setDirectionFilter] = useState( + paramDirection, + ); const [freightTypeFilter, setFreightTypeFilter] = useState(null); const [paymentStatusFilter, setPaymentStatusFilter] = useState(null); const [ownershipFilter, setOwnershipFilter] = useState(null); @@ -150,6 +161,15 @@ export default function BookingRequestsPage() { }, 400); }, []); + // Follow the URL when a deep link arrives while the page is already open + // (clicking the header alarm from this very list). Same-value writes are + // dropped so a manual filter change is never undone. + useEffect(() => { + const next = paramStatuses.split(",").filter(Boolean); + setStatusFilter((prev) => (prev.join(",") === next.join(",") ? prev : next)); + setDirectionFilter(paramDirection); + }, [paramStatuses, paramDirection]); + const filter: BookingListFilter = useMemo(() => { return { page: pagination.pageIndex + 1, diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractViewPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractViewPage.tsx index 147d637ef..89fc51225 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractViewPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractViewPage.tsx @@ -18,7 +18,9 @@ import toast from "react-hot-toast"; import { ContractSignSuccessModal } from "@/components/contracts/ContractSignSuccessModal"; import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad"; +import { StampUpload } from "@/components/contracts/StampUpload"; import { contractsService } from "@/services/contracts.service"; +import { extractApiError } from "@/utils/result"; import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; import { useAuth } from "@/auth/useAuth"; import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; @@ -38,6 +40,7 @@ export default function ContractViewPage() { const [successOpen, setSuccessOpen] = useState(false); const [signerName, setSignerName] = useState(""); const [signatureData, setSignatureData] = useState(null); + const [stampData, setStampData] = useState(null); const [drawNew, setDrawNew] = useState(false); const { data, isLoading, isError, refetch } = useQuery({ @@ -56,6 +59,7 @@ export default function ContractViewPage() { signatureImageBase64: usingSaved ? (savedSignatureImage as string) : (signatureData ?? ""), + stampImageBase64: stampData ?? "", signerDisplayName: signerName.trim(), consentText: "I confirm this contract on behalf of EDR.", }), @@ -66,7 +70,12 @@ export default function ContractViewPage() { void qc.invalidateQueries({ queryKey: QUERY_KEYS.CONTRACTS.byId(id!) }); void qc.invalidateQueries({ queryKey: QUERY_KEYS.CONTRACTS.ROOT }); }, - onError: () => toast.error("Failed to sign contract"), + // Surface the server's reason verbatim — the missing-customer-stamp gate + // and the status guards all explain themselves in the message. + onError: (err) => + toast.error( + extractApiError(err).message ?? "Failed to sign contract", + ), }); const handlePrint = () => iframeRef.current?.contentWindow?.print(); @@ -89,12 +98,13 @@ export default function ContractViewPage() { const openSign = () => { setSignerName(data?.savedSignature?.signerDisplayName ?? ""); setSignatureData(null); + setStampData(null); setDrawNew(false); setSignOpen(true); }; const confirmSign = () => { - if (!signerName.trim()) return; + if (!signerName.trim() || !stampData) return; const image = usingSaved ? savedSignatureImage : signatureData; if (!image) return; signMutation.mutate(); @@ -231,6 +241,14 @@ export default function ContractViewPage() { ) : ( )} + + + + + + + + + ) : ( + { + e.preventDefault(); + setDragging(true); + }} + onDragLeave={() => setDragging(false)} + onDrop={(e) => { + e.preventDefault(); + setDragging(false); + readFile(e.dataTransfer.files?.[0]); + }} + style={{ + borderColor: dragging + ? "var(--mantine-color-edr-green-6)" + : undefined, + borderStyle: "dashed", + backgroundColor: dragging + ? "var(--mantine-color-edr-green-0)" + : undefined, + cursor: "pointer", + }} + > + + + + Upload company stamp + + + {description} Drop an image here or click to browse — PNG or JPG, + up to {MAX_STAMP_MB} MB. + + + + )} + + {error && ( + + {error} + + )} + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx index fe9209c1d..4288117ec 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx @@ -923,7 +923,7 @@ export default function ContractDetailPage() { )} {item.isClearance && ( - + Customs service fee — billed on your shipment booking invoice together with the freight diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractViewPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractViewPage.tsx index e0ad792ff..cbf6440c7 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractViewPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractViewPage.tsx @@ -28,6 +28,7 @@ import toast from "react-hot-toast"; import { ContractSignSuccessModal } from "@/components/contracts/ContractSignSuccessModal"; import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad"; +import { StampUpload } from "@/components/contracts/StampUpload"; import { contractsService } from "@/services/contracts.service"; import { api } from "@/services/api"; import { extractApiError } from "@/utils/result"; @@ -52,6 +53,7 @@ export default function ContractViewPage() { const [successOpen, setSuccessOpen] = useState(false); const [signerName, setSignerName] = useState(""); const [signatureData, setSignatureData] = useState(null); + const [stampData, setStampData] = useState(null); const [drawNew, setDrawNew] = useState(false); const [hasScrolledToBottom, setHasScrolledToBottom] = useState(false); const [agreedToTerms, setAgreedToTerms] = useState(false); @@ -138,6 +140,7 @@ export default function ContractViewPage() { signatureImageBase64: usingSaved ? (savedSignatureImage as string) : (signatureData as string), + stampImageBase64: stampData as string, signerDisplayName: signerName.trim(), consentText: CONSENT_TEXT, otp: otpCode.trim(), @@ -161,6 +164,7 @@ export default function ContractViewPage() { if (!canProceedToSign) return; setSignerName(data?.savedSignature?.signerDisplayName ?? ""); setSignatureData(null); + setStampData(null); setDrawNew(false); setSignOpen(true); }; @@ -168,7 +172,7 @@ export default function ContractViewPage() { const confirmSign = () => { if (!signerName.trim()) return; const image = usingSaved ? savedSignatureImage : signatureData; - if (!image) return; + if (!image || !stampData) return; // The server resolves and validates the signer's own contacts; if the // account has neither phone nor email it returns a clear 400 that surfaces // via the mutation's onError. @@ -360,6 +364,13 @@ export default function ContractViewPage() { ) : ( )} + + + + + + + {/* Step 0 — Setup: operation, contract, service, currency, miles. */} {step === 0 && ( @@ -964,15 +1001,14 @@ export default function NewContractPage({ fw={700} tt="uppercase" c="edr-green" - mb="xs" + mb="md" style={{ letterSpacing: "0.06em" }} > Pricing schedule - - Final amount is calculated at booking — quantities are unknown - at the contract stage. - + + + {pricingData.lineItems.map((item) => ( )} {item.isClearance && ( - + Customs service fee — billed on your shipment booking invoice together with the freight diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/shared.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/shared.tsx index 7e3b06695..6165044bd 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/shared.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/shared.tsx @@ -1,11 +1,15 @@ import { + Box, Combobox, + Group, Input, InputBase, Select, + Stack, + Text, useCombobox, } from "@mantine/core"; -import { Loader } from "lucide-react"; +import { Loader, Receipt } from "lucide-react"; import type { ReactNode } from "react"; import { useMemo } from "react"; import type { @@ -84,6 +88,55 @@ export function SelectField< ); } +/** + * Prominent "these are unit rates, not your bill" banner. Shown wherever the + * customer is looking at contract pricing (review step + quotation modal) — + * the contract quotes per-unit rates only; the payable total is computed at + * booking from the quantities actually shipped. + */ +export function NotFinalPriceNotice() { + return ( + + + + + + + This is not your final price + + + The figures below are per-unit rates for each service + — not a total. Your payable amount is calculated on every booking from + the quantities you actually ship, and invoiced then. + + + + ); +} + interface AsyncComboboxOption { value: string; label: string; diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step3-cargo-scope.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step3-cargo-scope.tsx index f89a0008d..1afc08186 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step3-cargo-scope.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step3-cargo-scope.tsx @@ -25,7 +25,7 @@ import { type ContractFormValues, } from "./schema"; import { operationToTradeDirection } from "./helpers"; -import { fieldStyles, SelectField, StepLabel } from "./shared"; +import { AlertBox, fieldStyles, SelectField, StepLabel } from "./shared"; /** * file_upload_settings code holding the hazardous-cargo document requirements. @@ -375,6 +375,13 @@ export function Step3CargoScope({ )} /> )} + {!isOneTime && ( + + Hazardous material can only be declared on a one-time contract. + Switch Contract Kind to one-time to carry + hazardous cargo. + + )} {/* Refrigerated cargo is hidden for now — import-only when re-enabled. The effect above keeps isRefrigerated false while it's off. {isImport && ( diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step8-review.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step8-review.tsx index 11a96b4b9..0c0aaf3cd 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step8-review.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step8-review.tsx @@ -27,7 +27,7 @@ import { type ContractFormInputValues, type ContractFormValues, } from "./schema"; -import { StepHeader } from "./shared"; +import { NotFinalPriceNotice, StepHeader } from "./shared"; import { formatRateUnit } from "./unit-rates"; type ContractForm = UseFormReturn< @@ -125,15 +125,14 @@ function UnitRatePanel({ fw={700} tt="uppercase" c="edr-green" - mb="xs" + mb="md" style={{ letterSpacing: "0.06em" }} > Pricing schedule - - Estimated unit rates — the final amount is calculated at booking from the - quantities you ship. - + + + {lineItems.map((item) => ( { if (values.cargoType === "container") { @@ -399,16 +396,20 @@ export function Step8Review({ } /> - } - label="First mile — pick-up" - value={firstMileValue} - /> - } - label="Last mile — delivery" - value={lastMileValue} - /> + {firstMileValue && ( + } + label="First mile — pick-up" + value={firstMileValue} + /> + )} + {lastMileValue && ( + } + label="Last mile — delivery" + value={lastMileValue} + /> + )} } label="Customs clearing" diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/total.ts b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/total.ts index b4055bb1c..c778385fd 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/total.ts +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/total.ts @@ -154,11 +154,12 @@ export function computeShipmentTotal( } // Lashing / cargo securing — bulk-only, applies whenever the contract shows - // it (the commodity needs lashing). Per-ton scales by tonnage; per-wagon - // depends on the wagon capacity the train stocks — shown at real pricing. + // it (the commodity needs lashing). Per-ton/per-item scales by the cargo + // amount; per-wagon depends on the wagon capacity the train stocks — shown at + // real pricing. const lashing = items.find((i) => i.conditionalOn === "has_lashing"); - if (lashing && lashing.unit === "per_ton") { - const tons = Number(values.cargoWeightTons || 0); + if (lashing && (lashing.unit === "per_ton" || lashing.unit === "per_item")) { + const tons = Number(values.cargoWeightTons || values.itemCount || 0); if (tons > 0) { lines.push({ label: lashing.label, @@ -184,8 +185,8 @@ export function computeShipmentTotal( cl.unit === "per_wagon" ? Math.ceil(boxes * (cl.containerSize === "40ft" ? 1 : 0.5)) : boxes; - } else if (cl.unit === "per_ton") { - qty = Number(values.cargoWeightTons || 0); + } else if (cl.unit === "per_ton" || cl.unit === "per_item") { + qty = Number(values.cargoWeightTons || values.itemCount || 0); } else if (cl.unit === "flat") { qty = 1; } diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts index 58f3b330b..79328a5dd 100644 --- a/apps/edr-freight-web/portal/src/services/bookings.service.ts +++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts @@ -44,6 +44,7 @@ export interface ContractView { signerDisplayName: string; signedAt: string; signatureImageUrl?: string | null; + stampImageUrl?: string | null; }>; /** Current viewer's reusable saved signature, if they have one. */ savedSignature?: { @@ -124,6 +125,8 @@ export interface SubmitBookingResponse { export interface SignContractPayload { role: "CUSTOMER" | "STAFF"; signatureImageBase64: string; + /** Company stamp/seal image; required to sign a contract (not booking contracts). */ + stampImageBase64?: string; signerDisplayName: string; consentText?: string; /** Sudo-mode OTP challenge; required when role=CUSTOMER. */ diff --git a/packages/types/src/freight/contracts.ts b/packages/types/src/freight/contracts.ts index bb5c5fb63..cf01939a5 100644 --- a/packages/types/src/freight/contracts.ts +++ b/packages/types/src/freight/contracts.ts @@ -184,6 +184,8 @@ export interface IContractSignature { role: ContractSignatureRole; signerDisplayName: string; signatureFileId?: string | null; + /** Company stamp/seal image, required for the CUSTOMER and STAFF parties. */ + stampFileId?: string | null; consentText?: string | null; signedAt: string; } From 9a1c8e56034ef3cca9d9b854dda02375e80fb03f Mon Sep 17 00:00:00 2001 From: Marshal Date: Sat, 25 Jul 2026 21:10:09 +0000 Subject: [PATCH 05/21] feat: add hazardous goods declaration feature - Introduced HazardDeclarationPanel component to display dangerous goods declaration details. - Updated URL constants to include CLEARANCE_PROCEED endpoint for re-requesting operations. - Enhanced permissions to include hazardous approval roles for contract approvals. - Integrated HazardDeclarationPanel into ContractRequestDetailPage and ContractClearanceDetailPage. - Added proceedToOperation method in bookings service for handling operation re-requests. - Updated contract forms and schemas to include hazard class and UN number fields. - Implemented validation for hazardous contracts in the contract creation flow. - Added expiry notice functionality for contracts nearing validity end. - Created tests for expiry notice calculations and labels. - Updated UI components to reflect hazardous cargo information and validation errors. --- .../freight-permission.hazardous.spec.ts | 47 ++++ .../src/common/freight-permission.util.ts | 33 +++ .../contract-document-view-model.builder.ts | 21 +- ...0000000000-AddContractHazardDeclaration.ts | 34 +++ ...booking-lifecycle-notifier.service.spec.ts | 76 ++++++ .../booking-lifecycle-notifier.service.ts | 29 ++- .../booking-reference-data.service.ts | 93 ++++++-- .../modules/bookings/cargo-type-tree.spec.ts | 72 ++++++ .../contracts/contract-clearance.service.ts | 23 ++ .../contracts/contract-expiry.service.spec.ts | 63 +++++ .../contracts/contract-expiry.service.ts | 52 ++++ .../contracts/contract-stamp-resign.spec.ts | 131 ++++++++++ .../contracts/contract-transition.service.ts | 46 +++- .../modules/contracts/contracts.repository.ts | 24 ++ .../modules/contracts/contracts.service.ts | 11 + .../contracts/dto/create-contract.dto.ts | 24 ++ .../contracts/entities/contract.entity.ts | 8 + .../modules/routes/routes.duplicate.spec.ts | 80 +++++++ .../src/modules/routes/routes.service.ts | 55 ++++- .../src/seed/freight-permissions.registry.ts | 6 + .../BookingChangesRequestedAlert.tsx | 132 +++++++++++ .../contracts/ContractApprovalStepsCard.tsx | 68 ++++-- .../contracts/HazardDeclarationPanel.tsx | 65 +++++ .../backoffice/src/constants/URLS.ts | 2 + .../backoffice/src/lib/permissions.ts | 22 ++ .../contracts/ContractClearanceDetailPage.tsx | 23 +- .../contracts/ContractClearanceListPage.tsx | 3 +- .../contracts/ContractRequestDetailPage.tsx | 6 + .../backoffice/src/services/api.ts | 7 + .../src/services/bookings.service.ts | 8 + .../pages/contracts/ContractStepBanner.tsx | 24 +- .../src/pages/contracts/ContractViewPage.tsx | 9 + .../src/pages/contracts/ContractsList.tsx | 4 + .../src/pages/contracts/NewContractPage.tsx | 5 + .../contracts/contract-expiry-notice.test.ts | 56 +++++ .../src/pages/contracts/contract-ui.tsx | 84 ++++++- .../new-contract-form/contractToForm.ts | 2 + .../contracts/new-contract-form/schema.ts | 27 +++ .../new-contract-form/step3-cargo-scope.tsx | 223 +++++++++++++++--- .../new-contract-form/step8-review.tsx | 20 +- packages/types/src/freight/contracts.ts | 44 ++++ 41 files changed, 1663 insertions(+), 99 deletions(-) create mode 100644 apps/edr-freight-api/src/common/freight-permission.hazardous.spec.ts create mode 100644 apps/edr-freight-api/src/migrations/2920000000000-AddContractHazardDeclaration.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.spec.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/cargo-type-tree.spec.ts create mode 100644 apps/edr-freight-api/src/modules/contracts/contract-expiry.service.spec.ts create mode 100644 apps/edr-freight-api/src/modules/contracts/contract-stamp-resign.spec.ts create mode 100644 apps/edr-freight-api/src/modules/routes/routes.duplicate.spec.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/contracts/BookingChangesRequestedAlert.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/contracts/HazardDeclarationPanel.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/contracts/contract-expiry-notice.test.ts diff --git a/apps/edr-freight-api/src/common/freight-permission.hazardous.spec.ts b/apps/edr-freight-api/src/common/freight-permission.hazardous.spec.ts new file mode 100644 index 000000000..4658a5434 --- /dev/null +++ b/apps/edr-freight-api/src/common/freight-permission.hazardous.spec.ts @@ -0,0 +1,47 @@ +import { ForbiddenException } from '@nestjs/common'; + +import { + assertCanApproveContractStep, + canEditContractStep, +} from './freight-permission.util'; +import { FREIGHT_PERMS } from '../seed/freight-permissions.registry'; + +const userWith = (...keys: string[]) => ({ + permissions: keys.map((key) => ({ key })), +}); + +describe('hazardous contract approval steps', () => { + it('rejects an approver who only holds ordinary contract-approve permissions', () => { + // The blanket "any contract approve permission" fallback must NOT reach + // dangerous goods — that is the whole point of the dedicated desks. + const lineStaff = userWith(FREIGHT_PERMS.contracts.approveLineStaff); + + expect(() => + assertCanApproveContractStep(lineStaff, 'HAZARDOUS_APPROVAL_ONE'), + ).toThrow(ForbiddenException); + expect(canEditContractStep(lineStaff, 'HAZARDOUS_APPROVAL_ONE')).toBe(false); + }); + + it('accepts only the matching hazardous permission', () => { + const first = userWith(FREIGHT_PERMS.contracts.hazardousApprovalOne); + + expect(() => + assertCanApproveContractStep(first, 'HAZARDOUS_APPROVAL_ONE'), + ).not.toThrow(); + // Holding step one does not confer step two. + expect(() => + assertCanApproveContractStep(first, 'HAZARDOUS_APPROVAL_TWO'), + ).toThrow(ForbiddenException); + }); + + it('does not let a hazardous approver stand in for the commercial chain', () => { + const hazardOnly = userWith( + FREIGHT_PERMS.contracts.hazardousApprovalOne, + FREIGHT_PERMS.contracts.hazardousApprovalTwo, + ); + + expect(() => assertCanApproveContractStep(hazardOnly, 'CEO')).toThrow( + ForbiddenException, + ); + }); +}); diff --git a/apps/edr-freight-api/src/common/freight-permission.util.ts b/apps/edr-freight-api/src/common/freight-permission.util.ts index 429c910d3..56c0e77c2 100644 --- a/apps/edr-freight-api/src/common/freight-permission.util.ts +++ b/apps/edr-freight-api/src/common/freight-permission.util.ts @@ -151,6 +151,24 @@ const APPROVE_ROLE_PERMISSION: Record = { CEO: FREIGHT_PERMS.bookings.approveCeo, }; +/** + * Approval-chain roles synthesized for hazardous contracts (see + * `instantiateApprovalSteps`). Unlike the legacy roles below they are NOT + * position types — they authorize purely on their own dedicated permission, and + * they deliberately opt out of the blanket "holds any contract-approve + * permission" fallback so a normal approver cannot sign off dangerous goods. + */ +export const HAZARDOUS_APPROVAL_ROLE_PERMISSION: Record = { + HAZARDOUS_APPROVAL_ONE: FREIGHT_PERMS.contracts.hazardousApprovalOne, + HAZARDOUS_APPROVAL_TWO: FREIGHT_PERMS.contracts.hazardousApprovalTwo, +}; + +/** The two hazardous steps, in the order they are prepended to the chain. */ +export const HAZARDOUS_APPROVAL_ROLES = [ + 'HAZARDOUS_APPROVAL_ONE', + 'HAZARDOUS_APPROVAL_TWO', +] as const; + const CONTRACT_APPROVE_ROLE_PERMISSION: Record = { LINE_STAFF: FREIGHT_PERMS.contracts.approveLineStaff, DIRECTOR: FREIGHT_PERMS.contracts.approveDirector, @@ -183,6 +201,16 @@ export function assertCanApproveContractStep( ): void { if (isFreightApprovalAdmin(user)) return; + // Hazardous steps are permission-only and strict — no legacy alias, no + // blanket approve fallback. + const hazardousPermission = HAZARDOUS_APPROVAL_ROLE_PERMISSION[requiredRole]; + if (hazardousPermission) { + if (hasFreightPermission(user, hazardousPermission)) return; + throw new ForbiddenException( + `Missing permission: ${hazardousPermission}`, + ); + } + const positionTypes = collectPositionTypeKeys(user); if (positionTypes.includes(requiredRole)) return; @@ -219,6 +247,11 @@ export function canEditContractStep( ): boolean { if (isFreightApprovalAdmin(user)) return true; + const hazardousPermission = HAZARDOUS_APPROVAL_ROLE_PERMISSION[requiredRole]; + if (hazardousPermission) { + return hasFreightPermission(user, hazardousPermission); + } + const positionTypes = collectPositionTypeKeys(user); if (positionTypes.includes(requiredRole)) return true; diff --git a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts index 5ceb73978..eb9541d8e 100644 --- a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts @@ -1,4 +1,5 @@ import { Injectable, NotFoundException } from '@nestjs/common'; +import { hazardClassLabel } from '@edr/types'; import { ContractsRepository } from '../modules/contracts/contracts.repository'; import { @@ -143,6 +144,11 @@ export class ContractDocumentViewModelBuilder { const hasCustomer = signatures.some((s) => s.role === 'CUSTOMER'); const hasStaff = signatures.some((s) => s.role === 'STAFF'); + // Signed before company stamps were required — the customer has to sign + // again to attach one, otherwise EDR can never counter-sign the contract. + const customerStampMissing = signatures.some( + (s) => s.role === 'CUSTOMER' && !s.stampImageUrl, + ); const hasContractFile = Boolean( contract.files?.some((f) => f.code === 'contract'), ); @@ -185,7 +191,9 @@ export class ContractDocumentViewModelBuilder { // Cast: contract signers (CUSTOMER|STAFF|DIRECTOR|CEO) widen the booking // view-model's narrower CUSTOMER|STAFF role union. signatures: signatures as unknown as ContractViewModel['signatures'], - canSignCustomer: contract.status === 'CONTRACT_READY' && !hasCustomer, + canSignCustomer: + (contract.status === 'CONTRACT_READY' && !hasCustomer) || + (contract.status === 'SIGNED_CUSTOMER' && customerStampMissing), canSignStaff: contract.status === 'SIGNED_CUSTOMER' && hasCustomer && !hasStaff, hasContractDocument: hasContractFile, @@ -295,7 +303,16 @@ export class ContractDocumentViewModelBuilder { cargoDescription: this.valueOrDash(cargoName), totalWeightVgm: '—', equipmentReturn: this.valueOrDash(contract.equipmentReturn), - hazardousLabel: contract.isHazardous ? 'Yes' : 'No', + // A hazardous contract names the declared class + UN number on the + // schedule — the flag alone is not a dangerous-goods declaration. + hazardousLabel: contract.isHazardous + ? [ + hazardClassLabel(contract.hazardClass) ?? 'Yes', + contract.unNumber ? `UN ${contract.unNumber}` : null, + ] + .filter(Boolean) + .join(' · ') + : 'No', firstMilePickupAddress: this.valueOrDash(contract.firstMilePickupAddress), lastMileDeliveryAddress: this.valueOrDash(contract.lastMileDeliveryAddress), }; diff --git a/apps/edr-freight-api/src/migrations/2920000000000-AddContractHazardDeclaration.ts b/apps/edr-freight-api/src/migrations/2920000000000-AddContractHazardDeclaration.ts new file mode 100644 index 000000000..bef24c3e4 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2920000000000-AddContractHazardDeclaration.ts @@ -0,0 +1,34 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Hazardous contracts now declare WHAT the dangerous good is, not just that it + * exists: the UN/ADR class (CLASS_1..CLASS_9) and the shipment's UN number. Both + * are captured in the portal alongside the hazard documents and reviewed by the + * two hazardous approval desks. + * + * Nullable — non-hazardous contracts leave both null, and contracts created + * before this change have no declaration to backfill. + */ +export class AddContractHazardDeclaration2920000000000 + implements MigrationInterface +{ + name = 'AddContractHazardDeclaration2920000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.contracts ADD COLUMN IF NOT EXISTS hazard_class varchar(16);`, + ); + await queryRunner.query( + `ALTER TABLE freight.contracts ADD COLUMN IF NOT EXISTS un_number varchar(16);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.contracts DROP COLUMN IF EXISTS un_number;`, + ); + await queryRunner.query( + `ALTER TABLE freight.contracts DROP COLUMN IF EXISTS hazard_class;`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.spec.ts new file mode 100644 index 000000000..2c21d804c --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.spec.ts @@ -0,0 +1,76 @@ +import { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.service'; +import type { Booking } from './entities/booking.entity'; + +/** + * Who hears "Operations wants changes" depends on who owns the booking. A + * customs (Path B) booking is created BY GL Ethiopia on the customer's behalf — + * the customer can neither edit nor resubmit it, so the note has to reach the GL + * who made it, not the portal. + */ +describe('BookingLifecycleNotifierService — operation changes requested', () => { + const booking = (over: Partial = {}): Booking => + ({ + id: 'b-1', + reference: 'BKG-0001', + companyId: 'co-1', + contractId: 'ctr-1', + createdByRole: 'CUSTOMER', + company: { email: 'customer@example.com' }, + ...over, + }) as Booking; + + let notifications: { directSend: jest.Mock }; + let inbox: { notify: jest.Mock }; + let service: BookingLifecycleNotifierService; + + const flush = () => new Promise((resolve) => setImmediate(resolve)); + + beforeEach(() => { + notifications = { directSend: jest.fn().mockResolvedValue(undefined) }; + inbox = { notify: jest.fn().mockResolvedValue(undefined) }; + service = new BookingLifecycleNotifierService( + notifications as never, + inbox as never, + { query: jest.fn().mockResolvedValue([{ phone: '+251900000000' }]) } as never, + ); + }); + + it('sends a GL-created booking back to the GL who created it, not the customer', async () => { + service.operationChangesRequested( + booking({ createdByRole: 'GL_ET', createdByUserId: 'gl-user-1' }), + 'Cargo weight does not match the declaration', + ); + await flush(); + + expect(inbox.notify).toHaveBeenCalledTimes(1); + const sent = inbox.notify.mock.calls[0][0]; + expect(sent.recipients).toEqual({ userIds: ['gl-user-1'] }); + expect(sent.audience).toBe('BACKOFFICE'); + expect(sent.body).toContain('Cargo weight does not match the declaration'); + // Deep-links the clearance page GL works from, not the portal booking. + expect(sent.link).toBe('/dashboard/contracts/clearance/ctr-1'); + // The customer is not told to fix something they cannot touch. + expect(notifications.directSend).not.toHaveBeenCalled(); + }); + + it('still tells the customer when the booking is their own', async () => { + service.operationChangesRequested(booking(), 'Please attach the packing list'); + await flush(); + + const sent = inbox.notify.mock.calls[0][0]; + expect(sent.recipients).toEqual({ companyId: 'co-1' }); + expect(sent.audience).toBe('PORTAL'); + expect(sent.link).toBe('/bookings/b-1'); + expect(notifications.directSend).toHaveBeenCalled(); + }); + + it('falls back to the customer when the GL creator is unknown (legacy rows)', async () => { + service.operationChangesRequested( + booking({ createdByRole: 'GL_ET', createdByUserId: null }), + 'Fix the declaration', + ); + await flush(); + + expect(inbox.notify.mock.calls[0][0].recipients).toEqual({ companyId: 'co-1' }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts index caa41f5e9..4072a462e 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts @@ -179,8 +179,35 @@ export class BookingLifecycleNotifierService { }); } - /** Operations returned the operation request for changes. */ + /** + * Operations returned the operation request for changes. + * + * A customs (Path B) booking was created BY GL Ethiopia on the customer's + * behalf — the customer cannot edit or resubmit it, so telling them to "update + * from the portal" is a dead end. Those go to the GL who created it, linking + * the contract clearance page they work from. Everything else (customer-made + * bookings) keeps the portal message. + */ operationChangesRequested(b: Booking, note: string): void { + if (b.createdByRole === 'GL_ET' && b.createdByUserId) { + const msg = + `Operations returned booking ${b.reference} for changes: ${note}. ` + + `Address it on the contract clearance page and resubmit to Operations.`; + this.logger.log(`OPERATION CHANGES REQUESTED (to GL) — ${this.ref(b)}`); + void this.inbox.notify({ + recipients: { userIds: [b.createdByUserId] }, + audience: NotificationAudience.BACKOFFICE, + type: NotificationType.BOOKING_STATUS, + title: `Booking ${b.reference} needs changes`, + body: msg, + link: b.contractId + ? `/dashboard/contracts/clearance/${b.contractId}` + : `/dashboard/bookings/${b.id}/clearance`, + data: { bookingId: b.id, reference: b.reference, note }, + }); + return; + } + const msg = `Your operation request for booking ${b.reference} needs changes: ${note}. ` + `Please update and resubmit from the portal.`; diff --git a/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts index 507ef43d8..06268342b 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts @@ -33,41 +33,86 @@ import { BookingReferenceYardDto, } from "./dto/booking-reference-data.dto"; +/** + * Reference cargo tree: top-level groups, each carrying its selectable + * commodities. + * + * `cargo_types` is an arbitrary-depth tree (Bulk → Steel Billet → S1 → …), but + * only a LEAF is a real commodity — an intermediate node is a container for + * finer types, and booking against it would be ambiguous. So each group's + * `children` are all of its leaf descendants, flattened, whatever the depth. + * Deep leaves carry their path below the group ("Steel Billet → S1") so a + * generically-named leaf still reads unambiguously in a dropdown. + * + * A group with no active descendants is its own leaf and is emitted as its + * single child — otherwise it is selectable as a group but offers no commodity, + * which dead-ends every form that requires one. + */ export function buildCargoTypeTree( rows: CargoType[], ): BookingReferenceCargoTypeGroupDto[] { const active = rows.filter((r) => r.isActive); - const parents = active - .filter((r) => !r.parentGroupId) - .sort( - (a, b) => a.displayOrder - b.displayOrder || a.code.localeCompare(b.code), - ); + + const byOrder = (a: CargoType, b: CargoType) => + a.displayOrder - b.displayOrder || a.code.localeCompare(b.code); + + const childrenOf = new Map(); + for (const row of active) { + if (!row.parentGroupId) continue; + const siblings = childrenOf.get(row.parentGroupId) ?? []; + siblings.push(row); + childrenOf.set(row.parentGroupId, siblings); + } + for (const siblings of childrenOf.values()) siblings.sort(byOrder); + + const parents = active.filter((r) => !r.parentGroupId).sort(byOrder); + + /** Depth-first leaf walk; `trail` is the path below the group. */ + const collectLeaves = ( + node: CargoType, + trail: string[], + seen: Set, + ): BookingReferenceCargoTypeChildDto[] => { + // Admin-entered parent pointers could in principle cycle — never loop. + if (seen.has(node.id)) return []; + seen.add(node.id); + + const kids = childrenOf.get(node.id) ?? []; + if (kids.length === 0) { + return [ + { + id: node.id, + name: [...trail, node.cargoTypeName].join(" → "), + code: node.code, + unit_of_measure: node.unitOfMeasure ?? null, + }, + ]; + } + const nextTrail = [...trail, node.cargoTypeName]; + return kids.flatMap((kid) => collectLeaves(kid, nextTrail, seen)); + }; return parents.map((parent) => { - const children = active - .filter((r) => r.parentGroupId === parent.id) - .sort( - (a, b) => - a.displayOrder - b.displayOrder || a.code.localeCompare(b.code), - ) - .map( - (child): BookingReferenceCargoTypeChildDto => ({ - id: child.id, - name: child.cargoTypeName, - code: child.code, - unit_of_measure: child.unitOfMeasure ?? null, - }), - ); + const kids = childrenOf.get(parent.id) ?? []; + const children = + kids.length === 0 + ? // The group itself is the commodity. + [ + { + id: parent.id, + name: parent.cargoTypeName, + code: parent.code, + unit_of_measure: parent.unitOfMeasure ?? null, + }, + ] + : kids.flatMap((kid) => collectLeaves(kid, [], new Set())); - const group: BookingReferenceCargoTypeGroupDto = { + return { id: parent.id, name: parent.cargoTypeName, code: parent.code, + children, }; - if (children.length > 0) { - group.children = children; - } - return group; }); } diff --git a/apps/edr-freight-api/src/modules/bookings/cargo-type-tree.spec.ts b/apps/edr-freight-api/src/modules/bookings/cargo-type-tree.spec.ts new file mode 100644 index 000000000..aaa819505 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/cargo-type-tree.spec.ts @@ -0,0 +1,72 @@ +import { buildCargoTypeTree } from './booking-reference-data.service'; +import type { CargoType } from '../rule-engine/entities/cargo-type.entity'; + +const node = ( + id: string, + name: string, + parentGroupId: string | null, + isActive = true, +): CargoType => + ({ + id, + cargoTypeName: name, + code: name.toUpperCase().replace(/\s+/g, '_'), + parentGroupId, + displayOrder: 0, + isActive, + unitOfMeasure: 'PER_TON', + }) as unknown as CargoType; + +describe('buildCargoTypeTree', () => { + // Bulk ──┬─ Wheat (leaf, depth 2) + // └─ Steel Billet ──┬─ S1 (leaf, depth 3) + // └─ S2 ─ S2a (leaf, depth 4) + const rows = [ + node('bulk', 'Bulk', null), + node('wheat', 'Wheat', 'bulk'), + node('steel', 'Steel Billet', 'bulk'), + node('s1', 'S1', 'steel'), + node('s2', 'S2', 'steel'), + node('s2a', 'S2a', 's2'), + node('general', 'General Cargo', null), + ]; + + it('offers only leaves as commodities, at any depth', () => { + const [bulk] = buildCargoTypeTree(rows); + + // Leaves stay grouped under their branch (siblings ordered by + // displayOrder then code — STEEL_BILLET before WHEAT here). + expect(bulk.children?.map((c) => c.id)).toEqual(['s1', 's2a', 'wheat']); + // "Steel Billet" is a container for finer types, never bookable itself. + expect(bulk.children?.some((c) => c.id === 'steel')).toBe(false); + }); + + it('labels deep leaves with their path below the group', () => { + const [bulk] = buildCargoTypeTree(rows); + const byId = new Map(bulk.children?.map((c) => [c.id, c.name])); + + expect(byId.get('wheat')).toBe('Wheat'); + expect(byId.get('s1')).toBe('Steel Billet → S1'); + expect(byId.get('s2a')).toBe('Steel Billet → S2 → S2a'); + }); + + it('emits a childless group as its own commodity', () => { + const general = buildCargoTypeTree(rows).find((g) => g.id === 'general'); + + expect(general?.children).toEqual([ + expect.objectContaining({ id: 'general', name: 'General Cargo' }), + ]); + }); + + it('skips inactive nodes and their descendants', () => { + const withRetired = [ + ...rows, + node('retired', 'Retired', 'bulk', false), + node('retiredKid', 'Retired Kid', 'retired', false), + ]; + const [bulk] = buildCargoTypeTree(withRetired); + + expect(bulk.children?.map((c) => c.id)).not.toContain('retired'); + expect(bulk.children?.map((c) => c.id)).not.toContain('retiredKid'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts index d76391f42..88cb2149b 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts @@ -84,6 +84,14 @@ export interface ContractClearanceView { /** Reference + status of the GL-created shipment booking, once it exists. */ linkedBookingReference?: string | null; linkedBookingStatus?: string | null; + /** + * Operations' latest "needs changes" note on that booking. GL created the + * booking, so GL is the one who has to act on it — surfaced here because the + * clearance page is where GL works, not the portal. + */ + linkedBookingReviewNote?: string | null; + /** Shipment day the booking currently holds — the default when GL resubmits. */ + linkedBookingScheduledDate?: string | null; dutyAdvice?: { amount: number; currency: string; @@ -301,11 +309,24 @@ export class ContractClearanceService { // shortly" message. Reuse the export booking load; fetch for import too. let linkedBookingReference: string | null = null; let linkedBookingStatus: string | null = null; + let linkedBookingReviewNote: string | null = null; + let linkedBookingScheduledDate: string | null = null; if (cycle?.bookingId) { const booking = await this.bookingsService.findById(cycle.bookingId); if (booking) { linkedBookingReference = booking.reference ?? null; linkedBookingStatus = booking.status ?? null; + linkedBookingScheduledDate = booking.scheduledDate + ? new Date(booking.scheduledDate).toISOString() + : null; + // Newest changes-requested note (reviewNotes ride along on findById). + linkedBookingReviewNote = + [...(booking.reviewNotes ?? [])] + .filter((n) => n.type === 'CHANGES_REQUESTED') + .sort( + (a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + )[0]?.note ?? null; if (contract.tradeDirection === 'EXPORT') { nextAction = this.workflowService.computeNextActionForBooking( booking, @@ -349,6 +370,8 @@ export class ContractClearanceService { linkedBookingId: cycle?.bookingId ?? null, linkedBookingReference, linkedBookingStatus, + linkedBookingReviewNote, + linkedBookingScheduledDate, dutyAdvice, workflowFiles, t1, diff --git a/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.spec.ts new file mode 100644 index 000000000..0551cfc7a --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.spec.ts @@ -0,0 +1,63 @@ +import { ContractExpiryService } from './contract-expiry.service'; +import type { Contract } from './entities/contract.entity'; + +/** + * The reminder must warn each customer once, ten days out, and must never let a + * notification failure escape into the scheduler (that would also take out the + * expiry sweep sharing this service). + */ +describe('ContractExpiryService — expiry reminder', () => { + const contract = (over: Partial = {}): Contract => + ({ + id: 'c-1', + reference: 'CTR-2026-00042', + companyId: 'co-1', + contractValidUntil: new Date('2026-08-10T00:00:00.000Z'), + status: 'CONTRACT_ACTIVE', + ...over, + }) as Contract; + + let repo: { expireLapsedContracts: jest.Mock; findExpiringInDays: jest.Mock }; + let inbox: { notify: jest.Mock }; + let service: ContractExpiryService; + + beforeEach(() => { + repo = { + expireLapsedContracts: jest.fn().mockResolvedValue(0), + findExpiringInDays: jest.fn().mockResolvedValue([]), + }; + inbox = { notify: jest.fn().mockResolvedValue(undefined) }; + service = new ContractExpiryService(repo as never, inbox as never); + }); + + it('asks for the contracts lapsing ten days out', async () => { + await service.remindExpiringContracts(); + expect(repo.findExpiringInDays).toHaveBeenCalledWith(10); + }); + + it('notifies the owning company once, deep-linking the contract list', async () => { + repo.findExpiringInDays.mockResolvedValue([contract()]); + + await service.remindExpiringContracts(); + + expect(inbox.notify).toHaveBeenCalledTimes(1); + const sent = inbox.notify.mock.calls[0][0]; + expect(sent.recipients).toEqual({ companyId: 'co-1' }); + expect(sent.title).toContain('CTR-2026-00042'); + expect(sent.title).toContain('10 days'); + expect(sent.link).toBe('/contracts'); + expect(sent.data).toMatchObject({ contractId: 'c-1', action: 'CONTRACT_EXPIRING' }); + }); + + it('skips a contract with no owning company (nobody to notify)', async () => { + repo.findExpiringInDays.mockResolvedValue([contract({ companyId: null })]); + await service.remindExpiringContracts(); + expect(inbox.notify).not.toHaveBeenCalled(); + }); + + it('swallows a notification failure instead of throwing into the scheduler', async () => { + repo.findExpiringInDays.mockResolvedValue([contract()]); + inbox.notify.mockRejectedValue(new Error('inbox down')); + await expect(service.remindExpiringContracts()).resolves.toBeUndefined(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.ts index 439aff804..1ef84c141 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.ts @@ -5,6 +5,13 @@ import { NotificationAudience, NotificationType } from '@edr/types'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { ContractsRepository } from './contracts.repository'; +/** + * How many days before a contract lapses the customer is reminded. Mirrored by + * the portal contract list (EXPIRY_NOTICE_DAYS in contract-ui.tsx), which shows + * the same countdown on the row. + */ +const EXPIRY_NOTICE_DAYS = 10; + /** Nightly sweep that flips contracts past contractValidUntil to EXPIRED. */ @Injectable() export class ContractExpiryService { @@ -15,6 +22,51 @@ export class ContractExpiryService { private readonly inbox: NotificationInboxService, ) {} + /** + * Warn every customer whose contract lapses in ~10 days, once. The repository + * window is a rolling 24h slice, so a contract is picked up by exactly one + * daily run — no reminded-flag column needed. + * + * ponytail: a missed run (API down over the slice) skips that contract's + * reminder; the portal list still shows its countdown for the whole window. + */ + @Cron(CronExpression.EVERY_DAY_AT_2AM, { name: 'contract-expiry-reminder' }) + async remindExpiringContracts(): Promise { + try { + const expiring = + await this.contractsRepository.findExpiringInDays(EXPIRY_NOTICE_DAYS); + let notified = 0; + for (const contract of expiring) { + if (!contract.companyId || !contract.contractValidUntil) continue; + const endsOn = contract.contractValidUntil.toLocaleDateString('en-GB'); + await this.inbox.notify({ + recipients: { companyId: contract.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.CONTRACT_STATUS, + title: `Contract ${contract.reference} expires in ${EXPIRY_NOTICE_DAYS} days`, + body: + `Your contract ${contract.reference} is valid until ${endsOn}. ` + + 'After that date it stops accepting new bookings — contact EDR if ' + + 'you need it renewed.', + link: '/contracts', + data: { contractId: contract.id, action: 'CONTRACT_EXPIRING' }, + }); + notified += 1; + } + this.logger.log( + `Contract expiry reminder: ${notified} customer(s) warned of a contract ` + + `lapsing in ${EXPIRY_NOTICE_DAYS} days`, + ); + } catch (err) { + // Never throws into the scheduler — a failed reminder must not stop the + // expiry sweep from running. + this.logger.error( + `Contract expiry reminder failed: ${(err as Error).message}`, + (err as Error).stack, + ); + } + } + @Cron(CronExpression.EVERY_DAY_AT_1AM, { name: 'contract-expiry-sweep' }) async expireLapsedContracts(): Promise { try { diff --git a/apps/edr-freight-api/src/modules/contracts/contract-stamp-resign.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-stamp-resign.spec.ts new file mode 100644 index 000000000..2c8bc8fa7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-stamp-resign.spec.ts @@ -0,0 +1,131 @@ +import { BadRequestException, ConflictException } from '@nestjs/common'; + +import { ContractTransitionService } from './contract-transition.service'; + +/** + * Signing is one-shot. The single exception: a contract signed before company + * stamps were required must be re-signable so the customer can attach one — + * otherwise counterSign's both-stamps gate strands it forever. These specs pin + * that exception open and pin everything else shut. + */ +describe('customer re-sign to attach a missing stamp', () => { + const contractReady = { id: 'c-1', reference: 'CTR-1', status: 'CONTRACT_READY' }; + const signedNoStamp = { id: 'c-1', reference: 'CTR-1', status: 'SIGNED_CUSTOMER' }; + + const build = (contract: unknown, existingSignature: unknown) => { + const applied: unknown[] = []; + const service = Object.create( + ContractTransitionService.prototype, + ) as ContractTransitionService; + Object.assign(service, { + contractsService: { + findById: jest.fn().mockResolvedValue(contract), + assertCustomerCanAccessContract: jest.fn().mockResolvedValue(undefined), + }, + contractsRepository: { + findSignature: jest.fn().mockResolvedValue(existingSignature), + update: jest.fn().mockResolvedValue(undefined), + }, + otpService: { + verifyOtpForAction: jest.fn().mockResolvedValue(undefined), + sendOtp: jest.fn().mockResolvedValue(undefined), + }, + notifier: { customerSignedToStaff: jest.fn() }, + resolveSignerContacts: jest.fn().mockResolvedValue({ phone: '+251900000000' }), + applySignature: jest.fn((...args: unknown[]) => { + applied.push(args); + return Promise.resolve(); + }), + regenerateContractPdf: jest.fn().mockResolvedValue(undefined), + }); + return { service, applied }; + }; + + const dto = { + role: 'CUSTOMER' as const, + signerDisplayName: 'C. Customer', + signatureImageBase64: 'data:image/png;base64,AAAA', + stampImageBase64: 'data:image/png;base64,BBBB', + otp: '123456', + }; + + it('lets a customer sign again when their signature has no stamp', async () => { + const { service, applied } = build(signedNoStamp, { + id: 's-1', + role: 'CUSTOMER', + stampFileId: null, + }); + + await expect(service.sign('c-1', dto, { signerUserId: 'u-1' })).resolves.toBeDefined(); + expect(applied).toHaveLength(1); + }); + + it('still refuses a second signature once a stamp is on file', async () => { + const { service } = build(signedNoStamp, { + id: 's-1', + role: 'CUSTOMER', + stampFileId: 'file-1', + }); + + // Stamped already → not the re-sign case, so the status guard rejects + // SIGNED_CUSTOMER before the already-signed check is reached. + await expect(service.sign('c-1', dto, { signerUserId: 'u-1' })).rejects.toBeInstanceOf( + ConflictException, + ); + }); + + it('refuses a second signature on a still-ready contract', async () => { + const { service } = build(contractReady, { + id: 's-1', + role: 'CUSTOMER', + stampFileId: 'file-1', + }); + + await expect(service.sign('c-1', dto, { signerUserId: 'u-1' })).rejects.toThrow( + /already signed/i, + ); + }); + + it('signs normally when nothing is on file yet', async () => { + const { service, applied } = build(contractReady, null); + + await expect(service.sign('c-1', dto, { signerUserId: 'u-1' })).resolves.toBeDefined(); + expect(applied).toHaveLength(1); + }); + + it('sends a signing OTP for the stamp re-sign', async () => { + const { service } = build(signedNoStamp, { + id: 's-1', + role: 'CUSTOMER', + stampFileId: null, + }); + + await expect( + service.sendSigningOtp('c-1', { signerUserId: 'u-1' }), + ).resolves.toEqual(expect.objectContaining({ sentTo: expect.any(String) })); + }); + + it('refuses a signing OTP once the contract is signed and stamped', async () => { + const { service } = build(signedNoStamp, { + id: 's-1', + role: 'CUSTOMER', + stampFileId: 'file-1', + }); + + await expect( + service.sendSigningOtp('c-1', { signerUserId: 'u-1' }), + ).rejects.toBeInstanceOf(ConflictException); + }); + + it('requires the OTP on the re-sign path too', async () => { + const { service } = build(signedNoStamp, { + id: 's-1', + role: 'CUSTOMER', + stampFileId: null, + }); + + await expect( + service.sign('c-1', { ...dto, otp: undefined }, { signerUserId: 'u-1' }), + ).rejects.toBeInstanceOf(BadRequestException); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index ea17b26a5..5a22d2fb9 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -22,6 +22,7 @@ import { assertCanApproveContractStep, assertFreightPermission, canEditContractStep, + HAZARDOUS_APPROVAL_ROLES, } from '../../common/freight-permission.util'; import { FREIGHT_PERMS, @@ -530,12 +531,26 @@ export class ContractTransitionService { ); } - for (const rule of chain) { - await this.contractsRepository.createApprovalStep({ - contractId: contract.id, - stepOrder: rule.stepOrder, + // Dangerous goods clear two dedicated hazardous desks BEFORE the commercial + // chain — if either refuses, the contract never reaches the approvers who + // would price and sign it. Steps are renumbered sequentially so the prefix + // and the configured chain form one ordered list. + const roles: Array<{ requiredRole: string; blocksRole: string | null }> = [ + ...(contract.isHazardous ? [...HAZARDOUS_APPROVAL_ROLES] : []).map( + (requiredRole) => ({ requiredRole, blocksRole: null }), + ), + ...chain.map((rule) => ({ requiredRole: rule.requiredRole, blocksRole: rule.blocksRole ?? null, + })), + ]; + + for (const [index, role] of roles.entries()) { + await this.contractsRepository.createApprovalStep({ + contractId: contract.id, + stepOrder: index + 1, + requiredRole: role.requiredRole, + blocksRole: role.blocksRole, status: 'PENDING', }); } @@ -1111,7 +1126,17 @@ export class ContractTransitionService { options.signerUserId, contract, ); - assertContractStatus(contract, ['CONTRACT_READY']); + // SIGNED_CUSTOMER is allowed only for the re-sign-to-add-a-stamp case that + // {@link sign} permits — otherwise the code would be useless on arrival. + const existing = await this.contractsRepository.findSignature( + contractId, + 'CUSTOMER', + ); + const addingMissingStamp = Boolean(existing) && !existing?.stampFileId; + assertContractStatus( + contract, + addingMissingStamp ? ['CONTRACT_READY', 'SIGNED_CUSTOMER'] : ['CONTRACT_READY'], + ); const signerContacts = await this.resolveSignerContacts(options.signerUserId); await this.otpService.sendOtp(signerContacts); @@ -1135,9 +1160,16 @@ export class ContractTransitionService { options.signerUserId, contract, ); - assertContractStatus(contract, ['CONTRACT_READY']); const existing = await this.contractsRepository.findSignature(contractId, 'CUSTOMER'); - if (existing) { + // Signing is one-shot, with one exception: a contract signed before the + // company stamp was required has to be sealed before EDR can counter-sign + // it, so the customer may sign again purely to attach the missing stamp. + const addingMissingStamp = Boolean(existing) && !existing?.stampFileId; + assertContractStatus( + contract, + addingMissingStamp ? ['CONTRACT_READY', 'SIGNED_CUSTOMER'] : ['CONTRACT_READY'], + ); + if (existing && !addingMissingStamp) { throw new BadRequestException('Customer has already signed this contract'); } // Sudo-mode gate: a fresh, single-use OTP must be verified before the diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts index d71a6bd78..6b16d5647 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts @@ -107,6 +107,30 @@ export class ContractsRepository extends BaseRepository { return result.affected ?? 0; } + /** + * Live contracts whose validity ends between `days` and `days + 1` days from + * now — the slice the daily expiry-reminder cron warns about. The window is + * rolling and exactly 24h wide, so consecutive daily runs tile it without + * gaps or overlaps: each contract is picked up by exactly one run and the + * customer is notified once, with no "already reminded" flag to store. + */ + async findExpiringInDays(days: number): Promise { + const now = Date.now(); + return this.repository + .createQueryBuilder('contract') + .where('contract.deleted_at IS NULL') + .andWhere('contract.status NOT IN (:...terminal)', { + terminal: TERMINAL_CONTRACT_STATUSES, + }) + .andWhere('contract.contract_valid_until >= :from', { + from: new Date(now + days * 86_400_000), + }) + .andWhere('contract.contract_valid_until < :to', { + to: new Date(now + (days + 1) * 86_400_000), + }) + .getMany(); + } + /** Find a contract by ID with all child collections, service type, company and files. */ async findByIdWithRelations(id: string): Promise { if (!id) return null; diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts index 0247ac917..02459f2a5 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts @@ -343,6 +343,10 @@ export class ContractsService { lastMileDeliveryLat: dto.lastMileDeliveryLat ?? null, lastMileDeliveryLng: dto.lastMileDeliveryLng ?? null, isHazardous: dto.isHazardous ?? false, + // Hazard class / UN number only exist on a hazardous contract — a stale + // pair from an earlier draft must never survive the flag being turned off. + hazardClass: dto.isHazardous ? (dto.hazardClass ?? null) : null, + unNumber: dto.isHazardous ? (dto.unNumber ?? null) : null, isReefer: dto.isReefer ?? false, contractType: dto.contractType ?? null, status: 'DRAFT', @@ -515,6 +519,13 @@ export class ContractsService { paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency, isHazardous: dto.isHazardous ?? existing.isHazardous, isReefer: dto.isReefer ?? existing.isReefer, + // Same rule as create: clearing the flag clears the declaration with it. + hazardClass: (dto.isHazardous ?? existing.isHazardous) + ? (dto.hazardClass ?? existing.hazardClass ?? null) + : null, + unNumber: (dto.isHazardous ?? existing.isHazardous) + ? (dto.unNumber ?? existing.unNumber ?? null) + : null, equipmentReturn: dto.equipmentReturn ?? existing.equipmentReturn, firstMilePickupAddress: dto.firstMilePickupAddress ?? existing.firstMilePickupAddress, firstMilePickupLat: dto.firstMilePickupLat ?? existing.firstMilePickupLat, diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts index fb4f40654..6e68765f1 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts @@ -17,6 +17,8 @@ import { ValidateNested, } from 'class-validator'; +import { HAZARD_CLASS_VALUES } from '@edr/types'; + import { CONTRACT_KINDS } from '../entities/contract.entity'; const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'DOMESTIC'] as const; @@ -228,6 +230,28 @@ export class CreateContractDto { @Transform(({ value }) => value === 'true' || value === true) isHazardous?: boolean; + @ApiPropertyOptional({ + enum: HAZARD_CLASS_VALUES, + description: 'UN/ADR dangerous-goods class. Required when isHazardous.', + }) + @ValidateIf((o: CreateContractDto) => o.isHazardous === true) + @IsIn(HAZARD_CLASS_VALUES, { + message: `hazardClass must be one of: ${HAZARD_CLASS_VALUES.join(', ')}`, + }) + hazardClass?: string; + + @ApiPropertyOptional({ + description: 'UN number of the dangerous good. Required when isHazardous.', + }) + @ValidateIf((o: CreateContractDto) => o.isHazardous === true) + @IsString() + @MinLength(1) + @MaxLength(16) + @Transform(({ value }) => + typeof value === 'string' ? value.trim().toUpperCase() : value, + ) + unNumber?: string; + @ApiPropertyOptional({ default: false, description: 'Sets contracts.is_reefer' }) @IsOptional() @IsBoolean() diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts index b8635199d..3fe48ea27 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts @@ -188,6 +188,14 @@ export class Contract extends BaseEntity { @Column({ name: 'is_hazardous', type: 'boolean', default: false }) isHazardous!: boolean; + /** UN/ADR dangerous-goods class (CLASS_1..CLASS_9); null unless hazardous. */ + @Column({ name: 'hazard_class', type: 'varchar', length: 16, nullable: true }) + hazardClass?: string | null; + + /** UN number of the dangerous good; null unless hazardous. */ + @Column({ name: 'un_number', type: 'varchar', length: 16, nullable: true }) + unNumber?: string | null; + @Column({ name: 'is_reefer', type: 'boolean', default: false }) isReefer!: boolean; diff --git a/apps/edr-freight-api/src/modules/routes/routes.duplicate.spec.ts b/apps/edr-freight-api/src/modules/routes/routes.duplicate.spec.ts new file mode 100644 index 000000000..43194ce08 --- /dev/null +++ b/apps/edr-freight-api/src/modules/routes/routes.duplicate.spec.ts @@ -0,0 +1,80 @@ +import { ConflictException } from '@nestjs/common'; +import type { DataSource } from 'typeorm'; + +import { RoutesService } from './routes.service'; +import type { RoutesRepository } from './routes.repository'; + +type StopSeq = Array<{ yardId: string; sequenceNo: number }>; + +/** DataSource stub whose Route repository returns the given existing routes. */ +const serviceWith = ( + existing: Array<{ id: string; milestones: StopSeq }>, +): RoutesService => { + const dataSource = { + getRepository: () => ({ find: async () => existing }), + } as unknown as DataSource; + return new RoutesService(dataSource, {} as RoutesRepository); +}; + +const assertNotDuplicate = ( + service: RoutesService, + yardIds: string[], + excludeRouteId?: string, +): Promise => + ( + service as unknown as { + assertNotDuplicate: ( + m: Array<{ yardId: string }>, + id?: string, + ) => Promise; + } + ).assertNotDuplicate( + yardIds.map((yardId) => ({ yardId })), + excludeRouteId, + ); + +describe('RoutesService duplicate guard', () => { + const addisAdamaDire: StopSeq = [ + { yardId: 'addis', sequenceNo: 1 }, + { yardId: 'adama', sequenceNo: 2 }, + { yardId: 'dire', sequenceNo: 3 }, + ]; + + it('rejects an identical stop sequence', async () => { + const service = serviceWith([{ id: 'r1', milestones: addisAdamaDire }]); + + await expect( + assertNotDuplicate(service, ['addis', 'adama', 'dire']), + ).rejects.toBeInstanceOf(ConflictException); + }); + + it('allows the same endpoints with a different corridor', async () => { + // Same origin + destination, but skipping Adama is a genuinely other route. + const service = serviceWith([{ id: 'r1', milestones: addisAdamaDire }]); + + await expect( + assertNotDuplicate(service, ['addis', 'dire']), + ).resolves.toBeUndefined(); + }); + + it('does not flag the route being edited against itself', async () => { + const service = serviceWith([{ id: 'r1', milestones: addisAdamaDire }]); + + await expect( + assertNotDuplicate(service, ['addis', 'adama', 'dire'], 'r1'), + ).resolves.toBeUndefined(); + }); + + it('compares stops by sequence, not storage order', async () => { + const shuffled: StopSeq = [ + { yardId: 'dire', sequenceNo: 3 }, + { yardId: 'addis', sequenceNo: 1 }, + { yardId: 'adama', sequenceNo: 2 }, + ]; + const service = serviceWith([{ id: 'r1', milestones: shuffled }]); + + await expect( + assertNotDuplicate(service, ['addis', 'adama', 'dire']), + ).rejects.toBeInstanceOf(ConflictException); + }); +}); diff --git a/apps/edr-freight-api/src/modules/routes/routes.service.ts b/apps/edr-freight-api/src/modules/routes/routes.service.ts index 96e6c7fd1..855f8ec02 100644 --- a/apps/edr-freight-api/src/modules/routes/routes.service.ts +++ b/apps/edr-freight-api/src/modules/routes/routes.service.ts @@ -5,7 +5,7 @@ import { NotFoundException, } from '@nestjs/common'; import { TrainScheduleStatus } from '@edr/types'; -import { DataSource, In } from 'typeorm'; +import { DataSource, In, Not } from 'typeorm'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; import { Yard } from '../rule-engine/entities/yard.entity'; @@ -15,7 +15,7 @@ import { CreateRouteDto } from './dto/create-route.dto'; import { FilterRoutesDto } from './dto/filter-routes.dto'; import { UpdateRouteDto } from './dto/update-route.dto'; import { RouteMilestone } from './entities/route-milestone.entity'; -import { formatRouteLabel, Route } from './entities/route.entity'; +import { formatRouteLabel, Route, type RouteStatus } from './entities/route.entity'; import { RoutesRepository } from './routes.repository'; /** Order-insensitive key: distances are symmetric. */ @@ -88,6 +88,7 @@ export class RoutesService { async create(dto: CreateRouteDto): Promise { const validated = await this.validateMilestones(dto.milestones); + await this.assertNotDuplicate(validated.milestones); const route = await this.dataSource.transaction(async (manager) => { const savedRoute = await manager.getRepository(Route).save( @@ -123,6 +124,11 @@ export class RoutesService { ? await this.validateMilestones(dto.milestones) : null; + // An edit can collide with another route just as easily as a create can. + if (milestoneInput) { + await this.assertNotDuplicate(milestoneInput.milestones, id); + } + // Milestones or endpoints are about to be rewritten — reject if any // non-terminal schedule still references this route, otherwise its stop list // and distances would silently shift under a live plan. Status-only / @@ -187,6 +193,51 @@ export class RoutesService { return this.findById(id); } + /** + * A route IS its ordered stop list — "Addis → Adama → Dire Dawa" and + * "Addis → Dire Dawa" share endpoints but are different corridors. So the + * duplicate test compares the full yard sequence, not just origin/destination. + * + * Decommissioned routes (STOP_WORKING) are ignored: replacing a retired + * corridor with a fresh one is exactly what an admin does after deactivating, + * and there is no reactivate action to fall back on. + */ + private async assertNotDuplicate( + milestones: Array<{ yardId: string }>, + excludeRouteId?: string, + ): Promise { + const signature = milestones.map((m) => m.yardId).join('>'); + + const candidates = await this.dataSource.getRepository(Route).find({ + where: { + originYardId: milestones[0].yardId, + destinationYardId: milestones[milestones.length - 1].yardId, + status: Not('STOP_WORKING'), + }, + relations: { + originYard: true, + destinationYard: true, + milestones: { yard: true }, + }, + }); + + const duplicate = candidates.find((route) => { + if (route.id === excludeRouteId) return false; + const stops = [...(route.milestones ?? [])] + .sort((a, b) => a.sequenceNo - b.sequenceNo) + .map((m) => m.yardId) + .join('>'); + return stops === signature; + }); + + if (duplicate) { + throw new ConflictException( + `This route already exists: ${formatRouteLabel(duplicate)}. ` + + 'Edit the existing route instead of creating a duplicate.', + ); + } + } + private async validateMilestones(milestones: Array<{ yardId: string }>) { if (milestones.length < 2) { throw new BadRequestException('A route requires at least two yards'); diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index cf57a9a81..65ca3bcde 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -99,6 +99,10 @@ export const CONTRACT_PERMISSIONS: FreightPermissionSeed[] = [ perm('a3000001-0001-4000-8000-00000000000e', 'edr_freight_app:contracts:clearance_et_actions', 'GL Ethiopia phased clearance actions'), perm('a3000001-0001-4000-8000-00000000000f', 'edr_freight_app:contracts:clearance_dj_actions', 'GL Djibouti phased clearance actions'), perm('a3000001-0001-4000-8000-000000000010', 'edr_freight_app:contracts:clearance_duty_advise', 'Advise contract duty/tax'), + // Hazardous contracts get two extra approval steps ahead of the normal chain. + // Each has its own permission so the two desks are genuinely separate people. + perm('a3000001-0001-4000-8000-000000000019', 'edr_freight_app:contracts:hazardous_approval_one', 'Hazardous approval — first review'), + perm('a3000001-0001-4000-8000-00000000001a', 'edr_freight_app:contracts:hazardous_approval_two', 'Hazardous approval — second review'), ]; // Existing per-slug view ids are kept as-is: position-type grants reference @@ -423,6 +427,8 @@ export const FREIGHT_PERMS = { approveLineStaff: 'edr_freight_app:contracts:approve_line_staff', approveDirector: 'edr_freight_app:contracts:approve_director', approveCeo: 'edr_freight_app:contracts:approve_ceo', + hazardousApprovalOne: 'edr_freight_app:contracts:hazardous_approval_one', + hazardousApprovalTwo: 'edr_freight_app:contracts:hazardous_approval_two', generateContract: 'edr_freight_app:contracts:generate_contract', signStaff: { bulk: 'edr_freight_app:contracts:sign_staff:bulk', diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/BookingChangesRequestedAlert.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/BookingChangesRequestedAlert.tsx new file mode 100644 index 000000000..16b1ab602 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/BookingChangesRequestedAlert.tsx @@ -0,0 +1,132 @@ +import { Alert, Button, Group, Paper, Stack, Text } from "@mantine/core"; +import { DateInput } from "@mantine/dates"; +import { AlertTriangle, Send } from "lucide-react"; +import { useState } from "react"; +import { Link } from "react-router-dom"; +import toast from "react-hot-toast"; + +import { bookingsService } from "@/services/bookings.service"; + +export interface BookingChangesRequestedAlertProps { + bookingId: string; + reference?: string | null; + /** Operations' note — what has to change before this can go back to them. */ + note?: string | null; + /** Shipment day the booking currently holds; the resubmit default. */ + scheduledDate?: string | null; + /** GL Ethiopia owns customs bookings, so only they get the resubmit control. */ + canResubmit: boolean; + onResubmitted?: () => void; +} + +/** + * Operations sent a GL-created booking back for changes. + * + * The customer cannot act on this — GL created the booking on their behalf — so + * the note and the way out both live here, on the page GL works from. Resubmit + * re-requests operation on the chosen shipment day; the server re-checks the day + * has a departure that can carry the cargo and refuses with the reason if not. + */ +export function BookingChangesRequestedAlert({ + bookingId, + reference, + note, + scheduledDate, + canResubmit, + onResubmitted, +}: BookingChangesRequestedAlertProps) { + const [day, setDay] = useState( + scheduledDate ? new Date(scheduledDate) : null, + ); + const [sending, setSending] = useState(false); + + const resubmit = async () => { + if (!day) return; + setSending(true); + try { + await bookingsService.proceedToOperation(bookingId, day.toISOString()); + toast.success("Sent back to Operations for review"); + onResubmitted?.(); + } catch { + // The http interceptor already toasts the server's own reason (no + // departure that day, no wagon that can carry the cargo, export train + // full…) — a second toast here would just duplicate it. + } finally { + setSending(false); + } + }; + + return ( + } + title={`Operations returned booking ${reference ?? ""} for changes`.trim()} + > + + {note ? ( + + + What Operations asked for + + + {note} + + + ) : ( + + Operations returned this booking without a note — contact them for + the detail before resubmitting. + + )} + + + This booking was created by GL Ethiopia, so the customer cannot fix it. + Make the correction Operations asked for, then send it back for review.{" "} + + Open the booking → + + + + {canResubmit ? ( + + setDay(v ? new Date(v) : null)} + minDate={new Date()} + size="sm" + w={230} + /> + + + ) : null} + + + ); +} + +export default BookingChangesRequestedAlert; diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx index 25f877de3..3747c2d2d 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx @@ -1,5 +1,5 @@ import { useMemo, useState } from "react"; -import { Check, ShieldCheck, X } from "lucide-react"; +import { Check, Flame, ShieldCheck, X } from "lucide-react"; import { Stack, Group, @@ -14,10 +14,22 @@ import { import type { Freight } from "@edr/types"; import { formatContractApprovalProgress } from "@/features/contracts/contract-approval-progress"; +import { HazardDeclarationPanel } from "./HazardDeclarationPanel"; import { SectionCard } from "@/components/bookings/detail/SectionCard"; import type { useContractMutations } from "@/hooks/contracts/useContracts"; import { useAuth } from "@/auth/useAuth"; -import { canApproveContractStep } from "@/lib/permissions"; +import { + canApproveContractStep, + CONTRACT_APPROVAL_ROLE_LABELS, + HAZARDOUS_APPROVAL_ROLE_PERMISSION, +} from "@/lib/permissions"; + +/** Chain roles that exist only because the contract carries dangerous goods. */ +const isHazardStep = (requiredRole: string): boolean => + requiredRole in HAZARDOUS_APPROVAL_ROLE_PERMISSION; + +const roleLabel = (requiredRole: string): string => + CONTRACT_APPROVAL_ROLE_LABELS[requiredRole] ?? requiredRole; type Mutations = ReturnType; @@ -126,7 +138,7 @@ export function ContractApprovalStepsCard({ const subtitle = summary.detail || (nextPending - ? `Next: ${nextPending.requiredRole} · step ${nextPending.stepOrder}` + ? `Next: ${roleLabel(nextPending.requiredRole)} · step ${nextPending.stepOrder}` : steps.length ? "All steps complete" : "Accept submission to begin"); @@ -196,7 +208,7 @@ export function ContractApprovalStepsCard({ You are about to approve the{" "} - {pendingStep?.requiredRole} + {roleLabel(pendingStep?.requiredRole ?? "")} {" "} step for contract{" "} @@ -204,6 +216,9 @@ export function ContractApprovalStepsCard({ . This action cannot be undone from this screen. + {pendingStep && isHazardStep(pendingStep.requiredRole) && ( + + )} @@ -333,6 +348,7 @@ function StepRow({ : isNext ? "edr-green" : "gray"; + const hazard = isHazardStep(step.requiredRole); return ( @@ -371,9 +395,23 @@ function StepRow({ {step.stepOrder} - - {step.requiredRole} - + + + {roleLabel(step.requiredRole)} + + {hazard && ( + } + style={{ flexShrink: 0 }} + > + Hazmat + + )} + {step.note && ( {step.note} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/HazardDeclarationPanel.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/HazardDeclarationPanel.tsx new file mode 100644 index 000000000..56eb794c7 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/HazardDeclarationPanel.tsx @@ -0,0 +1,65 @@ +import { Badge, Box, Group, Stack, Text } from "@mantine/core"; +import { Flame } from "lucide-react"; +import { hazardClassLabel, type Freight } from "@edr/types"; + +/** + * The contract's dangerous-goods declaration — the UN/ADR class and UN number + * the customer declared alongside the hazard documents. Shown wherever a + * hazardous contract is reviewed: the cargo-scope card and the two hazardous + * approval confirmations, so no one signs off without seeing what is moving. + */ +export function HazardDeclarationPanel({ + contract, +}: { + contract: Pick; +}) { + const classLabel = hazardClassLabel(contract.hazardClass); + + return ( + + + + + + + Dangerous goods declaration + + + + {classLabel ?? "Class not declared"} + + + {contract.unNumber ? `UN ${contract.unNumber}` : "UN number not declared"} + + + + Check the declaration against the uploaded hazard documents before + approving. + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 032411b26..48c581519 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -156,6 +156,8 @@ export const URL_CONSTANTS = { `/bookings/${id}/clearance/ro-amendment`, CLEARANCE_EXPORT_RELEASE: (id: string) => `/bookings/${id}/clearance/export-release`, + // Re-request operation after Operations sent the booking back for changes. + CLEARANCE_PROCEED: (id: string) => `/bookings/${id}/clearance/proceed`, CLEARANCE_ET_QUEUE: "/bookings/clearance/et-queue", CLEARANCE_DJ_QUEUE: "/bookings/clearance/dj-queue", }, diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index 01bca1e11..88b5872d3 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -46,6 +46,8 @@ export const FREIGHT_PERMS = { approveLineStaff: "edr_freight_app:contracts:approve_line_staff", approveDirector: "edr_freight_app:contracts:approve_director", approveCeo: "edr_freight_app:contracts:approve_ceo", + hazardousApprovalOne: "edr_freight_app:contracts:hazardous_approval_one", + hazardousApprovalTwo: "edr_freight_app:contracts:hazardous_approval_two", generateContract: "edr_freight_app:contracts:generate_contract", signStaff: { bulk: "edr_freight_app:contracts:sign_staff:bulk", @@ -442,6 +444,22 @@ const CONTRACT_APPROVE_ROLE_PERMISSION: Record = { CEO: FREIGHT_PERMS.contracts.approveCeo, }; +/** + * The two hazardous-goods steps prepended to a hazardous contract's chain. + * They are not position types — they authorize purely on their own permission, + * exactly as the API's HAZARDOUS_APPROVAL_ROLE_PERMISSION does. + */ +export const HAZARDOUS_APPROVAL_ROLE_PERMISSION: Record = { + HAZARDOUS_APPROVAL_ONE: FREIGHT_PERMS.contracts.hazardousApprovalOne, + HAZARDOUS_APPROVAL_TWO: FREIGHT_PERMS.contracts.hazardousApprovalTwo, +}; + +/** Display label for an approval step's role (hazardous steps get real names). */ +export const CONTRACT_APPROVAL_ROLE_LABELS: Record = { + HAZARDOUS_APPROVAL_ONE: "Hazardous review — first approver", + HAZARDOUS_APPROVAL_TWO: "Hazardous review — second approver", +}; + /** * Can this user action a contract approval step requiring `requiredRole`? * @@ -464,6 +482,10 @@ export function canApproveContractStep( if (!user || !requiredRole) return false; if (isFreightApprovalAdmin(user)) return true; + // Hazardous steps are permission-only — no position type stands in for them. + const hazardousPermission = HAZARDOUS_APPROVAL_ROLE_PERMISSION[requiredRole]; + if (hazardousPermission) return hasPermission(user, hazardousPermission); + const positionTypes = getPositionTypeKeys(user); if (positionTypes.includes(requiredRole)) return true; diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx index 1ed7d71ec..68b3443c8 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx @@ -35,6 +35,7 @@ import { isDjiboutiGl, } from "@/lib/permissions"; +import { BookingChangesRequestedAlert } from "@/components/contracts/BookingChangesRequestedAlert"; import { ClearanceOpsTabs } from "@/components/contracts/ClearanceOpsTabs"; import { PageContainer } from "@/components/page/PageContainer"; import { PageHeader } from "@/components/page/PageHeader"; @@ -137,10 +138,15 @@ export default function ContractClearanceDetailPage() { // The GL-created booking expired unpaid — the slot is free again and GL // rebooks on the customer's behalf (customs bookings are never self-booked). const bookingExpired = clearance?.linkedBookingStatus === "EXPIRED"; - const canRebook = - bookingExpired && + // Operations sent the GL-created booking back. GL owns customs bookings, so + // the note and the resubmit belong here, not in the customer's portal. + const bookingNeedsChanges = + clearance?.linkedBookingStatus === "OPERATION_CHANGES_REQUESTED"; + const isGlBookingOwner = hasPermission(user, FREIGHT_PERMS.contracts.createBooking) && !isDjiboutiGl(user); + const canResubmitBooking = bookingNeedsChanges && isGlBookingOwner; + const canRebook = bookingExpired && isGlBookingOwner; const rebookHref = linkedBookingId ? `${bookingHref}?copyFrom=${linkedBookingId}` : bookingHref; @@ -294,6 +300,19 @@ export default function ContractClearanceDetailPage() { ) : null} + ) : bookingNeedsChanges && linkedBookingId ? ( + { + void refetch(); + void refetchContract(); + refetchBookingMilestonesIfLinked(); + }} + /> ) : bookingAlreadyCreated ? ( ) : null} + {contract.isHazardous ? ( + + + + ) : null} {(contract.cargoScope ?? []).length === 0 ? ( No cargo scope lines. diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index 0f2dd1f25..a6e708784 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -2533,6 +2533,13 @@ export const api = { bookingsService.reviewOperation(id, decision, { note }), ), + proceedToOperation: endpoint< + { id: string; scheduledDate: string }, + BookingDetail + >("bookings", "proceedToOperation", ({ id, scheduledDate }) => + bookingsService.proceedToOperation(id, scheduledDate), + ), + generateContract: endpoint<{ id: string }, BookingDetail>( "bookings", "generateContract", diff --git a/apps/edr-freight-web/backoffice/src/services/bookings.service.ts b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts index d814ceac6..fce85102e 100644 --- a/apps/edr-freight-web/backoffice/src/services/bookings.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts @@ -243,6 +243,14 @@ export const bookingsService = { ...options, }), + /** + * Re-request operation on a booking Operations sent back for changes. The + * customer path uses the same endpoint from the portal; GL needs it here + * because a customs booking is GL's to fix, not the customer's. + */ + proceedToOperation: (id: string, scheduledDate: string) => + postBooking(B.CLEARANCE_PROCEED(id), { scheduledDate }), + generateContract: (id: string) => postBooking(B.CONTRACT_GENERATE(id)), diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractStepBanner.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractStepBanner.tsx index 3278e34e1..bc7d55691 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractStepBanner.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractStepBanner.tsx @@ -15,7 +15,14 @@ import { import type { LucideIcon } from "lucide-react"; import type { Freight } from "@edr/types"; -import { BORDER, GREEN, GREEN_DARK, INK, MUTED } from "./contract-ui"; +import { + BORDER, + GREEN, + GREEN_DARK, + INK, + MUTED, + expiryNoticeDays, +} from "./contract-ui"; /** * The customer-facing contract journey, in order. This is the *contract track* @@ -124,15 +131,6 @@ function resolveStep(status: string): StepState { } } -/** Days until the contract validity lapses, if any (negative = already lapsed). */ -function daysUntil(dateIso?: string | null): number | null { - if (!dateIso) return null; - const end = new Date(dateIso).getTime(); - if (Number.isNaN(end)) return null; - const ms = end - Date.now(); - return Math.ceil(ms / 86_400_000); -} - export interface ContractStepBannerProps { contract: Freight.IContract; } @@ -145,9 +143,9 @@ export function ContractStepBanner({ contract }: ContractStepBannerProps) { const { activeIdx, terminal, next } = resolveStep(contract.status); const isTerminalBad = terminal === "REJECTED" || terminal === "CANCELLED" || terminal === "EXPIRED"; - const expiryDays = daysUntil(contract.contractValidUntil); - const expirySoon = - !terminal && expiryDays !== null && expiryDays >= 0 && expiryDays <= 14; + // Same notice window as the list badge and the API's reminder. + const expiryDays = expiryNoticeDays(contract); + const expirySoon = !terminal && expiryDays !== null; return ( + {/* Signed before company stamps were required — re-signing is the only + way to attach one, and EDR cannot counter-sign until it is there. */} + {data.canSignCustomer && data.status === "SIGNED_CUSTOMER" && ( + + This contract was signed before a company stamp was required. Please + sign again and attach your stamp so EDR can counter-sign it. + + )} + {data.canSignCustomer && !hasScrolledToBottom && ( Please scroll through the entire contract before signing. diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx index bd447db73..f465ca849 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx @@ -43,6 +43,7 @@ import { usePagination } from "@edr/ui-common"; import { BORDER, ContractDocButton, + ContractExpiryBadge, ContractStatusBadge, GREEN, INK, @@ -600,6 +601,9 @@ export default function ContractsList() { ).toLocaleDateString() : "—"} + {/* Countdown once the contract is inside the notice + window — renders nothing before that. */} + diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx index e0f2d11a8..0acd5d907 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx @@ -623,6 +623,11 @@ export default function NewContractPage({ } : {}), isHazardous: data.isHazardous, + // The dangerous-goods declaration only travels with the flag — the API + // rejects a hazardous contract that omits either field. + ...(data.isHazardous + ? { hazardClass: data.hazardClass, unNumber: data.unNumber } + : {}), // Reefer is a contract-level flag for both container and bulk. isReefer: data.isRefrigerated, ...(data.previousContractRef diff --git a/apps/edr-freight-web/portal/src/pages/contracts/contract-expiry-notice.test.ts b/apps/edr-freight-web/portal/src/pages/contracts/contract-expiry-notice.test.ts new file mode 100644 index 000000000..2b2ca987c --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/contracts/contract-expiry-notice.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; + +import { EXPIRY_NOTICE_DAYS, expiryNoticeDays, expiryNoticeLabel } from "./contract-ui"; + +const inDays = (days: number): string => + // Half a day past the boundary so ceil() lands on `days` regardless of the + // clock at test time. + new Date(Date.now() + (days - 0.5) * 86_400_000).toISOString(); + +const contract = (over: Record = {}) => + ({ + status: "CONTRACT_ACTIVE", + contractValidUntil: inDays(5), + ...over, + }) as never; + +describe("expiryNoticeDays", () => { + it("counts the days left once inside the notice window", () => { + expect(expiryNoticeDays(contract({ contractValidUntil: inDays(5) }))).toBe(5); + expect( + expiryNoticeDays( + contract({ contractValidUntil: inDays(EXPIRY_NOTICE_DAYS) }), + ), + ).toBe(EXPIRY_NOTICE_DAYS); + }); + + it("stays silent while the contract is further out than the window", () => { + expect( + expiryNoticeDays( + contract({ contractValidUntil: inDays(EXPIRY_NOTICE_DAYS + 1) }), + ), + ).toBeNull(); + }); + + it("stays silent for a contract with no validity date", () => { + expect(expiryNoticeDays(contract({ contractValidUntil: null }))).toBeNull(); + }); + + it("stays silent once the date has passed — that is expiry, not a warning", () => { + expect(expiryNoticeDays(contract({ contractValidUntil: inDays(-1) }))).toBeNull(); + }); + + it("stays silent on contracts that are already over", () => { + for (const status of ["EXPIRED", "CANCELLED", "REJECTED", "CONTRACT_CLOSED"]) { + expect(expiryNoticeDays(contract({ status }))).toBeNull(); + } + }); +}); + +describe("expiryNoticeLabel", () => { + it("reads naturally at the edges", () => { + expect(expiryNoticeLabel(0)).toBe("Expires today"); + expect(expiryNoticeLabel(1)).toBe("1 day left"); + expect(expiryNoticeLabel(10)).toBe("10 days left"); + }); +}); diff --git a/apps/edr-freight-web/portal/src/pages/contracts/contract-ui.tsx b/apps/edr-freight-web/portal/src/pages/contracts/contract-ui.tsx index 7028943df..4043fc71d 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/contract-ui.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/contract-ui.tsx @@ -1,5 +1,5 @@ import { Box, Group, Paper, Text, Tooltip } from "@mantine/core"; -import { FileText } from "lucide-react"; +import { AlertTriangle, FileText } from "lucide-react"; import type { LucideIcon } from "lucide-react"; import type { ReactNode } from "react"; import type { Freight } from "@edr/types"; @@ -209,6 +209,88 @@ export function ContractStatusBadge({ status }: { status: string }) { ); } +/** + * How close to its validity end a contract has to be before the customer is + * warned. The API notifies at the same distance (contract-expiry.service), so + * the inbox message and the list badge agree. + */ +export const EXPIRY_NOTICE_DAYS = 10; + +/** Whole days until a date (0 = today, negative = already past). Null if unset. */ +export function daysUntil(dateIso?: string | null): number | null { + if (!dateIso) return null; + const end = new Date(dateIso).getTime(); + if (Number.isNaN(end)) return null; + return Math.ceil((end - Date.now()) / 86_400_000); +} + +/** Contracts that are already over — no point warning about their expiry. */ +const CLOSED_CONTRACT_STATUSES = [ + "REJECTED", + "CANCELLED", + "CONTRACT_CLOSED", + "ARCHIVED", + "EXPIRED", +]; + +/** + * Days left on a live contract, but only inside the notice window — null when + * the contract is closed, has no validity date, has already lapsed, or is still + * further out than {@link EXPIRY_NOTICE_DAYS}. + */ +export function expiryNoticeDays( + contract: Pick, +): number | null { + if (CLOSED_CONTRACT_STATUSES.includes(contract.status)) return null; + const days = daysUntil(contract.contractValidUntil); + if (days == null || days < 0 || days > EXPIRY_NOTICE_DAYS) return null; + return days; +} + +/** "Expires today" / "5 days left" — the wording shared by list and banner. */ +export function expiryNoticeLabel(days: number): string { + if (days === 0) return "Expires today"; + return `${days} day${days === 1 ? "" : "s"} left`; +} + +/** + * Amber countdown pill shown on a contract that is about to lapse. Renders + * nothing outside the notice window, so callers can drop it in unconditionally. + */ +export function ContractExpiryBadge({ + contract, +}: { + contract: Pick; +}) { + const days = expiryNoticeDays(contract); + if (days == null) return null; + return ( + + + + + {expiryNoticeLabel(days)} + + + + ); +} + /** A labelled value used across the contract detail summary cards. */ export function MetaItem({ label, diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/contractToForm.ts b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/contractToForm.ts index 38a7b3463..21de86b42 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/contractToForm.ts +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/contractToForm.ts @@ -122,6 +122,8 @@ export function contractToFormValues( bulkQuantityCap: isGeneral && bulkRow?.quantityCap != null ? bulkRow.quantityCap : 0, isHazardous: contract.isHazardous, + hazardClass: contract.hazardClass ?? "", + unNumber: contract.unNumber ?? "", isRefrigerated: contract.isReefer, originYard: primaryRoute?.originYardId ?? "", diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/schema.ts b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/schema.ts index d20719c24..3105ac485 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/schema.ts @@ -1,5 +1,6 @@ import { DeepPartial, Path } from "react-hook-form"; import * as z from "zod"; +import { HAZARD_CLASS_VALUES } from "@edr/types"; // Wizard steps for the contract creation flow. Condensed to four steps: the // pickers are dropdown selects so each step fits one screen without scrolling. @@ -177,6 +178,10 @@ export const contractFormSchema = z bulkQuantityCap: nonNegativeQuantityCap.default(0), // Contract-level billing flags. isHazardous: z.boolean().default(false), + // Dangerous-goods declaration — collected with the hazard documents and + // mandatory whenever isHazardous (enforced in the superRefine below). + hazardClass: z.string().default(""), + unNumber: z.string().default(""), isRefrigerated: z.boolean().default(false), // ── Route ── (one route per contract — general contracts included) @@ -241,6 +246,24 @@ export const contractFormSchema = z }); } } + // Hazardous cargo must name its UN/ADR class and UN number — the API + // rejects the contract otherwise, so catch it before the wizard submits. + if (data.isHazardous) { + if (!HAZARD_CLASS_VALUES.includes(data.hazardClass)) { + ctx.addIssue({ + code: "custom", + path: ["hazardClass"], + message: "Select the dangerous-goods class.", + }); + } + if (!data.unNumber.trim()) { + ctx.addIssue({ + code: "custom", + path: ["unNumber"], + message: "Enter the UN number.", + }); + } + } // GENERAL contracts are uncapped: no quantity cap is collected, so the // customer can book repeatedly until the contract's validity expires. The // cap fields default to 0/empty and map to quantityCap = NULL (uncapped) at @@ -271,6 +294,8 @@ export const initialContractFormValues: DeepPartial = { cargoFreeText: "", bulkQuantityCap: 0, isHazardous: false, + hazardClass: "", + unNumber: "", isRefrigerated: false, originYard: "", @@ -307,6 +332,8 @@ export const contractStepFields: Record< "cargoFreeText", "bulkQuantityCap", "isHazardous", + "hazardClass", + "unNumber", "isRefrigerated", "originYard", "destinationYard", diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step3-cargo-scope.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step3-cargo-scope.tsx index 1afc08186..84248d3e7 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step3-cargo-scope.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step3-cargo-scope.tsx @@ -3,6 +3,7 @@ import { Controller, type UseFormReturn } from "react-hook-form"; // Snowflake — restore with the Refrigerated Cargo switch below. import { Container, Flame, RotateCcw } from "lucide-react"; import { + Badge, Box, Button, Group, @@ -13,10 +14,11 @@ import { Stack, Switch, Text, + TextInput, } from "@mantine/core"; import { useQuery } from "@tanstack/react-query"; import { SmartFileInput } from "@edr/ui-common"; -import type { Freight } from "@edr/types"; +import { HAZARD_CLASSES, hazardClassLabel, type Freight } from "@edr/types"; import { api } from "@/services/api"; import { CONTAINER_SIZES, @@ -95,6 +97,18 @@ export function Step3CargoScope({ const [hazardModalOpen, setHazardModalOpen] = useState(false); const [hazardDraft, setHazardDraft] = useState({}); const [hazardErrors, setHazardErrors] = useState>({}); + // Dangerous-goods declaration, edited in the modal and only written back to + // the form once the customer confirms — cancelling must leave the contract + // exactly as it was. + const [classDraft, setClassDraft] = useState(null); + const [unDraft, setUnDraft] = useState(""); + const [declErrors, setDeclErrors] = useState<{ + hazardClass?: string; + unNumber?: string; + }>({}); + + const hazardClass = form.watch("hazardClass"); + const unNumber = form.watch("unNumber"); /** Drop every hazardous document from the contract's document map. */ const clearHazardDocs = () => { @@ -117,6 +131,9 @@ export function Step3CargoScope({ ), ); setHazardErrors({}); + setClassDraft(form.getValues("hazardClass") || null); + setUnDraft(form.getValues("unNumber") ?? ""); + setDeclErrors({}); setHazardModalOpen(true); }; @@ -124,14 +141,20 @@ export function Step3CargoScope({ const missing = hazardFields.filter( (f) => f.isRequired && !hasUploaded(hazardDraft[f.fileKey]), ); - if (missing.length > 0) { - setHazardErrors( - Object.fromEntries( - missing.map((f) => [f.fileKey, `${f.fileLabel} is required.`]), - ), - ); - return; + const nextDeclErrors: typeof declErrors = {}; + if (!classDraft) { + nextDeclErrors.hazardClass = "Select the dangerous-goods class."; } + if (!unDraft.trim()) nextDeclErrors.unNumber = "Enter the UN number."; + + setHazardErrors( + Object.fromEntries( + missing.map((f) => [f.fileKey, `${f.fileLabel} is required.`]), + ), + ); + setDeclErrors(nextDeclErrors); + if (missing.length > 0 || Object.keys(nextDeclErrors).length > 0) return; + form.setValue( "documents", { @@ -140,16 +163,29 @@ export function Step3CargoScope({ }, { shouldDirty: true }, ); + form.setValue("hazardClass", classDraft!, { shouldDirty: true }); + form.setValue("unNumber", unDraft.trim().toUpperCase(), { + shouldDirty: true, + }); + form.clearErrors(["hazardClass", "unNumber"]); form.setValue("isHazardous", true, { shouldDirty: true }); setHazardModalOpen(false); }; + /** Turning the switch off drops the declaration with the documents. */ + const clearHazardDeclaration = () => { + form.setValue("hazardClass", "", { shouldDirty: true }); + form.setValue("unNumber", "", { shouldDirty: true }); + form.clearErrors(["hazardClass", "unNumber"]); + }; + // A hidden flag must never leak into the payload: a general contract can't be // hazardous, and a non-import contract carries neither reefer nor empty return. useEffect(() => { if (isOneTime) return; if (form.getValues("isHazardous")) { form.setValue("isHazardous", false, { shouldDirty: true }); + clearHazardDeclaration(); } // Runs again once the hazard field list loads — a no-op when nothing matches. clearHazardDocs(); @@ -356,22 +392,32 @@ export function Step3CargoScope({ name="isHazardous" control={form.control} render={({ field }) => ( - } - iconBg="#FBEAE7" - iconColor="#C0392B" - title="Hazardous Material" - description="Applies a hazard surcharge as a per-container unit rate. Requires hazard documents." - checked={field.value ?? false} - onChange={(v) => { - if (v) { - openHazardModal(); - return; - } - field.onChange(false); - clearHazardDocs(); - }} - /> + + } + iconBg="#FBEAE7" + iconColor="#C0392B" + title="Hazardous Material" + description="Applies a hazard surcharge as a per-container unit rate. Requires a UN class, UN number and hazard documents." + checked={field.value ?? false} + onChange={(v) => { + if (v) { + openHazardModal(); + return; + } + field.onChange(false); + clearHazardDocs(); + clearHazardDeclaration(); + }} + /> + {field.value && ( + + )} + )} /> )} @@ -429,17 +475,83 @@ export function Step3CargoScope({ setHazardModalOpen(false)} - title="Hazardous cargo documents" + title={ + + + + + + Hazardous cargo declaration + + + } size="lg" centered radius={14} > - + - Hazardous cargo can only move once the documents below are attached - to the contract. + Dangerous goods move only once the class and UN number are declared + and the documents below are attached to the contract. EDR reviews + this declaration in two dedicated hazardous approval steps. + + Dangerous-goods declaration +
+ { const [historyTarget, setHistoryTarget] = useState(null); const [selectedDriver, setSelectedDriver] = useState(""); const [wagonWorkspaceOpen, setWagonWorkspaceOpen] = useState(false); - const [transferRequestsOpen, setTransferRequestsOpen] = useState(false); const { viewMode, setViewMode } = useFleetViewMode(slug); const serverListFilters = useMemo((): FleetListFilters | undefined => { @@ -90,6 +88,11 @@ const FleetResourcePage = () => { if (trainNumber && trainNumber !== "ALL") { (filters as { trainNumber?: string }).trainNumber = trainNumber; } + // Wagons only: narrow the fleet to one wagon type (the API filters on it). + const wagonTypeId = listFilterValues.wagonTypeId; + if (wagonTypeId && wagonTypeId !== "ALL") { + (filters as { wagonTypeId?: string }).wagonTypeId = wagonTypeId; + } if (slug !== "locomotives" && search.trim()) { filters.search = search.trim(); } @@ -449,12 +452,15 @@ const FleetResourcePage = () => { ) : null} {canTransfer ? ( + // The desk is its own page now (list + fulfil + history with + // pagination); this is just the way in from the fleet list. @@ -713,13 +719,6 @@ const FleetResourcePage = () => { /> ) : null} - {slug === "wagons" ? ( - setTransferRequestsOpen(false)} - /> - ) : null} - {slug === "wagons" ? ( void; + onDone?: () => void; +} + +/** + * Move wagons against an open request. Any number from one up to whatever is + * still owed — a yard that can only spare 20 of 50 sends 20 now and the request + * stays open for the rest, so the picker caps at the OUTSTANDING count, not the + * originally requested one. + */ +export function TransferFulfillModal({ + request, + onClose, + onDone, +}: TransferFulfillModalProps) { + const [picked, setPicked] = useState>(new Set()); + const outstanding = request ? outstandingOn(request) : 0; + + const { data: wagons = [], isLoading } = useQuery({ + ...api.wagons.list.queryOptions({ + input: { + filters: request + ? { + currentYardId: request.fromYardId, + wagonTypeId: request.wagonTypeId, + status: Freight.WagonStatus.Available, + } + : {}, + }, + }), + enabled: Boolean(request), + }); + + const fulfill = useMutation(api.wagonTransferRequests.fulfill.mutationOptions()); + + const canTake = useMemo( + () => Math.min(outstanding, wagons.length), + [outstanding, wagons.length], + ); + + const toggle = (id: string) => + setPicked((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + // Never let staff pick more than is still owed — the API rejects it too. + else if (next.size >= outstanding) return prev; + else next.add(id); + return next; + }); + + const takeAllAvailable = () => + setPicked(new Set(wagons.slice(0, canTake).map((w) => w.id))); + + const close = () => { + setPicked(new Set()); + onClose(); + }; + + const submit = async () => { + if (!request || picked.size === 0) return; + try { + const moved = picked.size; + await fulfill.mutateAsync({ id: request.id, wagonIds: [...picked] }); + toast.success( + moved >= outstanding + ? `Request complete — ${moved} wagon(s) transferred` + : `${moved} wagon(s) transferred · ${outstanding - moved} still owed`, + ); + close(); + onDone?.(); + } catch { + // The http interceptor surfaces the server's reason. + } + }; + + return ( + + {yardLabel(request.fromYard)} + + {yardLabel(request.toYard)} + + {wagonTypeLabel(request.wagonType)} + + + ) : null + } + > + {!request ? null : ( + + + + + {outstanding} + {" "} + wagon(s) still owed ·{" "} + + {wagons.length} + {" "} + available in {yardLabel(request.fromYard)} + + + + + {wagons.length < outstanding ? ( + }> + This yard can only cover {wagons.length} of the {outstanding}{" "} + outstanding. Send what is here — the request stays open for the + rest, or close it short so the requester can ask another yard. + + ) : null} + + {isLoading ? ( + + + + ) : wagons.length === 0 ? ( + + No available wagons of this type in the source yard right now. + + ) : ( + + + {wagons.map((w) => ( + toggle(w.id)} + label={w.wagonNumber} + /> + ))} + + + )} + + + + + + + )} + + ); +} + +export default TransferFulfillModal; diff --git a/apps/edr-freight-web/backoffice/src/pages/wagons/TransferRequestModals.tsx b/apps/edr-freight-web/backoffice/src/pages/wagons/TransferRequestModals.tsx new file mode 100644 index 000000000..df90a7683 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/wagons/TransferRequestModals.tsx @@ -0,0 +1,276 @@ +import { + Alert, + Button, + Group, + Modal, + NumberInput, + Select, + Stack, + Text, + Textarea, +} from "@mantine/core"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { AlertTriangle, Send, XCircle } from "lucide-react"; +import { useEffect, useState } from "react"; +import toast from "react-hot-toast"; + +import { api } from "@/services/api"; +import type { WagonTransferRequest } from "@/services/wagon.service"; + +import { outstandingOn, wagonTypeLabel, yardLabel } from "./wagon-transfer-ui"; + +/** Yard + wagon-type option lists, shared by both modals. */ +function useTransferOptions(enabled: boolean) { + const { data: yards = [] } = useQuery({ + ...api.routes.yards.queryOptions({ staleTime: 5 * 60_000 }), + enabled, + }); + const { data: wagonTypes = [] } = useQuery({ + ...api.wagonTypes.list.queryOptions(), + enabled, + }); + return { + yardOptions: yards.map((y) => ({ + value: y.id, + label: y.label ?? y.code ?? y.id, + })), + typeOptions: wagonTypes.map((t) => ({ + value: t.id, + label: [t.code, t.name].filter(Boolean).join(" · "), + })), + }; +} + +export interface TransferRequestFormModalProps { + opened: boolean; + onClose: () => void; + /** + * Carry-over from a request that could not be met in full: the destination, + * type, outstanding count and reason are pre-filled and the user only picks + * WHICH other yard to ask. Undefined for a plain new request. + */ + prefillFrom?: WagonTransferRequest | null; + onCreated?: () => void; +} + +/** + * File a wagon-transfer request. The count is deliberately NOT capped by what + * the source yard holds today — OCC fulfils in instalments, so asking for 50 + * where 20 sit is a normal request. + */ +export function TransferRequestFormModal({ + opened, + onClose, + prefillFrom, + onCreated, +}: TransferRequestFormModalProps) { + const { yardOptions, typeOptions } = useTransferOptions(opened); + const [fromYardId, setFromYardId] = useState(null); + const [toYardId, setToYardId] = useState(null); + const [wagonTypeId, setWagonTypeId] = useState(null); + const [quantity, setQuantity] = useState(1); + const [reason, setReason] = useState(""); + + // Re-seed on every open so a carry-over never leaks into the next request. + useEffect(() => { + if (!opened) return; + setFromYardId(null); // always chosen fresh — that is the point of a re-ask + setToYardId(prefillFrom?.toYardId ?? null); + setWagonTypeId(prefillFrom?.wagonTypeId ?? null); + setQuantity(prefillFrom ? outstandingOn(prefillFrom) : 1); + setReason(prefillFrom?.reason ?? ""); + }, [opened, prefillFrom]); + + const create = useMutation(api.wagonTransferRequests.create.mutationOptions()); + + const sameYard = Boolean(fromYardId && fromYardId === toYardId); + const valid = + Boolean(fromYardId && toYardId && wagonTypeId && reason.trim()) && + !sameYard && + Number(quantity) >= 1; + + const submit = async () => { + if (!valid) return; + try { + await create.mutateAsync({ + fromYardId: fromYardId!, + toYardId: toYardId!, + wagonTypeId: wagonTypeId!, + quantity: Number(quantity), + reason: reason.trim(), + }); + toast.success("Transfer request filed"); + onClose(); + onCreated?.(); + } catch { + // Server reason is surfaced by the http interceptor. + } + }; + + return ( + + + {prefillFrom ? ( + }> + {yardLabel(prefillFrom.fromYard)} supplied{" "} + {prefillFrom.fulfilledQuantity} of {prefillFrom.quantity}. Pick + another yard to cover the remaining {outstandingOn(prefillFrom)}. + + ) : null} + + +