mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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:
@@ -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}%` },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }) =>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user