Intercity load unload with grn ,Warehouse , fleet , and allocation endpoints permission

This commit is contained in:
Hagernesh
2026-07-24 11:50:25 +00:00
parent cf8a2e928d
commit 2f124bc666
22 changed files with 827 additions and 99 deletions

View File

@@ -20,8 +20,13 @@ import { WarehouseInspectionService } from './warehouse-inspection.service';
@ApiTags('warehouse-inspection')
@ApiBearerAuth()
// Baseline read: inspection reports are opened from inventory screens too —
// either view permission grants reads; writes stack their own per route.
@Controller()
@BookingStaff(FREIGHT_PERMS.warehouseInspectionReports.view)
@BookingStaff([
FREIGHT_PERMS.warehouseInspectionReports.view,
FREIGHT_PERMS.warehouseInventory.view,
])
export class WarehouseInspectionController {
constructor(private readonly inspectionService: WarehouseInspectionService) {}

View File

@@ -5,7 +5,7 @@ import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { actorLabel } from './current-actor.util';
import { BookingStaff } from '../../common/booking-guards';
import { BookingStaff, StaffReference } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { BulkReceiveDto } from './dto/bulk-receive.dto';
import { BulkInspectDto } from './dto/bulk-inspect.dto';
@@ -456,6 +456,7 @@ export class WarehouseInventoryController {
}
@Get(':id/handover-document')
@StaffReference()
@ApiOperation({ summary: 'View import goods handover document PDF' })
async handoverDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
const { filename, buffer } = await this.inventoryService.handoverDocument(id);
@@ -466,6 +467,7 @@ export class WarehouseInventoryController {
}
@Post('bookings/:bookingId/approve-delivery')
@StaffReference()
@ApiOperation({ summary: "Approve delivery — customer records their full name (signature optional)" })
approveDeliveryForBooking(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@@ -481,12 +483,14 @@ export class WarehouseInventoryController {
}
@Get('bookings/:bookingId/handovers')
@StaffReference()
@ApiOperation({ summary: 'Handover records for a booking (per-booking or per-truck)' })
bookingHandovers(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.handoverService.list(bookingId);
}
@Post('handovers/:handoverId/sign')
@StaffReference()
@ApiOperation({ summary: 'Customer signs one handover (EDR last-mile: one signature per truck)' })
signHandover(
@Param('handoverId', ParseUUIDPipe) handoverId: string,
@@ -502,12 +506,14 @@ export class WarehouseInventoryController {
}
@Post('bookings/:bookingId/request-handover-signature')
@StaffReference()
@ApiOperation({ summary: 'Ask the customer to sign the handover (creates one if none, then notifies)' })
requestHandoverSignature(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.handoverService.requestSignature(bookingId);
}
@Get('bookings/:bookingId/grn-document')
@StaffReference()
@ApiOperation({ summary: 'View GRN PDF for a booking (customer portal)' })
async bookingGrnDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) {
const { filename, buffer } = await this.inventoryService.grnDocumentForBooking(bookingId);
@@ -518,6 +524,7 @@ export class WarehouseInventoryController {
}
@Get('bookings/:bookingId/release-document')
@StaffReference()
@ApiOperation({ summary: 'View gate-clearance / release-order PDF for a booking (customer portal)' })
async bookingReleaseDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) {
const { filename, buffer } = await this.inventoryService.releaseDocumentForBooking(bookingId);
@@ -528,6 +535,7 @@ export class WarehouseInventoryController {
}
@Get('bookings/:bookingId/handover-document')
@StaffReference()
@ApiOperation({ summary: 'View import goods handover document PDF (resolved by booking; ?handoverId= for the per-truck variant)' })
async bookingHandoverDocument(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@@ -545,18 +553,21 @@ export class WarehouseInventoryController {
}
@Get('bookings/:bookingId/container-items')
@StaffReference()
@ApiOperation({ summary: 'Per-container/bulk items of a booking with lifecycle stage + refs' })
containerItems(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.inventoryService.containerItems(bookingId);
}
@Get('bookings/:bookingId/container-weights')
@StaffReference()
@ApiOperation({ summary: "A booking's containers + VGM cargo weight (tonnes) for exit weighing" })
containerWeights(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.inventoryService.bookingContainerWeights(bookingId);
}
@Get('bookings/:bookingId/location')
@StaffReference()
@ApiOperation({ summary: "Warehouse location of a booking's inventory (customer portal)" })
bookingLocation(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.inventoryService.bookingLocation(bookingId);

View File

@@ -5,7 +5,7 @@ import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { actorLabel } from './current-actor.util';
import { BookingStaff } from '../../common/booking-guards';
import { BookingStaff, StaffReference } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { PayInvoiceDto as GatewayPayInvoiceDto } from '../billing/dto/pay-invoice.dto';
import { GenerateInvoiceDto, PayInvoiceBodyDto } from './dto/invoice.dto';
@@ -43,6 +43,7 @@ export class WarehouseInvoiceController {
}
@Get('bookings/:id/warehouse-fee-invoices')
@StaffReference()
@ApiOperation({ summary: 'List warehouse fee invoices for a booking' })
listForBooking(@Param('id', ParseUUIDPipe) id: string) {
return this.invoiceService.listForBooking(id);
@@ -70,12 +71,14 @@ export class WarehouseInvoiceController {
}
@Get('warehouse-fee-invoices/:id')
@StaffReference()
@ApiOperation({ summary: 'Get a warehouse fee invoice with items + payment history' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.invoiceService.findById(id);
}
@Get('warehouse-fee-invoices/:id/document')
@StaffReference()
@ApiOperation({ summary: 'Download sealed warehouse fee invoice PDF' })
async document(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
const { filename, buffer } = await this.invoiceService.document(id);
@@ -86,6 +89,7 @@ export class WarehouseInvoiceController {
}
@Get('warehouse-fee-invoices/:id/receipt')
@StaffReference()
@ApiOperation({ summary: 'Download sealed warehouse fee payment receipt PDF' })
async receipt(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
const { filename, buffer } = await this.invoiceService.receipt(id);
@@ -110,6 +114,7 @@ export class WarehouseInvoiceController {
}
@Post('warehouse-fee-invoices/:id/pay-online')
@StaffReference()
@ApiOperation({ summary: 'Initiate Telebirr/Waafi payment for a warehouse fee invoice' })
payOnline(@Param('id', ParseUUIDPipe) id: string, @Body() dto: GatewayPayInvoiceDto) {
return this.invoiceService.initiatePayment(id, dto);

View File

@@ -1,7 +1,7 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { BookingStaff, StaffReference } from '../../common/booking-guards';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { CreateWarehouseZoneDto } from './dto/create-warehouse-zone.dto';
import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto';
@@ -10,8 +10,8 @@ import { WarehouseZonesService } from './warehouse-zones.service';
@ApiTags('warehouse-yards')
@ApiBearerAuth()
// No class-level guard: the two reference GETs are open to any signed-in
// staff (StaffReference), every other route carries its own permission.
// No class-level guard: every route carries its own permission (reads accept
// yard-view OR inventory-view so inventory flows can populate yard pickers).
@Controller('warehouse-yards')
export class WarehouseYardsController {
constructor(
@@ -20,14 +20,14 @@ export class WarehouseYardsController {
) {}
@Get()
@StaffReference()
@BookingStaff([FREIGHT_PERMS.warehouseYards.view, FREIGHT_PERMS.warehouseInventory.view])
@ApiOperation({ summary: 'List all warehouse yards' })
findAll() {
return this.yardsService.findAll();
}
@Get(':id')
@StaffReference()
@BookingStaff([FREIGHT_PERMS.warehouseYards.view, FREIGHT_PERMS.warehouseInventory.view])
@ApiOperation({ summary: 'Get warehouse yard by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.yardsService.findById(id);

View File

@@ -8,8 +8,11 @@ import { WarehouseZonesService } from './warehouse-zones.service';
@ApiTags('warehouse-zones')
@ApiBearerAuth()
// Baseline read: zone reference data also serves inventory flows (allocation,
// receive/move pickers) — either view permission grants reads; writes stack
// their specific permission per route.
@Controller('warehouse-zones')
@BookingStaff(FREIGHT_PERMS.warehouseZones.view)
@BookingStaff([FREIGHT_PERMS.warehouseZones.view, FREIGHT_PERMS.warehouseInventory.view])
export class WarehouseZonesController {
constructor(private readonly zonesService: WarehouseZonesService) {}

View File

@@ -13,8 +13,15 @@ import { WarehousesService } from './warehouses.service';
@ApiTags('warehouses')
@ApiBearerAuth()
// Baseline read: warehouse reference data is consumed by inventory/dashboard
// flows too, so any of the three view permissions grants reads. Writes stack
// their specific create/update permission per route on top.
@Controller('warehouses')
@BookingStaff(FREIGHT_PERMS.warehouses.view)
@BookingStaff([
FREIGHT_PERMS.warehouses.view,
FREIGHT_PERMS.warehouseInventory.view,
FREIGHT_PERMS.warehouseDashboard.view,
])
export class WarehousesController {
constructor(
private readonly warehousesService: WarehousesService,

View File

@@ -0,0 +1,96 @@
import { Button, Group, TextInput } from "@mantine/core";
import { DatePickerInput } from "@mantine/dates";
import { Search, X } from "lucide-react";
import type { ReactNode } from "react";
export interface ListControlsProps {
search: string;
onSearchChange: (value: string) => void;
searchPlaceholder?: string;
/** `YYYY-MM-DD`, matching Mantine 9's date inputs. */
dateFrom: string | null;
onDateFromChange: (value: string | null) => void;
dateTo: string | null;
onDateToChange: (value: string | null) => void;
/** Label above the range, naming the date being filtered (e.g. "Arrival date"). */
dateLabel?: string;
hasFilters?: boolean;
onReset?: () => void;
/** Page-specific selects (status, warehouse…) rendered after the date range. */
children?: ReactNode;
showSearch?: boolean;
showDateRange?: boolean;
}
/**
* Search box + inclusive date range + clear, shared by every freight list so the
* controls sit in the same place and behave the same way on all of them.
* Pair with `useListControls`, which owns the state and does the filtering.
*/
const ListControls = ({
search,
onSearchChange,
searchPlaceholder = "Search…",
dateFrom,
onDateFromChange,
dateTo,
onDateToChange,
dateLabel,
hasFilters,
onReset,
children,
showSearch = true,
showDateRange = true,
}: ListControlsProps) => (
<Group gap="sm" align="flex-end" wrap="wrap">
{showSearch && (
<TextInput
placeholder={searchPlaceholder}
value={search}
onChange={(e) => onSearchChange(e.currentTarget.value)}
leftSection={<Search size={16} />}
style={{ flex: "1 1 240px", minWidth: 200 }}
/>
)}
{showDateRange && (
<>
<DatePickerInput
label={dateLabel ? `${dateLabel} from` : "From"}
placeholder="Any"
value={dateFrom}
onChange={onDateFromChange}
// Cannot start after it ends — the picker refuses the invalid range
// instead of silently returning nothing.
maxDate={dateTo ?? undefined}
clearable
w={150}
/>
<DatePickerInput
label={dateLabel ? `${dateLabel} to` : "To"}
placeholder="Any"
value={dateTo}
onChange={onDateToChange}
minDate={dateFrom ?? undefined}
clearable
w={150}
/>
</>
)}
{children}
{hasFilters && onReset && (
<Button
variant="subtle"
color="gray"
leftSection={<X size={14} />}
onClick={onReset}
>
Clear
</Button>
)}
</Group>
);
export default ListControls;

View File

@@ -18,6 +18,11 @@ import { LoadInventoryModal } from './LoadInventoryModal';
import { MoveInventoryModal } from './MoveInventoryModal';
import { ReleaseOrderModal } from './ReleaseOrderModal';
import { WarehouseInventoryTable } from './WarehouseInventoryTable';
import ListControls from '@/components/common/ListControls';
// Generic list footer — already shared by the fleet and train-scheduling lists
// despite the ruleEngine path; reused here rather than adding a second one.
import RuleEngineListFooter from '@/components/ruleEngine/RuleEngineListFooter';
import { useListControls } from '@/hooks/useListControls';
import { extractDownloadErrorMessage, extractErrorMessage } from './options';
import { openPdfBlob, saveBlob } from './pdf';
@@ -56,8 +61,17 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
api.warehouses.bulkMarkInspected.mutationOptions(),
);
const controls = useListControls(items, {
searchKeys: ['grnNumber', 'bookingReference', 'customerName', 'status', 'releaseOrderReference', 'notes'],
dateKey: 'arrivedAt',
});
const visible = controls.filteredRows;
const [selected, setSelected] = useState<Set<string>>(new Set());
const allSelected = items.length > 0 && selected.size === items.length;
// Select-all spans everything matching the current filters, not just the rows
// on screen — bulk "mark inspected" over one page of a filtered set would be a
// surprise. Counts compare against the filtered set for the same reason.
const allSelected = visible.length > 0 && selected.size === visible.length;
const someSelected = selected.size > 0 && !allSelected;
const toggleSelect = (id: string) =>
setSelected((prev) => {
@@ -66,7 +80,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
return next;
});
const toggleSelectAll = () =>
setSelected(allSelected ? new Set() : new Set(items.map((i) => i.id)));
setSelected(allSelected ? new Set() : new Set(visible.map((i) => i.id)));
const markInspected = async () => {
if (selected.size === 0) {
@@ -268,8 +282,21 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
</Button>
</Group>
<ListControls
search={controls.search}
onSearchChange={controls.setSearch}
searchPlaceholder="GRN, container, booking, customer…"
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
dateLabel="Arrived"
hasFilters={controls.hasFilters}
onReset={controls.reset}
/>
<WarehouseInventoryTable
items={items}
items={controls.pagedRows}
busyId={busyId}
onAdvance={advance}
onMove={setMoveItem}
@@ -287,6 +314,14 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
allSelected={allSelected}
someSelected={someSelected}
/>
<RuleEngineListFooter
pagination={controls.pagination}
pageCount={controls.pageCount}
totalCount={controls.totalCount}
itemLabel="items"
onPaginationChange={controls.setPagination}
/>
</Stack>
<MoveInventoryModal opened={Boolean(moveItem)} onClose={() => setMoveItem(null)} item={moveItem} />

View File

@@ -0,0 +1,164 @@
import { useEffect, useMemo, useState } from "react";
import { usePagination } from "@edr/ui-common";
/**
* Search + date-range + pagination over an already-fetched array.
*
* Client-side on purpose: the freight lists are hundreds of rows (largest table
* is ~1.1k), so filtering in the browser avoids paginating ~20 API endpoints —
* several of which sit on billing paths. If a list ever outgrows this (roughly
* 5k rows, where the per-keystroke filter starts to feel slow), move that ONE
* page to a server-side query; the component API here stays the same.
*
* Dates are `YYYY-MM-DD` strings, matching Mantine 9's date inputs. Comparing
* them lexically keeps the range on calendar days and sidesteps timezone drift
* entirely — a UTC timestamp is truncated to its date before the comparison.
*
* ponytail: linear scan per keystroke, no debounce — fine at this size; add
* a debounce (or server-side filtering) if a list gets big enough to stutter.
*/
export interface ListControlsOptions<T> {
/**
* Fields matched against the search box. Constrained to real keys of the row
* so a typo is a compile error rather than a filter that silently matches
* nothing. For nested or derived values, pass `searchValue` instead.
*/
searchKeys?: (keyof T)[];
/**
* Row's meaningful business date (arrival, invoice, dispatch…), which is what
* staff actually filter by. Falls back to `createdAt` when the row has no
* value for it, so a record is never silently invisible to a date range.
*/
dateKey?: keyof T;
/** Rows per page. */
pageSize?: number;
/** Custom search extractor when the value isn't a top-level field. */
searchValue?: (row: T) => string;
}
const readField = (row: unknown, key: string): unknown =>
row && typeof row === "object" ? (row as Record<string, unknown>)[key] : undefined;
/**
* Reduce any stored date to its `YYYY-MM-DD` calendar day. ISO strings are cut
* directly rather than parsed, so a timestamp is never shifted into the
* previous/next day by the viewer's timezone.
*/
export const toDayString = (raw: unknown): string | null => {
if (!raw) return null;
if (raw instanceof Date) {
return Number.isNaN(raw.getTime()) ? null : raw.toISOString().slice(0, 10);
}
const text = String(raw);
if (/^\d{4}-\d{2}-\d{2}/.test(text)) return text.slice(0, 10);
const parsed = new Date(text);
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString().slice(0, 10);
};
/**
* Does a stored date fall inside an inclusive `YYYY-MM-DD` range? Exported for
* lists that already own their filtering (e.g. FleetResourcePage, which folds
* server-side filters and search together) so the range semantics — inclusive
* ends, undated rows excluded — stay defined in exactly one place.
*/
export const matchesDayRange = (
raw: unknown,
dateFrom: string | null,
dateTo: string | null,
): boolean => {
if (!dateFrom && !dateTo) return true;
const day = toDayString(raw);
if (!day) return false;
if (dateFrom && day < dateFrom) return false;
if (dateTo && day > dateTo) return false;
return true;
};
export const useListControls = <T,>(rows: T[], options: ListControlsOptions<T> = {}) => {
const { searchKeys = [], dateKey, pageSize = 10, searchValue } = options;
const [search, setSearch] = useState("");
const [dateFrom, setDateFrom] = useState<string | null>(null);
const [dateTo, setDateTo] = useState<string | null>(null);
const { pagination, setPagination } = usePagination({ pageSize });
const keys = searchKeys.map(String);
const keySignature = keys.join("|");
const dateKeyStr = dateKey ? String(dateKey) : undefined;
const filteredRows = useMemo(() => {
const term = search.trim().toLowerCase();
if (!term && !dateFrom && !dateTo) return rows;
return rows.filter((row) => {
if (term) {
const haystack = searchValue
? searchValue(row)
: keys.map((key) => String(readField(row, key) ?? "")).join(" ");
if (!haystack.toLowerCase().includes(term)) return false;
}
if (dateFrom || dateTo) {
const raw = dateKeyStr
? (readField(row, dateKeyStr) ?? readField(row, "createdAt"))
: null;
if (!matchesDayRange(raw, dateFrom, dateTo)) return false;
}
return true;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rows, search, dateFrom, dateTo, keySignature, dateKeyStr, searchValue]);
// Narrowing the result set can strand the user on a page that no longer
// exists (filter to 3 rows while on page 5 → empty table). Snap back to the
// first page whenever the filters change.
useEffect(() => {
setPagination((prev) => (prev.pageIndex === 0 ? prev : { ...prev, pageIndex: 0 }));
}, [search, dateFrom, dateTo, setPagination]);
const pageCount = Math.max(1, Math.ceil(filteredRows.length / pagination.pageSize));
const pagedRows = useMemo(() => {
const start = pagination.pageIndex * pagination.pageSize;
return filteredRows.slice(start, start + pagination.pageSize);
}, [filteredRows, pagination.pageIndex, pagination.pageSize]);
const hasFilters = Boolean(search || dateFrom || dateTo);
const reset = () => {
setSearch("");
setDateFrom(null);
setDateTo(null);
};
return {
search,
setSearch,
dateFrom,
setDateFrom,
dateTo,
setDateTo,
hasFilters,
reset,
filteredRows,
pagedRows,
pageCount,
pagination,
setPagination,
totalCount: filteredRows.length,
/** Spread straight onto <DataTable /> so every list paginates identically. */
tableProps: {
pagination: {
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: filteredRows.length,
},
tableOptions: {
manualPagination: true as const,
pageCount,
state: { pagination },
onPaginationChange: setPagination,
},
},
};
};

View File

@@ -18,6 +18,11 @@ import {
} from "@mantine/core";
import { Plus, AlertTriangle } from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import ListControls from "@/components/common/ListControls";
// Generic list footer — already shared by the fleet and train-scheduling lists
// despite the ruleEngine path.
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { useListControls } from "@/hooks/useListControls";
import { useToast } from "@/hooks/use-toast";
import {
complianceService,
@@ -86,6 +91,11 @@ export default function CompliancePage() {
},
});
const controls = useListControls(records as ComplianceRecord[], {
searchKeys: ["type", "status", "documentNumber"],
dateKey: "expiryDate",
});
const createMutation = useMutation({
mutationFn: async (data: typeof formData) => {
const res = await complianceService.create({
@@ -210,6 +220,18 @@ export default function CompliancePage() {
Compliance Records
</Title>
<Card withBorder>
<ListControls
search={controls.search}
onSearchChange={controls.setSearch}
searchPlaceholder="Search type, status, document no…"
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
dateLabel="Expiry"
hasFilters={controls.hasFilters}
onReset={controls.reset}
/>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
@@ -239,7 +261,7 @@ export default function CompliancePage() {
</Table.Td>
</Table.Tr>
) : null}
{(records as ComplianceRecord[]).map((record) => (
{controls.pagedRows.map((record) => (
<Table.Tr key={record.id}>
<Table.Td>{vehicleLabel(record)}</Table.Td>
<Table.Td>
@@ -259,6 +281,13 @@ export default function CompliancePage() {
))}
</Table.Tbody>
</Table>
<RuleEngineListFooter
pagination={controls.pagination}
pageCount={controls.pageCount}
totalCount={controls.totalCount}
itemLabel="records"
onPaginationChange={controls.setPagination}
/>
</Card>
{/* Modal */}

View File

@@ -1,5 +1,6 @@
import type { ColumnDef } from "@edr/ui-common";
import { Box, Button, Card, Container, Group, Modal, Select, Stack, Text, Title } from "@mantine/core";
import { DatePickerInput } from "@mantine/dates";
import { useMutation, useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
@@ -15,6 +16,7 @@ import FleetFormDialog from "@/components/fleet/FleetFormDialog";
import FleetHistoryModal from "@/components/fleet/FleetHistoryModal";
import FleetRecordActions from "@/components/fleet/FleetRecordActions";
import FleetToolbar from "@/components/fleet/FleetToolbar";
import { matchesDayRange } from "@/hooks/useListControls";
import WagonMovementHistoryModal from "@/components/fleet/WagonMovementHistoryModal";
import WagonYardWorkspaceModal from "@/components/wagons/WagonYardWorkspaceModal";
import WagonTransferRequestsModal from "@/components/wagons/WagonTransferRequestsModal";
@@ -47,6 +49,10 @@ const FleetResourcePage = () => {
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("ALL");
// Registration date range. Server-side list filters (status/yard/train) are
// applied by the API; this narrows what comes back, alongside search.
const [dateFrom, setDateFrom] = useState<string | null>(null);
const [dateTo, setDateTo] = useState<string | null>(null);
const [listFilterValues, setListFilterValues] = useState<Record<string, string>>({});
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<FleetRecord | null>(null);
@@ -125,7 +131,7 @@ const FleetResourcePage = () => {
useEffect(() => {
setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize }));
}, [search, listFilterValues, setPagination]);
}, [search, listFilterValues, dateFrom, dateTo, setPagination]);
const hasStatusColumn = Boolean(config?.columns.some((col) => col.accessorKey === "status"));
const usesServerListFilters = Boolean(config?.listFilters?.length);
@@ -255,10 +261,13 @@ const FleetResourcePage = () => {
const filteredRows = useMemo(() => {
if (!config) return allRows;
if (usesServerListFilters) return allRows;
const term = search.trim().toLowerCase();
return allRows.filter((row) => {
const record = row as unknown as Record<string, unknown>;
// The date range applies even when the API already filtered the list —
// it is not one of the server-side filters.
if (!matchesDayRange(record.createdAt, dateFrom, dateTo)) return false;
if (usesServerListFilters) return true;
if (statusFilter !== "ALL" && String(record.status ?? "") !== statusFilter) {
return false;
}
@@ -269,7 +278,7 @@ const FleetResourcePage = () => {
.includes(term),
);
});
}, [allRows, search, statusFilter, config, usesServerListFilters]);
}, [allRows, search, statusFilter, config, usesServerListFilters, dateFrom, dateTo]);
const pageCount = Math.max(1, Math.ceil(filteredRows.length / pagination.pageSize));
const pagedRows = useMemo(() => {
@@ -466,7 +475,30 @@ const FleetResourcePage = () => {
viewMode={viewMode}
onViewModeChange={setViewMode}
filters={
listFilterSelects ? (
<Group gap="sm" wrap="wrap" align="center">
<DatePickerInput
aria-label="Created from"
placeholder="Created from"
value={dateFrom}
onChange={setDateFrom}
maxDate={dateTo ?? undefined}
clearable
size="sm"
radius="lg"
w={160}
/>
<DatePickerInput
aria-label="Created to"
placeholder="Created to"
value={dateTo}
onChange={setDateTo}
minDate={dateFrom ?? undefined}
clearable
size="sm"
radius="lg"
w={160}
/>
{listFilterSelects ? (
<Group gap="sm" wrap="wrap" align="center">
{listFilterSelects.map((filter) => (
<Select
@@ -509,7 +541,8 @@ const FleetResourcePage = () => {
))}
</Group>
</Group>
) : undefined
) : null}
</Group>
}
/>
</Box>

View File

@@ -19,6 +19,11 @@ import {
} from "@mantine/core";
import { Plus } from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import ListControls from "@/components/common/ListControls";
// Generic list footer — already shared by the fleet and train-scheduling lists
// despite the ruleEngine path.
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { useListControls } from "@/hooks/useListControls";
import { useToast } from "@/hooks/use-toast";
import { api } from "@/auth/http";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
@@ -118,6 +123,11 @@ export default function FuelPurchasePage() {
const totalCost = formData.liters * formData.costPerLiter;
// Aggregate stats (guarded against divide-by-zero when there are no purchases)
const controls = useListControls(purchasesData as FuelPurchase[], {
searchKeys: ["fuelStation", "paymentMethod"],
dateKey: "purchaseDate",
});
const totalLiters = (purchasesData as FuelPurchase[]).reduce(
(sum, p) => sum + Number(p.liters),
0
@@ -185,6 +195,18 @@ export default function FuelPurchasePage() {
{/* Purchases Table */}
<Card withBorder>
<ListControls
search={controls.search}
onSearchChange={controls.setSearch}
searchPlaceholder="Search station or payment method…"
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
dateLabel="Purchased"
hasFilters={controls.hasFilters}
onReset={controls.reset}
/>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
@@ -215,7 +237,7 @@ export default function FuelPurchasePage() {
</Table.Td>
</Table.Tr>
) : null}
{(purchasesData as FuelPurchase[])?.map((purchase) => (
{controls.pagedRows.map((purchase) => (
<Table.Tr key={purchase.id}>
<Table.Td>{(purchase as any).vehicle?.registrationNumber || (purchase as any).vehicle?.plateNumber || purchase.vehicleId}</Table.Td>
<Table.Td>{new Date(purchase.purchaseDate).toLocaleDateString()}</Table.Td>
@@ -230,6 +252,13 @@ export default function FuelPurchasePage() {
))}
</Table.Tbody>
</Table>
<RuleEngineListFooter
pagination={controls.pagination}
pageCount={controls.pageCount}
totalCount={controls.totalCount}
itemLabel="purchases"
onPaginationChange={controls.setPagination}
/>
</Card>
{/* Modal */}

View File

@@ -20,6 +20,11 @@ import {
} from "@mantine/core";
import { Plus } from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import ListControls from "@/components/common/ListControls";
// Generic list footer — already shared by the fleet and train-scheduling lists
// despite the ruleEngine path.
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { useListControls } from "@/hooks/useListControls";
import { useToast } from "@/hooks/use-toast";
import {
incidentsService,
@@ -161,6 +166,10 @@ export default function IncidentsPage() {
})) || [];
const incidents = incidentsData as Incident[];
const controls = useListControls(incidents, {
searchKeys: ["type", "severity", "status"],
dateKey: "occurredAt",
});
const totalCount = incidents.length;
const openCount = incidents.filter((i) => OPEN_STATUSES.includes(i.status)).length;
const underReviewCount = incidents.filter((i) => i.status === "UNDER_REVIEW").length;
@@ -237,6 +246,18 @@ export default function IncidentsPage() {
{/* Incidents Table */}
<Card withBorder>
<ListControls
search={controls.search}
onSearchChange={controls.setSearch}
searchPlaceholder="Search type, severity, status…"
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
dateLabel="Occurred"
hasFilters={controls.hasFilters}
onReset={controls.reset}
/>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
@@ -267,7 +288,7 @@ export default function IncidentsPage() {
</Table.Td>
</Table.Tr>
) : null}
{incidents.map((incident) => (
{controls.pagedRows.map((incident) => (
<Table.Tr key={incident.id}>
<Table.Td>{new Date(incident.occurredAt).toLocaleDateString()}</Table.Td>
<Table.Td>
@@ -294,6 +315,13 @@ export default function IncidentsPage() {
))}
</Table.Tbody>
</Table>
<RuleEngineListFooter
pagination={controls.pagination}
pageCount={controls.pageCount}
totalCount={controls.totalCount}
itemLabel="incidents"
onPaginationChange={controls.setPagination}
/>
</Card>
{/* Modal */}

View File

@@ -16,6 +16,8 @@ import { CheckCircle2, Download, Eye, FileText, Printer, Search } from 'lucide-r
import type { ReactNode } from 'react';
import { DataTable, type ColumnDef } from '@edr/ui-common';
import ListControls from '@/components/common/ListControls';
import { useListControls } from '@/hooks/useListControls';
import { PageContainer, PageHeader } from '@/components/page';
import { VisualEmptyState, formatDate, formatNumber } from '@/components/warehouses';
@@ -263,6 +265,9 @@ export default function InterchangeDocumentsPage() {
const [viewId, setViewId] = useState<string | null>(null);
const filter = useMemo(() => ({ search: search.trim() || undefined }), [search]);
const { data: documents = [], isLoading } = useInterchangeDocuments(filter);
// Search stays server-side (passed in `filter`); this adds the date range and
// pagination over what comes back.
const controls = useListControls(documents, { dateKey: 'generatedAt' });
const acknowledge = useAcknowledgeInterchangeDocument();
const dispute = useDisputeInterchangeDocument();
@@ -435,7 +440,7 @@ export default function InterchangeDocumentsPage() {
<Card withBorder radius="md" padding="lg">
<Group justify="space-between" mb="md">
<Text fw={600}>{documents.length} document(s)</Text>
<Text fw={600}>{controls.totalCount} document(s)</Text>
<TextInput
w={{ base: '100%', sm: 320 }}
leftSection={<Search size={16} />}
@@ -445,6 +450,19 @@ export default function InterchangeDocumentsPage() {
/>
</Group>
<ListControls
showSearch={false}
search=""
onSearchChange={() => {}}
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
dateLabel="Generated"
hasFilters={controls.hasFilters}
onReset={controls.reset}
/>
{!isLoading && documents.length === 0 ? (
<VisualEmptyState
variant="container"
@@ -454,9 +472,10 @@ export default function InterchangeDocumentsPage() {
) : (
<DataTable
columns={documentColumns}
data={documents}
data={controls.pagedRows}
status={isLoading ? 'loading' : 'success'}
containerClassName="border-0 shadow-none"
{...controls.tableProps}
/>
)}
</Card>

View File

@@ -2,6 +2,7 @@ import { useMemo, useState } from "react";
import {
Alert,
Badge,
Button,
Card,
Center,
Group,
@@ -12,10 +13,16 @@ import {
Text,
Tooltip,
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { AlertTriangle, PackageCheck, TrainFront, Warehouse } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, PackageCheck, PackageOpen, TrainFront, Warehouse } from "lucide-react";
import { PageContainer, PageHeader } from "@/components/page";
import ListControls from "@/components/common/ListControls";
// Generic list footer — already shared by the fleet and train-scheduling lists
// despite the ruleEngine path.
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { useListControls } from "@/hooks/useListControls";
import { useToast } from "@/hooks/use-toast";
import { api } from "@/services/api";
import type { IntercityRideAlongRow } from "@/types/trainScheduling";
@@ -74,7 +81,45 @@ function FacilityCell({
);
}
const apiErrorMessage = (error: unknown) => {
if (error && typeof error === "object" && "response" in error) {
const message = (error as { response?: { data?: { message?: unknown } } }).response?.data
?.message;
if (Array.isArray(message)) return message.join("; ");
if (typeof message === "string") return message;
}
return error instanceof Error ? error.message : undefined;
};
function Rows({ rows }: { rows: IntercityRideAlongRow[] }) {
const { toast } = useToast();
const queryClient = useQueryClient();
const refresh = () =>
queryClient.invalidateQueries({
queryKey: api.trainScheduling.intercityBookings.queryKey(undefined),
});
// Same endpoints as the schedule page's ride-along panel — the server still
// validates the train's recorded checkpoint, payment and yard equipment.
const load = useMutation(
api.trainScheduling.loadIntercityBooking.mutationOptions({
onSuccess: () => {
toast({ title: "Cargo loaded onto the train" });
void refresh();
},
onError: (error) =>
toast({ variant: "destructive", title: "Load failed", description: apiErrorMessage(error) }),
}),
);
const unload = useMutation(
api.trainScheduling.unloadIntercityBooking.mutationOptions({
onSuccess: () => {
toast({ title: "Cargo unloaded — booking completed" });
void refresh();
},
onError: (error) =>
toast({ variant: "destructive", title: "Unload failed", description: apiErrorMessage(error) }),
}),
);
if (rows.length === 0) {
return (
<Alert variant="light" color="gray">
@@ -95,6 +140,7 @@ function Rows({ rows }: { rows: IntercityRideAlongRow[] }) {
<Table.Th ta="right">Weight</Table.Th>
<Table.Th>GRN</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
@@ -151,6 +197,38 @@ function Rows({ rows }: { rows: IntercityRideAlongRow[] }) {
{r.status}
</Badge>
</Table.Td>
<Table.Td>
<Group gap="xs" justify="flex-end" wrap="nowrap">
{/* Work the cargo right here while the train is at the yard. */}
{r.trainScheduleId && atOrigin(r) && isWaiting(r) && r.status === "PAID" && (
<Button
size="compact-xs"
variant="light"
leftSection={<PackageCheck size={13} />}
loading={load.isPending}
onClick={() =>
load.mutate({ scheduleId: r.trainScheduleId as string, bookingId: r.bookingId })
}
>
Load
</Button>
)}
{r.trainScheduleId && atDestination(r) && isRiding(r) && (
<Button
size="compact-xs"
variant="light"
color="orange"
leftSection={<PackageOpen size={13} />}
loading={unload.isPending}
onClick={() =>
unload.mutate({ scheduleId: r.trainScheduleId as string, bookingId: r.bookingId })
}
>
Unload
</Button>
)}
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
@@ -205,6 +283,15 @@ export default function IntercityPage() {
[rows],
);
// Controls follow the active tab, so search/date/paging always describe what
// is on screen. Panels unmount when hidden (keepMounted={false}) so a hidden
// tab can never render another tab's paged slice.
const active = tab === "riding" ? riding : tab === "done" ? done : waiting;
const controls = useListControls(active, {
searchKeys: ["reference", "grnNumber", "customer", "origin", "destination", "trainNumber"],
dateKey: "loadedAt",
});
return (
<PageContainer>
<PageHeader
@@ -283,19 +370,40 @@ export default function IntercityPage() {
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="waiting">
<Rows rows={waiting} />
<ListControls
search={controls.search}
onSearchChange={controls.setSearch}
searchPlaceholder="Reference, GRN, customer, train…"
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
dateLabel="Loaded"
hasFilters={controls.hasFilters}
onReset={controls.reset}
/>
<Tabs.Panel value="waiting" keepMounted={false}>
<Rows rows={controls.pagedRows} />
</Tabs.Panel>
<Tabs.Panel value="riding">
<Rows rows={riding} />
<Tabs.Panel value="riding" keepMounted={false}>
<Rows rows={controls.pagedRows} />
</Tabs.Panel>
<Tabs.Panel value="done">
<Rows rows={done} />
<Tabs.Panel value="done" keepMounted={false}>
<Rows rows={controls.pagedRows} />
</Tabs.Panel>
</Tabs>
<RuleEngineListFooter
pagination={controls.pagination}
pageCount={controls.pageCount}
totalCount={controls.totalCount}
itemLabel="bookings"
onPaginationChange={controls.setPagination}
/>
<Text size="xs" c="dimmed" mt="sm">
Loading and unloading happen on the train's schedule page, where the ride-along
panel confirms the train is at the yard.
Load and Unload appear on a row while its train is recorded at that yard ("train
here"); the same actions also live on the train's schedule page.
</Text>
</Card>
</>

View File

@@ -15,6 +15,11 @@ import {
useInventoryInquiry,
useWarehouses,
} from '@/hooks/useWarehouses';
import ListControls from '@/components/common/ListControls';
// Generic list footer — already shared by the fleet and train-scheduling lists
// despite the ruleEngine path.
import RuleEngineListFooter from '@/components/ruleEngine/RuleEngineListFooter';
import { useListControls } from '@/hooks/useListControls';
import type { InventoryInquiryFilter, InventoryInquiryResult, InventoryStatus } from '@/types/warehouse';
export default function InventoryInquiryPage() {
@@ -29,6 +34,11 @@ export default function InventoryInquiryPage() {
const { data, isFetching } = useInventoryInquiry(applied);
const results = data ?? [];
const controls = useListControls(results, {
searchKeys: ['containerNumber', 'bookingReference', 'customerName', 'cargoDescription', 'locationSummary'],
dateKey: 'arrivedAt',
});
const warehouseOptions = useMemo(
() => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })),
[warehousesQuery.data],
@@ -173,7 +183,28 @@ export default function InventoryInquiryPage() {
description="Adjust your filters and search to locate cargo, containers or goods across the warehouse network."
/>
) : (
<WarehouseInquiryTable results={results} onView={setViewResult} />
<>
<ListControls
search={controls.search}
onSearchChange={controls.setSearch}
searchPlaceholder="Container, booking, customer, location…"
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
dateLabel="Arrived"
hasFilters={controls.hasFilters}
onReset={controls.reset}
/>
<WarehouseInquiryTable results={controls.pagedRows} onView={setViewResult} />
<RuleEngineListFooter
pagination={controls.pagination}
pageCount={controls.pageCount}
totalCount={controls.totalCount}
itemLabel="items"
onPaginationChange={controls.setPagination}
/>
</>
)}
</Card>
</Stack>

View File

@@ -1,5 +1,7 @@
import { Badge, Card, Group, Text } from '@mantine/core';
import { DataTable, type ColumnDef } from '@edr/ui-common';
import ListControls from '@/components/common/ListControls';
import { useListControls } from '@/hooks/useListControls';
import { PageContainer, PageHeader } from '@/components/page';
import { useQuery } from '@tanstack/react-query';
@@ -67,6 +69,10 @@ export default function LoadedInventoryPage() {
api.warehouses.loadings.queryOptions({ input: {} }),
);
const loadings = data ?? [];
const controls = useListControls(loadings, {
searchKeys: ['wagonNumber'],
dateKey: 'loadedAt',
});
return (
<PageContainer>
@@ -83,12 +89,27 @@ export default function LoadedInventoryPage() {
description="Once items are loaded onto a wagon, their records show here."
/>
) : (
<DataTable
columns={columns}
data={loadings}
status={isLoading ? 'loading' : 'success'}
containerClassName="border-0 shadow-none"
/>
<>
<ListControls
search={controls.search}
onSearchChange={controls.setSearch}
searchPlaceholder="Search by wagon"
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
dateLabel="Loaded"
hasFilters={controls.hasFilters}
onReset={controls.reset}
/>
<DataTable
columns={columns}
data={controls.pagedRows}
status={isLoading ? 'loading' : 'success'}
containerClassName="border-0 shadow-none"
{...controls.tableProps}
/>
</>
)}
</Card>
</PageContainer>

View File

@@ -7,12 +7,15 @@ import {
SegmentedControl,
Table,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
import { Search } from "lucide-react";
import { PageContainer, PageHeader } from "@/components/page";
import ListControls from "@/components/common/ListControls";
// Generic list footer — already shared by the fleet and train-scheduling lists
// despite the ruleEngine path; reused here rather than adding a second one.
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { useListControls } from "@/hooks/useListControls";
import { useTrucksOnSite } from "@/hooks/useWarehouses";
import type { TruckOnSite } from "@/types/warehouse";
@@ -148,20 +151,21 @@ export default function TrucksOnSitePage() {
scopeParam === "ON_SITE" || scopeParam === "INBOUND" ? scopeParam : "ALL",
);
const [source, setSource] = useState<"ALL" | "CUSTOMER" | "EDR">("ALL");
const [search, setSearch] = useState("");
const rows = useMemo(() => {
const term = search.trim().toLowerCase();
return trucks
.filter((t) => scope === "ALL" || t.status === scope)
.filter((t) => source === "ALL" || t.source === source)
.filter((t) =>
!term
? true
: [t.plateNumber, t.driverName, t.bookingReference, t.customerName, t.containers]
.some((field) => field?.toLowerCase().includes(term)),
);
}, [trucks, scope, source, search]);
// Scope/source are page filters and run first; the shared control then does
// search + arrival-date range + pagination over what they leave.
const scoped = useMemo(
() =>
trucks
.filter((t) => scope === "ALL" || t.status === scope)
.filter((t) => source === "ALL" || t.source === source),
[trucks, scope, source],
);
const controls = useListControls(scoped, {
searchKeys: ["plateNumber", "driverName", "bookingReference", "customerName", "containers"],
dateKey: "arrivedAt",
});
const onSiteCount = trucks.filter((t) => t.status === "ON_SITE").length;
const inboundCount = trucks.length - onSiteCount;
@@ -198,17 +202,35 @@ export default function TrucksOnSitePage() {
]}
/>
</Group>
<TextInput
size="xs"
w={280}
placeholder="Plate, driver, booking, container…"
leftSection={<Search size={14} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
</Group>
{isLoading ? <Text size="sm">Loading</Text> : <Rows rows={rows} />}
<ListControls
search={controls.search}
onSearchChange={controls.setSearch}
searchPlaceholder="Plate, driver, booking, container…"
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
dateLabel="Arrived"
hasFilters={controls.hasFilters}
onReset={controls.reset}
/>
{isLoading ? (
<Text size="sm">Loading</Text>
) : (
<>
<Rows rows={controls.pagedRows} />
<RuleEngineListFooter
pagination={controls.pagination}
pageCount={controls.pageCount}
totalCount={controls.totalCount}
itemLabel="trucks"
onPaginationChange={controls.setPagination}
/>
</>
)}
</PageContainer>
);
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useState } from 'react';
import {
ActionIcon,
Badge,
@@ -15,9 +15,11 @@ import {
Text,
TextInput,
} from '@mantine/core';
import { Ban, CreditCard, DoorOpen, Download, ExternalLink, Eye, Receipt, Search } from 'lucide-react';
import { Ban, CreditCard, DoorOpen, Download, ExternalLink, Eye, Receipt } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { DataTable, type ColumnDef } from '@edr/ui-common';
import ListControls from '@/components/common/ListControls';
import { useListControls } from '@/hooks/useListControls';
import { PageContainer, PageHeader } from '@/components/page';
import { AccrualDashboard } from '@/components/warehouses';
@@ -50,7 +52,6 @@ const fmtDate = (d?: string | null) => (d ? new Date(d).toLocaleDateString() : '
export default function WarehouseInvoicesPage() {
const [status, setStatus] = useState<WarehouseInvoiceStatus | null>(null);
const [search, setSearch] = useState('');
const [detailId, setDetailId] = useState<string | null>(null);
const { data, isLoading } = useQuery(
@@ -60,11 +61,10 @@ export default function WarehouseInvoicesPage() {
);
const invoices = data ?? [];
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return invoices;
return invoices.filter((i) => [i.invoiceNumber, i.bookingId, i.customerId].join(' ').toLowerCase().includes(q));
}, [invoices, search]);
const controls = useListControls(invoices, {
searchKeys: ['invoiceNumber', 'bookingReference', 'customerName', 'containerNumber'],
dateKey: 'issuedAt',
});
const invoiceColumns: ColumnDef<WarehouseFeeInvoice>[] = [
{
@@ -131,30 +131,36 @@ export default function WarehouseInvoicesPage() {
</Stack>
<Card>
<Group justify="space-between" mb="md" wrap="wrap">
<TextInput
placeholder="Search invoice no / booking / customer"
leftSection={<Search size={16} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
w={320}
/>
<Select
placeholder="All statuses"
data={WAREHOUSE_INVOICE_STATUSES.map((s) => ({ value: s, label: s.replace(/_/g, ' ') }))}
value={status}
onChange={(v) => setStatus((v as WarehouseInvoiceStatus) ?? null)}
clearable
w={200}
/>
</Group>
<ListControls
search={controls.search}
onSearchChange={controls.setSearch}
searchPlaceholder="Search invoice no / booking / customer"
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
dateLabel="Issued"
hasFilters={controls.hasFilters}
onReset={controls.reset}
>
<Select
label="Status"
placeholder="All statuses"
data={WAREHOUSE_INVOICE_STATUSES.map((s) => ({ value: s, label: s.replace(/_/g, ' ') }))}
value={status}
onChange={(v) => setStatus((v as WarehouseInvoiceStatus) ?? null)}
clearable
w={200}
/>
</ListControls>
<DataTable
columns={invoiceColumns}
data={filtered}
data={controls.pagedRows}
status={isLoading ? 'loading' : 'success'}
emptyMessage="No invoices found."
containerClassName="border-0 shadow-none"
{...controls.tableProps}
/>
</Card>

View File

@@ -12,6 +12,11 @@ import {
WarehouseTable,
type WarehouseView,
} from '@/components/warehouses';
import ListControls from '@/components/common/ListControls';
// Generic list footer — already shared by the fleet and train-scheduling lists
// despite the ruleEngine path.
import RuleEngineListFooter from '@/components/ruleEngine/RuleEngineListFooter';
import { useListControls } from '@/hooks/useListControls';
import { useWarehouses } from '@/hooks/useWarehouses';
import type { Warehouse, WarehouseFilter } from '@/types/warehouse';
@@ -31,6 +36,11 @@ export default function WarehouseListPage() {
const { data, isLoading, isError } = useWarehouses(queryFilter);
const warehouses = data ?? [];
// Search stays with WarehouseFilters — it is server-side and debounced, so
// re-doing it client-side here would be a regression. This adds only the date
// range + pagination, shared by both the table and card views.
const controls = useListControls(warehouses, { dateKey: 'createdAt' });
const openCreate = () => {
setEditing(null);
setModalOpen(true);
@@ -57,6 +67,19 @@ export default function WarehouseListPage() {
<Stack gap="md">
<WarehouseFilters filter={filter} onChange={setFilter} view={view} onViewChange={setView} />
<ListControls
showSearch={false}
search=""
onSearchChange={() => {}}
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
dateLabel="Created"
hasFilters={controls.hasFilters}
onReset={controls.reset}
/>
{isLoading ? (
<Center py="xl">
<Loader />
@@ -65,10 +88,21 @@ export default function WarehouseListPage() {
<Text c="red" ta="center" py="xl">
Failed to load warehouses.
</Text>
) : view === 'table' ? (
<WarehouseTable warehouses={warehouses} onView={openDetail} onEdit={openEdit} />
) : (
<WarehouseCardView warehouses={warehouses} onView={openDetail} onEdit={openEdit} />
<>
{view === 'table' ? (
<WarehouseTable warehouses={controls.pagedRows} onView={openDetail} onEdit={openEdit} />
) : (
<WarehouseCardView warehouses={controls.pagedRows} onView={openDetail} onEdit={openEdit} />
)}
<RuleEngineListFooter
pagination={controls.pagination}
pageCount={controls.pageCount}
totalCount={controls.totalCount}
itemLabel="warehouses"
onPaginationChange={controls.setPagination}
/>
</>
)}
</Stack>
</Card>

View File

@@ -19,6 +19,8 @@ import { Info, Pencil, Plus, Trash2 } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { DataTable, type ColumnDef } from '@edr/ui-common';
import ListControls from '@/components/common/ListControls';
import { useListControls } from '@/hooks/useListControls';
import { PageContainer, PageHeader } from '@/components/page';
import { useToast } from '@/hooks/use-toast';
@@ -140,6 +142,10 @@ function AllocationRules() {
});
const rules = data ?? [];
const controls = useListControls(rules, {
searchKeys: ['name', 'targetYardCode', 'targetWarehouseCode', 'targetZoneCode', 'freightType', 'tradeDirection', 'cargoTypeCode', 'storageType'],
dateKey: 'createdAt',
});
const yardOptions = yards
.filter((yard) => yard.code)
.map((yard) => ({
@@ -254,7 +260,7 @@ function AllocationRules() {
<>
<Group justify="space-between" mb="sm">
<Text c="dimmed" size="sm">
{rules.length} rule(s) matched by ascending priority
{controls.totalCount} rule(s) matched by ascending priority
</Text>
<Button leftSection={<Plus size={16} />} onClick={() => { resetForm(); setOpen(true); }}>
New allocation rule
@@ -267,12 +273,26 @@ function AllocationRules() {
</Text>
</Alert>
<ListControls
search={controls.search}
onSearchChange={controls.setSearch}
searchPlaceholder="Search allocation rules…"
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
dateLabel="Created"
hasFilters={controls.hasFilters}
onReset={controls.reset}
/>
<DataTable
columns={allocationColumns}
data={rules}
data={controls.pagedRows}
status={isLoading ? 'loading' : 'success'}
emptyMessage="No allocation rules yet. Create one to route inventory to a yard automatically."
containerClassName="border-0 shadow-none"
{...controls.tableProps}
/>
<Modal opened={open} onClose={() => { setOpen(false); resetForm(); }} title={editingId ? 'Edit allocation rule' : 'New allocation rule'} centered size="lg">
@@ -419,6 +439,10 @@ function FeeRules() {
currency: 'USD',
});
const rules = data ?? [];
const controls = useListControls(rules, {
searchKeys: ['name', 'ruleType', 'freightType', 'tradeDirection', 'cargoTypeCode', 'containerType', 'vehicleType', 'currency'],
dateKey: 'createdAt',
});
const cargoTypeOptions = codeOptions(cargoTypes);
const containerTypeOptions = codeOptions(containerTypes);
const isBulkRule = form.freightType === 'BULK';
@@ -647,19 +671,33 @@ function FeeRules() {
<>
<Group justify="space-between" mb="sm">
<Text c="dimmed" size="sm">
{rules.length} rule(s) - most specific match applies
{controls.totalCount} rule(s) - most specific match applies
</Text>
<Button leftSection={<Plus size={16} />} onClick={() => { resetForm(); setOpen(true); }}>
New fee rule
</Button>
</Group>
<ListControls
search={controls.search}
onSearchChange={controls.setSearch}
searchPlaceholder="Search fee rules…"
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
dateLabel="Created"
hasFilters={controls.hasFilters}
onReset={controls.reset}
/>
<DataTable
columns={feeColumns}
data={rules}
data={controls.pagedRows}
status={isLoading ? 'loading' : 'success'}
emptyMessage="No storage or demurrage fee rules yet."
containerClassName="border-0 shadow-none"
{...controls.tableProps}
/>
<Modal opened={open} onClose={() => { setOpen(false); resetForm(); }} title={editingId ? 'Edit fee rule' : 'New fee rule'} centered size="lg">

View File

@@ -756,8 +756,10 @@ export interface AllocationRule {
targetZoneCode?: string | null;
storageType?: string | null;
isActive: boolean;
/** Set by the API (BaseEntity); used for the list date filter. */
createdAt?: string;
}
export type SaveAllocationRulePayload = Omit<AllocationRule, 'id'>;
export type SaveAllocationRulePayload = Omit<AllocationRule, 'id' | 'createdAt'>;
export const FEE_RULE_TYPES = [
'STORAGE_FEE',
@@ -813,8 +815,10 @@ export interface FeeRule {
tiers?: FeeRuleTier[];
currency: string;
isActive: boolean;
/** Set by the API (BaseEntity); used for the list date filter. */
createdAt?: string;
}
export type SaveFeeRulePayload = Omit<FeeRule, 'id'>;
export type SaveFeeRulePayload = Omit<FeeRule, 'id' | 'createdAt'>;
export interface FeeRuleTier {
fromDay: number;