Merge pull request #1303 from Tria-plc/freight_feature/usermanagement

feat: enhance booking and scheduling features with shipping line supp…
This commit is contained in:
marshal
2026-08-16 11:20:09 +03:00
committed by GitHub
8 changed files with 225 additions and 75 deletions

View File

@@ -16,6 +16,7 @@ import { computeFacets, FacetBucket } from '../../common/utils/facets.util';
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util'; import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
import { ContainerType } from '../rule-engine/entities/container-type.entity'; import { ContainerType } from '../rule-engine/entities/container-type.entity';
import { Contract } from '../contracts/entities/contract.entity'; import { Contract } from '../contracts/entities/contract.entity';
import { ShippingLineCompany } from '../shipping-lines/entities/shipping-line-company.entity';
import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity'; import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity';
import { ContractRoute } from '../contracts/entities/contract-route.entity'; import { ContractRoute } from '../contracts/entities/contract-route.entity';
import { applyDirectionScope } from '../user-trade-access/trade-scope.util'; import { applyDirectionScope } from '../user-trade-access/trade-scope.util';
@@ -783,16 +784,20 @@ export class BookingsRepository extends BaseRepository<Booking> {
// by TypeORM and crashes). // by TypeORM and crashes).
.leftJoin(Contract, 'contract', 'contract.id = booking.contract_id') .leftJoin(Contract, 'contract', 'contract.id = booking.contract_id')
.addSelect('contract.reference', 'contract_reference') .addSelect('contract.reference', 'contract_reference')
// Shipping-line owner name for search only (no relation, see entity) —
// the list rows get `shippingLineCompany` hydrated by the service.
.leftJoin(ShippingLineCompany, 'slc', 'slc.id = booking.shipping_line_company_id')
.where('booking.deleted_at IS NULL'); .where('booking.deleted_at IS NULL');
this.applyListFilters(qb, options); this.applyListFilters(qb, options);
// Free-text search spans joined columns (company, contract) that only this // Free-text search spans joined columns (company, shipping line, contract)
// list query joins — so it lives here, not in applyListFilters (shared // that only this list query joins — so it lives here, not in
// with getListSummaryMetrics, whose query builder has no joins). // applyListFilters (shared with getListSummaryMetrics, whose query builder
// has no joins).
if (options.search) { if (options.search) {
qb.andWhere( qb.andWhere(
'(booking.reference ILIKE :search OR company.name ILIKE :search OR contract.reference ILIKE :search)', '(booking.reference ILIKE :search OR company.name ILIKE :search OR slc.name ILIKE :search OR contract.reference ILIKE :search)',
{ search: `%${options.search}%` }, { search: `%${options.search}%` },
); );
} }

View File

@@ -148,7 +148,7 @@ export class FilterBookingDto {
@ApiPropertyOptional({ @ApiPropertyOptional({
description: description:
'Free-text search across booking reference, company name, and contract reference.', 'Free-text search across booking reference, customer / shipping-line company name, and contract reference.',
}) })
@IsOptional() @IsOptional()
@Transform(({ value }) => @Transform(({ value }) =>

View File

@@ -72,6 +72,8 @@ describe('SchedulingRescheduleService', () => {
previewTrainSchedule: jest.fn(), previewTrainSchedule: jest.fn(),
unassignBooking: jest.fn(), unassignBooking: jest.fn(),
assignBookingsToSchedule: jest.fn(), assignBookingsToSchedule: jest.fn(),
windowFieldsForNewDeparture: jest.fn().mockResolvedValue({}),
emitWindowState: jest.fn().mockResolvedValue(undefined),
}; };
schedulingRescheduleRepository = { schedulingRescheduleRepository = {
createEvent: jest.fn().mockResolvedValue({ id: 'event-1' }), createEvent: jest.fn().mockResolvedValue({ id: 'event-1' }),
@@ -213,6 +215,13 @@ describe('SchedulingRescheduleService', () => {
}); });
trainSchedulesRepository.updateStatus.mockResolvedValue(undefined); trainSchedulesRepository.updateStatus.mockResolvedValue(undefined);
trainSchedulingService.assignBookingsToSchedule.mockResolvedValue({ id: 'sched-1' }); trainSchedulingService.assignBookingsToSchedule.mockResolvedValue({ id: 'sched-1' });
// An OPEN window's close must follow the new departure (this is the
// portal's "closes in" countdown) — the derived fields ride along with the
// date write.
const newCloses = new Date('2099-06-22T08:00:00.000Z');
trainSchedulingService.windowFieldsForNewDeparture.mockResolvedValue({
windowClosesAt: newCloses,
});
const result = await service.maintenanceReschedule( const result = await service.maintenanceReschedule(
'sched-1', 'sched-1',
@@ -228,9 +237,16 @@ describe('SchedulingRescheduleService', () => {
expect(trainSchedulesRepository.updateStatus).toHaveBeenCalledWith( expect(trainSchedulesRepository.updateStatus).toHaveBeenCalledWith(
'sched-1', 'sched-1',
'DRAFT', 'DRAFT',
{ scheduledDepartureDate: new Date('2099-06-22T10:00:00.000Z') }, {
scheduledDepartureDate: new Date('2099-06-22T10:00:00.000Z'),
windowClosesAt: newCloses,
},
txManager, txManager,
); );
expect(trainSchedulingService.windowFieldsForNewDeparture).toHaveBeenCalledWith(
schedule,
new Date('2099-06-22T10:00:00.000Z'),
);
expect(schedulingRescheduleRepository.createEvent).toHaveBeenCalledWith( expect(schedulingRescheduleRepository.createEvent).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
trigger: 'TRAIN_MAINTENANCE', trigger: 'TRAIN_MAINTENANCE',

View File

@@ -227,17 +227,25 @@ export class SchedulingRescheduleService {
// through this manager without editing TrainSchedulingService. A failure // through this manager without editing TrainSchedulingService. A failure
// between those steps and this block can still leave partial state; a human // between those steps and this block can still leave partial state; a human
// must finish the full cross-service transaction threading. // must finish the full cross-service transaction threading.
// The booking window must follow the new departure (an OPEN window's
// "closes in" countdown is capped at departure close offset; PRE_WINDOW /
// DONE re-derive their open/close). Same math as maintenanceReschedule.
const windowFields = newDeparture
? await this.trainSchedulingService.windowFieldsForNewDeparture(
schedule,
newDeparture,
)
: {};
await this.dataSource.transaction(async (manager) => { await this.dataSource.transaction(async (manager) => {
if (newDeparture) { if (newDeparture) {
// M7: raw write of scheduledDepartureDate. We deliberately do NOT // Raw write of scheduledDepartureDate: updateScheduleDate only permits a
// delegate to TrainSchedulingService.updateScheduleDate, which only // date change while windowPhase === 'PRE_WINDOW' and would reject
// permits a date change while windowPhase === 'PRE_WINDOW' and would // reschedules of already-open (SCHEDULED) trains.
// reject reschedules of already-open (SCHEDULED) trains. Consequence:
// the booking-window fields are NOT re-derived for the new date here.
await this.trainSchedulesRepository.updateStatus( await this.trainSchedulesRepository.updateStatus(
scheduleId, scheduleId,
schedule.status as TrainScheduleStatus, schedule.status as TrainScheduleStatus,
{ scheduledDepartureDate: newDeparture }, { scheduledDepartureDate: newDeparture, ...windowFields },
manager, manager,
); );
} }
@@ -263,6 +271,7 @@ export class SchedulingRescheduleService {
// `newDeparture` is null when the date was unchanged, so retained customers // `newDeparture` is null when the date was unchanged, so retained customers
// are not falsely told the train was rescheduled. // are not falsely told the train was rescheduled.
await this.notifyRescheduleOutcome(dto, newDeparture); await this.notifyRescheduleOutcome(dto, newDeparture);
if (newDeparture) void this.trainSchedulingService.emitWindowState(scheduleId);
return { plan, schedule: assignResult }; return { plan, schedule: assignResult };
} }

View File

@@ -1821,5 +1821,31 @@ describe('TrainSchedulingService', () => {
expect(written.windowPhase).toBeUndefined(); expect(written.windowPhase).toBeUndefined();
expect(written.windowOpensAt).toBeUndefined(); expect(written.windowOpensAt).toBeUndefined();
}); });
it('moves an OPEN export close to the new departure but keeps the open', async () => {
const opensAt = new Date('2027-06-19T03:00:00.000Z');
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(
doneExportSchedule({
windowPhase: 'OPEN',
bookingWindowStatus: 'OPEN',
windowOpensAt: opensAt,
windowClosesAt: new Date('2027-06-20T03:00:00.000Z'),
}),
);
// Departure pushed 3 days later → close = new departure 120min; the
// open customers already booked against stays untouched.
await service.maintenanceReschedule('sch-done', {
newDepartureDate: '2027-06-23T05:00:00.000Z',
} as never);
const written = scheduleUpdate.mock.calls[0][1];
expect(written.scheduledDepartureDate).toEqual(
new Date('2027-06-23T05:00:00.000Z'),
);
expect(written.windowClosesAt).toEqual(new Date('2027-06-23T03:00:00.000Z'));
expect(written.windowOpensAt).toBeUndefined();
expect(written.windowPhase).toBeUndefined();
});
}); });
}); });

View File

@@ -164,6 +164,8 @@ import {
} from '../booking-batch.constants'; } from '../booking-batch.constants';
import { orderConsistWagons } from '../consist-order.util'; import { orderConsistWagons } from '../consist-order.util';
import { import {
bookingCloseCutoff,
clampCloseToOfficeHours,
computeExportWindowTimes, computeExportWindowTimes,
computeImportWindowTimes, computeImportWindowTimes,
earliestSchedulableDeparture, earliestSchedulableDeparture,
@@ -473,7 +475,7 @@ export class TrainSchedulingService {
* used for lifecycle changes outside the window tick (create, cancel, * used for lifecycle changes outside the window tick (create, cancel,
* finalize, restamp). A push failure must never break the mutation. * finalize, restamp). A push failure must never break the mutation.
*/ */
private async emitWindowState(scheduleId: string): Promise<void> { async emitWindowState(scheduleId: string): Promise<void> {
try { try {
const fresh = await this.trainSchedulesRepository.findById(scheduleId); const fresh = await this.trainSchedulesRepository.findById(scheduleId);
// Dedicated shipping-line departures are never announced to the portal — // Dedicated shipping-line departures are never announced to the portal —
@@ -1159,66 +1161,73 @@ export class TrainSchedulingService {
} }
/** /**
* Maintenance reschedule: the admin moves a train (with everything aboard) to * Booking-window fields that must follow a train's departure moving to
* a new departure. Unlike {@link updateScheduleDate} this runs at ANY window * `departure` (any window phase). Shared by every reschedule path so the
* phase and inside the booking lead window — a maintenance move is an * "closes in" countdown always tracks the real departure.
* operational fact, not a planning choice. What moves and what stays:
* *
* - MOVES: scheduledDepartureDate; scheduledArrivalDate (same delta); every * PRE_WINDOW: the stamped open/close were derived from the old departure
* aboard/targeted booking's scheduledDate (the day-pool queries key on it, * and the window hasn't opened yet, so re-derive them from the schedule's
* so a booking left on the old day would fall out of its own train's pool). * own rule snapshot against the new date (joining the target day's route
* - STAYS: train set, wagon assignments, schedule↔booking links, route, * group timeline when one exists, exactly like updateScheduleDate).
* maxWagons, and the window RULE snapshot. Stamped window times are only
* re-derived for PRE_WINDOW schedules (their window hasn't run yet); a
* schedule mid- or post-window keeps its timeline untouched.
* *
* Customers of every moved booking are notified (maintenanceMoved). * DONE: the window already finished (e.g. the close offset hit and then the
* train was moved to a later departure). The window must follow the new
* departure, so it REOPENS: re-derive open/close the same way, reset the
* phase to PRE_WINDOW and clamp a past open into the present so the tick
* opens it immediately. A FULL train stays closed — there is nothing left
* to sell — and so does one whose re-derived window would already be over.
*
* OPEN: customers are already booking against the open they were shown, so
* the open stays put — but the close was capped at the OLD departure's
* cutoff, so it must follow the new one (import: open + duration under
* office hours, capped at the cutoff; export: the cutoff itself). Moving
* the train later extends the "closes in" countdown, moving it earlier
* shortens it (a close now in the past is picked up by the next tick).
*
* DOC_REVIEW/PAYMENT keep their running timeline.
*/ */
async maintenanceReschedule( async windowFieldsForNewDeparture(
id: string, schedule: TrainSchedule,
dto: MaintenanceRescheduleDto, departure: Date,
): Promise<TrainSchedule> { ): Promise<
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id); Partial<
if (!schedule) { Pick<
throw new NotFoundException(`Train schedule ${id} not found`); TrainSchedule,
} | 'windowOpensAt'
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) { | 'windowClosesAt'
throw new BadRequestException( | 'windowPhase'
`Cannot reschedule a ${schedule.status.toLowerCase()} train`, | 'bookingWindowStatus'
); | 'docReviewCompletedAt'
} | 'docReviewEndsAt'
| 'paymentPhaseEndsAt'
const departure = new Date(dto.newDepartureDate); >
if (Number.isNaN(departure.getTime())) { >
throw new BadRequestException('Invalid departure date.'); > {
}
if (departure.getTime() <= Date.now()) {
throw new BadRequestException('New departure must be in the future.');
}
const deltaMs =
departure.getTime() - new Date(schedule.scheduledDepartureDate).getTime();
const scheduledArrivalDate = schedule.scheduledArrivalDate
? new Date(new Date(schedule.scheduledArrivalDate).getTime() + deltaMs)
: undefined;
// PRE_WINDOW: the stamped open/close were derived from the old departure
// and the window hasn't opened yet, so re-derive them from the schedule's
// own rule snapshot against the new date (joining the target day's route
// group timeline when one exists, exactly like updateScheduleDate).
//
// DONE: the window already finished (e.g. the close offset hit and then the
// train was moved to a later departure). The window must follow the new
// departure, so it REOPENS: re-derive open/close the same way, reset the
// phase to PRE_WINDOW and clamp a past open into the present so the tick
// opens it immediately. A FULL train stays closed — there is nothing left
// to sell — and so does one whose re-derived window would already be over.
//
// Mid-window phases (OPEN/DOC_REVIEW/PAYMENT) keep their running timeline.
const reopenFromDone = const reopenFromDone =
schedule.windowPhase === 'DONE' && schedule.bookingWindowStatus !== 'FULL'; schedule.windowPhase === 'DONE' && schedule.bookingWindowStatus !== 'FULL';
const shiftOpenClose =
schedule.windowPhase === 'OPEN' && schedule.windowOpensAt != null;
const windowFields = const windowFields =
schedule.windowPhase === 'PRE_WINDOW' || reopenFromDone shiftOpenClose
? await (async () => {
const merged = effectiveWindowConfig(
schedule,
await this.getWindowConfig(),
);
const opensAt = schedule.windowOpensAt!;
const cutoff = bookingCloseCutoff(departure, schedule.direction, merged);
let closesAt = cutoff;
if (schedule.direction !== 'EXPORT') {
closesAt = clampCloseToOfficeHours(
opensAt,
new Date(opensAt.getTime() + merged.windowDurationHours * 3_600_000),
merged,
);
if (closesAt.getTime() > cutoff.getTime()) closesAt = cutoff;
}
return { windowClosesAt: closesAt };
})()
: schedule.windowPhase === 'PRE_WINDOW' || reopenFromDone
? await (async () => { ? await (async () => {
const merged = effectiveWindowConfig( const merged = effectiveWindowConfig(
schedule, schedule,
@@ -1266,6 +1275,55 @@ export class TrainSchedulingService {
}; };
})() })()
: {}; : {};
return windowFields;
}
/**
* Maintenance reschedule: the admin moves a train (with everything aboard) to
* a new departure. Unlike {@link updateScheduleDate} this runs at ANY window
* phase and inside the booking lead window — a maintenance move is an
* operational fact, not a planning choice. What moves and what stays:
*
* - MOVES: scheduledDepartureDate; scheduledArrivalDate (same delta); every
* aboard/targeted booking's scheduledDate (the day-pool queries key on it,
* so a booking left on the old day would fall out of its own train's pool).
* - STAYS: train set, wagon assignments, schedule↔booking links, route,
* maxWagons, and the window RULE snapshot. Stamped window times are
* re-derived for PRE_WINDOW schedules (their window hasn't run yet); an
* OPEN schedule keeps its open but its close follows the new departure;
* DOC_REVIEW/PAYMENT keep their timeline untouched.
*
* Customers of every moved booking are notified (maintenanceMoved).
*/
async maintenanceReschedule(
id: string,
dto: MaintenanceRescheduleDto,
): Promise<TrainSchedule> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id);
if (!schedule) {
throw new NotFoundException(`Train schedule ${id} not found`);
}
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
throw new BadRequestException(
`Cannot reschedule a ${schedule.status.toLowerCase()} train`,
);
}
const departure = new Date(dto.newDepartureDate);
if (Number.isNaN(departure.getTime())) {
throw new BadRequestException('Invalid departure date.');
}
if (departure.getTime() <= Date.now()) {
throw new BadRequestException('New departure must be in the future.');
}
const deltaMs =
departure.getTime() - new Date(schedule.scheduledDepartureDate).getTime();
const scheduledArrivalDate = schedule.scheduledArrivalDate
? new Date(new Date(schedule.scheduledArrivalDate).getTime() + deltaMs)
: undefined;
const windowFields = await this.windowFieldsForNewDeparture(schedule, departure);
await this.dataSource.getRepository(TrainSchedule).update(id, { await this.dataSource.getRepository(TrainSchedule).update(id, {
scheduledDepartureDate: departure, scheduledDepartureDate: departure,

View File

@@ -500,7 +500,7 @@ export default function BookingRequestsPage() {
<FilterBar <FilterBar
defs={bookingFilterDefs} defs={bookingFilterDefs}
controls={controls} controls={controls}
searchPlaceholder="Search booking, contract or customer…" searchPlaceholder="Search booking, contract, customer or shipping line…"
viewId="booking-requests" viewId="booking-requests"
/> />
</Box> </Box>

View File

@@ -14,7 +14,7 @@ import { DatePickerInput } from "@mantine/dates";
import { getDateRangePresets } from "@/components/common/dateRangePresets"; import { getDateRangePresets } from "@/components/common/dateRangePresets";
import { useDebouncedValue } from "@mantine/hooks"; import { useDebouncedValue } from "@mantine/hooks";
import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { FileText, Inbox, RefreshCw, Search, User, X } from "lucide-react"; import { FileText, Inbox, RefreshCw, Search, Ship, User, X } from "lucide-react";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
@@ -75,6 +75,12 @@ const OWNERSHIP_OPTIONS = [
{ value: "false", label: "Private" }, { value: "false", label: "Private" },
]; ];
/** Who booked: shipping lines own bookings via `shippingLineCompanyId`, not a customer company. */
const CUSTOMER_KIND_OPTIONS = [
{ value: "SHIPPING_LINE", label: "Shipping line" },
{ value: "CUSTOMER", label: "Customer" },
];
function startOfDayIso(d: Date): string { function startOfDayIso(d: Date): string {
const x = new Date(d); const x = new Date(d);
x.setHours(0, 0, 0, 0); x.setHours(0, 0, 0, 0);
@@ -98,6 +104,7 @@ export default function ClearanceDocumentsPage() {
const [directionFilter, setDirectionFilter] = useState<string | null>(null); const [directionFilter, setDirectionFilter] = useState<string | null>(null);
const [freightTypeFilter, setFreightTypeFilter] = useState<string | null>(null); const [freightTypeFilter, setFreightTypeFilter] = useState<string | null>(null);
const [ownershipFilter, setOwnershipFilter] = useState<string | null>(null); const [ownershipFilter, setOwnershipFilter] = useState<string | null>(null);
const [customerKindFilter, setCustomerKindFilter] = useState<string | null>(null);
const [createdFrom, setCreatedFrom] = useState<Date | null>(null); const [createdFrom, setCreatedFrom] = useState<Date | null>(null);
const [createdTo, setCreatedTo] = useState<Date | null>(null); const [createdTo, setCreatedTo] = useState<Date | null>(null);
const { pagination, setPagination } = usePagination({ pageSize: PAGE_SIZE }); const { pagination, setPagination } = usePagination({ pageSize: PAGE_SIZE });
@@ -118,6 +125,7 @@ export default function ClearanceDocumentsPage() {
directionFilter, directionFilter,
freightTypeFilter, freightTypeFilter,
ownershipFilter, ownershipFilter,
customerKindFilter,
createdFrom, createdFrom,
createdTo, createdTo,
page, page,
@@ -138,6 +146,9 @@ export default function ClearanceDocumentsPage() {
...(ownershipFilter ...(ownershipFilter
? { isGovernment: ownershipFilter as "true" | "false" } ? { isGovernment: ownershipFilter as "true" | "false" }
: {}), : {}),
...(customerKindFilter
? { customerKind: customerKindFilter as "SHIPPING_LINE" | "CUSTOMER" }
: {}),
...(createdFrom ? { createdFrom: startOfDayIso(createdFrom) } : {}), ...(createdFrom ? { createdFrom: startOfDayIso(createdFrom) } : {}),
...(createdTo ? { createdTo: endOfDayIso(createdTo) } : {}), ...(createdTo ? { createdTo: endOfDayIso(createdTo) } : {}),
}), }),
@@ -153,17 +164,29 @@ export default function ClearanceDocumentsPage() {
header: () => <span className={bookingTable.headerCell}>Customer</span>, header: () => <span className={bookingTable.headerCell}>Customer</span>,
cell: ({ row }) => { cell: ({ row }) => {
const b = row.original; const b = row.original;
const customer = b.isGovernment const isShippingLine = Boolean(b.shippingLineCompany ?? b.shippingLineCompanyId);
? (b.governmentInstitution ?? "Government") const customer = isShippingLine
: (b.company?.name ?? ""); ? (b.shippingLineCompany?.name ?? "Shipping line")
: b.isGovernment
? (b.governmentInstitution ?? "Government")
: (b.company?.name ?? "—");
return ( return (
<div className="flex items-center gap-3 py-1.5"> <div className="flex items-center gap-3 py-1.5">
<div className={bookingTable.rowIcon}> <div className={bookingTable.rowIcon}>
<User className="size-4" strokeWidth={1.75} /> {isShippingLine ? (
<Ship className="size-4" strokeWidth={1.75} />
) : (
<User className="size-4" strokeWidth={1.75} />
)}
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<p className="font-medium text-foreground"> <p className="flex items-center gap-1.5 font-medium text-foreground">
{customer} {customer}
{isShippingLine ? (
<Badge variant="secondary" className="h-4 shrink-0 px-1 text-[9px] font-medium">
Shipping line
</Badge>
) : null}
</p> </p>
<p className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground"> <p className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
<FileText className="size-3 shrink-0 opacity-70" /> <FileText className="size-3 shrink-0 opacity-70" />
@@ -267,7 +290,7 @@ export default function ClearanceDocumentsPage() {
<Box px="md" pt="md" pb="sm" w="100%"> <Box px="md" pt="md" pb="sm" w="100%">
<Group justify="space-between" gap="md" wrap="wrap"> <Group justify="space-between" gap="md" wrap="wrap">
<TextInput <TextInput
placeholder="Search booking, contract or customer…" placeholder="Search booking, contract, customer or shipping line…"
leftSection={<Search size={18} />} leftSection={<Search size={18} />}
value={query} value={query}
onChange={(e) => { onChange={(e) => {
@@ -333,6 +356,19 @@ export default function ClearanceDocumentsPage() {
style={{ minWidth: 140 }} style={{ minWidth: 140 }}
aria-label="Filter by freight type" aria-label="Filter by freight type"
/> />
<Select
placeholder="Booked by"
data={CUSTOMER_KIND_OPTIONS}
value={customerKindFilter}
onChange={(v) => {
setCustomerKindFilter(v);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 140 }}
aria-label="Filter by booked by"
/>
<Select <Select
placeholder="Gov / Private" placeholder="Gov / Private"
data={OWNERSHIP_OPTIONS} data={OWNERSHIP_OPTIONS}