mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
@@ -16,6 +16,7 @@ import { computeFacets, FacetBucket } from '../../common/utils/facets.util';
|
||||
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
|
||||
import { ContainerType } from '../rule-engine/entities/container-type.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 { ContractRoute } from '../contracts/entities/contract-route.entity';
|
||||
import { applyDirectionScope } from '../user-trade-access/trade-scope.util';
|
||||
@@ -783,16 +784,20 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
// by TypeORM and crashes).
|
||||
.leftJoin(Contract, 'contract', 'contract.id = booking.contract_id')
|
||||
.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');
|
||||
|
||||
this.applyListFilters(qb, options);
|
||||
|
||||
// Free-text search spans joined columns (company, contract) that only this
|
||||
// list query joins — so it lives here, not in applyListFilters (shared
|
||||
// with getListSummaryMetrics, whose query builder has no joins).
|
||||
// Free-text search spans joined columns (company, shipping line, contract)
|
||||
// that only this list query joins — so it lives here, not in
|
||||
// applyListFilters (shared with getListSummaryMetrics, whose query builder
|
||||
// has no joins).
|
||||
if (options.search) {
|
||||
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}%` },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ export class FilterBookingDto {
|
||||
|
||||
@ApiPropertyOptional({
|
||||
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()
|
||||
@Transform(({ value }) =>
|
||||
|
||||
@@ -72,6 +72,8 @@ describe('SchedulingRescheduleService', () => {
|
||||
previewTrainSchedule: jest.fn(),
|
||||
unassignBooking: jest.fn(),
|
||||
assignBookingsToSchedule: jest.fn(),
|
||||
windowFieldsForNewDeparture: jest.fn().mockResolvedValue({}),
|
||||
emitWindowState: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
schedulingRescheduleRepository = {
|
||||
createEvent: jest.fn().mockResolvedValue({ id: 'event-1' }),
|
||||
@@ -213,6 +215,13 @@ describe('SchedulingRescheduleService', () => {
|
||||
});
|
||||
trainSchedulesRepository.updateStatus.mockResolvedValue(undefined);
|
||||
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(
|
||||
'sched-1',
|
||||
@@ -228,9 +237,16 @@ describe('SchedulingRescheduleService', () => {
|
||||
expect(trainSchedulesRepository.updateStatus).toHaveBeenCalledWith(
|
||||
'sched-1',
|
||||
'DRAFT',
|
||||
{ scheduledDepartureDate: new Date('2099-06-22T10:00:00.000Z') },
|
||||
{
|
||||
scheduledDepartureDate: new Date('2099-06-22T10:00:00.000Z'),
|
||||
windowClosesAt: newCloses,
|
||||
},
|
||||
txManager,
|
||||
);
|
||||
expect(trainSchedulingService.windowFieldsForNewDeparture).toHaveBeenCalledWith(
|
||||
schedule,
|
||||
new Date('2099-06-22T10:00:00.000Z'),
|
||||
);
|
||||
expect(schedulingRescheduleRepository.createEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
trigger: 'TRAIN_MAINTENANCE',
|
||||
|
||||
@@ -227,17 +227,25 @@ export class SchedulingRescheduleService {
|
||||
// through this manager without editing TrainSchedulingService. A failure
|
||||
// between those steps and this block can still leave partial state; a human
|
||||
// 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) => {
|
||||
if (newDeparture) {
|
||||
// M7: raw write of scheduledDepartureDate. We deliberately do NOT
|
||||
// delegate to TrainSchedulingService.updateScheduleDate, which only
|
||||
// permits a date change while windowPhase === 'PRE_WINDOW' and would
|
||||
// reject reschedules of already-open (SCHEDULED) trains. Consequence:
|
||||
// the booking-window fields are NOT re-derived for the new date here.
|
||||
// Raw write of scheduledDepartureDate: updateScheduleDate only permits a
|
||||
// date change while windowPhase === 'PRE_WINDOW' and would reject
|
||||
// reschedules of already-open (SCHEDULED) trains.
|
||||
await this.trainSchedulesRepository.updateStatus(
|
||||
scheduleId,
|
||||
schedule.status as TrainScheduleStatus,
|
||||
{ scheduledDepartureDate: newDeparture },
|
||||
{ scheduledDepartureDate: newDeparture, ...windowFields },
|
||||
manager,
|
||||
);
|
||||
}
|
||||
@@ -263,6 +271,7 @@ export class SchedulingRescheduleService {
|
||||
// `newDeparture` is null when the date was unchanged, so retained customers
|
||||
// are not falsely told the train was rescheduled.
|
||||
await this.notifyRescheduleOutcome(dto, newDeparture);
|
||||
if (newDeparture) void this.trainSchedulingService.emitWindowState(scheduleId);
|
||||
|
||||
return { plan, schedule: assignResult };
|
||||
}
|
||||
|
||||
@@ -1821,5 +1821,31 @@ describe('TrainSchedulingService', () => {
|
||||
expect(written.windowPhase).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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -164,6 +164,8 @@ import {
|
||||
} from '../booking-batch.constants';
|
||||
import { orderConsistWagons } from '../consist-order.util';
|
||||
import {
|
||||
bookingCloseCutoff,
|
||||
clampCloseToOfficeHours,
|
||||
computeExportWindowTimes,
|
||||
computeImportWindowTimes,
|
||||
earliestSchedulableDeparture,
|
||||
@@ -473,7 +475,7 @@ export class TrainSchedulingService {
|
||||
* used for lifecycle changes outside the window tick (create, cancel,
|
||||
* finalize, restamp). A push failure must never break the mutation.
|
||||
*/
|
||||
private async emitWindowState(scheduleId: string): Promise<void> {
|
||||
async emitWindowState(scheduleId: string): Promise<void> {
|
||||
try {
|
||||
const fresh = await this.trainSchedulesRepository.findById(scheduleId);
|
||||
// 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
|
||||
* 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:
|
||||
* Booking-window fields that must follow a train's departure moving to
|
||||
* `departure` (any window phase). Shared by every reschedule path so the
|
||||
* "closes in" countdown always tracks the real departure.
|
||||
*
|
||||
* - 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 only
|
||||
* re-derived for PRE_WINDOW schedules (their window hasn't run yet); a
|
||||
* schedule mid- or post-window keeps its timeline untouched.
|
||||
* 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).
|
||||
*
|
||||
* 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(
|
||||
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;
|
||||
|
||||
// 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.
|
||||
async windowFieldsForNewDeparture(
|
||||
schedule: TrainSchedule,
|
||||
departure: Date,
|
||||
): Promise<
|
||||
Partial<
|
||||
Pick<
|
||||
TrainSchedule,
|
||||
| 'windowOpensAt'
|
||||
| 'windowClosesAt'
|
||||
| 'windowPhase'
|
||||
| 'bookingWindowStatus'
|
||||
| 'docReviewCompletedAt'
|
||||
| 'docReviewEndsAt'
|
||||
| 'paymentPhaseEndsAt'
|
||||
>
|
||||
>
|
||||
> {
|
||||
const reopenFromDone =
|
||||
schedule.windowPhase === 'DONE' && schedule.bookingWindowStatus !== 'FULL';
|
||||
const shiftOpenClose =
|
||||
schedule.windowPhase === 'OPEN' && schedule.windowOpensAt != null;
|
||||
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 () => {
|
||||
const merged = effectiveWindowConfig(
|
||||
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, {
|
||||
scheduledDepartureDate: departure,
|
||||
|
||||
@@ -500,7 +500,7 @@ export default function BookingRequestsPage() {
|
||||
<FilterBar
|
||||
defs={bookingFilterDefs}
|
||||
controls={controls}
|
||||
searchPlaceholder="Search booking, contract or customer…"
|
||||
searchPlaceholder="Search booking, contract, customer or shipping line…"
|
||||
viewId="booking-requests"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -14,7 +14,7 @@ import { DatePickerInput } from "@mantine/dates";
|
||||
import { getDateRangePresets } from "@/components/common/dateRangePresets";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
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 { useNavigate } from "react-router-dom";
|
||||
|
||||
@@ -75,6 +75,12 @@ const OWNERSHIP_OPTIONS = [
|
||||
{ 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 {
|
||||
const x = new Date(d);
|
||||
x.setHours(0, 0, 0, 0);
|
||||
@@ -98,6 +104,7 @@ export default function ClearanceDocumentsPage() {
|
||||
const [directionFilter, setDirectionFilter] = useState<string | null>(null);
|
||||
const [freightTypeFilter, setFreightTypeFilter] = 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 [createdTo, setCreatedTo] = useState<Date | null>(null);
|
||||
const { pagination, setPagination } = usePagination({ pageSize: PAGE_SIZE });
|
||||
@@ -118,6 +125,7 @@ export default function ClearanceDocumentsPage() {
|
||||
directionFilter,
|
||||
freightTypeFilter,
|
||||
ownershipFilter,
|
||||
customerKindFilter,
|
||||
createdFrom,
|
||||
createdTo,
|
||||
page,
|
||||
@@ -138,6 +146,9 @@ export default function ClearanceDocumentsPage() {
|
||||
...(ownershipFilter
|
||||
? { isGovernment: ownershipFilter as "true" | "false" }
|
||||
: {}),
|
||||
...(customerKindFilter
|
||||
? { customerKind: customerKindFilter as "SHIPPING_LINE" | "CUSTOMER" }
|
||||
: {}),
|
||||
...(createdFrom ? { createdFrom: startOfDayIso(createdFrom) } : {}),
|
||||
...(createdTo ? { createdTo: endOfDayIso(createdTo) } : {}),
|
||||
}),
|
||||
@@ -153,17 +164,29 @@ export default function ClearanceDocumentsPage() {
|
||||
header: () => <span className={bookingTable.headerCell}>Customer</span>,
|
||||
cell: ({ row }) => {
|
||||
const b = row.original;
|
||||
const customer = b.isGovernment
|
||||
? (b.governmentInstitution ?? "Government")
|
||||
: (b.company?.name ?? "—");
|
||||
const isShippingLine = Boolean(b.shippingLineCompany ?? b.shippingLineCompanyId);
|
||||
const customer = isShippingLine
|
||||
? (b.shippingLineCompany?.name ?? "Shipping line")
|
||||
: b.isGovernment
|
||||
? (b.governmentInstitution ?? "Government")
|
||||
: (b.company?.name ?? "—");
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-1.5">
|
||||
<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 className="min-w-0">
|
||||
<p className="font-medium text-foreground">
|
||||
<p className="flex items-center gap-1.5 font-medium text-foreground">
|
||||
{customer}
|
||||
{isShippingLine ? (
|
||||
<Badge variant="secondary" className="h-4 shrink-0 px-1 text-[9px] font-medium">
|
||||
Shipping line
|
||||
</Badge>
|
||||
) : null}
|
||||
</p>
|
||||
<p className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<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%">
|
||||
<Group justify="space-between" gap="md" wrap="wrap">
|
||||
<TextInput
|
||||
placeholder="Search booking, contract or customer…"
|
||||
placeholder="Search booking, contract, customer or shipping line…"
|
||||
leftSection={<Search size={18} />}
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
@@ -333,6 +356,19 @@ export default function ClearanceDocumentsPage() {
|
||||
style={{ minWidth: 140 }}
|
||||
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
|
||||
placeholder="Gov / Private"
|
||||
data={OWNERSHIP_OPTIONS}
|
||||
|
||||
Reference in New Issue
Block a user