From 5c33ea90bdc72013957f51033708984934d62a8e Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Mon, 3 Aug 2026 13:11:02 +0000 Subject: [PATCH 01/11] Truck operations inside each bookings --- .../bookings/detail/BookingTrucksPanel.tsx | 255 +++--------------- .../src/pages/warehouses/ImportTrucksPage.tsx | 6 +- 2 files changed, 38 insertions(+), 223 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingTrucksPanel.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingTrucksPanel.tsx index 1a77632ec..4427367cb 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingTrucksPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingTrucksPanel.tsx @@ -1,13 +1,11 @@ import { useMemo, useState } from "react"; import { useQueries, useQuery } from "@tanstack/react-query"; -import { Badge, Button, Center, Group, Loader, SimpleGrid, Stack, Table, Text } from "@mantine/core"; +import { Button, Center, Group, Loader, SimpleGrid, Stack, Table, Text } from "@mantine/core"; import { Coins, Truck } from "lucide-react"; import { api } from "@/services/api"; -import { warehouseService } from "@/services/warehouse.service"; -import { lastMileService } from "@/services/last-mile.service"; import { FeePreviewModal } from "@/components/warehouses/FeePreviewModal"; -import { TruckDetentionModal } from "@/components/operations/TruckDetentionModal"; +import { groupByBooking, TruckRows } from "@/pages/warehouses/ImportTrucksPage"; import { SectionCard } from "./SectionCard"; import { MetricTile } from "./MetricTile"; @@ -15,43 +13,14 @@ import { MetricTile } from "./MetricTile"; const money = (amount: number, currency: string) => `${Number(amount).toLocaleString()} ${currency === "ETB" ? "Birr (ETB)" : currency}`; -const fmt = (iso: string | null | undefined) => (iso ? new Date(iso).toLocaleString() : "—"); - -function inspectionLabel(status: string | null | undefined): { text: string; color: string } { - if (!status) return { text: "Pending", color: "gray" }; - if (status === "PASSED") return { text: "Passed", color: "edr-green" }; - if (status === "FAILED") return { text: "Failed", color: "red" }; - return { text: status, color: "gray" }; -} - -interface TruckRow { - key: string; - plate: string; - driver: string | null; - truckType: string | null; - containers: string[]; - warehouseArrived: string | null; - warehouseDeparted: string | null; - destinationArrived: string | null; - returned: string | null; - detentionOpen: boolean; - detentionDays: number | null; - detentionAmount: number | null; - hasDetentionRule: boolean; - inspection: { text: string; color: string }; -} - /** - * Every truck tied to a booking's last mile — EDR-dispatched or customer - * self-haul (a booking only ever uses one), each with its own warehouse-gate - * and destination-detention clocks, plus the booking's cargo-side cost totals - * (storage/demurrage/double handling — billed per row internally, always - * shown here as one booking-level total). Detention stays EDR-only; customer - * self-haul rows show "—" since EDR only bills detention on its own fleet. + * Cargo costs (booking-level totals) plus the same truck-import block the + * Unloaded Queue's "Import trucks" view uses — Truck Arrival/Leaving, Exit + * paper, Handover, Inspect, Detention times, Warehouse gate times — reused + * as-is so this tab never drifts from that queue's behavior. */ export function BookingTrucksPanel({ bookingId }: { bookingId: string }) { const [feeModalOpen, setFeeModalOpen] = useState(false); - const [detentionModalOpen, setDetentionModalOpen] = useState(false); const inventoryQuery = useQuery( api.warehouses.listInventory.queryOptions({ input: { filter: { bookingId } } }), @@ -59,47 +28,27 @@ export function BookingTrucksPanel({ bookingId }: { bookingId: string }) { const inventoryItems = inventoryQuery.data ?? []; const latestInventory = inventoryItems[0] ?? null; - const edrTrucksQuery = useQuery({ - queryKey: ["booking-edr-trucks", bookingId], - queryFn: () => warehouseService.getLastMileTrucks(bookingId), - }); - const edrTrucks = edrTrucksQuery.data ?? []; - - const customerTrucksQuery = useQuery({ - queryKey: ["booking-customer-trucks", bookingId], - queryFn: () => warehouseService.getCustomerTrucks(bookingId), - enabled: edrTrucksQuery.isSuccess && edrTrucks.length === 0, - }); - const customerTrucks = customerTrucksQuery.data ?? []; - - const mode: "EDR" | "CUSTOMER" | "NONE" = - edrTrucks.length > 0 ? "EDR" : customerTrucks.length > 0 ? "CUSTOMER" : "NONE"; - - const containerItemsQuery = useQuery({ - queryKey: ["booking-container-items-for-trucks", bookingId], - queryFn: () => warehouseService.getContainerItems(bookingId), - }); - const inspectionByContainer = new Map( - (containerItemsQuery.data ?? []).map((c) => [c.containerNumber, c.inspectionStatus]), + // Same query key as the Unloaded Queue page — shares its cache instead of + // refetching the whole queue when it's already loaded elsewhere. + const unloadedQuery = useQuery(api.warehouses.importUnloadedQueue.queryOptions({})); + const bookingRows = useMemo( + () => (unloadedQuery.data ?? []).filter((row) => row.bookingId === bookingId), + [unloadedQuery.data, bookingId], ); - - const lastMileId = edrTrucks[0]?.lastMileId ?? null; - - const detentionPreviewQuery = useQuery({ - queryKey: ["truck-detention-preview-for-trucks-tab", lastMileId], - queryFn: () => lastMileService.truckDetentionPreview(lastMileId as string).then((r) => r.data), - enabled: Boolean(lastMileId), - }); - const detentionPreview = detentionPreviewQuery.data; - const detentionByVehicle = new Map( - (detentionPreview?.groups ?? []).map((g) => [g.vehicleId ?? "", g]), - ); - - const lastMileRecordQuery = useQuery({ - queryKey: ["last-mile-record-for-trucks-tab", lastMileId], - queryFn: () => lastMileService.getById(lastMileId as string).then((r) => r.data), - enabled: Boolean(lastMileId), - }); + const group = useMemo(() => { + const [existing] = groupByBooking(bookingRows); + return ( + existing ?? { + bookingId, + bookingReference: bookingId, + customerName: null, + trainSchedule: null, + status: "NONE", + arrivalTime: null, + rows: [], + } + ); + }, [bookingRows, bookingId]); // Booking-level cost strip: same per-row fee preview the accrual dashboard // and FeePreviewModal already use, summed across every inventory row on @@ -114,61 +63,7 @@ export function BookingTrucksPanel({ bookingId }: { bookingId: string }) { const sumByType = (type: string) => allFees.filter((f) => f.ruleType === type).reduce((sum, f) => sum + Number(f.amount || 0), 0); - const rows: TruckRow[] = useMemo(() => { - if (mode === "EDR") { - return edrTrucks.map((t) => { - const g = detentionByVehicle.get(t.vehicleId); - return { - key: t.vehicleId, - plate: [t.truckPlateNumber, t.trailerPlateNumber].filter(Boolean).join(" + ") || "—", - driver: t.driverName, - truckType: t.truckType, - containers: t.containerNumber ? [t.containerNumber] : [], - warehouseArrived: t.arrivedAt, - warehouseDeparted: t.departedAt, - destinationArrived: g?.startDate ?? null, - returned: g?.endIsOpen ? null : g?.endDate ?? null, - detentionOpen: Boolean(g?.endIsOpen), - detentionDays: g?.chargeableDays ?? null, - detentionAmount: g?.amount ?? null, - hasDetentionRule: Boolean(g?.ruleId), - inspection: inspectionLabel(t.containerNumber ? inspectionByContainer.get(t.containerNumber) : undefined), - }; - }); - } - if (mode === "CUSTOMER") { - return customerTrucks.map((t) => { - const containers = (t.containers ?? []).map((c) => c.containerNumber); - const statuses = new Set(containers.map((cn) => inspectionByContainer.get(cn) ?? null)); - const inspection = - containers.length === 0 - ? inspectionLabel(undefined) - : statuses.size > 1 - ? { text: "Mixed", color: "yellow" } - : inspectionLabel([...statuses][0]); - return { - key: t.id, - plate: t.plateNumber, - driver: t.driverName, - truckType: t.truckType, - containers, - warehouseArrived: t.arrivedAt ?? null, - warehouseDeparted: t.departedAt ?? null, - destinationArrived: null, - returned: null, - detentionOpen: false, - detentionDays: null, - detentionAmount: null, - hasDetentionRule: false, - inspection, - }; - }); - } - return []; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [mode, edrTrucks, customerTrucks, inspectionByContainer, detentionByVehicle]); - - if (inventoryQuery.isLoading || edrTrucksQuery.isLoading) { + if (inventoryQuery.isLoading || unloadedQuery.isLoading) { return (
@@ -201,87 +96,14 @@ export function BookingTrucksPanel({ bookingId }: { bookingId: string }) { - setDetentionModalOpen(true)}> - Detention times - - ) - } - > - {rows.length === 0 ? ( - - No trucks assigned to this booking's last mile yet. - - ) : ( - - - - - Plate - Driver - Type - Container(s) - Wh. arrived - Wh. departed - Dest. arrived - Returned - Detention - Inspection - - - - {rows.map((r) => ( - - {r.plate} - {r.driver ?? "—"} - {r.truckType ?? "—"} - {r.containers.length ? r.containers.join(", ") : "—"} - {fmt(r.warehouseArrived)} - {fmt(r.warehouseDeparted)} - {fmt(r.destinationArrived)} - - {r.detentionOpen ? ( - - still out - - ) : ( - fmt(r.returned) - )} - - - {mode !== "EDR" || r.detentionDays == null ? ( - "—" - ) : ( - <> - {r.detentionDays}d · {money(r.detentionAmount ?? 0, detentionPreview?.currency ?? "USD")} - {!r.hasDetentionRule && ( - - {" "} - · no rule - - )} - - )} - - - - {r.inspection.text} - - - - ))} - -
-
- )} + + + + + + +
+
setFeeModalOpen(false)} inventoryId={latestInventory?.id ?? null} /> - {mode === "EDR" && ( - setDetentionModalOpen(false)} - record={lastMileRecordQuery.data ?? null} - /> - )} ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/ImportTrucksPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/ImportTrucksPage.tsx index 8072b67d5..bf10e86aa 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/ImportTrucksPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/ImportTrucksPage.tsx @@ -82,7 +82,7 @@ const money = (amount: number, currency: string) => const formatTime = (iso: string | null | undefined) => iso ? new Date(iso).toLocaleString() : "—"; -interface BookingGroup { +export interface BookingGroup { bookingId: string; bookingReference: string; customerName: string | null; @@ -93,7 +93,7 @@ interface BookingGroup { } /** One collapsed line per booking; its inventory rows travel with it for the fee lookup. */ -function groupByBooking(items: ImportUnloadedItem[]): BookingGroup[] { +export function groupByBooking(items: ImportUnloadedItem[]): BookingGroup[] { const groups = new Map(); for (const item of items) { if (!item.bookingId) continue; @@ -140,7 +140,7 @@ interface TruckRow { * Haulage mode is decided by which list comes back non-empty — a booking is * either EDR last mile or customer self-haul, never both. */ -function TruckRows({ group }: { group: BookingGroup }) { +export function TruckRows({ group }: { group: BookingGroup }) { const { toast } = useToast(); const queryClient = useQueryClient(); const [busy, setBusy] = useState(false); From dfc7dfec5a708a4ef68f35b95ef2f3d0531581c5 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Tue, 4 Aug 2026 06:54:52 +0000 Subject: [PATCH 02/11] fix(freight): filter container returns by returned-by truck type Top Returned Containers table ignored the EDR/Customer tab because the backend never persisted which truck type performed the return. Added returned_by column + DTO/entity field, wired create payload to send it, and filtered the table by the active tab. --- .../3210000000000-EmptyContainerReturnedBy.ts | 19 + .../src/modules/bookings/bookings.service.ts | 10 +- .../dto/import-operations.dto.ts | 5 + .../entities/empty-container-return.entity.ts | 3 + .../import-operations.service.ts | 1 + apps/edr-freight-web/backoffice/src/App.tsx | 6 - .../warehouses/GrnDocumentButton.tsx | 54 ++ .../warehouses/ReceiveInventoryModal.tsx | 47 +- .../warehouses/WarehouseInventoryTable.tsx | 51 +- .../src/pages/warehouses/ArrivalQueuePage.tsx | 464 +----------------- .../pages/warehouses/ContainerReturnsPage.tsx | 103 +++- .../src/pages/warehouses/ImportTrucksPage.tsx | 11 +- .../backoffice/src/types/importOperations.ts | 2 + 13 files changed, 198 insertions(+), 578 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3210000000000-EmptyContainerReturnedBy.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/warehouses/GrnDocumentButton.tsx diff --git a/apps/edr-freight-api/src/migrations/3210000000000-EmptyContainerReturnedBy.ts b/apps/edr-freight-api/src/migrations/3210000000000-EmptyContainerReturnedBy.ts new file mode 100644 index 000000000..b6f23b19e --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3210000000000-EmptyContainerReturnedBy.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class EmptyContainerReturnedBy3210000000000 implements MigrationInterface { + name = 'EmptyContainerReturnedBy3210000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.empty_container_returns + ADD COLUMN IF NOT EXISTS returned_by varchar(20) NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.empty_container_returns + DROP COLUMN IF EXISTS returned_by + `); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 67ac73a58..ed134d683 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -28,6 +28,7 @@ import { EventEmitter2 } from '@nestjs/event-emitter'; import { DataSource, In } from 'typeorm'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; +import { assertExportReceivedWithGrn } from '../../common/export-received-gate'; import { Yard } from '../rule-engine/entities/yard.entity'; import { ServiceType } from '../rule-engine/entities/service-type.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; @@ -239,6 +240,10 @@ export class BookingsService { */ async carriageAcceptanceSheet(bookingId: string): Promise<{ filename: string; buffer: Buffer }> { const booking = await this.findById(bookingId); + // The sheet attests that EDR has taken custody. For export that happens at + // cargo receipt (GRN), so the GRN is required even when wagons are already + // allocated — an allocation is a plan, not possession. + await assertExportReceivedWithGrn(this.dataSource, booking); let wagons: CarriageAcceptanceWagonRow[] = await this.dataSource.query( `SELECT tsw.sequence_no AS "sequenceNo", COALESCE(wt.code, wt.name) AS "wagonType", @@ -291,7 +296,10 @@ export class BookingsService { LEFT JOIN freight.containers c ON c.id = inv.container_id AND c.deleted_at IS NULL WHERE inv.booking_id = $1 AND inv.deleted_at IS NULL - AND COALESCE(NULLIF(TRIM(inv.grn_number), ''), '') <> '' + AND COALESCE( + NULLIF(TRIM(inv.grn_number), ''), + substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)') + ) IS NOT NULL ORDER BY inv.created_at`, [bookingId], ) diff --git a/apps/edr-freight-api/src/modules/import-operations/dto/import-operations.dto.ts b/apps/edr-freight-api/src/modules/import-operations/dto/import-operations.dto.ts index 1cf4c72e5..b3ec979b4 100644 --- a/apps/edr-freight-api/src/modules/import-operations/dto/import-operations.dto.ts +++ b/apps/edr-freight-api/src/modules/import-operations/dto/import-operations.dto.ts @@ -169,6 +169,11 @@ export class CreateEmptyContainerReturnDto { @IsOptional() @IsString() performedBy?: string; + + @ApiPropertyOptional({ enum: ['EDR', 'CUSTOMER'] }) + @IsOptional() + @IsIn(['EDR', 'CUSTOMER']) + returnedBy?: 'EDR' | 'CUSTOMER'; } export class UpdateEmptyContainerReturnStatusDto extends ImportOperationActionDto { diff --git a/apps/edr-freight-api/src/modules/import-operations/entities/empty-container-return.entity.ts b/apps/edr-freight-api/src/modules/import-operations/entities/empty-container-return.entity.ts index 727aee322..b213a520a 100644 --- a/apps/edr-freight-api/src/modules/import-operations/entities/empty-container-return.entity.ts +++ b/apps/edr-freight-api/src/modules/import-operations/entities/empty-container-return.entity.ts @@ -53,4 +53,7 @@ export class EmptyContainerReturn extends BaseEntity { @Column({ name: 'performed_by', type: 'varchar', length: 120, nullable: true }) performedBy?: string | null; + + @Column({ name: 'returned_by', type: 'varchar', length: 20, nullable: true }) + returnedBy?: 'EDR' | 'CUSTOMER' | null; } diff --git a/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts b/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts index bbc1228a5..02260cf39 100644 --- a/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts +++ b/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts @@ -161,6 +161,7 @@ export class ImportOperationsService { condition: dto.condition ?? null, handoverNote: dto.handoverNote ?? null, performedBy: dto.performedBy ?? null, + returnedBy: dto.returnedBy ?? null, }), ); } diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index d4892d198..4dc1fb9e9 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -435,12 +435,6 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.warehouseInventory.view, }, - { - label: "Dispatch Queue", - href: "/dashboard/dispatch-queue", - icon: , - permission: FREIGHT_PERMS.warehouseInventory.view, - }, { label: "Terminal Inventory", href: "/dashboard/warehouse-inventory?direction=IMPORT", diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/GrnDocumentButton.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/GrnDocumentButton.tsx new file mode 100644 index 000000000..41da007fd --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/GrnDocumentButton.tsx @@ -0,0 +1,54 @@ +import { useState, type MouseEvent } from 'react'; +import { Button } from '@mantine/core'; +import { FileText } from 'lucide-react'; + +import { useToast } from '@/hooks/use-toast'; +import { warehouseService } from '@/services/warehouse.service'; +import { extractDownloadErrorMessage } from './options'; +import { openPdfBlob } from './pdf'; + +/** Opens (or downloads) an inventory item's GRN document — same button everywhere it appears. */ +export function GrnDocumentButton({ + inventoryId, + grnNumber, +}: { + inventoryId: string; + grnNumber?: string | null; +}) { + const { toast } = useToast(); + const [loading, setLoading] = useState(false); + + const openDocument = async (event: MouseEvent) => { + event.stopPropagation(); + if (!grnNumber) { + toast({ variant: 'destructive', title: 'GRN document unavailable', description: 'This item has no GRN number yet.' }); + return; + } + setLoading(true); + const pdfWindow = window.open('', '_blank'); + try { + const response = await warehouseService.downloadGrnDocument(inventoryId); + const opened = openPdfBlob(response.data, `grn-${grnNumber}.pdf`, pdfWindow); + toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' }); + } catch (error) { + pdfWindow?.close(); + toast({ variant: 'destructive', title: 'GRN document failed', description: await extractDownloadErrorMessage(error) }); + } finally { + setLoading(false); + } + }; + + return ( + + ); +} 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 cfbdd7b82..8ea4ade67 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -1,4 +1,4 @@ -import { Fragment, useEffect, useMemo, useState, type MouseEvent } from 'react'; +import { Fragment, useEffect, useMemo, useState } from 'react'; import { ActionIcon, Alert, @@ -71,6 +71,7 @@ import { BookingSelect } from './BookingSelect'; import { DeliverInventoryModal } from './DeliverInventoryModal'; import { ContainerItemsModal } from './ContainerItemsModal'; import { FeePreviewModal } from './FeePreviewModal'; +import { GrnDocumentButton } from './GrnDocumentButton'; import { InspectionReportModal } from './InspectionReportModal'; import { InventoryDetailModal } from './InventoryDetailModal'; import { InventoryHistoryModal } from './InventoryHistoryModal'; @@ -98,44 +99,6 @@ interface ReceiveInventoryModalProps { onReceived?: () => void; } -function GrnDocumentButton({ inventoryId, grnNumber }: { inventoryId: string; grnNumber?: string | null }) { - const { toast } = useToast(); - const [loading, setLoading] = useState(false); - - const openDocument = async (event: MouseEvent) => { - event.stopPropagation(); - if (!grnNumber) { - toast({ variant: 'destructive', title: 'GRN document unavailable', description: 'This item has no GRN number yet.' }); - return; - } - setLoading(true); - const pdfWindow = window.open('', '_blank'); - try { - const response = await warehouseService.downloadGrnDocument(inventoryId); - const opened = openPdfBlob(response.data, `grn-${grnNumber}.pdf`, pdfWindow); - toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' }); - } catch (error) { - pdfWindow?.close(); - toast({ variant: 'destructive', title: 'GRN document failed', description: await extractDownloadErrorMessage(error) }); - } finally { - setLoading(false); - } - }; - - return ( - - ); -} interface Location { warehouseId: string; @@ -1513,7 +1476,7 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged @@ -2228,7 +2191,7 @@ const getPendingUnloadBookings = (train: ImportTrain) => const isFullyUnloaded = (train: ImportTrain) => Boolean(train.fullyUnloaded) || (train.totalBookings > 0 && getPendingUnloadBookings(train) === 0); -function ImportArriveQueueTab({ +export function ImportArriveQueueTab({ enabled, onChanged, }: { @@ -2769,7 +2732,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { {r.handoverDocumentReference ? 'View handover' : 'Handover'} )} - setInspectId(r.id)}>Inspect / report + setInspectId(r.id)}>Inspection / Report {/* Double handling is decided once the goods are off the wagon (every row here is unloaded) — Yes is what makes the fee rule bill this booking. */} diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx index 5eacf7fc1..d224db40b 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx @@ -1,9 +1,7 @@ -import { Fragment, useState, type MouseEvent } from 'react'; +import { Fragment, useState } from 'react'; import { ActionIcon, Badge, Button, Checkbox, Group, Table, Text, Tooltip } from '@mantine/core'; import { ArrowRightLeft, ChevronDown, ChevronRight, ClipboardList, Coins, Download, Eye, FileText, History, MapPin } from 'lucide-react'; -import { useToast } from '@/hooks/use-toast'; -import { warehouseService } from '@/services/warehouse.service'; import { getNextInventoryAction, type InventoryAction, @@ -11,8 +9,8 @@ import { } from '@/types/warehouse'; import { InventoryStatusBadge } from './badges'; import { TruckBreakdownRow } from './TruckBreakdownRow'; -import { extractDownloadErrorMessage, formatDate, formatNumber, humanizeEnum } from './options'; -import { openPdfBlob } from './pdf'; +import { GrnDocumentButton } from './GrnDocumentButton'; +import { formatDate, formatNumber, humanizeEnum } from './options'; interface WarehouseInventoryTableProps { items: WarehouseInventoryItem[]; @@ -64,45 +62,6 @@ const noteLineValue = (notes: string | null | undefined, label: string) => { const handoverDocumentReference = (item: WarehouseInventoryItem) => item.handoverDocumentReference ?? noteLineValue(item.notes, 'Handover Reference'); -function GrnDocumentButton({ item }: { item: WarehouseInventoryItem }) { - const { toast } = useToast(); - const [loading, setLoading] = useState(false); - - const openDocument = async (event: MouseEvent) => { - event.stopPropagation(); - if (!item.grnNumber) { - toast({ variant: 'destructive', title: 'GRN document unavailable', description: 'This item has no GRN number yet.' }); - return; - } - setLoading(true); - const pdfWindow = window.open('', '_blank'); - try { - const response = await warehouseService.downloadGrnDocument(item.id); - const opened = openPdfBlob(response.data, `grn-${item.grnNumber}.pdf`, pdfWindow); - toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' }); - } catch (error) { - pdfWindow?.close(); - toast({ variant: 'destructive', title: 'GRN document failed', description: await extractDownloadErrorMessage(error) }); - } finally { - setLoading(false); - } - }; - - return ( - - ); -} - export function WarehouseInventoryTable({ items, busyId, @@ -239,7 +198,7 @@ export function WarehouseInventoryTable({ )} - + {item.warehouse?.facility?.name ?? '-'} {item.warehouse?.code ?? '-'} @@ -341,7 +300,7 @@ export function WarehouseInventoryTable({ )} {onDownloadBundle && item.grnNumber && ( - + onDownloadBundle(item)}> diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx index 8bb1080d9..5304f8b2c 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx @@ -1,338 +1,11 @@ -import { Fragment, useEffect, useMemo, useState } from 'react'; -import { - Badge, - Button, - Card, - Group, - Loader, - Select, - Stack, - Table, - Text, -} from '@mantine/core'; -import { ChevronDown, ChevronRight, PackageOpen, Truck } from 'lucide-react'; +import { Card } from '@mantine/core'; import { PageContainer, PageHeader } from '@/components/page'; -import { - VisualEmptyState, - WarehouseOpsKpiStrip, - formatDate, - formatNumber, - warehousesAtStation, - yardsForBooking, -} from '@/components/warehouses'; -import { - useAutoUnloadArrivedBookings, - useAllWarehouseYards, - useAllWarehouseZones, - useImportArriveQueue, - useImportTrainItems, - useWarehouses, -} from '@/hooks/useWarehouses'; -import { useToast } from '@/hooks/use-toast'; -import type { AutoUnloadArrivedResult, ImportTrain, ImportTrainItem, Warehouse, WarehouseYard, WarehouseZone } from '@/types/warehouse'; - -type UnloadAssignment = { bookingId: string; warehouseId: string; yardId: string; zoneId: string }; -type AssignmentDraft = Partial>; - -const getErrorMessage = (error: unknown) => { - if (error && typeof error === 'object' && 'response' in error) { - const response = (error as { response?: { data?: { message?: unknown } } }).response; - const message = response?.data?.message; - if (Array.isArray(message)) return message.join(', '); - if (typeof message === 'string') return message; - } - return error instanceof Error ? error.message : undefined; -}; - -const getPendingUnloadBookings = (train: ImportTrain) => - train.pendingUnloadBookings ?? train.totalBookings; - -const isFullyUnloaded = (train: ImportTrain) => - Boolean(train.fullyUnloaded) || (train.totalBookings > 0 && getPendingUnloadBookings(train) === 0); - -const isContainerFreight = (freightType: string | null | undefined) => - (freightType ?? '').toUpperCase() === 'CONTAINER'; - -function isUnloadPending(item: ImportTrainItem) { - return !item.currentStatus || item.currentStatus === 'RECEIVED'; -} - -function ImportTrainDetailRows({ - train, - warehouses, - yards, - zones, - assignments, - onAssignmentChange, - onReadyChange, -}: { - train: ImportTrain; - warehouses: Warehouse[]; - yards: WarehouseYard[]; - zones: WarehouseZone[]; - assignments: Record; - onAssignmentChange: (bookingId: string, draft: AssignmentDraft) => void; - onReadyChange: (ready: boolean) => void; -}) { - const { data: items = [], isLoading } = useImportTrainItems(train.scheduleId); - // A train only ever unloads at the warehouse actually sitting at its - // destination station — Indode's train never offers Sebeta's warehouse. - const scopedWarehouses = useMemo( - () => warehousesAtStation(warehouses, train.destinationStationId), - [warehouses, train.destinationStationId], - ); - const warehouseOptions = useMemo( - () => scopedWarehouses.map((warehouse) => ({ value: warehouse.id, label: `${warehouse.name} (${warehouse.code})` })), - [scopedWarehouses], - ); - // With exactly one warehouse at the station there is nothing to choose — - // pre-fill it so staff only has to pick yard/zone, not re-discover Indode. - useEffect(() => { - if (scopedWarehouses.length !== 1) return; - const onlyWarehouseId = scopedWarehouses[0].id; - items.filter(isUnloadPending).forEach((item) => { - if (!assignments[item.bookingId]?.warehouseId) { - onAssignmentChange(item.bookingId, { ...assignments[item.bookingId], warehouseId: onlyWarehouseId }); - } - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [scopedWarehouses, items]); - - // Once a booking's warehouse is known, its yard (and then zone) follow from - // what the cargo actually is — a Wheat booking only ever has one candidate - // yard (Dry Bulk) once Indode's real yard layout is configured, so staff - // never see a picker for something that isn't actually a choice. - useEffect(() => { - items.filter(isUnloadPending).forEach((item) => { - const draft = assignments[item.bookingId]; - if (!draft?.warehouseId) return; - - if (!draft.yardId) { - const candidateYards = yardsForBooking(yards, { - warehouseId: draft.warehouseId, - freightType: item.freightType, - tradeDirection: 'IMPORT', - cargoTypeCode: item.cargoTypeCode, - }); - if (candidateYards.length === 1) { - onAssignmentChange(item.bookingId, { ...draft, yardId: candidateYards[0].id }); - } - return; - } - - if (!draft.zoneId) { - const candidateZones = zones.filter((zone) => zone.yardId === draft.yardId); - if (candidateZones.length === 1) { - onAssignmentChange(item.bookingId, { ...draft, zoneId: candidateZones[0].id }); - } - } - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [assignments, items, yards, zones]); - - useEffect(() => { - const pending = items.filter(isUnloadPending); - onReadyChange( - pending.length > 0 && - pending.every((item) => { - const draft = assignments[item.bookingId]; - return Boolean(draft?.warehouseId && draft.yardId && draft.zoneId); - }), - ); - }, [assignments, items]); - - if (isLoading) { - return ( - - - - ); - } - - if (items.length === 0) { - return ( - - No assigned bookings found for this train. - - ); - } - - return ( - - - - Booking - Customer - Container - Cargo - Weight - Arrival - Status - Warehouse - Yard - Zone - Inspection - Pickup - - - - {items.map((item: ImportTrainItem) => { - const draft = assignments[item.bookingId] ?? {}; - const yardOptions = yardsForBooking(yards, { - warehouseId: draft.warehouseId, - freightType: item.freightType, - tradeDirection: 'IMPORT', - cargoTypeCode: item.cargoTypeCode, - }).map((yard) => ({ value: yard.id, label: `${yard.name} (${yard.code})` })); - // The yard is already scoped to what this cargo can go into — a - // zone's own type always matches its parent yard's purpose (see the - // Indode seed migration), so no separate zone-type filter is needed. - const zoneOptions = zones - .filter((zone) => zone.yardId === draft.yardId) - .map((zone) => ({ value: zone.id, label: `${zone.name} (${zone.code})` })); - const pending = isUnloadPending(item); - - return ( - - - - {item.bookingReference ?? item.bookingId.slice(0, 8)} - - - {item.customerName ?? '—'} - {item.containerNumber ?? '—'} - {item.cargoType ?? '—'} - {formatNumber(item.weight)} - {formatDate(item.arrivalTime)} - - - {item.currentStatus ?? 'PENDING'} - - - - - onAssignmentChange(item.bookingId, { ...draft, yardId: value ?? undefined, zoneId: undefined }) - } - searchable - disabled={!pending || !draft.warehouseId} - w={190} - /> - - -
- ); -} +import { WarehouseOpsKpiStrip } from '@/components/warehouses'; +import { ImportArriveQueueTab } from '@/components/warehouses/ReceiveInventoryModal'; /** Arrived import trains awaiting unload into warehouse inventory. */ export default function ArrivalQueuePage() { - const { toast } = useToast(); - const { data: trains = [], isLoading } = useImportArriveQueue(); - const { data: warehouses = [], isLoading: warehousesLoading } = useWarehouses({ status: 'ACTIVE' }); - const { data: yards = [] } = useAllWarehouseYards(); - const { data: zones = [] } = useAllWarehouseZones(); - const autoUnload = useAutoUnloadArrivedBookings(); - const [openScheduleId, setOpenScheduleId] = useState(null); - const [busyScheduleId, setBusyScheduleId] = useState(null); - const [assignmentsBySchedule, setAssignmentsBySchedule] = useState>>({}); - const [readyBySchedule, setReadyBySchedule] = useState>({}); - - const unloadTrain = async (train: ImportTrain) => { - const assignments = Object.entries(assignmentsBySchedule[train.scheduleId] ?? {}) - .filter((entry): entry is [string, Required] => - Boolean(entry[1].warehouseId && entry[1].yardId && entry[1].zoneId), - ) - .map(([bookingId, draft]) => ({ - bookingId, - warehouseId: draft.warehouseId, - yardId: draft.yardId, - zoneId: draft.zoneId, - })); - - if (!readyBySchedule[train.scheduleId] || assignments.length === 0) { - toast({ - variant: 'destructive', - title: 'Assign locations', - description: 'Select warehouse, yard and zone for each pending booking before unloading.', - }); - return; - } - - if (isFullyUnloaded(train)) { - toast({ - title: 'Already unloaded', - description: `${train.trainNumber ?? 'This train'} has no remaining bookings to auto unload.`, - }); - return; - } - - setBusyScheduleId(train.scheduleId); - try { - const res = (await autoUnload.mutateAsync({ scheduleId: train.scheduleId, assignments })) as { - data: AutoUnloadArrivedResult; - }; - const result = res.data; - const alreadyUnloaded = result.unloadedCount === 0 && result.skippedCount > 0 && result.failedCount === 0; - const firstReason = result.results.find((item) => item.reason)?.reason; - const details = [ - result.skippedCount ? `${result.skippedCount} skipped` : '', - result.failedCount ? `${result.failedCount} failed` : '', - ] - .filter(Boolean) - .join(', '); - - toast({ - title: alreadyUnloaded ? 'Already unloaded' : `${result.unloadedCount} booking(s) unloaded`, - description: alreadyUnloaded - ? firstReason ?? `${train.trainNumber ?? 'Train'} is already in warehouse inventory.` - : details || `${train.trainNumber ?? 'Train'} moved into warehouse inventory.`, - }); - } catch (error) { - toast({ - variant: 'destructive', - title: 'Auto unload failed', - description: getErrorMessage(error), - }); - } finally { - setBusyScheduleId(null); - } - }; - return ( - - - {trains.length} arrived import train(s) - - Open a train, assign each booking to a warehouse yard and zone, then unload it. - - - - - {isLoading ? ( - - - - ) : trains.length === 0 ? ( - - ) : ( - - - - - Train - Route - Origin - Destination - Arrival - Bookings - Containers - Cargoes - Status - Actions - - - - {trains.map((train: ImportTrain) => { - const isOpen = openScheduleId === train.scheduleId; - const fullyUnloaded = isFullyUnloaded(train); - const unloadedBookings = train.unloadedBookings ?? train.totalBookings - getPendingUnloadBookings(train); - return ( - - - - - - {train.trainNumber ?? '—'} - - - {train.scheduleId.slice(0, 8)} - - - - {train.route ?? '—'} - {train.origin ?? '—'} - {train.destination ?? '—'} - - {formatDate(train.arrivalTime)} - - {train.totalBookings} - {train.totalContainers} - {train.totalCargoes} - - - - {train.status} - - - {Math.max(unloadedBookings, 0)}/{train.totalBookings} unloaded - - - - - - - - - - - {isOpen && ( - - - - setAssignmentsBySchedule((current) => ({ - ...current, - [train.scheduleId]: { - ...(current[train.scheduleId] ?? {}), - [bookingId]: draft.warehouseId - ? draft - : {}, - }, - })) - } - onReadyChange={(ready) => - setReadyBySchedule((current) => ({ ...current, [train.scheduleId]: ready })) - } - /> - - - )} - - ); - })} - -
-
- )} +
); diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/ContainerReturnsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/ContainerReturnsPage.tsx index 36745db3b..699e52f0c 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/ContainerReturnsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/ContainerReturnsPage.tsx @@ -27,9 +27,30 @@ import { useWarehouseYards, useWarehouseZones } from "@/hooks/useWarehouses"; import { api } from "@/services/api"; import { warehouseService } from "@/services/warehouse.service"; import { importOperationsService } from "@/services/importOperations.service"; +import type { EmptyContainerReturnStatus } from "@/types/importOperations"; type ReturnType = "all" | "edr" | "customer"; +const RETURN_STATUS_ORDER: EmptyContainerReturnStatus[] = [ + "RETURNED", + "ASSIGNED_STORAGE", + "DOCUMENTATION_CLEARED", + "WAGON_ALLOCATED", + "TRANSPORTED_TO_DJIBOUTI", + "HANDOVER_ISSUED", + "COMPLETED", +]; + +const RETURN_STATUS_LABEL: Record = { + RETURNED: "Returned", + ASSIGNED_STORAGE: "Assigned Storage", + DOCUMENTATION_CLEARED: "Documentation Cleared", + WAGON_ALLOCATED: "Wagon Allocated", + TRANSPORTED_TO_DJIBOUTI: "Transported to Djibouti", + HANDOVER_ISSUED: "Handover Issued", + COMPLETED: "Completed", +}; + interface ContainerReturnRow { key: string; containerNumber: string; @@ -164,6 +185,11 @@ export default function ContainerReturnsPage() { enabled: bookingIds.length > 0 && !queueLoading, }); + const filteredReturnedContainers = useMemo(() => { + if (filterType === "all") return returnedContainers; + return returnedContainers.filter((ret: any) => ret.returnedBy === filterType.toUpperCase()); + }, [returnedContainers, filterType]); + const allGroups = useMemo(() => containerReturnsQuery.data ?? [], [containerReturnsQuery.data]); const filteredGroups = useMemo(() => { if (filterType === "all") return allGroups; @@ -202,6 +228,7 @@ export default function ContainerReturnsPage() { facility: container.warehouse, condition: container.condition, handoverNote: container.handoverNote, + returnedBy: truck.returnType, }); results.push(result); } @@ -223,6 +250,26 @@ export default function ContainerReturnsPage() { }, }); + const advanceStatusMutation = useMutation({ + mutationFn: (id: string) => { + const current = returnedContainers.find((r: any) => r.id === id); + const nextIndex = RETURN_STATUS_ORDER.indexOf(current?.status ?? "RETURNED") + 1; + const status = RETURN_STATUS_ORDER[nextIndex] ?? "COMPLETED"; + return importOperationsService.updateEmptyReturnStatus(id, { status }); + }, + onSuccess: () => { + toast({ title: "Return status updated" }); + qc.invalidateQueries({ queryKey: ["empty-container-returns"] }); + }, + onError: (error: any) => { + toast({ + variant: "destructive", + title: "Failed to update return status", + description: error?.response?.data?.message || error?.message, + }); + }, + }); + const activeGroup = activeKey ? (filteredGroups.find((g) => `${g.returnType.toLowerCase()}-${g.bookingId}` === activeKey) ?? null) : null; if (queueLoading || containerReturnsQuery.isLoading) { @@ -257,7 +304,7 @@ export default function ContainerReturnsPage() {
- {returnedContainers.length > 0 && ( + {filteredReturnedContainers.length > 0 && ( <> Returned Containers @@ -266,27 +313,55 @@ export default function ContainerReturnsPage() { Container Number Booking Ref + Returned By Returned Date Facility Yard Condition Status + Action - {returnedContainers.map((ret: any) => ( - - {ret.containerNumber} - {ret.bookingId ? "Associated" : "—"} - {ret.returnDate ? new Date(ret.returnDate).toLocaleDateString() : "—"} - {ret.facility || "—"} - {ret.yard || "—"} - {ret.condition || "—"} - - {ret.status} - - - ))} + {filteredReturnedContainers.map((ret: any) => { + const nextStatus = RETURN_STATUS_ORDER[RETURN_STATUS_ORDER.indexOf(ret.status) + 1]; + return ( + + {ret.containerNumber} + {ret.bookingId ? "Associated" : "—"} + + {ret.returnedBy ? ( + + {ret.returnedBy === "EDR" ? "EDR Last Mile" : "Customer Self-Haul"} + + ) : ( + "—" + )} + + {ret.returnDate ? new Date(ret.returnDate).toLocaleDateString() : "—"} + {ret.facility || "—"} + {ret.yard || "—"} + {ret.condition || "—"} + + {RETURN_STATUS_LABEL[ret.status as EmptyContainerReturnStatus] ?? ret.status} + + + {nextStatus ? ( + + ) : ( + Done + )} + + + ); + })} diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/ImportTrucksPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/ImportTrucksPage.tsx index bf10e86aa..d2cc909f2 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/ImportTrucksPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/ImportTrucksPage.tsx @@ -423,14 +423,7 @@ export function TruckRows({ group }: { group: BookingGroup }) { disabled={!primaryId} onClick={() => openRelease(t)} > - Truck Arrival - - } - disabled={!primaryId} - onClick={() => openRelease(t)} - > - Truck Leaving + {t.arrivedAt ? 'Truck Arrival / Leaving' : 'Truck Arrival'} setInspectId(primaryId)} > - Inspect / report + Inspection / Report {isEdr && ( <> diff --git a/apps/edr-freight-web/backoffice/src/types/importOperations.ts b/apps/edr-freight-web/backoffice/src/types/importOperations.ts index 64214eddc..1712a36b7 100644 --- a/apps/edr-freight-web/backoffice/src/types/importOperations.ts +++ b/apps/edr-freight-web/backoffice/src/types/importOperations.ts @@ -102,6 +102,7 @@ export interface EmptyContainerReturn { status: EmptyContainerReturnStatus; wagonAllocationReference: string | null; performedBy: string | null; + returnedBy: 'EDR' | 'CUSTOMER' | null; } export interface CreateEmptyContainerReturnPayload { @@ -115,6 +116,7 @@ export interface CreateEmptyContainerReturnPayload { condition?: string; handoverNote?: string; performedBy?: string; + returnedBy?: 'EDR' | 'CUSTOMER'; } export interface UpdateEmptyContainerReturnStatusPayload extends ImportOperationActionPayload { From 85d5faed34f92f4e9900653c67e3f5679e09fc8a Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Tue, 4 Aug 2026 10:27:59 +0300 Subject: [PATCH 03/11] chore: ( iam ) update iam package --- apps/edr-passenger-api/package.json | 2 +- pnpm-lock.yaml | 424 ++++++++++++++++------------ 2 files changed, 238 insertions(+), 188 deletions(-) diff --git a/apps/edr-passenger-api/package.json b/apps/edr-passenger-api/package.json index f58cd7bcf..d99701c5a 100644 --- a/apps/edr-passenger-api/package.json +++ b/apps/edr-passenger-api/package.json @@ -46,7 +46,7 @@ "@prisma/client": "^6.19.3", "@sendgrid/mail": "^8.1.0", "@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz", - "@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.9.tgz", + "@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-1.0.0.tgz", "@types/bcrypt": "^6.0.0", "amqp-connection-manager": "^5.0.0", "amqplib": "^2.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2ea29ee73..66ba04c62 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -595,7 +595,7 @@ importers: version: 5.101.0(react@19.2.6) '@tria-plc/iamui': specifier: file:../../../local-packages/tria-plc-iamui-0.1.1.tgz - version: file:local-packages/tria-plc-iamui-0.1.1.tgz(0ce39b7e349029277dcd938d06eeb0f7) + version: file:local-packages/tria-plc-iamui-0.1.1.tgz(a5d0bdee55164ae56064fb273b530e00) '@vis.gl/react-google-maps': specifier: ^1.8.3 version: 1.8.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -822,10 +822,10 @@ importers: version: 8.1.6 '@tria-plc/api-common': specifier: file:../../local-packages/tria-plc-api-common-1.4.3.tgz - version: file:local-packages/tria-plc-api-common-1.4.3.tgz(aaad3d77da283ea37b052677c39644c3) + version: file:local-packages/tria-plc-api-common-1.4.3.tgz(c157ca255d77789444a03b1bac2609fa) '@tria-plc/iamapi-common': - specifier: file:../../local-packages/tria-plc-iamapi-common-0.7.9.tgz - version: file:local-packages/tria-plc-iamapi-common-0.7.9.tgz(4d1d275441e80423228c2d8370f9d999) + specifier: file:../../local-packages/tria-plc-iamapi-common-1.0.0.tgz + version: file:local-packages/tria-plc-iamapi-common-1.0.0.tgz(4d1d275441e80423228c2d8370f9d999) '@types/bcrypt': specifier: ^6.0.0 version: 6.0.0 @@ -4664,28 +4664,6 @@ packages: rxjs: ^7.8.0 typeorm: ^0.3.0 - '@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.9.tgz': - resolution: {integrity: sha512-Y6SDEJUR4NcwLXrRJFZ+SbknpczybMwp59cR000vjFEu09RClLr5Gzv8TaJbOeDytyhdgjkZriVNPb2Dt6tipA==, tarball: file:local-packages/tria-plc-iamapi-common-0.7.9.tgz} - version: 0.7.9 - engines: {node: '>=20'} - peerDependencies: - '@nestjs/axios': ^4.0.0 - '@nestjs/common': ^11.0.0 - '@nestjs/core': ^11.0.0 - '@nestjs/jwt': ^11.0.0 - '@nestjs/microservices': ^11.0.0 - '@nestjs/passport': ^11.0.0 - '@nestjs/swagger': ^11.0.0 - '@nestjs/throttler': ^6.0.0 - '@nestjs/typeorm': ^11.0.0 - '@tria-plc/api-common': '*' - axios: ^1.9.0 - class-transformer: ^0.5.1 - class-validator: ^0.14.1 - reflect-metadata: ^0.2.0 - rxjs: ^7.8.0 - typeorm: ^0.3.0 - '@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-1.0.0.tgz': resolution: {integrity: sha512-rfHSXOm/0VUMTj7HrvYysrEAqxItqfPs4Rd35qWJyaAk+snF1wTKFRVz6cRGPoQNj8fhplkVAefnvuKBuHT+xQ==, tarball: file:local-packages/tria-plc-iamapi-common-1.0.0.tgz} version: 1.0.0 @@ -5598,9 +5576,6 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} - api-common@1.2.2: - resolution: {integrity: sha512-2A3NpFNlOOvPY8Vq+g8Pokj6PdMk8fJpg58JpI/QuSXeB9JrS4YbRgzdexPBnJIUCNDHnjr+7S1vOsRRctHzyw==} - app-root-path@3.1.0: resolution: {integrity: sha512-biN3PwB2gUtjaYy/isrU3aNWI5w+fAfvHkSvCKeQGxhmYpwKFUxudR3Yya+KqVRHBmEDYh+/lTozYCFbmzX4nA==} engines: {node: '>= 6.0.0'} @@ -5741,9 +5716,6 @@ packages: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} - async@2.6.4: - resolution: {integrity: sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==} - async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} @@ -6302,10 +6274,6 @@ packages: colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} - colors@1.0.3: - resolution: {integrity: sha512-pFGrxThWcWQ2MsAz6RtgeWe4NK2kUE1WfsrvvlctdII745EW9I0yflqhe7++M5LEc7bV2c/9/5zc8sFcpL0Drw==} - engines: {node: '>=0.1.90'} - colors@1.4.0: resolution: {integrity: sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==} engines: {node: '>=0.1.90'} @@ -6549,10 +6517,6 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} - cycle@1.0.3: - resolution: {integrity: sha512-TVF6svNzeQCOpjCqsy0/CSy8VgObG3wXusJ73xW2GbG5rGx7lC8zxDSURicsXI2UsGdi2L0QNRCi745/wUDvsA==} - engines: {node: '>=0.4.0'} - cypress@15.18.1: resolution: {integrity: sha512-JtkTVtUE2lvLYgZCaug+Uai0H9IqsJirlBO49c87QwG0bJUGvAUVBz1EJve0b0oaYP244Ew9M0BkrHpcqkYxmw==} engines: {node: ^20.1.0 || ^22.0.0 || >=24.0.0} @@ -6995,12 +6959,6 @@ packages: resolution: {integrity: sha512-VyjaKxUmeDX/m2lxm/aknsJ1GWDWUO2Ze2Ad8S1Pb9dykAm9TjSKp5CjrNyltYqZ5W/PO6TInAmO2/BfwMyT1g==} engines: {node: '>=0.10.0'} - error-tojson@0.0.1: - resolution: {integrity: sha512-zhtlVKgW0CgzltibgAlgi6oljh7L8k7jo61NuATFXodGMI2aiqAusW5FaiAtMcT8OLzdPkfhqh34I2XjxPI+Aw==} - - errors@0.3.0: - resolution: {integrity: sha512-/4VTzspBdKkY8DE7VnjGYdHaSZdnQqQyOwYv3o2lwaKLhTvQmVATmoUCvFIFLVrn5kJqDHZl5ZltOu1Bit8rvg==} - es-abstract@1.24.2: resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==} engines: {node: '>= 0.4'} @@ -7340,10 +7298,6 @@ packages: resolution: {integrity: sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==} engines: {'0': node >=0.6.0} - eyes@0.1.8: - resolution: {integrity: sha512-GipyPsXO1anza0AOZdy69Im7hGFCNB7Y/NGjDlZGJ3GJJLtwNSb2vrzYrTYJRrRloVx7pl+bhUaTB8yiccPvFQ==} - engines: {node: '> 0.1.90'} - falsey@0.3.2: resolution: {integrity: sha512-lxEuefF5MBIVDmE6XeqCdM4BWk1+vYmGZtkbKZ/VFcg6uBBw6fXNEbWmxCjDdQlFc9hy450nkiWwM3VAW6G1qg==} engines: {node: '>=0.10.0'} @@ -8742,9 +8696,6 @@ packages: jws@4.0.1: resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} - jwt-decode@2.2.0: - resolution: {integrity: sha512-86GgN2vzfUu7m9Wcj63iUkuDzFNYFVmjeDm2GzWpUk+opB0pEpMsw6ePCMrhYkumz2C1ihqtZzOMAg7FiXcNoQ==} - keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -9271,9 +9222,6 @@ packages: resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} engines: {node: '>=16 || 14 >=14.17'} - minimist@0.0.8: - resolution: {integrity: sha512-miQKw5Hv4NS1Psg2517mV4e4dYNaO3++hjAvLOAzKqZ61rH8NS1SK+vbfBWZ5PY/Me/bEWhUwqMghEW5Fb9T7Q==} - minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} @@ -9292,11 +9240,6 @@ packages: resolution: {integrity: sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==} engines: {node: '>=0.10.0'} - mkdirp@0.5.1: - resolution: {integrity: sha512-SknJC52obPfGQPnjIkXbmA6+5H15E+fR+E4iR2oQ3zzCLbd7/ONua69R/Gw7AgkTLsRG+r5fzksYwWe1AgTyWA==} - deprecated: Legacy versions of mkdirp are no longer supported. Please update to mkdirp 1.x. (Note that the API surface has changed to use Promises in 1.x.) - hasBin: true - mkdirp@0.5.6: resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} hasBin: true @@ -9860,10 +9803,6 @@ packages: pkg-types@2.3.1: resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} - pkginfo@0.4.1: - resolution: {integrity: sha512-8xCNE/aT/EXKenuMDZ+xTVwkT8gsoHN2z/Q29l80u0ppGEXVvsKRzNMbtKhg8LS8k1tJLAHHylf6p4VFmP6XUQ==} - engines: {node: '>= 0.4.0'} - playwright-core@1.61.1: resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} engines: {node: '>=18'} @@ -10814,9 +10753,6 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - short-id-gen@1.1.2: - resolution: {integrity: sha512-rIxGIHcAhbf8jCgB6LYTeFC8jXffu4m0g+SXTljxGLkNhlMAq4jgQYPxvURtIX+tyqAx8YXuuh7j1qDVxJSZIA==} - side-channel-list@1.0.1: resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} engines: {node: '>= 0.4'} @@ -10979,9 +10915,6 @@ packages: stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} - stack-trace@0.0.10: - resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==} - stack-utils@2.0.6: resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} engines: {node: '>=10'} @@ -12105,15 +12038,6 @@ packages: engines: {node: '>=8'} hasBin: true - winston-daily-rotate-file@1.7.2: - resolution: {integrity: sha512-bUkpSyWuDZVD2L7Ci/JrH09sIeqpwhQvmDrIAJ9PhUaewIbv9FTDTCvFnE2AFIIfDcTm7+AKiEKK4EP5lRL3fg==} - peerDependencies: - winston: 2.x - - winston@2.4.7: - resolution: {integrity: sha512-vLB4BqzCKDnnZH9PHGoS2ycawueX4HLqENXQitvFHczhgW2vFpSOn31LZtVr1KU8YTw7DS4tM+cqyovxo8taVg==} - engines: {node: '>= 0.10.0'} - wmf@1.0.2: resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==} engines: {node: '>=0.8'} @@ -12391,11 +12315,11 @@ snapshots: '@babel/helpers': 7.29.7 '@babel/parser': 7.29.7 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7(supports-color@5.5.0) + '@babel/traverse': 7.29.7 '@babel/types': 7.29.7 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -12430,7 +12354,7 @@ snapshots: '@babel/helper-optimise-call-expression': 7.29.7 '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 - '@babel/traverse': 7.29.7(supports-color@5.5.0) + '@babel/traverse': 7.29.7 semver: 6.3.1 transitivePeerDependencies: - supports-color @@ -12439,7 +12363,14 @@ snapshots: '@babel/helper-member-expression-to-functions@7.29.7': dependencies: - '@babel/traverse': 7.29.7(supports-color@5.5.0) + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -12454,9 +12385,9 @@ snapshots: '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0) + '@babel/helper-module-imports': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.7(supports-color@5.5.0) + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color @@ -12471,13 +12402,13 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-member-expression-to-functions': 7.29.7 '@babel/helper-optimise-call-expression': 7.29.7 - '@babel/traverse': 7.29.7(supports-color@5.5.0) + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color '@babel/helper-skip-transparent-expression-wrappers@7.29.7': dependencies: - '@babel/traverse': 7.29.7(supports-color@5.5.0) + '@babel/traverse': 7.29.7 '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -12630,6 +12561,18 @@ snapshots: '@babel/parser': 7.29.7 '@babel/types': 7.29.7 + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + '@babel/traverse@7.29.7(supports-color@5.5.0)': dependencies: '@babel/code-frame': 7.29.7 @@ -12855,7 +12798,7 @@ snapshots: '@emotion/babel-plugin@11.13.5': dependencies: - '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0) + '@babel/helper-module-imports': 7.29.7 '@babel/runtime': 7.29.7 '@emotion/hash': 0.9.2 '@emotion/memoize': 0.9.0 @@ -13021,7 +12964,7 @@ snapshots: '@eslint/eslintrc@2.1.4': dependencies: ajv: 6.15.0 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) espree: 9.6.1 globals: 13.24.0 ignore: 5.3.2 @@ -13181,7 +13124,7 @@ snapshots: '@humanwhocodes/config-array@0.13.0': dependencies: '@humanwhocodes/object-schema': 2.0.3 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) minimatch: 3.1.5 transitivePeerDependencies: - supports-color @@ -14378,7 +14321,7 @@ snapshots: '@puppeteer/browsers@2.13.2': dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) extract-zip: 2.0.1 progress: 2.0.3 proxy-agent: 6.5.0 @@ -16446,7 +16389,7 @@ snapshots: '@tokenizer/inflate@0.4.1': dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) token-types: 6.1.2 transitivePeerDependencies: - supports-color @@ -16455,7 +16398,7 @@ snapshots: '@tootallnate/quickjs-emscripten@0.23.0': {} - '@tria-plc/api-common@file:local-packages/tria-plc-api-common-1.4.3.tgz(aaad3d77da283ea37b052677c39644c3)': + '@tria-plc/api-common@file:local-packages/tria-plc-api-common-1.4.3.tgz(c157ca255d77789444a03b1bac2609fa)': dependencies: '@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2) '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -16466,7 +16409,7 @@ snapshots: '@nestjs/swagger': 7.4.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) '@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2) '@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) - '@tria-plc/iamapi-common': file:local-packages/tria-plc-iamapi-common-0.7.9.tgz(4d1d275441e80423228c2d8370f9d999) + '@tria-plc/iamapi-common': file:local-packages/tria-plc-iamapi-common-1.0.0.tgz(4d1d275441e80423228c2d8370f9d999) argon2: 0.43.1 axios: 1.17.0 change-case: 5.4.4 @@ -16542,7 +16485,7 @@ snapshots: - debug - supports-color - '@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.9.tgz(4d1d275441e80423228c2d8370f9d999)': + '@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-1.0.0.tgz(4d1d275441e80423228c2d8370f9d999)': dependencies: '@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2) '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -16553,8 +16496,7 @@ snapshots: '@nestjs/swagger': 7.4.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) '@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2) '@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) - '@tria-plc/api-common': file:local-packages/tria-plc-api-common-1.4.3.tgz(aaad3d77da283ea37b052677c39644c3) - api-common: 1.2.2 + '@tria-plc/api-common': file:local-packages/tria-plc-api-common-1.4.3.tgz(c157ca255d77789444a03b1bac2609fa) argon2: 0.43.1 axios: 1.17.0 class-transformer: 0.5.1 @@ -16735,6 +16677,130 @@ snapshots: - utf-8-validate - vite + '@tria-plc/iamui@file:local-packages/tria-plc-iamui-0.1.1.tgz(a5d0bdee55164ae56064fb273b530e00)': + dependencies: + '@emotion/react': 11.14.0(@types/react@18.3.31)(react@19.2.6) + '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react@19.2.6) + '@hookform/resolvers': 5.4.0(react-hook-form@7.77.0(react@19.2.6)) + '@lottiefiles/react-lottie-player': 3.6.0(react@19.2.6) + '@mantine/charts': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(recharts@3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1)) + '@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@mantine/dates': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@mantine/hooks': 7.17.8(react@19.2.6) + '@mantine/notifications': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-accordion': 1.2.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-alert-dialog': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-avatar': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-checkbox': 1.3.4(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-collapsible': 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-context-menu': 2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-dialog': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-dropdown-menu': 2.1.17(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-hover-card': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-label': 2.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-navigation-menu': 1.2.15(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-popover': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-progress': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-radio-group': 1.4.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-scroll-area': 1.2.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-select': 2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-separator': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-slot': 1.2.5(@types/react@18.3.31)(react@19.2.6) + '@radix-ui/react-switch': 1.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-tabs': 1.1.14(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-toast': 1.2.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-tooltip': 1.2.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@react-pdf-viewer/core': 3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@react-pdf-viewer/default-layout': 3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@react-pdf/renderer': 4.5.1(react@19.2.6) + '@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@18.3.31)(react@19.2.6)(redux@5.0.1))(react@19.2.6) + '@tabler/icons-react': 3.44.0(react@19.2.6) + '@tailwindcss/vite': 4.3.0(vite@5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0)) + '@tanstack/react-query': 5.101.0(react@19.2.6) + '@tanstack/react-query-devtools': 5.101.0(@tanstack/react-query@5.101.0(react@19.2.6))(react@19.2.6) + '@tanstack/react-table': 8.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@tinymce/tinymce-react': 6.3.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tinymce@7.9.3) + '@types/dompurify': 3.2.0 + '@types/node': 24.13.1 + '@types/tinymce': 4.6.9 + axios: 1.17.0 + class-variance-authority: 0.7.1 + clsx: 2.1.1 + cmdk: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + date-fns: 3.6.0 + dayjs: 1.11.21 + dompurify: 3.4.8 + ethiopian-calendar-date-converter: 2.1.6 + ethiopian-calendar-new: 1.1.0 + file-type: 18.7.0 + framer-motion: 12.40.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + html2canvas: 1.4.1 + i18next: 25.10.10(typescript@5.9.3) + i18next-browser-languagedetector: 8.2.1 + jquery: 3.7.1 + js-cookie: 3.0.8 + jspdf: 3.0.4 + lodash: 4.18.1 + lucide-react: 0.513.0(react@19.2.6) + mantine-react-table: 2.0.0-beta.9(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/dates@7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(@tabler/icons-react@3.44.0(react@19.2.6))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + mui-ethiopian-datepicker: 0.3.2(4b3af212eafdf0059f009b005d7e343d) + next-themes: 0.4.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + path: 0.12.7 + pdf-lib: 1.17.1 + qs: 6.15.2 + react: 19.2.6 + react-cookie: 8.1.2(@types/react@18.3.31)(react@19.2.6) + react-css-nocode-editor: 1.0.13(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6) + react-day-picker: 8.10.2(date-fns@3.6.0)(react@19.2.6) + react-dom: 19.2.6(react@19.2.6) + react-dropzone: 14.4.1(react@19.2.6) + react-hook-form: 7.77.0(react@19.2.6) + react-i18next: 15.7.4(i18next@25.10.10(typescript@5.9.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + react-icons: 5.6.0(react@19.2.6) + react-image-crop: 11.0.10(react@19.2.6) + react-intersection-observer: 9.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react-pdf: 10.4.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react-pdf-html: 2.1.5(@react-pdf/renderer@4.5.1(react@19.2.6))(react@19.2.6) + react-redux: 9.3.0(@types/react@18.3.31)(react@19.2.6)(redux@5.0.1) + react-resizable-panels: 3.0.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react-router-dom: 7.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react-signature-canvas: 1.1.0-alpha.2(@types/prop-types@15.7.15)(@types/react@18.3.31)(prop-types@15.8.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + recharts: 3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1) + rollup-plugin-visualizer: 7.0.1(rollup@4.61.1) + socket.io-client: 4.8.3 + sonner: 2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + tailwind-merge: 3.6.0 + tailwind-scrollbar-hide: 4.0.0(tailwindcss@4.3.0) + tailwindcss: 4.3.0 + tailwindcss-animate: 1.0.7(tailwindcss@4.3.0) + tinymce: 7.9.3 + url: 0.11.4 + vaul: 1.1.2(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + xlsx: 0.18.5 + zod: 3.25.76 + transitivePeerDependencies: + - '@babel/core' + - '@emotion/is-prop-valid' + - '@mui/icons-material' + - '@mui/material' + - '@mui/x-date-pickers' + - '@types/prop-types' + - '@types/react' + - '@types/react-dom' + - bufferutil + - debug + - pdfjs-dist + - prop-types + - react-is + - react-native + - redux + - rolldown + - rollup + - supports-color + - typescript + - utf-8-validate + - vite + '@ts-morph/common@0.27.0': dependencies: fast-glob: 3.3.3 @@ -17113,7 +17179,7 @@ snapshots: '@typescript-eslint/types': 8.60.1 '@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.60.1 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) eslint: 8.57.1 typescript: 5.9.3 transitivePeerDependencies: @@ -17123,7 +17189,7 @@ snapshots: dependencies: '@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3) '@typescript-eslint/types': 8.60.1 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -17142,7 +17208,7 @@ snapshots: '@typescript-eslint/types': 8.60.1 '@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3) '@typescript-eslint/utils': 8.60.1(eslint@8.57.1)(typescript@5.9.3) - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) eslint: 8.57.1 ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 @@ -17157,7 +17223,7 @@ snapshots: '@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3) '@typescript-eslint/types': 8.60.1 '@typescript-eslint/visitor-keys': 8.60.1 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) minimatch: 10.2.5 semver: 7.8.2 tinyglobby: 0.2.17 @@ -17446,7 +17512,7 @@ snapshots: agent-base@6.0.2: dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -17686,17 +17752,6 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.2 - api-common@1.2.2: - dependencies: - error-tojson: 0.0.1 - errors: 0.3.0 - jwt-decode: 2.2.0 - moment: 2.30.1 - pkginfo: 0.4.1 - short-id-gen: 1.1.2 - winston: 2.4.7 - winston-daily-rotate-file: 1.7.2(winston@2.4.7) - app-root-path@3.1.0: {} append-field@1.0.0: {} @@ -17872,10 +17927,6 @@ snapshots: async-function@1.0.0: {} - async@2.6.4: - dependencies: - lodash: 4.18.1 - async@3.2.6: {} asynckit@0.4.0: {} @@ -17970,6 +18021,16 @@ snapshots: transitivePeerDependencies: - supports-color + babel-plugin-styled-components@2.3.0(styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6))(supports-color@5.5.0): + dependencies: + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0) + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) + picomatch: 4.0.4 + styled-components: 5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6) + transitivePeerDependencies: + - supports-color + babel-polyfill@6.26.0: dependencies: babel-runtime: 6.26.0 @@ -18123,7 +18184,7 @@ snapshots: dependencies: bytes: 3.1.2 content-type: 1.0.5 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 @@ -18503,8 +18564,6 @@ snapshots: colorette@2.0.20: {} - colors@1.0.3: {} - colors@1.4.0: optional: true @@ -18738,8 +18797,6 @@ snapshots: csstype@3.2.3: {} - cycle@1.0.3: {} - cypress@15.18.1: dependencies: '@cypress/request': 4.0.1 @@ -19124,7 +19181,7 @@ snapshots: engine.io-client@6.6.5: dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) engine.io-parser: 5.2.3 ws: 8.20.1 xmlhttprequest-ssl: 2.1.2 @@ -19144,7 +19201,7 @@ snapshots: base64id: 2.0.0 cookie: 0.7.2 cors: 2.8.6 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) engine.io-parser: 5.2.3 ws: 8.21.0 transitivePeerDependencies: @@ -19187,10 +19244,6 @@ snapshots: error-symbol@0.1.0: {} - error-tojson@0.0.1: {} - - errors@0.3.0: {} - es-abstract@1.24.2: dependencies: array-buffer-byte-length: 1.0.2 @@ -19377,7 +19430,7 @@ snapshots: eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) eslint: 8.57.1 get-tsconfig: 4.14.0 is-bun-module: 2.0.0 @@ -19505,7 +19558,7 @@ snapshots: ajv: 6.15.0 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) doctrine: 3.0.0 escape-string-regexp: 4.0.0 eslint-scope: 7.2.2 @@ -19735,7 +19788,7 @@ snapshots: content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) depd: 2.0.0 encodeurl: 2.0.0 escape-html: 1.0.3 @@ -19788,7 +19841,7 @@ snapshots: extract-zip@2.0.1: dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) get-stream: 5.2.0 yauzl: 2.10.0 optionalDependencies: @@ -19798,8 +19851,6 @@ snapshots: extsprintf@1.3.0: {} - eyes@0.1.8: {} - falsey@0.3.2: dependencies: kind-of: 5.1.0 @@ -19941,7 +19992,7 @@ snapshots: finalhandler@2.1.1: dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 @@ -20187,7 +20238,7 @@ snapshots: dependencies: basic-ftp: 5.3.1 data-uri-to-buffer: 6.0.2 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -20468,7 +20519,7 @@ snapshots: http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -20481,14 +20532,14 @@ snapshots: https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -20913,7 +20964,7 @@ snapshots: istanbul-lib-source-maps@4.0.1: dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) istanbul-lib-coverage: 3.2.2 source-map: 0.6.1 transitivePeerDependencies: @@ -21445,8 +21496,6 @@ snapshots: jwa: 2.0.1 safe-buffer: 5.2.1 - jwt-decode@2.2.0: {} - keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -21561,7 +21610,7 @@ snapshots: dependencies: chalk: 5.6.2 commander: 13.1.0 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) execa: 8.0.1 lilconfig: 3.1.3 listr2: 8.3.3 @@ -21912,8 +21961,6 @@ snapshots: dependencies: brace-expansion: 2.1.1 - minimist@0.0.8: {} - minimist@1.2.8: {} minio@7.1.3: @@ -21942,10 +21989,6 @@ snapshots: for-in: 1.0.2 is-extendable: 1.0.1 - mkdirp@0.5.1: - dependencies: - minimist: 0.0.8 - mkdirp@0.5.6: dependencies: minimist: 1.2.8 @@ -22364,7 +22407,7 @@ snapshots: dependencies: '@tootallnate/quickjs-emscripten': 0.23.0 agent-base: 7.1.4 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) get-uri: 6.0.5 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 @@ -22554,8 +22597,6 @@ snapshots: exsolve: 1.0.8 pathe: 2.0.3 - pkginfo@0.4.1: {} - playwright-core@1.61.1: {} playwright@1.61.1: @@ -22704,7 +22745,7 @@ snapshots: proxy-agent@6.5.0: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 lru-cache: 7.18.3 @@ -22733,7 +22774,7 @@ snapshots: dependencies: '@puppeteer/browsers': 2.13.2 chromium-bidi: 14.0.0(devtools-protocol@0.0.1608973) - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) devtools-protocol: 0.0.1608973 typed-query-selector: 2.12.2 webdriver-bidi-protocol: 0.4.1 @@ -22986,6 +23027,15 @@ snapshots: - '@babel/core' - react-is + react-css-nocode-editor@1.0.13(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6): + dependencies: + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + styled-components: 5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6) + transitivePeerDependencies: + - '@babel/core' + - react-is + react-day-picker@8.10.2(date-fns@3.6.0)(react@19.2.6): dependencies: date-fns: 3.6.0 @@ -23559,7 +23609,7 @@ snapshots: router@2.2.0: dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 @@ -23677,7 +23727,7 @@ snapshots: send@1.2.1: dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -23809,8 +23859,6 @@ snapshots: shebang-regex@3.0.0: {} - short-id-gen@1.1.2: {} - side-channel-list@1.0.1: dependencies: es-errors: 1.3.0 @@ -23895,7 +23943,7 @@ snapshots: socket.io-adapter@2.5.8: dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) ws: 8.21.0 transitivePeerDependencies: - bufferutil @@ -23905,7 +23953,7 @@ snapshots: socket.io-client@4.8.3: dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) engine.io-client: 6.6.5 socket.io-parser: 4.2.6 transitivePeerDependencies: @@ -23916,7 +23964,7 @@ snapshots: socket.io-parser@4.2.6: dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -23925,7 +23973,7 @@ snapshots: accepts: 1.3.8 base64id: 2.0.0 cors: 2.8.6 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) engine.io: 6.6.9 socket.io-adapter: 2.5.8 socket.io-parser: 4.2.6 @@ -23937,7 +23985,7 @@ snapshots: socks-proxy-agent@8.0.5: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) socks: 2.8.9 transitivePeerDependencies: - supports-color @@ -24012,8 +24060,6 @@ snapshots: stable-hash@0.0.5: {} - stack-trace@0.0.10: {} - stack-utils@2.0.6: dependencies: escape-string-regexp: 2.0.0 @@ -24217,6 +24263,24 @@ snapshots: transitivePeerDependencies: - '@babel/core' + styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6): + dependencies: + '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0) + '@babel/traverse': 7.29.7(supports-color@5.5.0) + '@emotion/is-prop-valid': 1.4.0 + '@emotion/stylis': 0.8.5 + '@emotion/unitless': 0.7.5 + babel-plugin-styled-components: 2.3.0(styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6))(supports-color@5.5.0) + css-to-react-native: 3.2.0 + hoist-non-react-statics: 3.3.2 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react-is: 19.2.7 + shallowequal: 1.1.0 + supports-color: 5.5.0 + transitivePeerDependencies: + - '@babel/core' + styled-jsx@5.1.1(babel-plugin-macros@3.1.0)(react@18.3.1): dependencies: client-only: 0.0.1 @@ -24242,7 +24306,7 @@ snapshots: dependencies: component-emitter: 1.3.1 cookiejar: 2.1.4 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) fast-safe-stringify: 2.1.1 form-data: 4.0.5 formidable: 3.5.4 @@ -24751,7 +24815,7 @@ snapshots: app-root-path: 3.1.0 buffer: 6.0.3 dayjs: 1.11.21 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) dedent: 1.7.2(babel-plugin-macros@3.1.0) dotenv: 16.6.1 glob: 10.5.0 @@ -24775,7 +24839,7 @@ snapshots: app-root-path: 3.1.0 buffer: 6.0.3 dayjs: 1.11.21 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) dedent: 1.7.2(babel-plugin-macros@3.1.0) dotenv: 16.6.1 glob: 10.5.0 @@ -25078,7 +25142,7 @@ snapshots: vite-node@2.1.9(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0): dependencies: cac: 6.7.14 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) es-module-lexer: 1.7.0 pathe: 1.1.2 vite: 5.4.21(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0) @@ -25096,7 +25160,7 @@ snapshots: vite-node@2.1.9(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0): dependencies: cac: 6.7.14 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) es-module-lexer: 1.7.0 pathe: 1.1.2 vite: 5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0) @@ -25143,7 +25207,7 @@ snapshots: '@vitest/spy': 2.1.9 '@vitest/utils': 2.1.9 chai: 5.3.3 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) expect-type: 1.3.0 magic-string: 0.30.21 pathe: 1.1.2 @@ -25179,7 +25243,7 @@ snapshots: '@vitest/spy': 2.1.9 '@vitest/utils': 2.1.9 chai: 5.3.3 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3(supports-color@8.1.1) expect-type: 1.3.0 magic-string: 0.30.21 pathe: 1.1.2 @@ -25369,20 +25433,6 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 - winston-daily-rotate-file@1.7.2(winston@2.4.7): - dependencies: - mkdirp: 0.5.1 - winston: 2.4.7 - - winston@2.4.7: - dependencies: - async: 2.6.4 - colors: 1.0.3 - cycle: 1.0.3 - eyes: 0.1.8 - isstream: 0.1.2 - stack-trace: 0.0.10 - wmf@1.0.2: {} word-wrap@1.2.5: {} From 19ab3af1cbbebcf45a644666f20d8f2a53de7466 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Tue, 4 Aug 2026 10:56:47 +0300 Subject: [PATCH 04/11] chore: ( iam ) update iam package --- apps/edr-passenger-api/package.json | 2 +- pnpm-lock.yaml | 30 ++++++----------------------- 2 files changed, 7 insertions(+), 25 deletions(-) diff --git a/apps/edr-passenger-api/package.json b/apps/edr-passenger-api/package.json index d99701c5a..b3571871c 100644 --- a/apps/edr-passenger-api/package.json +++ b/apps/edr-passenger-api/package.json @@ -45,7 +45,7 @@ "@nestjs/websockets": "^11.1.27", "@prisma/client": "^6.19.3", "@sendgrid/mail": "^8.1.0", - "@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz", + "@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.6.0.tgz", "@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-1.0.0.tgz", "@types/bcrypt": "^6.0.0", "amqp-connection-manager": "^5.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 66ba04c62..734e66efe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -821,11 +821,11 @@ importers: specifier: ^8.1.0 version: 8.1.6 '@tria-plc/api-common': - specifier: file:../../local-packages/tria-plc-api-common-1.4.3.tgz - version: file:local-packages/tria-plc-api-common-1.4.3.tgz(c157ca255d77789444a03b1bac2609fa) + specifier: file:../../local-packages/tria-plc-api-common-1.6.0.tgz + version: file:local-packages/tria-plc-api-common-1.6.0.tgz(2b4e99ab22f78c34e7861d649f4ff29b) '@tria-plc/iamapi-common': specifier: file:../../local-packages/tria-plc-iamapi-common-1.0.0.tgz - version: file:local-packages/tria-plc-iamapi-common-1.0.0.tgz(4d1d275441e80423228c2d8370f9d999) + version: file:local-packages/tria-plc-iamapi-common-1.0.0.tgz(cc085a020c559b355f168432c579a024) '@types/bcrypt': specifier: ^6.0.0 version: 6.0.0 @@ -4631,23 +4631,6 @@ packages: '@tootallnate/quickjs-emscripten@0.23.0': resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} - '@tria-plc/api-common@file:local-packages/tria-plc-api-common-1.4.3.tgz': - resolution: {integrity: sha512-cHlo96Wh3ET8qHjq5mevJwqitFRynU17KzEpE5fz0efCs9NIw7N3K6L+bYwConOCP7jbIROe/2rkG9ToY/AA3w==, tarball: file:local-packages/tria-plc-api-common-1.4.3.tgz} - version: 1.4.3 - peerDependencies: - '@nestjs/common': ^11.0.0 - '@nestjs/core': ^11.0.0 - '@nestjs/jwt': ^11.0.0 - '@nestjs/microservices': ^11.0.0 - '@nestjs/passport': ^11.0.0 - '@nestjs/swagger': ^11.0.0 - '@nestjs/throttler': ^6.0.0 - '@nestjs/typeorm': ^11.0.0 - '@tria-plc/iamapi-common': ^0.1.0 - reflect-metadata: ^0.2.0 - rxjs: ^7.8.0 - typeorm: ^0.3.0 - '@tria-plc/api-common@file:local-packages/tria-plc-api-common-1.6.0.tgz': resolution: {integrity: sha512-SZomla65xesBQZ12n8xH+9eX0TRbXNWToQ3SNURLhP1zlHJWUMTVHoRXTd5zWoe4mqah2Lr83L8ueHERsqCTFw==, tarball: file:local-packages/tria-plc-api-common-1.6.0.tgz} version: 1.6.0 @@ -16398,7 +16381,7 @@ snapshots: '@tootallnate/quickjs-emscripten@0.23.0': {} - '@tria-plc/api-common@file:local-packages/tria-plc-api-common-1.4.3.tgz(c157ca255d77789444a03b1bac2609fa)': + '@tria-plc/api-common@file:local-packages/tria-plc-api-common-1.6.0.tgz(2b4e99ab22f78c34e7861d649f4ff29b)': dependencies: '@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2) '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -16409,7 +16392,6 @@ snapshots: '@nestjs/swagger': 7.4.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) '@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2) '@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) - '@tria-plc/iamapi-common': file:local-packages/tria-plc-iamapi-common-1.0.0.tgz(4d1d275441e80423228c2d8370f9d999) argon2: 0.43.1 axios: 1.17.0 change-case: 5.4.4 @@ -16485,7 +16467,7 @@ snapshots: - debug - supports-color - '@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-1.0.0.tgz(4d1d275441e80423228c2d8370f9d999)': + '@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-1.0.0.tgz(cc085a020c559b355f168432c579a024)': dependencies: '@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2) '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -16496,7 +16478,7 @@ snapshots: '@nestjs/swagger': 7.4.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) '@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2) '@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) - '@tria-plc/api-common': file:local-packages/tria-plc-api-common-1.4.3.tgz(c157ca255d77789444a03b1bac2609fa) + '@tria-plc/api-common': file:local-packages/tria-plc-api-common-1.6.0.tgz(2b4e99ab22f78c34e7861d649f4ff29b) argon2: 0.43.1 axios: 1.17.0 class-transformer: 0.5.1 From 87b04973d8d61949c081720b1e4a4787eed326a1 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Tue, 4 Aug 2026 07:59:03 +0000 Subject: [PATCH 05/11] fix(bookings): gate export carriage acceptance sheet on GRN receipt The sheet attests EDR has taken custody. Export custody happens at warehouse receipt, so the shared received-with-GRN gate now runs even when wagons are already allocated. Receipt-row query also accepts the legacy notes GRN fallback, matching the loading gate. --- ...00000-EmptyContainerReturnStatusHistory.ts | 26 ++++++++++ .../entities/empty-container-return.entity.ts | 7 +++ .../import-operations.service.ts | 10 +++- .../pages/warehouses/ContainerReturnsPage.tsx | 49 ++++++++++++++----- .../backoffice/src/types/importOperations.ts | 5 ++ 5 files changed, 83 insertions(+), 14 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3220000000000-EmptyContainerReturnStatusHistory.ts diff --git a/apps/edr-freight-api/src/migrations/3220000000000-EmptyContainerReturnStatusHistory.ts b/apps/edr-freight-api/src/migrations/3220000000000-EmptyContainerReturnStatusHistory.ts new file mode 100644 index 000000000..da9267713 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3220000000000-EmptyContainerReturnStatusHistory.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class EmptyContainerReturnStatusHistory3220000000000 implements MigrationInterface { + name = 'EmptyContainerReturnStatusHistory3220000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.empty_container_returns + ADD COLUMN IF NOT EXISTS status_history jsonb NOT NULL DEFAULT '[]'::jsonb + `); + await queryRunner.query(` + UPDATE freight.empty_container_returns + SET status_history = jsonb_build_array( + jsonb_build_object('status', status, 'changedAt', created_at, 'performedBy', performed_by) + ) + WHERE status_history = '[]'::jsonb + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.empty_container_returns + DROP COLUMN IF EXISTS status_history + `); + } +} diff --git a/apps/edr-freight-api/src/modules/import-operations/entities/empty-container-return.entity.ts b/apps/edr-freight-api/src/modules/import-operations/entities/empty-container-return.entity.ts index b213a520a..2538a204b 100644 --- a/apps/edr-freight-api/src/modules/import-operations/entities/empty-container-return.entity.ts +++ b/apps/edr-freight-api/src/modules/import-operations/entities/empty-container-return.entity.ts @@ -56,4 +56,11 @@ export class EmptyContainerReturn extends BaseEntity { @Column({ name: 'returned_by', type: 'varchar', length: 20, nullable: true }) returnedBy?: 'EDR' | 'CUSTOMER' | null; + + @Column({ name: 'status_history', type: 'jsonb', default: () => "'[]'" }) + statusHistory!: Array<{ + status: EmptyContainerReturnStatus; + changedAt: string; + performedBy: string | null; + }>; } diff --git a/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts b/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts index 02260cf39..bd40e2ab1 100644 --- a/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts +++ b/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts @@ -149,12 +149,13 @@ export class ImportOperationsService { } async createEmptyReturn(dto: CreateEmptyContainerReturnDto) { + const returnDate = dto.returnDate ? new Date(dto.returnDate) : new Date(); return this.emptyReturns.save( this.emptyReturns.create({ containerNumber: dto.containerNumber, bookingId: dto.bookingId ?? null, customerId: dto.customerId ?? null, - returnDate: dto.returnDate ? new Date(dto.returnDate) : new Date(), + returnDate, facility: dto.facility ?? null, yard: dto.yard ?? null, zone: dto.zone ?? null, @@ -162,6 +163,9 @@ export class ImportOperationsService { handoverNote: dto.handoverNote ?? null, performedBy: dto.performedBy ?? null, returnedBy: dto.returnedBy ?? null, + statusHistory: [ + { status: 'RETURNED', changedAt: returnDate.toISOString(), performedBy: dto.performedBy ?? null }, + ], }), ); } @@ -176,6 +180,10 @@ export class ImportOperationsService { wagonAllocationReference: dto.wagonAllocationReference ?? row.wagonAllocationReference ?? null, handoverNote: dto.handoverNote ?? row.handoverNote ?? null, performedBy: dto.performedBy ?? row.performedBy ?? null, + statusHistory: [ + ...(row.statusHistory ?? []), + { status: dto.status, changedAt: new Date().toISOString(), performedBy: dto.performedBy ?? row.performedBy ?? null }, + ], }); return this.emptyReturns.findOneOrFail({ where: { id } }); } diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/ContainerReturnsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/ContainerReturnsPage.tsx index 699e52f0c..feb4c4815 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/ContainerReturnsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/ContainerReturnsPage.tsx @@ -17,7 +17,7 @@ import { Select, Checkbox, } from "@mantine/core"; -import { ChevronDown, ChevronRight } from "lucide-react"; +import { ChevronDown, ChevronRight, History } from "lucide-react"; import { PageContainer, PageHeader } from "@/components/page"; import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter"; @@ -82,6 +82,7 @@ export default function ContainerReturnsPage() { const [returnModalOpen, setReturnModalOpen] = useState(false); const [standaloneModalOpen, setStandaloneModalOpen] = useState(false); const [activeKey, setActiveKey] = useState(null); + const [historyRow, setHistoryRow] = useState(null); const { data: unloadedQueue = [], isLoading: queueLoading } = useQuery({ queryKey: ["import-unloaded-queue"], @@ -346,18 +347,23 @@ export default function ContainerReturnsPage() { {RETURN_STATUS_LABEL[ret.status as EmptyContainerReturnStatus] ?? ret.status} - {nextStatus ? ( - - ) : ( - Done - )} + + setHistoryRow(ret)} title="View status history"> + + + {nextStatus ? ( + + ) : ( + Done + )} + ); @@ -479,6 +485,23 @@ export default function ContainerReturnsPage() { onSubmit={(payload) => createReturnsMutation.mutate(payload)} loading={createReturnsMutation.isPending} /> + + setHistoryRow(null)} title="Status History" size="sm"> + {historyRow && ( + + {historyRow.containerNumber} + {(historyRow.statusHistory ?? []).map((entry: any, idx: number) => ( + + {RETURN_STATUS_LABEL[entry.status as EmptyContainerReturnStatus] ?? entry.status} + {new Date(entry.changedAt).toLocaleString()} + + ))} + {!(historyRow.statusHistory ?? []).length && ( + No history recorded. + )} + + )} + ); } diff --git a/apps/edr-freight-web/backoffice/src/types/importOperations.ts b/apps/edr-freight-web/backoffice/src/types/importOperations.ts index 1712a36b7..ff5c1901f 100644 --- a/apps/edr-freight-web/backoffice/src/types/importOperations.ts +++ b/apps/edr-freight-web/backoffice/src/types/importOperations.ts @@ -103,6 +103,11 @@ export interface EmptyContainerReturn { wagonAllocationReference: string | null; performedBy: string | null; returnedBy: 'EDR' | 'CUSTOMER' | null; + statusHistory: Array<{ + status: EmptyContainerReturnStatus; + changedAt: string; + performedBy: string | null; + }>; } export interface CreateEmptyContainerReturnPayload { From 2eb752e8c213f476e49572aeaee492cb2a06e4ad Mon Sep 17 00:00:00 2001 From: Marshal Date: Tue, 4 Aug 2026 08:10:00 +0000 Subject: [PATCH 06/11] allow the custoemr to book while the payment window time --- .../bookings/booking-transition.service.ts | 7 ------ .../modules/bookings/bookings.repository.ts | 10 --------- .../src/modules/bookings/bookings.service.ts | 22 ------------------- .../contracts/contract-booking.service.ts | 20 ----------------- 4 files changed, 59 deletions(-) diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 149875e6e..181c47b2d 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -927,13 +927,6 @@ export class BookingTransitionService { "OPERATION_CHANGES_REQUESTED", ]); - // A company sitting on another unpaid hold commits nothing new — this is - // the moment export capacity locks, so the lock applies here too. - // Government bookings allocate without paying and are exempt. - if (!booking.isGovernment) { - await this.bookingsService.assertNoUnpaidHold(booking.companyId); - } - // A bare initiated instance (clearance-first flow) carries no cargo or // price — it must go through the contract completion endpoint, which // persists cargo, prices, invoices and only then lands here itself. diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 185a6aaa7..35f84e419 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -1396,16 +1396,6 @@ export class BookingsRepository extends BaseRepository { .getMany(); } - /** Open unpaid holds (wagons reserved, pay window running) for a company. */ - countUnpaidHoldsForCompany(companyId: string): Promise { - return this.repository.count({ - where: { - companyId, - status: In(['SELECTED_FOR_BATCH', 'AWAITING_PAYMENT']), - }, - }); - } - /** Bookings currently reserved (SELECTED_FOR_BATCH) against a schedule. */ findReservedForSchedule(scheduleId: string): Promise { return this.repository diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 67ac73a58..1a53dd7a6 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -909,24 +909,6 @@ export class BookingsService { return result.booking; } - /** - * A company with an open unpaid hold (SELECTED_FOR_BATCH — wagons reserved, - * pay window running) may not take more capacity until it pays or the hold - * dies: otherwise one customer can lock a train's wagons over and over - * without ever paying. EXPIRED / CANCELLED holds free the lock. - */ - async assertNoUnpaidHold(companyId?: string | null): Promise { - if (!companyId) return; - const holds = - await this.bookingsRepository.countUnpaidHoldsForCompany(companyId); - if (holds > 0) { - throw new ConflictException( - 'You already have a booking waiting for payment. Pay it or cancel it ' + - 'before making a new booking.', - ); - } - } - /** Create a new freight booking. */ async create( dto: CreateBookingDto, @@ -989,10 +971,6 @@ export class BookingsService { companyId = company.id; } - // Government bookings allocate without paying, so the unpaid-hold lock - // only applies to commercial companies. - if (!isGovernment) await this.assertNoUnpaidHold(companyId); - if (dto.trainScheduleId) { // Staff manual pin: the schedule must be OPEN and on the same route. const schedule = await this.dataSource 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 8da28e400..7e0a23e3f 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 @@ -109,23 +109,6 @@ export class ContractBookingService { private readonly bookingTransitionService: BookingTransitionService, ) {} - /** - * Mirrors BookingsService.assertNoUnpaidHold for the contract booking paths: - * a company sitting on an unpaid hold (SELECTED_FOR_BATCH) books nothing new - * until it pays or the hold dies. - */ - private async assertNoUnpaidHold(companyId?: string | null): Promise { - if (!companyId) return; - const holds = - await this.bookingsRepository.countUnpaidHoldsForCompany(companyId); - if (holds > 0) { - throw new ConflictException( - 'You already have a booking waiting for payment. Pay it or cancel it ' + - 'before making a new booking.', - ); - } - } - async createUnderContract( contractId: string, dto: CreateBookingUnderContractDto, @@ -186,8 +169,6 @@ export class ContractBookingService { // remainder; the customer cannot start any other booking on the contract. // If the remainder splits again the same rule repeats until the cap is // exhausted and the contract completes. - await this.assertNoUnpaidHold(contract.companyId); - if (contract.contractKind === 'ONE_TIME') { if (await this.hasSplitBooking(contractId)) { await this.assertExactRemainder(contract, dto); @@ -475,7 +456,6 @@ export class ContractBookingService { ); } } - await this.assertNoUnpaidHold(contract.companyId); const route = await this.resolveRoute(contract, dto.contractRouteId); From eb972862c1a3c2f5934163591b01041b60111fd6 Mon Sep 17 00:00:00 2001 From: Yonas Tewabe Date: Tue, 4 Aug 2026 11:24:43 +0300 Subject: [PATCH 07/11] Create sonar-project.properties --- sonar-project.properties | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 sonar-project.properties diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 000000000..793b6128b --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,13 @@ +sonar.projectKey=edr-platform +sonar.projectName=EDR Platform + +sonar.sources=apps,packages +sonar.exclusions=**/node_modules/**,**/dist/**,**/build/**,**/*.spec.ts,**/*.test.ts,**/*.e2e-spec.ts,**/coverage/**,**/.turbo/**,e2e/**,integration/** + +sonar.tests=apps,packages +sonar.test.inclusions=**/*.spec.ts,**/*.test.ts + +sonar.javascript.lcov.reportPaths=apps/*/coverage/lcov.info,apps/edr-freight-web/*/coverage/lcov.info,apps/edr-passenger-web/*/coverage/lcov.info,packages/*/coverage/lcov.info +sonar.typescript.lcov.reportPaths=apps/*/coverage/lcov.info,apps/edr-freight-web/*/coverage/lcov.info,apps/edr-passenger-web/*/coverage/lcov.info,packages/*/coverage/lcov.info + +sonar.sourceEncoding=UTF-8 From aac11759642113da253c78479b0867b6ae7a2ff9 Mon Sep 17 00:00:00 2001 From: Marshal Date: Tue, 4 Aug 2026 08:42:15 +0000 Subject: [PATCH 08/11] change ewit images --- .../src/pages/bookings/booking-display.tsx | 39 +++++++++++++++---- .../src/pages/bookings/payment-badge.test.ts | 35 +++++++++++++++++ 2 files changed, 67 insertions(+), 7 deletions(-) create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/payment-badge.test.ts diff --git a/apps/edr-freight-web/portal/src/pages/bookings/booking-display.tsx b/apps/edr-freight-web/portal/src/pages/bookings/booking-display.tsx index ec5abad1b..7dc1109eb 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/booking-display.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/booking-display.tsx @@ -145,12 +145,38 @@ export function paymentStatusLabel(status?: string | null): string { } /** - * Payment status pill. Once the booking's own lifecycle status has moved past - * payment (PAID or later — stage ≥ 3 in STATUS_CONFIG), payment is a settled - * fact: show "Paid" even if a stale/lagging `paymentStatus` value says - * otherwise, rather than surface a contradictory "Paid booking, pending - * payment" row. + * Booking statuses that are only reachable at or after the payment gate. + * Settlement writes `status` and `paymentStatus` in one transaction + * (booking-invoice.service.ts `advanceBookingOnPayment`), so these are a + * backstop for a stale/lagging `paymentStatus` — not the primary signal. */ +const PAID_OR_LATER_STATUSES = new Set([ + "PAID", + "TRUCK_ASSIGNED", + "IN_TRANSIT", + "ARRIVED", + "COMPLETED", +]); + +/** + * Payment status pill. `paymentStatus` is authoritative; the status set above + * only covers a lagging read, so a booking past the payment gate never shows a + * contradictory "Paid booking, pending payment" row. + * + * Note the set is explicit rather than derived from `STATUS_CONFIG.stage` — + * stage is a portal timeline grouping, and stage 3 lumps pre-payment clearance + * statuses (AWAITING_DOCUMENTS, CLEARANCE_READY, SIGNED_CUSTOMER, OPERATION_*) + * in with genuinely post-payment ones, which made every freshly initiated + * contract booking render as "Paid". + */ +export function effectivePaymentStatus( + status?: string | null, + bookingStatus?: string | null, +): string | null | undefined { + const settled = bookingStatus ? PAID_OR_LATER_STATUSES.has(bookingStatus) : false; + return settled ? "PAID" : status; +} + export function PaymentBadge({ status, bookingStatus, @@ -158,8 +184,7 @@ export function PaymentBadge({ status?: string | null; bookingStatus?: string | null; }) { - const settled = bookingStatus ? (STATUS_CONFIG[bookingStatus]?.stage ?? 0) >= 3 : false; - const effective = settled ? "PAID" : status; + const effective = effectivePaymentStatus(status, bookingStatus); if (!effective) return ; return ( { + it("keeps PENDING for pre-payment statuses", () => { + // Regression: these all sit at STATUS_CONFIG stage 3, so the old + // `stage >= 3` heuristic rendered every freshly initiated contract + // booking as "Paid". + for (const s of [ + "AWAITING_DOCUMENTS", + "DOCUMENTS_UNDER_REVIEW", + "CLEARANCE_READY", + "SIGNED_CUSTOMER", + "FULLY_EXECUTED", + "SELECTED_FOR_BATCH", + "OPERATION_REQUEST_PENDING", + "PNR_GENERATED", + ]) { + expect(effectivePaymentStatus("PENDING", s)).toBe("PENDING"); + } + }); + + it("shows PAID once the booking is at or past the payment gate", () => { + for (const s of ["PAID", "TRUCK_ASSIGNED", "IN_TRANSIT", "ARRIVED", "COMPLETED"]) { + expect(effectivePaymentStatus("PENDING", s)).toBe("PAID"); + } + }); + + it("passes the real payment status through when the booking status is absent", () => { + expect(effectivePaymentStatus("PAID", null)).toBe("PAID"); + expect(effectivePaymentStatus("FAILED", undefined)).toBe("FAILED"); + expect(effectivePaymentStatus(null, null)).toBeNull(); + }); +}); From a20498fd7784354cc48799d49947481178827e20 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 4 Aug 2026 08:57:25 +0000 Subject: [PATCH 09/11] chore: more it test --- docker-compose.it.yaml | 5 + integration/README.md | 11 ++ .../src/expired-invoice-late-settle.it.ts | 175 ++++++++++++++++++ 3 files changed, 191 insertions(+) create mode 100644 integration/src/expired-invoice-late-settle.it.ts diff --git a/docker-compose.it.yaml b/docker-compose.it.yaml index 3fb1b16a2..8bb341f0b 100644 --- a/docker-compose.it.yaml +++ b/docker-compose.it.yaml @@ -163,3 +163,8 @@ services: # RABBITMQ_ENABLED stays "false" (base stack) — it only gates the SMS/email # clients, which must remain off. The payment consumer is wired by # PAYMENT_RABBITMQ_URL alone. + # Drain tail on every pay window. Production defaults to 5 minutes; a + # reservation here lives ~60s, so 5 would push every natural expiry past + # the suite's 180s timeouts. One minute keeps the tail real and observable + # (src/expired-invoice-late-settle.it.ts asserts both sides of it). + FREIGHT_PAYMENT_DRAIN_MINUTES: "1" diff --git a/integration/README.md b/integration/README.md index b35d682ce..9798fd7f8 100644 --- a/integration/README.md +++ b/integration/README.md @@ -57,6 +57,7 @@ gateway-mock-it`) — the code is a read-only mount, not baked into an image. | `src/concurrency.it.ts` | duplicate callbacks, two intents on one invoice, wagon budget, pay-window gate | | `src/authz.it.ts` | cross-tenant isolation, login audience, service-token gates | | `src/cbe-bill.it.ts` | inbound CBE Unified Bill: token → query (hops into freight) → payment | +| `src/expired-invoice-late-settle.it.ts` | pay-window drain tail; a settlement landing after the hold expired still pays the invoice and revives the booking | | `src/bulk-import-full-train.it.ts` | six wheat bookings fill 54 wagons, then gate pass → T1 → dispatch → corridor → arrival → customs tail | | `src/bulk-import-waiting-expiry.it.ts` | exact-fill trio selected, waiting three expire with the day | | `src/bulk-import-split-promote.it.ts` | partial offer, split on settlement, expiry promotion, exact-remainder rebooking | @@ -131,6 +132,16 @@ what it actually does and says so in a comment, so a fix fails loudly: the phased path (`customs_clearing_enabled` on the contract) avoids it — which is why S8's customs tenant is Path B. +- **A live intent makes a hold unexpirable here** (`expired-invoice-late-settle`). + Reconcile-before-expire live-queries every non-FAILED intent; the mock answers + `PROCESSING` for an unpaid order, which the payment API reads as "money in + flight" → `unverifiable` → never expire on unknown. So while a CBE Birr intent + is open, NOTHING retires the reservation — not the settle tick, not the staff + `bookings/:id/expire` override (it runs the same guard). Producing the + expired-invoice-with-a-payment case therefore needs the intent retired first + (`status = 'FAILED'`, which reconcile skips), after which a webhook still + late-captures it (`applyProviderResult`). + ## Gotchas - **One unpaid hold per company.** `assertNoUnpaidHold` blocks a company with a diff --git a/integration/src/expired-invoice-late-settle.it.ts b/integration/src/expired-invoice-late-settle.it.ts new file mode 100644 index 000000000..6fb57f8c0 --- /dev/null +++ b/integration/src/expired-invoice-late-settle.it.ts @@ -0,0 +1,175 @@ +/** + * The swallowed late payment + * (docs/dev-testing/finding-expired-invoice-swallows-payment.md). + * + * Settlement is asynchronous — customer taps pay → provider confirms → payment + * API enqueues → relay delivers — so the success can land after the booking + * window cron has already expired the invoice. Before the fix the money simply + * vanished: the intent flipped to SUCCEEDED, `settleByPaymentId` matched no OPEN + * invoice and returned null, the relay was told `processed: true`, and the + * customer was left debited with an EXPIRED invoice and an EXPIRED booking. + * + * Two layers are asserted here: + * 1. the drain tail — a deadline that just passed does NOT expire anything + * (FREIGHT_PAYMENT_DRAIN_MINUTES, 1 in this stack); + * 2. the backstop — a settlement that lands after the drain is treated exactly + * like an in-window payment: invoice PAID, booking PAID. + */ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { apiOk, chief, closeDb, db, gateway, poll } from "./client"; +import { + createImportSchedule, + currentInvoice, + departureAt, + ensureCorridorRoute, + forceWindowOpen, + freightPayment, + gatewayIntent, + invoiceForBooking, + payInvoice, + prepareBooking, + releaseUnpaidHolds, + resetCorridorDay, + runBatch, + type ReadyBooking, +} from "./flows"; + +const DEPARTURE = departureAt(4); +const STAMP = String(Date.now()); + +/** The stack runs a 1-minute tail (FREIGHT_PAYMENT_DRAIN_MINUTES, docker-compose.it.yaml). */ +const DRAIN_MS = 60_000; +/** Long enough for several 10s settle ticks, comfortably inside the tail. */ +const INSIDE_TAIL_MS = DRAIN_MS * 0.4; + +const bookingRow = async (id: string) => + ( + await db<{ status: string; payment_status: string; train_schedule_id: string | null }>( + `SELECT status, payment_status, train_schedule_id FROM freight.bookings WHERE id = $1`, + [id], + ) + )[0]; + +describe("a payment that lands after the pay window is not swallowed", () => { + let booking: ReadyBooking; + let invoiceId: string; + + beforeAll(async () => { + await gateway.reset(); + await releaseUnpaidHolds(); + await ensureCorridorRoute(); + await resetCorridorDay(DEPARTURE); + const schedule = await createImportSchedule({ departure: DEPARTURE }); + await forceWindowOpen(schedule.id, 45); + + booking = await prepareBooking({ + suffix: "LATE1", + departure: DEPARTURE, + runStamp: STAMP, + isoSeed: 0, + twenty: 2, + }); + await runBatch(schedule.id); + invoiceId = (await invoiceForBooking(booking.bookingId)).id; + }); + + afterAll(closeDb); + + it("holds the reservation while the drain tail runs", async () => { + // Deadline just behind now(): the settle tick sees it every 10s and must + // leave it alone — this is the customer who tapped pay in the last seconds. + await db( + `UPDATE freight.bookings SET payment_deadline = now() - interval '5 seconds' + WHERE id = $1`, + [booking.bookingId], + ); + + await new Promise((r) => setTimeout(r, INSIDE_TAIL_MS)); + + const held = await bookingRow(booking.bookingId); + expect(held.status).toBe("SELECTED_FOR_BATCH"); + // The wagons are still HIS — releasing them at the raw deadline would sell + // them to the next customer while his settlement is still in flight. + expect(held.train_schedule_id).toBeTruthy(); + expect((await currentInvoice(invoiceId)).status).not.toBe("EXPIRED"); + }, 60_000); + + it("expires the hold while the customer's payment is still in flight", async () => { + // Open the intent first — payInvoice refuses once dueAt is behind us, which + // is the point: no NEW payment may start, only an in-flight one may land. + const res = await payInvoice(invoiceId, { method: "CBE_BIRR" }); + expect(res.status, JSON.stringify(res.body)).toBeLessThanOrEqual(201); + + const intent = await gatewayIntent(booking.bookingId); + expect(intent.status).toBe("REQUIRES_ACTION"); + expect((await currentInvoice(invoiceId)).payment_id).toBeTruthy(); + + // The provider reported a failure and we retired the intent — the same shape + // the payment API's own sweep writes. This is what makes the hold expirable: + // reconcile-before-expire skips FAILED candidates (intents.service.ts:412) + // and answers a clean "not paid". While the intent is live the mock answers + // PROCESSING → `unverifiable` → NOTHING expires the hold, not the settle tick + // and not staff. The money can still land afterwards: `applyProviderResult` + // registers a late capture on a retired intent (intents.service.ts:576). + await db( + `UPDATE edr_payment.payment_intent SET status = 'FAILED' WHERE id = $1`, + [intent.id], + ); + await apiOk(chief, "post", `/api/train-scheduling/bookings/${booking.bookingId}/expire`); + + const expired = await poll<{ status: string }>( + "invoice EXPIRED with the hold", + `SELECT status FROM freight.invoices WHERE id = $1`, + [invoiceId], + (row) => row?.status === "EXPIRED", + { attempts: 20, intervalMs: 2000 }, + ); + expect(expired.status).toBe("EXPIRED"); + expect((await bookingRow(booking.bookingId)).status).toBe("EXPIRED"); + // The settlement correlation key survives the expiry — `paymentId` is what + // settleByPaymentId looks the invoice up by when the money finally lands. + const linked = (await currentInvoice(invoiceId)).payment_id!; + expect((await freightPayment(linked)).merchant_order_id).toBe( + intent.merchant_order_id, + ); + }, 180_000); + + it("settles the EXPIRED invoice and revives the booking when the money lands", async () => { + const intent = await gatewayIntent(booking.bookingId); + const res = await gateway.webhook({ merchantOrderId: intent.merchant_order_id }); + expect(res.body.delivered).toBe(200); + + const settled = await poll<{ status: string }>( + "payment intent SUCCEEDED", + `SELECT status FROM edr_payment.payment_intent WHERE id = $1`, + [intent.id], + (row) => row?.status === "SUCCEEDED", + { attempts: 20, intervalMs: 1000 }, + ); + expect(settled.status).toBe("SUCCEEDED"); + + // The bug: this row used to sit at EXPIRED with paid_amount null forever, + // because settleByPaymentId only matched OPEN_STATUSES. + const invoice = await poll<{ status: string; paid_amount: string; balance_amount: string }>( + "expired invoice settled by the late payment", + `SELECT status, paid_amount, balance_amount FROM freight.invoices WHERE id = $1`, + [invoiceId], + (row) => row?.status === "PAID", + { attempts: 30, intervalMs: 2000 }, + ); + expect(Number(invoice.paid_amount)).toBeGreaterThan(0); + expect(Number(invoice.balance_amount)).toBe(0); + + // …and the second swallow point: advanceBookingOnPayment used to refuse an + // EXPIRED booking outright, so the money landed on a booking that stayed dead. + const revived = await poll<{ status: string }>( + "expired booking revived by the late payment", + `SELECT status FROM freight.bookings WHERE id = $1`, + [booking.bookingId], + (row) => row?.status === "PAID", + { attempts: 30, intervalMs: 2000 }, + ); + expect(revived.status).toBe("PAID"); + expect((await bookingRow(booking.bookingId)).payment_status).toBe("PAID"); + }, 180_000); +}); From acd2cfbe8c31fb3f14c99ce6942d7a54afee836f Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 4 Aug 2026 08:59:33 +0000 Subject: [PATCH 10/11] feat: add drain tail to the payments --- apps/edr-freight-api/.env.example | 6 + .../modules/billing/billing.service.spec.ts | 123 ++++++++++++++++++ .../src/modules/billing/billing.service.ts | 43 +++++- .../bookings/booking-invoice.service.ts | 19 ++- .../booking-batch.constants.ts | 42 +++++- .../booking-batch.service.spec.ts | 46 ++++++- .../train-scheduling/booking-batch.service.ts | 42 ++++-- .../train-scheduling/booking-split.service.ts | 13 ++ .../train-scheduling/pay-window-drain.spec.ts | 67 ++++++++++ 9 files changed, 379 insertions(+), 22 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/pay-window-drain.spec.ts diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index d6a39bfed..9338a958f 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -30,6 +30,12 @@ FREIGHT_PORTAL_URL=http://localhost:5173 # Point these at the freight portal's public payment result routes. PAYMENT_RETURN_URL=http://localhost:5173/payment/success PAYMENT_FAILURE_URL=http://localhost:5173/payment/failure + +# Drain tail (minutes) added to every booking pay window before anything expires: +# settlement is asynchronous, so a payment made in the window's last seconds lands +# after the deadline. Nothing is expired, no wagons are resold and no window cycle +# concludes until the tail passes. Defaults to 5 when unset. +FREIGHT_PAYMENT_DRAIN_MINUTES=5 # JWT (used by @tria-plc/api-common SharedAuthModule) JWT_SECRET= JWT_ACCESS_TOKEN_SECRET= diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts index dbf2bd4fd..1356f8cfa 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts @@ -203,6 +203,129 @@ describe("BillingService.markInvoiceAsPaid", () => { }); }); +/** + * A gateway success can land after the pay window AND its drain tail (relay + * backlog, payment-api restart, a CBE bill paid at a counter). The money is + * captured either way, so the settle lookup must accept an EXPIRED invoice — + * matching only OPEN_STATUSES used to drop it silently, leaving a debited + * customer with an EXPIRED invoice, an EXPIRED booking and no alert. + */ +describe("BillingService.settleByPaymentId", () => { + function serviceFor(invoice: Record | null) { + const mg = { + findOne: jest.fn().mockResolvedValue(invoice), + update: jest.fn().mockResolvedValue(undefined), + }; + const events = makeEvents(); + // The lookup is by paymentId ALONE — status is judged on the resolved row, + // so a stale capture can never skip past a newer invoice to an older one. + const findOne = jest.fn(({ where }: { where: Record }) => { + expect(where).toEqual({ paymentId: "pay-1" }); + return Promise.resolve(invoice); + }); + const dataSource = { + getRepository: () => ({ findOne }), + transaction: (cb: (mg: unknown) => unknown) => cb(mg), + manager: mg, + }; + const service = new BillingService( + dataSource as never, + {} as never, + {} as never, + events as never, + {} as never, // payment + {} as never, // companies + {} as never, // invoiceDocuments + ); + return { service, mg, events }; + } + + it("settles an EXPIRED invoice — the money was already captured", async () => { + const { service, mg, events } = serviceFor({ + id: "inv-1", + status: Freight.InvoiceStatus.Expired, + source: "booking", + sourceId: "booking-1", + totalAmount: 1500, + paidAt: null, + }); + + const settled = await service.settleByPaymentId("pay-1", "txn-1"); + + expect(settled?.status).toBe(Freight.InvoiceStatus.Paid); + expect(mg.update).toHaveBeenCalledWith( + expect.anything(), + { id: "inv-1" }, + expect.objectContaining({ + status: Freight.InvoiceStatus.Paid, + paidAmount: 1500, + balanceAmount: 0, + }), + ); + // The domain reacts to this — it is what revives the expired booking. + expect(events.emitAsync).toHaveBeenCalledWith( + "booking.invoice.paid", + expect.objectContaining({ invoiceId: "inv-1" }), + ); + }); + + it("still settles an open (PENDING) invoice", async () => { + const { service, mg } = serviceFor({ + id: "inv-1", + status: Freight.InvoiceStatus.Pending, + source: "booking", + sourceId: "booking-1", + totalAmount: 1500, + paidAt: null, + }); + + await service.settleByPaymentId("pay-1"); + + expect(mg.update).toHaveBeenCalled(); + }); + + /** + * `upsertIntent` keeps ONE local payments row per booking reference, so every + * invoice the booking was ever charged on carries the same `paymentId`. A + * capture from a lapsed first attempt must not reach back past the invoice the + * customer actually paid and settle the older EXPIRED one — that would mark two + * invoices paid off a single payment. + */ + it("no-ops when the booking's newest invoice is already PAID", async () => { + const { service, mg, events } = serviceFor({ + id: "inv-2", + status: Freight.InvoiceStatus.Paid, + source: "booking", + sourceId: "booking-1", + totalAmount: 1500, + }); + + expect(await service.settleByPaymentId("pay-1")).toBeNull(); + expect(mg.update).not.toHaveBeenCalled(); + expect(events.emitAsync).not.toHaveBeenCalled(); + }); + + it.each([ + ["CANCELLED", Freight.InvoiceStatus.Cancelled], + ["REFUNDED", Freight.InvoiceStatus.Refunded], + ])("does not settle a %s invoice — that is a refund case", async ( + _label, + status, + ) => { + const { service, mg, events } = serviceFor({ + id: "inv-1", + status, + source: "booking", + sourceId: "booking-1", + totalAmount: 1500, + }); + + expect(await service.settleByPaymentId("pay-1")).toBeNull(); + expect(mg.update).not.toHaveBeenCalled(); + expect(events.emitAsync).not.toHaveBeenCalled(); + }); +}); + describe("BillingService.recordPayment", () => { function serviceFor(invoice: Record | null) { const mg = { diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 88ef92b3d..24046aa5c 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -1162,6 +1162,25 @@ export class BillingService { * `paymentId`, marks it paid, and emits `${source}.invoice.paid` for the domain * to advance on. Idempotent — no-op when no open invoice is linked (already * settled, or settled inline by {@link payInvoice}). + * + * EXPIRED is settleable HERE and only here: this is the gateway path, so the + * money is already captured and we are recording a fait accompli. A success can + * land after the pay window plus its drain tail (relay backlog, payment-api + * restart, a CBE bill paid at a counter) — matching only `OPEN_STATUSES` used to + * drop it silently, leaving a debited customer with an EXPIRED invoice and no + * alert. The manual/offline path ({@link recordPayment}) keeps its EXPIRED guard: + * a teller must not accept cash against a lapsed invoice. + * + * The status is checked on the RESOLVED invoice, never inside the lookup. + * `paymentId` is freight's local intent projection, and `upsertIntent` keeps ONE + * row per booking reference across every pay attempt — so a booking that was + * re-invoiced after a lapsed attempt has SEVERAL invoices carrying the same + * `paymentId`. Filtering by status inside the query would let a late capture from + * attempt 1 skip past the already-PAID attempt-2 invoice and settle the older + * EXPIRED one, marking two invoices paid off a single capture. Resolving the + * newest invoice first and then asking whether IT is settleable makes the answer + * "this booking's money is already recorded" instead. CANCELLED/REFUNDED are + * refund cases, not settlements, and are logged rather than settled. */ async settleByPaymentId( paymentId: string, @@ -1169,11 +1188,31 @@ export class BillingService { paidAt?: Date, ): Promise { const invoice = await this.dataSource.getRepository(Invoice).findOne({ - where: { paymentId, status: In(OPEN_STATUSES) }, - order: { issuedAt: "DESC" }, + where: { paymentId }, + // NULLS LAST: a DRAFT invoice has no issuedAt and Postgres sorts NULLs + // first on DESC, which would hand back an unissued invoice. + order: { issuedAt: { direction: "DESC", nulls: "LAST" } }, }); if (!invoice) return null; + const settleable: Freight.InvoiceStatus[] = [ + ...OPEN_STATUSES, + Freight.InvoiceStatus.Expired, + ]; + if (!settleable.includes(invoice.status)) { + // Already PAID is the ordinary idempotent no-op (redelivery, or settled + // inline by payInvoice). Anything else means money was captured with + // nowhere to land — that needs a person, so say so loudly. + if (invoice.status !== Freight.InvoiceStatus.Paid) { + this.logger.error( + `Payment ${paymentId} succeeded but invoice ${invoice.invoiceNumber} ` + + `(${invoice.id}) is ${invoice.status} — nothing settled. The capture ` + + `needs a refund or a manual settlement.`, + ); + } + return null; + } + return this.markInvoiceAsPaid(invoice.id, paymentId, undefined, { providerTxnId, paidAt, diff --git a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts index 82bb1144e..9212bcdef 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts @@ -102,6 +102,10 @@ export class BookingInvoiceService { ); switch (payload.type) { case "PREPAID": + // Before advancing: if this invoice belonged to a partial offer that + // lapsed before the settlement landed, revive it, or the booking boards + // whole having paid only the offered part. + await this.bookingBatch.reviveOfferForInvoice(payload.invoiceId); await this.advanceBookingOnPayment(payload.sourceId); break; default: @@ -164,15 +168,24 @@ export class BookingInvoiceService { // may have moved on or been terminated between invoicing and settlement. // Only advance one that is still awaiting payment: no-op when already PAID, // and refuse to advance a booking in a terminal/advanced status - // (CANCELLED/REJECTED/EXPIRED or already past the payment gate) so we never - // rewrite its status or re-run allocation. + // (CANCELLED/REJECTED or already past the payment gate) so we never rewrite + // its status or re-run allocation. + // + // EXPIRED is NOT in that list: settlement is async, so a payment can land + // after the pay window and its drain tail (relay backlog, payment-api + // restart, a CBE bill paid at a counter). The money was captured, so it gets + // exactly the same treatment as an in-window payment — the booking becomes + // PAID and ensurePaidBookingAllocated re-places it via + // replaceStrandedPaidBooking (a same-day train with room, or a manual-assign + // log). Leaving EXPIRED here debited the customer for nothing. CANCELLED and + // REJECTED stay: a person terminated those, so a payment against them is a + // refund case, not a boarding. if (booking.paymentStatus === "PAID" || booking.status === "PAID") { return; } const TERMINAL_OR_ADVANCED_STATUSES: string[] = [ "CANCELLED", "REJECTED", - "EXPIRED", "IN_TRANSIT", "ARRIVED", "COMPLETED", diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts index d43cff60b..45370f546 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts @@ -7,11 +7,51 @@ * (BookingWindowService) drives all timing off that config. */ -export const BATCH_TIMEZONE = 'Africa/Addis_Ababa'; +export const BATCH_TIMEZONE = "Africa/Addis_Ababa"; /** How long before the pay deadline the one reminder notification goes out. */ export const PAYMENT_REMINDER_LEAD_MS = 10 * 60_000; +/** Drain tail applied to every pay window when FREIGHT_PAYMENT_DRAIN_MINUTES is unset. */ +export const DEFAULT_PAYMENT_DRAIN_MINUTES = 7; + +/** + * Drain tail on every pay window, in ms. Read per call so the env var can be + * changed without a rebuild (and so tests can set it). + */ +export function paymentDrainMs(): number { + // An empty/blank value is UNSET, not zero — a bare `FREIGHT_PAYMENT_DRAIN_MINUTES=` + // left in a .env must not silently disable the drain (Number('') is 0). + const raw = process.env.FREIGHT_PAYMENT_DRAIN_MINUTES?.trim(); + const minutes = raw ? Number(raw) : NaN; + return ( + (Number.isFinite(minutes) && minutes >= 0 + ? minutes + : DEFAULT_PAYMENT_DRAIN_MINUTES) * 60_000 + ); +} + +/** + * A pay window AND its drain tail have closed. + * + * Settlement is asynchronous (provider confirm → payment-api → outbox relay), so + * a payment made in the last seconds of the window lands after `paymentDeadline`. + * The drain defers the WHOLE expiry pipeline — wagons stay held, the waiting list + * is not promoted, the window cycle does not conclude — so that settlement still + * has a live booking to land on. A payment that arrives even later is not lost + * either: it settles the expired invoice and revives the booking (see + * BillingService.settleByPaymentId + BookingInvoiceService.advanceBookingOnPayment). + * + * No deadline ⇒ never lapsed; callers decide what an unknown deadline means. + */ +export function payWindowLapsed( + deadline: Date | null | undefined, + now: number, + drainMs: number = paymentDrainMs(), +): boolean { + return deadline != null && deadline.getTime() + drainMs <= now; +} + /** Fallback wagons-per-booking when a booking has no computed `wagonsRequired`. */ export const DEFAULT_WAGONS_PER_BOOKING = 1; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts index 1a1a75275..ce0e77a3e 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts @@ -1,7 +1,14 @@ import { BookingBatchService } from './booking-batch.service'; +import { paymentDrainMs } from './booking-batch.constants'; import { Booking } from '../bookings/entities/booking.entity'; import { WagonStockLedger } from './wagon-stock-ledger.util'; +/** + * A pay window closed long enough ago to be past its drain tail as well — i.e. + * genuinely expirable. Inside the tail nothing expires (see payWindowLapsed). + */ +const fullyLapsedDeadline = () => new Date(Date.now() - 60_000 - paymentDrainMs()); + describe('BookingBatchService — PAID reconcile', () => { const scheduleId = 'schedule-1'; const bookingId = 'booking-1'; @@ -856,7 +863,7 @@ describe('BookingBatchService — PAID reconcile', () => { // One reservation whose pay window lapsed, and one booking on the waiting list. const lapsed = booking('lapsed', 50, { status: 'SELECTED_FOR_BATCH', - paymentDeadline: new Date(Date.now() - 60_000), + paymentDeadline: fullyLapsedDeadline(), }); const waiting = booking('waiting', 10, { trainScheduleId: null }); @@ -888,10 +895,41 @@ describe('BookingBatchService — PAID reconcile', () => { expect((notifier.payNow.mock.calls[0][0] as Booking).id).toBe('waiting'); }); + it('holds a reservation whose deadline passed but whose drain tail has not', async () => { + // Settlement is asynchronous, so a payment made in the window's last + // seconds lands after the deadline. Expiring here would free the wagons + // out from under it — the swallowed-payment finding. + const draining = booking('draining', 50, { + status: 'SELECTED_FOR_BATCH', + paymentDeadline: new Date(Date.now() - 60_000), + }); + const waiting = booking('waiting', 10, { trainScheduleId: null }); + + bookingsRepository.findReservedForSchedule + .mockResolvedValueOnce([draining]) + .mockResolvedValue([]); + bookingsRepository.findBatchPoolByCorridorDay + .mockResolvedValueOnce([waiting]) + .mockResolvedValue([]); + const byId: Record = { draining, waiting }; + dataSource + .getRepository() + .findOne.mockImplementation( + async (opts: { where?: { id?: string } }) => + byId[opts?.where?.id ?? ''] ?? null, + ); + + await service.settleDueReservations(trainId); + + expect(notifier.expired).not.toHaveBeenCalled(); + // ...and its wagons were NOT handed to the waiting list either. + expect(notifier.payNow).not.toHaveBeenCalled(); + }); + it('serialises concurrent settles so the same reservation is not settled twice', async () => { const lapsed = booking('lapsed', 50, { status: 'SELECTED_FOR_BATCH', - paymentDeadline: new Date(Date.now() - 60_000), + paymentDeadline: fullyLapsedDeadline(), }); // Both callers read the reservation; the lock must stop the second from // acting on rows the first already expired. (The PAYMENT transition and the @@ -922,7 +960,7 @@ describe('BookingBatchService — PAID reconcile', () => { it('never expires a reservation whose payment landed — allocates it instead', async () => { const latePaid = booking('late-paid', 50, { status: 'SELECTED_FOR_BATCH', - paymentDeadline: new Date(Date.now() - 60_000), + paymentDeadline: fullyLapsedDeadline(), }); bookingsRepository.findReservedForSchedule .mockResolvedValueOnce([latePaid]) @@ -1036,7 +1074,7 @@ describe('BookingBatchService — PAID reconcile', () => { ...(waiting as unknown as Record), status: 'SELECTED_FOR_BATCH', trainScheduleId: exportScheduleId, - paymentDeadline: new Date(Date.now() - 60_000), + paymentDeadline: fullyLapsedDeadline(), originYardId: 'yard-a', destinationYardId: 'yard-b', priorityScore: 0, 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 3d3b992d3..77a3f8e55 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 @@ -64,6 +64,8 @@ import { DEFAULT_CONTAINER_WAGON_TARE_TONS, DEFAULT_WAGONS_PER_BOOKING, PAYMENT_REMINDER_LEAD_MS, + payWindowLapsed, + paymentDrainMs, } from "./booking-batch.constants"; import { LocomotiveLimits, @@ -692,6 +694,15 @@ export class BookingBatchService implements OnModuleInit { await this.ensurePaidBookingAllocated(bookingId); } + /** + * A late settlement paid a partial offer whose window had lapsed — bring the + * offer back so `ensurePaidBookingAllocated`'s applySplit still reduces the + * booking to what was actually bought. No-op without the split feature. + */ + async reviveOfferForInvoice(invoiceId: string): Promise { + await this.splitService?.reviveOfferForInvoice(invoiceId); + } + /** * Day-level backstop for stranded PAID bookings: reconcilePaidUnlinked is * keyed on train_schedule_id, so a booking whose hold was expired (schedule @@ -2730,11 +2741,13 @@ export class BookingBatchService implements OnModuleInit { const isPaid = (b: Booking) => b.paymentStatus === "PAID" || b.status === "PAID"; - // Deadline is the line — no fixed slack. A payment that beat the deadline - // but whose webhook is late is caught by expire()'s gateway reconcile. + // The deadline carries a drain tail (payWindowLapsed): settlement is async, + // so a payment made in the window's last seconds lands after it. Nothing is + // expired until the tail passes. expire()'s gateway reconcile is the second + // line of defence, not the first. const isExpired = (b: Booking) => b.paymentDeadline - ? b.paymentDeadline.getTime() <= now + ? payWindowLapsed(b.paymentDeadline, now) : expireUnpaidUnknownDeadline; for (const booking of reserved) { @@ -4596,11 +4609,12 @@ export class BookingBatchService implements OnModuleInit { const allocated = (schedule.scheduleBookings ?? []) .map((sb) => sb.booking) .filter((b): b is Booking => Boolean(b)); - // Lazy-expiry guard: a hold whose deadline lapsed no longer blocks - // capacity, even before the 10s sweep flips it to EXPIRED — availability - // shown to the next customer is honest between ticks. A late capture the - // gateway reconcile later confirms lands as PAID and, if the wagons went - // meanwhile, degrades to WAITING_FOR_WAGON for manual placement. + // Lazy-expiry guard: a hold whose deadline AND drain tail lapsed no longer + // blocks capacity, even before the 10s sweep flips it to EXPIRED — + // availability shown to the next customer is honest between ticks. The drain + // has to be honoured here too: releasing the wagons at the raw deadline + // would resell them to someone else while the paying customer's settlement + // is still in flight, stranding it into WAITING_FOR_WAGON. const deadlineCutoff = Date.now(); const reserved = ( await this.bookingsRepository.findReservedForSchedule(schedule.id) @@ -4608,8 +4622,7 @@ export class BookingBatchService implements OnModuleInit { (b) => b.paymentStatus === "PAID" || b.status === "PAID" || - b.paymentDeadline == null || - b.paymentDeadline.getTime() > deadlineCutoff, + !payWindowLapsed(b.paymentDeadline, deadlineCutoff), ); for (const b of [...allocated, ...reserved]) { budget.subtract( @@ -4684,7 +4697,9 @@ export class BookingBatchService implements OnModuleInit { } /** - * A reservation on this schedule still has time left to pay. + * A reservation on this schedule still has time left to pay — including its + * drain tail, so the cycle cannot conclude out from under a settlement that is + * still in flight. * * The PAYMENT phase ends a hair BEFORE its own reservations do: `paymentPhaseEndsAt` * is stamped when the phase starts, then `reserve()` gives each booking @@ -4705,7 +4720,7 @@ export class BookingBatchService implements OnModuleInit { b.paymentStatus !== "PAID" && b.status !== "PAID" && b.paymentDeadline != null && - b.paymentDeadline.getTime() > now, + !payWindowLapsed(b.paymentDeadline, now), ); } @@ -4914,7 +4929,10 @@ export class BookingBatchService implements OnModuleInit { */ private armSettle(scheduleId: string): void { void this.scheduleById(scheduleId) + // + drain tail: firing at the raw deadline is a guaranteed no-op pass now + // that nothing expires until the tail passes. .then((schedule) => this.paymentWindowMsFor(schedule)) + .then((windowMs: number) => windowMs + paymentDrainMs()) .then((delayMs: number) => { this.removeTimeout(scheduleId); const handle = setTimeout(() => { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.ts index 9121e4841..ba4f08552 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.ts @@ -302,6 +302,19 @@ export class BookingSplitService { .update({ bookingId, status: 'OFFERED' }, { status: 'EXPIRED' }); } + /** + * A late gateway settlement paid the invoice of an offer whose window had + * already lapsed. The customer bought the offered part, so the offer is live + * again and {@link applySplit} must reduce the booking to it — otherwise the + * booking boards WHOLE having paid only the offered portion. Keyed on the paid + * invoice, never on the booking: an offer that simply timed out stays dead. + */ + async reviveOfferForInvoice(invoiceId: string): Promise { + await this.dataSource + .getRepository(BookingBatchOffer) + .update({ invoiceId, status: 'EXPIRED' }, { status: 'OFFERED' }); + } + async findOpenOffer(bookingId: string): Promise { return this.dataSource.getRepository(BookingBatchOffer).findOne({ where: { bookingId, status: 'OFFERED' }, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/pay-window-drain.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/pay-window-drain.spec.ts new file mode 100644 index 000000000..6054f28b2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/pay-window-drain.spec.ts @@ -0,0 +1,67 @@ +import { + DEFAULT_PAYMENT_DRAIN_MINUTES, + paymentDrainMs, + payWindowLapsed, +} from "./booking-batch.constants"; + +/** + * The drain tail is what keeps a pay window's LAST payment from being thrown + * away: settlement is asynchronous (provider confirm → payment-api → outbox + * relay), so a payment made in the window's final seconds lands after + * `paymentDeadline`. Nothing may expire, no wagons may be resold and no window + * cycle may conclude until the tail has passed. + */ +describe("payWindowLapsed — pay-window drain tail", () => { + const deadline = new Date("2026-08-02T22:04:41Z"); + const at = (offsetMs: number) => deadline.getTime() + offsetMs; + const MIN = 60_000; + + afterEach(() => { + delete process.env.FREIGHT_PAYMENT_DRAIN_MINUTES; + }); + + it("is not lapsed before the deadline", () => { + expect(payWindowLapsed(deadline, at(-1 * MIN))).toBe(false); + }); + + it("is not lapsed inside the drain tail", () => { + // The reproduced finding: settled ~7 minutes late. With the default 5-minute + // tail the booking is still live at 4 minutes. + expect(payWindowLapsed(deadline, at(4 * MIN))).toBe(false); + expect(payWindowLapsed(deadline, at(DEFAULT_PAYMENT_DRAIN_MINUTES * MIN - 1))).toBe( + false, + ); + }); + + it("is lapsed once the tail passes", () => { + expect(payWindowLapsed(deadline, at(DEFAULT_PAYMENT_DRAIN_MINUTES * MIN))).toBe( + true, + ); + expect(payWindowLapsed(deadline, at(7 * MIN))).toBe(true); + }); + + it("never lapses without a deadline — callers decide what unknown means", () => { + expect(payWindowLapsed(null, at(60 * MIN))).toBe(false); + expect(payWindowLapsed(undefined, at(60 * MIN))).toBe(false); + }); + + it("honours FREIGHT_PAYMENT_DRAIN_MINUTES", () => { + process.env.FREIGHT_PAYMENT_DRAIN_MINUTES = "20"; + expect(paymentDrainMs()).toBe(20 * MIN); + expect(payWindowLapsed(deadline, at(10 * MIN))).toBe(false); + expect(payWindowLapsed(deadline, at(20 * MIN))).toBe(true); + }); + + it("allows an explicit zero drain (old deadline-is-the-line behaviour)", () => { + process.env.FREIGHT_PAYMENT_DRAIN_MINUTES = "0"; + expect(paymentDrainMs()).toBe(0); + expect(payWindowLapsed(deadline, at(0))).toBe(true); + }); + + it("falls back to the default on garbage or negative values", () => { + for (const bad of ["", "abc", "-3"]) { + process.env.FREIGHT_PAYMENT_DRAIN_MINUTES = bad; + expect(paymentDrainMs()).toBe(DEFAULT_PAYMENT_DRAIN_MINUTES * MIN); + } + }); +}); From 7c78a815eb6f222d3878e8efb293afa32c8fd92b Mon Sep 17 00:00:00 2001 From: Marshal Date: Tue, 4 Aug 2026 09:48:49 +0000 Subject: [PATCH 11/11] add custom contrat templates --- .../contract-document-view-model.builder.ts | 1 + ...0000000-SplitContractTemplatesByCustoms.ts | 101 ++++++++++++++++++ .../contract-template-code.spec.ts | 66 ++++++++++++ .../contract-templates.service.spec.ts | 20 ++-- .../contract-templates.service.ts | 30 ++++-- .../entities/contract-template.entity.ts | 46 ++++++-- .../contracts/contract-transition.service.ts | 1 + .../seed/data/contract-template-defaults.ts | 97 +++++++++++++---- .../ContractTemplatesPage.tsx | 48 +++++++-- .../services/contract-templates.service.ts | 14 ++- 10 files changed, 366 insertions(+), 58 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3230000000000-SplitContractTemplatesByCustoms.ts create mode 100644 apps/edr-freight-api/src/modules/contract-templates/contract-template-code.spec.ts diff --git a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts index eb9541d8e..e6fcaf8f7 100644 --- a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts @@ -119,6 +119,7 @@ export class ContractDocumentViewModelBuilder { const dynamicSource = await this.contractTemplates.findActiveForContract( contract.tradeDirection, contract.freightType, + contract.customsClearingEnabled, ); dynamicTemplate = dynamicSource ? { diff --git a/apps/edr-freight-api/src/migrations/3230000000000-SplitContractTemplatesByCustoms.ts b/apps/edr-freight-api/src/migrations/3230000000000-SplitContractTemplatesByCustoms.ts new file mode 100644 index 000000000..88e715b05 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3230000000000-SplitContractTemplatesByCustoms.ts @@ -0,0 +1,101 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +import { CONTRACT_TEMPLATE_DEFAULTS } from '../seed/data/contract-template-defaults'; + +/** + * The codes that gain a customs variant. Intercity is deliberately absent: it + * is a domestic Ethiopian movement that crosses no border, so it has no customs + * leg and keeps its single unsuffixed template. + */ +const SPLIT_CODES = [ + 'IMPORT_BULK', + 'EXPORT_BULK', + 'IMPORT_CONTAINER', + 'EXPORT_CONTAINER', +]; + +/** + * Split the four cross-border contract templates into eight — one `_CUSTOMS` + * and one `_NO_CUSTOMS` variant each — so the generated contract document + * reflects whether EDR clears customs on the Client's behalf. Together with the + * two untouched intercity templates the table ends up with ten rows. + * + * The four existing rows are RENAMED to `_NO_CUSTOMS` rather than + * replaced, so any article text staff already edited through the template + * editor survives. The four `_CUSTOMS` rows are then inserted from the seed + * (the same base pack plus the customs-clearing articles). + * + * Idempotent: the rename is guarded on the legacy code still existing, and the + * insert is ON CONFLICT (code) DO NOTHING. + */ +export class SplitContractTemplatesByCustoms3230000000000 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + // 1. Carry each legacy row over to its _NO_CUSTOMS code, preserving edits. + // Guarded so a re-run (or a DB already holding the new code) is a no-op. + for (const legacy of SPLIT_CODES) { + await queryRunner.query( + ` + UPDATE freight.contract_templates + SET code = $2, updated_at = now() + WHERE code = $1 + AND NOT EXISTS ( + SELECT 1 FROM freight.contract_templates WHERE code = $2 + ); + `, + [legacy, `${legacy}_NO_CUSTOMS`], + ); + } + + // 2. Seed anything still missing — the six _CUSTOMS rows on an existing DB, + // or all twelve on a database that never held the legacy codes. + for (const seed of CONTRACT_TEMPLATE_DEFAULTS) { + const articles = seed.articles.map((article, index) => ({ + ...article, + order: index + 1, + })); + await queryRunner.query( + ` + INSERT INTO freight.contract_templates + (code, name, description, document_title, whereas_clauses, articles) + VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb) + ON CONFLICT (code) DO NOTHING; + `, + [ + seed.code, + seed.name, + seed.description, + seed.documentTitle, + JSON.stringify(seed.whereasClauses), + JSON.stringify(articles), + ], + ); + } + } + + /** + * Drop the _CUSTOMS rows and fold the _NO_CUSTOMS rows back onto the legacy + * codes, returning the table to six templates. The two intercity rows were + * never touched by up(), so they need no reversal. + */ + public async down(queryRunner: QueryRunner): Promise { + for (const legacy of SPLIT_CODES) { + await queryRunner.query( + `DELETE FROM freight.contract_templates WHERE code = $1;`, + [`${legacy}_CUSTOMS`], + ); + await queryRunner.query( + ` + UPDATE freight.contract_templates + SET code = $1, updated_at = now() + WHERE code = $2 + AND NOT EXISTS ( + SELECT 1 FROM freight.contract_templates WHERE code = $1 + ); + `, + [legacy, `${legacy}_NO_CUSTOMS`], + ); + } + } +} diff --git a/apps/edr-freight-api/src/modules/contract-templates/contract-template-code.spec.ts b/apps/edr-freight-api/src/modules/contract-templates/contract-template-code.spec.ts new file mode 100644 index 000000000..803814967 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contract-templates/contract-template-code.spec.ts @@ -0,0 +1,66 @@ +import { + CONTRACT_TEMPLATE_CODES, + contractTemplateCodeFor, +} from './entities/contract-template.entity'; +import { CONTRACT_TEMPLATE_DEFAULTS } from '../../seed/data/contract-template-defaults'; + +describe('contractTemplateCodeFor', () => { + it('splits import and export by the customs flag', () => { + expect(contractTemplateCodeFor('IMPORT', 'BULK', true)).toBe('IMPORT_BULK_CUSTOMS'); + expect(contractTemplateCodeFor('IMPORT', 'BULK', false)).toBe('IMPORT_BULK_NO_CUSTOMS'); + expect(contractTemplateCodeFor('EXPORT', 'CONTAINER', true)).toBe( + 'EXPORT_CONTAINER_CUSTOMS', + ); + expect(contractTemplateCodeFor('EXPORT', 'CONTAINER', false)).toBe( + 'EXPORT_CONTAINER_NO_CUSTOMS', + ); + }); + + it('never gives intercity a customs variant — it crosses no border', () => { + for (const flag of [true, false, null, undefined]) { + expect(contractTemplateCodeFor('DOMESTIC', 'BULK', flag)).toBe('INTERCITY_BULK'); + expect(contractTemplateCodeFor('DOMESTIC', 'CONTAINER', flag)).toBe( + 'INTERCITY_CONTAINER', + ); + } + }); + + it('treats a missing customs flag as no customs on cross-border contracts', () => { + expect(contractTemplateCodeFor('IMPORT', 'CONTAINER', null)).toBe( + 'IMPORT_CONTAINER_NO_CUSTOMS', + ); + expect(contractTemplateCodeFor('IMPORT', 'CONTAINER', undefined)).toBe( + 'IMPORT_CONTAINER_NO_CUSTOMS', + ); + }); + + it('only ever resolves to a code that exists', () => { + const directions = ['IMPORT', 'EXPORT', 'DOMESTIC', null]; + const freights = ['BULK', 'CONTAINER', 'BREAK_BULK', null]; + for (const d of directions) { + for (const f of freights) { + for (const c of [true, false]) { + expect(CONTRACT_TEMPLATE_CODES).toContain(contractTemplateCodeFor(d, f, c)); + } + } + } + }); +}); + +describe('CONTRACT_TEMPLATE_DEFAULTS', () => { + it('seeds exactly the ten declared codes, once each', () => { + const seeded = CONTRACT_TEMPLATE_DEFAULTS.map((t) => t.code).sort(); + expect(seeded).toHaveLength(10); + expect(seeded).toEqual([...CONTRACT_TEMPLATE_CODES].sort()); + }); + + it('gives every _CUSTOMS template the customs articles and no other one', () => { + for (const seed of CONTRACT_TEMPLATE_DEFAULTS) { + const hasCustomsArticle = seed.articles.some((a) => a.id === 'customs-clearing'); + // Note "_NO_CUSTOMS" also ends with "_CUSTOMS" — exclude it explicitly. + const isCustomsVariant = + seed.code.endsWith('_CUSTOMS') && !seed.code.endsWith('_NO_CUSTOMS'); + expect(hasCustomsArticle).toBe(isCustomsVariant); + } + }); +}); diff --git a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.spec.ts b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.spec.ts index 0478db102..11a838223 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.spec.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.spec.ts @@ -25,11 +25,19 @@ function seededTemplate(code: string): ContractTemplate { } describe("contractTemplateCodeFor", () => { - it("maps every direction/freight pair to one of the six codes", () => { - expect(contractTemplateCodeFor("IMPORT", "BULK")).toBe("IMPORT_BULK"); - expect(contractTemplateCodeFor("EXPORT", "CONTAINER")).toBe("EXPORT_CONTAINER"); - expect(contractTemplateCodeFor("DOMESTIC", "CONTAINER")).toBe("INTERCITY_CONTAINER"); - expect(contractTemplateCodeFor("DOMESTIC", "BULK")).toBe("INTERCITY_BULK"); + it("maps every direction/freight/customs triple to one of the ten codes", () => { + expect(contractTemplateCodeFor("IMPORT", "BULK", true)).toBe("IMPORT_BULK_CUSTOMS"); + expect(contractTemplateCodeFor("IMPORT", "BULK", false)).toBe( + "IMPORT_BULK_NO_CUSTOMS", + ); + expect(contractTemplateCodeFor("EXPORT", "CONTAINER", true)).toBe( + "EXPORT_CONTAINER_CUSTOMS", + ); + // Intercity is domestic — no border, so no customs variant either way. + expect(contractTemplateCodeFor("DOMESTIC", "CONTAINER", true)).toBe( + "INTERCITY_CONTAINER", + ); + expect(contractTemplateCodeFor("DOMESTIC", "BULK", false)).toBe("INTERCITY_BULK"); expect(contractTemplateCodeFor(null, null)).toBe("INTERCITY_CONTAINER"); }); }); @@ -60,7 +68,7 @@ describe("ContractTemplatesService.preview", () => { ); it("interpolates {{contractYear}} inside seeded article bodies", async () => { - const { html } = await service.preview("IMPORT_BULK"); + const { html } = await service.preview("IMPORT_BULK_CUSTOMS"); expect(html).toContain(`August 31, ${new Date().getFullYear()}`); }); }); diff --git a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts index d2755fece..cb956878d 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts @@ -24,13 +24,22 @@ import { contractTemplateCodeFor, } from "./entities/contract-template.entity"; -/** Registry keys used to derive labels for the mock preview per template code. */ +/** + * Registry keys used to derive labels for the mock preview per template code. + * The registry's FORWARDING scope carries the customs/clearing clause pack, so + * the `_CUSTOMS` codes preview against it and `_NO_CUSTOMS` against + * TRANSPORT_ONLY. + */ const PREVIEW_TEMPLATE_KEYS: Record = { - IMPORT_BULK: "IMP_BULK_USD_FORWARDING", - EXPORT_BULK: "EXP_BULK_USD_TRANSPORT_ONLY", + IMPORT_BULK_CUSTOMS: "IMP_BULK_USD_FORWARDING", + IMPORT_BULK_NO_CUSTOMS: "IMP_BULK_USD_TRANSPORT_ONLY", + EXPORT_BULK_CUSTOMS: "EXP_BULK_USD_FORWARDING", + EXPORT_BULK_NO_CUSTOMS: "EXP_BULK_USD_TRANSPORT_ONLY", INTERCITY_BULK: "DOM_BULK_USD_TRANSPORT_ONLY", - IMPORT_CONTAINER: "IMP_CON_USD_TRANSPORT_ONLY", - EXPORT_CONTAINER: "EXP_CON_USD_FORWARDING", + IMPORT_CONTAINER_CUSTOMS: "IMP_CON_USD_FORWARDING", + IMPORT_CONTAINER_NO_CUSTOMS: "IMP_CON_USD_TRANSPORT_ONLY", + EXPORT_CONTAINER_CUSTOMS: "EXP_CON_USD_FORWARDING", + EXPORT_CONTAINER_NO_CUSTOMS: "EXP_CON_USD_TRANSPORT_ONLY", INTERCITY_CONTAINER: "DOM_CON_USD_TRANSPORT_ONLY", }; @@ -59,14 +68,19 @@ export class ContractTemplatesService { /** * The active template used when generating a contract document for the given - * direction/freight pair; null when missing or deactivated (the renderer then - * falls back to the built-in generic layout). + * direction/freight/customs triple; null when missing or deactivated (the + * renderer then falls back to the built-in generic layout). */ async findActiveForContract( tradeDirection?: string | null, freightType?: string | null, + customsClearingEnabled?: boolean | null, ): Promise { - const code = contractTemplateCodeFor(tradeDirection, freightType); + const code = contractTemplateCodeFor( + tradeDirection, + freightType, + customsClearingEnabled, + ); const template = await this.repository.findByCode(code); return template?.isActive ? template : null; } diff --git a/apps/edr-freight-api/src/modules/contract-templates/entities/contract-template.entity.ts b/apps/edr-freight-api/src/modules/contract-templates/entities/contract-template.entity.ts index 73729fbb6..c322c20a4 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/entities/contract-template.entity.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/entities/contract-template.entity.ts @@ -2,17 +2,30 @@ import { BaseEntity } from "@edr/api-common"; import { Column, Entity, Index } from "typeorm"; /** - * The six canonical contract document templates, one per - * (trade direction × freight type) combination. Contracts store DOMESTIC for - * intercity movements; the template layer labels those INTERCITY to match the - * commercial vocabulary used on the printed documents. + * The ten canonical contract document templates. Import and export split by + * customs clearing (× freight type = 8); intercity does not, because it is a + * purely domestic Ethiopian movement that crosses no border and therefore has + * no customs leg at all (× freight type = 2). + * + * Contracts store DOMESTIC for intercity movements; the template layer labels + * those INTERCITY to match the commercial vocabulary used on the printed + * documents. + * + * The `_CUSTOMS` variant is issued when the contract has customs clearing + * enabled (the Service Provider clears in Djibouti/Ethiopia on the Client's + * behalf); `_NO_CUSTOMS` is the transport-only paper, where the Client handles + * its own declarations. */ export const CONTRACT_TEMPLATE_CODES = [ - "IMPORT_BULK", - "EXPORT_BULK", + "IMPORT_BULK_CUSTOMS", + "IMPORT_BULK_NO_CUSTOMS", + "EXPORT_BULK_CUSTOMS", + "EXPORT_BULK_NO_CUSTOMS", "INTERCITY_BULK", - "IMPORT_CONTAINER", - "EXPORT_CONTAINER", + "IMPORT_CONTAINER_CUSTOMS", + "IMPORT_CONTAINER_NO_CUSTOMS", + "EXPORT_CONTAINER_CUSTOMS", + "EXPORT_CONTAINER_NO_CUSTOMS", "INTERCITY_CONTAINER", ] as const; @@ -33,10 +46,19 @@ export interface ContractTemplateArticle { order: number; } -/** Map a contract's stored direction/freight pair onto a template code. */ +/** + * Map a contract's stored direction/freight/customs triple onto a template + * code. `customsClearingEnabled` is treated as false when absent so an older + * contract row with a null flag still resolves to a real template rather than + * falling through to the generic layout. + * + * Intercity is domestic and has no customs leg, so it resolves to a single + * unsuffixed code regardless of the flag. + */ export function contractTemplateCodeFor( tradeDirection?: string | null, freightType?: string | null, + customsClearingEnabled?: boolean | null, ): ContractTemplateCode { const direction = tradeDirection === "IMPORT" @@ -46,7 +68,11 @@ export function contractTemplateCodeFor( : "INTERCITY"; const freight = (freightType ?? "").toUpperCase().includes("BULK") ? "BULK" : "CONTAINER"; - return `${direction}_${freight}` as ContractTemplateCode; + if (direction === "INTERCITY") { + return `INTERCITY_${freight}` as ContractTemplateCode; + } + const customs = customsClearingEnabled ? "CUSTOMS" : "NO_CUSTOMS"; + return `${direction}_${freight}_${customs}` as ContractTemplateCode; } @Entity({ schema: "freight", name: "contract_templates" }) diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index 1a4de8245..0a073c9f8 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -423,6 +423,7 @@ export class ContractTransitionService { const active = await this.contractTemplates.findActiveForContract( contract.tradeDirection, contract.freightType, + contract.customsClearingEnabled, ); if (!active) return null; return { diff --git a/apps/edr-freight-api/src/seed/data/contract-template-defaults.ts b/apps/edr-freight-api/src/seed/data/contract-template-defaults.ts index 01442db07..6255c90fc 100644 --- a/apps/edr-freight-api/src/seed/data/contract-template-defaults.ts +++ b/apps/edr-freight-api/src/seed/data/contract-template-defaults.ts @@ -4,7 +4,7 @@ import type { } from "../../modules/contract-templates/entities/contract-template.entity"; /** - * Default article packs for the six contract templates, transcribed from the + * Default article packs for the ten contract templates, transcribed from the * signed EDR contract documents (test/contrat_docs). Article bodies use the * dynamic-article text format: one clause per line, "- " prefix for bullets * nested under the previous clause, single-line body = plain paragraph. @@ -20,6 +20,13 @@ export interface ContractTemplateSeed { articles: Array>; } +/** + * A base pack keyed by direction/freight only. Each one is transcribed from a + * signed EDR contract and is split at the bottom of this file into the + * `_CUSTOMS` / `_NO_CUSTOMS` pair the template table actually stores. + */ +type ContractTemplateBase = Omit; + const a = (id: string, title: string, body: string): Omit => ({ id, title, @@ -28,8 +35,7 @@ const a = (id: string, title: string, body: string): Omit> = [ + a( + "customs-clearing", + "Customs Clearing Services", + `The Service Provider shall carry out customs clearing on behalf of the Client for the cargo covered by this Agreement, including declaration, lodgement, and follow-up at the customs stations of Djibouti and Ethiopia as applicable to the agreed corridor. +The Service Provider shall act only within the authority granted by the Client and shall not amend a declaration without the Client's written instruction. +Customs duties, taxes, and any government charges assessed on the cargo remain payable by the Client and are not included in the freight price; the Service Provider shall settle them on the Client's behalf only where the Client has placed the corresponding funds in advance. +The Service Provider shall hand over all customs documents obtained in the course of clearing to the Client upon completion of each shipment.`, + ), + a( + "customs-client-duties", + "Client Obligations for Customs Clearing", + `Grant the Service Provider a duly signed and stamped power of attorney authorising it to act as the Client's customs agent for the duration of this Agreement. +Submit every document required for declaration (commercial invoice, packing list, bill of lading or airway bill, permits, certificates of origin, and any authority-specific licence) within one (1) calendar day of the Service Provider's request. +Warrant that the declared description, quantity, value, and tariff classification of the cargo are complete and accurate. +Bear any penalty, demurrage, storage, or re-inspection cost arising from incorrect, incomplete, or late Client-supplied information or documentation. +Settle assessed duties and taxes within the period notified by the Service Provider, failing which the Service Provider may suspend clearing and the cargo shall remain at the Client's risk and cost.`, + ), +]; + +/** Build the stored `_CUSTOMS` / `_NO_CUSTOMS` pair for one base pack. */ +function splitByCustoms( + base: ContractTemplateBase, + codeStem: string, +): ContractTemplateSeed[] { + return [ + { + ...base, + code: `${codeStem}_CUSTOMS` as ContractTemplateCode, + name: `${base.name} (with customs clearing)`, + description: `${base.description} Customs clearing is performed by the Service Provider.`, + articles: [...base.articles, ...CUSTOMS_ARTICLES], + }, + { + ...base, + code: `${codeStem}_NO_CUSTOMS` as ContractTemplateCode, + name: `${base.name} (without customs clearing)`, + description: `${base.description} Customs clearing is handled by the Client.`, + articles: [...base.articles], + }, + ]; +} + +/** + * Ten templates: import and export each split by customs clearing, intercity + * not split at all — it is a domestic Ethiopian movement that crosses no + * border, so there is no customs leg to contract for. + */ +export const CONTRACT_TEMPLATE_DEFAULTS: ContractTemplateSeed[] = [ + ...splitByCustoms(IMPORT_BULK_BASE, "IMPORT_BULK"), + ...splitByCustoms(EXPORT_BULK_BASE, "EXPORT_BULK"), + { ...INTERCITY_BULK_BASE, code: "INTERCITY_BULK" }, + ...splitByCustoms(IMPORT_CONTAINER_BASE, "IMPORT_CONTAINER"), + ...splitByCustoms(EXPORT_CONTAINER_BASE, "EXPORT_CONTAINER"), + { ...INTERCITY_CONTAINER_BASE, code: "INTERCITY_CONTAINER" }, ]; diff --git a/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplatesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplatesPage.tsx index 4a3654aa3..30b31f54d 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplatesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplatesPage.tsx @@ -43,8 +43,19 @@ function templateDirection(code: ContractTemplate["code"]): string { return code.split("_")[0]; } +// Codes are DIRECTION_FREIGHT_{CUSTOMS,NO_CUSTOMS}, so the freight segment is +// the second one — never the suffix. function isBulk(code: ContractTemplate["code"]): boolean { - return code.endsWith("_BULK"); + return code.split("_")[1] === "BULK"; +} + +// Intercity is domestic and crosses no border, so it has no customs variant at +// all — hence null rather than false, which would wrongly read as a deliberate +// "client clears its own customs" choice. +function customsVariant(code: ContractTemplate["code"]): boolean | null { + if (code.endsWith("_NO_CUSTOMS")) return false; + if (code.endsWith("_CUSTOMS")) return true; + return null; } function formatUpdated(value: string): string { @@ -66,12 +77,12 @@ export default function ContractTemplatesPage() { {isLoading - ? Array.from({ length: 6 }, (_, i) => ) + ? Array.from({ length: 10 }, (_, i) => ) : (templates ?? []).map((template) => ( - {!template.isActive && ( - - - Inactive - - - )} + + {customs !== null && ( + + + {customs ? "With customs" : "No customs"} + + + )} + {!template.isActive && ( + + + Inactive + + + )} + {/* Name + description */} diff --git a/apps/edr-freight-web/backoffice/src/services/contract-templates.service.ts b/apps/edr-freight-web/backoffice/src/services/contract-templates.service.ts index 75202507b..267ee113a 100644 --- a/apps/edr-freight-web/backoffice/src/services/contract-templates.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/contract-templates.service.ts @@ -11,12 +11,18 @@ export interface ContractTemplateArticle { export interface ContractTemplate { id: string; + // Import/export split by customs clearing; intercity is domestic, crosses no + // border, and so has a single template. code: - | "IMPORT_BULK" - | "EXPORT_BULK" + | "IMPORT_BULK_CUSTOMS" + | "IMPORT_BULK_NO_CUSTOMS" + | "EXPORT_BULK_CUSTOMS" + | "EXPORT_BULK_NO_CUSTOMS" | "INTERCITY_BULK" - | "IMPORT_CONTAINER" - | "EXPORT_CONTAINER" + | "IMPORT_CONTAINER_CUSTOMS" + | "IMPORT_CONTAINER_NO_CUSTOMS" + | "EXPORT_CONTAINER_CUSTOMS" + | "EXPORT_CONTAINER_NO_CUSTOMS" | "INTERCITY_CONTAINER"; name: string; description?: string | null;