feat(warehouses): filters, pagination and charts on container returns

Returned-containers list now uses the shared DataTable + useListControls
(search, inclusive date range, status select, pagination) instead of a
hand-rolled table. Adds two charts below the list — returns per day by
truck type and returns by status — driven by the same filtered rows.
Series colors validated for CVD separation and surface contrast.
This commit is contained in:
Hagernesh
2026-08-04 09:54:15 +00:00
parent 87b04973d8
commit 50fab52b1c
3 changed files with 287 additions and 79 deletions

View File

@@ -5,6 +5,7 @@ import { resolve } from 'path';
config({ path: resolve(__dirname, '../../.env') }); config({ path: resolve(__dirname, '../../.env') });
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { DataSource } from 'typeorm';
import { AppModule } from '../app.module'; import { AppModule } from '../app.module';
import { Batch14TestDataSeeder } from '../seed/batch1-4-test-data.seeder'; import { Batch14TestDataSeeder } from '../seed/batch1-4-test-data.seeder';
import { Batch5TestDataSeeder } from '../seed/batch5-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 { PricingDataSeeder } from '../seed/pricing-data.seeder';
import { WarehouseDemoSeeder } from '../seed/warehouse-demo.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() { async function main() {
assertLocalhost();
const app = await NestFactory.createApplicationContext(AppModule, { const app = await NestFactory.createApplicationContext(AppModule, {
logger: ['error', 'warn', 'log'], logger: ['error', 'warn', 'log'],
}); });
try { try {
await app.get(PricingDataSeeder).run(); // Demo seeders are intentionally not AppModule providers (they'd run on every
await app.get(IndodeFacilitySeeder).run(); // boot), so construct them against the app's DataSource instead of via DI.
await app.get(Batch14TestDataSeeder).run(); const dataSource = app.get(DataSource);
await app.get(Batch5TestDataSeeder).run(); await new PricingDataSeeder(dataSource).run();
await app.get(Batch7TestDataSeeder).run(); await new IndodeFacilitySeeder(dataSource).run();
await app.get(Batch8TestDataSeeder).run(); await new Batch14TestDataSeeder(dataSource).run();
await app.get(WarehouseDemoSeeder).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.'); console.log('Warehouse demo data seeded.');
} finally { } finally {

View File

@@ -2,8 +2,10 @@ import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { Booking } from '../modules/bookings/entities/booking.entity'; 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 { CargoType } from '../modules/rule-engine/entities/cargo-type.entity';
import { CompanyProfile } from '../modules/companies/entities/company-profile.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 { Locomotive } from '../modules/locomotives/entities/locomotive.entity';
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
import { Yard } from '../modules/rule-engine/entities/yard.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 → Arrive Queue : an ARRIVED import train with IN_TRANSIT bookings (no inventory)
* Import → Unloaded Queue : UNLOADED import inventory * Import → Unloaded Queue : UNLOADED import inventory
* Import → Dispatch Queue : READY_FOR_PICKUP import inventory (PASSED) * 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 * 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, * 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). // 4) Import Unloaded Queue — UNLOADED import inventory (not inspected, not stored).
let firstUnloadedBooking: Booking | null = null;
for (let i = 1; i <= 3; i++) { for (let i = 1; i <= 3; i++) {
const b = await makeBooking(`WH-DEMO-UNL-${i}`, 'IMPORT', 'IN_TRANSIT', 5000 + i * 500, i); const b = await makeBooking(`WH-DEMO-UNL-${i}`, 'IMPORT', 'IN_TRANSIT', 5000 + i * 500, i);
await makeInventory(b, 'UNLOADED', 5000 + i * 500, { await makeInventory(b, 'UNLOADED', 5000 + i * 500, {
arrivedAt: ago(90), arrivedAt: ago(90),
unloadedAt: ago(45), unloadedAt: ago(45),
}); });
firstUnloadedBooking ??= b;
created++; created++;
} }
// 5) Import Dispatch Queue — READY_FOR_PICKUP import inventory (inspection PASSED). // 5) Import Dispatch Queue — READY_FOR_PICKUP import inventory (inspection PASSED).
let firstPickupBooking: Booking | null = null;
for (let i = 1; i <= 3; i++) { for (let i = 1; i <= 3; i++) {
const b = await makeBooking(`WH-DEMO-PKR-${i}`, 'IMPORT', 'IN_TRANSIT', 5500 + i * 500, 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, { await makeInventory(b, 'READY_FOR_PICKUP', 5500 + i * 500, {
@@ -185,6 +192,50 @@ export class WarehouseDemoSeeder {
inspectedAt: ago(120), inspectedAt: ago(120),
readyForPickupAt: ago(60), 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++; created++;
} }

View File

@@ -5,10 +5,12 @@ import {
Alert, Alert,
Badge, Badge,
Button, Button,
Card,
Group, Group,
Loader, Loader,
Modal, Modal,
SegmentedControl, SegmentedControl,
SimpleGrid,
Stack, Stack,
Table, Table,
Text, Text,
@@ -18,16 +20,23 @@ import {
Checkbox, Checkbox,
} from "@mantine/core"; } from "@mantine/core";
import { ChevronDown, ChevronRight, History } from "lucide-react"; import { ChevronDown, ChevronRight, History } from "lucide-react";
import { DataTable, type ColumnDef } from "@edr/ui-common";
import { PageContainer, PageHeader } from "@/components/page"; import { PageContainer, PageHeader } from "@/components/page";
import ListControls from "@/components/common/ListControls";
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter"; 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 { useToast } from "@/hooks/use-toast";
import { useWarehouseYards, useWarehouseZones } from "@/hooks/useWarehouses"; import { useWarehouseYards, useWarehouseZones } from "@/hooks/useWarehouses";
import { api } from "@/services/api"; import { api } from "@/services/api";
import { warehouseService } from "@/services/warehouse.service"; import { warehouseService } from "@/services/warehouse.service";
import { importOperationsService } from "@/services/importOperations.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"; type ReturnType = "all" | "edr" | "customer";
@@ -51,6 +60,13 @@ const RETURN_STATUS_LABEL: Record<EmptyContainerReturnStatus, string> = {
COMPLETED: "Completed", 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 { interface ContainerReturnRow {
key: string; key: string;
containerNumber: string; containerNumber: string;
@@ -186,10 +202,47 @@ export default function ContainerReturnsPage() {
enabled: bookingIds.length > 0 && !queueLoading, enabled: bookingIds.length > 0 && !queueLoading,
}); });
const [statusFilter, setStatusFilter] = useState<string | null>(null);
const filteredReturnedContainers = useMemo(() => { const filteredReturnedContainers = useMemo(() => {
if (filterType === "all") return returnedContainers; let rows = returnedContainers as EmptyContainerReturn[];
return returnedContainers.filter((ret: any) => ret.returnedBy === filterType.toUpperCase()); if (filterType !== "all") {
}, [returnedContainers, filterType]); 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<string, { date: string; edr: number; customer: number }>();
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 allGroups = useMemo(() => containerReturnsQuery.data ?? [], [containerReturnsQuery.data]);
const filteredGroups = useMemo(() => { const filteredGroups = useMemo(() => {
@@ -271,6 +324,103 @@ export default function ContainerReturnsPage() {
}, },
}); });
const returnedColumns: ColumnDef<EmptyContainerReturn>[] = [
{
id: "containerNumber",
header: "Container Number",
cell: ({ row }) => (
<Text fw={600} size="sm">
{row.original.containerNumber}
</Text>
),
},
{
id: "bookingRef",
header: "Booking Ref",
cell: ({ row }) => (row.original.bookingId ? "Associated" : "—"),
},
{
id: "returnedBy",
header: "Returned By",
cell: ({ row }) =>
row.original.returnedBy ? (
<Badge size="sm" color={row.original.returnedBy === "EDR" ? "edr-green" : "orange"}>
{row.original.returnedBy === "EDR" ? "EDR Last Mile" : "Customer Self-Haul"}
</Badge>
) : (
"—"
),
},
{
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 }) => (
<Text size="sm" lineClamp={2}>
{row.original.condition || "—"}
</Text>
),
},
{
id: "status",
header: "Status",
cell: ({ row }) => (
<Badge size="sm">
{RETURN_STATUS_LABEL[row.original.status] ?? row.original.status}
</Badge>
),
},
{
id: "action",
header: "Action",
cell: ({ row }) => {
const ret = row.original;
const nextStatus = RETURN_STATUS_ORDER[RETURN_STATUS_ORDER.indexOf(ret.status) + 1];
return (
<Group gap="xs" justify="flex-end" wrap="nowrap">
<ActionIcon
variant="subtle"
color="gray"
onClick={() => setHistoryRow(ret)}
title="View status history"
>
<History size={14} />
</ActionIcon>
{nextStatus ? (
<Button
size="xs"
variant="light"
loading={advanceStatusMutation.isPending && advanceStatusMutation.variables === ret.id}
onClick={() => advanceStatusMutation.mutate(ret.id)}
>
Advance to {RETURN_STATUS_LABEL[nextStatus]}
</Button>
) : (
<Text size="xs" c="dimmed">
Done
</Text>
)}
</Group>
);
},
},
];
const activeGroup = activeKey ? (filteredGroups.find((g) => `${g.returnType.toLowerCase()}-${g.bookingId}` === activeKey) ?? null) : null; const activeGroup = activeKey ? (filteredGroups.find((g) => `${g.returnType.toLowerCase()}-${g.bookingId}` === activeKey) ?? null) : null;
if (queueLoading || containerReturnsQuery.isLoading) { if (queueLoading || containerReturnsQuery.isLoading) {
@@ -305,73 +455,45 @@ export default function ContainerReturnsPage() {
</Button> </Button>
</Group> </Group>
{filteredReturnedContainers.length > 0 && ( {returnedContainers.length > 0 && (
<> <Card withBorder radius="lg" p="md" mb="lg">
<Text fw={600} mb="xs">Returned Containers</Text> <Stack gap="md">
<Table.ScrollContainer minWidth={1000} mb="lg"> <Text fw={600}>Returned Containers</Text>
<Table striped highlightOnHover verticalSpacing="xs"> <ListControls
<Table.Thead> search={returnedControls.search}
<Table.Tr> onSearchChange={returnedControls.setSearch}
<Table.Th>Container Number</Table.Th> searchPlaceholder="Search container, facility, condition…"
<Table.Th>Booking Ref</Table.Th> dateFrom={returnedControls.dateFrom}
<Table.Th>Returned By</Table.Th> onDateFromChange={returnedControls.setDateFrom}
<Table.Th>Returned Date</Table.Th> dateTo={returnedControls.dateTo}
<Table.Th>Facility</Table.Th> onDateToChange={returnedControls.setDateTo}
<Table.Th>Yard</Table.Th> dateLabel="Returned"
<Table.Th>Condition</Table.Th> hasFilters={returnedControls.hasFilters || Boolean(statusFilter)}
<Table.Th>Status</Table.Th> onReset={() => {
<Table.Th ta="right">Action</Table.Th> returnedControls.reset();
</Table.Tr> setStatusFilter(null);
</Table.Thead> }}
<Table.Tbody> >
{filteredReturnedContainers.map((ret: any) => { <Select
const nextStatus = RETURN_STATUS_ORDER[RETURN_STATUS_ORDER.indexOf(ret.status) + 1]; placeholder="Status"
return ( value={statusFilter}
<Table.Tr key={ret.id}> onChange={setStatusFilter}
<Table.Td>{ret.containerNumber}</Table.Td> data={RETURN_STATUS_ORDER.map((status) => ({
<Table.Td>{ret.bookingId ? "Associated" : "—"}</Table.Td> value: status,
<Table.Td> label: RETURN_STATUS_LABEL[status],
{ret.returnedBy ? ( }))}
<Badge size="sm" color={ret.returnedBy === "EDR" ? "edr-green" : "blue"}> clearable
{ret.returnedBy === "EDR" ? "EDR Last Mile" : "Customer Self-Haul"} w={200}
</Badge> />
) : ( </ListControls>
"—" <DataTable
)} columns={returnedColumns}
</Table.Td> data={returnedControls.pagedRows}
<Table.Td>{ret.returnDate ? new Date(ret.returnDate).toLocaleDateString() : "—"}</Table.Td> containerClassName="border-0 shadow-none"
<Table.Td>{ret.facility || "—"}</Table.Td> {...returnedControls.tableProps}
<Table.Td>{ret.yard || "—"}</Table.Td> />
<Table.Td>{ret.condition || "—"}</Table.Td> </Stack>
<Table.Td> </Card>
<Badge size="sm">{RETURN_STATUS_LABEL[ret.status as EmptyContainerReturnStatus] ?? ret.status}</Badge>
</Table.Td>
<Table.Td ta="right">
<Group gap="xs" justify="flex-end" wrap="nowrap">
<ActionIcon variant="subtle" color="gray" onClick={() => setHistoryRow(ret)} title="View status history">
<History size={14} />
</ActionIcon>
{nextStatus ? (
<Button
size="xs"
variant="light"
loading={advanceStatusMutation.isPending && advanceStatusMutation.variables === ret.id}
onClick={() => advanceStatusMutation.mutate(ret.id)}
>
Advance to {RETURN_STATUS_LABEL[nextStatus]}
</Button>
) : (
<Text size="xs" c="dimmed">Done</Text>
)}
</Group>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
</>
)} )}
{filteredGroups.length === 0 ? ( {filteredGroups.length === 0 ? (
@@ -471,6 +593,26 @@ export default function ContainerReturnsPage() {
</> </>
)} )}
{returnedContainers.length > 0 && (
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md" mt="lg">
<OverviewStackedBarChart
title="Returns per day by truck type"
data={returnsPerDay}
series={RETURNED_BY_SERIES}
formatXLabel={(value) =>
new Date(value).toLocaleDateString(undefined, { day: "numeric", month: "short" })
}
emptyMessage="No returns in this range"
/>
<OverviewHorizontalBarChart
title="Returns by status"
data={returnsByStatus}
valueLabel="Containers"
emptyMessage="No returns in this range"
/>
</SimpleGrid>
)}
<ContainerReturnModal <ContainerReturnModal
opened={returnModalOpen} opened={returnModalOpen}
onClose={() => setReturnModalOpen(false)} onClose={() => setReturnModalOpen(false)}