mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Intercity load unload with grn ,Warehouse , fleet , and allocation endpoints permission
This commit is contained in:
@@ -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) {}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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} />
|
||||
|
||||
164
apps/edr-freight-web/backoffice/src/hooks/useListControls.ts
Normal file
164
apps/edr-freight-web/backoffice/src/hooks/useListControls.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user