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 && ( + + )} + +); + +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) => ( ({ value: s, label: s.replace(/_/g, ' ') }))} - value={status} - onChange={(v) => setStatus((v as WarehouseInvoiceStatus) ?? null)} - clearable - w={200} - /> - + +