mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
enhance shipment requests page with filtering and sorting options
- Added status and cargo filters to the ShipmentRequestsPage. - Implemented date range filtering for preferred dates. - Introduced sorting options for shipment requests based on submission date and reference. - Enhanced the display of shipment request details, including status badges and customer information. - Updated the UI to include a search input with clear functionality and improved layout for filters. feat: add equipment return option in new shipment form - Introduced a toggle for equipment return in the NewShipmentPage. - Updated form schema to include field for container contracts. - Enhanced user experience with visual feedback on the equipment return selection. fix: update booking DTO to include equipment return option - Added field to CreateBookingUnderContractDto for per-shipment override. - Updated related types and schemas to accommodate the new field for better contract handling.
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -28,3 +28,4 @@ coverage/
|
|||||||
*~
|
*~
|
||||||
\#*\#
|
\#*\#
|
||||||
.\#*
|
.\#*
|
||||||
|
docker-compose.override.yml
|
||||||
|
|||||||
@@ -22,12 +22,15 @@ export class BookingRequestRepository extends BaseRepository<BookingRequest> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** GL queue: pending requests across all contracts, oldest first. */
|
/**
|
||||||
async findPending(): Promise<BookingRequest[]> {
|
* GL queue: every request across all contracts, newest first. The queue page
|
||||||
|
* filters by status client-side (pending work vs accepted/rejected history),
|
||||||
|
* and surfaces the customer — so the contract's company rides along.
|
||||||
|
*/
|
||||||
|
async findQueue(): Promise<BookingRequest[]> {
|
||||||
return this.repository.find({
|
return this.repository.find({
|
||||||
where: { status: 'PENDING' },
|
order: { createdAt: 'DESC' },
|
||||||
order: { createdAt: 'ASC' },
|
relations: { contract: { company: true } },
|
||||||
relations: { contract: true },
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ export class BookingRequestService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
queue(): Promise<BookingRequest[]> {
|
queue(): Promise<BookingRequest[]> {
|
||||||
return this.repo.findPending();
|
return this.repo.findQueue();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async findPending(requestId: string): Promise<BookingRequest> {
|
private async findPending(requestId: string): Promise<BookingRequest> {
|
||||||
|
|||||||
@@ -218,7 +218,7 @@ export class ContractBookingService {
|
|||||||
contractType: 'NEW',
|
contractType: 'NEW',
|
||||||
customsClearingEnabled: contract.customsClearingEnabled,
|
customsClearingEnabled: contract.customsClearingEnabled,
|
||||||
customsClearingAgent: contract.customsClearingAgent ?? null,
|
customsClearingAgent: contract.customsClearingAgent ?? null,
|
||||||
equipmentReturn: contract.equipmentReturn ?? 'WITHOUT_RETURN',
|
equipmentReturn: dto.equipmentReturn ?? contract.equipmentReturn ?? 'WITHOUT_RETURN',
|
||||||
originYardId: route?.originYardId ?? null,
|
originYardId: route?.originYardId ?? null,
|
||||||
destinationYardId: route?.destinationYardId ?? null,
|
destinationYardId: route?.destinationYardId ?? null,
|
||||||
tradeDirection: contract.tradeDirection,
|
tradeDirection: contract.tradeDirection,
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export class ContractsController {
|
|||||||
|
|
||||||
@Get('booking-requests/queue')
|
@Get('booking-requests/queue')
|
||||||
@BookingStaff(FREIGHT_PERMS.contracts.createBooking)
|
@BookingStaff(FREIGHT_PERMS.contracts.createBooking)
|
||||||
@ApiOperation({ summary: 'GL queue: pending shipment requests across contracts' })
|
@ApiOperation({ summary: 'GL queue: shipment requests across contracts (all statuses, newest first)' })
|
||||||
bookingRequestQueue() {
|
bookingRequestQueue() {
|
||||||
return this.bookingRequestService.queue();
|
return this.bookingRequestService.queue();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
IsArray,
|
IsArray,
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsDateString,
|
IsDateString,
|
||||||
|
IsIn,
|
||||||
IsInt,
|
IsInt,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
@@ -14,6 +15,9 @@ import {
|
|||||||
ValidateNested,
|
ValidateNested,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
|
/** Per-shipment equipment return — "NA" stays contract-level only. */
|
||||||
|
const SHIPMENT_EQUIPMENT_RETURNS = ['WITH_RETURN', 'WITHOUT_RETURN'] as const;
|
||||||
|
|
||||||
/** One physical container under a booking line — entered at booking time. */
|
/** One physical container under a booking line — entered at booking time. */
|
||||||
export class CreateContainerUnitDto {
|
export class CreateContainerUnitDto {
|
||||||
@ApiProperty({ description: 'ISO 6346 container number, e.g. ABCD1234567' })
|
@ApiProperty({ description: 'ISO 6346 container number, e.g. ABCD1234567' })
|
||||||
@@ -134,6 +138,15 @@ export class CreateBookingUnderContractDto {
|
|||||||
@IsDateString()
|
@IsDateString()
|
||||||
scheduledDate?: string;
|
scheduledDate?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
enum: SHIPMENT_EQUIPMENT_RETURNS,
|
||||||
|
description:
|
||||||
|
'Per-shipment equipment return override; omitted → the contract default applies.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn([...SHIPMENT_EQUIPMENT_RETURNS])
|
||||||
|
equipmentReturn?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ type: [CreateBookingContainerLineDto] })
|
@ApiPropertyOptional({ type: [CreateBookingContainerLineDto] })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsArray()
|
@IsArray()
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity
|
|||||||
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
||||||
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
||||||
import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository';
|
import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository';
|
||||||
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
|
|
||||||
import { BookingNotifierService } from './booking-notifier.service';
|
import { BookingNotifierService } from './booking-notifier.service';
|
||||||
import { TrainSchedulingService } from './train-scheduling.service';
|
import { TrainSchedulingService } from './train-scheduling.service';
|
||||||
import { eatDay, groupBookingsIntoBoardWindows } from './batch-window.util';
|
import { eatDay, groupBookingsIntoBoardWindows } from './batch-window.util';
|
||||||
@@ -568,7 +567,6 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rules = await this.loadGlobalRules();
|
|
||||||
const wagonDims = await this.loadWagonDims();
|
const wagonDims = await this.loadWagonDims();
|
||||||
const required = need ?? this.needFor(booking, wagonDims);
|
const required = need ?? this.needFor(booking, wagonDims);
|
||||||
let corridorMatched = false;
|
let corridorMatched = false;
|
||||||
@@ -578,7 +576,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
);
|
);
|
||||||
const locomotive = schedule?.trainSet?.locomotive;
|
const locomotive = schedule?.trainSet?.locomotive;
|
||||||
if (!schedule || !locomotive) continue;
|
if (!schedule || !locomotive) continue;
|
||||||
const limits = await this.capacityLimits(locomotive, rules);
|
const limits = await this.capacityLimits(locomotive);
|
||||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||||
const leg = budget.legOf(booking.originYardId, booking.destinationYardId);
|
const leg = budget.legOf(booking.originYardId, booking.destinationYardId);
|
||||||
if (!leg) continue; // this train's route doesn't carry the booking's leg
|
if (!leg) continue; // this train's route doesn't carry the booking's leg
|
||||||
@@ -757,7 +755,6 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const wagonDims = await this.loadWagonDims();
|
const wagonDims = await this.loadWagonDims();
|
||||||
const rules = await this.loadGlobalRules();
|
|
||||||
const linkRepo = this.dataSource.getRepository(TrainScheduleBooking);
|
const linkRepo = this.dataSource.getRepository(TrainScheduleBooking);
|
||||||
|
|
||||||
const board: BatchBoardSchedule[] = [];
|
const board: BatchBoardSchedule[] = [];
|
||||||
@@ -787,7 +784,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
board.push(this.buildScheduleSummary(s, items, rules));
|
board.push(this.buildScheduleSummary(s, items));
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -817,7 +814,6 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const wagonDims = await this.loadWagonDims();
|
const wagonDims = await this.loadWagonDims();
|
||||||
const rules = await this.loadGlobalRules();
|
|
||||||
const linkRepo = this.dataSource.getRepository(TrainScheduleBooking);
|
const linkRepo = this.dataSource.getRepository(TrainScheduleBooking);
|
||||||
const links = await linkRepo.find({ where: { trainScheduleId: s.id } });
|
const links = await linkRepo.find({ where: { trainScheduleId: s.id } });
|
||||||
const linkedIds = new Set(links.map((l) => l.bookingId));
|
const linkedIds = new Set(links.map((l) => l.bookingId));
|
||||||
@@ -1011,7 +1007,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
|
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null, rules),
|
capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null),
|
||||||
counts: {
|
counts: {
|
||||||
allocated: items.filter((i) => i.state === "ALLOCATED").length,
|
allocated: items.filter((i) => i.state === "ALLOCATED").length,
|
||||||
selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH")
|
selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH")
|
||||||
@@ -1045,9 +1041,10 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
/**
|
/**
|
||||||
* Board capacity figures. `usedWeightTons` is GROSS (each item's weight already
|
* Board capacity figures. `usedWeightTons` is GROSS (each item's weight already
|
||||||
* includes the tare of the wagons it occupies), so the ceiling it is measured
|
* includes the tare of the wagons it occupies), so the ceiling it is measured
|
||||||
* against must be the same one the fill loop spends from: the locomotive floored
|
* against must be the same one the fill loop spends from: the locomotive's own
|
||||||
* by the global rule caps and widened by its overage tolerance. Reading the raw
|
* limits widened by its overage tolerance (global rule caps do not apply, same
|
||||||
* `loco.maxPullWeightTons` here showed staff a ceiling the batch engine did not use.
|
* as {@link capacityLimits}). Reading the raw `loco.maxPullWeightTons` here
|
||||||
|
* showed staff a ceiling the batch engine did not use.
|
||||||
*/
|
*/
|
||||||
private computeBoardCapacity(
|
private computeBoardCapacity(
|
||||||
items: Array<{
|
items: Array<{
|
||||||
@@ -1058,29 +1055,18 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
}>,
|
}>,
|
||||||
loco: Locomotive | null,
|
loco: Locomotive | null,
|
||||||
maxWagons: number | null,
|
maxWagons: number | null,
|
||||||
rules: TrainSchedulingGlobalRules | null,
|
|
||||||
): BatchBoardSchedule["capacity"] {
|
): BatchBoardSchedule["capacity"] {
|
||||||
const allocated = items.filter((i) => i.state === "ALLOCATED");
|
const allocated = items.filter((i) => i.state === "ALLOCATED");
|
||||||
const committed = items.filter(
|
const committed = items.filter(
|
||||||
(i) => i.state === "ALLOCATED" || i.state === "SELECTED_FOR_BATCH",
|
(i) => i.state === "ALLOCATED" || i.state === "SELECTED_FOR_BATCH",
|
||||||
);
|
);
|
||||||
const caps = loco
|
const caps = loco
|
||||||
? trainHardCaps(
|
? trainHardCaps({
|
||||||
{
|
|
||||||
maxPullWeightTons: Number(loco.maxPullWeightTons),
|
maxPullWeightTons: Number(loco.maxPullWeightTons),
|
||||||
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
|
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
|
||||||
overageToleranceTons: Number(loco.overageToleranceTons) || 0,
|
overageToleranceTons: Number(loco.overageToleranceTons) || 0,
|
||||||
overageToleranceMeters: Number(loco.overageToleranceMeters) || 0,
|
overageToleranceMeters: Number(loco.overageToleranceMeters) || 0,
|
||||||
},
|
})
|
||||||
{
|
|
||||||
maxTrainWeightTons: rules?.maxTrainWeightTons
|
|
||||||
? Number(rules.maxTrainWeightTons)
|
|
||||||
: undefined,
|
|
||||||
maxTrainLengthMeters: rules?.maxTrainLengthMeters
|
|
||||||
? Number(rules.maxTrainLengthMeters)
|
|
||||||
: undefined,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
: null;
|
: null;
|
||||||
const round2 = (value: number) => Math.round(value * 100) / 100;
|
const round2 = (value: number) => Math.round(value * 100) / 100;
|
||||||
|
|
||||||
@@ -1099,7 +1085,6 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
private buildScheduleSummary(
|
private buildScheduleSummary(
|
||||||
s: TrainSchedule,
|
s: TrainSchedule,
|
||||||
items: BatchBoardBooking[],
|
items: BatchBoardBooking[],
|
||||||
rules: TrainSchedulingGlobalRules | null,
|
|
||||||
): BatchBoardSchedule {
|
): BatchBoardSchedule {
|
||||||
const loco = s.trainSet?.locomotive ?? null;
|
const loco = s.trainSet?.locomotive ?? null;
|
||||||
|
|
||||||
@@ -1134,7 +1119,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
|
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null, rules),
|
capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null),
|
||||||
counts: {
|
counts: {
|
||||||
allocated: items.filter((i) => i.state === "ALLOCATED").length,
|
allocated: items.filter((i) => i.state === "ALLOCATED").length,
|
||||||
selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH")
|
selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH")
|
||||||
@@ -1203,10 +1188,9 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rules = await this.loadGlobalRules();
|
|
||||||
const wagonDims = await this.loadWagonDims();
|
const wagonDims = await this.loadWagonDims();
|
||||||
const limits = await this.capacityLimits(locomotive, rules);
|
const limits = await this.capacityLimits(locomotive);
|
||||||
await this.syncScheduleMaxWagons(schedule, locomotive, rules);
|
await this.syncScheduleMaxWagons(schedule, locomotive);
|
||||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||||
const minPerWagon = this.minPerWagonNeed(wagonDims);
|
const minPerWagon = this.minPerWagonNeed(wagonDims);
|
||||||
if (budget.isExhausted(minPerWagon)) {
|
if (budget.isExhausted(minPerWagon)) {
|
||||||
@@ -1405,7 +1389,6 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
return { scheduleIds: [], commercialReserved: 0 };
|
return { scheduleIds: [], commercialReserved: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
const rules = await this.loadGlobalRules();
|
|
||||||
const wagonDims = await this.loadWagonDims();
|
const wagonDims = await this.loadWagonDims();
|
||||||
|
|
||||||
// Live per-schedule corridor budget + arm flag, in departure order.
|
// Live per-schedule corridor budget + arm flag, in departure order.
|
||||||
@@ -1420,8 +1403,8 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const limits = await this.capacityLimits(locomotive, rules);
|
const limits = await this.capacityLimits(locomotive);
|
||||||
await this.syncScheduleMaxWagons(schedule, locomotive, rules);
|
await this.syncScheduleMaxWagons(schedule, locomotive);
|
||||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||||
trains.push({ id, budget, armed: false });
|
trains.push({ id, budget, armed: false });
|
||||||
}
|
}
|
||||||
@@ -1973,9 +1956,8 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||||
const locomotive = schedule?.trainSet?.locomotive;
|
const locomotive = schedule?.trainSet?.locomotive;
|
||||||
if (!schedule || !locomotive) return null;
|
if (!schedule || !locomotive) return null;
|
||||||
const rules = await this.loadGlobalRules();
|
|
||||||
const wagonDims = await this.loadWagonDims();
|
const wagonDims = await this.loadWagonDims();
|
||||||
const limits = await this.capacityLimits(locomotive, rules);
|
const limits = await this.capacityLimits(locomotive);
|
||||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||||
return { budget, needFor: (booking) => this.needFor(booking, wagonDims) };
|
return { budget, needFor: (booking) => this.needFor(booking, wagonDims) };
|
||||||
}
|
}
|
||||||
@@ -2520,11 +2502,12 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
* `base` via {@link needFor}, whose weight axis is gross. The locomotive's
|
* `base` via {@link needFor}, whose weight axis is gross. The locomotive's
|
||||||
* overage tolerance is returned separately — the corridor budget spends it
|
* overage tolerance is returned separately — the corridor budget spends it
|
||||||
* only to admit a booking whole, never to size a split.
|
* only to admit a booking whole, never to size a split.
|
||||||
|
*
|
||||||
|
* Limits come from the LOCOMOTIVE ALONE — the global-rules weight/length
|
||||||
|
* caps deliberately do not apply here (a mis-set global row once capped
|
||||||
|
* every train at 14m and no export booking could board).
|
||||||
*/
|
*/
|
||||||
private async capacityLimits(
|
private async capacityLimits(locomotive: Locomotive): Promise<TrainLimits> {
|
||||||
locomotive: Locomotive,
|
|
||||||
rules: TrainSchedulingGlobalRules | null,
|
|
||||||
): Promise<TrainLimits> {
|
|
||||||
const wagonTypes = await this.loadWagonTypeDimensions();
|
const wagonTypes = await this.loadWagonTypeDimensions();
|
||||||
const derived = deriveTrainCapacityFromLocomotive(
|
const derived = deriveTrainCapacityFromLocomotive(
|
||||||
{
|
{
|
||||||
@@ -2534,14 +2517,6 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
overageToleranceMeters: Number(locomotive.overageToleranceMeters) || 0,
|
overageToleranceMeters: Number(locomotive.overageToleranceMeters) || 0,
|
||||||
},
|
},
|
||||||
wagonTypes,
|
wagonTypes,
|
||||||
{
|
|
||||||
maxTrainWeightTons: rules?.maxTrainWeightTons
|
|
||||||
? Number(rules.maxTrainWeightTons)
|
|
||||||
: undefined,
|
|
||||||
maxTrainLengthMeters: rules?.maxTrainLengthMeters
|
|
||||||
? Number(rules.maxTrainLengthMeters)
|
|
||||||
: undefined,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
base: {
|
base: {
|
||||||
@@ -2556,18 +2531,27 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Keep schedule.max_wagons aligned with locomotive physical limits. */
|
/**
|
||||||
|
* Keep schedule.max_wagons aligned with the train's real boarding limit: the
|
||||||
|
* locomotive's length-derived slot count, floored by the physical wagons in
|
||||||
|
* the train set (slots that exist on paper but not in the yard must not be
|
||||||
|
* sold — see {@link remainingBudget}).
|
||||||
|
*/
|
||||||
private async syncScheduleMaxWagons(
|
private async syncScheduleMaxWagons(
|
||||||
schedule: TrainSchedule,
|
schedule: TrainSchedule,
|
||||||
locomotive: Locomotive,
|
locomotive: Locomotive,
|
||||||
rules: TrainSchedulingGlobalRules | null,
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const limits = await this.capacityLimits(locomotive, rules);
|
const limits = await this.capacityLimits(locomotive);
|
||||||
if ((schedule.maxWagons ?? 0) !== limits.base.wagons) {
|
const physicalWagons = schedule.trainSet?.wagons?.length ?? 0;
|
||||||
|
const maxWagons =
|
||||||
|
physicalWagons > 0
|
||||||
|
? Math.min(limits.base.wagons, physicalWagons)
|
||||||
|
: limits.base.wagons;
|
||||||
|
if ((schedule.maxWagons ?? 0) !== maxWagons) {
|
||||||
await this.dataSource
|
await this.dataSource
|
||||||
.getRepository(TrainSchedule)
|
.getRepository(TrainSchedule)
|
||||||
.update(schedule.id, { maxWagons: limits.base.wagons });
|
.update(schedule.id, { maxWagons });
|
||||||
schedule.maxWagons = limits.base.wagons;
|
schedule.maxWagons = maxWagons;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2655,12 +2639,6 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadGlobalRules(): Promise<TrainSchedulingGlobalRules | null> {
|
|
||||||
return this.dataSource
|
|
||||||
.getRepository(TrainSchedulingGlobalRules)
|
|
||||||
.findOne({ where: {} });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ordered stop yards of the schedule's route (origin → milestones →
|
* Ordered stop yards of the schedule's route (origin → milestones →
|
||||||
* destination); the legacy two-stop pseudo-route when milestones are absent.
|
* destination); the legacy two-stop pseudo-route when milestones are absent.
|
||||||
@@ -2684,6 +2662,12 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
* Remaining capacity per corridor edge = hard caps minus what allocated +
|
* Remaining capacity per corridor edge = hard caps minus what allocated +
|
||||||
* reserved bookings already use ON THEIR OWN LEGS. A booking riding only
|
* reserved bookings already use ON THEIR OWN LEGS. A booking riding only
|
||||||
* Dire→Djibouti leaves the Addis→Dire edges untouched.
|
* Dire→Djibouti leaves the Addis→Dire edges untouched.
|
||||||
|
*
|
||||||
|
* The wagon axis is additionally capped by the PHYSICAL wagons marshalled in
|
||||||
|
* the schedule's train set. The length-derived slot count says how many wagons
|
||||||
|
* the locomotive could pull, not how many exist: a 760m/54-slot train with a
|
||||||
|
* 50-wagon set once split-offered 4 wagons that were never buildable — the
|
||||||
|
* customer paid and the wagon planner had nothing to assign.
|
||||||
*/
|
*/
|
||||||
private async remainingBudget(
|
private async remainingBudget(
|
||||||
schedule: TrainSchedule,
|
schedule: TrainSchedule,
|
||||||
@@ -2691,7 +2675,12 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
wagonDims: WagonDims,
|
wagonDims: WagonDims,
|
||||||
): Promise<CorridorBudget> {
|
): Promise<CorridorBudget> {
|
||||||
const stops = await this.stopsForSchedule(schedule);
|
const stops = await this.stopsForSchedule(schedule);
|
||||||
const budget = new CorridorBudget(stops, limits.base, limits.tolerance);
|
const physicalWagons = schedule.trainSet?.wagons?.length ?? 0;
|
||||||
|
const base =
|
||||||
|
physicalWagons > 0
|
||||||
|
? { ...limits.base, wagons: Math.min(limits.base.wagons, physicalWagons) }
|
||||||
|
: limits.base;
|
||||||
|
const budget = new CorridorBudget(stops, base, limits.tolerance);
|
||||||
const allocated = (schedule.scheduleBookings ?? [])
|
const allocated = (schedule.scheduleBookings ?? [])
|
||||||
.map((sb) => sb.booking)
|
.map((sb) => sb.booking)
|
||||||
.filter((b): b is Booking => Boolean(b));
|
.filter((b): b is Booking => Boolean(b));
|
||||||
@@ -2795,9 +2784,8 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
if ((await this.remainingWagons(schedule)) <= 0) return true;
|
if ((await this.remainingWagons(schedule)) <= 0) return true;
|
||||||
const locomotive = schedule.trainSet?.locomotive;
|
const locomotive = schedule.trainSet?.locomotive;
|
||||||
if (!locomotive) return false; // no weight/length limits to bind against
|
if (!locomotive) return false; // no weight/length limits to bind against
|
||||||
const rules = await this.loadGlobalRules();
|
|
||||||
const wagonDims = await this.loadWagonDims();
|
const wagonDims = await this.loadWagonDims();
|
||||||
const limits = await this.capacityLimits(locomotive, rules);
|
const limits = await this.capacityLimits(locomotive);
|
||||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||||
return budget.isExhausted(this.minPerWagonNeed(wagonDims));
|
return budget.isExhausted(this.minPerWagonNeed(wagonDims));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
NotificationAudience,
|
NotificationAudience,
|
||||||
|
NotificationPriority,
|
||||||
NotificationType,
|
NotificationType,
|
||||||
NotifyInput,
|
NotifyInput,
|
||||||
} from '@edr/types';
|
} from '@edr/types';
|
||||||
@@ -114,11 +115,15 @@ export class BookingNotifierService {
|
|||||||
const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' });
|
const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' });
|
||||||
const msg =
|
const msg =
|
||||||
`Only ${offeredWagons} of ${totalWagons} wagons fit the train for booking ${b.reference ?? b.id}. ` +
|
`Only ${offeredWagons} of ${totalWagons} wagons fit the train for booking ${b.reference ?? b.id}. ` +
|
||||||
`Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to accept and ship ${offeredWagons} wagon${offeredWagons === 1 ? '' : 's'} now ` +
|
`Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to accept and ship ${offeredWagons} wagon${offeredWagons === 1 ? '' : 's'} now. ` +
|
||||||
`(the rest returns to your contract to book later). If you do not pay, the booking stays whole and you can rebook in the next window. Deadline: ${eat} EAT.`;
|
`The remaining ${totalWagons - offeredWagons} return${totalWagons - offeredWagons === 1 ? 's' : ''} to your contract — book them yourself in a later window. ` +
|
||||||
|
`If you do not pay, the booking stays whole and you can rebook in the next window. Deadline: ${eat} EAT.`;
|
||||||
await this.notifyContact(b, msg, 'PAY NOW (PARTIAL)');
|
await this.notifyContact(b, msg, 'PAY NOW (PARTIAL)');
|
||||||
|
// HIGH: a split is a change to what the customer ordered AND a live payment
|
||||||
|
// deadline — it must reach email/SMS, not just the portal inbox.
|
||||||
this.inApp(b, 'Partial allocation offer', msg, {
|
this.inApp(b, 'Partial allocation offer', msg, {
|
||||||
type: NotificationType.INVOICE_ISSUED,
|
type: NotificationType.INVOICE_ISSUED,
|
||||||
|
priority: NotificationPriority.HIGH,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3111,6 +3111,10 @@ export class TrainSchedulingService {
|
|||||||
const wagonTypes = await this.loadSchedulingWagonTypeDimensions();
|
const wagonTypes = await this.loadSchedulingWagonTypeDimensions();
|
||||||
|
|
||||||
if (locomotive) {
|
if (locomotive) {
|
||||||
|
// With a locomotive assigned its own limits are the single source of
|
||||||
|
// truth — global-rules / env caps do not floor them (a mis-set global
|
||||||
|
// row once capped every train at 14m). Only an explicit per-request dto
|
||||||
|
// override still applies.
|
||||||
const derived = deriveTrainCapacityFromLocomotive(
|
const derived = deriveTrainCapacityFromLocomotive(
|
||||||
{
|
{
|
||||||
maxPullWeightTons: Number(locomotive.maxPullWeightTons),
|
maxPullWeightTons: Number(locomotive.maxPullWeightTons),
|
||||||
@@ -3120,8 +3124,8 @@ export class TrainSchedulingService {
|
|||||||
},
|
},
|
||||||
wagonTypes,
|
wagonTypes,
|
||||||
{
|
{
|
||||||
maxTrainWeightTons: ruleWeightCap,
|
maxTrainWeightTons: dto?.maxTrainWeightTons,
|
||||||
maxTrainLengthMeters: ruleLengthCap,
|
maxTrainLengthMeters: dto?.maxTrainLengthMeters,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -196,12 +196,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
icon: <Send />,
|
icon: <Send />,
|
||||||
permission: FREIGHT_PERMS.contracts.createBooking,
|
permission: FREIGHT_PERMS.contracts.createBooking,
|
||||||
},
|
},
|
||||||
{
|
// {
|
||||||
label: "Self-Clearance Review",
|
// label: "Self-Clearance Review",
|
||||||
href: "/dashboard/contracts/ops-clearance",
|
// href: "/dashboard/contracts/ops-clearance",
|
||||||
icon: <ShieldCheck />,
|
// icon: <ShieldCheck />,
|
||||||
permission: FREIGHT_PERMS.contracts.opsClearanceReview,
|
// permission: FREIGHT_PERMS.contracts.opsClearanceReview,
|
||||||
},
|
// },
|
||||||
{
|
{
|
||||||
label: "GL Djibouti Clearance",
|
label: "GL Djibouti Clearance",
|
||||||
href: "/dashboard/gl-djibouti/clearance",
|
href: "/dashboard/gl-djibouti/clearance",
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
MapPin,
|
MapPin,
|
||||||
Package,
|
Package,
|
||||||
Receipt,
|
Receipt,
|
||||||
|
Repeat,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
@@ -192,9 +193,19 @@ export default function GlCreateBookingForm() {
|
|||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
const [containerLines, setContainerLines] = useState<ContainerLineDraft[]>([]);
|
const [containerLines, setContainerLines] = useState<ContainerLineDraft[]>([]);
|
||||||
const [bulkLines, setBulkLines] = useState<BulkLineDraft[]>([]);
|
const [bulkLines, setBulkLines] = useState<BulkLineDraft[]>([]);
|
||||||
|
const [withReturn, setWithReturn] = useState(false);
|
||||||
const [prefilled, setPrefilled] = useState(false);
|
const [prefilled, setPrefilled] = useState(false);
|
||||||
const [priceOpen, setPriceOpen] = useState(false);
|
const [priceOpen, setPriceOpen] = useState(false);
|
||||||
const seededRef = useRef(false);
|
const seededRef = useRef(false);
|
||||||
|
const returnSeededRef = useRef(false);
|
||||||
|
|
||||||
|
// Seed the equipment-return toggle from the contract exactly once (also when
|
||||||
|
// the form is prefilled from a shipment request); GL can flip it per shipment.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!contract || returnSeededRef.current) return;
|
||||||
|
returnSeededRef.current = true;
|
||||||
|
setWithReturn(contract.equipmentReturn === "WITH_RETURN");
|
||||||
|
}, [contract]);
|
||||||
|
|
||||||
const isContainer = contract?.freightType === "CONTAINER";
|
const isContainer = contract?.freightType === "CONTAINER";
|
||||||
const routes = useMemo(
|
const routes = useMemo(
|
||||||
@@ -527,6 +538,10 @@ export default function GlCreateBookingForm() {
|
|||||||
scheduledDate,
|
scheduledDate,
|
||||||
...(contractRouteId ? { contractRouteId } : {}),
|
...(contractRouteId ? { contractRouteId } : {}),
|
||||||
...(notes.trim() ? { notes: notes.trim() } : {}),
|
...(notes.trim() ? { notes: notes.trim() } : {}),
|
||||||
|
// Equipment return is a container concern — bulk keeps the contract default.
|
||||||
|
...(isContainer
|
||||||
|
? { equipmentReturn: withReturn ? "WITH_RETURN" : "WITHOUT_RETURN" }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isContainer) {
|
if (isContainer) {
|
||||||
@@ -1091,6 +1106,67 @@ export default function GlCreateBookingForm() {
|
|||||||
</StepCard>
|
</StepCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isContainer ? (
|
||||||
|
<StepCard>
|
||||||
|
<StepHeader
|
||||||
|
icon={<Repeat size={22} />}
|
||||||
|
title="Equipment Return"
|
||||||
|
description="Choose whether the empty container(s) come back to EDR after unloading."
|
||||||
|
/>
|
||||||
|
<Paper
|
||||||
|
withBorder
|
||||||
|
radius="md"
|
||||||
|
p="md"
|
||||||
|
style={{
|
||||||
|
borderColor: withReturn ? "#CDEBDD" : "#E6ECF2",
|
||||||
|
background: withReturn ? "#F6FBF8" : "white",
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "border-color 150ms ease, background 150ms ease",
|
||||||
|
}}
|
||||||
|
onClick={() => setWithReturn((v) => !v)}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" wrap="nowrap" gap="md">
|
||||||
|
<Group gap={13} wrap="nowrap" align="flex-start">
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
width: 38,
|
||||||
|
height: 38,
|
||||||
|
flexShrink: 0,
|
||||||
|
borderRadius: 11,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
background: withReturn ? "#ECF6F1" : "#F1F4F7",
|
||||||
|
color: withReturn ? "#0A6F4D" : "#6B7C8E",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Repeat size={18} />
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text fz={14} fw={700}>
|
||||||
|
With return
|
||||||
|
</Text>
|
||||||
|
<Text fz={12} c="dimmed" style={{ lineHeight: 1.4 }}>
|
||||||
|
{withReturn
|
||||||
|
? "Container(s) returned to EDR after unloading."
|
||||||
|
: "Container(s) retained by the customer after delivery."}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
<Switch
|
||||||
|
size="md"
|
||||||
|
color="edr-green"
|
||||||
|
aria-label="With return"
|
||||||
|
checked={withReturn}
|
||||||
|
onChange={(e) => setWithReturn(e.currentTarget.checked)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
</StepCard>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<StepCard>
|
<StepCard>
|
||||||
<StepHeader
|
<StepHeader
|
||||||
icon={<CalendarDays size={22} />}
|
icon={<CalendarDays size={22} />}
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ export interface ShipmentListRow {
|
|||||||
summary: string;
|
summary: string;
|
||||||
status: Freight.BookingRequestStatus;
|
status: Freight.BookingRequestStatus;
|
||||||
createdBookingId?: string | null;
|
createdBookingId?: string | null;
|
||||||
|
/** When the customer submitted the request — the queue's default sort key. */
|
||||||
|
createdAt?: string | null;
|
||||||
|
customerName?: string | null;
|
||||||
|
freightKind?: "CONTAINER" | "BULK";
|
||||||
|
hazardous?: boolean;
|
||||||
|
reefer?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ShipmentRowAction =
|
export type ShipmentRowAction =
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useRef, useState } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Badge,
|
Badge,
|
||||||
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Center,
|
Center,
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
|
Menu,
|
||||||
Modal,
|
Modal,
|
||||||
Paper,
|
Paper,
|
||||||
|
ScrollArea,
|
||||||
Stack,
|
Stack,
|
||||||
Switch,
|
Switch,
|
||||||
Text,
|
Text,
|
||||||
@@ -19,13 +22,29 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
|
AlertTriangle,
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
|
Banknote,
|
||||||
|
Building2,
|
||||||
|
CalendarClock,
|
||||||
|
CalendarDays,
|
||||||
|
CalendarRange,
|
||||||
|
ChevronDown,
|
||||||
|
Coins,
|
||||||
|
Hash,
|
||||||
|
ListOrdered,
|
||||||
|
ListPlus,
|
||||||
|
Mail,
|
||||||
|
MapPin,
|
||||||
|
Package,
|
||||||
Pencil,
|
Pencil,
|
||||||
|
Phone,
|
||||||
Plus,
|
Plus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Settings2,
|
Settings2,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
Weight,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { PageContainer, PageHeader } from "@/components/page";
|
import { PageContainer, PageHeader } from "@/components/page";
|
||||||
@@ -41,7 +60,7 @@ import {
|
|||||||
import type { ContractTemplateArticle } from "@/services/contract-templates.service";
|
import type { ContractTemplateArticle } from "@/services/contract-templates.service";
|
||||||
|
|
||||||
const BODY_HINT =
|
const BODY_HINT =
|
||||||
'One clause per line — clauses are numbered automatically. Prefix a line with "- " to nest it as a bullet under the previous clause. Placeholders like {{client.companyName}}, {{contractDate}}, {{contractYear}} and {{reference}} are filled from the contract.';
|
'One clause per line — clauses are numbered automatically. Prefix a line with "- " to nest it as a bullet under the previous clause. Use the buttons above to drop a placeholder at the cursor — it is filled from the contract when the document is generated.';
|
||||||
|
|
||||||
interface ArticleDraft {
|
interface ArticleDraft {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -49,6 +68,254 @@ interface ArticleDraft {
|
|||||||
body: string;
|
body: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PlaceholderDef {
|
||||||
|
token: string;
|
||||||
|
label: string;
|
||||||
|
icon: typeof Building2;
|
||||||
|
hint: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Placeholders the renderer fills from the contract view model
|
||||||
|
* (contract-view-model.builder.ts). Quick row = the ones template authors
|
||||||
|
* reach for constantly; the rest live in the grouped "More" menu.
|
||||||
|
*/
|
||||||
|
const QUICK_PLACEHOLDERS: PlaceholderDef[] = [
|
||||||
|
{
|
||||||
|
token: "{{client.companyName}}",
|
||||||
|
label: "Client name",
|
||||||
|
icon: Building2,
|
||||||
|
hint: "Company name of the contracting client",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: "{{reference}}",
|
||||||
|
label: "Reference",
|
||||||
|
icon: Hash,
|
||||||
|
hint: "Contract reference number",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: "{{contractDate}}",
|
||||||
|
label: "Contract date",
|
||||||
|
icon: CalendarDays,
|
||||||
|
hint: "Full signature date of the contract",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: "{{contractYear}}",
|
||||||
|
label: "Contract year",
|
||||||
|
icon: CalendarRange,
|
||||||
|
hint: "Year the contract is signed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: "{{pricing.totalAmount}}",
|
||||||
|
label: "Total price",
|
||||||
|
icon: Banknote,
|
||||||
|
hint: "Total contract price from the pricing schedule",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const MORE_PLACEHOLDER_GROUPS: { label: string; items: PlaceholderDef[] }[] = [
|
||||||
|
{
|
||||||
|
label: "Client",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
token: "{{client.companyAddress}}",
|
||||||
|
label: "Client address",
|
||||||
|
icon: MapPin,
|
||||||
|
hint: "Street address of the client",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: "{{client.companyLocation}}",
|
||||||
|
label: "Client location",
|
||||||
|
icon: MapPin,
|
||||||
|
hint: "Region / city of the client",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: "{{client.phone}}",
|
||||||
|
label: "Client phone",
|
||||||
|
icon: Phone,
|
||||||
|
hint: "Client phone number",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: "{{client.email}}",
|
||||||
|
label: "Client email",
|
||||||
|
icon: Mail,
|
||||||
|
hint: "Client email address",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: "{{client.tinNumber}}",
|
||||||
|
label: "Client TIN",
|
||||||
|
icon: Hash,
|
||||||
|
hint: "Client tax identification number",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Route & cargo",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
token: "{{schedule.originLabel}}",
|
||||||
|
label: "Origin",
|
||||||
|
icon: MapPin,
|
||||||
|
hint: "Origin yard / station",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: "{{schedule.destinationLabel}}",
|
||||||
|
label: "Destination",
|
||||||
|
icon: MapPin,
|
||||||
|
hint: "Destination yard / station",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: "{{schedule.serviceType}}",
|
||||||
|
label: "Service type",
|
||||||
|
icon: Settings2,
|
||||||
|
hint: "Contracted service type name",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: "{{schedule.cargoDescription}}",
|
||||||
|
label: "Cargo description",
|
||||||
|
icon: Package,
|
||||||
|
hint: "Description of the cargo",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: "{{schedule.totalWeightVgm}}",
|
||||||
|
label: "Total weight",
|
||||||
|
icon: Weight,
|
||||||
|
hint: "Total verified gross mass",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: "{{schedule.equipmentReturn}}",
|
||||||
|
label: "Equipment return",
|
||||||
|
icon: RefreshCw,
|
||||||
|
hint: "Empty-equipment return terms",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: "{{schedule.scheduledDate}}",
|
||||||
|
label: "Scheduled date",
|
||||||
|
icon: CalendarClock,
|
||||||
|
hint: "Scheduled shipment date",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Pricing",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
token: "{{pricing.currency}}",
|
||||||
|
label: "Currency",
|
||||||
|
icon: Coins,
|
||||||
|
hint: "Payment currency (e.g. USD)",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Service provider (EDR)",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
token: "{{provider.name}}",
|
||||||
|
label: "Provider name",
|
||||||
|
icon: Building2,
|
||||||
|
hint: "EDR legal company name",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: "{{provider.address}}",
|
||||||
|
label: "Provider address",
|
||||||
|
icon: MapPin,
|
||||||
|
hint: "EDR principal place of business",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: "{{provider.phone}}",
|
||||||
|
label: "Provider phone",
|
||||||
|
icon: Phone,
|
||||||
|
hint: "EDR phone number",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: "{{provider.email}}",
|
||||||
|
label: "Provider email",
|
||||||
|
icon: Mail,
|
||||||
|
hint: "EDR email address",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const ALL_PLACEHOLDERS: PlaceholderDef[] = [
|
||||||
|
...QUICK_PLACEHOLDERS,
|
||||||
|
...MORE_PLACEHOLDER_GROUPS.flatMap((g) => g.items),
|
||||||
|
];
|
||||||
|
|
||||||
|
const KNOWN_TOKENS = new Set<string>(ALL_PLACEHOLDERS.map((p) => p.token));
|
||||||
|
|
||||||
|
/** Any {{…}} tokens in the text the renderer does not know how to fill. */
|
||||||
|
function unknownTokens(text: string): string[] {
|
||||||
|
const found = text.match(/\{\{[^{}]+\}\}/g) ?? [];
|
||||||
|
return [...new Set(found.filter((t) => !KNOWN_TOKENS.has(t)))];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParsedClause {
|
||||||
|
text: string;
|
||||||
|
bullets: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParsedBody {
|
||||||
|
/** Set (instead of clauses) when the body is one plain paragraph. */
|
||||||
|
paragraph?: string;
|
||||||
|
clauses: ParsedClause[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirror of the API renderer's rules (contract-article.util.ts): one clause per
|
||||||
|
* line, "- " nests a bullet under the previous clause, and a single bullet-less
|
||||||
|
* clause renders as a plain paragraph instead of a numbered list of one.
|
||||||
|
*/
|
||||||
|
function parseArticleBody(body: string): ParsedBody {
|
||||||
|
const clauses: ParsedClause[] = [];
|
||||||
|
for (const raw of body.split("\n")) {
|
||||||
|
const line = raw.trim();
|
||||||
|
if (!line) continue;
|
||||||
|
if (line.startsWith("- ") && clauses.length > 0) {
|
||||||
|
clauses[clauses.length - 1].bullets.push(line.slice(2).trim());
|
||||||
|
} else {
|
||||||
|
clauses.push({ text: line.replace(/^- /, ""), bullets: [] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (clauses.length === 1 && clauses[0].bullets.length === 0) {
|
||||||
|
return { paragraph: clauses[0].text, clauses: [] };
|
||||||
|
}
|
||||||
|
return { clauses };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render clause text with {{placeholders}} highlighted as green chips. */
|
||||||
|
function HighlightedText({ text }: { text: string }) {
|
||||||
|
const parts = text.split(/(\{\{[^{}]+\}\})/g);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{parts.map((part, i) =>
|
||||||
|
/^\{\{[^{}]+\}\}$/.test(part) ? (
|
||||||
|
<Text
|
||||||
|
key={i}
|
||||||
|
component="span"
|
||||||
|
size="xs"
|
||||||
|
fw={600}
|
||||||
|
c={KNOWN_TOKENS.has(part) ? "edr-green.8" : "red.7"}
|
||||||
|
px={4}
|
||||||
|
style={{
|
||||||
|
borderRadius: 4,
|
||||||
|
background: KNOWN_TOKENS.has(part)
|
||||||
|
? "var(--mantine-color-edr-green-0)"
|
||||||
|
: "var(--mantine-color-red-0)",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{part}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<span key={i}>{part}</span>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function ContractTemplateEditorPage() {
|
export default function ContractTemplateEditorPage() {
|
||||||
const { code } = useParams<{ code: string }>();
|
const { code } = useParams<{ code: string }>();
|
||||||
const { data: template, isLoading } = useContractTemplate(code);
|
const { data: template, isLoading } = useContractTemplate(code);
|
||||||
@@ -79,15 +346,12 @@ export default function ContractTemplateEditorPage() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveArticle = () => {
|
const saveArticle = (values: { title: string; body: string }) => {
|
||||||
if (!articleDraft) return;
|
if (!articleDraft) return;
|
||||||
if (articleDraft.id) {
|
if (articleDraft.id) {
|
||||||
updateArticle.mutate({
|
updateArticle.mutate({ articleId: articleDraft.id, payload: values });
|
||||||
articleId: articleDraft.id,
|
|
||||||
payload: { title: articleDraft.title, body: articleDraft.body },
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
addArticle.mutate({ title: articleDraft.title, body: articleDraft.body });
|
addArticle.mutate(values);
|
||||||
}
|
}
|
||||||
setArticleDraft(null);
|
setArticleDraft(null);
|
||||||
};
|
};
|
||||||
@@ -264,55 +528,14 @@ export default function ContractTemplateEditorPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Add / edit article modal ───────────────────────────────────── */}
|
{/* ── Add / edit article modal ───────────────────────────────────── */}
|
||||||
<Modal
|
|
||||||
opened={Boolean(articleDraft)}
|
|
||||||
onClose={() => setArticleDraft(null)}
|
|
||||||
title={articleDraft?.id ? "Edit article" : "Add article"}
|
|
||||||
size="xl"
|
|
||||||
>
|
|
||||||
{articleDraft && (
|
{articleDraft && (
|
||||||
<Stack gap="sm">
|
<ArticleEditorModal
|
||||||
<TextInput
|
initial={articleDraft}
|
||||||
label="Article title"
|
saving={addArticle.isPending || updateArticle.isPending}
|
||||||
placeholder="e.g. Obligations of the Client"
|
onClose={() => setArticleDraft(null)}
|
||||||
value={articleDraft.title}
|
onSave={saveArticle}
|
||||||
onChange={(event) =>
|
|
||||||
setArticleDraft({ ...articleDraft, title: event.currentTarget.value })
|
|
||||||
}
|
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
<Textarea
|
|
||||||
label="Article body"
|
|
||||||
description={BODY_HINT}
|
|
||||||
value={articleDraft.body}
|
|
||||||
onChange={(event) =>
|
|
||||||
setArticleDraft({ ...articleDraft, body: event.currentTarget.value })
|
|
||||||
}
|
|
||||||
autosize
|
|
||||||
minRows={12}
|
|
||||||
maxRows={24}
|
|
||||||
styles={{ input: { fontFamily: "ui-monospace, monospace", fontSize: 13 } }}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<Group justify="flex-end">
|
|
||||||
<Button variant="default" onClick={() => setArticleDraft(null)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color="edr-green"
|
|
||||||
disabled={
|
|
||||||
articleDraft.title.trim().length < 2 ||
|
|
||||||
articleDraft.body.trim().length < 2
|
|
||||||
}
|
|
||||||
loading={addArticle.isPending || updateArticle.isPending}
|
|
||||||
onClick={saveArticle}
|
|
||||||
>
|
|
||||||
{articleDraft.id ? "Save changes" : "Add article"}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
)}
|
)}
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{/* ── Delete confirm ─────────────────────────────────────────────── */}
|
{/* ── Delete confirm ─────────────────────────────────────────────── */}
|
||||||
<Modal
|
<Modal
|
||||||
@@ -364,6 +587,269 @@ export default function ContractTemplateEditorPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ArticleEditorModalProps {
|
||||||
|
initial: ArticleDraft;
|
||||||
|
saving: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (values: { title: string; body: string }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rich add/edit article editor: placeholder buttons insert at the text cursor
|
||||||
|
* of whichever field (title or body) was focused last, with a live preview of
|
||||||
|
* the numbered clauses exactly as the renderer lays them out.
|
||||||
|
*/
|
||||||
|
function ArticleEditorModal({
|
||||||
|
initial,
|
||||||
|
saving,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
}: ArticleEditorModalProps) {
|
||||||
|
const [title, setTitle] = useState(initial.title);
|
||||||
|
const [body, setBody] = useState(initial.body);
|
||||||
|
|
||||||
|
const titleRef = useRef<HTMLInputElement>(null);
|
||||||
|
const bodyRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
// Placeholders drop into whichever field held the cursor last (body default).
|
||||||
|
const lastFocused = useRef<"title" | "body">("body");
|
||||||
|
|
||||||
|
const insertAtCursor = (snippet: string) => {
|
||||||
|
const isTitle = lastFocused.current === "title";
|
||||||
|
const el = isTitle ? titleRef.current : bodyRef.current;
|
||||||
|
const value = isTitle ? title : body;
|
||||||
|
const start = el?.selectionStart ?? value.length;
|
||||||
|
const end = el?.selectionEnd ?? start;
|
||||||
|
const next = value.slice(0, start) + snippet + value.slice(end);
|
||||||
|
if (isTitle) setTitle(next);
|
||||||
|
else setBody(next);
|
||||||
|
// Refocus and place the caret right after the inserted snippet once the
|
||||||
|
// controlled re-render has flushed.
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (!el) return;
|
||||||
|
el.focus();
|
||||||
|
const caret = start + snippet.length;
|
||||||
|
el.setSelectionRange(caret, caret);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const insertLinePrefix = (prefix: string) => {
|
||||||
|
const el = bodyRef.current;
|
||||||
|
const start = el?.selectionStart ?? body.length;
|
||||||
|
// Start the snippet on its own line unless the caret already is.
|
||||||
|
const needsNewline = start > 0 && body[start - 1] !== "\n";
|
||||||
|
lastFocused.current = "body";
|
||||||
|
insertAtCursor(`${needsNewline ? "\n" : ""}${prefix}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const parsed = useMemo(() => parseArticleBody(body), [body]);
|
||||||
|
const clauseCount = parsed.paragraph ? 1 : parsed.clauses.length;
|
||||||
|
const unknown = useMemo(
|
||||||
|
() => unknownTokens(`${title}\n${body}`),
|
||||||
|
[title, body],
|
||||||
|
);
|
||||||
|
const canSave = title.trim().length >= 2 && body.trim().length >= 2;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened
|
||||||
|
onClose={onClose}
|
||||||
|
title={initial.id ? "Edit article" : "Add article"}
|
||||||
|
size="min(1120px, 95vw)"
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
{/* ── Editor ─────────────────────────────────────────────────── */}
|
||||||
|
<Stack gap="sm">
|
||||||
|
<TextInput
|
||||||
|
ref={titleRef}
|
||||||
|
label="Article title"
|
||||||
|
placeholder="e.g. Obligations of the Client"
|
||||||
|
value={title}
|
||||||
|
onChange={(event) => setTitle(event.currentTarget.value)}
|
||||||
|
onFocus={() => (lastFocused.current = "title")}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Text size="sm" fw={500} mb={4}>
|
||||||
|
Insert placeholder
|
||||||
|
</Text>
|
||||||
|
<Group gap={6} wrap="wrap">
|
||||||
|
{QUICK_PLACEHOLDERS.map(({ token, label, icon: Icon, hint }) => (
|
||||||
|
<Tooltip key={token} label={hint} withArrow>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="edr-green"
|
||||||
|
size="compact-sm"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<Icon size={13} />}
|
||||||
|
// Keep the field's focus/caret alive so insertion lands
|
||||||
|
// where the user was typing.
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => insertAtCursor(token)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
|
<Menu shadow="md" width={300} position="bottom-start">
|
||||||
|
<Menu.Target>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="compact-sm"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<Plus size={13} />}
|
||||||
|
rightSection={<ChevronDown size={13} />}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
More
|
||||||
|
</Button>
|
||||||
|
</Menu.Target>
|
||||||
|
<Menu.Dropdown mah={340} style={{ overflowY: "auto" }}>
|
||||||
|
{MORE_PLACEHOLDER_GROUPS.map((group) => (
|
||||||
|
<Box key={group.label}>
|
||||||
|
<Menu.Label>{group.label}</Menu.Label>
|
||||||
|
{group.items.map(({ token, label, icon: Icon, hint }) => (
|
||||||
|
<Menu.Item
|
||||||
|
key={token}
|
||||||
|
leftSection={<Icon size={14} />}
|
||||||
|
onClick={() => insertAtCursor(token)}
|
||||||
|
>
|
||||||
|
<Text size="sm">{label}</Text>
|
||||||
|
<Text size="xs" c="dimmed" title={hint}>
|
||||||
|
{token}
|
||||||
|
</Text>
|
||||||
|
</Menu.Item>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
<Tooltip label="Start a new numbered clause" withArrow>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="compact-sm"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<ListOrdered size={13} />}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => insertLinePrefix("")}
|
||||||
|
>
|
||||||
|
New clause
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip label="Nest a bullet under the previous clause" withArrow>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="compact-sm"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<ListPlus size={13} />}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => insertLinePrefix("- ")}
|
||||||
|
>
|
||||||
|
Bullet
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
ref={bodyRef}
|
||||||
|
label="Article body"
|
||||||
|
description={BODY_HINT}
|
||||||
|
value={body}
|
||||||
|
onChange={(event) => setBody(event.currentTarget.value)}
|
||||||
|
onFocus={() => (lastFocused.current = "body")}
|
||||||
|
autosize
|
||||||
|
minRows={12}
|
||||||
|
maxRows={22}
|
||||||
|
styles={{
|
||||||
|
input: { fontFamily: "ui-monospace, monospace", fontSize: 13 },
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
{unknown.length > 0 && (
|
||||||
|
<Group gap={6} wrap="nowrap" align="flex-start">
|
||||||
|
<AlertTriangle size={14} className="mt-0.5 shrink-0 text-red-600" />
|
||||||
|
<Text size="xs" c="red.7">
|
||||||
|
Unknown placeholder{unknown.length > 1 ? "s" : ""}{" "}
|
||||||
|
{unknown.join(", ")} — the generator won't fill{" "}
|
||||||
|
{unknown.length > 1 ? "these" : "this"}. Pick from the Insert
|
||||||
|
placeholder buttons instead.
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* ── Live preview ───────────────────────────────────────────── */}
|
||||||
|
<Paper withBorder radius="md" p="md" className="self-start lg:sticky lg:top-0">
|
||||||
|
<Group justify="space-between" mb="xs">
|
||||||
|
<Text fw={600} size="sm">
|
||||||
|
Live preview
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{clauseCount} clause{clauseCount !== 1 ? "s" : ""}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<ScrollArea.Autosize mah="60vh">
|
||||||
|
{title.trim() || clauseCount > 0 ? (
|
||||||
|
<Stack gap="xs">
|
||||||
|
{title.trim() && (
|
||||||
|
<Title order={5}>
|
||||||
|
<HighlightedText text={title} />
|
||||||
|
</Title>
|
||||||
|
)}
|
||||||
|
{parsed.paragraph && (
|
||||||
|
<Text size="sm">
|
||||||
|
<HighlightedText text={parsed.paragraph} />
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{parsed.clauses.map((clause, i) => (
|
||||||
|
<Box key={i}>
|
||||||
|
<Text size="sm">
|
||||||
|
<Text component="span" fw={600} c="edr-green.7">
|
||||||
|
{i + 1}.{" "}
|
||||||
|
</Text>
|
||||||
|
<HighlightedText text={clause.text} />
|
||||||
|
</Text>
|
||||||
|
{clause.bullets.length > 0 && (
|
||||||
|
<Stack gap={2} mt={2} pl="lg">
|
||||||
|
{clause.bullets.map((bullet, j) => (
|
||||||
|
<Text key={j} size="sm" c="dimmed">
|
||||||
|
• <HighlightedText text={bullet} />
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Text size="sm" c="dimmed" ta="center" py="xl">
|
||||||
|
Start typing — the article renders here exactly as it will
|
||||||
|
appear in the contract.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</ScrollArea.Autosize>
|
||||||
|
</Paper>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Group justify="flex-end" mt="md">
|
||||||
|
<Button variant="default" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
disabled={!canSave}
|
||||||
|
loading={saving}
|
||||||
|
onClick={() => onSave({ title: title.trim(), body: body.trim() })}
|
||||||
|
>
|
||||||
|
{initial.id ? "Save changes" : "Add article"}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface DocumentDetailsModalProps {
|
interface DocumentDetailsModalProps {
|
||||||
opened: boolean;
|
opened: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -6,14 +6,26 @@ import {
|
|||||||
Badge,
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
|
CloseButton,
|
||||||
Group,
|
Group,
|
||||||
Modal,
|
Modal,
|
||||||
|
Paper,
|
||||||
|
SegmentedControl,
|
||||||
|
Select,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
Textarea,
|
Textarea,
|
||||||
TextInput,
|
TextInput,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { Inbox, PackageSearch, RefreshCw, Search } from "lucide-react";
|
import { DateInput } from "@mantine/dates";
|
||||||
|
import {
|
||||||
|
ArrowUpDown,
|
||||||
|
FilterX,
|
||||||
|
Inbox,
|
||||||
|
PackageSearch,
|
||||||
|
RefreshCw,
|
||||||
|
Search,
|
||||||
|
} from "lucide-react";
|
||||||
import { DataTable, type ColumnDef } from "@edr/ui-common";
|
import { DataTable, type ColumnDef } from "@edr/ui-common";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
@@ -41,6 +53,17 @@ const fmtDate = (iso?: string | null) =>
|
|||||||
}).format(new Date(iso))
|
}).format(new Date(iso))
|
||||||
: "—";
|
: "—";
|
||||||
|
|
||||||
|
const fmtDateTime = (iso?: string | null) =>
|
||||||
|
iso
|
||||||
|
? new Intl.DateTimeFormat("en-GB", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
}).format(new Date(iso))
|
||||||
|
: "—";
|
||||||
|
|
||||||
function summarizeLines(lines: Freight.RequestedShipmentLines): string {
|
function summarizeLines(lines: Freight.RequestedShipmentLines): string {
|
||||||
if (lines.containers?.length) {
|
if (lines.containers?.length) {
|
||||||
return lines.containers
|
return lines.containers
|
||||||
@@ -56,10 +79,46 @@ function summarizeLines(lines: Freight.RequestedShipmentLines): string {
|
|||||||
return "—";
|
return "—";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const STATUS_META: Record<
|
||||||
|
Freight.BookingRequestStatus,
|
||||||
|
{ label: string; color: string }
|
||||||
|
> = {
|
||||||
|
PENDING: { label: "Pending", color: "yellow" },
|
||||||
|
ACCEPTED: { label: "Accepted", color: "edr-green" },
|
||||||
|
REJECTED: { label: "Rejected", color: "red" },
|
||||||
|
CANCELLED: { label: "Cancelled", color: "gray" },
|
||||||
|
};
|
||||||
|
|
||||||
|
type StatusFilter = "ALL" | Freight.BookingRequestStatus;
|
||||||
|
type CargoFilter = "ALL" | "CONTAINER" | "BULK";
|
||||||
|
type SortKey =
|
||||||
|
| "submitted-desc"
|
||||||
|
| "submitted-asc"
|
||||||
|
| "preferred-asc"
|
||||||
|
| "preferred-desc"
|
||||||
|
| "reference";
|
||||||
|
|
||||||
|
const SORT_OPTIONS: Array<{ value: SortKey; label: string }> = [
|
||||||
|
{ value: "submitted-desc", label: "Newest first" },
|
||||||
|
{ value: "submitted-asc", label: "Oldest first" },
|
||||||
|
{ value: "preferred-asc", label: "Preferred date (soonest)" },
|
||||||
|
{ value: "preferred-desc", label: "Preferred date (latest)" },
|
||||||
|
{ value: "reference", label: "Reference A–Z" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const time = (iso?: string | null) => (iso ? new Date(iso).getTime() : 0);
|
||||||
|
|
||||||
export default function ShipmentRequestsPage() {
|
export default function ShipmentRequestsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
|
const [status, setStatus] = useState<StatusFilter>("PENDING");
|
||||||
|
const [cargo, setCargo] = useState<CargoFilter>("ALL");
|
||||||
|
const [preferredFrom, setPreferredFrom] = useState<Date | null>(null);
|
||||||
|
const [preferredTo, setPreferredTo] = useState<Date | null>(null);
|
||||||
|
const [sort, setSort] = useState<SortKey>("submitted-desc");
|
||||||
|
|
||||||
const [rejectTarget, setRejectTarget] = useState<ShipmentListRow | null>(null);
|
const [rejectTarget, setRejectTarget] = useState<ShipmentListRow | null>(null);
|
||||||
const [rejectNote, setRejectNote] = useState("");
|
const [rejectNote, setRejectNote] = useState("");
|
||||||
const [acceptTarget, setAcceptTarget] = useState<ShipmentListRow | null>(null);
|
const [acceptTarget, setAcceptTarget] = useState<ShipmentListRow | null>(null);
|
||||||
@@ -80,26 +139,132 @@ export default function ShipmentRequestsPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const rows = useMemo<ShipmentListRow[]>(() => {
|
const allRows = useMemo<ShipmentListRow[]>(
|
||||||
const all = (data ?? []).map((r) => ({
|
() =>
|
||||||
|
(data ?? []).map((r) => {
|
||||||
|
const lines = r.requestedLines ?? {};
|
||||||
|
return {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
reference: r.reference || r.id.slice(0, 8),
|
reference: r.reference || r.id.slice(0, 8),
|
||||||
contractId: r.contractId,
|
contractId: r.contractId,
|
||||||
contractReference: r.contract?.reference ?? r.contractId,
|
contractReference: r.contract?.reference ?? r.contractId,
|
||||||
scheduledDate: r.scheduledDate,
|
scheduledDate: r.scheduledDate,
|
||||||
summary: summarizeLines(r.requestedLines ?? {}),
|
summary: summarizeLines(lines),
|
||||||
status: r.status,
|
status: r.status,
|
||||||
createdBookingId: r.createdBookingId,
|
createdBookingId: r.createdBookingId,
|
||||||
}));
|
createdAt: r.createdAt,
|
||||||
|
customerName: r.contract?.company?.name ?? null,
|
||||||
|
freightKind: lines.containers?.length
|
||||||
|
? "CONTAINER"
|
||||||
|
: lines.bulk
|
||||||
|
? "BULK"
|
||||||
|
: r.contract?.freightType === "BULK"
|
||||||
|
? "BULK"
|
||||||
|
: "CONTAINER",
|
||||||
|
hazardous:
|
||||||
|
(lines.containers ?? []).some((c) => (c.hazardousQuantity ?? 0) > 0) ||
|
||||||
|
(lines.bulk?.hazardousQuantity ?? 0) > 0,
|
||||||
|
reefer: (lines.containers ?? []).some(
|
||||||
|
(c) => (c.reeferQuantity ?? 0) > 0,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
[data],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Status counts always reflect the whole queue so the segmented control
|
||||||
|
// reads as a live overview, independent of the other filters.
|
||||||
|
const counts = useMemo(() => {
|
||||||
|
const c: Record<StatusFilter, number> = {
|
||||||
|
ALL: allRows.length,
|
||||||
|
PENDING: 0,
|
||||||
|
ACCEPTED: 0,
|
||||||
|
REJECTED: 0,
|
||||||
|
CANCELLED: 0,
|
||||||
|
};
|
||||||
|
allRows.forEach((r) => {
|
||||||
|
c[r.status] += 1;
|
||||||
|
});
|
||||||
|
return c;
|
||||||
|
}, [allRows]);
|
||||||
|
|
||||||
|
const rows = useMemo<ShipmentListRow[]>(() => {
|
||||||
|
let out = allRows;
|
||||||
|
|
||||||
|
if (status !== "ALL") out = out.filter((r) => r.status === status);
|
||||||
|
if (cargo !== "ALL") out = out.filter((r) => r.freightKind === cargo);
|
||||||
|
|
||||||
|
// Preferred-date range: rows without a preferred day drop out once a bound
|
||||||
|
// is set — a date filter that keeps dateless rows reads as broken.
|
||||||
|
if (preferredFrom || preferredTo) {
|
||||||
|
const from = preferredFrom ? preferredFrom.getTime() : -Infinity;
|
||||||
|
const to = preferredTo
|
||||||
|
? preferredTo.getTime() + 24 * 60 * 60 * 1000 - 1
|
||||||
|
: Infinity;
|
||||||
|
out = out.filter((r) => {
|
||||||
|
if (!r.scheduledDate) return false;
|
||||||
|
const t = time(r.scheduledDate);
|
||||||
|
return t >= from && t <= to;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
if (!q) return all;
|
if (q) {
|
||||||
return all.filter(
|
out = out.filter(
|
||||||
(r) =>
|
(r) =>
|
||||||
r.reference.toLowerCase().includes(q) ||
|
r.reference.toLowerCase().includes(q) ||
|
||||||
r.contractReference.toLowerCase().includes(q) ||
|
r.contractReference.toLowerCase().includes(q) ||
|
||||||
|
(r.customerName ?? "").toLowerCase().includes(q) ||
|
||||||
r.summary.toLowerCase().includes(q),
|
r.summary.toLowerCase().includes(q),
|
||||||
);
|
);
|
||||||
}, [data, query]);
|
}
|
||||||
|
|
||||||
|
const sorted = [...out];
|
||||||
|
switch (sort) {
|
||||||
|
case "submitted-asc":
|
||||||
|
sorted.sort((a, b) => time(a.createdAt) - time(b.createdAt));
|
||||||
|
break;
|
||||||
|
case "preferred-asc":
|
||||||
|
// Requests without a preferred day sink to the bottom in both orders.
|
||||||
|
sorted.sort(
|
||||||
|
(a, b) =>
|
||||||
|
(a.scheduledDate ? time(a.scheduledDate) : Infinity) -
|
||||||
|
(b.scheduledDate ? time(b.scheduledDate) : Infinity),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "preferred-desc":
|
||||||
|
sorted.sort(
|
||||||
|
(a, b) =>
|
||||||
|
(b.scheduledDate ? time(b.scheduledDate) : -Infinity) -
|
||||||
|
(a.scheduledDate ? time(a.scheduledDate) : -Infinity),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "reference":
|
||||||
|
sorted.sort((a, b) => a.reference.localeCompare(b.reference));
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// Newest submitted on top.
|
||||||
|
sorted.sort((a, b) => time(b.createdAt) - time(a.createdAt));
|
||||||
|
}
|
||||||
|
return sorted;
|
||||||
|
}, [allRows, status, cargo, preferredFrom, preferredTo, query, sort]);
|
||||||
|
|
||||||
|
const filtersActive =
|
||||||
|
query.trim() !== "" ||
|
||||||
|
status !== "PENDING" ||
|
||||||
|
cargo !== "ALL" ||
|
||||||
|
preferredFrom !== null ||
|
||||||
|
preferredTo !== null ||
|
||||||
|
sort !== "submitted-desc";
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setQuery("");
|
||||||
|
setStatus("PENDING");
|
||||||
|
setCargo("ALL");
|
||||||
|
setPreferredFrom(null);
|
||||||
|
setPreferredTo(null);
|
||||||
|
setSort("submitted-desc");
|
||||||
|
};
|
||||||
|
|
||||||
const columns = useMemo<ColumnDef<ShipmentListRow>[]>(
|
const columns = useMemo<ColumnDef<ShipmentListRow>[]>(
|
||||||
() => [
|
() => [
|
||||||
@@ -108,9 +273,14 @@ export default function ShipmentRequestsPage() {
|
|||||||
header: "Request",
|
header: "Request",
|
||||||
meta: cellMeta,
|
meta: cellMeta,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
|
<Box>
|
||||||
<Text size="sm" fw={700} c="dark.5">
|
<Text size="sm" fw={700} c="dark.5">
|
||||||
{row.original.reference}
|
{row.original.reference}
|
||||||
</Text>
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" mt={2}>
|
||||||
|
Submitted {fmtDateTime(row.original.createdAt)}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -118,9 +288,16 @@ export default function ShipmentRequestsPage() {
|
|||||||
header: "Contract",
|
header: "Contract",
|
||||||
meta: cellMeta,
|
meta: cellMeta,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
|
<Box>
|
||||||
<Text size="sm" c="gray.7">
|
<Text size="sm" c="gray.7">
|
||||||
{row.original.contractReference}
|
{row.original.contractReference}
|
||||||
</Text>
|
</Text>
|
||||||
|
{row.original.customerName ? (
|
||||||
|
<Text size="xs" c="dimmed" mt={2}>
|
||||||
|
{row.original.customerName}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -128,9 +305,21 @@ export default function ShipmentRequestsPage() {
|
|||||||
header: "Requested",
|
header: "Requested",
|
||||||
meta: cellMeta,
|
meta: cellMeta,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
|
<Group gap={6} wrap="wrap">
|
||||||
<Badge variant="light" color="edr-green" radius="sm">
|
<Badge variant="light" color="edr-green" radius="sm">
|
||||||
{row.original.summary}
|
{row.original.summary}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{row.original.hazardous ? (
|
||||||
|
<Badge variant="light" color="red" radius="sm">
|
||||||
|
Hazardous
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
{row.original.reefer ? (
|
||||||
|
<Badge variant="light" color="blue" radius="sm">
|
||||||
|
Reefer
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -141,6 +330,19 @@ export default function ShipmentRequestsPage() {
|
|||||||
<Text size="sm">{fmtDate(row.original.scheduledDate)}</Text>
|
<Text size="sm">{fmtDate(row.original.scheduledDate)}</Text>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "status",
|
||||||
|
header: "Status",
|
||||||
|
meta: cellMeta,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const meta = STATUS_META[row.original.status];
|
||||||
|
return (
|
||||||
|
<Badge variant="light" color={meta.color} radius="sm">
|
||||||
|
{meta.label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
header: () => <span className={ruleEngineTable.headerCell}>Action</span>,
|
header: () => <span className={ruleEngineTable.headerCell}>Action</span>,
|
||||||
@@ -193,6 +395,8 @@ export default function ShipmentRequestsPage() {
|
|||||||
[navigate],
|
[navigate],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const hasAnyRequests = allRows.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
@@ -206,7 +410,7 @@ export default function ShipmentRequestsPage() {
|
|||||||
radius="sm"
|
radius="sm"
|
||||||
leftSection={<PackageSearch size={13} />}
|
leftSection={<PackageSearch size={13} />}
|
||||||
>
|
>
|
||||||
{rows.length} pending
|
{counts.PENDING} pending
|
||||||
</Badge>
|
</Badge>
|
||||||
}
|
}
|
||||||
action={
|
action={
|
||||||
@@ -223,16 +427,108 @@ export default function ShipmentRequestsPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Paper withBorder radius="lg" p="md" style={{ borderColor: "#E6ECF2" }}>
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Group gap="sm" wrap="wrap">
|
||||||
<TextInput
|
<TextInput
|
||||||
radius="md"
|
radius="md"
|
||||||
maw={360}
|
style={{ flex: 1, minWidth: 220 }}
|
||||||
placeholder="Search request, contract, cargo…"
|
placeholder="Search request, contract, customer, cargo…"
|
||||||
leftSection={<Search size={15} />}
|
leftSection={<Search size={15} />}
|
||||||
|
rightSection={
|
||||||
|
query ? (
|
||||||
|
<CloseButton
|
||||||
|
size="sm"
|
||||||
|
aria-label="Clear search"
|
||||||
|
onClick={() => setQuery("")}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.currentTarget.value)}
|
onChange={(e) => setQuery(e.currentTarget.value)}
|
||||||
/>
|
/>
|
||||||
|
<Select
|
||||||
|
radius="md"
|
||||||
|
w={150}
|
||||||
|
value={cargo}
|
||||||
|
onChange={(v) => setCargo((v as CargoFilter) ?? "ALL")}
|
||||||
|
data={[
|
||||||
|
{ value: "ALL", label: "All cargo" },
|
||||||
|
{ value: "CONTAINER", label: "Containers" },
|
||||||
|
{ value: "BULK", label: "Bulk" },
|
||||||
|
]}
|
||||||
|
allowDeselect={false}
|
||||||
|
aria-label="Cargo type"
|
||||||
|
/>
|
||||||
|
<DateInput
|
||||||
|
radius="md"
|
||||||
|
w={150}
|
||||||
|
placeholder="Preferred from"
|
||||||
|
value={preferredFrom}
|
||||||
|
onChange={(v) => setPreferredFrom(v ? new Date(v) : null)}
|
||||||
|
maxDate={preferredTo ?? undefined}
|
||||||
|
clearable
|
||||||
|
aria-label="Preferred date from"
|
||||||
|
/>
|
||||||
|
<DateInput
|
||||||
|
radius="md"
|
||||||
|
w={150}
|
||||||
|
placeholder="Preferred to"
|
||||||
|
value={preferredTo}
|
||||||
|
onChange={(v) => setPreferredTo(v ? new Date(v) : null)}
|
||||||
|
minDate={preferredFrom ?? undefined}
|
||||||
|
clearable
|
||||||
|
aria-label="Preferred date to"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
radius="md"
|
||||||
|
w={215}
|
||||||
|
leftSection={<ArrowUpDown size={14} />}
|
||||||
|
value={sort}
|
||||||
|
onChange={(v) => setSort((v as SortKey) ?? "submitted-desc")}
|
||||||
|
data={SORT_OPTIONS}
|
||||||
|
allowDeselect={false}
|
||||||
|
aria-label="Sort by"
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
{rows.length === 0 && !isLoading ? (
|
<Group justify="space-between" gap="sm" wrap="wrap">
|
||||||
|
<SegmentedControl
|
||||||
|
radius="md"
|
||||||
|
size="xs"
|
||||||
|
value={status}
|
||||||
|
onChange={(v) => setStatus(v as StatusFilter)}
|
||||||
|
data={[
|
||||||
|
{ value: "ALL", label: `All · ${counts.ALL}` },
|
||||||
|
{ value: "PENDING", label: `Pending · ${counts.PENDING}` },
|
||||||
|
{ value: "ACCEPTED", label: `Accepted · ${counts.ACCEPTED}` },
|
||||||
|
{ value: "REJECTED", label: `Rejected · ${counts.REJECTED}` },
|
||||||
|
{ value: "CANCELLED", label: `Cancelled · ${counts.CANCELLED}` },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Group gap="sm">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{rows.length} of {allRows.length} request
|
||||||
|
{allRows.length === 1 ? "" : "s"}
|
||||||
|
</Text>
|
||||||
|
{filtersActive ? (
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
size="compact-sm"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<FilterX size={14} />}
|
||||||
|
onClick={clearFilters}
|
||||||
|
>
|
||||||
|
Clear filters
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{rows.length === 0 && !isLoading && !isError ? (
|
||||||
<Box
|
<Box
|
||||||
py={56}
|
py={56}
|
||||||
style={{
|
style={{
|
||||||
@@ -242,9 +538,28 @@ export default function ShipmentRequestsPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Inbox size={26} className="text-muted-foreground" />
|
<Inbox size={26} className="text-muted-foreground" />
|
||||||
|
{hasAnyRequests ? (
|
||||||
|
<>
|
||||||
<Text c="dimmed" mt="sm">
|
<Text c="dimmed" mt="sm">
|
||||||
No pending shipment requests.
|
No requests match the current filters.
|
||||||
</Text>
|
</Text>
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
size="compact-sm"
|
||||||
|
radius="md"
|
||||||
|
mt="xs"
|
||||||
|
leftSection={<FilterX size={14} />}
|
||||||
|
onClick={clearFilters}
|
||||||
|
>
|
||||||
|
Clear filters
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Text c="dimmed" mt="sm">
|
||||||
|
No shipment requests yet.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<DataTable
|
<DataTable
|
||||||
|
|||||||
@@ -201,6 +201,11 @@ export interface BookingDetail {
|
|||||||
latestChangeRequestNote?: string | null;
|
latestChangeRequestNote?: string | null;
|
||||||
nextStep?: BookingNextStep | null;
|
nextStep?: BookingNextStep | null;
|
||||||
paymentReceipt?: InAppPaymentReceipt;
|
paymentReceipt?: InAppPaymentReceipt;
|
||||||
|
/** Phased-clearance fields the ET/DJ queue rows surface (GENERAL customs bookings). */
|
||||||
|
clearanceCurrentPhase?: string | null;
|
||||||
|
roHoldReason?: string | null;
|
||||||
|
roAmendmentRequestedAt?: string | null;
|
||||||
|
preClearanceFinalizedAt?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
// customer?: BookingNamedRef & { companyName?: string };
|
// customer?: BookingNamedRef & { companyName?: string };
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
Modal,
|
Modal,
|
||||||
Paper,
|
Paper,
|
||||||
Stack,
|
Stack,
|
||||||
|
Switch,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
Textarea,
|
Textarea,
|
||||||
@@ -32,6 +33,7 @@ import {
|
|||||||
MapPin,
|
MapPin,
|
||||||
Package,
|
Package,
|
||||||
Receipt,
|
Receipt,
|
||||||
|
Repeat,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
@@ -230,7 +232,12 @@ function NewShipmentBookingForm({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const form = useForm<ShipmentFormInputValues, any, ShipmentFormValues>({
|
const form = useForm<ShipmentFormInputValues, any, ShipmentFormValues>({
|
||||||
defaultValues: initialShipmentFormValues,
|
defaultValues: {
|
||||||
|
...initialShipmentFormValues,
|
||||||
|
// Seed the equipment-return toggle from the contract; the customer can
|
||||||
|
// still flip it per shipment.
|
||||||
|
withReturn: contract.equipmentReturn === "WITH_RETURN",
|
||||||
|
},
|
||||||
resolver: zodResolver(
|
resolver: zodResolver(
|
||||||
createShipmentFormSchema({
|
createShipmentFormSchema({
|
||||||
isContainer: contract.freightType === "CONTAINER",
|
isContainer: contract.freightType === "CONTAINER",
|
||||||
@@ -276,8 +283,10 @@ function NewShipmentBookingForm({
|
|||||||
...(values.scheduledDate
|
...(values.scheduledDate
|
||||||
? { scheduledDate: new Date(values.scheduledDate).toISOString() }
|
? { scheduledDate: new Date(values.scheduledDate).toISOString() }
|
||||||
: {}),
|
: {}),
|
||||||
|
// Equipment return is a container concern — bulk keeps the contract default.
|
||||||
...(isContainer
|
...(isContainer
|
||||||
? {
|
? {
|
||||||
|
equipmentReturn: values.withReturn ? "WITH_RETURN" : "WITHOUT_RETURN",
|
||||||
containers: values.containers
|
containers: values.containers
|
||||||
.filter((l) => Number(l.quantity) >= 1)
|
.filter((l) => Number(l.quantity) >= 1)
|
||||||
.map((l) => ({
|
.map((l) => ({
|
||||||
@@ -405,6 +414,9 @@ function NewShipmentBookingForm({
|
|||||||
<Stack gap="lg" className="mx-auto max-w-4xl">
|
<Stack gap="lg" className="mx-auto max-w-4xl">
|
||||||
<RouteStep form={form} contract={contract} routes={routes} />
|
<RouteStep form={form} contract={contract} routes={routes} />
|
||||||
<CargoStep form={form} contract={contract} />
|
<CargoStep form={form} contract={contract} />
|
||||||
|
{contract.freightType === "CONTAINER" && (
|
||||||
|
<EquipmentReturnStep form={form} />
|
||||||
|
)}
|
||||||
<ScheduleStep form={form} contract={contract} routes={routes} />
|
<ScheduleStep form={form} contract={contract} routes={routes} />
|
||||||
<NotesSection form={form} />
|
<NotesSection form={form} />
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -1187,6 +1199,78 @@ function CargoStep({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function EquipmentReturnStep({ form }: { form: ShipmentForm }) {
|
||||||
|
return (
|
||||||
|
<StepCard>
|
||||||
|
<StepHeader
|
||||||
|
icon={<Repeat size={22} />}
|
||||||
|
title="Equipment Return"
|
||||||
|
description="Choose whether the empty container(s) come back to EDR after unloading."
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="withReturn"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => {
|
||||||
|
const on = field.value ?? false;
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
withBorder
|
||||||
|
radius="md"
|
||||||
|
p="md"
|
||||||
|
style={{
|
||||||
|
borderColor: on ? "#CDEBDD" : "#E6ECF2",
|
||||||
|
background: on ? "#F6FBF8" : "white",
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "border-color 150ms ease, background 150ms ease",
|
||||||
|
}}
|
||||||
|
onClick={() => field.onChange(!on)}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" wrap="nowrap" gap="md">
|
||||||
|
<Group gap={13} wrap="nowrap" align="flex-start">
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
width: 38,
|
||||||
|
height: 38,
|
||||||
|
flexShrink: 0,
|
||||||
|
borderRadius: 11,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
background: on ? "#ECF6F1" : "#F1F4F7",
|
||||||
|
color: on ? "#0A6F4D" : "#6B7C8E",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Repeat size={18} />
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text fz={14} fw={700} c="#10202F">
|
||||||
|
With return
|
||||||
|
</Text>
|
||||||
|
<Text fz={12} c="dimmed" style={{ lineHeight: 1.4 }}>
|
||||||
|
{on
|
||||||
|
? "Container(s) returned to EDR after unloading."
|
||||||
|
: "Container(s) retained by you after delivery."}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
<Switch
|
||||||
|
size="md"
|
||||||
|
color="edr-green"
|
||||||
|
aria-label="With return"
|
||||||
|
checked={on}
|
||||||
|
onChange={(e) => field.onChange(e.currentTarget.checked)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</StepCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function NotesSection({ form }: { form: ShipmentForm }) {
|
function NotesSection({ form }: { form: ShipmentForm }) {
|
||||||
return (
|
return (
|
||||||
<StepCard>
|
<StepCard>
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ const containerLineSchema = z.object({
|
|||||||
const shipmentFormBase = z.object({
|
const shipmentFormBase = z.object({
|
||||||
contractRouteId: z.string().default(""),
|
contractRouteId: z.string().default(""),
|
||||||
scheduledDate: z.string().default(""),
|
scheduledDate: z.string().default(""),
|
||||||
|
// Container contracts only: return the empty container(s) to EDR after
|
||||||
|
// unloading. Seeded from the contract's equipment return; bulk ignores it.
|
||||||
|
withReturn: z.boolean().default(false),
|
||||||
containers: z.array(containerLineSchema).default([]),
|
containers: z.array(containerLineSchema).default([]),
|
||||||
cargoWeightTons: z.string().default(""),
|
cargoWeightTons: z.string().default(""),
|
||||||
itemCount: z.string().default(""),
|
itemCount: z.string().default(""),
|
||||||
@@ -210,6 +213,7 @@ export type ShipmentFormInputValues = z.input<typeof shipmentFormSchema>;
|
|||||||
export const initialShipmentFormValues: DeepPartial<ShipmentFormValues> = {
|
export const initialShipmentFormValues: DeepPartial<ShipmentFormValues> = {
|
||||||
contractRouteId: "",
|
contractRouteId: "",
|
||||||
scheduledDate: "",
|
scheduledDate: "",
|
||||||
|
withReturn: false,
|
||||||
containers: [],
|
containers: [],
|
||||||
cargoWeightTons: "",
|
cargoWeightTons: "",
|
||||||
itemCount: "",
|
itemCount: "",
|
||||||
@@ -229,6 +233,7 @@ export const shipmentStepFields: Record<
|
|||||||
"itemCount",
|
"itemCount",
|
||||||
"bulkHazardousQuantity",
|
"bulkHazardousQuantity",
|
||||||
"bulkReeferQuantity",
|
"bulkReeferQuantity",
|
||||||
|
"withReturn",
|
||||||
],
|
],
|
||||||
2: ["scheduledDate"],
|
2: ["scheduledDate"],
|
||||||
3: ["notes"],
|
3: ["notes"],
|
||||||
|
|||||||
@@ -705,6 +705,8 @@ export interface CreateBookingUnderContractDto {
|
|||||||
contractRouteId?: string;
|
contractRouteId?: string;
|
||||||
/** Binding shipment day. Omitted for intercity (DOMESTIC) bookings — staff assign a passing train later. */
|
/** Binding shipment day. Omitted for intercity (DOMESTIC) bookings — staff assign a passing train later. */
|
||||||
scheduledDate?: string;
|
scheduledDate?: string;
|
||||||
|
/** "WITH_RETURN" | "WITHOUT_RETURN" — per-shipment override; falls back to the contract's equipment return. */
|
||||||
|
equipmentReturn?: string;
|
||||||
containers?: CreateBookingContainerLineDto[];
|
containers?: CreateBookingContainerLineDto[];
|
||||||
bulkLines?: CreateBulkLineDto[];
|
bulkLines?: CreateBulkLineDto[];
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user