From e5d2bb2f637367de5d5eb7d9b95657e58136c413 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 9 Jul 2026 12:51:12 +0000 Subject: [PATCH 01/29] fix: unused var --- .../edr-freight-web/portal/src/pages/contracts/ContractsList.tsx | 1 - 1 file changed, 1 deletion(-) 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 b6ba870d6..40a519840 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx @@ -33,7 +33,6 @@ import { X, } from "lucide-react"; -import { PROFILE_TYPE_LABELS } from "@/constants/profileMode"; import useAuth from "@/hooks/useAuth"; import { api } from "@/services/api"; import { ContractCustomerAction } from "@/components/customer-actions/ContractCustomerAction"; From fc2366f9e0af5d621bb6872c8eceb72c5c0bbc29 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 10 Jul 2026 06:42:20 +0000 Subject: [PATCH 02/29] feat(warehouses): prefill receive-to-warehouse from existing booking data The receive form now arrives filled with everything the booking already knows, instead of the operator re-typing it: - container numbers come from the per-unit records (booking_container_units) entered at booking time, falling back to the line-level aggregate - customs seal number prefilled from the units' seal numbers - net weight prefilled from the booking's declared cargo weight (tonnes) - driver signatory defaults to the arriving driver's name Existing prefills (owner, TIN, phone, booking ref, cargo, packaging, unit count, first-mile / customer truck + driver) unchanged; all fields stay editable where they were editable before. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../warehouses/warehouse-inventory.service.ts | 12 +++++++++++- .../components/warehouses/ReceiveInventoryModal.tsx | 7 +++++++ .../backoffice/src/types/warehouse.ts | 2 ++ 3 files changed, 20 insertions(+), 1 deletion(-) 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 2b98856d0..7b3d1c2fe 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 @@ -207,6 +207,7 @@ export interface EligibleBookingRow { customerTin: string | null; customerPhone: string | null; containerNumber: string | null; + sealNumbers: string | null; containerQuantity: number | null; containerPackagingType: string | null; cargoDescription: string | null; @@ -774,7 +775,8 @@ export class WarehouseInventoryService { company.name AS "customer", company.tin AS "customerTin", COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) AS "customerPhone", - bc.container_numbers AS "containerNumber", + COALESCE(bcu.unit_numbers, bc.container_numbers) AS "containerNumber", + bcu.seal_numbers AS "sealNumbers", bc.container_quantity AS "containerQuantity", bc.container_packaging_type AS "containerPackagingType", (NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL @@ -831,6 +833,14 @@ export class WarehouseInventoryService { LEFT JOIN freight.container_types container_type ON container_type.id = booking_container.container_type_id WHERE booking_container.booking_id = b.id AND booking_container.deleted_at IS NULL ) bc ON true + LEFT JOIN LATERAL ( + SELECT string_agg(NULLIF(unit.container_number, ''), ', ' ORDER BY unit.container_number) AS unit_numbers, + string_agg(DISTINCT NULLIF(unit.seal_number, ''), ', ') AS seal_numbers + FROM freight.booking_container_units unit + JOIN freight.booking_container line + ON line.id = unit.booking_container_id AND line.deleted_at IS NULL + WHERE line.booking_id = b.id AND unit.deleted_at IS NULL + ) bcu ON true LEFT JOIN LATERAL ( SELECT first_mile.id, first_mile.status, first_mile.vehicle_id FROM freight.first_mile first_mile diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx index a1b91decb..b55143c93 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -296,6 +296,10 @@ const truckEntranceFromBookings = (bookings: EligibleBooking[]): { const truckType = commonNonEmptyValue( bookings.map((booking) => booking.firstMileTruckType || booking.customerTruckType), ); + const customsSealNumber = commonNonEmptyValue(bookings.map((booking) => booking.sealNumbers)); + // Booking's declared cargo weight (tonnes) — the receive-time net until re-weighed. + const bookingWeight = bookings.length === 1 ? Number(bookings[0]?.weight ?? '') : NaN; + const netWeightKg: number | '' = Number.isFinite(bookingWeight) && bookingWeight > 0 ? bookingWeight : ''; const edrDigitalBookingId = bookings.length === 1 ? bookings[0]?.reference ?? bookings[0]?.id ?? '' @@ -321,9 +325,11 @@ const truckEntranceFromBookings = (bookings: EligibleBooking[]): { customerPhone, edrDigitalBookingId, assignedEquipmentNumber, + customsSealNumber, itemDescription, packagingType, unitCount, + netWeightKg, grossWeightKg: '', truckPlateNumber, trailerPlateNumber, @@ -331,6 +337,7 @@ const truckEntranceFromBookings = (bookings: EligibleBooking[]): { driverPhone, driverLicenseNumber, truckType, + driverSignatoryName: driverName, }, lockedFields: { ownerName: Boolean(ownerName), diff --git a/apps/edr-freight-web/backoffice/src/types/warehouse.ts b/apps/edr-freight-web/backoffice/src/types/warehouse.ts index 8a806201e..2802a0355 100644 --- a/apps/edr-freight-web/backoffice/src/types/warehouse.ts +++ b/apps/edr-freight-web/backoffice/src/types/warehouse.ts @@ -388,6 +388,8 @@ export interface EligibleBooking { customerTin: string | null; customerPhone: string | null; containerNumber: string | null; + /** Distinct seal numbers from the booking's container units, comma-joined. */ + sealNumbers: string | null; containerQuantity: number | null; containerPackagingType: string | null; cargoDescription: string | null; From a7b429544b05e7b9572e7f437469c24b9ea7aec1 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 10 Jul 2026 06:50:35 +0000 Subject: [PATCH 03/29] fix(warehouses): trim receive form to fields with a real source - remove Incoterms, HS codes, and Item code from the truck-entrance form: nothing in the booking captures them, so they were always hand-typed noise - remove the hand-typed "Warehouse code and location" input: the backend now stamps it from the warehouse/yard/zone the operator actually selected (WH / YARD / ZONE codes), so the GRN and notes always match reality - Declaration number and Item description widen to full rows Backend DTO keeps the optional fields for compatibility. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../warehouses/warehouse-inventory.service.ts | 9 +++- .../warehouses/ReceiveInventoryModal.tsx | 54 ++++--------------- 2 files changed, 17 insertions(+), 46 deletions(-) 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 7b3d1c2fe..92617db6d 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 @@ -891,11 +891,18 @@ export class WarehouseInventoryService { }> = []; await this.dataSource.transaction(async (manager) => { - await this.validateLocation(manager, { + const { warehouse, yard, zone } = await this.validateLocation(manager, { warehouseId: dto.warehouseId, yardId: dto.yardId, zoneId: dto.zoneId, }); + // The receive location is whatever the operator selected above — never a + // hand-typed string. Stamp it on the truck entrance for the GRN/notes. + if (dto.truckEntrance && !dto.truckEntrance.warehouseCodeLocation) { + dto.truckEntrance.warehouseCodeLocation = [warehouse.code, yard.code, zone.code] + .filter(Boolean) + .join(' / '); + } for (const bookingId of dto.bookingIds) { const skip = (reason: string) => { diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx index b55143c93..de41c1a82 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -150,9 +150,6 @@ interface TruckEntranceFormState { assignedEquipmentNumber: string; customsSealNumber: string; declarationNumber: string; - incoterms: string; - hsCodes: string; - itemCode: string; itemDescription: string; packagingType: string; unitCount: number | ''; @@ -162,7 +159,6 @@ interface TruckEntranceFormState { volumeDimensions: string; conditionAtReceipt: string; damagedRejectedQuantity: number | ''; - warehouseCodeLocation: string; driverName: string; driverPhone: string; driverLicenseNumber: string; @@ -205,9 +201,6 @@ const emptyTruckEntrance = (): TruckEntranceFormState => ({ assignedEquipmentNumber: '', customsSealNumber: '', declarationNumber: '', - incoterms: '', - hsCodes: '', - itemCode: '', itemDescription: '', packagingType: '', unitCount: '', @@ -217,7 +210,6 @@ const emptyTruckEntrance = (): TruckEntranceFormState => ({ volumeDimensions: '', conditionAtReceipt: '', damagedRejectedQuantity: '', - warehouseCodeLocation: '', driverName: '', driverPhone: '', driverLicenseNumber: '', @@ -239,9 +231,6 @@ const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayl assignedEquipmentNumber: form.assignedEquipmentNumber.trim() || undefined, customsSealNumber: form.customsSealNumber.trim() || undefined, declarationNumber: form.declarationNumber.trim() || undefined, - incoterms: form.incoterms.trim() || undefined, - hsCodes: form.hsCodes.trim() || undefined, - itemCode: form.itemCode.trim() || undefined, itemDescription: form.itemDescription.trim() || undefined, packagingType: form.packagingType.trim() || undefined, unitCount: form.unitCount === '' ? undefined : Number(form.unitCount), @@ -251,7 +240,6 @@ const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayl volumeDimensions: form.volumeDimensions.trim() || undefined, conditionAtReceipt: form.conditionAtReceipt.trim() || undefined, damagedRejectedQuantity: form.damagedRejectedQuantity === '' ? undefined : Number(form.damagedRejectedQuantity), - warehouseCodeLocation: form.warehouseCodeLocation.trim() || undefined, driverName: form.driverName.trim(), driverPhone: form.driverPhone.trim(), driverLicenseNumber: form.driverLicenseNumber.trim() || undefined, @@ -557,38 +545,19 @@ function TruckEntranceFields({ )} Customs and compliance - - onChange({ ...value, declarationNumber: e.currentTarget.value })} - /> - onChange({ ...value, incoterms: e.currentTarget.value })} - /> - onChange({ ...value, hsCodes: e.currentTarget.value })} + label="Declaration / Bill of Entry number" + value={value.declarationNumber} + onChange={(e) => onChange({ ...value, declarationNumber: e.currentTarget.value })} /> Physical cargo specifications - - onChange({ ...value, itemCode: e.currentTarget.value })} - /> - onChange({ ...value, itemDescription: e.currentTarget.value })} - /> - + onChange({ ...value, itemDescription: e.currentTarget.value })} + /> setSearch(e.target.value)} @@ -395,6 +399,20 @@ export default function TariffRatesPage() { )} +
+ + +

Flat fee per passenger (e.g., travel insurance)

+
+
+ {errors.passengers?.[index]?.passportIssueDate && ( +

{errors.passengers[index]?.passportIssueDate?.message}

+ )}
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 fa52aad32..183e8af01 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 @@ -22,6 +22,8 @@ import { } from "lucide-react"; import { format } from "date-fns"; import { formatTime, getTimePeriod } from "@/utils/format"; +import { formatFare } from "@/utils/fare-utils"; +import { useCurrencySymbol } from "@/lib/useCurrencies"; import { useState, useEffect } from "react"; export default function ResultsPage() { @@ -35,6 +37,8 @@ export default function ResultsPage() { const [outboundScheduleData, setOutboundScheduleData] = useState( () => useBookingStore.getState().outboundSchedule, ); + const [effectiveDepartureDate, setEffectiveDepartureDate] = useState(''); + const [effectiveReturnDate, setEffectiveReturnDate] = useState(''); const [classModal, setClassModal] = useState(null); const [promoData, setPromoData] = useState<{ code: string; @@ -84,6 +88,17 @@ export default function ResultsPage() { promoCode: searchParams.get("promoCode") || searchCriteria?.promoCode || "", }; + // Initialise effective dates from URL/store once searchData is stable + useEffect(() => { + if (searchData.date && !effectiveDepartureDate) setEffectiveDepartureDate(searchData.date); + if (searchData.returnDate && !effectiveReturnDate) setEffectiveReturnDate(searchData.returnDate); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchData.date, searchData.returnDate]); + + const nat = (searchData.nationality ?? '').toUpperCase(); + const displayCurrencyCode = nat === 'DJIBOUTIAN' ? 'DJF' : nat === 'ETHIOPIAN' ? 'ETB' : 'USD'; + const displayCurrencySymbol = useCurrencySymbol(displayCurrencyCode); + useEffect(() => { if (searchParams.get("origin")) { setSearchCriteria({ @@ -249,7 +264,7 @@ export default function ResultsPage() { const minFare = coachType?.classes.length ? Math.min(...coachType.classes.map((c) => c.baseFareMinor)) : 0; - const fareCurrency = "ETB"; + const fareCurrency = displayCurrencyCode; const hours = Math.floor((schedule.durationMinutes || 0) / 60); const minutes = (schedule.durationMinutes || 0) % 60; @@ -281,10 +296,21 @@ export default function ResultsPage() { coachTypes: schedule.coachTypes || [], }; + // Extract the actual date from the schedule (YYYY-MM-DD) + const scheduleDate = schedule.departureAt + ? schedule.departureAt.slice(0, 10) + : null; + // For round trip, store outbound and advance to inbound step if (isRoundTrip && isOutbound) { setOutboundScheduleData(scheduleData); setOutboundSchedule(scheduleData); + if (scheduleDate) { + setEffectiveDepartureDate(scheduleDate); + if (searchCriteria && scheduleDate !== searchCriteria.departureDate) { + setSearchCriteria({ ...searchCriteria, departureDate: scheduleDate }); + } + } setClassModal(null); setRoundTripStep("inbound"); window.scrollTo({ top: 0, behavior: "smooth" }); @@ -293,6 +319,12 @@ export default function ResultsPage() { // For round trip inbound, proceed with both schedules if (isRoundTrip && !isOutbound) { + if (scheduleDate) { + setEffectiveReturnDate(scheduleDate); + if (searchCriteria && scheduleDate !== searchCriteria.returnDate) { + setSearchCriteria({ ...searchCriteria, returnDate: scheduleDate }); + } + } // Mirror the outbound's coachTypes (fares) onto the inbound schedule so the // return seat selection page shows the same prices as the outbound leg. const inboundScheduleData = outboundScheduleData @@ -307,6 +339,12 @@ export default function ResultsPage() { setSelectedSchedule(outboundScheduleData); // Set primary as outbound } else { // For one-way + if (scheduleDate) { + setEffectiveDepartureDate(scheduleDate); + if (searchCriteria && scheduleDate !== searchCriteria.departureDate) { + setSearchCriteria({ ...searchCriteria, departureDate: scheduleDate }); + } + } setSelectedSchedule(scheduleData); } @@ -378,10 +416,10 @@ export default function ResultsPage() { selectedCoachType?.id === coachType.coachTypeId; const minPrice = coachType.classes.length ? Math.min( - ...coachType.classes.map((c: any) => c.baseFareMinor), + ...coachType.classes.map((c: any) => c.displayAmountMinor ?? c.baseFareMinor), ) : 0; - const coachCurrency = "ETB"; + const coachCurrency = displayCurrencySymbol; const CoachIcon = getCoachIcon(coachType.coachTypeName); const selectThisCoach = () => @@ -468,10 +506,7 @@ export default function ResultsPage() { : "text-gray-900 dark:text-white" }`} > - {(minPrice / 100).toFixed(2)} - - - {coachCurrency} + {formatFare(minPrice, coachCurrency)}
@@ -503,7 +538,7 @@ export default function ResultsPage() {
- {(cls.baseFareMinor / 100).toFixed(2)} + {((cls.displayAmountMinor ?? cls.baseFareMinor) / 100).toFixed(2)} {coachCurrency} @@ -581,11 +616,11 @@ export default function ResultsPage() { // Calculate lowest fare and display currency from coach types / faresByClass. // Prefer displayAmountMinor (passenger's own currency) over baseFareMinor (ETB internal). let lowestFare = null; - const displayCurrency = "ETB"; + const displayCurrency = displayCurrencySymbol; if (schedule.coachTypes?.length) { const allClasses = schedule.coachTypes.flatMap((ct) => ct.classes); const allFares = allClasses - .map((c) => c.baseFareMinor) + .map((c) => c.displayAmountMinor ?? c.baseFareMinor) .filter((f) => f > 0); lowestFare = allFares.length ? Math.min(...allFares) : null; } else if (schedule.faresByClass?.length) { @@ -715,9 +750,7 @@ export default function ResultsPage() { Starting from
- {lowestFare - ? `${displayCurrency} ${(lowestFare / 100).toFixed(2)}` - : "N/A"} + {lowestFare ? formatFare(lowestFare, displayCurrency) : "N/A"}
per adult @@ -1098,8 +1131,8 @@ export default function ResultsPage() {
- {searchData.date - ? format(new Date(searchData.date), "EEEE, MMMM d, yyyy") + {effectiveDepartureDate + ? format(new Date(`${effectiveDepartureDate}T00:00:00`), "EEEE, MMMM d, yyyy") : "Date not specified"}
@@ -1129,11 +1162,8 @@ export default function ResultsPage() { Select Outbound Journey

- {searchData.date - ? format( - new Date(searchData.date), - "EEEE, MMMM d, yyyy", - ) + {effectiveDepartureDate + ? format(new Date(`${effectiveDepartureDate}T00:00:00`), "EEEE, MMMM d, yyyy") : ""}

@@ -1182,6 +1212,9 @@ export default function ResultsPage() {

{outboundScheduleData.origin} →{" "} {outboundScheduleData.destination} + {outboundScheduleData.departureTime + ? ` · ${format(new Date(outboundScheduleData.departureTime), "EEE, MMM d, yyyy")}` + : ""} {outboundScheduleData.selectedSeatClassName ? ` · ${outboundScheduleData.selectedSeatClassName}` : ""} @@ -1213,11 +1246,8 @@ export default function ResultsPage() { Select Return Journey

- {searchData.returnDate - ? format( - new Date(searchData.returnDate), - "EEEE, MMMM d, yyyy", - ) + {effectiveReturnDate + ? format(new Date(`${effectiveReturnDate}T00:00:00`), "EEEE, MMMM d, yyyy") : ""}

diff --git a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx index 57b098dc1..5f4b41c72 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx @@ -10,6 +10,7 @@ import { formatTime, getTimePeriod } from '@/utils/format'; import { useState, useEffect, useCallback } from 'react'; import { ChevronLeft } from 'lucide-react'; import { isChild, isFirstChild, formatFare } from '@/utils/fare-utils'; +import { useCurrencySymbol } from '@/lib/useCurrencies'; // Helper function to decode JWT token and extract passengerId function getPassengerIdFromToken(token: string): string | null { @@ -56,9 +57,10 @@ export default function ReviewPage() { const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP'; - // Prefer the currency already stored on the selected schedule (set from search results). - // Fall back to deriving from nationality so the review page is never left with a stale value. - const displayCurrency = 'ETB'; + // Derive display currency from nationality so fares show in the passenger's home currency. + const nat = (searchCriteria?.nationality ?? '').toUpperCase(); + const displayCurrencyCode = nat === 'DJIBOUTIAN' ? 'DJF' : nat === 'ETHIOPIAN' ? 'ETB' : 'USD'; + const displayCurrencySymbol = useCurrencySymbol(displayCurrencyCode); useEffect(() => { if (!seatHold?.expiresAt) return; @@ -329,7 +331,7 @@ export default function ReviewPage() { destinationStationId: searchCriteria.destinationStationId, seatClassId: seatClassId, bookingType: isRoundTrip ? 'ROUND_TRIP' : 'ONE_WAY', - displayCurrency: displayCurrency, + displayCurrency: displayCurrencyCode, passengers: bookingPassengers.map((p) => { const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian'; const seatId = isRoundTrip ? (p as any).outboundSeatId : p.seatId; @@ -386,7 +388,7 @@ export default function ReviewPage() { destinationStationId: searchCriteria.destinationStationId, seatClassId: seatClassId, bookingType: isRoundTrip ? 'ROUND_TRIP' : 'ONE_WAY', - displayCurrency: displayCurrency, + displayCurrency: displayCurrencyCode, passengers: guestBookingPassengers.map(p => { const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian'; const seatId = isRoundTrip ? (p as any).outboundSeatId : p.seatId; @@ -510,7 +512,7 @@ export default function ReviewPage() { originStationId, destinationStationId, passengers: passengersParam, - displayCurrency, + displayCurrency: displayCurrencyCode, ...(searchCriteria?.promoCode ? { promoCode: searchCriteria.promoCode } : {}), }); @@ -518,7 +520,7 @@ export default function ReviewPage() { setFareBreakdown(result); } catch (err) { } - }, [isPackageBooking, passengers, selectedSchedule, outboundSchedule, isRoundTrip, searchCriteria, displayCurrency]); + }, [isPackageBooking, passengers, selectedSchedule, outboundSchedule, isRoundTrip, searchCriteria, displayCurrencyCode]); useEffect(() => { if (!searchCriteria?.originStationId || !searchCriteria?.destinationStationId) return; @@ -589,7 +591,7 @@ export default function ReviewPage() { )} - {formatFare(passengerTotal, displayCurrency)} + {formatFare(passengerTotal, displayCurrencySymbol)} {/* Round-trip: show outbound + inbound breakdown */} @@ -597,11 +599,11 @@ export default function ReviewPage() {
↗ Outbound - {outboundFare != null ? formatFare(outboundFare, displayCurrency) : '—'} + {outboundFare != null ? formatFare(outboundFare, displayCurrencySymbol) : '—'}
↙ Return - {inboundFare != null ? formatFare(inboundFare, displayCurrency) : '—'} + {inboundFare != null ? formatFare(inboundFare, displayCurrencySymbol) : '—'}
)} @@ -610,7 +612,7 @@ export default function ReviewPage() { })}
Total - {displayCurrency} {(total / 100).toFixed(2)} + {formatFare(total, displayCurrencySymbol)}
{/* Action buttons — visible only in desktop sidebar */} @@ -886,49 +888,51 @@ export default function ReviewPage() {
{passengers.map((p, i) => (
-
-
-

{p.name}

+
+ {/* Left — passenger info */} +
+

{p.name}

{p.dateOfBirth ? format(new Date(p.dateOfBirth), 'PP') : 'N/A'} • {p.nationality}

+ {/* Right — seat details */} + {isRoundTrip ? ( +
+
+

Outbound

+

+ {(p as any).outboundCoachNumber && {(p as any).outboundCoachNumber} — } + {(p as any).outboundSeatId ? (seatDetails[`outbound-${(p as any).outboundSeatId}`] || 'Loading...') : (isPkgFreeChild(i) || (!packageId && isChild(p) && isFirstChild(passengers, i)) ? '—' : 'Auto-assign')} +

+ {(p as any).outboundSeatId && ( +

{formatSeatClass(outboundSchedule)}

+ )} +
+
+

Return

+

+ {(p as any).inboundCoachNumber && {(p as any).inboundCoachNumber} — } + {(p as any).inboundSeatId ? (seatDetails[`inbound-${(p as any).inboundSeatId}`] || 'Loading...') : (isPkgFreeChild(i) || (!packageId && isChild(p) && isFirstChild(passengers, i)) ? '—' : 'Auto-assign')} +

+ {(p as any).inboundSeatId && ( +

{formatSeatClass(inboundSchedule)}

+ )} +
+
+ ) : ( +
+

Seat

+

+ {p.coachNumber && {p.coachNumber} — } + {p.seatId ? (seatDetails[p.seatId] || 'Loading...') : (isPkgFreeChild(i) || (!packageId && isChild(p) && isFirstChild(passengers, i)) ? '—' : 'Auto-assign')} +

+ {p.seatId && ( +

{formatSeatClass(selectedSchedule)}

+ )} +
+ )}
- {isRoundTrip ? ( -
-
-

Outbound Seat

-

- {(p as any).outboundCoachNumber && {(p as any).outboundCoachNumber} — } - {(p as any).outboundSeatId ? (seatDetails[`outbound-${(p as any).outboundSeatId}`] || 'Loading...') : (isPkgFreeChild(i) || (!packageId && isChild(p) && isFirstChild(passengers, i)) ? '' : 'Auto-assign')} -

- {(p as any).outboundSeatId && ( -

{formatSeatClass(outboundSchedule)}

- )} -
-
-

Return Seat

-

- {(p as any).inboundCoachNumber && {(p as any).inboundCoachNumber} —} - {(p as any).inboundSeatId ? (seatDetails[`inbound-${(p as any).inboundSeatId}`] || 'Loading...') : (isPkgFreeChild(i) || (!packageId && isChild(p) && isFirstChild(passengers, i)) ? '' : 'Auto-assign')} -

- {(p as any).inboundSeatId && ( -

{formatSeatClass(inboundSchedule)}

- )} -
-
- ) : ( -
-

Seat

-

- {p.coachNumber && {p.coachNumber} — } - {p.seatId ? (seatDetails[p.seatId] || 'Loading...') : (isPkgFreeChild(i) || (!packageId && isChild(p) && isFirstChild(passengers, i)) ? '' : 'Auto-assign')} -

- {p.seatId && ( -

{formatSeatClass(selectedSchedule)}

- )} -
- )}
))}
@@ -956,7 +960,7 @@ export default function ReviewPage() {
Total - {displayCurrency} {(total / 100).toFixed(2)} + {formatFare(total, displayCurrencySymbol)}
{createBookingMutation.isError && (

diff --git a/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx index 574b08487..e2e75f1d5 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx @@ -17,8 +17,6 @@ import { Search, Users, ChevronDown, - Gift, - Check, X, ChevronLeft, Clock, @@ -54,7 +52,6 @@ const searchSchema = z nationality: z.enum(["ETHIOPIAN", "DJIBOUTIAN", "OTHER"], { errorMap: () => ({ message: "Please select your nationality" }), }), - promoCode: z.string().optional(), }) .refine( (d) => { @@ -373,8 +370,7 @@ function PassengerModal({ onClick={onClose} className="w-full py-3.5 bg-[rgb(20,113,76)] text-white font-bold text-sm rounded-xl" > - Done — {adultCount + childCount} Passenger - {adultCount + childCount !== 1 ? "s" : ""} + Continue

@@ -551,13 +547,6 @@ export default function SearchPage() { const dark = useDarkMode(); const [passengerModalOpen, setPassengerModalOpen] = useState(false); - const [promoVisible, setPromoVisible] = useState(false); - const [promoCode, setPromoCode] = useState(""); - const [promoValidation, setPromoValidation] = useState<{ - valid: boolean; - message: string; - } | null>(null); - const [promoLoading, setPromoLoading] = useState(false); const [swapping, setSwapping] = useState(false); const [stationModal, setStationModal] = useState< "origin" | "destination" | null @@ -615,7 +604,6 @@ export default function SearchPage() { // selecting it. nationality: "" as any, departureDate: "", - promoCode: "", }, }); @@ -693,33 +681,6 @@ export default function SearchPage() { }, 300); }; - const handleValidatePromo = async () => { - if (!promoCode.trim()) return setPromoValidation(null); - setPromoLoading(true); - try { - const res = (await apiClient.post("/promos/validate", { - code: promoCode, - })) as any; - const valid = res.applicable || res.valid; - setPromoValidation({ - valid, - message: - res.message || (valid ? "Promo applied!" : "Invalid promo code"), - }); - if (valid) setValue("promoCode", promoCode); - else setPromoCode(""); - } catch (err: any) { - setPromoValidation({ - valid: false, - message: - err?.response?.data?.message || "Promo code is invalid or expired", - }); - setPromoCode(""); - } finally { - setPromoLoading(false); - } - }; - const onSubmit = (data: SearchForm) => { setHasInteracted(true); // Clear previous booking selections and search cache before starting a new search @@ -738,7 +699,6 @@ export default function SearchPage() { nationality: data.nationality, ...(data.tripType === "ROUND_TRIP" && data.returnDate && { returnDate: data.returnDate }), - ...(data.promoCode && { promoCode: data.promoCode }), }); router.push(`/booking/results?${params}`); }; @@ -828,16 +788,7 @@ export default function SearchPage() { )} {/* ── 90vh hero with banner image ── */} - {/* Round trip stacks an extra Return Date field into the widget on mobile, which grows - upward from its bottom-anchored position — give the hero extra height there so the - widget's top edge doesn't creep up into the sticky header. */} -
+
{/* Background image with zoom - fully isolated */}
- {totalPassengers} Pax + {totalPassengers} {totalPassengers === 1 ? "Passenger" : "Passengers"} {nationalityFlag(watch("nationality")) ? ` · ${nationalityFlag(watch("nationality"))}` - : " · Select nationality"} + : " · Nationality"} @@ -1204,10 +1155,10 @@ export default function SearchPage() { > - {totalPassengers} Pax + {totalPassengers} {totalPassengers === 1 ? "Passenger" : "Passengers"} {nationalityFlag(watch("nationality")) ? ` · ${nationalityFlag(watch("nationality"))}` - : " · Select nationality"} + : " · Nationality"} @@ -1226,340 +1177,129 @@ export default function SearchPage() {
) : ( - // ROUND TRIP: Two row layout -
- {/* Row 1: From, Swap, To, Departure Date, Return Date */} -
- {/* From */} -
- - { - setHasInteracted(true); - setValue("originStationId", s.id); - if (s.id) saveRecent(s.id); - clearErrors("originStationId"); - clearErrors("destinationStationId"); - }} - error={ - hasInteracted - ? errors.originStationId?.message - : undefined - } - onOpen={scrollWidgetIntoView} - /> - {hasInteracted && errors.originStationId && ( -

- {errors.originStationId.message} -

- )} -
- {/* Swap */} + // ROUND TRIP: Single row — From · Swap · To · Departure · Return · Passengers · Search +
+ {/* From */} +
+ + { + setHasInteracted(true); + setValue("originStationId", s.id); + if (s.id) saveRecent(s.id); + clearErrors("originStationId"); + clearErrors("destinationStationId"); + }} + error={hasInteracted ? errors.originStationId?.message : undefined} + onOpen={scrollWidgetIntoView} + /> + {hasInteracted && errors.originStationId && ( +

{errors.originStationId.message}

+ )} +
+ {/* Swap */} + + {/* To */} +
+ + { + setHasInteracted(true); + setValue("destinationStationId", s.id); + if (s.id) saveRecent(s.id); + clearErrors("destinationStationId"); + }} + error={hasInteracted ? errors.destinationStationId?.message : undefined} + onOpen={scrollWidgetIntoView} + /> + {hasInteracted && errors.destinationStationId && ( +

{errors.destinationStationId.message}

+ )} +
+ {/* Departure Date */} +
+ + { + setValue("departureDate", `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`); + trigger("departureDate"); + trigger("returnDate"); + }} + minDate={new Date()} + placeholder="Select date" + /> + {errors.departureDate && ( +

{errors.departureDate.message}

+ )} +
+ {/* Return Date */} +
+ + { + setValue("returnDate", `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`); + trigger("returnDate"); + }} + minDate={departureDate ? new Date(departureDate + "T00:00:00") : new Date()} + placeholder="Select date" + /> + {errors.returnDate && ( +

{errors.returnDate.message}

+ )} +
+ {/* Passengers */} +
+ - {/* To */} -
- - { - setHasInteracted(true); - setValue("destinationStationId", s.id); - if (s.id) saveRecent(s.id); - clearErrors("destinationStationId"); - }} - error={ - hasInteracted - ? errors.destinationStationId?.message - : undefined - } - onOpen={scrollWidgetIntoView} - /> - {hasInteracted && errors.destinationStationId && ( -

- {errors.destinationStationId.message} -

- )} -
- {/* Divider */} -
- {/* Departure Date */} -
- -
- { - setValue( - "departureDate", - `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`, - ); - trigger("departureDate"); - trigger("returnDate"); - }} - minDate={new Date()} - placeholder="Select date" - /> -
- {errors.departureDate && ( -

- {errors.departureDate.message} -

- )} -
- {/* Return Date */} -
- -
- { - setValue( - "returnDate", - `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`, - ); - trigger("returnDate"); - }} - minDate={ - departureDate - ? new Date(departureDate + "T00:00:00") - : new Date() - } - placeholder="Select date" - /> -
- {errors.returnDate && ( -

- {errors.returnDate.message} -

- )} -
-
- - {/* Row 2: Promo, Passengers, Search */} -
- {/* Promo Code */} -
- - {!promoVisible ? ( - - ) : ( -
-
-
- - { - setPromoCode( - e.target.value.toUpperCase(), - ); - if (promoValidation) - setPromoValidation(null); - }} - placeholder="Enter promo code" - onKeyDown={(e) => - e.key === "Enter" && - (e.preventDefault(), - handleValidatePromo()) - } - className="w-full pl-9 pr-3 py-3.5 border-2 border-gray-200 dark:border-gray-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/30 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400" - autoFocus - /> -
- - -
- {promoValidation && ( -
- {promoValidation.valid && ( - - )} - {promoValidation.message} -
- )} -
- )} -
- {/* Divider */} -
- {/* Pax + Nationality */} -
- - - {showNationalityError && ( -

{errors.nationality?.message}

- )} -
- {/* Search Button */} -
- - -
+ {showNationalityError && ( +

{errors.nationality?.message}

+ )}
+ {/* Search */} +
)}
- {/* Promo - Only visible in ONE WAY mode on desktop */} - {tripType === "ONE_WAY" && ( -
- {!promoVisible ? ( - - ) : ( -
-
-
- - { - setPromoCode(e.target.value.toUpperCase()); - if (promoValidation) setPromoValidation(null); - }} - placeholder="Enter promo code" - onKeyDown={(e) => - e.key === "Enter" && - (e.preventDefault(), handleValidatePromo()) - } - className="w-full pl-9 pr-3 py-2.5 border-2 border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/30 text-sm bg-white placeholder-gray-400" - autoFocus - /> -
- - -
- {promoValidation && ( -
- {promoValidation.valid && ( - - )} - {promoValidation.message} -
- )} -
- )} -
- )}
diff --git a/apps/edr-passenger-web/portal/src/lib/useCurrencies.ts b/apps/edr-passenger-web/portal/src/lib/useCurrencies.ts new file mode 100644 index 000000000..2baa75973 --- /dev/null +++ b/apps/edr-passenger-web/portal/src/lib/useCurrencies.ts @@ -0,0 +1,28 @@ +import { useQuery } from '@tanstack/react-query'; +import { apiClient } from './api-client'; + +interface Currency { + code: string; + symbol: string; + name: string; +} + +const FALLBACK_SYMBOLS: Record = { + ETB: 'Br', + DJF: 'Fdj', + USD: '$', +}; + +export function useCurrencies() { + return useQuery({ + queryKey: ['currencies'], + queryFn: () => apiClient.get('/currencies'), + staleTime: 5 * 60 * 1000, + }); +} + +export function useCurrencySymbol(code: string): string { + const { data, isLoading, isError } = useCurrencies(); + if (isLoading || isError || !data) return FALLBACK_SYMBOLS[code] ?? code; + return data.find(c => c.code === code)?.symbol ?? FALLBACK_SYMBOLS[code] ?? code; +} diff --git a/apps/edr-passenger-web/portal/src/utils/fare-utils.ts b/apps/edr-passenger-web/portal/src/utils/fare-utils.ts index 22fdaff31..3206c0a49 100644 --- a/apps/edr-passenger-web/portal/src/utils/fare-utils.ts +++ b/apps/edr-passenger-web/portal/src/utils/fare-utils.ts @@ -83,8 +83,8 @@ export function getPassengerCategory(passenger: PassengerWithAge): 'ADULT' | 'CH /** * Format fare amount for display */ -export function formatFare(amountMinor: number, currency: string = 'ETB'): string { - return `${currency} ${(amountMinor / 100).toFixed(2)}`; +export function formatFare(amountMinor: number, currencyOrSymbol: string = 'ETB'): string { + return `${currencyOrSymbol} ${(amountMinor / 100).toFixed(2)}`; } /** From 78cb1ac5d3757d7040aaa83d90699ec4cbed5f4c Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 10 Jul 2026 08:18:37 +0000 Subject: [PATCH 06/29] feat: add poa and poa delegation file to onboarding --- .../modules/companies/companies.service.ts | 66 ++++++++++++- .../onboarding-requirements-response.dto.ts | 17 ++++ .../src/seed/file-upload-settings.seeder.ts | 24 +++++ .../onboarding/OnboardingWizardDialog.tsx | 14 ++- .../src/pages/accounts/CompanyProfileForm.tsx | 95 +++++++++++++++++-- .../accounts/companyProfileForm/schema.ts | 48 +++++++++- .../portal/src/services/companies.service.ts | 10 ++ 7 files changed, 256 insertions(+), 18 deletions(-) diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index e31851aef..ab2711dfb 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -54,6 +54,23 @@ const LICENSE_CODE = "business_license"; /** Code for a license file staged in an open change request (not yet live). */ const LICENSE_PENDING_CODE = "business_license_pending"; +/** Mirrors the field seeded in seed/file-upload-settings.seeder.ts. */ +const POA_DELEGATION_FILE_KEY = "poa_delegation_letter"; +/** company.attributes keys that together mean "a PoA was entered". */ +const POA_ATTRIBUTES = [ + "poaName", + "poaPhone", + "poaEmail", + "poaLocation", + "poaAddress", +] as const; +/** Mandatory once the company operates as a freight forwarder. */ +const REQUIRED_POA_FIELDS: { key: string; label: string }[] = [ + { key: "poaName", label: "PoA name" }, + { key: "poaEmail", label: "PoA email" }, + { key: "poaPhone", label: "PoA phone" }, +]; + export interface UserIdentity { userId: string; firstName: string; @@ -1240,6 +1257,29 @@ export class CompaniesService { ); const missingLicenses = licenseProfiles.filter((p) => !p.uploaded); + // 4. Power of Attorney. Optional in general, but a freight forwarder acts on + // other companies' behalf so its PoA is mandatory. Either way, a PoA that + // has been entered must be evidenced by the delegation letter. + const poaRequired = (company.companyProfiles ?? []).some( + (p) => p.type === ProfileType.freightForwarder, + ); + const poaProvided = POA_ATTRIBUTES.some((k) => + (company.attributes?.[k] as string | undefined)?.trim(), + ); + const missingPoaFields = poaRequired + ? REQUIRED_POA_FIELDS.filter( + (f) => !(company.attributes?.[f.key] as string | undefined)?.trim(), + ) + : []; + // Only gate on the letter once the document set actually carries the field. + const delegationField = (setting?.fields ?? []).find( + (f) => f.fileKey === POA_DELEGATION_FILE_KEY, + ); + const missingDelegation = + Boolean(delegationField) && + (poaRequired || poaProvided) && + !uploadedCodes.has(POA_DELEGATION_FILE_KEY); + const outstanding = [ ...missingInfo.map((f) => `Add your ${f.label.toLowerCase()}`), ...missingDocs.map((d) => `Upload your ${d.fileLabel}`), @@ -1247,18 +1287,31 @@ export class CompaniesService { (p) => `Upload a business license for your ${p.type.replace(/_/g, " ")} profile`, ), + ...missingPoaFields.map((f) => `Add your ${f.label.toLowerCase()}`), + ...(missingDelegation + ? ["Upload the delegation letter for your Power of Attorney"] + : []), ]; // Progress spans every required item the user has to satisfy: company-info - // fields, required documents and one license per operational profile. + // fields, required documents, one license per operational profile, and the + // PoA details/letter whenever those are mandatory. const requiredDocCount = documents.filter((d) => d.isRequired).length; + const poaItemCount = + (poaRequired ? REQUIRED_POA_FIELDS.length : 0) + + (delegationField && (poaRequired || poaProvided) ? 1 : 0); const total = this.REQUIRED_COMPANY_INFO.length + requiredDocCount + - licenseProfiles.length; + licenseProfiles.length + + poaItemCount; const completed = total - - (missingInfo.length + missingDocs.length + missingLicenses.length); + (missingInfo.length + + missingDocs.length + + missingLicenses.length + + missingPoaFields.length + + (missingDelegation ? 1 : 0)); return new OnboardingRequirementsResponseDto({ documentSettingCode, @@ -1266,6 +1319,13 @@ export class CompaniesService { companyInfo: { complete: missingInfo.length === 0, missingFields: missingInfo }, documents, licenseProfiles, + poa: { + required: poaRequired, + provided: poaProvided, + delegationLetterUploaded: uploadedCodes.has(POA_DELEGATION_FILE_KEY), + missingFields: missingPoaFields, + complete: missingPoaFields.length === 0 && !missingDelegation, + }, progress: { completed, total }, isComplete: outstanding.length === 0, onboardingCompleted: profile.onboardingCompleted, diff --git a/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts index 92f9fa513..da908a177 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/onboarding-requirements-response.dto.ts @@ -35,6 +35,19 @@ export interface OnboardingLicenseProfile { uploaded: boolean; } +export interface OnboardingPoaState { + /** True when the company operates as a freight forwarder — PoA is mandatory. */ + required: boolean; + /** True once any PoA detail has been entered. */ + provided: boolean; + /** True when the delegation letter is stored for the company. */ + delegationLetterUploaded: boolean; + /** PoA details still missing (only populated when `required`). */ + missingFields: OnboardingInfoField[]; + /** False while the PoA step still owes details or a delegation letter. */ + complete: boolean; +} + export class OnboardingRequirementsResponseDto { /** Resolved document setting code (by nationality) the docs were drawn from. */ documentSettingCode: string; @@ -52,6 +65,9 @@ export class OnboardingRequirementsResponseDto { /** Per-operational-profile business-license requirements. */ licenseProfiles: OnboardingLicenseProfile[]; + /** Power of Attorney state, so the wizard needn't re-derive the rule. */ + poa: OnboardingPoaState; + /** Overall setup progress across fields + documents + licenses. */ progress: { completed: number; total: number }; @@ -70,6 +86,7 @@ export class OnboardingRequirementsResponseDto { this.companyInfo = init.companyInfo; this.documents = init.documents; this.licenseProfiles = init.licenseProfiles; + this.poa = init.poa; this.progress = init.progress; this.isComplete = init.isComplete; this.onboardingCompleted = init.onboardingCompleted; diff --git a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts index 99dae5974..13759ddb3 100644 --- a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts +++ b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts @@ -18,6 +18,28 @@ interface OnboardingField { const DOC_EXTENSIONS = ["pdf", "jpg", "jpeg", "png"]; +/** fileKey of the delegation letter attached to the Power of Attorney step. */ +export const POA_DELEGATION_FILE_KEY = "poa_delegation_letter"; + +/** + * Seeded as optional: the delegation letter is only mandatory once a PoA has + * been entered, or when the company operates as a freight forwarder. That rule + * spans form fields as well as files, so it lives in the onboarding gate + * (companies.service.getOnboardingRequirements) rather than in `isRequired`. + */ +const poaDelegationField = (displayOrder: number): OnboardingField => ({ + fileKey: POA_DELEGATION_FILE_KEY, + fileLabel: "PoA Delegation Letter", + helpText: + "Signed letter in which the General Manager delegates the representative named above.", + isRequired: false, + isMultiple: false, + maxFiles: 1, + allowedExtensions: DOC_EXTENSIONS, + maxSizeMb: 10, + displayOrder, +}); + /** Documents required from an Ethiopian company at onboarding. */ const ETHIOPIAN_ONBOARDING_FIELDS: OnboardingField[] = [ { @@ -54,6 +76,7 @@ const ETHIOPIAN_ONBOARDING_FIELDS: OnboardingField[] = [ maxSizeMb: 10, displayOrder: 3, }, + poaDelegationField(4), ]; /** Documents required from a Foreign company at onboarding. */ @@ -102,6 +125,7 @@ const FOREIGN_ONBOARDING_FIELDS: OnboardingField[] = [ maxSizeMb: 10, displayOrder: 4, }, + poaDelegationField(5), ]; /** Legacy combined set, kept for the older per-company-type codes. */ diff --git a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx index 849ba50c5..7dbc6d7c4 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx @@ -392,11 +392,17 @@ export default function OnboardingWizardDialog({ const requiredDocsMissing = requirementDocuments.some( (d) => d.isRequired && !d.uploaded, ); + // The PoA gets the same treatment: a resumed draft that predates the + // delegation-letter requirement (or a forwarder whose PoA is blank) must land + // back on the PoA step, where both the details and the letter are entered. + const poaIncomplete = requirementsQuery.data?.poa?.complete === false; + // Each unmet requirement lowers the ceiling; resume never moves forward. + let ceiling = FORM_STEPS.length - 1; + if (requiredDocsMissing) + ceiling = Math.min(ceiling, FORM_STEPS.indexOf("documents")); + if (poaIncomplete) ceiling = Math.min(ceiling, FORM_STEPS.indexOf("poa")); const effectiveResumeStep: FormStep = - requiredDocsMissing && - FORM_STEPS.indexOf(resumeFormStep) > FORM_STEPS.indexOf("documents") - ? "documents" - : resumeFormStep; + FORM_STEPS[Math.min(Math.max(FORM_STEPS.indexOf(resumeFormStep), 0), ceiling)]; const formProps = { documentSettingCode: resolvedDocumentSettingCode, diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index abfb61551..cadb102b4 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -12,7 +12,7 @@ import { import { zodResolver } from "@hookform/resolvers/zod"; import { useQuery } from "@tanstack/react-query"; import { AlertCircle, ArrowLeft, ArrowRight } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useForm } from "react-hook-form"; import type { AuthUser } from "@/types/auth"; @@ -28,9 +28,11 @@ import RoleLicenseStep, { } from "@/components/onboarding/RoleLicenseStep"; import ETradeInfo from "@/components/onboarding/ETradeInfo"; import { + buildOnboardingSchema, type CompanyStep, type FormData, - onboardingSchema, + hasPoaDetails, + POA_DELEGATION_FILE_KEY, stepFields, } from "./companyProfileForm/schema"; import { @@ -155,6 +157,12 @@ export default function CompanyProfileForm({ }), ); + // A freight forwarder signs on other companies' behalf, so its Power of + // Attorney (details + delegation letter) is mandatory rather than optional. + const requirePoa = (roleProfiles ?? []).some( + (p) => p.type === "freight_forwarder", + ); + const { register, control, @@ -164,7 +172,7 @@ export default function CompanyProfileForm({ setValue, formState: { errors }, } = useForm({ - resolver: zodResolver(onboardingSchema), + resolver: zodResolver(buildOnboardingSchema(requirePoa)), defaultValues: { companyName: "", companyEmail: "", @@ -354,7 +362,34 @@ export default function CompanyProfileForm({ } }; - const hasDocuments = Boolean(uploadSetting?.fields?.length); + // The delegation letter is seeded into the same nationality document set as + // the rest, but belongs on the PoA step next to the details it evidences — + // so it's split out here and the Documents step renders the remainder. Both + // halves share `documentFiles`, so the existing bulk upload still carries it. + const poaDocumentField = uploadSetting?.fields?.find( + (f) => f.fileKey === POA_DELEGATION_FILE_KEY, + ); + const documentsSetting = useMemo( + () => + uploadSetting + ? { + ...uploadSetting, + fields: uploadSetting.fields.filter( + (f) => f.fileKey !== POA_DELEGATION_FILE_KEY, + ), + } + : undefined, + [uploadSetting], + ); + const poaDocumentSetting = useMemo( + () => + uploadSetting && poaDocumentField + ? { ...uploadSetting, fields: [poaDocumentField] } + : undefined, + [uploadSetting, poaDocumentField], + ); + + const hasDocuments = Boolean(documentsSetting?.fields?.length); // Hard verification for the documents step: required company-level // documents and a business license per operational profile must both be @@ -368,7 +403,7 @@ export default function CompanyProfileForm({ const validateRequiredDocuments = (): Record => { const errs: Record = {}; - for (const field of uploadSetting?.fields ?? []) { + for (const field of documentsSetting?.fields ?? []) { const min = getMinFiles(field); if (min <= 0) continue; if ((uploadedDocumentKeys ?? []).includes(field.fileKey)) continue; @@ -447,6 +482,20 @@ export default function CompanyProfileForm({ ]; const currentIdx = stepOrder.indexOf(step); + // The delegation letter is what proves the representative was actually + // delegated, so it's required the moment a PoA exists — and unconditionally + // for a freight forwarder, whose PoA itself is mandatory. Skipped entirely + // when the document set predates the field (seeder not yet re-run). + const poaProvided = hasPoaDetails(watch()); + const delegationRequired = + Boolean(poaDocumentField) && (requirePoa || poaProvided); + const delegationPresent = + (uploadedDocumentKeys ?? []).includes(POA_DELEGATION_FILE_KEY) || + (() => { + const v = documentFiles[POA_DELEGATION_FILE_KEY]; + return Array.isArray(v) ? v.length > 0 : v != null; + })(); + /** Validate + persist the current step, returning whether we may advance. */ const saveCurrentStep = async (): Promise => { setSaveError(null); @@ -498,6 +547,20 @@ export default function CompanyProfileForm({ handleSubmit((data) => onSubmit(buildPayload(data, user)))(); return; } + // The PoA step also gates on a file, which lives outside the form state. + if (step === "poa" && delegationRequired && !delegationPresent) { + setDocumentFieldErrors({ + [POA_DELEGATION_FILE_KEY]: "Delegation letter is required", + }); + setSaveError( + requirePoa + ? "Freight forwarders must provide Power of Attorney details and a delegation letter." + : "Upload the delegation letter for the Power of Attorney you entered, or clear the PoA details to skip.", + ); + // Fall through to validate the text fields too, so every problem shows at once. + await trigger(stepFields.poa); + return; + } // Field steps validate + save before advancing. const ok = await saveCurrentStep(); if (!ok) return; @@ -743,8 +806,9 @@ export default function CompanyProfileForm({ {step === "poa" && ( <> - Power of Attorney details are optional. Fill them in if you have - them, or skip to continue. + {requirePoa + ? "As a freight forwarder you act on other companies' behalf, so Power of Attorney details and a delegation letter are required." + : "Power of Attorney details are optional. Fill them in if you have them, or skip to continue. If you do enter a representative, upload the delegation letter authorising them."} {watch("contactPersonName") && ( + + {poaDocumentSetting && ( + <> + + + + )} )} @@ -797,13 +874,13 @@ export default function CompanyProfileForm({ - ) : !uploadSetting ? ( + ) : !documentsSetting ? ( No document requirements found for your account type. ) : ( !v || isValidPhone(v), "Enter a valid phone number"), poaAddress: z.string().optional(), - poaEmail: z.string().optional(), + poaEmail: z + .string() + .optional() + .refine( + (v) => !v || z.string().email().safeParse(v).success, + "Invalid email address", + ), poaLocation: z.string().optional(), }); export type FormData = z.infer; +/** fileKey of the delegation letter uploaded on the Power of Attorney step. */ +export const POA_DELEGATION_FILE_KEY = "poa_delegation_letter"; + +export const POA_FIELDS = [ + "poaName", + "poaPhone", + "poaEmail", + "poaLocation", + "poaAddress", +] as const satisfies readonly (keyof FormData)[]; + +/** True once the customer has entered any Power of Attorney detail. */ +export const hasPoaDetails = (d: Partial) => + POA_FIELDS.some((f) => d[f]?.trim()); + +/** + * A freight forwarder acts on other companies' behalf, so its PoA is mandatory + * rather than optional. Everyone else keeps the optional PoA — but once they + * start filling it in, the identifying fields have to be complete (the + * delegation-letter upload is enforced alongside this, in CompanyProfileForm, + * since files live outside the form state). + */ +export function buildOnboardingSchema(requirePoa: boolean) { + if (!requirePoa) return onboardingSchema; + return onboardingSchema.superRefine((d, ctx) => { + const required: [keyof FormData, string][] = [ + ["poaName", "PoA name is required for freight forwarders"], + ["poaEmail", "PoA email is required for freight forwarders"], + ["poaPhone", "PoA phone is required for freight forwarders"], + ]; + for (const [path, message] of required) { + if (!d[path]?.trim()) { + ctx.addIssue({ code: z.ZodIssueCode.custom, path: [path], message }); + } + } + }); +} + export const stepFields: Record = { company: [ "companyName", @@ -102,7 +146,7 @@ export const stepFields: Record = { "contactPersonEmail", "contactPersonPhone", ], - poa: [], + poa: [...POA_FIELDS], documents: [], additional: [], }; diff --git a/apps/edr-freight-web/portal/src/services/companies.service.ts b/apps/edr-freight-web/portal/src/services/companies.service.ts index ad93e03a8..b5c1c3cdf 100644 --- a/apps/edr-freight-web/portal/src/services/companies.service.ts +++ b/apps/edr-freight-web/portal/src/services/companies.service.ts @@ -144,6 +144,15 @@ export interface OnboardingLicenseProfile { uploaded: boolean; } +/** Power of Attorney state — mandatory for freight forwarders, optional otherwise. */ +export interface OnboardingPoaState { + required: boolean; + provided: boolean; + delegationLetterUploaded: boolean; + missingFields: { key: string; label: string }[]; + complete: boolean; +} + /** * Server-driven onboarding requirements. The portal renders this verbatim: the * backend decides which documents apply (by nationality) and what is still @@ -158,6 +167,7 @@ export interface OnboardingRequirements { }; documents: OnboardingDocumentField[]; licenseProfiles: OnboardingLicenseProfile[]; + poa: OnboardingPoaState; progress: { completed: number; total: number }; isComplete: boolean; onboardingCompleted: boolean; From ea0f264d72c922b840b7e4433ffeac0ade9bec50 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 10 Jul 2026 08:19:12 +0000 Subject: [PATCH 07/29] fea: add poa section to backoffice customer detail page --- .../pages/customers/CustomerDetailPage.tsx | 167 ++++++++++++++++++ .../src/services/customers.service.ts | 5 + .../backoffice/src/types/customer.ts | 5 + 3 files changed, 177 insertions(+) diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx index 317f915fb..5d0655f8d 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx @@ -7,6 +7,7 @@ import { Card, Center, Container, + Divider, Group, Loader, SimpleGrid, @@ -90,6 +91,9 @@ function tableStatus(query: { isLoading: boolean; isError: boolean }) { return query.isLoading ? "loading" : query.isError ? "error" : "success"; } +/** Matches the fileKey seeded in the API's file-upload-settings seeder. */ +const POA_DELEGATION_CODE = "poa_delegation_letter"; + export default function CustomerDetailPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); @@ -531,6 +535,26 @@ export default function CustomerDetailPage() { (p) => p.licenseFiles && p.licenseFiles.length > 0, ); + const poaDocuments = useMemo( + () => documents.filter((d) => d.code === POA_DELEGATION_CODE), + [documents], + ); + const poaFields = [ + { label: "PoA name", value: company?.poaName }, + { label: "PoA email", value: company?.poaEmail }, + { label: "PoA phone", value: company?.poaPhone }, + { label: "PoA location", value: company?.poaLocation }, + { label: "PoA address", value: company?.poaAddress }, + ]; + const hasPoaDetails = poaFields.some((f) => f.value?.trim()); + // A freight forwarder acts on other companies' behalf, so its PoA — details + // and delegation letter both — is mandatory rather than optional. + const poaMandatory = (company?.companyProfiles ?? []).some( + (p) => p.type === "freight_forwarder", + ); + const delegationMissing = + (hasPoaDetails || poaMandatory) && poaDocuments.length === 0; + if (isLoading) { return (
@@ -672,6 +696,149 @@ export default function CustomerDetailPage() { + + + + + + Power of Attorney + + {poaMandatory && ( + + Required for freight forwarder + + )} + + {delegationMissing ? ( + + Delegation letter missing + + ) : poaDocuments.length > 0 ? ( + + Delegation letter on file + + ) : ( + + Not provided + + )} + + + {hasPoaDetails ? ( + + {poaFields.map((f) => ( + + ))} + + ) : ( + + No Power of Attorney representative recorded for this + customer. + + )} + + + + + + Delegation letter + + + {documentsQuery.isLoading ? ( + + + + Loading documents… + + + ) : documentsQuery.isError ? ( + + + Failed to load documents. + + void documentsQuery.refetch()} + > + Retry + + + ) : poaDocuments.length === 0 ? ( + + No delegation letter uploaded. + + ) : ( + poaDocuments.map((doc) => ( + + + + + view({ + name: doc.name, + url: fileViewUrl(doc.id), + mimeType: doc.mimeType, + }) + } + > + {doc.name} + + + {formatBytes(doc.size)} ·{" "} + {formatDate(doc.uploadedAt)} + + + + + view({ + name: doc.name, + url: fileViewUrl(doc.id), + mimeType: doc.mimeType, + }) + } + > + + + + + + + + )) + )} + + + + diff --git a/apps/edr-freight-web/backoffice/src/services/customers.service.ts b/apps/edr-freight-web/backoffice/src/services/customers.service.ts index 0476d41bb..c9a649946 100644 --- a/apps/edr-freight-web/backoffice/src/services/customers.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/customers.service.ts @@ -33,6 +33,11 @@ function mapCompany(dto: Record): Company { generalManagerName: (attrs.generalManagerName as string | null) ?? null, generalManagerEmail: (attrs.generalManagerEmail as string | null) ?? null, generalManagerPhone: (attrs.generalManagerPhone as string | null) ?? null, + poaName: (attrs.poaName as string | null) ?? null, + poaEmail: (attrs.poaEmail as string | null) ?? null, + poaPhone: (attrs.poaPhone as string | null) ?? null, + poaLocation: (attrs.poaLocation as string | null) ?? null, + poaAddress: (attrs.poaAddress as string | null) ?? null, }; } diff --git a/apps/edr-freight-web/backoffice/src/types/customer.ts b/apps/edr-freight-web/backoffice/src/types/customer.ts index 7328decf0..5931ea5d6 100644 --- a/apps/edr-freight-web/backoffice/src/types/customer.ts +++ b/apps/edr-freight-web/backoffice/src/types/customer.ts @@ -127,6 +127,11 @@ export interface Company { generalManagerName?: string | null; generalManagerEmail?: string | null; generalManagerPhone?: string | null; + poaName?: string | null; + poaEmail?: string | null; + poaPhone?: string | null; + poaLocation?: string | null; + poaAddress?: string | null; website?: string | null; attributes?: Record | null; companyProfiles: CompanyProfile[]; From 9eb10bb4ce9bdf917e1e317c6d2d15c1152b2697 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 10 Jul 2026 07:53:13 +0000 Subject: [PATCH 08/29] feat(warehouses): auto-load targets a selected train, never loads trainless "Auto Load Ready Items" now opens a train picker (pre-dispatch trains with these bookings assigned, via the existing loadable-trains flow). No train available -> no auto-loading, with a clear notice. Loading goes through the existing per-wagon load path, so items without an allocated wagon are skipped with a reason. The train association is stored on the existing warehouse_loadings table (no new table needed): new train_schedule_id column + a note recording train number, origin -> destination, and departure time; wagon_id becomes nullable. The trainless load-passed-export endpoint, its frontend wiring, and the unused useLoadPassedExport hook are removed. Migration 2100000000000 (idempotent) also applied to the dev database. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...000000-WarehouseLoadingTrainAssociation.ts | 35 ++++++++ .../warehouses/dto/load-inventory.dto.ts | 5 ++ .../entities/warehouse-loading.entity.ts | 14 ++- .../warehouse-inventory.controller.ts | 5 -- .../warehouses/warehouse-inventory.service.ts | 81 +++++++---------- .../warehouses/ReceiveInventoryModal.tsx | 89 +++++++++++++++++-- .../backoffice/src/hooks/useWarehouses.ts | 2 - .../backoffice/src/services/api.ts | 9 -- .../src/services/warehouse.service.ts | 3 - 9 files changed, 166 insertions(+), 77 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/2100000000000-WarehouseLoadingTrainAssociation.ts diff --git a/apps/edr-freight-api/src/migrations/2100000000000-WarehouseLoadingTrainAssociation.ts b/apps/edr-freight-api/src/migrations/2100000000000-WarehouseLoadingTrainAssociation.ts new file mode 100644 index 000000000..26afdf82a --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2100000000000-WarehouseLoadingTrainAssociation.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Auto-load onto a selected train: a warehouse_loadings row now records WHICH + * train the item was loaded onto (train_schedule_id), and wagon_id becomes + * nullable because a schedule-level load may not resolve to a single wagon. + */ +export class WarehouseLoadingTrainAssociation2100000000000 implements MigrationInterface { + name = 'WarehouseLoadingTrainAssociation2100000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.warehouse_loadings + ADD COLUMN IF NOT EXISTS train_schedule_id UUID NULL + `); + await queryRunner.query(` + ALTER TABLE freight.warehouse_loadings + ALTER COLUMN wagon_id DROP NOT NULL + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_warehouse_loadings_train_schedule + ON freight.warehouse_loadings(train_schedule_id) + WHERE train_schedule_id IS NOT NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_warehouse_loadings_train_schedule`); + await queryRunner.query(` + ALTER TABLE freight.warehouse_loadings DROP COLUMN IF EXISTS train_schedule_id + `); + // wagon_id stays nullable on revert: restoring NOT NULL would fail on rows + // recorded without a wagon and re-introduce the outage this fixes. + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/load-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/load-inventory.dto.ts index c81550cd0..3c9c6c85f 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/load-inventory.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/load-inventory.dto.ts @@ -6,6 +6,11 @@ export class LoadInventoryDto { @IsUUID() wagonId!: string; + @ApiPropertyOptional({ format: 'uuid', description: 'Train schedule this load belongs to (recorded on the loading).' }) + @IsOptional() + @IsUUID() + trainScheduleId?: string; + @ApiPropertyOptional({ description: 'Weight loaded onto the wagon (t)' }) @IsOptional() @IsNumber() diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-loading.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-loading.entity.ts index 5f6952aec..1698b8a5e 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-loading.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-loading.entity.ts @@ -28,9 +28,17 @@ export class WarehouseLoading extends BaseEntity { @JoinColumn({ name: 'booking_id' }) booking?: Booking | null; - /** Physical wagon the item was loaded onto. References freight.wagons (read-only link). */ - @Column({ name: 'wagon_id', type: 'uuid' }) - wagonId!: string; + /** + * Physical wagon the item was loaded onto. References freight.wagons + * (read-only link). Nullable: a schedule-level auto-load may not resolve to + * one wagon — the train association then lives in trainScheduleId. + */ + @Column({ name: 'wagon_id', type: 'uuid', nullable: true }) + wagonId?: string | null; + + /** Train schedule the item was loaded onto (read-only link to scheduling). */ + @Column({ name: 'train_schedule_id', type: 'uuid', nullable: true }) + trainScheduleId?: string | null; @Column({ name: 'loaded_at', type: 'timestamptz' }) loadedAt!: Date; diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index 242cecc76..070a267a4 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -77,11 +77,6 @@ export class WarehouseInventoryController { return this.inventoryService.bulkReceive(dto); } - @Post('load-passed-export') - @ApiOperation({ summary: 'Bulk-load all EXPORT inventory that passed inspection (READY_FOR_LOADING)' }) - loadPassedExport(@Body('performedBy') performedBy?: string) { - return this.inventoryService.loadPassedExport(performedBy); - } @Get('ready-to-load-export') @ApiOperation({ summary: 'EXPORT inventory that passed inspection and is READY_FOR_LOADING' }) diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 92617db6d..8f7a71fc1 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -243,11 +243,6 @@ export interface BulkReceiveResult { results: { bookingId: string; status: string; inventoryId?: string; grnNumber?: string; reason?: string }[]; } -export interface LoadPassedExportResult { - loadedCount: number; - skippedCount: number; - results: { inventoryId: string; status: string; reason?: string }[]; -} export interface BulkInspectResult { inspectedCount: number; @@ -1098,46 +1093,6 @@ export class WarehouseInventoryService { return result; } - /** Bulk-load all EXPORT inventory that passed inspection and is READY_FOR_LOADING. */ - async loadPassedExport(performedBy?: string): Promise { - const ready = await this.inventoryRepository.findAll({ where: { status: 'READY_FOR_LOADING' } }); - const result: LoadPassedExportResult = { loadedCount: 0, skippedCount: 0, results: [] }; - - for (const item of ready) { - const skip = (reason: string) => { - result.skippedCount += 1; - result.results.push({ inventoryId: item.id, status: 'SKIPPED', reason }); - }; - - if (item.inspectionStatus !== 'PASSED') { skip('Inspection not PASSED'); continue; } - const direction = item.bookingId ? await this.getBookingDirection(item.bookingId) : null; - if (direction !== 'EXPORT') { skip('Not an EXPORT item'); continue; } - const bookingStatus = item.bookingId ? await this.getBookingStatus(item.bookingId) : null; - if (bookingStatus !== 'PAID') { skip('Booking not PAID'); continue; } - - await this.dataSource.transaction(async (manager) => { - await manager.getRepository(WarehouseInventory).update(item.id, { - status: 'LOADED', - loadedAt: new Date(), - }); - await this.activityLog.record( - { - activityType: 'INVENTORY_LOADED', - inventoryId: item.id, - warehouseId: item.warehouseId, - description: 'Bulk loaded (passed export)', - performedBy, - }, - manager, - ); - }); - - result.loadedCount += 1; - result.results.push({ inventoryId: item.id, status: 'LOADED' }); - } - - return result; - } /** EXPORT inventory rows at a given status (route-derived direction), with booking detail. */ private async exportInventoryByStatus( @@ -1319,6 +1274,29 @@ export class WarehouseInventoryService { performedBy?: string, ): Promise { const result: TrainLoadResult = { loadedCount: 0, skippedCount: 0, results: [] }; + const [schedule]: Array<{ + trainNumber: string | null; + origin: string | null; + destination: string | null; + departure: string | null; + }> = await this.dataSource.query( + `SELECT ts.train_number AS "trainNumber", + COALESCE(oy.label, oy.code) AS "origin", + COALESCE(dy.label, dy.code) AS "destination", + ts.scheduled_departure_date AS "departure" + FROM freight.train_schedules ts + LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id + LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id + WHERE ts.id = $1 AND ts.deleted_at IS NULL`, + [scheduleId], + ); + const trainNote = schedule + ? `Loaded onto train ${schedule.trainNumber ?? scheduleId.slice(0, 8)}` + + (schedule.origin || schedule.destination + ? ` (${schedule.origin ?? '?'} -> ${schedule.destination ?? '?'})` + : '') + + (schedule.departure ? `, departure ${new Date(schedule.departure).toISOString()}` : '') + : undefined; const items = await this.trainLoadableItems(scheduleId); const byId = new Map(items.map((i) => [i.id, i])); const affectedBookingIds = new Set(); @@ -1335,7 +1313,12 @@ export class WarehouseInventoryService { if (!item.wagonId) { skip('No wagon allocated — allocate a wagon first'); continue; } try { - await this.load(inventoryId, { wagonId: item.wagonId, loadedBy: performedBy }); + await this.load(inventoryId, { + wagonId: item.wagonId, + loadedBy: performedBy, + trainScheduleId: scheduleId, + notes: trainNote, + }); result.loadedCount += 1; result.results.push({ inventoryId, status: 'LOADED' }); if (item.bookingId) affectedBookingIds.add(item.bookingId); @@ -3403,6 +3386,8 @@ export class WarehouseInventoryService { warehouseInventoryId: id, bookingId: item.bookingId ?? null, wagonId: dto.wagonId, + // Which train this load belongs to — durable even if wagons reshuffle. + trainScheduleId: dto.trainScheduleId ?? null, loadedAt: now, loadedBy: dto.loadedBy ?? null, loadedWeight, @@ -3441,7 +3426,7 @@ export class WarehouseInventoryService { }); // Enrich with wagon numbers (read-only lookup into the scheduling domain). - const wagonIds = [...new Set(loadings.map((l) => l.wagonId))]; + const wagonIds = [...new Set(loadings.map((l) => l.wagonId).filter((id): id is string => Boolean(id)))]; const wagonNumbers = new Map(); if (wagonIds.length > 0) { const rows: Array<{ id: string; wagon_number: string }> = await this.dataSource.query( @@ -3452,7 +3437,7 @@ export class WarehouseInventoryService { } return loadings.map((loading) => - Object.assign(loading, { wagonNumber: wagonNumbers.get(loading.wagonId) ?? null }), + Object.assign(loading, { wagonNumber: (loading.wagonId && wagonNumbers.get(loading.wagonId)) ?? null }), ); } diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx index de41c1a82..ff8dc0abd 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -1414,8 +1414,30 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: const { data: rows = [], isLoading } = useQuery( api.warehouses.readyToLoadExport.queryOptions({ enabled }), ); - const loadPassed = useMutation(api.warehouses.loadPassedExport.mutationOptions()); + const qc = useQueryClient(); const [selected, setSelected] = useState>(new Set()); + const [trainPickerOpen, setTrainPickerOpen] = useState(false); + const [targetScheduleId, setTargetScheduleId] = useState(null); + // Loading is always onto a SPECIFIC pre-dispatch train. No train -> no auto-load. + const { data: trains = [], isLoading: trainsLoading } = useQuery({ + queryKey: ['warehouse-inventory', 'loadable-trains'], + queryFn: () => warehouseService.getLoadableTrains(), + enabled: enabled && trainPickerOpen, + }); + const loadOntoTrain = useMutation({ + mutationFn: async (scheduleId: string) => { + const items = await warehouseService.getTrainLoadableItems(scheduleId); + const loadableIds = items.filter((i) => i.loadable).map((i) => i.id); + if (!loadableIds.length) { + throw new Error('No ready items with an allocated wagon on this train'); + } + return warehouseService.loadItemsOntoTrain(scheduleId, loadableIds); + }, + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] }); + void qc.invalidateQueries({ queryKey: ['warehouse-loadings'] }); + }, + }); const allSelected = rows.length > 0 && selected.size === rows.length; const someSelected = selected.size > 0 && !allSelected; @@ -1427,13 +1449,22 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: return next; }); - const autoLoad = async () => { + const confirmLoad = async () => { + if (!targetScheduleId) { + toast({ variant: 'destructive', title: 'Select a train to load onto' }); + return; + } try { - const r = await loadPassed.mutateAsync(undefined); + const r = await loadOntoTrain.mutateAsync(targetScheduleId); + const train = trains.find((t) => t.scheduleId === targetScheduleId); toast({ - title: `${r.loadedCount} items loaded`, - description: r.skippedCount ? `${r.skippedCount} skipped` : undefined, + title: `${r.loadedCount} item(s) loaded onto train ${train?.trainNumber ?? ''}`.trim(), + description: r.skippedCount + ? `${r.skippedCount} skipped — ${r.results.find((x) => x.reason)?.reason ?? 'see results'}` + : undefined, }); + setTrainPickerOpen(false); + setTargetScheduleId(null); setSelected(new Set()); onChanged?.(); } catch (error) { @@ -1452,14 +1483,58 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: variant="filled" color="teal" leftSection={} - loading={loadPassed.isPending} disabled={rows.length === 0} - onClick={autoLoad} + onClick={() => setTrainPickerOpen(true)} > Auto Load Ready Items + setTrainPickerOpen(false)} + title="Load ready items onto a train" + centered + size="lg" + > + + {trainsLoading ? ( + + ) : trains.length === 0 ? ( + }> + No train available. Auto-loading needs a scheduled (not yet dispatched) train with + these bookings assigned — schedule the train and allocate wagons first. + + ) : ( + { + const file = e.target.files?.[0]; + if (file) pickFile(file); + e.target.value = ""; + }} + /> + + + + + {mutation.isSuccess && ( + + + + Saved successfully + + + )} + {mutation.isError && ( + + + + Save failed + + + )} + + + {mode === "edit" && ( + + )} + + - - + + + + {viewer} + + ); +} + +/** + * One letter already on file. `pending_add` / `pending_remove` reflect a change + * request the backoffice hasn't ruled on yet; `markedForRemoval` and + * `supersededBy` are this session's unsaved edits. + */ +function LetterRow({ + file, + markedForRemoval, + supersededBy, + disabled, + onToggleRemove, + onViewFile, +}: { + file: LicenseFile; + markedForRemoval: boolean; + supersededBy: File | null; + disabled: boolean; + onToggleRemove: () => void; + onViewFile: (file: ViewableFile) => void; +}) { + const badge = STATUS_BADGE[file.status]; + const superseded = Boolean(supersededBy) && file.status !== "pending_remove"; + const struck = + file.status === "pending_remove" || markedForRemoval || superseded; + + return ( + + + + + + onViewFile({ + name: file.name, + url: fileViewUrl(file.id), + mimeType: file.mimeType, + }) + } + style={{ + textAlign: "left", + textDecoration: struck ? "line-through" : undefined, + }} + lineClamp={1} + > + {file.name} + + {file.size > 0 && ( + + {formatBytes(file.size)} + + )} + + + {badge && ( + } + style={{ backgroundColor: badge.bg, color: badge.fg, flexShrink: 0 }} + > + {badge.label} + + )} + {superseded && !markedForRemoval && ( + + Replaced on save + + )} + {markedForRemoval && ( + + Removed on save + + )} + + {file.status !== "pending_remove" && !superseded && ( + + + {markedForRemoval ? : } + + + )} + ); } diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index 04aaf4153..87cd2c28f 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -59,6 +59,7 @@ import type { CompanyInfoResponse, CompanyNationality, CompanyProfileResponse, + LicenseFile, CreateCompanyPayload, DashboardSummary, OnboardingRequirements, @@ -231,6 +232,12 @@ export const api = { ({ companyId }) => companiesService.getDocuments(companyId), ), + poaDelegation: endpoint( + "companies", + "poaDelegation", + companiesService.getPoaDelegation, + ), + changeRequest: endpoint( "companies", "changeRequest", diff --git a/apps/edr-freight-web/portal/src/services/companies.service.ts b/apps/edr-freight-web/portal/src/services/companies.service.ts index b5c1c3cdf..106d17f71 100644 --- a/apps/edr-freight-web/portal/src/services/companies.service.ts +++ b/apps/edr-freight-web/portal/src/services/companies.service.ts @@ -412,6 +412,37 @@ export const companiesService = { return unwrap(response.data); }, + /** The PoA delegation letter on file, with its review state. */ + getPoaDelegation: async (): Promise => { + const response = await client.get>( + URL_CONSTANTS.COMPANIES_API.POA_DELEGATION, + ); + return unwrap(response.data); + }, + + /** + * Upload the PoA delegation letter, replacing any existing one. On an approved + * company the upload is staged for backoffice review; during onboarding it + * goes live immediately. + */ + uploadPoaDelegation: async (file: File): Promise => { + const formData = new FormData(); + formData.append("poa_delegation_letter", file); + const response = await client.post>( + URL_CONSTANTS.COMPANIES_API.POA_DELEGATION, + formData, + ); + return unwrap(response.data); + }, + + /** Remove the PoA delegation letter (staged for review on an approved company). */ + removePoaDelegation: async (fileId: string): Promise => { + const response = await client.delete>( + URL_CONSTANTS.COMPANIES_API.POA_DELEGATION_FILE(fileId), + ); + return unwrap(response.data); + }, + /** List business-license document(s) (with review state) for a company profile. */ getProfileLicense: async (profileId: string): Promise => { const response = await client.get>( From bd4d7b72fd30d9064bd02c562224d77f597ef627 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 10 Jul 2026 09:09:05 +0000 Subject: [PATCH 11/29] fix --- .../src/pages/settings/TabPowerOfAttorney.tsx | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabPowerOfAttorney.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabPowerOfAttorney.tsx index 6c1a4456b..401070911 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabPowerOfAttorney.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabPowerOfAttorney.tsx @@ -127,7 +127,6 @@ export default function TabPowerOfAttorney({ // Save submits the file and the fields together, into one change request. const [pickedFile, setPickedFile] = useState(null); const [removeIds, setRemoveIds] = useState([]); - const [letterError, setLetterError] = useState(null); const [saveBlocked, setSaveBlocked] = useState(false); /** Letters that will still be on file once the staged edits are applied. */ @@ -176,7 +175,6 @@ export default function TabPowerOfAttorney({ onSuccess: () => { setPickedFile(null); setRemoveIds([]); - setLetterError(null); queryClient.invalidateQueries({ queryKey: api.companies.poaDelegation.queryKey(), }); @@ -203,13 +201,14 @@ export default function TabPowerOfAttorney({ setPickedFile(null); setRemoveIds([]); setSaveBlocked(false); - setLetterError(null); }; const pickFile = (file: File) => { - setLetterError(null); setSaveBlocked(false); setPickedFile(file); + // An upload already supersedes every letter on file, so a pending explicit + // removal would be a no-op — drop it rather than mislabel the row. + setRemoveIds([]); }; const toggleRemove = (fileId: string) => { @@ -419,15 +418,6 @@ export default function TabPowerOfAttorney({ )} - {letterError && ( - - - - {letterError} - - - )} - Date: Fri, 10 Jul 2026 09:14:49 +0000 Subject: [PATCH 12/29] fix(warehouses): drop truncated UUID columns and duplicate GRN buttons Queue tables showed "Booking ID" / "Customer ID" as dimmed 8-char UUID hashes next to the human-readable Booking Ref and Customer Name - unusable columns that forced extra horizontal scrolling. Removed across all six tables (eligible, received, ready-to-load, loaded/dispatch, import unloaded, import train detail). The GRN document button also rendered twice per row (inside the Booking Ref cell and again in the GRN column) in four tables - kept the GRN column only. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../warehouses/ReceiveInventoryModal.tsx | 68 ++----------------- 1 file changed, 4 insertions(+), 64 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx index ff8dc0abd..8e39ea53e 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -1043,8 +1043,6 @@ function EligibleTab({ /> Booking Ref - Booking ID - Customer ID Customer Name Origin Destination @@ -1077,12 +1075,6 @@ function EligibleTab({ {r.reference} - - {r.id.slice(0, 8)}… - - - {r.customerId ? `${r.customerId.slice(0, 8)}…` : '—'} - {r.customer ?? '—'} {r.origin ?? '—'} {r.destination ?? '—'} @@ -1329,8 +1321,6 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged Booking Ref GRN - Booking ID - Customer ID Customer Name Container / Cargo Items Cargo Type @@ -1355,20 +1345,11 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged /> - - {r.bookingReference ?? '—'} - - + {r.bookingReference ?? '—'} - - {r.bookingId ? `${r.bookingId.slice(0, 8)}…` : '—'} - - - {r.customerId ? `${r.customerId.slice(0, 8)}…` : '—'} - {r.customerName ?? '—'} {r.containerNumber ?? '—'} {r.cargoType ?? '—'} @@ -1558,8 +1539,6 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: Booking Ref GRN - Booking ID - Customer ID Customer Name Container # Cargo Type @@ -1580,20 +1559,11 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: /> - - {r.bookingReference ?? '—'} - - + {r.bookingReference ?? '—'} - - {r.bookingId ? `${r.bookingId.slice(0, 8)}…` : '—'} - - - {r.customerId ? `${r.customerId.slice(0, 8)}…` : '—'} - {r.customerName ?? '—'} {r.containerNumber ?? '—'} {r.cargoType ?? '—'} @@ -1735,8 +1705,6 @@ function LoadedExportTab({ )} Booking Ref GRN - Booking ID - Customer ID Customer Name Container # Cargo Type @@ -1758,20 +1726,11 @@ function LoadedExportTab({ )} - - {r.bookingReference ?? '—'} - - + {r.bookingReference ?? '—'} - - {r.bookingId ? `${r.bookingId.slice(0, 8)}…` : '—'} - - - {r.customerId ? `${r.customerId.slice(0, 8)}…` : '—'} - {r.customerName ?? '—'} {r.containerNumber ?? '—'} {r.cargoType ?? '—'} @@ -1891,9 +1850,7 @@ function ImportTrainDetailTable({ Wagon - Booking ID Booking Ref - Customer ID Customer Name Container # Cargo Type @@ -1927,15 +1884,9 @@ function ImportTrainDetailTable({ {it.sequenceNo ? `#${it.sequenceNo}` : '-'} {it.wagonNumber ?? ''} - - {it.bookingId.slice(0, 8)}… - {it.bookingReference ?? '—'} - - {it.customerId ? `${it.customerId.slice(0, 8)}…` : '—'} - {it.customerName ?? '—'} {it.containerNumber ?? '—'} {it.cargoType ?? '—'} @@ -2384,10 +2335,8 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { onChange={() => (allSelected ? unselectAll() : selectAll())} /> - Booking ID Booking Ref GRN - Customer ID Customer Name Arrival Time Container # @@ -2412,20 +2361,11 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { /> - {r.bookingId ? `${r.bookingId.slice(0, 8)}…` : '—'} - - - - {r.bookingReference ?? '—'} - - + {r.bookingReference ?? '—'} - - {r.customerId ? `${r.customerId.slice(0, 8)}…` : '—'} - {r.customerName ?? '—'} {formatDate(r.arrivalTime)} {r.containerNumber ?? '—'} From 9e30c0c07128357c06ff4f86f0a1ea4941b52f24 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 10 Jul 2026 09:22:23 +0000 Subject: [PATCH 13/29] ci: replace actions/checkout with plain git on self-hosted runners Runners on this network intermittently time out downloading the action tarball from codeload.github.com (HttpClient 100s limit, 3 attempts, job dead before the first step). git fetch talks to github.com directly and needs no action download at all. - detect-changes: fetch --depth 2 (keeps the HEAD~1 diff working) - deploy: fetch --depth 1 - token passed via env for the fetch, then scrubbed from .git/config so it doesn't persist in the runner workspace; git clean keeps checkout@v4's clean-workspace behaviour Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/deploy.yml | 37 ++++++++++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 62530611c..5e1f46ad0 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -17,10 +17,23 @@ jobs: outputs: matrix: ${{ steps.filter.outputs.matrix }} steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 2 + # Plain git instead of actions/checkout: self-hosted runners on this + # network intermittently time out downloading action tarballs from + # codeload.github.com (100s HttpClient limit x3 = dead job). git fetch + # talks to github.com directly and needs no action download at all. + - name: Checkout (plain git, depth 2) + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + git init -q . + git remote remove origin 2>/dev/null || true + git remote add origin "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" + git fetch -q --depth 2 origin "${{ github.sha }}" + git checkout -q --force "${{ github.sha }}" + git clean -ffdq + # Don't leave the token in .git/config on the persistent runner workspace. + git remote set-url origin "https://github.com/${{ github.repository }}.git" - name: Determine changed services id: filter @@ -103,8 +116,20 @@ jobs: COMPOSE_DOCKER_CLI_BUILD: "1" steps: - - name: Checkout - uses: actions/checkout@v4 + # Same rationale as detect-changes: no action download on this network. + - name: Checkout (plain git) + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + git init -q . + git remote remove origin 2>/dev/null || true + git remote add origin "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" + git fetch -q --depth 1 origin "${{ github.sha }}" + git checkout -q --force "${{ github.sha }}" + git clean -ffdq + # Don't leave the token in .git/config on the persistent runner workspace. + git remote set-url origin "https://github.com/${{ github.repository }}.git" - name: Resolve project and build env file run: | From 1abef3ce343970923b1dfcfa85f0235788b4f7eb Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 10 Jul 2026 10:32:41 +0000 Subject: [PATCH 14/29] enhance shipment requests page with filtering and sorting options - Added status and cargo filters to the ShipmentRequestsPage. - Implemented date range filtering for preferred dates. - Introduced sorting options for shipment requests based on submission date and reference. - Enhanced the display of shipment request details, including status badges and customer information. - Updated the UI to include a search input with clear functionality and improved layout for filters. feat: add equipment return option in new shipment form - Introduced a toggle for equipment return in the NewShipmentPage. - Updated form schema to include field for container contracts. - Enhanced user experience with visual feedback on the equipment return selection. fix: update booking DTO to include equipment return option - Added field to CreateBookingUnderContractDto for per-shipment override. - Updated related types and schemas to accommodate the new field for better contract handling. --- .gitignore | 1 + .../contracts/booking-request.repository.ts | 13 +- .../contracts/booking-request.service.ts | 2 +- .../contracts/contract-booking.service.ts | 2 +- .../modules/contracts/contracts.controller.ts | 2 +- .../dto/create-booking-under-contract.dto.ts | 13 + .../train-scheduling/booking-batch.service.ts | 116 +- .../booking-notifier.service.ts | 9 +- .../train-scheduling.service.ts | 8 +- apps/edr-freight-web/backoffice/src/App.tsx | 12 +- .../contracts/GlCreateBookingForm.tsx | 76 ++ .../features/contracts/mapShipmentListRow.ts | 6 + .../ContractTemplateEditorPage.tsx | 600 ++++++++- .../contracts/GlDjiboutiClearanceListPage.tsx | 1069 +++++++++++++++-- .../pages/contracts/ShipmentRequestsPage.tsx | 397 +++++- .../backoffice/src/types/booking.ts | 5 + .../src/pages/contracts/NewShipmentPage.tsx | 86 +- .../contracts/new-shipment-form/schema.ts | 5 + packages/types/src/freight/contracts.ts | 2 + 19 files changed, 2130 insertions(+), 294 deletions(-) diff --git a/.gitignore b/.gitignore index ffdc4b78b..ca2a5b7af 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ coverage/ *~ \#*\# .\#* +docker-compose.override.yml diff --git a/apps/edr-freight-api/src/modules/contracts/booking-request.repository.ts b/apps/edr-freight-api/src/modules/contracts/booking-request.repository.ts index 0ae829529..d0705bfb1 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-request.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-request.repository.ts @@ -22,12 +22,15 @@ export class BookingRequestRepository extends BaseRepository { }); } - /** GL queue: pending requests across all contracts, oldest first. */ - async findPending(): Promise { + /** + * GL queue: every request across all contracts, newest first. The queue page + * filters by status client-side (pending work vs accepted/rejected history), + * and surfaces the customer — so the contract's company rides along. + */ + async findQueue(): Promise { return this.repository.find({ - where: { status: 'PENDING' }, - order: { createdAt: 'ASC' }, - relations: { contract: true }, + order: { createdAt: 'DESC' }, + relations: { contract: { company: true } }, }); } diff --git a/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts index e038c7bff..17270c738 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts @@ -139,7 +139,7 @@ export class BookingRequestService { } queue(): Promise { - return this.repo.findPending(); + return this.repo.findQueue(); } private async findPending(requestId: string): Promise { diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index 70083a2ae..9a03bfe8a 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -218,7 +218,7 @@ export class ContractBookingService { contractType: 'NEW', customsClearingEnabled: contract.customsClearingEnabled, customsClearingAgent: contract.customsClearingAgent ?? null, - equipmentReturn: contract.equipmentReturn ?? 'WITHOUT_RETURN', + equipmentReturn: dto.equipmentReturn ?? contract.equipmentReturn ?? 'WITHOUT_RETURN', originYardId: route?.originYardId ?? null, destinationYardId: route?.destinationYardId ?? null, tradeDirection: contract.tradeDirection, diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index 4ea7634b6..7f75b12c8 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -107,7 +107,7 @@ export class ContractsController { @Get('booking-requests/queue') @BookingStaff(FREIGHT_PERMS.contracts.createBooking) - @ApiOperation({ summary: 'GL queue: pending shipment requests across contracts' }) + @ApiOperation({ summary: 'GL queue: shipment requests across contracts (all statuses, newest first)' }) bookingRequestQueue() { return this.bookingRequestService.queue(); } diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts index f220cae0d..870817365 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts @@ -4,6 +4,7 @@ import { IsArray, IsBoolean, IsDateString, + IsIn, IsInt, IsNumber, IsOptional, @@ -14,6 +15,9 @@ import { ValidateNested, } from 'class-validator'; +/** Per-shipment equipment return — "NA" stays contract-level only. */ +const SHIPMENT_EQUIPMENT_RETURNS = ['WITH_RETURN', 'WITHOUT_RETURN'] as const; + /** One physical container under a booking line — entered at booking time. */ export class CreateContainerUnitDto { @ApiProperty({ description: 'ISO 6346 container number, e.g. ABCD1234567' }) @@ -134,6 +138,15 @@ export class CreateBookingUnderContractDto { @IsDateString() scheduledDate?: string; + @ApiPropertyOptional({ + enum: SHIPMENT_EQUIPMENT_RETURNS, + description: + 'Per-shipment equipment return override; omitted → the contract default applies.', + }) + @IsOptional() + @IsIn([...SHIPMENT_EQUIPMENT_RETURNS]) + equipmentReturn?: string; + @ApiPropertyOptional({ type: [CreateBookingContainerLineDto] }) @IsOptional() @IsArray() diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 7dd78d167..5609c8801 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -31,7 +31,6 @@ import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity'; import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository'; -import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity'; import { BookingNotifierService } from './booking-notifier.service'; import { TrainSchedulingService } from './train-scheduling.service'; import { eatDay, groupBookingsIntoBoardWindows } from './batch-window.util'; @@ -568,7 +567,6 @@ export class BookingBatchService implements OnModuleInit { ); } - const rules = await this.loadGlobalRules(); const wagonDims = await this.loadWagonDims(); const required = need ?? this.needFor(booking, wagonDims); let corridorMatched = false; @@ -578,7 +576,7 @@ export class BookingBatchService implements OnModuleInit { ); const locomotive = schedule?.trainSet?.locomotive; if (!schedule || !locomotive) continue; - const limits = await this.capacityLimits(locomotive, rules); + const limits = await this.capacityLimits(locomotive); const budget = await this.remainingBudget(schedule, limits, wagonDims); const leg = budget.legOf(booking.originYardId, booking.destinationYardId); if (!leg) continue; // this train's route doesn't carry the booking's leg @@ -757,7 +755,6 @@ export class BookingBatchService implements OnModuleInit { }); const wagonDims = await this.loadWagonDims(); - const rules = await this.loadGlobalRules(); const linkRepo = this.dataSource.getRepository(TrainScheduleBooking); const board: BatchBoardSchedule[] = []; @@ -787,7 +784,7 @@ export class BookingBatchService implements OnModuleInit { }; }); - board.push(this.buildScheduleSummary(s, items, rules)); + board.push(this.buildScheduleSummary(s, items)); } return { @@ -817,7 +814,6 @@ export class BookingBatchService implements OnModuleInit { } const wagonDims = await this.loadWagonDims(); - const rules = await this.loadGlobalRules(); const linkRepo = this.dataSource.getRepository(TrainScheduleBooking); const links = await linkRepo.find({ where: { trainScheduleId: s.id } }); const linkedIds = new Set(links.map((l) => l.bookingId)); @@ -1011,7 +1007,7 @@ export class BookingBatchService implements OnModuleInit { maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), } : null, - capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null, rules), + capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null), counts: { allocated: items.filter((i) => i.state === "ALLOCATED").length, selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH") @@ -1045,9 +1041,10 @@ export class BookingBatchService implements OnModuleInit { /** * Board capacity figures. `usedWeightTons` is GROSS (each item's weight already * includes the tare of the wagons it occupies), so the ceiling it is measured - * against must be the same one the fill loop spends from: the locomotive floored - * by the global rule caps and widened by its overage tolerance. Reading the raw - * `loco.maxPullWeightTons` here showed staff a ceiling the batch engine did not use. + * against must be the same one the fill loop spends from: the locomotive's own + * limits widened by its overage tolerance (global rule caps do not apply, same + * as {@link capacityLimits}). Reading the raw `loco.maxPullWeightTons` here + * showed staff a ceiling the batch engine did not use. */ private computeBoardCapacity( items: Array<{ @@ -1058,29 +1055,18 @@ export class BookingBatchService implements OnModuleInit { }>, loco: Locomotive | null, maxWagons: number | null, - rules: TrainSchedulingGlobalRules | null, ): BatchBoardSchedule["capacity"] { const allocated = items.filter((i) => i.state === "ALLOCATED"); const committed = items.filter( (i) => i.state === "ALLOCATED" || i.state === "SELECTED_FOR_BATCH", ); const caps = loco - ? trainHardCaps( - { - maxPullWeightTons: Number(loco.maxPullWeightTons), - maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), - overageToleranceTons: Number(loco.overageToleranceTons) || 0, - overageToleranceMeters: Number(loco.overageToleranceMeters) || 0, - }, - { - maxTrainWeightTons: rules?.maxTrainWeightTons - ? Number(rules.maxTrainWeightTons) - : undefined, - maxTrainLengthMeters: rules?.maxTrainLengthMeters - ? Number(rules.maxTrainLengthMeters) - : undefined, - }, - ) + ? trainHardCaps({ + maxPullWeightTons: Number(loco.maxPullWeightTons), + maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), + overageToleranceTons: Number(loco.overageToleranceTons) || 0, + overageToleranceMeters: Number(loco.overageToleranceMeters) || 0, + }) : null; const round2 = (value: number) => Math.round(value * 100) / 100; @@ -1099,7 +1085,6 @@ export class BookingBatchService implements OnModuleInit { private buildScheduleSummary( s: TrainSchedule, items: BatchBoardBooking[], - rules: TrainSchedulingGlobalRules | null, ): BatchBoardSchedule { const loco = s.trainSet?.locomotive ?? null; @@ -1134,7 +1119,7 @@ export class BookingBatchService implements OnModuleInit { maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), } : null, - capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null, rules), + capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null), counts: { allocated: items.filter((i) => i.state === "ALLOCATED").length, selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH") @@ -1203,10 +1188,9 @@ export class BookingBatchService implements OnModuleInit { return 0; } - const rules = await this.loadGlobalRules(); const wagonDims = await this.loadWagonDims(); - const limits = await this.capacityLimits(locomotive, rules); - await this.syncScheduleMaxWagons(schedule, locomotive, rules); + const limits = await this.capacityLimits(locomotive); + await this.syncScheduleMaxWagons(schedule, locomotive); const budget = await this.remainingBudget(schedule, limits, wagonDims); const minPerWagon = this.minPerWagonNeed(wagonDims); if (budget.isExhausted(minPerWagon)) { @@ -1405,7 +1389,6 @@ export class BookingBatchService implements OnModuleInit { return { scheduleIds: [], commercialReserved: 0 }; } - const rules = await this.loadGlobalRules(); const wagonDims = await this.loadWagonDims(); // Live per-schedule corridor budget + arm flag, in departure order. @@ -1420,8 +1403,8 @@ export class BookingBatchService implements OnModuleInit { ); continue; } - const limits = await this.capacityLimits(locomotive, rules); - await this.syncScheduleMaxWagons(schedule, locomotive, rules); + const limits = await this.capacityLimits(locomotive); + await this.syncScheduleMaxWagons(schedule, locomotive); const budget = await this.remainingBudget(schedule, limits, wagonDims); trains.push({ id, budget, armed: false }); } @@ -1973,9 +1956,8 @@ export class BookingBatchService implements OnModuleInit { await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); const locomotive = schedule?.trainSet?.locomotive; if (!schedule || !locomotive) return null; - const rules = await this.loadGlobalRules(); const wagonDims = await this.loadWagonDims(); - const limits = await this.capacityLimits(locomotive, rules); + const limits = await this.capacityLimits(locomotive); const budget = await this.remainingBudget(schedule, limits, wagonDims); return { budget, needFor: (booking) => this.needFor(booking, wagonDims) }; } @@ -2520,11 +2502,12 @@ export class BookingBatchService implements OnModuleInit { * `base` via {@link needFor}, whose weight axis is gross. The locomotive's * overage tolerance is returned separately — the corridor budget spends it * only to admit a booking whole, never to size a split. + * + * Limits come from the LOCOMOTIVE ALONE — the global-rules weight/length + * caps deliberately do not apply here (a mis-set global row once capped + * every train at 14m and no export booking could board). */ - private async capacityLimits( - locomotive: Locomotive, - rules: TrainSchedulingGlobalRules | null, - ): Promise { + private async capacityLimits(locomotive: Locomotive): Promise { const wagonTypes = await this.loadWagonTypeDimensions(); const derived = deriveTrainCapacityFromLocomotive( { @@ -2534,14 +2517,6 @@ export class BookingBatchService implements OnModuleInit { overageToleranceMeters: Number(locomotive.overageToleranceMeters) || 0, }, wagonTypes, - { - maxTrainWeightTons: rules?.maxTrainWeightTons - ? Number(rules.maxTrainWeightTons) - : undefined, - maxTrainLengthMeters: rules?.maxTrainLengthMeters - ? Number(rules.maxTrainLengthMeters) - : undefined, - }, ); return { base: { @@ -2556,18 +2531,27 @@ export class BookingBatchService implements OnModuleInit { }; } - /** Keep schedule.max_wagons aligned with locomotive physical limits. */ + /** + * Keep schedule.max_wagons aligned with the train's real boarding limit: the + * locomotive's length-derived slot count, floored by the physical wagons in + * the train set (slots that exist on paper but not in the yard must not be + * sold — see {@link remainingBudget}). + */ private async syncScheduleMaxWagons( schedule: TrainSchedule, locomotive: Locomotive, - rules: TrainSchedulingGlobalRules | null, ): Promise { - const limits = await this.capacityLimits(locomotive, rules); - if ((schedule.maxWagons ?? 0) !== limits.base.wagons) { + const limits = await this.capacityLimits(locomotive); + const physicalWagons = schedule.trainSet?.wagons?.length ?? 0; + const maxWagons = + physicalWagons > 0 + ? Math.min(limits.base.wagons, physicalWagons) + : limits.base.wagons; + if ((schedule.maxWagons ?? 0) !== maxWagons) { await this.dataSource .getRepository(TrainSchedule) - .update(schedule.id, { maxWagons: limits.base.wagons }); - schedule.maxWagons = limits.base.wagons; + .update(schedule.id, { maxWagons }); + schedule.maxWagons = maxWagons; } } @@ -2655,12 +2639,6 @@ export class BookingBatchService implements OnModuleInit { }; } - private async loadGlobalRules(): Promise { - return this.dataSource - .getRepository(TrainSchedulingGlobalRules) - .findOne({ where: {} }); - } - /** * Ordered stop yards of the schedule's route (origin → milestones → * destination); the legacy two-stop pseudo-route when milestones are absent. @@ -2684,6 +2662,12 @@ export class BookingBatchService implements OnModuleInit { * Remaining capacity per corridor edge = hard caps minus what allocated + * reserved bookings already use ON THEIR OWN LEGS. A booking riding only * Dire→Djibouti leaves the Addis→Dire edges untouched. + * + * The wagon axis is additionally capped by the PHYSICAL wagons marshalled in + * the schedule's train set. The length-derived slot count says how many wagons + * the locomotive could pull, not how many exist: a 760m/54-slot train with a + * 50-wagon set once split-offered 4 wagons that were never buildable — the + * customer paid and the wagon planner had nothing to assign. */ private async remainingBudget( schedule: TrainSchedule, @@ -2691,7 +2675,12 @@ export class BookingBatchService implements OnModuleInit { wagonDims: WagonDims, ): Promise { const stops = await this.stopsForSchedule(schedule); - const budget = new CorridorBudget(stops, limits.base, limits.tolerance); + const physicalWagons = schedule.trainSet?.wagons?.length ?? 0; + const base = + physicalWagons > 0 + ? { ...limits.base, wagons: Math.min(limits.base.wagons, physicalWagons) } + : limits.base; + const budget = new CorridorBudget(stops, base, limits.tolerance); const allocated = (schedule.scheduleBookings ?? []) .map((sb) => sb.booking) .filter((b): b is Booking => Boolean(b)); @@ -2795,9 +2784,8 @@ export class BookingBatchService implements OnModuleInit { if ((await this.remainingWagons(schedule)) <= 0) return true; const locomotive = schedule.trainSet?.locomotive; if (!locomotive) return false; // no weight/length limits to bind against - const rules = await this.loadGlobalRules(); const wagonDims = await this.loadWagonDims(); - const limits = await this.capacityLimits(locomotive, rules); + const limits = await this.capacityLimits(locomotive); const budget = await this.remainingBudget(schedule, limits, wagonDims); return budget.isExhausted(this.minPerWagonNeed(wagonDims)); } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts index 48985bdf0..c189825fd 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts @@ -1,6 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { NotificationAudience, + NotificationPriority, NotificationType, NotifyInput, } from '@edr/types'; @@ -114,11 +115,15 @@ export class BookingNotifierService { const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' }); const msg = `Only ${offeredWagons} of ${totalWagons} wagons fit the train for booking ${b.reference ?? b.id}. ` + - `Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to accept and ship ${offeredWagons} wagon${offeredWagons === 1 ? '' : 's'} now ` + - `(the rest returns to your contract to book later). If you do not pay, the booking stays whole and you can rebook in the next window. Deadline: ${eat} EAT.`; + `Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to accept and ship ${offeredWagons} wagon${offeredWagons === 1 ? '' : 's'} now. ` + + `The remaining ${totalWagons - offeredWagons} return${totalWagons - offeredWagons === 1 ? 's' : ''} to your contract — book them yourself in a later window. ` + + `If you do not pay, the booking stays whole and you can rebook in the next window. Deadline: ${eat} EAT.`; await this.notifyContact(b, msg, 'PAY NOW (PARTIAL)'); + // HIGH: a split is a change to what the customer ordered AND a live payment + // deadline — it must reach email/SMS, not just the portal inbox. this.inApp(b, 'Partial allocation offer', msg, { type: NotificationType.INVOICE_ISSUED, + priority: NotificationPriority.HIGH, }); } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index f53af2a93..0fbfd30ce 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -3111,6 +3111,10 @@ export class TrainSchedulingService { const wagonTypes = await this.loadSchedulingWagonTypeDimensions(); if (locomotive) { + // With a locomotive assigned its own limits are the single source of + // truth — global-rules / env caps do not floor them (a mis-set global + // row once capped every train at 14m). Only an explicit per-request dto + // override still applies. const derived = deriveTrainCapacityFromLocomotive( { maxPullWeightTons: Number(locomotive.maxPullWeightTons), @@ -3120,8 +3124,8 @@ export class TrainSchedulingService { }, wagonTypes, { - maxTrainWeightTons: ruleWeightCap, - maxTrainLengthMeters: ruleLengthCap, + maxTrainWeightTons: dto?.maxTrainWeightTons, + maxTrainLengthMeters: dto?.maxTrainLengthMeters, }, ); return { diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 15c8cf19c..d9a326d91 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -196,12 +196,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.contracts.createBooking, }, - { - label: "Self-Clearance Review", - href: "/dashboard/contracts/ops-clearance", - icon: , - permission: FREIGHT_PERMS.contracts.opsClearanceReview, - }, + // { + // label: "Self-Clearance Review", + // href: "/dashboard/contracts/ops-clearance", + // icon: , + // permission: FREIGHT_PERMS.contracts.opsClearanceReview, + // }, { label: "GL Djibouti Clearance", href: "/dashboard/gl-djibouti/clearance", diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index 33254d3ce..32ec2c511 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -38,6 +38,7 @@ import { MapPin, Package, Receipt, + Repeat, X, } from "lucide-react"; import type { Freight } from "@edr/types"; @@ -192,9 +193,19 @@ export default function GlCreateBookingForm() { const [notes, setNotes] = useState(""); const [containerLines, setContainerLines] = useState([]); const [bulkLines, setBulkLines] = useState([]); + const [withReturn, setWithReturn] = useState(false); const [prefilled, setPrefilled] = useState(false); const [priceOpen, setPriceOpen] = useState(false); const seededRef = useRef(false); + const returnSeededRef = useRef(false); + + // Seed the equipment-return toggle from the contract exactly once (also when + // the form is prefilled from a shipment request); GL can flip it per shipment. + useEffect(() => { + if (!contract || returnSeededRef.current) return; + returnSeededRef.current = true; + setWithReturn(contract.equipmentReturn === "WITH_RETURN"); + }, [contract]); const isContainer = contract?.freightType === "CONTAINER"; const routes = useMemo( @@ -527,6 +538,10 @@ export default function GlCreateBookingForm() { scheduledDate, ...(contractRouteId ? { contractRouteId } : {}), ...(notes.trim() ? { notes: notes.trim() } : {}), + // Equipment return is a container concern — bulk keeps the contract default. + ...(isContainer + ? { equipmentReturn: withReturn ? "WITH_RETURN" : "WITHOUT_RETURN" } + : {}), }; if (isContainer) { @@ -1091,6 +1106,67 @@ export default function GlCreateBookingForm() { )} + {isContainer ? ( + + } + title="Equipment Return" + description="Choose whether the empty container(s) come back to EDR after unloading." + /> + setWithReturn((v) => !v)} + > + + + + + + + + With return + + + {withReturn + ? "Container(s) returned to EDR after unloading." + : "Container(s) retained by the customer after delivery."} + + + + setWithReturn(e.currentTarget.checked)} + onClick={(e) => e.stopPropagation()} + style={{ flexShrink: 0 }} + /> + + + + ) : null} + } diff --git a/apps/edr-freight-web/backoffice/src/features/contracts/mapShipmentListRow.ts b/apps/edr-freight-web/backoffice/src/features/contracts/mapShipmentListRow.ts index 766c04ee1..9e58e84bc 100644 --- a/apps/edr-freight-web/backoffice/src/features/contracts/mapShipmentListRow.ts +++ b/apps/edr-freight-web/backoffice/src/features/contracts/mapShipmentListRow.ts @@ -9,6 +9,12 @@ export interface ShipmentListRow { summary: string; status: Freight.BookingRequestStatus; createdBookingId?: string | null; + /** When the customer submitted the request — the queue's default sort key. */ + createdAt?: string | null; + customerName?: string | null; + freightKind?: "CONTAINER" | "BULK"; + hazardous?: boolean; + reefer?: boolean; } export type ShipmentRowAction = diff --git a/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplateEditorPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplateEditorPage.tsx index 0ad7ef301..2d635fe58 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplateEditorPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplateEditorPage.tsx @@ -1,15 +1,18 @@ -import { useMemo, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import { useParams } from "react-router-dom"; import { ActionIcon, Badge, + Box, Button, Card, Center, Group, Loader, + Menu, Modal, Paper, + ScrollArea, Stack, Switch, Text, @@ -19,13 +22,29 @@ import { Tooltip, } from "@mantine/core"; import { + AlertTriangle, ArrowDown, ArrowUp, + Banknote, + Building2, + CalendarClock, + CalendarDays, + CalendarRange, + ChevronDown, + Coins, + Hash, + ListOrdered, + ListPlus, + Mail, + MapPin, + Package, Pencil, + Phone, Plus, RefreshCw, Settings2, Trash2, + Weight, } from "lucide-react"; import { PageContainer, PageHeader } from "@/components/page"; @@ -41,7 +60,7 @@ import { import type { ContractTemplateArticle } from "@/services/contract-templates.service"; const BODY_HINT = - 'One clause per line — clauses are numbered automatically. Prefix a line with "- " to nest it as a bullet under the previous clause. Placeholders like {{client.companyName}}, {{contractDate}}, {{contractYear}} and {{reference}} are filled from the contract.'; + 'One clause per line — clauses are numbered automatically. Prefix a line with "- " to nest it as a bullet under the previous clause. Use the buttons above to drop a placeholder at the cursor — it is filled from the contract when the document is generated.'; interface ArticleDraft { id?: string; @@ -49,6 +68,254 @@ interface ArticleDraft { body: string; } +interface PlaceholderDef { + token: string; + label: string; + icon: typeof Building2; + hint: string; +} + +/** + * Placeholders the renderer fills from the contract view model + * (contract-view-model.builder.ts). Quick row = the ones template authors + * reach for constantly; the rest live in the grouped "More" menu. + */ +const QUICK_PLACEHOLDERS: PlaceholderDef[] = [ + { + token: "{{client.companyName}}", + label: "Client name", + icon: Building2, + hint: "Company name of the contracting client", + }, + { + token: "{{reference}}", + label: "Reference", + icon: Hash, + hint: "Contract reference number", + }, + { + token: "{{contractDate}}", + label: "Contract date", + icon: CalendarDays, + hint: "Full signature date of the contract", + }, + { + token: "{{contractYear}}", + label: "Contract year", + icon: CalendarRange, + hint: "Year the contract is signed", + }, + { + token: "{{pricing.totalAmount}}", + label: "Total price", + icon: Banknote, + hint: "Total contract price from the pricing schedule", + }, +]; + +const MORE_PLACEHOLDER_GROUPS: { label: string; items: PlaceholderDef[] }[] = [ + { + label: "Client", + items: [ + { + token: "{{client.companyAddress}}", + label: "Client address", + icon: MapPin, + hint: "Street address of the client", + }, + { + token: "{{client.companyLocation}}", + label: "Client location", + icon: MapPin, + hint: "Region / city of the client", + }, + { + token: "{{client.phone}}", + label: "Client phone", + icon: Phone, + hint: "Client phone number", + }, + { + token: "{{client.email}}", + label: "Client email", + icon: Mail, + hint: "Client email address", + }, + { + token: "{{client.tinNumber}}", + label: "Client TIN", + icon: Hash, + hint: "Client tax identification number", + }, + ], + }, + { + label: "Route & cargo", + items: [ + { + token: "{{schedule.originLabel}}", + label: "Origin", + icon: MapPin, + hint: "Origin yard / station", + }, + { + token: "{{schedule.destinationLabel}}", + label: "Destination", + icon: MapPin, + hint: "Destination yard / station", + }, + { + token: "{{schedule.serviceType}}", + label: "Service type", + icon: Settings2, + hint: "Contracted service type name", + }, + { + token: "{{schedule.cargoDescription}}", + label: "Cargo description", + icon: Package, + hint: "Description of the cargo", + }, + { + token: "{{schedule.totalWeightVgm}}", + label: "Total weight", + icon: Weight, + hint: "Total verified gross mass", + }, + { + token: "{{schedule.equipmentReturn}}", + label: "Equipment return", + icon: RefreshCw, + hint: "Empty-equipment return terms", + }, + { + token: "{{schedule.scheduledDate}}", + label: "Scheduled date", + icon: CalendarClock, + hint: "Scheduled shipment date", + }, + ], + }, + { + label: "Pricing", + items: [ + { + token: "{{pricing.currency}}", + label: "Currency", + icon: Coins, + hint: "Payment currency (e.g. USD)", + }, + ], + }, + { + label: "Service provider (EDR)", + items: [ + { + token: "{{provider.name}}", + label: "Provider name", + icon: Building2, + hint: "EDR legal company name", + }, + { + token: "{{provider.address}}", + label: "Provider address", + icon: MapPin, + hint: "EDR principal place of business", + }, + { + token: "{{provider.phone}}", + label: "Provider phone", + icon: Phone, + hint: "EDR phone number", + }, + { + token: "{{provider.email}}", + label: "Provider email", + icon: Mail, + hint: "EDR email address", + }, + ], + }, +]; + +const ALL_PLACEHOLDERS: PlaceholderDef[] = [ + ...QUICK_PLACEHOLDERS, + ...MORE_PLACEHOLDER_GROUPS.flatMap((g) => g.items), +]; + +const KNOWN_TOKENS = new Set(ALL_PLACEHOLDERS.map((p) => p.token)); + +/** Any {{…}} tokens in the text the renderer does not know how to fill. */ +function unknownTokens(text: string): string[] { + const found = text.match(/\{\{[^{}]+\}\}/g) ?? []; + return [...new Set(found.filter((t) => !KNOWN_TOKENS.has(t)))]; +} + +interface ParsedClause { + text: string; + bullets: string[]; +} + +interface ParsedBody { + /** Set (instead of clauses) when the body is one plain paragraph. */ + paragraph?: string; + clauses: ParsedClause[]; +} + +/** + * Mirror of the API renderer's rules (contract-article.util.ts): one clause per + * line, "- " nests a bullet under the previous clause, and a single bullet-less + * clause renders as a plain paragraph instead of a numbered list of one. + */ +function parseArticleBody(body: string): ParsedBody { + const clauses: ParsedClause[] = []; + for (const raw of body.split("\n")) { + const line = raw.trim(); + if (!line) continue; + if (line.startsWith("- ") && clauses.length > 0) { + clauses[clauses.length - 1].bullets.push(line.slice(2).trim()); + } else { + clauses.push({ text: line.replace(/^- /, ""), bullets: [] }); + } + } + if (clauses.length === 1 && clauses[0].bullets.length === 0) { + return { paragraph: clauses[0].text, clauses: [] }; + } + return { clauses }; +} + +/** Render clause text with {{placeholders}} highlighted as green chips. */ +function HighlightedText({ text }: { text: string }) { + const parts = text.split(/(\{\{[^{}]+\}\})/g); + return ( + <> + {parts.map((part, i) => + /^\{\{[^{}]+\}\}$/.test(part) ? ( + + {part} + + ) : ( + {part} + ), + )} + + ); +} + export default function ContractTemplateEditorPage() { const { code } = useParams<{ code: string }>(); const { data: template, isLoading } = useContractTemplate(code); @@ -79,15 +346,12 @@ export default function ContractTemplateEditorPage() { ); }; - const saveArticle = () => { + const saveArticle = (values: { title: string; body: string }) => { if (!articleDraft) return; if (articleDraft.id) { - updateArticle.mutate({ - articleId: articleDraft.id, - payload: { title: articleDraft.title, body: articleDraft.body }, - }); + updateArticle.mutate({ articleId: articleDraft.id, payload: values }); } else { - addArticle.mutate({ title: articleDraft.title, body: articleDraft.body }); + addArticle.mutate(values); } setArticleDraft(null); }; @@ -264,55 +528,14 @@ export default function ContractTemplateEditorPage() {
{/* ── Add / edit article modal ───────────────────────────────────── */} - setArticleDraft(null)} - title={articleDraft?.id ? "Edit article" : "Add article"} - size="xl" - > - {articleDraft && ( - - - setArticleDraft({ ...articleDraft, title: event.currentTarget.value }) - } - required - /> -