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/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/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 8072b67d5..d2cc909f2 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); @@ -423,14 +423,7 @@ 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 {