diff --git a/apps/edr-freight-api/src/scripts/seed-warehouse-demo.ts b/apps/edr-freight-api/src/scripts/seed-warehouse-demo.ts index 24118d882..593abb305 100644 --- a/apps/edr-freight-api/src/scripts/seed-warehouse-demo.ts +++ b/apps/edr-freight-api/src/scripts/seed-warehouse-demo.ts @@ -5,6 +5,7 @@ import { resolve } from 'path'; config({ path: resolve(__dirname, '../../.env') }); import { NestFactory } from '@nestjs/core'; +import { DataSource } from 'typeorm'; import { AppModule } from '../app.module'; import { Batch14TestDataSeeder } from '../seed/batch1-4-test-data.seeder'; import { Batch5TestDataSeeder } from '../seed/batch5-test-data.seeder'; @@ -14,19 +15,33 @@ import { IndodeFacilitySeeder } from '../seed/indode-facility.seeder'; import { PricingDataSeeder } from '../seed/pricing-data.seeder'; import { WarehouseDemoSeeder } from '../seed/warehouse-demo.seeder'; +/** Demo data only — refuse to run against anything but a local dev database. */ +function assertLocalhost() { + const host = process.env.DB_HOST ?? 'localhost'; + if (host !== 'localhost' && host !== '127.0.0.1') { + console.error(`Refusing to seed demo data: DB_HOST is "${host}", not localhost.`); + process.exit(1); + } +} + async function main() { + assertLocalhost(); + const app = await NestFactory.createApplicationContext(AppModule, { logger: ['error', 'warn', 'log'], }); try { - await app.get(PricingDataSeeder).run(); - await app.get(IndodeFacilitySeeder).run(); - await app.get(Batch14TestDataSeeder).run(); - await app.get(Batch5TestDataSeeder).run(); - await app.get(Batch7TestDataSeeder).run(); - await app.get(Batch8TestDataSeeder).run(); - await app.get(WarehouseDemoSeeder).run(); + // Demo seeders are intentionally not AppModule providers (they'd run on every + // boot), so construct them against the app's DataSource instead of via DI. + const dataSource = app.get(DataSource); + await new PricingDataSeeder(dataSource).run(); + await new IndodeFacilitySeeder(dataSource).run(); + await new Batch14TestDataSeeder(dataSource).run(); + await new Batch5TestDataSeeder(dataSource).run(); + await new Batch7TestDataSeeder(dataSource).run(); + await new Batch8TestDataSeeder(dataSource).run(); + await new WarehouseDemoSeeder(dataSource).run(); console.log('Warehouse demo data seeded.'); } finally { diff --git a/apps/edr-freight-api/src/seed/warehouse-demo.seeder.ts b/apps/edr-freight-api/src/seed/warehouse-demo.seeder.ts index d8e585a35..d669e0335 100644 --- a/apps/edr-freight-api/src/seed/warehouse-demo.seeder.ts +++ b/apps/edr-freight-api/src/seed/warehouse-demo.seeder.ts @@ -2,8 +2,10 @@ import { Injectable, Logger } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { Booking } from '../modules/bookings/entities/booking.entity'; +import { CustomerTruckAssignment } from '../modules/bookings/entities/customer-truck-assignment.entity'; import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity'; import { CompanyProfile } from '../modules/companies/entities/company-profile.entity'; +import { EmptyContainerReturn } from '../modules/import-operations/entities/empty-container-return.entity'; import { Locomotive } from '../modules/locomotives/entities/locomotive.entity'; import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; import { Yard } from '../modules/rule-engine/entities/yard.entity'; @@ -23,6 +25,8 @@ import { WarehouseZone } from '../modules/warehouses/entities/warehouse-zone.ent * Import → Arrive Queue : an ARRIVED import train with IN_TRANSIT bookings (no inventory) * Import → Unloaded Queue : UNLOADED import inventory * Import → Dispatch Queue : READY_FOR_PICKUP import inventory (PASSED) + * Import → Import Trucks : a customer self-haul truck assigned to an unloaded booking + * Import → Container Returns : empty container returns at two different statuses * * Idempotent: guarded on a sentinel booking reference. Uses dedicated WH-DEMO-* references so it * never collides with other seeders. To repopulate after items are walked through their lifecycle, @@ -166,16 +170,19 @@ export class WarehouseDemoSeeder { } // 4) Import Unloaded Queue — UNLOADED import inventory (not inspected, not stored). + let firstUnloadedBooking: Booking | null = null; for (let i = 1; i <= 3; i++) { const b = await makeBooking(`WH-DEMO-UNL-${i}`, 'IMPORT', 'IN_TRANSIT', 5000 + i * 500, i); await makeInventory(b, 'UNLOADED', 5000 + i * 500, { arrivedAt: ago(90), unloadedAt: ago(45), }); + firstUnloadedBooking ??= b; created++; } // 5) Import Dispatch Queue — READY_FOR_PICKUP import inventory (inspection PASSED). + let firstPickupBooking: Booking | null = null; for (let i = 1; i <= 3; i++) { const b = await makeBooking(`WH-DEMO-PKR-${i}`, 'IMPORT', 'IN_TRANSIT', 5500 + i * 500, i); await makeInventory(b, 'READY_FOR_PICKUP', 5500 + i * 500, { @@ -185,6 +192,50 @@ export class WarehouseDemoSeeder { inspectedAt: ago(120), readyForPickupAt: ago(60), }); + firstPickupBooking ??= b; + created++; + } + + // 6) Import Trucks / booking Trucks tab — a customer self-haul truck on the unloaded booking. + if (firstUnloadedBooking) { + await this.dataSource.getRepository(CustomerTruckAssignment).save( + this.dataSource.getRepository(CustomerTruckAssignment).create({ + bookingId: firstUnloadedBooking.id, + plateNumber: 'WH-DEMO-3210', + driverName: 'Demo Driver', + truckType: 'FLATBED', + assignedAt: ago(80), + arrivedAt: ago(50), + }), + ); + created++; + } + + // 7) Container Returns — two empty returns at different stages of the return workflow. + if (firstPickupBooking) { + const returnRepo = this.dataSource.getRepository(EmptyContainerReturn); + await returnRepo.save( + returnRepo.create({ + containerNumber: 'WHDU1234561', + bookingId: firstPickupBooking.id, + returnDate: ago(20), + facility: 'Indode', + status: 'RETURNED', + returnedBy: 'CUSTOMER', + statusHistory: [], + }), + ); + await returnRepo.save( + returnRepo.create({ + containerNumber: 'WHDU1234562', + bookingId: firstPickupBooking.id, + returnDate: ago(90), + facility: 'Indode', + status: 'DOCUMENTATION_CLEARED', + returnedBy: 'CUSTOMER', + statusHistory: [], + }), + ); created++; } 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 feb4c4815..6f329c8d7 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/ContainerReturnsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/ContainerReturnsPage.tsx @@ -5,10 +5,12 @@ import { Alert, Badge, Button, + Card, Group, Loader, Modal, SegmentedControl, + SimpleGrid, Stack, Table, Text, @@ -18,16 +20,23 @@ import { Checkbox, } from "@mantine/core"; import { ChevronDown, ChevronRight, History } from "lucide-react"; +import { DataTable, type ColumnDef } from "@edr/ui-common"; import { PageContainer, PageHeader } from "@/components/page"; +import ListControls from "@/components/common/ListControls"; import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter"; -import { useListControls } from "@/hooks/useListControls"; +import { OverviewHorizontalBarChart } from "@/components/overview/OverviewHorizontalBarChart"; +import { OverviewStackedBarChart } from "@/components/overview/OverviewStackedBarChart"; +import { useListControls, toDayString } from "@/hooks/useListControls"; import { useToast } from "@/hooks/use-toast"; 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"; +import type { + EmptyContainerReturn, + EmptyContainerReturnStatus, +} from "@/types/importOperations"; type ReturnType = "all" | "edr" | "customer"; @@ -51,6 +60,13 @@ const RETURN_STATUS_LABEL: Record = { COMPLETED: "Completed", }; +// Fixed series colors (colors follow the entity, never the rank) — pair +// validated for CVD separation + surface contrast. +const RETURNED_BY_SERIES = [ + { key: "edr", label: "EDR Last Mile", color: "#0d9488" }, + { key: "customer", label: "Customer Self-Haul", color: "#b45309" }, +]; + interface ContainerReturnRow { key: string; containerNumber: string; @@ -186,10 +202,47 @@ export default function ContainerReturnsPage() { enabled: bookingIds.length > 0 && !queueLoading, }); + const [statusFilter, setStatusFilter] = useState(null); + const filteredReturnedContainers = useMemo(() => { - if (filterType === "all") return returnedContainers; - return returnedContainers.filter((ret: any) => ret.returnedBy === filterType.toUpperCase()); - }, [returnedContainers, filterType]); + let rows = returnedContainers as EmptyContainerReturn[]; + if (filterType !== "all") { + rows = rows.filter((ret) => ret.returnedBy === filterType.toUpperCase()); + } + if (statusFilter) { + rows = rows.filter((ret) => ret.status === statusFilter); + } + return rows; + }, [returnedContainers, filterType, statusFilter]); + + const returnedControls = useListControls(filteredReturnedContainers, { + dateKey: "returnDate", + searchValue: (ret) => + `${ret.containerNumber} ${ret.facility ?? ""} ${ret.yard ?? ""} ${ret.condition ?? ""}`, + }); + + // Charts read the filtered set, so the controls above drive them too. + const returnsPerDay = useMemo(() => { + const byDay = new Map(); + for (const ret of returnedControls.filteredRows) { + const day = toDayString(ret.returnDate); + if (!day) continue; + const entry = byDay.get(day) ?? { date: day, edr: 0, customer: 0 }; + if (ret.returnedBy === "CUSTOMER") entry.customer += 1; + else entry.edr += 1; + byDay.set(day, entry); + } + return [...byDay.values()].sort((a, b) => a.date.localeCompare(b.date)); + }, [returnedControls.filteredRows]); + + const returnsByStatus = useMemo( + () => + RETURN_STATUS_ORDER.map((status) => ({ + label: RETURN_STATUS_LABEL[status], + value: returnedControls.filteredRows.filter((ret) => ret.status === status).length, + })), + [returnedControls.filteredRows], + ); const allGroups = useMemo(() => containerReturnsQuery.data ?? [], [containerReturnsQuery.data]); const filteredGroups = useMemo(() => { @@ -271,6 +324,103 @@ export default function ContainerReturnsPage() { }, }); + const returnedColumns: ColumnDef[] = [ + { + id: "containerNumber", + header: "Container Number", + cell: ({ row }) => ( + + {row.original.containerNumber} + + ), + }, + { + id: "bookingRef", + header: "Booking Ref", + cell: ({ row }) => (row.original.bookingId ? "Associated" : "—"), + }, + { + id: "returnedBy", + header: "Returned By", + cell: ({ row }) => + row.original.returnedBy ? ( + + {row.original.returnedBy === "EDR" ? "EDR Last Mile" : "Customer Self-Haul"} + + ) : ( + "—" + ), + }, + { + id: "returnDate", + header: "Returned Date", + cell: ({ row }) => + row.original.returnDate ? new Date(row.original.returnDate).toLocaleDateString() : "—", + }, + { + id: "facility", + header: "Facility", + cell: ({ row }) => row.original.facility || "—", + }, + { + id: "yard", + header: "Yard", + cell: ({ row }) => row.original.yard || "—", + }, + { + id: "condition", + header: "Condition", + cell: ({ row }) => ( + + {row.original.condition || "—"} + + ), + }, + { + id: "status", + header: "Status", + cell: ({ row }) => ( + + {RETURN_STATUS_LABEL[row.original.status] ?? row.original.status} + + ), + }, + { + id: "action", + header: "Action", + cell: ({ row }) => { + const ret = row.original; + const nextStatus = RETURN_STATUS_ORDER[RETURN_STATUS_ORDER.indexOf(ret.status) + 1]; + return ( + + setHistoryRow(ret)} + title="View status history" + > + + + {nextStatus ? ( + + ) : ( + + Done + + )} + + ); + }, + }, + ]; + const activeGroup = activeKey ? (filteredGroups.find((g) => `${g.returnType.toLowerCase()}-${g.bookingId}` === activeKey) ?? null) : null; if (queueLoading || containerReturnsQuery.isLoading) { @@ -305,73 +455,45 @@ export default function ContainerReturnsPage() { - {filteredReturnedContainers.length > 0 && ( - <> - Returned Containers - - - - - Container Number - Booking Ref - Returned By - Returned Date - Facility - Yard - Condition - Status - Action - - - - {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} - - - - setHistoryRow(ret)} title="View status history"> - - - {nextStatus ? ( - - ) : ( - Done - )} - - - - ); - })} - -
-
- + {returnedContainers.length > 0 && ( + + + Returned Containers + { + returnedControls.reset(); + setStatusFilter(null); + }} + > +