diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts
index 7bd56e593..cdf34cd66 100644
--- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts
+++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts
@@ -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) {}
diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts
index 0174ef3e9..b3868b465 100644
--- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts
+++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts
@@ -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);
diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts
index 4ad469ce0..c2f5cec9c 100644
--- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts
+++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts
@@ -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);
diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts
index 5f4205815..7e658d9db 100644
--- a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts
+++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts
@@ -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);
diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts
index b0371cbcc..594fd7a6f 100644
--- a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts
+++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts
@@ -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) {}
diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts
index 63c40de94..3ee381a8c 100644
--- a/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts
+++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts
@@ -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,
diff --git a/apps/edr-freight-web/backoffice/src/components/common/ListControls.tsx b/apps/edr-freight-web/backoffice/src/components/common/ListControls.tsx
new file mode 100644
index 000000000..92ab514ad
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/common/ListControls.tsx
@@ -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) => (
+
+ {showSearch && (
+ onSearchChange(e.currentTarget.value)}
+ leftSection={}
+ style={{ flex: "1 1 240px", minWidth: 200 }}
+ />
+ )}
+
+ {showDateRange && (
+ <>
+
+
+ >
+ )}
+
+ {children}
+
+ {hasFilters && onReset && (
+ }
+ onClick={onReset}
+ >
+ Clear
+
+ )}
+
+);
+
+export default ListControls;
diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx
index d8b370de0..88c6de18c 100644
--- a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx
@@ -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>(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
+
+
+
+
setMoveItem(null)} item={moveItem} />
diff --git a/apps/edr-freight-web/backoffice/src/hooks/useListControls.ts b/apps/edr-freight-web/backoffice/src/hooks/useListControls.ts
new file mode 100644
index 000000000..11e0124eb
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/hooks/useListControls.ts
@@ -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 {
+ /**
+ * 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)[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 = (rows: T[], options: ListControlsOptions = {}) => {
+ const { searchKeys = [], dateKey, pageSize = 10, searchValue } = options;
+
+ const [search, setSearch] = useState("");
+ const [dateFrom, setDateFrom] = useState(null);
+ const [dateTo, setDateTo] = useState(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 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,
+ },
+ },
+ };
+};
diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/CompliancePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/CompliancePage.tsx
index 008fee25b..f31bf5e76 100644
--- a/apps/edr-freight-web/backoffice/src/pages/fleet/CompliancePage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/fleet/CompliancePage.tsx
@@ -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
+
@@ -239,7 +261,7 @@ export default function CompliancePage() {
) : null}
- {(records as ComplianceRecord[]).map((record) => (
+ {controls.pagedRows.map((record) => (
{vehicleLabel(record)}
@@ -259,6 +281,13 @@ export default function CompliancePage() {
))}
+
{/* Modal */}
diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx
index 027b16d4e..47539decf 100644
--- a/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FleetResourcePage.tsx
@@ -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(null);
+ const [dateTo, setDateTo] = useState(null);
const [listFilterValues, setListFilterValues] = useState>({});
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState(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;
+ // 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 ? (
+
+
+
+ {listFilterSelects ? (
{listFilterSelects.map((filter) => (
- ) : undefined
+ ) : null}
+
}
/>
diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FuelPurchasePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FuelPurchasePage.tsx
index 6ca9f8870..8ecedd712 100644
--- a/apps/edr-freight-web/backoffice/src/pages/fleet/FuelPurchasePage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FuelPurchasePage.tsx
@@ -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 */}
+
@@ -215,7 +237,7 @@ export default function FuelPurchasePage() {
) : null}
- {(purchasesData as FuelPurchase[])?.map((purchase) => (
+ {controls.pagedRows.map((purchase) => (
{(purchase as any).vehicle?.registrationNumber || (purchase as any).vehicle?.plateNumber || purchase.vehicleId}
{new Date(purchase.purchaseDate).toLocaleDateString()}
@@ -230,6 +252,13 @@ export default function FuelPurchasePage() {
))}
+
{/* Modal */}
diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/IncidentsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/IncidentsPage.tsx
index 2d947b73a..6276091b8 100644
--- a/apps/edr-freight-web/backoffice/src/pages/fleet/IncidentsPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/fleet/IncidentsPage.tsx
@@ -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 */}
+
@@ -267,7 +288,7 @@ export default function IncidentsPage() {
) : null}
- {incidents.map((incident) => (
+ {controls.pagedRows.map((incident) => (
{new Date(incident.occurredAt).toLocaleDateString()}
@@ -294,6 +315,13 @@ export default function IncidentsPage() {
))}
+
{/* Modal */}
diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/InterchangeDocumentsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/InterchangeDocumentsPage.tsx
index 04fe38970..3cc1f1ab6 100644
--- a/apps/edr-freight-web/backoffice/src/pages/warehouses/InterchangeDocumentsPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/InterchangeDocumentsPage.tsx
@@ -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(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() {
- {documents.length} document(s)
+ {controls.totalCount} document(s)
}
@@ -445,6 +450,19 @@ export default function InterchangeDocumentsPage() {
/>
+ {}}
+ dateFrom={controls.dateFrom}
+ onDateFromChange={controls.setDateFrom}
+ dateTo={controls.dateTo}
+ onDateToChange={controls.setDateTo}
+ dateLabel="Generated"
+ hasFilters={controls.hasFilters}
+ onReset={controls.reset}
+ />
+
{!isLoading && documents.length === 0 ? (
)}
diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/IntercityPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/IntercityPage.tsx
index bce1f52e3..cfda31629 100644
--- a/apps/edr-freight-web/backoffice/src/pages/warehouses/IntercityPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/IntercityPage.tsx
@@ -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 (
@@ -95,6 +140,7 @@ function Rows({ rows }: { rows: IntercityRideAlongRow[] }) {
Weight
GRN
Status
+
@@ -151,6 +197,38 @@ function Rows({ rows }: { rows: IntercityRideAlongRow[] }) {
{r.status}
+
+
+ {/* Work the cargo right here while the train is at the yard. */}
+ {r.trainScheduleId && atOrigin(r) && isWaiting(r) && r.status === "PAID" && (
+ }
+ loading={load.isPending}
+ onClick={() =>
+ load.mutate({ scheduleId: r.trainScheduleId as string, bookingId: r.bookingId })
+ }
+ >
+ Load
+
+ )}
+ {r.trainScheduleId && atDestination(r) && isRiding(r) && (
+ }
+ loading={unload.isPending}
+ onClick={() =>
+ unload.mutate({ scheduleId: r.trainScheduleId as string, bookingId: r.bookingId })
+ }
+ >
+ Unload
+
+ )}
+
+
))}
@@ -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 (
-
-
+
+
+
+
-
-
+
+
-
-
+
+
+
+
- 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.
>
diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/InventoryInquiryPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/InventoryInquiryPage.tsx
index d85cb815b..72d2a9732 100644
--- a/apps/edr-freight-web/backoffice/src/pages/warehouses/InventoryInquiryPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/InventoryInquiryPage.tsx
@@ -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."
/>
) : (
-
+ <>
+
+
+
+ >
)}
diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadedInventoryPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadedInventoryPage.tsx
index 7dfc1996c..dbf7b14e3 100644
--- a/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadedInventoryPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/LoadedInventoryPage.tsx
@@ -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 (
@@ -83,12 +89,27 @@ export default function LoadedInventoryPage() {
description="Once items are loaded onto a wagon, their records show here."
/>
) : (
-
+ <>
+
+
+ >
)}
diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/TrucksOnSitePage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/TrucksOnSitePage.tsx
index 8210d7784..f095d94d4 100644
--- a/apps/edr-freight-web/backoffice/src/pages/warehouses/TrucksOnSitePage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/TrucksOnSitePage.tsx
@@ -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() {
]}
/>
- }
- value={search}
- onChange={(e) => setSearch(e.currentTarget.value)}
- />
- {isLoading ? Loading… : }
+
+
+ {isLoading ? (
+ Loading…
+ ) : (
+ <>
+
+
+ >
+ )}
);
}
diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx
index 1f632c1e7..92f807363 100644
--- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx
@@ -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(null);
- const [search, setSearch] = useState('');
const [detailId, setDetailId] = useState(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[] = [
{
@@ -131,30 +131,36 @@ export default function WarehouseInvoicesPage() {
-
- }
- value={search}
- onChange={(e) => setSearch(e.currentTarget.value)}
- w={320}
- />
-
+
+
diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseListPage.tsx
index 3f9df44f6..8b3600026 100644
--- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseListPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseListPage.tsx
@@ -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() {
+ {}}
+ dateFrom={controls.dateFrom}
+ onDateFromChange={controls.setDateFrom}
+ dateTo={controls.dateTo}
+ onDateToChange={controls.setDateTo}
+ dateLabel="Created"
+ hasFilters={controls.hasFilters}
+ onReset={controls.reset}
+ />
+
{isLoading ? (
@@ -65,10 +88,21 @@ export default function WarehouseListPage() {
Failed to load warehouses.
- ) : view === 'table' ? (
-
) : (
-
+ <>
+ {view === 'table' ? (
+
+ ) : (
+
+ )}
+
+ >
)}
diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx
index b7e452cd7..20b7efbb0 100644
--- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx
@@ -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() {
<>
- {rules.length} rule(s) matched by ascending priority
+ {controls.totalCount} rule(s) matched by ascending priority
} onClick={() => { resetForm(); setOpen(true); }}>
New allocation rule
@@ -267,12 +273,26 @@ function AllocationRules() {
+
+
{ 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() {
<>
- {rules.length} rule(s) - most specific match applies
+ {controls.totalCount} rule(s) - most specific match applies
} onClick={() => { resetForm(); setOpen(true); }}>
New fee rule
+
+
{ setOpen(false); resetForm(); }} title={editingId ? 'Edit fee rule' : 'New fee rule'} centered size="lg">
diff --git a/apps/edr-freight-web/backoffice/src/types/warehouse.ts b/apps/edr-freight-web/backoffice/src/types/warehouse.ts
index 1ca31e886..37d2897c2 100644
--- a/apps/edr-freight-web/backoffice/src/types/warehouse.ts
+++ b/apps/edr-freight-web/backoffice/src/types/warehouse.ts
@@ -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;
+export type SaveAllocationRulePayload = Omit;
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;
+export type SaveFeeRulePayload = Omit;
export interface FeeRuleTier {
fromDay: number;