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 - /> -