mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 23:35:42 +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')
|
@ApiTags('warehouse-inspection')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
|
// Baseline read: inspection reports are opened from inventory screens too —
|
||||||
|
// either view permission grants reads; writes stack their own per route.
|
||||||
@Controller()
|
@Controller()
|
||||||
@BookingStaff(FREIGHT_PERMS.warehouseInspectionReports.view)
|
@BookingStaff([
|
||||||
|
FREIGHT_PERMS.warehouseInspectionReports.view,
|
||||||
|
FREIGHT_PERMS.warehouseInventory.view,
|
||||||
|
])
|
||||||
export class WarehouseInspectionController {
|
export class WarehouseInspectionController {
|
||||||
constructor(private readonly inspectionService: WarehouseInspectionService) {}
|
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 type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||||
|
|
||||||
import { actorLabel } from './current-actor.util';
|
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 { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||||
import { BulkReceiveDto } from './dto/bulk-receive.dto';
|
import { BulkReceiveDto } from './dto/bulk-receive.dto';
|
||||||
import { BulkInspectDto } from './dto/bulk-inspect.dto';
|
import { BulkInspectDto } from './dto/bulk-inspect.dto';
|
||||||
@@ -456,6 +456,7 @@ export class WarehouseInventoryController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id/handover-document')
|
@Get(':id/handover-document')
|
||||||
|
@StaffReference()
|
||||||
@ApiOperation({ summary: 'View import goods handover document PDF' })
|
@ApiOperation({ summary: 'View import goods handover document PDF' })
|
||||||
async handoverDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
|
async handoverDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
|
||||||
const { filename, buffer } = await this.inventoryService.handoverDocument(id);
|
const { filename, buffer } = await this.inventoryService.handoverDocument(id);
|
||||||
@@ -466,6 +467,7 @@ export class WarehouseInventoryController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('bookings/:bookingId/approve-delivery')
|
@Post('bookings/:bookingId/approve-delivery')
|
||||||
|
@StaffReference()
|
||||||
@ApiOperation({ summary: "Approve delivery — customer records their full name (signature optional)" })
|
@ApiOperation({ summary: "Approve delivery — customer records their full name (signature optional)" })
|
||||||
approveDeliveryForBooking(
|
approveDeliveryForBooking(
|
||||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||||
@@ -481,12 +483,14 @@ export class WarehouseInventoryController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('bookings/:bookingId/handovers')
|
@Get('bookings/:bookingId/handovers')
|
||||||
|
@StaffReference()
|
||||||
@ApiOperation({ summary: 'Handover records for a booking (per-booking or per-truck)' })
|
@ApiOperation({ summary: 'Handover records for a booking (per-booking or per-truck)' })
|
||||||
bookingHandovers(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
bookingHandovers(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||||
return this.handoverService.list(bookingId);
|
return this.handoverService.list(bookingId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('handovers/:handoverId/sign')
|
@Post('handovers/:handoverId/sign')
|
||||||
|
@StaffReference()
|
||||||
@ApiOperation({ summary: 'Customer signs one handover (EDR last-mile: one signature per truck)' })
|
@ApiOperation({ summary: 'Customer signs one handover (EDR last-mile: one signature per truck)' })
|
||||||
signHandover(
|
signHandover(
|
||||||
@Param('handoverId', ParseUUIDPipe) handoverId: string,
|
@Param('handoverId', ParseUUIDPipe) handoverId: string,
|
||||||
@@ -502,12 +506,14 @@ export class WarehouseInventoryController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('bookings/:bookingId/request-handover-signature')
|
@Post('bookings/:bookingId/request-handover-signature')
|
||||||
|
@StaffReference()
|
||||||
@ApiOperation({ summary: 'Ask the customer to sign the handover (creates one if none, then notifies)' })
|
@ApiOperation({ summary: 'Ask the customer to sign the handover (creates one if none, then notifies)' })
|
||||||
requestHandoverSignature(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
requestHandoverSignature(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||||
return this.handoverService.requestSignature(bookingId);
|
return this.handoverService.requestSignature(bookingId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('bookings/:bookingId/grn-document')
|
@Get('bookings/:bookingId/grn-document')
|
||||||
|
@StaffReference()
|
||||||
@ApiOperation({ summary: 'View GRN PDF for a booking (customer portal)' })
|
@ApiOperation({ summary: 'View GRN PDF for a booking (customer portal)' })
|
||||||
async bookingGrnDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) {
|
async bookingGrnDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) {
|
||||||
const { filename, buffer } = await this.inventoryService.grnDocumentForBooking(bookingId);
|
const { filename, buffer } = await this.inventoryService.grnDocumentForBooking(bookingId);
|
||||||
@@ -518,6 +524,7 @@ export class WarehouseInventoryController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('bookings/:bookingId/release-document')
|
@Get('bookings/:bookingId/release-document')
|
||||||
|
@StaffReference()
|
||||||
@ApiOperation({ summary: 'View gate-clearance / release-order PDF for a booking (customer portal)' })
|
@ApiOperation({ summary: 'View gate-clearance / release-order PDF for a booking (customer portal)' })
|
||||||
async bookingReleaseDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) {
|
async bookingReleaseDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) {
|
||||||
const { filename, buffer } = await this.inventoryService.releaseDocumentForBooking(bookingId);
|
const { filename, buffer } = await this.inventoryService.releaseDocumentForBooking(bookingId);
|
||||||
@@ -528,6 +535,7 @@ export class WarehouseInventoryController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('bookings/:bookingId/handover-document')
|
@Get('bookings/:bookingId/handover-document')
|
||||||
|
@StaffReference()
|
||||||
@ApiOperation({ summary: 'View import goods handover document PDF (resolved by booking; ?handoverId= for the per-truck variant)' })
|
@ApiOperation({ summary: 'View import goods handover document PDF (resolved by booking; ?handoverId= for the per-truck variant)' })
|
||||||
async bookingHandoverDocument(
|
async bookingHandoverDocument(
|
||||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||||
@@ -545,18 +553,21 @@ export class WarehouseInventoryController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('bookings/:bookingId/container-items')
|
@Get('bookings/:bookingId/container-items')
|
||||||
|
@StaffReference()
|
||||||
@ApiOperation({ summary: 'Per-container/bulk items of a booking with lifecycle stage + refs' })
|
@ApiOperation({ summary: 'Per-container/bulk items of a booking with lifecycle stage + refs' })
|
||||||
containerItems(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
containerItems(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||||
return this.inventoryService.containerItems(bookingId);
|
return this.inventoryService.containerItems(bookingId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('bookings/:bookingId/container-weights')
|
@Get('bookings/:bookingId/container-weights')
|
||||||
|
@StaffReference()
|
||||||
@ApiOperation({ summary: "A booking's containers + VGM cargo weight (tonnes) for exit weighing" })
|
@ApiOperation({ summary: "A booking's containers + VGM cargo weight (tonnes) for exit weighing" })
|
||||||
containerWeights(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
containerWeights(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||||
return this.inventoryService.bookingContainerWeights(bookingId);
|
return this.inventoryService.bookingContainerWeights(bookingId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('bookings/:bookingId/location')
|
@Get('bookings/:bookingId/location')
|
||||||
|
@StaffReference()
|
||||||
@ApiOperation({ summary: "Warehouse location of a booking's inventory (customer portal)" })
|
@ApiOperation({ summary: "Warehouse location of a booking's inventory (customer portal)" })
|
||||||
bookingLocation(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
bookingLocation(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||||
return this.inventoryService.bookingLocation(bookingId);
|
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 type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||||
|
|
||||||
import { actorLabel } from './current-actor.util';
|
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 { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||||
import { PayInvoiceDto as GatewayPayInvoiceDto } from '../billing/dto/pay-invoice.dto';
|
import { PayInvoiceDto as GatewayPayInvoiceDto } from '../billing/dto/pay-invoice.dto';
|
||||||
import { GenerateInvoiceDto, PayInvoiceBodyDto } from './dto/invoice.dto';
|
import { GenerateInvoiceDto, PayInvoiceBodyDto } from './dto/invoice.dto';
|
||||||
@@ -43,6 +43,7 @@ export class WarehouseInvoiceController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('bookings/:id/warehouse-fee-invoices')
|
@Get('bookings/:id/warehouse-fee-invoices')
|
||||||
|
@StaffReference()
|
||||||
@ApiOperation({ summary: 'List warehouse fee invoices for a booking' })
|
@ApiOperation({ summary: 'List warehouse fee invoices for a booking' })
|
||||||
listForBooking(@Param('id', ParseUUIDPipe) id: string) {
|
listForBooking(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
return this.invoiceService.listForBooking(id);
|
return this.invoiceService.listForBooking(id);
|
||||||
@@ -70,12 +71,14 @@ export class WarehouseInvoiceController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('warehouse-fee-invoices/:id')
|
@Get('warehouse-fee-invoices/:id')
|
||||||
|
@StaffReference()
|
||||||
@ApiOperation({ summary: 'Get a warehouse fee invoice with items + payment history' })
|
@ApiOperation({ summary: 'Get a warehouse fee invoice with items + payment history' })
|
||||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
return this.invoiceService.findById(id);
|
return this.invoiceService.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('warehouse-fee-invoices/:id/document')
|
@Get('warehouse-fee-invoices/:id/document')
|
||||||
|
@StaffReference()
|
||||||
@ApiOperation({ summary: 'Download sealed warehouse fee invoice PDF' })
|
@ApiOperation({ summary: 'Download sealed warehouse fee invoice PDF' })
|
||||||
async document(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
|
async document(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
|
||||||
const { filename, buffer } = await this.invoiceService.document(id);
|
const { filename, buffer } = await this.invoiceService.document(id);
|
||||||
@@ -86,6 +89,7 @@ export class WarehouseInvoiceController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('warehouse-fee-invoices/:id/receipt')
|
@Get('warehouse-fee-invoices/:id/receipt')
|
||||||
|
@StaffReference()
|
||||||
@ApiOperation({ summary: 'Download sealed warehouse fee payment receipt PDF' })
|
@ApiOperation({ summary: 'Download sealed warehouse fee payment receipt PDF' })
|
||||||
async receipt(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
|
async receipt(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
|
||||||
const { filename, buffer } = await this.invoiceService.receipt(id);
|
const { filename, buffer } = await this.invoiceService.receipt(id);
|
||||||
@@ -110,6 +114,7 @@ export class WarehouseInvoiceController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('warehouse-fee-invoices/:id/pay-online')
|
@Post('warehouse-fee-invoices/:id/pay-online')
|
||||||
|
@StaffReference()
|
||||||
@ApiOperation({ summary: 'Initiate Telebirr/Waafi payment for a warehouse fee invoice' })
|
@ApiOperation({ summary: 'Initiate Telebirr/Waafi payment for a warehouse fee invoice' })
|
||||||
payOnline(@Param('id', ParseUUIDPipe) id: string, @Body() dto: GatewayPayInvoiceDto) {
|
payOnline(@Param('id', ParseUUIDPipe) id: string, @Body() dto: GatewayPayInvoiceDto) {
|
||||||
return this.invoiceService.initiatePayment(id, dto);
|
return this.invoiceService.initiatePayment(id, dto);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post } from '@nestjs/common';
|
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post } from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
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 { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||||
import { CreateWarehouseZoneDto } from './dto/create-warehouse-zone.dto';
|
import { CreateWarehouseZoneDto } from './dto/create-warehouse-zone.dto';
|
||||||
import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto';
|
import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto';
|
||||||
@@ -10,8 +10,8 @@ import { WarehouseZonesService } from './warehouse-zones.service';
|
|||||||
|
|
||||||
@ApiTags('warehouse-yards')
|
@ApiTags('warehouse-yards')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
// No class-level guard: the two reference GETs are open to any signed-in
|
// No class-level guard: every route carries its own permission (reads accept
|
||||||
// staff (StaffReference), every other route carries its own permission.
|
// yard-view OR inventory-view so inventory flows can populate yard pickers).
|
||||||
@Controller('warehouse-yards')
|
@Controller('warehouse-yards')
|
||||||
export class WarehouseYardsController {
|
export class WarehouseYardsController {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -20,14 +20,14 @@ export class WarehouseYardsController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@StaffReference()
|
@BookingStaff([FREIGHT_PERMS.warehouseYards.view, FREIGHT_PERMS.warehouseInventory.view])
|
||||||
@ApiOperation({ summary: 'List all warehouse yards' })
|
@ApiOperation({ summary: 'List all warehouse yards' })
|
||||||
findAll() {
|
findAll() {
|
||||||
return this.yardsService.findAll();
|
return this.yardsService.findAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@StaffReference()
|
@BookingStaff([FREIGHT_PERMS.warehouseYards.view, FREIGHT_PERMS.warehouseInventory.view])
|
||||||
@ApiOperation({ summary: 'Get warehouse yard by ID' })
|
@ApiOperation({ summary: 'Get warehouse yard by ID' })
|
||||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
return this.yardsService.findById(id);
|
return this.yardsService.findById(id);
|
||||||
|
|||||||
@@ -8,8 +8,11 @@ import { WarehouseZonesService } from './warehouse-zones.service';
|
|||||||
|
|
||||||
@ApiTags('warehouse-zones')
|
@ApiTags('warehouse-zones')
|
||||||
@ApiBearerAuth()
|
@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')
|
@Controller('warehouse-zones')
|
||||||
@BookingStaff(FREIGHT_PERMS.warehouseZones.view)
|
@BookingStaff([FREIGHT_PERMS.warehouseZones.view, FREIGHT_PERMS.warehouseInventory.view])
|
||||||
export class WarehouseZonesController {
|
export class WarehouseZonesController {
|
||||||
constructor(private readonly zonesService: WarehouseZonesService) {}
|
constructor(private readonly zonesService: WarehouseZonesService) {}
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,15 @@ import { WarehousesService } from './warehouses.service';
|
|||||||
|
|
||||||
@ApiTags('warehouses')
|
@ApiTags('warehouses')
|
||||||
@ApiBearerAuth()
|
@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')
|
@Controller('warehouses')
|
||||||
@BookingStaff(FREIGHT_PERMS.warehouses.view)
|
@BookingStaff([
|
||||||
|
FREIGHT_PERMS.warehouses.view,
|
||||||
|
FREIGHT_PERMS.warehouseInventory.view,
|
||||||
|
FREIGHT_PERMS.warehouseDashboard.view,
|
||||||
|
])
|
||||||
export class WarehousesController {
|
export class WarehousesController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly warehousesService: WarehousesService,
|
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 { MoveInventoryModal } from './MoveInventoryModal';
|
||||||
import { ReleaseOrderModal } from './ReleaseOrderModal';
|
import { ReleaseOrderModal } from './ReleaseOrderModal';
|
||||||
import { WarehouseInventoryTable } from './WarehouseInventoryTable';
|
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 { extractDownloadErrorMessage, extractErrorMessage } from './options';
|
||||||
import { openPdfBlob, saveBlob } from './pdf';
|
import { openPdfBlob, saveBlob } from './pdf';
|
||||||
|
|
||||||
@@ -56,8 +61,17 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
|||||||
api.warehouses.bulkMarkInspected.mutationOptions(),
|
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 [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 someSelected = selected.size > 0 && !allSelected;
|
||||||
const toggleSelect = (id: string) =>
|
const toggleSelect = (id: string) =>
|
||||||
setSelected((prev) => {
|
setSelected((prev) => {
|
||||||
@@ -66,7 +80,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
const toggleSelectAll = () =>
|
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 () => {
|
const markInspected = async () => {
|
||||||
if (selected.size === 0) {
|
if (selected.size === 0) {
|
||||||
@@ -268,8 +282,21 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
|||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</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
|
<WarehouseInventoryTable
|
||||||
items={items}
|
items={controls.pagedRows}
|
||||||
busyId={busyId}
|
busyId={busyId}
|
||||||
onAdvance={advance}
|
onAdvance={advance}
|
||||||
onMove={setMoveItem}
|
onMove={setMoveItem}
|
||||||
@@ -287,6 +314,14 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
|||||||
allSelected={allSelected}
|
allSelected={allSelected}
|
||||||
someSelected={someSelected}
|
someSelected={someSelected}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<RuleEngineListFooter
|
||||||
|
pagination={controls.pagination}
|
||||||
|
pageCount={controls.pageCount}
|
||||||
|
totalCount={controls.totalCount}
|
||||||
|
itemLabel="items"
|
||||||
|
onPaginationChange={controls.setPagination}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<MoveInventoryModal opened={Boolean(moveItem)} onClose={() => setMoveItem(null)} item={moveItem} />
|
<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";
|
} from "@mantine/core";
|
||||||
import { Plus, AlertTriangle } from "lucide-react";
|
import { Plus, AlertTriangle } from "lucide-react";
|
||||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
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 { useToast } from "@/hooks/use-toast";
|
||||||
import {
|
import {
|
||||||
complianceService,
|
complianceService,
|
||||||
@@ -86,6 +91,11 @@ export default function CompliancePage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const controls = useListControls(records as ComplianceRecord[], {
|
||||||
|
searchKeys: ["type", "status", "documentNumber"],
|
||||||
|
dateKey: "expiryDate",
|
||||||
|
});
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: async (data: typeof formData) => {
|
mutationFn: async (data: typeof formData) => {
|
||||||
const res = await complianceService.create({
|
const res = await complianceService.create({
|
||||||
@@ -210,6 +220,18 @@ export default function CompliancePage() {
|
|||||||
Compliance Records
|
Compliance Records
|
||||||
</Title>
|
</Title>
|
||||||
<Card withBorder>
|
<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 striped highlightOnHover>
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
@@ -239,7 +261,7 @@ export default function CompliancePage() {
|
|||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
) : null}
|
) : null}
|
||||||
{(records as ComplianceRecord[]).map((record) => (
|
{controls.pagedRows.map((record) => (
|
||||||
<Table.Tr key={record.id}>
|
<Table.Tr key={record.id}>
|
||||||
<Table.Td>{vehicleLabel(record)}</Table.Td>
|
<Table.Td>{vehicleLabel(record)}</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
@@ -259,6 +281,13 @@ export default function CompliancePage() {
|
|||||||
))}
|
))}
|
||||||
</Table.Tbody>
|
</Table.Tbody>
|
||||||
</Table>
|
</Table>
|
||||||
|
<RuleEngineListFooter
|
||||||
|
pagination={controls.pagination}
|
||||||
|
pageCount={controls.pageCount}
|
||||||
|
totalCount={controls.totalCount}
|
||||||
|
itemLabel="records"
|
||||||
|
onPaginationChange={controls.setPagination}
|
||||||
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Modal */}
|
{/* Modal */}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ColumnDef } from "@edr/ui-common";
|
import type { ColumnDef } from "@edr/ui-common";
|
||||||
import { Box, Button, Card, Container, Group, Modal, Select, Stack, Text, Title } from "@mantine/core";
|
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 { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
@@ -15,6 +16,7 @@ import FleetFormDialog from "@/components/fleet/FleetFormDialog";
|
|||||||
import FleetHistoryModal from "@/components/fleet/FleetHistoryModal";
|
import FleetHistoryModal from "@/components/fleet/FleetHistoryModal";
|
||||||
import FleetRecordActions from "@/components/fleet/FleetRecordActions";
|
import FleetRecordActions from "@/components/fleet/FleetRecordActions";
|
||||||
import FleetToolbar from "@/components/fleet/FleetToolbar";
|
import FleetToolbar from "@/components/fleet/FleetToolbar";
|
||||||
|
import { matchesDayRange } from "@/hooks/useListControls";
|
||||||
import WagonMovementHistoryModal from "@/components/fleet/WagonMovementHistoryModal";
|
import WagonMovementHistoryModal from "@/components/fleet/WagonMovementHistoryModal";
|
||||||
import WagonYardWorkspaceModal from "@/components/wagons/WagonYardWorkspaceModal";
|
import WagonYardWorkspaceModal from "@/components/wagons/WagonYardWorkspaceModal";
|
||||||
import WagonTransferRequestsModal from "@/components/wagons/WagonTransferRequestsModal";
|
import WagonTransferRequestsModal from "@/components/wagons/WagonTransferRequestsModal";
|
||||||
@@ -47,6 +49,10 @@ const FleetResourcePage = () => {
|
|||||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [statusFilter, setStatusFilter] = useState("ALL");
|
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 [listFilterValues, setListFilterValues] = useState<Record<string, string>>({});
|
||||||
const [formOpen, setFormOpen] = useState(false);
|
const [formOpen, setFormOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<FleetRecord | null>(null);
|
const [editing, setEditing] = useState<FleetRecord | null>(null);
|
||||||
@@ -125,7 +131,7 @@ const FleetResourcePage = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize }));
|
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 hasStatusColumn = Boolean(config?.columns.some((col) => col.accessorKey === "status"));
|
||||||
const usesServerListFilters = Boolean(config?.listFilters?.length);
|
const usesServerListFilters = Boolean(config?.listFilters?.length);
|
||||||
@@ -255,10 +261,13 @@ const FleetResourcePage = () => {
|
|||||||
|
|
||||||
const filteredRows = useMemo(() => {
|
const filteredRows = useMemo(() => {
|
||||||
if (!config) return allRows;
|
if (!config) return allRows;
|
||||||
if (usesServerListFilters) return allRows;
|
|
||||||
const term = search.trim().toLowerCase();
|
const term = search.trim().toLowerCase();
|
||||||
return allRows.filter((row) => {
|
return allRows.filter((row) => {
|
||||||
const record = row as unknown as Record<string, unknown>;
|
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) {
|
if (statusFilter !== "ALL" && String(record.status ?? "") !== statusFilter) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -269,7 +278,7 @@ const FleetResourcePage = () => {
|
|||||||
.includes(term),
|
.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 pageCount = Math.max(1, Math.ceil(filteredRows.length / pagination.pageSize));
|
||||||
const pagedRows = useMemo(() => {
|
const pagedRows = useMemo(() => {
|
||||||
@@ -466,7 +475,30 @@ const FleetResourcePage = () => {
|
|||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
onViewModeChange={setViewMode}
|
onViewModeChange={setViewMode}
|
||||||
filters={
|
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">
|
<Group gap="sm" wrap="wrap" align="center">
|
||||||
{listFilterSelects.map((filter) => (
|
{listFilterSelects.map((filter) => (
|
||||||
<Select
|
<Select
|
||||||
@@ -509,7 +541,8 @@ const FleetResourcePage = () => {
|
|||||||
))}
|
))}
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
) : undefined
|
) : null}
|
||||||
|
</Group>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
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 { useToast } from "@/hooks/use-toast";
|
||||||
import { api } from "@/auth/http";
|
import { api } from "@/auth/http";
|
||||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||||
@@ -118,6 +123,11 @@ export default function FuelPurchasePage() {
|
|||||||
const totalCost = formData.liters * formData.costPerLiter;
|
const totalCost = formData.liters * formData.costPerLiter;
|
||||||
|
|
||||||
// Aggregate stats (guarded against divide-by-zero when there are no purchases)
|
// 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(
|
const totalLiters = (purchasesData as FuelPurchase[]).reduce(
|
||||||
(sum, p) => sum + Number(p.liters),
|
(sum, p) => sum + Number(p.liters),
|
||||||
0
|
0
|
||||||
@@ -185,6 +195,18 @@ export default function FuelPurchasePage() {
|
|||||||
|
|
||||||
{/* Purchases Table */}
|
{/* Purchases Table */}
|
||||||
<Card withBorder>
|
<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 striped highlightOnHover>
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
@@ -215,7 +237,7 @@ export default function FuelPurchasePage() {
|
|||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
) : null}
|
) : null}
|
||||||
{(purchasesData as FuelPurchase[])?.map((purchase) => (
|
{controls.pagedRows.map((purchase) => (
|
||||||
<Table.Tr key={purchase.id}>
|
<Table.Tr key={purchase.id}>
|
||||||
<Table.Td>{(purchase as any).vehicle?.registrationNumber || (purchase as any).vehicle?.plateNumber || purchase.vehicleId}</Table.Td>
|
<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>
|
<Table.Td>{new Date(purchase.purchaseDate).toLocaleDateString()}</Table.Td>
|
||||||
@@ -230,6 +252,13 @@ export default function FuelPurchasePage() {
|
|||||||
))}
|
))}
|
||||||
</Table.Tbody>
|
</Table.Tbody>
|
||||||
</Table>
|
</Table>
|
||||||
|
<RuleEngineListFooter
|
||||||
|
pagination={controls.pagination}
|
||||||
|
pageCount={controls.pageCount}
|
||||||
|
totalCount={controls.totalCount}
|
||||||
|
itemLabel="purchases"
|
||||||
|
onPaginationChange={controls.setPagination}
|
||||||
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Modal */}
|
{/* Modal */}
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
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 { useToast } from "@/hooks/use-toast";
|
||||||
import {
|
import {
|
||||||
incidentsService,
|
incidentsService,
|
||||||
@@ -161,6 +166,10 @@ export default function IncidentsPage() {
|
|||||||
})) || [];
|
})) || [];
|
||||||
|
|
||||||
const incidents = incidentsData as Incident[];
|
const incidents = incidentsData as Incident[];
|
||||||
|
const controls = useListControls(incidents, {
|
||||||
|
searchKeys: ["type", "severity", "status"],
|
||||||
|
dateKey: "occurredAt",
|
||||||
|
});
|
||||||
const totalCount = incidents.length;
|
const totalCount = incidents.length;
|
||||||
const openCount = incidents.filter((i) => OPEN_STATUSES.includes(i.status)).length;
|
const openCount = incidents.filter((i) => OPEN_STATUSES.includes(i.status)).length;
|
||||||
const underReviewCount = incidents.filter((i) => i.status === "UNDER_REVIEW").length;
|
const underReviewCount = incidents.filter((i) => i.status === "UNDER_REVIEW").length;
|
||||||
@@ -237,6 +246,18 @@ export default function IncidentsPage() {
|
|||||||
|
|
||||||
{/* Incidents Table */}
|
{/* Incidents Table */}
|
||||||
<Card withBorder>
|
<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 striped highlightOnHover>
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
@@ -267,7 +288,7 @@ export default function IncidentsPage() {
|
|||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
) : null}
|
) : null}
|
||||||
{incidents.map((incident) => (
|
{controls.pagedRows.map((incident) => (
|
||||||
<Table.Tr key={incident.id}>
|
<Table.Tr key={incident.id}>
|
||||||
<Table.Td>{new Date(incident.occurredAt).toLocaleDateString()}</Table.Td>
|
<Table.Td>{new Date(incident.occurredAt).toLocaleDateString()}</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
@@ -294,6 +315,13 @@ export default function IncidentsPage() {
|
|||||||
))}
|
))}
|
||||||
</Table.Tbody>
|
</Table.Tbody>
|
||||||
</Table>
|
</Table>
|
||||||
|
<RuleEngineListFooter
|
||||||
|
pagination={controls.pagination}
|
||||||
|
pageCount={controls.pageCount}
|
||||||
|
totalCount={controls.totalCount}
|
||||||
|
itemLabel="incidents"
|
||||||
|
onPaginationChange={controls.setPagination}
|
||||||
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Modal */}
|
{/* Modal */}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import { CheckCircle2, Download, Eye, FileText, Printer, Search } from 'lucide-r
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
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 { PageContainer, PageHeader } from '@/components/page';
|
||||||
import { VisualEmptyState, formatDate, formatNumber } from '@/components/warehouses';
|
import { VisualEmptyState, formatDate, formatNumber } from '@/components/warehouses';
|
||||||
@@ -263,6 +265,9 @@ export default function InterchangeDocumentsPage() {
|
|||||||
const [viewId, setViewId] = useState<string | null>(null);
|
const [viewId, setViewId] = useState<string | null>(null);
|
||||||
const filter = useMemo(() => ({ search: search.trim() || undefined }), [search]);
|
const filter = useMemo(() => ({ search: search.trim() || undefined }), [search]);
|
||||||
const { data: documents = [], isLoading } = useInterchangeDocuments(filter);
|
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 acknowledge = useAcknowledgeInterchangeDocument();
|
||||||
const dispute = useDisputeInterchangeDocument();
|
const dispute = useDisputeInterchangeDocument();
|
||||||
|
|
||||||
@@ -435,7 +440,7 @@ export default function InterchangeDocumentsPage() {
|
|||||||
|
|
||||||
<Card withBorder radius="md" padding="lg">
|
<Card withBorder radius="md" padding="lg">
|
||||||
<Group justify="space-between" mb="md">
|
<Group justify="space-between" mb="md">
|
||||||
<Text fw={600}>{documents.length} document(s)</Text>
|
<Text fw={600}>{controls.totalCount} document(s)</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
w={{ base: '100%', sm: 320 }}
|
w={{ base: '100%', sm: 320 }}
|
||||||
leftSection={<Search size={16} />}
|
leftSection={<Search size={16} />}
|
||||||
@@ -445,6 +450,19 @@ export default function InterchangeDocumentsPage() {
|
|||||||
/>
|
/>
|
||||||
</Group>
|
</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 ? (
|
{!isLoading && documents.length === 0 ? (
|
||||||
<VisualEmptyState
|
<VisualEmptyState
|
||||||
variant="container"
|
variant="container"
|
||||||
@@ -454,9 +472,10 @@ export default function InterchangeDocumentsPage() {
|
|||||||
) : (
|
) : (
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={documentColumns}
|
columns={documentColumns}
|
||||||
data={documents}
|
data={controls.pagedRows}
|
||||||
status={isLoading ? 'loading' : 'success'}
|
status={isLoading ? 'loading' : 'success'}
|
||||||
containerClassName="border-0 shadow-none"
|
containerClassName="border-0 shadow-none"
|
||||||
|
{...controls.tableProps}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useMemo, useState } from "react";
|
|||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Badge,
|
Badge,
|
||||||
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Center,
|
Center,
|
||||||
Group,
|
Group,
|
||||||
@@ -12,10 +13,16 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { AlertTriangle, PackageCheck, TrainFront, Warehouse } from "lucide-react";
|
import { AlertTriangle, PackageCheck, PackageOpen, TrainFront, Warehouse } from "lucide-react";
|
||||||
|
|
||||||
import { PageContainer, PageHeader } from "@/components/page";
|
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 { api } from "@/services/api";
|
||||||
import type { IntercityRideAlongRow } from "@/types/trainScheduling";
|
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[] }) {
|
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) {
|
if (rows.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Alert variant="light" color="gray">
|
<Alert variant="light" color="gray">
|
||||||
@@ -95,6 +140,7 @@ function Rows({ rows }: { rows: IntercityRideAlongRow[] }) {
|
|||||||
<Table.Th ta="right">Weight</Table.Th>
|
<Table.Th ta="right">Weight</Table.Th>
|
||||||
<Table.Th>GRN</Table.Th>
|
<Table.Th>GRN</Table.Th>
|
||||||
<Table.Th>Status</Table.Th>
|
<Table.Th>Status</Table.Th>
|
||||||
|
<Table.Th />
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
@@ -151,6 +197,38 @@ function Rows({ rows }: { rows: IntercityRideAlongRow[] }) {
|
|||||||
{r.status}
|
{r.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Table.Td>
|
</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.Tr>
|
||||||
))}
|
))}
|
||||||
</Table.Tbody>
|
</Table.Tbody>
|
||||||
@@ -205,6 +283,15 @@ export default function IntercityPage() {
|
|||||||
[rows],
|
[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 (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -283,19 +370,40 @@ export default function IntercityPage() {
|
|||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
|
|
||||||
<Tabs.Panel value="waiting">
|
<ListControls
|
||||||
<Rows rows={waiting} />
|
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>
|
||||||
<Tabs.Panel value="riding">
|
<Tabs.Panel value="riding" keepMounted={false}>
|
||||||
<Rows rows={riding} />
|
<Rows rows={controls.pagedRows} />
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
<Tabs.Panel value="done">
|
<Tabs.Panel value="done" keepMounted={false}>
|
||||||
<Rows rows={done} />
|
<Rows rows={controls.pagedRows} />
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
|
<RuleEngineListFooter
|
||||||
|
pagination={controls.pagination}
|
||||||
|
pageCount={controls.pageCount}
|
||||||
|
totalCount={controls.totalCount}
|
||||||
|
itemLabel="bookings"
|
||||||
|
onPaginationChange={controls.setPagination}
|
||||||
|
/>
|
||||||
<Text size="xs" c="dimmed" mt="sm">
|
<Text size="xs" c="dimmed" mt="sm">
|
||||||
Loading and unloading happen on the train's schedule page, where the ride-along
|
Load and Unload appear on a row while its train is recorded at that yard ("train
|
||||||
panel confirms the train is at the yard.
|
here"); the same actions also live on the train's schedule page.
|
||||||
</Text>
|
</Text>
|
||||||
</Card>
|
</Card>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ import {
|
|||||||
useInventoryInquiry,
|
useInventoryInquiry,
|
||||||
useWarehouses,
|
useWarehouses,
|
||||||
} from '@/hooks/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';
|
import type { InventoryInquiryFilter, InventoryInquiryResult, InventoryStatus } from '@/types/warehouse';
|
||||||
|
|
||||||
export default function InventoryInquiryPage() {
|
export default function InventoryInquiryPage() {
|
||||||
@@ -29,6 +34,11 @@ export default function InventoryInquiryPage() {
|
|||||||
const { data, isFetching } = useInventoryInquiry(applied);
|
const { data, isFetching } = useInventoryInquiry(applied);
|
||||||
const results = data ?? [];
|
const results = data ?? [];
|
||||||
|
|
||||||
|
const controls = useListControls(results, {
|
||||||
|
searchKeys: ['containerNumber', 'bookingReference', 'customerName', 'cargoDescription', 'locationSummary'],
|
||||||
|
dateKey: 'arrivedAt',
|
||||||
|
});
|
||||||
|
|
||||||
const warehouseOptions = useMemo(
|
const warehouseOptions = useMemo(
|
||||||
() => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })),
|
() => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })),
|
||||||
[warehousesQuery.data],
|
[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."
|
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>
|
</Card>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Badge, Card, Group, Text } from '@mantine/core';
|
import { Badge, Card, Group, Text } from '@mantine/core';
|
||||||
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
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 { PageContainer, PageHeader } from '@/components/page';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
@@ -67,6 +69,10 @@ export default function LoadedInventoryPage() {
|
|||||||
api.warehouses.loadings.queryOptions({ input: {} }),
|
api.warehouses.loadings.queryOptions({ input: {} }),
|
||||||
);
|
);
|
||||||
const loadings = data ?? [];
|
const loadings = data ?? [];
|
||||||
|
const controls = useListControls(loadings, {
|
||||||
|
searchKeys: ['wagonNumber'],
|
||||||
|
dateKey: 'loadedAt',
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
@@ -83,12 +89,27 @@ export default function LoadedInventoryPage() {
|
|||||||
description="Once items are loaded onto a wagon, their records show here."
|
description="Once items are loaded onto a wagon, their records show here."
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<DataTable
|
<>
|
||||||
columns={columns}
|
<ListControls
|
||||||
data={loadings}
|
search={controls.search}
|
||||||
status={isLoading ? 'loading' : 'success'}
|
onSearchChange={controls.setSearch}
|
||||||
containerClassName="border-0 shadow-none"
|
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>
|
</Card>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
|
|||||||
@@ -7,12 +7,15 @@ import {
|
|||||||
SegmentedControl,
|
SegmentedControl,
|
||||||
Table,
|
Table,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { Search } from "lucide-react";
|
|
||||||
|
|
||||||
import { PageContainer, PageHeader } from "@/components/page";
|
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 { useTrucksOnSite } from "@/hooks/useWarehouses";
|
||||||
import type { TruckOnSite } from "@/types/warehouse";
|
import type { TruckOnSite } from "@/types/warehouse";
|
||||||
|
|
||||||
@@ -148,20 +151,21 @@ export default function TrucksOnSitePage() {
|
|||||||
scopeParam === "ON_SITE" || scopeParam === "INBOUND" ? scopeParam : "ALL",
|
scopeParam === "ON_SITE" || scopeParam === "INBOUND" ? scopeParam : "ALL",
|
||||||
);
|
);
|
||||||
const [source, setSource] = useState<"ALL" | "CUSTOMER" | "EDR">("ALL");
|
const [source, setSource] = useState<"ALL" | "CUSTOMER" | "EDR">("ALL");
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
|
|
||||||
const rows = useMemo(() => {
|
// Scope/source are page filters and run first; the shared control then does
|
||||||
const term = search.trim().toLowerCase();
|
// search + arrival-date range + pagination over what they leave.
|
||||||
return trucks
|
const scoped = useMemo(
|
||||||
.filter((t) => scope === "ALL" || t.status === scope)
|
() =>
|
||||||
.filter((t) => source === "ALL" || t.source === source)
|
trucks
|
||||||
.filter((t) =>
|
.filter((t) => scope === "ALL" || t.status === scope)
|
||||||
!term
|
.filter((t) => source === "ALL" || t.source === source),
|
||||||
? true
|
[trucks, scope, source],
|
||||||
: [t.plateNumber, t.driverName, t.bookingReference, t.customerName, t.containers]
|
);
|
||||||
.some((field) => field?.toLowerCase().includes(term)),
|
|
||||||
);
|
const controls = useListControls(scoped, {
|
||||||
}, [trucks, scope, source, search]);
|
searchKeys: ["plateNumber", "driverName", "bookingReference", "customerName", "containers"],
|
||||||
|
dateKey: "arrivedAt",
|
||||||
|
});
|
||||||
|
|
||||||
const onSiteCount = trucks.filter((t) => t.status === "ON_SITE").length;
|
const onSiteCount = trucks.filter((t) => t.status === "ON_SITE").length;
|
||||||
const inboundCount = trucks.length - onSiteCount;
|
const inboundCount = trucks.length - onSiteCount;
|
||||||
@@ -198,17 +202,35 @@ export default function TrucksOnSitePage() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
<TextInput
|
|
||||||
size="xs"
|
|
||||||
w={280}
|
|
||||||
placeholder="Plate, driver, booking, container…"
|
|
||||||
leftSection={<Search size={14} />}
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
</Group>
|
</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>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Badge,
|
Badge,
|
||||||
@@ -15,9 +15,11 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
} from '@mantine/core';
|
} 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 { useNavigate } from 'react-router-dom';
|
||||||
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
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 { PageContainer, PageHeader } from '@/components/page';
|
||||||
import { AccrualDashboard } from '@/components/warehouses';
|
import { AccrualDashboard } from '@/components/warehouses';
|
||||||
@@ -50,7 +52,6 @@ const fmtDate = (d?: string | null) => (d ? new Date(d).toLocaleDateString() : '
|
|||||||
|
|
||||||
export default function WarehouseInvoicesPage() {
|
export default function WarehouseInvoicesPage() {
|
||||||
const [status, setStatus] = useState<WarehouseInvoiceStatus | null>(null);
|
const [status, setStatus] = useState<WarehouseInvoiceStatus | null>(null);
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const [detailId, setDetailId] = useState<string | null>(null);
|
const [detailId, setDetailId] = useState<string | null>(null);
|
||||||
|
|
||||||
const { data, isLoading } = useQuery(
|
const { data, isLoading } = useQuery(
|
||||||
@@ -60,11 +61,10 @@ export default function WarehouseInvoicesPage() {
|
|||||||
);
|
);
|
||||||
const invoices = data ?? [];
|
const invoices = data ?? [];
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const controls = useListControls(invoices, {
|
||||||
const q = search.trim().toLowerCase();
|
searchKeys: ['invoiceNumber', 'bookingReference', 'customerName', 'containerNumber'],
|
||||||
if (!q) return invoices;
|
dateKey: 'issuedAt',
|
||||||
return invoices.filter((i) => [i.invoiceNumber, i.bookingId, i.customerId].join(' ').toLowerCase().includes(q));
|
});
|
||||||
}, [invoices, search]);
|
|
||||||
|
|
||||||
const invoiceColumns: ColumnDef<WarehouseFeeInvoice>[] = [
|
const invoiceColumns: ColumnDef<WarehouseFeeInvoice>[] = [
|
||||||
{
|
{
|
||||||
@@ -131,30 +131,36 @@ export default function WarehouseInvoicesPage() {
|
|||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<Group justify="space-between" mb="md" wrap="wrap">
|
<ListControls
|
||||||
<TextInput
|
search={controls.search}
|
||||||
placeholder="Search invoice no / booking / customer"
|
onSearchChange={controls.setSearch}
|
||||||
leftSection={<Search size={16} />}
|
searchPlaceholder="Search invoice no / booking / customer"
|
||||||
value={search}
|
dateFrom={controls.dateFrom}
|
||||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
onDateFromChange={controls.setDateFrom}
|
||||||
w={320}
|
dateTo={controls.dateTo}
|
||||||
/>
|
onDateToChange={controls.setDateTo}
|
||||||
<Select
|
dateLabel="Issued"
|
||||||
placeholder="All statuses"
|
hasFilters={controls.hasFilters}
|
||||||
data={WAREHOUSE_INVOICE_STATUSES.map((s) => ({ value: s, label: s.replace(/_/g, ' ') }))}
|
onReset={controls.reset}
|
||||||
value={status}
|
>
|
||||||
onChange={(v) => setStatus((v as WarehouseInvoiceStatus) ?? null)}
|
<Select
|
||||||
clearable
|
label="Status"
|
||||||
w={200}
|
placeholder="All statuses"
|
||||||
/>
|
data={WAREHOUSE_INVOICE_STATUSES.map((s) => ({ value: s, label: s.replace(/_/g, ' ') }))}
|
||||||
</Group>
|
value={status}
|
||||||
|
onChange={(v) => setStatus((v as WarehouseInvoiceStatus) ?? null)}
|
||||||
|
clearable
|
||||||
|
w={200}
|
||||||
|
/>
|
||||||
|
</ListControls>
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={invoiceColumns}
|
columns={invoiceColumns}
|
||||||
data={filtered}
|
data={controls.pagedRows}
|
||||||
status={isLoading ? 'loading' : 'success'}
|
status={isLoading ? 'loading' : 'success'}
|
||||||
emptyMessage="No invoices found."
|
emptyMessage="No invoices found."
|
||||||
containerClassName="border-0 shadow-none"
|
containerClassName="border-0 shadow-none"
|
||||||
|
{...controls.tableProps}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ import {
|
|||||||
WarehouseTable,
|
WarehouseTable,
|
||||||
type WarehouseView,
|
type WarehouseView,
|
||||||
} from '@/components/warehouses';
|
} 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 { useWarehouses } from '@/hooks/useWarehouses';
|
||||||
import type { Warehouse, WarehouseFilter } from '@/types/warehouse';
|
import type { Warehouse, WarehouseFilter } from '@/types/warehouse';
|
||||||
|
|
||||||
@@ -31,6 +36,11 @@ export default function WarehouseListPage() {
|
|||||||
const { data, isLoading, isError } = useWarehouses(queryFilter);
|
const { data, isLoading, isError } = useWarehouses(queryFilter);
|
||||||
const warehouses = data ?? [];
|
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 = () => {
|
const openCreate = () => {
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
@@ -57,6 +67,19 @@ export default function WarehouseListPage() {
|
|||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<WarehouseFilters filter={filter} onChange={setFilter} view={view} onViewChange={setView} />
|
<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 ? (
|
{isLoading ? (
|
||||||
<Center py="xl">
|
<Center py="xl">
|
||||||
<Loader />
|
<Loader />
|
||||||
@@ -65,10 +88,21 @@ export default function WarehouseListPage() {
|
|||||||
<Text c="red" ta="center" py="xl">
|
<Text c="red" ta="center" py="xl">
|
||||||
Failed to load warehouses.
|
Failed to load warehouses.
|
||||||
</Text>
|
</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>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import { Info, Pencil, Plus, Trash2 } from 'lucide-react';
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
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 { PageContainer, PageHeader } from '@/components/page';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
@@ -140,6 +142,10 @@ function AllocationRules() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const rules = data ?? [];
|
const rules = data ?? [];
|
||||||
|
const controls = useListControls(rules, {
|
||||||
|
searchKeys: ['name', 'targetYardCode', 'targetWarehouseCode', 'targetZoneCode', 'freightType', 'tradeDirection', 'cargoTypeCode', 'storageType'],
|
||||||
|
dateKey: 'createdAt',
|
||||||
|
});
|
||||||
const yardOptions = yards
|
const yardOptions = yards
|
||||||
.filter((yard) => yard.code)
|
.filter((yard) => yard.code)
|
||||||
.map((yard) => ({
|
.map((yard) => ({
|
||||||
@@ -254,7 +260,7 @@ function AllocationRules() {
|
|||||||
<>
|
<>
|
||||||
<Group justify="space-between" mb="sm">
|
<Group justify="space-between" mb="sm">
|
||||||
<Text c="dimmed" size="sm">
|
<Text c="dimmed" size="sm">
|
||||||
{rules.length} rule(s) matched by ascending priority
|
{controls.totalCount} rule(s) matched by ascending priority
|
||||||
</Text>
|
</Text>
|
||||||
<Button leftSection={<Plus size={16} />} onClick={() => { resetForm(); setOpen(true); }}>
|
<Button leftSection={<Plus size={16} />} onClick={() => { resetForm(); setOpen(true); }}>
|
||||||
New allocation rule
|
New allocation rule
|
||||||
@@ -267,12 +273,26 @@ function AllocationRules() {
|
|||||||
</Text>
|
</Text>
|
||||||
</Alert>
|
</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
|
<DataTable
|
||||||
columns={allocationColumns}
|
columns={allocationColumns}
|
||||||
data={rules}
|
data={controls.pagedRows}
|
||||||
status={isLoading ? 'loading' : 'success'}
|
status={isLoading ? 'loading' : 'success'}
|
||||||
emptyMessage="No allocation rules yet. Create one to route inventory to a yard automatically."
|
emptyMessage="No allocation rules yet. Create one to route inventory to a yard automatically."
|
||||||
containerClassName="border-0 shadow-none"
|
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">
|
<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',
|
currency: 'USD',
|
||||||
});
|
});
|
||||||
const rules = data ?? [];
|
const rules = data ?? [];
|
||||||
|
const controls = useListControls(rules, {
|
||||||
|
searchKeys: ['name', 'ruleType', 'freightType', 'tradeDirection', 'cargoTypeCode', 'containerType', 'vehicleType', 'currency'],
|
||||||
|
dateKey: 'createdAt',
|
||||||
|
});
|
||||||
const cargoTypeOptions = codeOptions(cargoTypes);
|
const cargoTypeOptions = codeOptions(cargoTypes);
|
||||||
const containerTypeOptions = codeOptions(containerTypes);
|
const containerTypeOptions = codeOptions(containerTypes);
|
||||||
const isBulkRule = form.freightType === 'BULK';
|
const isBulkRule = form.freightType === 'BULK';
|
||||||
@@ -647,19 +671,33 @@ function FeeRules() {
|
|||||||
<>
|
<>
|
||||||
<Group justify="space-between" mb="sm">
|
<Group justify="space-between" mb="sm">
|
||||||
<Text c="dimmed" size="sm">
|
<Text c="dimmed" size="sm">
|
||||||
{rules.length} rule(s) - most specific match applies
|
{controls.totalCount} rule(s) - most specific match applies
|
||||||
</Text>
|
</Text>
|
||||||
<Button leftSection={<Plus size={16} />} onClick={() => { resetForm(); setOpen(true); }}>
|
<Button leftSection={<Plus size={16} />} onClick={() => { resetForm(); setOpen(true); }}>
|
||||||
New fee rule
|
New fee rule
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</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
|
<DataTable
|
||||||
columns={feeColumns}
|
columns={feeColumns}
|
||||||
data={rules}
|
data={controls.pagedRows}
|
||||||
status={isLoading ? 'loading' : 'success'}
|
status={isLoading ? 'loading' : 'success'}
|
||||||
emptyMessage="No storage or demurrage fee rules yet."
|
emptyMessage="No storage or demurrage fee rules yet."
|
||||||
containerClassName="border-0 shadow-none"
|
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">
|
<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;
|
targetZoneCode?: string | null;
|
||||||
storageType?: string | null;
|
storageType?: string | null;
|
||||||
isActive: boolean;
|
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 = [
|
export const FEE_RULE_TYPES = [
|
||||||
'STORAGE_FEE',
|
'STORAGE_FEE',
|
||||||
@@ -813,8 +815,10 @@ export interface FeeRule {
|
|||||||
tiers?: FeeRuleTier[];
|
tiers?: FeeRuleTier[];
|
||||||
currency: string;
|
currency: string;
|
||||||
isActive: boolean;
|
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 {
|
export interface FeeRuleTier {
|
||||||
fromDay: number;
|
fromDay: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user