mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat: full wagon cancel, leg board, wagon dates
feat(freight): editable train leg times, SL invoice payer
This commit is contained in:
@@ -252,7 +252,46 @@ export class BillingService {
|
|||||||
this.applyInvoiceFilters(qb, filter);
|
this.applyInvoiceFilters(qb, filter);
|
||||||
|
|
||||||
const [items, total] = await qb.getManyAndCount();
|
const [items, total] = await qb.getManyAndCount();
|
||||||
return { items, total };
|
return { items: await this.attachShippingLineCompanies(items), total };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch-hydrate `shippingLineCompany` for any invoice billed to a shipping
|
||||||
|
* line (`companyId` null). No relation on `Invoice` to eager-load — see the
|
||||||
|
* entity's doc comment — so this is a second query keyed off the ids
|
||||||
|
* already loaded, same shape as `company`.
|
||||||
|
*/
|
||||||
|
private async attachShippingLineCompanies<T extends Invoice>(
|
||||||
|
invoices: T[],
|
||||||
|
): Promise<T[]> {
|
||||||
|
const ids = [
|
||||||
|
...new Set(
|
||||||
|
invoices
|
||||||
|
.map((i) => i.shippingLineCompanyId)
|
||||||
|
.filter((id): id is string => id != null),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
if (!ids.length) return invoices;
|
||||||
|
const lines = await this.dataSource
|
||||||
|
.getRepository(ShippingLineCompany)
|
||||||
|
.find({ where: { id: In(ids) } });
|
||||||
|
const byId = new Map(lines.map((l) => [l.id, l]));
|
||||||
|
return invoices.map((invoice) => {
|
||||||
|
const line = invoice.shippingLineCompanyId
|
||||||
|
? byId.get(invoice.shippingLineCompanyId)
|
||||||
|
: undefined;
|
||||||
|
return line
|
||||||
|
? ({
|
||||||
|
...invoice,
|
||||||
|
shippingLineCompany: {
|
||||||
|
id: line.id,
|
||||||
|
name: line.name,
|
||||||
|
email: line.email,
|
||||||
|
phoneNumber: line.phoneNumber,
|
||||||
|
},
|
||||||
|
} as T)
|
||||||
|
: invoice;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -432,11 +471,12 @@ export class BillingService {
|
|||||||
relations: { company: true, companyProfile: true },
|
relations: { company: true, companyProfile: true },
|
||||||
});
|
});
|
||||||
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
|
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
|
||||||
|
const [hydrated] = await this.attachShippingLineCompanies([invoice]);
|
||||||
const lines = await this.invoiceLines.findAll({
|
const lines = await this.invoiceLines.findAll({
|
||||||
where: { invoiceId: id },
|
where: { invoiceId: id },
|
||||||
order: { createdAt: "ASC" },
|
order: { createdAt: "ASC" },
|
||||||
});
|
});
|
||||||
return { ...invoice, lines } as Invoice & { lines: InvoiceLine[] };
|
return { ...hydrated, lines } as Invoice & { lines: InvoiceLine[] };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Documents (central PDF) ──────────────────────────────────────────────────
|
// ── Documents (central PDF) ──────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
|||||||
BookingsService,
|
BookingsService,
|
||||||
BookingsRepository,
|
BookingsRepository,
|
||||||
BookingPricingService,
|
BookingPricingService,
|
||||||
|
ContainerValidationService,
|
||||||
BookingInvoiceService,
|
BookingInvoiceService,
|
||||||
BookingLifecycleNotifierService,
|
BookingLifecycleNotifierService,
|
||||||
BookingTransitionService,
|
BookingTransitionService,
|
||||||
|
|||||||
@@ -67,9 +67,16 @@ export class ContainerValidationService {
|
|||||||
const has20ft = containerLines.some((bc) => (bc.containerSize ?? '').includes('20'));
|
const has20ft = containerLines.some((bc) => (bc.containerSize ?? '').includes('20'));
|
||||||
if (!has20ft) return [];
|
if (!has20ft) return [];
|
||||||
|
|
||||||
const units = await this.load20ftUnits(booking);
|
return this.validate20ftPairingUnits(await this.load20ftUnits(booking));
|
||||||
if (units.length < 2) return [];
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same rule over units that are not (yet) persisted — a completion payload
|
||||||
|
* being previewed or submitted. Shipping-line completion uses this: its
|
||||||
|
* cargo only hits the DB after the check passes.
|
||||||
|
*/
|
||||||
|
async validate20ftPairingUnits(units: Container20ftUnit[]): Promise<PairingViolation[]> {
|
||||||
|
if (units.length < 2) return [];
|
||||||
const maxDiff = await this.maxPairDiffTons();
|
const maxDiff = await this.maxPairDiffTons();
|
||||||
return validate20ftWeightPairing(units, maxDiff);
|
return validate20ftWeightPairing(units, maxDiff);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { In, Repository } from "typeorm";
|
|||||||
import { BookingPricingService } from "../bookings/booking-pricing.service";
|
import { BookingPricingService } from "../bookings/booking-pricing.service";
|
||||||
import { BookingTransitionService } from "../bookings/booking-transition.service";
|
import { BookingTransitionService } from "../bookings/booking-transition.service";
|
||||||
import { BookingsService } from "../bookings/bookings.service";
|
import { BookingsService } from "../bookings/bookings.service";
|
||||||
|
import type { Container20ftUnit } from "../bookings/container-pairing.util";
|
||||||
|
import { ContainerValidationService } from "../bookings/container-validation.service";
|
||||||
import { BookingContainer } from "../bookings/entities/booking-container.entity";
|
import { BookingContainer } from "../bookings/entities/booking-container.entity";
|
||||||
import { BookingContainerUnit } from "../bookings/entities/booking-container-unit.entity";
|
import { BookingContainerUnit } from "../bookings/entities/booking-container-unit.entity";
|
||||||
import { Booking } from "../bookings/entities/booking.entity";
|
import { Booking } from "../bookings/entities/booking.entity";
|
||||||
@@ -57,8 +59,35 @@ export class ShippingLineBookingCompletionService {
|
|||||||
private readonly trainSchedulingService: TrainSchedulingService,
|
private readonly trainSchedulingService: TrainSchedulingService,
|
||||||
private readonly bookingBatchService: BookingBatchService,
|
private readonly bookingBatchService: BookingBatchService,
|
||||||
private readonly creditsService: ShippingLineCreditsService,
|
private readonly creditsService: ShippingLineCreditsService,
|
||||||
|
private readonly containerValidationService: ContainerValidationService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 20ft weight-pairing check over the completion payload — the same rule the
|
||||||
|
* customer shipment form enforces (`max20ftPairWeightDiffTons`, default 10t):
|
||||||
|
* two 20ft sharing a wagon must be within the cap. Preview surfaces the
|
||||||
|
* messages; completion hard-blocks on them. Runs off the DTO so nothing is
|
||||||
|
* persisted before the check passes.
|
||||||
|
*/
|
||||||
|
private async pairingViolationMessages(
|
||||||
|
dto: CompleteShippingLineBookingDto,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const units: Container20ftUnit[] = [];
|
||||||
|
for (const line of dto.containers ?? []) {
|
||||||
|
const containerType = await this.resolveContainerType(line);
|
||||||
|
if (containerType.sizeFt !== 20) continue;
|
||||||
|
(line.units ?? []).forEach((u, idx) =>
|
||||||
|
units.push({
|
||||||
|
label: u.containerNumber || `20ft-${idx + 1}`,
|
||||||
|
grossWeightTons: Number(u.vgmTons ?? 0),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const violations =
|
||||||
|
await this.containerValidationService.validate20ftPairingUnits(units);
|
||||||
|
return violations.map((v) => v.message);
|
||||||
|
}
|
||||||
|
|
||||||
/** Same session→owner resolution every shipping-line entry point uses. */
|
/** Same session→owner resolution every shipping-line entry point uses. */
|
||||||
private async requireShippingLine(userId: string) {
|
private async requireShippingLine(userId: string) {
|
||||||
const shippingLine =
|
const shippingLine =
|
||||||
@@ -193,6 +222,17 @@ export class ShippingLineBookingCompletionService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Unbalanced 20ft pairs can never be planned onto wagons — refuse before
|
||||||
|
// any cargo/credit write below. Same block the contract path applies.
|
||||||
|
if (booking.freightType === "CONTAINER") {
|
||||||
|
const pairing = await this.pairingViolationMessages(dto);
|
||||||
|
if (pairing.length) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Cannot complete booking — 20ft containers cannot be paired on wagons: ${pairing.join(" ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Completion is booking time. A lane with trains DEDICATED to this line
|
// Completion is booking time. A lane with trains DEDICATED to this line
|
||||||
// has no window concept at all: the line books whenever it wants until the
|
// has no window concept at all: the line books whenever it wants until the
|
||||||
// train's close offset. Only a lane with no dedicated train falls back to
|
// train's close offset. Only a lane with no dedicated train falls back to
|
||||||
@@ -526,11 +566,21 @@ export class ShippingLineBookingCompletionService {
|
|||||||
computed.appliedModifiers,
|
computed.appliedModifiers,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Pairing is reported, not thrown: the confirm modal shows it next to the
|
||||||
|
// price (as the customer form does) and disables confirm; /complete
|
||||||
|
// hard-blocks the same payload.
|
||||||
|
const pairingErrors =
|
||||||
|
booking.freightType === "CONTAINER"
|
||||||
|
? await this.pairingViolationMessages(dto)
|
||||||
|
: [];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalAmount: computed.totalAmount,
|
totalAmount: computed.totalAmount,
|
||||||
currency: computed.currency,
|
currency: computed.currency,
|
||||||
lineItems: computed.lineItems,
|
lineItems: computed.lineItems,
|
||||||
warnings: computed.warnings,
|
warnings: computed.warnings,
|
||||||
|
overweightLines: computed.overweightLines,
|
||||||
|
pairingErrors,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { UserTradeAccessService } from "../../user-trade-access/user-trade-acces
|
|||||||
import { resolveAuthUserId } from "../../../common/resolve-auth-user-id";
|
import { resolveAuthUserId } from "../../../common/resolve-auth-user-id";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res,
|
Body, Controller, Delete, Get, Param, ParseIntPipe, ParseUUIDPipe, Patch, Post, Query, Res,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { CurrentUser } from "@edr/api-common";
|
import { CurrentUser } from "@edr/api-common";
|
||||||
import {
|
import {
|
||||||
@@ -37,7 +37,11 @@ import { UpdateImportLoadingStatusDto } from "../dto/update-import-loading-statu
|
|||||||
import { PreviewBulkTrainScheduleDto } from "../dto/preview-bulk-train-schedule.dto";
|
import { PreviewBulkTrainScheduleDto } from "../dto/preview-bulk-train-schedule.dto";
|
||||||
import { PreviewContainerTrainScheduleDto } from "../dto/preview-container-train-schedule.dto";
|
import { PreviewContainerTrainScheduleDto } from "../dto/preview-container-train-schedule.dto";
|
||||||
import { PreviewTrainScheduleDto } from "../dto/preview-train-schedule.dto";
|
import { PreviewTrainScheduleDto } from "../dto/preview-train-schedule.dto";
|
||||||
import { RecordCheckpointDto } from "../dto/record-checkpoint.dto";
|
import {
|
||||||
|
DispatchScheduleDto,
|
||||||
|
RecordCheckpointDto,
|
||||||
|
UpdateCheckpointDto,
|
||||||
|
} from "../dto/record-checkpoint.dto";
|
||||||
import {
|
import {
|
||||||
ImportDjiboutiActionDto,
|
ImportDjiboutiActionDto,
|
||||||
UploadImportDjiboutiDocumentDto,
|
UploadImportDjiboutiDocumentDto,
|
||||||
@@ -516,9 +520,14 @@ export class TrainSchedulingController {
|
|||||||
|
|
||||||
@Post("schedules/:id/dispatch")
|
@Post("schedules/:id/dispatch")
|
||||||
@BookingStaff(FREIGHT_PERMS.trainScheduling.dispatch)
|
@BookingStaff(FREIGHT_PERMS.trainScheduling.dispatch)
|
||||||
@ApiOperation({ summary: "Dispatch a scheduled train" })
|
@ApiOperation({
|
||||||
dispatchSchedule(@Param("id", ParseUUIDPipe) id: string) {
|
summary: "Dispatch a scheduled train (optional actual departure time, past allowed)",
|
||||||
return this.trainSchedulingService.dispatchSchedule(id);
|
})
|
||||||
|
dispatchSchedule(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: DispatchScheduleDto,
|
||||||
|
) {
|
||||||
|
return this.trainSchedulingService.dispatchSchedule(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("intercity/bookings")
|
@Get("intercity/bookings")
|
||||||
@@ -956,6 +965,20 @@ export class TrainSchedulingController {
|
|||||||
return this.trainSchedulingService.recordCheckpoint(id, dto);
|
return this.trainSchedulingService.recordCheckpoint(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Patch("schedules/:id/checkpoints/:sequenceNo")
|
||||||
|
@TrainSchedulingUpdate()
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Edit a logged leg's time/note (no side effects; allowed while dispatched or after arrival)",
|
||||||
|
})
|
||||||
|
updateCheckpoint(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@Param("sequenceNo", ParseIntPipe) sequenceNo: number,
|
||||||
|
@Body() dto: UpdateCheckpointDto,
|
||||||
|
) {
|
||||||
|
return this.trainSchedulingService.updateCheckpoint(id, sequenceNo, dto);
|
||||||
|
}
|
||||||
|
|
||||||
@Post("schedules/:id/arrive")
|
@Post("schedules/:id/arrive")
|
||||||
@TrainSchedulingUpdate()
|
@TrainSchedulingUpdate()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ import {
|
|||||||
Min,
|
Min,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
import { IsNotBackdated } from '../../../common/validators/is-not-backdated.validator';
|
|
||||||
|
|
||||||
export class RecordCheckpointDto {
|
export class RecordCheckpointDto {
|
||||||
@ApiProperty({ description: 'Station position along the route (0 = origin).' })
|
@ApiProperty({ description: 'Station position along the route (0 = origin).' })
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@@ -24,17 +22,17 @@ export class RecordCheckpointDto {
|
|||||||
kind?: TrainCheckpointKind;
|
kind?: TrainCheckpointKind;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A checkpoint records where the train is as staff observe it, and the final
|
* When the train was actually at the station — staff often log after the
|
||||||
* one arrives the schedule — so a backdated value rewrites the journey after
|
* fact, so a past value is allowed. The service rejects the future and any
|
||||||
* the fact. Only "now" is accepted; omit the field and the service stamps it.
|
* value out of order with the neighbouring legs.
|
||||||
*/
|
*/
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
required: false,
|
required: false,
|
||||||
description: 'ISO timestamp; defaults to now. Cannot be earlier than now.',
|
description:
|
||||||
|
'ISO timestamp; defaults to now. Past allowed, future rejected, must be in corridor order.',
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsISO8601()
|
@IsISO8601()
|
||||||
@IsNotBackdated()
|
|
||||||
occurredAt?: string;
|
occurredAt?: string;
|
||||||
|
|
||||||
@ApiProperty({ required: false })
|
@ApiProperty({ required: false })
|
||||||
@@ -43,3 +41,30 @@ export class RecordCheckpointDto {
|
|||||||
@MaxLength(500)
|
@MaxLength(500)
|
||||||
note?: string;
|
note?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Edit an already-logged leg's time/note — no side effects (no unload, no arrival). */
|
||||||
|
export class UpdateCheckpointDto {
|
||||||
|
@ApiProperty({
|
||||||
|
required: false,
|
||||||
|
description: 'ISO timestamp. Past allowed, future rejected, must be in corridor order.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsISO8601()
|
||||||
|
occurredAt?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
note?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DispatchScheduleDto {
|
||||||
|
@ApiProperty({
|
||||||
|
required: false,
|
||||||
|
description: 'Actual departure time; defaults to now. Past allowed, future rejected.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsISO8601()
|
||||||
|
actualDepartureAt?: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ describe('TrainSchedulingService', () => {
|
|||||||
let wagonBookingAllocationsRepository: Record<string, jest.Mock>;
|
let wagonBookingAllocationsRepository: Record<string, jest.Mock>;
|
||||||
let wagonAllocationContainerItemsRepository: Record<string, jest.Mock>;
|
let wagonAllocationContainerItemsRepository: Record<string, jest.Mock>;
|
||||||
let wagonAllocationBulkLoadsRepository: Record<string, jest.Mock>;
|
let wagonAllocationBulkLoadsRepository: Record<string, jest.Mock>;
|
||||||
|
let trainCheckpointEventsRepository: Record<string, jest.Mock>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// findGroupSiblings runs a query builder off dataSource.manager; default it
|
// findGroupSiblings runs a query builder off dataSource.manager; default it
|
||||||
@@ -127,6 +128,7 @@ describe('TrainSchedulingService', () => {
|
|||||||
findByIdWithFullGraph: jest.fn(),
|
findByIdWithFullGraph: jest.fn(),
|
||||||
findAll: jest.fn(),
|
findAll: jest.fn(),
|
||||||
updateStatus: jest.fn(),
|
updateStatus: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
maxReferenceSequence: jest.fn().mockResolvedValue(0),
|
maxReferenceSequence: jest.fn().mockResolvedValue(0),
|
||||||
};
|
};
|
||||||
trainScheduleBookingsRepository = {
|
trainScheduleBookingsRepository = {
|
||||||
@@ -150,7 +152,7 @@ describe('TrainSchedulingService', () => {
|
|||||||
findAll: jest.fn().mockResolvedValue([]),
|
findAll: jest.fn().mockResolvedValue([]),
|
||||||
};
|
};
|
||||||
|
|
||||||
const trainCheckpointEventsRepository = {
|
trainCheckpointEventsRepository = {
|
||||||
findBySchedule: jest.fn().mockResolvedValue([]),
|
findBySchedule: jest.fn().mockResolvedValue([]),
|
||||||
findAll: jest.fn().mockResolvedValue([]),
|
findAll: jest.fn().mockResolvedValue([]),
|
||||||
create: jest.fn(),
|
create: jest.fn(),
|
||||||
@@ -1638,6 +1640,61 @@ describe('TrainSchedulingService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('updateCheckpoint — leg time correction', () => {
|
||||||
|
const t = (h: number) => new Date(Date.UTC(2026, 0, 1, h));
|
||||||
|
const schedule = {
|
||||||
|
id: 'sch-track',
|
||||||
|
status: 'ARRIVED',
|
||||||
|
routeId: null,
|
||||||
|
originStationId: 'y0',
|
||||||
|
destinationStationId: 'y1',
|
||||||
|
actualDepartureAt: t(8),
|
||||||
|
};
|
||||||
|
const events = () => [
|
||||||
|
{ id: 'e0', yardId: 'y0', sequenceNo: 0, kind: 'DEPARTED', occurredAt: t(8) },
|
||||||
|
{ id: 'e1', yardId: 'y1', sequenceNo: 1, kind: 'ARRIVED', occurredAt: t(12) },
|
||||||
|
];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(schedule);
|
||||||
|
trainCheckpointEventsRepository.findBySchedule.mockImplementation(async () => events());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a leg time earlier than the previous leg', async () => {
|
||||||
|
await expect(
|
||||||
|
service.updateCheckpoint('sch-track', 1, { occurredAt: t(7).toISOString() }),
|
||||||
|
).rejects.toThrow(/cannot be earlier than/);
|
||||||
|
expect(trainCheckpointEventsRepository.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a leg time later than the next leg', async () => {
|
||||||
|
await expect(
|
||||||
|
service.updateCheckpoint('sch-track', 0, { occurredAt: t(13).toISOString() }),
|
||||||
|
).rejects.toThrow(/cannot be later than/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a future time', async () => {
|
||||||
|
const future = new Date(Date.now() + 3_600_000).toISOString();
|
||||||
|
await expect(
|
||||||
|
service.updateCheckpoint('sch-track', 1, { occurredAt: future }),
|
||||||
|
).rejects.toThrow(/future/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts an in-order past time and re-stamps arrival for the final leg', async () => {
|
||||||
|
await service.updateCheckpoint('sch-track', 1, {
|
||||||
|
occurredAt: t(11).toISOString(),
|
||||||
|
note: 'late log',
|
||||||
|
});
|
||||||
|
expect(trainCheckpointEventsRepository.update).toHaveBeenCalledWith('e1', {
|
||||||
|
occurredAt: t(11),
|
||||||
|
note: 'late log',
|
||||||
|
});
|
||||||
|
expect(trainSchedulesRepository.update).toHaveBeenCalledWith('sch-track', {
|
||||||
|
actualArrivalAt: t(11),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('effectiveWagonsRequired', () => {
|
describe('effectiveWagonsRequired', () => {
|
||||||
const effective = (booking: unknown): number =>
|
const effective = (booking: unknown): number =>
|
||||||
(service as never as { effectiveWagonsRequired(b: unknown): number })
|
(service as never as { effectiveWagonsRequired(b: unknown): number })
|
||||||
|
|||||||
@@ -174,7 +174,11 @@ import {
|
|||||||
import { TrainCheckpointEvent } from '../entities/train-checkpoint-event.entity';
|
import { TrainCheckpointEvent } from '../entities/train-checkpoint-event.entity';
|
||||||
import { BookingJourneyService } from '../booking-journey.service';
|
import { BookingJourneyService } from '../booking-journey.service';
|
||||||
import { TrainCheckpointEventsRepository } from '../repositories/train-checkpoint-events.repository';
|
import { TrainCheckpointEventsRepository } from '../repositories/train-checkpoint-events.repository';
|
||||||
import { RecordCheckpointDto } from '../dto/record-checkpoint.dto';
|
import {
|
||||||
|
DispatchScheduleDto,
|
||||||
|
RecordCheckpointDto,
|
||||||
|
UpdateCheckpointDto,
|
||||||
|
} from '../dto/record-checkpoint.dto';
|
||||||
import { RouteMilestone } from '../../routes/entities/route-milestone.entity';
|
import { RouteMilestone } from '../../routes/entities/route-milestone.entity';
|
||||||
import { deriveTradeDirection } from '../../../common/derive-trade-direction.util';
|
import { deriveTradeDirection } from '../../../common/derive-trade-direction.util';
|
||||||
import { WarehouseInventoryService } from '../../warehouses/warehouse-inventory.service';
|
import { WarehouseInventoryService } from '../../warehouses/warehouse-inventory.service';
|
||||||
@@ -2645,7 +2649,7 @@ export class TrainSchedulingService {
|
|||||||
return this.getTrainScheduleById(scheduleId);
|
return this.getTrainScheduleById(scheduleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async dispatchSchedule(scheduleId: string) {
|
async dispatchSchedule(scheduleId: string, dto: DispatchScheduleDto = {}) {
|
||||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||||
if (!schedule) {
|
if (!schedule) {
|
||||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||||
@@ -2653,6 +2657,9 @@ export class TrainSchedulingService {
|
|||||||
if (schedule.status !== TrainScheduleStatusEnum.Scheduled) {
|
if (schedule.status !== TrainScheduleStatusEnum.Scheduled) {
|
||||||
throw new BadRequestException('Only SCHEDULED trains can be dispatched');
|
throw new BadRequestException('Only SCHEDULED trains can be dispatched');
|
||||||
}
|
}
|
||||||
|
// Staff may record the departure after the fact — past is fine, future is not.
|
||||||
|
const now = dto.actualDepartureAt ? new Date(dto.actualDepartureAt) : new Date();
|
||||||
|
this.assertNotFuture(now, 'Departure time');
|
||||||
await this.assertImportDjiboutiMayDepart(schedule);
|
await this.assertImportDjiboutiMayDepart(schedule);
|
||||||
// Cargo readiness (in the warehouse, not inspected, not loaded onto a wagon)
|
// Cargo readiness (in the warehouse, not inspected, not loaded onto a wagon)
|
||||||
// never blocks departure — the dispatch confirm dialog warns and staff decide.
|
// never blocks departure — the dispatch confirm dialog warns and staff decide.
|
||||||
@@ -2677,7 +2684,6 @@ export class TrainSchedulingService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
await this.dataSource.transaction(async (manager) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
const trainNumber = await this.assignTrainNumber(manager, schedule);
|
const trainNumber = await this.assignTrainNumber(manager, schedule);
|
||||||
if (setLocomotiveIds.length) {
|
if (setLocomotiveIds.length) {
|
||||||
@@ -4160,6 +4166,7 @@ export class TrainSchedulingService {
|
|||||||
? TrainCheckpointKind.Arrived
|
? TrainCheckpointKind.Arrived
|
||||||
: TrainCheckpointKind.Passed);
|
: TrainCheckpointKind.Passed);
|
||||||
const occurredAt = dto.occurredAt ? new Date(dto.occurredAt) : new Date();
|
const occurredAt = dto.occurredAt ? new Date(dto.occurredAt) : new Date();
|
||||||
|
await this.assertCheckpointTime(schedule, stations, dto.sequenceNo, occurredAt);
|
||||||
|
|
||||||
// Upsert by (scheduleId, sequenceNo) so re-logging a station updates rather than duplicates.
|
// Upsert by (scheduleId, sequenceNo) so re-logging a station updates rather than duplicates.
|
||||||
const [existing] = await this.trainCheckpointEventsRepository.findAll({
|
const [existing] = await this.trainCheckpointEventsRepository.findAll({
|
||||||
@@ -4183,8 +4190,14 @@ export class TrainSchedulingService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The origin DEPARTED checkpoint IS the departure — keep the schedule's
|
||||||
|
// headline timestamp on the same clock the operator just entered.
|
||||||
|
if (dto.sequenceNo === 0) {
|
||||||
|
await this.trainSchedulesRepository.update(scheduleId, { actualDepartureAt: occurredAt });
|
||||||
|
}
|
||||||
|
|
||||||
if (dto.sequenceNo === finalSeq) {
|
if (dto.sequenceNo === finalSeq) {
|
||||||
await this.arriveSchedule(scheduleId);
|
await this.arriveSchedule(scheduleId, occurredAt);
|
||||||
} else {
|
} else {
|
||||||
// Mid-corridor auto-unload: bookings destined for this yard alight the
|
// Mid-corridor auto-unload: bookings destined for this yard alight the
|
||||||
// moment the train is recorded here — the yard operator no longer has to
|
// moment the train is recorded here — the yard operator no longer has to
|
||||||
@@ -4220,11 +4233,133 @@ export class TrainSchedulingService {
|
|||||||
return this.getScheduleCheckpoints(scheduleId);
|
return this.getScheduleCheckpoints(scheduleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Correct an already-logged leg's time/note. Pure edit: no auto-unload, no
|
||||||
|
* position fix, no arrival — those already happened when the leg was logged.
|
||||||
|
* Allowed on DISPATCHED and ARRIVED trains (a journey is corrected after the
|
||||||
|
* fact as often as during it). The origin/final legs also re-stamp the
|
||||||
|
* schedule's departure/arrival so the headline figures follow the edit.
|
||||||
|
*/
|
||||||
|
async updateCheckpoint(scheduleId: string, sequenceNo: number, dto: UpdateCheckpointDto) {
|
||||||
|
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||||
|
if (!schedule) {
|
||||||
|
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
schedule.status !== TrainScheduleStatusEnum.Dispatched &&
|
||||||
|
schedule.status !== TrainScheduleStatusEnum.Arrived
|
||||||
|
) {
|
||||||
|
throw new BadRequestException('Only DISPATCHED or ARRIVED trains have checkpoints to edit');
|
||||||
|
}
|
||||||
|
const stations = await this.buildScheduleStations(schedule);
|
||||||
|
const station = stations.find((s) => s.sequenceNo === sequenceNo);
|
||||||
|
if (!station) {
|
||||||
|
throw new BadRequestException(`Station ${sequenceNo} is not on this route`);
|
||||||
|
}
|
||||||
|
// Match by yard, like getScheduleCheckpoints — legacy rows may carry an
|
||||||
|
// older station numbering.
|
||||||
|
const events = await this.trainCheckpointEventsRepository.findBySchedule(scheduleId);
|
||||||
|
const existing =
|
||||||
|
events.find((e) => e.yardId === station.yardId) ??
|
||||||
|
events.find((e) => e.sequenceNo === sequenceNo);
|
||||||
|
if (!existing) {
|
||||||
|
throw new BadRequestException(`Station ${station.label} has not been logged yet`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const patch: Partial<TrainCheckpointEvent> = {};
|
||||||
|
if (dto.occurredAt) {
|
||||||
|
const occurredAt = new Date(dto.occurredAt);
|
||||||
|
await this.assertCheckpointTime(schedule, stations, sequenceNo, occurredAt, existing.id);
|
||||||
|
patch.occurredAt = occurredAt;
|
||||||
|
}
|
||||||
|
if (dto.note !== undefined) patch.note = dto.note;
|
||||||
|
if (Object.keys(patch).length) {
|
||||||
|
await this.trainCheckpointEventsRepository.update(existing.id, patch);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patch.occurredAt) {
|
||||||
|
const finalSeq = stations[stations.length - 1].sequenceNo;
|
||||||
|
if (sequenceNo === 0) {
|
||||||
|
await this.trainSchedulesRepository.update(scheduleId, {
|
||||||
|
actualDepartureAt: patch.occurredAt,
|
||||||
|
});
|
||||||
|
} else if (sequenceNo === finalSeq && schedule.status === TrainScheduleStatusEnum.Arrived) {
|
||||||
|
await this.trainSchedulesRepository.update(scheduleId, {
|
||||||
|
actualArrivalAt: patch.occurredAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.getScheduleCheckpoints(scheduleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertNotFuture(at: Date, what: string) {
|
||||||
|
if (Number.isNaN(at.getTime())) {
|
||||||
|
throw new BadRequestException(`${what} is not a valid date`);
|
||||||
|
}
|
||||||
|
// Small skew allowance so an honest "now" from a client clock passes.
|
||||||
|
if (at.getTime() > Date.now() + 60_000) {
|
||||||
|
throw new BadRequestException(`${what} cannot be in the future`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A leg's time must not be in the future and must sit in corridor order:
|
||||||
|
* no earlier than every logged leg before it (and the dispatch time, for
|
||||||
|
* legs after the origin), no later than every logged leg after it.
|
||||||
|
* `ignoreEventId` excludes the row being edited from its own bounds.
|
||||||
|
*/
|
||||||
|
private async assertCheckpointTime(
|
||||||
|
schedule: TrainSchedule,
|
||||||
|
stations: { sequenceNo: number; yardId: string; label: string }[],
|
||||||
|
sequenceNo: number,
|
||||||
|
occurredAt: Date,
|
||||||
|
ignoreEventId?: string,
|
||||||
|
) {
|
||||||
|
this.assertNotFuture(occurredAt, 'Checkpoint time');
|
||||||
|
|
||||||
|
const seqByYard = new Map(stations.map((s) => [s.yardId, s.sequenceNo]));
|
||||||
|
const labelBySeq = new Map(stations.map((s) => [s.sequenceNo, s.label]));
|
||||||
|
const events = (await this.trainCheckpointEventsRepository.findBySchedule(schedule.id)).filter(
|
||||||
|
(e) => e.id !== ignoreEventId,
|
||||||
|
);
|
||||||
|
const seqOf = (e: TrainCheckpointEvent) => seqByYard.get(e.yardId) ?? e.sequenceNo;
|
||||||
|
const fmt = (d: Date) => d.toISOString().replace('T', ' ').slice(0, 16) + ' UTC';
|
||||||
|
|
||||||
|
let floor: { at: Date; label: string } | null = null;
|
||||||
|
let ceil: { at: Date; label: string } | null = null;
|
||||||
|
for (const e of events) {
|
||||||
|
const s = seqOf(e);
|
||||||
|
if (s < sequenceNo && (!floor || e.occurredAt > floor.at)) {
|
||||||
|
floor = { at: e.occurredAt, label: labelBySeq.get(s) ?? `station ${s}` };
|
||||||
|
}
|
||||||
|
if (s > sequenceNo && (!ceil || e.occurredAt < ceil.at)) {
|
||||||
|
ceil = { at: e.occurredAt, label: labelBySeq.get(s) ?? `station ${s}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The origin leg rewrites the departure itself; every later leg must
|
||||||
|
// follow it.
|
||||||
|
if (sequenceNo > 0 && schedule.actualDepartureAt && (!floor || schedule.actualDepartureAt > floor.at)) {
|
||||||
|
floor = { at: schedule.actualDepartureAt, label: 'departure' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (floor && occurredAt < floor.at) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Checkpoint time cannot be earlier than ${floor.label} (${fmt(floor.at)})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (ceil && occurredAt > ceil.at) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Checkpoint time cannot be later than ${ceil.label} (${fmt(ceil.at)})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mark a dispatched train arrived: close out the schedule, move the locomotive
|
* Mark a dispatched train arrived: close out the schedule, move the locomotive
|
||||||
* and wagons to the destination yard, and free the assets for re-use.
|
* and wagons to the destination yard, and free the assets for re-use.
|
||||||
*/
|
*/
|
||||||
async arriveSchedule(scheduleId: string) {
|
async arriveSchedule(scheduleId: string, arrivedAt?: Date) {
|
||||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||||
if (!schedule) {
|
if (!schedule) {
|
||||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||||
@@ -4233,7 +4368,9 @@ export class TrainSchedulingService {
|
|||||||
throw new BadRequestException('Only DISPATCHED trains can arrive');
|
throw new BadRequestException('Only DISPATCHED trains can arrive');
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date();
|
// The arrival clock: the operator's entered time when arriving via the final
|
||||||
|
// checkpoint (already order/future-checked there), else now.
|
||||||
|
const now = arrivedAt ?? new Date();
|
||||||
|
|
||||||
await this.dataSource.transaction(async (manager) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
await this.trainSchedulesRepository.updateStatus(
|
await this.trainSchedulesRepository.updateStatus(
|
||||||
@@ -9025,33 +9162,88 @@ export class TrainSchedulingService {
|
|||||||
throw new BadRequestException('Source wagon has no load to move');
|
throw new BadRequestException('Source wagon has no load to move');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Target: a slot of this train set, or an empty consist-only wagon of the
|
// Leg spans: a physical wagon carries one slot PER LEG (cross-leg sharing —
|
||||||
// built train (physical wagon with no slot row yet).
|
// Gelan→Adama and Adama→Doraleh loads ride the same wagon in two slots), so
|
||||||
|
// "the slot on that wagon" only means the one whose leg overlaps the moving
|
||||||
|
// load's leg. Null board/alight = the schedule's own endpoints.
|
||||||
|
const stops = await this.stopYardsForSchedule(schedule);
|
||||||
|
const spanOf = (slot: {
|
||||||
|
boardYardId?: string | null;
|
||||||
|
alightYardId?: string | null;
|
||||||
|
}): [number, number] => {
|
||||||
|
const from = slot.boardYardId ? stops.indexOf(slot.boardYardId) : 0;
|
||||||
|
const to = slot.alightYardId ? stops.indexOf(slot.alightYardId) : stops.length - 1;
|
||||||
|
return [from < 0 ? 0 : from, to < 0 ? Math.max(1, stops.length - 1) : to];
|
||||||
|
};
|
||||||
|
const overlaps = (a: [number, number], b: [number, number]) => a[0] < b[1] && b[0] < a[1];
|
||||||
|
const sourceSpan = spanOf(source);
|
||||||
|
|
||||||
|
// Target: a slot of this train set, or a physical wagon of this train —
|
||||||
|
// coupled-but-empty consist wagon (built train), or a wagon already pinned
|
||||||
|
// by another slot of this set (then: the overlapping-leg slot, or a fresh
|
||||||
|
// slot for a free leg).
|
||||||
const slotById = slots.find((w) => w.id === dto.targetWagonId) ?? null;
|
const slotById = slots.find((w) => w.id === dto.targetWagonId) ?? null;
|
||||||
const wagonForTarget = slotById
|
let wagonForTarget: Wagon | null = null;
|
||||||
? null
|
if (!slotById) {
|
||||||
: schedule.trainSet?.trainId
|
const wagon = await this.dataSource.getRepository(Wagon).findOne({
|
||||||
? await this.dataSource.getRepository(Wagon).findOne({
|
where: { id: dto.targetWagonId },
|
||||||
where: { id: dto.targetWagonId, trainId: schedule.trainSet.trainId },
|
relations: { wagonType: true },
|
||||||
relations: { wagonType: true },
|
});
|
||||||
})
|
const onThisTrain =
|
||||||
: null;
|
!!wagon &&
|
||||||
|
((!!schedule.trainSet?.trainId && wagon.trainId === schedule.trainSet.trainId) ||
|
||||||
|
slots.some((w) => w.physicalWagonId === wagon.id));
|
||||||
|
wagonForTarget = onThisTrain ? wagon : null;
|
||||||
|
}
|
||||||
if (!slotById && !wagonForTarget) {
|
if (!slotById && !wagonForTarget) {
|
||||||
throw new NotFoundException('Target wagon is not part of this schedule');
|
throw new NotFoundException('Target wagon is not part of this schedule');
|
||||||
}
|
}
|
||||||
// A physical wagon holds at most one slot. When the caller addressed the
|
|
||||||
// wagon directly but a slot is already pinned to it, move into that slot
|
|
||||||
// rather than minting a second one on the same wagon.
|
|
||||||
const targetSlot =
|
const targetSlot =
|
||||||
slotById ??
|
slotById ??
|
||||||
(wagonForTarget
|
(wagonForTarget
|
||||||
? (slots.find((w) => w.physicalWagonId === wagonForTarget.id) ?? null)
|
? (slots.find(
|
||||||
|
(w) =>
|
||||||
|
w.physicalWagonId === wagonForTarget.id && overlaps(spanOf(w), sourceSpan),
|
||||||
|
) ?? null)
|
||||||
: null);
|
: null);
|
||||||
const consistWagon = targetSlot ? null : wagonForTarget;
|
const consistWagon = targetSlot ? null : wagonForTarget;
|
||||||
|
|
||||||
|
// Leg clash guard: after the move, no two slots on one physical wagon may
|
||||||
|
// ride the same edge. Source load → target wagon; on a swap, target load →
|
||||||
|
// source wagon.
|
||||||
|
const targetPhysicalId = targetSlot?.physicalWagonId ?? consistWagon?.id ?? null;
|
||||||
|
const clashOn = (
|
||||||
|
physicalWagonId: string | null,
|
||||||
|
excludeSlotId: string | null,
|
||||||
|
span: [number, number],
|
||||||
|
) =>
|
||||||
|
!!physicalWagonId &&
|
||||||
|
slots.some(
|
||||||
|
(w) =>
|
||||||
|
w.physicalWagonId === physicalWagonId &&
|
||||||
|
w.id !== excludeSlotId &&
|
||||||
|
w.id !== source.id &&
|
||||||
|
(w.allocations?.length ?? 0) > 0 &&
|
||||||
|
overlaps(spanOf(w), span),
|
||||||
|
);
|
||||||
|
if (clashOn(targetPhysicalId, targetSlot?.id ?? null, sourceSpan)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'That wagon already carries another load on the same leg — pick a wagon free on that leg.',
|
||||||
|
);
|
||||||
|
}
|
||||||
const targetAllocs = targetSlot ? await loadAllocations(targetSlot.id) : [];
|
const targetAllocs = targetSlot ? await loadAllocations(targetSlot.id) : [];
|
||||||
if (targetSlot && targetSlot.id === source.id) {
|
if (targetSlot && targetSlot.id === source.id) {
|
||||||
return this.getTrainScheduleById(scheduleId);
|
return this.getTrainScheduleById(scheduleId);
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
targetSlot &&
|
||||||
|
targetAllocs.length &&
|
||||||
|
clashOn(source.physicalWagonId ?? null, source.id, spanOf(targetSlot))
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Swap refused: the source wagon already carries another load on the incoming load’s leg.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const loadTypesOf = (allocs: WagonBookingAllocation[]) => [
|
const loadTypesOf = (allocs: WagonBookingAllocation[]) => [
|
||||||
...new Set(allocs.map((a) => (a.loadType ?? 'CONTAINER').toUpperCase())),
|
...new Set(allocs.map((a) => (a.loadType ?? 'CONTAINER').toUpperCase())),
|
||||||
|
|||||||
@@ -121,8 +121,46 @@ export class WagonsService {
|
|||||||
* (yard workspace, coupling pickers) walk the pages client-side — see
|
* (yard workspace, coupling pickers) walk the pages client-side — see
|
||||||
* `wagonService.listAll` in the backoffice.
|
* `wagonService.listAll` in the backoffice.
|
||||||
*/
|
*/
|
||||||
findAll(query: ListWagonsQueryDto = {}): Promise<PaginatedResponse<Wagon>> {
|
async findAll(query: ListWagonsQueryDto = {}): Promise<PaginatedResponse<Wagon>> {
|
||||||
return paginateQuery(this.buildListQuery(query), query, { defaultPageSize: 10 });
|
const page = await paginateQuery(this.buildListQuery(query), query, { defaultPageSize: 10 });
|
||||||
|
await this.attachStatusDates(page.items);
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Latest status-flip dates from the audit log, for the wagons desk columns:
|
||||||
|
* when the wagon last went to MAINTENANCE and when it last became AVAILABLE.
|
||||||
|
* One grouped query per page; null when the log has no such flip.
|
||||||
|
*/
|
||||||
|
private async attachStatusDates(wagons: Wagon[]): Promise<void> {
|
||||||
|
if (!wagons.length) return;
|
||||||
|
const rows: Array<{
|
||||||
|
wagonId: string;
|
||||||
|
lastMaintenanceAt: Date | null;
|
||||||
|
lastAvailableAt: Date | null;
|
||||||
|
}> = await this.dataSource
|
||||||
|
.getRepository(WagonStatusLog)
|
||||||
|
.createQueryBuilder('l')
|
||||||
|
.select('l.wagon_id', 'wagonId')
|
||||||
|
.addSelect(
|
||||||
|
`MAX(l.created_at) FILTER (WHERE l.to_status = '${WagonStatus.Maintenance}')`,
|
||||||
|
'lastMaintenanceAt',
|
||||||
|
)
|
||||||
|
.addSelect(
|
||||||
|
`MAX(l.created_at) FILTER (WHERE l.to_status = '${WagonStatus.Available}')`,
|
||||||
|
'lastAvailableAt',
|
||||||
|
)
|
||||||
|
.where('l.wagon_id IN (:...ids)', { ids: wagons.map((w) => w.id) })
|
||||||
|
.groupBy('l.wagon_id')
|
||||||
|
.getRawMany();
|
||||||
|
const byId = new Map(rows.map((r) => [r.wagonId, r]));
|
||||||
|
for (const w of wagons) {
|
||||||
|
const r = byId.get(w.id);
|
||||||
|
Object.assign(w, {
|
||||||
|
lastMaintenanceAt: r?.lastMaintenanceAt ?? null,
|
||||||
|
lastAvailableAt: r?.lastAvailableAt ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async findById(id: string): Promise<Wagon> {
|
async findById(id: string): Promise<Wagon> {
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { Button, Group, Modal, Stack, Text, Textarea } from "@mantine/core";
|
||||||
|
import { DateTimePicker } from "@mantine/dates";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Time + note for one leg of a train's journey — used both to log a pass
|
||||||
|
* (defaults to now) and to correct an already-logged leg (prefilled). Past
|
||||||
|
* times are allowed (staff record after the fact); the future is not, and the
|
||||||
|
* server additionally keeps legs in corridor order.
|
||||||
|
*/
|
||||||
|
export function CheckpointTimeModal({
|
||||||
|
opened,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
description,
|
||||||
|
initialOccurredAt,
|
||||||
|
initialNote,
|
||||||
|
submitLabel,
|
||||||
|
submitColor = "edr-green",
|
||||||
|
loading,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
description?: string;
|
||||||
|
/** ISO; omit to default to now. */
|
||||||
|
initialOccurredAt?: string | null;
|
||||||
|
initialNote?: string | null;
|
||||||
|
submitLabel: string;
|
||||||
|
submitColor?: string;
|
||||||
|
loading: boolean;
|
||||||
|
onSubmit: (values: { occurredAt: string; note: string }) => void;
|
||||||
|
}) {
|
||||||
|
const [at, setAt] = useState<Date | null>(null);
|
||||||
|
const [note, setNote] = useState("");
|
||||||
|
useEffect(() => {
|
||||||
|
if (!opened) return;
|
||||||
|
setAt(initialOccurredAt ? new Date(initialOccurredAt) : new Date());
|
||||||
|
setNote(initialNote ?? "");
|
||||||
|
}, [opened, initialOccurredAt, initialNote]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={onClose}
|
||||||
|
centered
|
||||||
|
radius="lg"
|
||||||
|
title={
|
||||||
|
<Group gap={8}>
|
||||||
|
{icon}
|
||||||
|
<Text fw={700}>{title}</Text>
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
{description ? (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{description}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<DateTimePicker
|
||||||
|
label="Time"
|
||||||
|
description="Defaults to now — pick an earlier time if you are recording after the fact."
|
||||||
|
value={at}
|
||||||
|
onChange={(v) => setAt(v ? new Date(v) : null)}
|
||||||
|
maxDate={new Date()}
|
||||||
|
valueFormat="DD MMM YYYY HH:mm"
|
||||||
|
clearable={false}
|
||||||
|
radius="md"
|
||||||
|
/>
|
||||||
|
<Textarea
|
||||||
|
label="Note"
|
||||||
|
placeholder="Optional"
|
||||||
|
value={note}
|
||||||
|
onChange={(e) => setNote(e.currentTarget.value)}
|
||||||
|
autosize
|
||||||
|
minRows={2}
|
||||||
|
maxRows={4}
|
||||||
|
maxLength={500}
|
||||||
|
radius="md"
|
||||||
|
/>
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
<Button variant="default" onClick={onClose} disabled={loading}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color={submitColor}
|
||||||
|
loading={loading}
|
||||||
|
disabled={!at}
|
||||||
|
onClick={() =>
|
||||||
|
at && onSubmit({ occurredAt: at.toISOString(), note: note.trim() })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{submitLabel}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,423 @@
|
|||||||
|
import { Fragment, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { isAxiosError } from "axios";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
Text,
|
||||||
|
Tooltip,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { ArrowLeftRight, Boxes, Info, MoveRight, Wheat, X } from "lucide-react";
|
||||||
|
|
||||||
|
import { api } from "@/services/api";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import type { TrainScheduleDetail } from "@/types/trainScheduling";
|
||||||
|
|
||||||
|
type Slot = NonNullable<TrainScheduleDetail["trainSet"]>["wagons"][number];
|
||||||
|
type Stop = { yardId: string; label: string };
|
||||||
|
type Span = [number, number];
|
||||||
|
|
||||||
|
/** One physical wagon of the consist with every slot (leg load) pinned to it. */
|
||||||
|
interface WagonRow {
|
||||||
|
key: string;
|
||||||
|
physicalWagonId: string | null;
|
||||||
|
label: string;
|
||||||
|
position: number;
|
||||||
|
typeCode: string | null;
|
||||||
|
capacityTons: number;
|
||||||
|
slots: Array<{ slot: Slot; span: Span; loaded: boolean }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const round1 = (n: number) => Math.round(n * 10) / 10;
|
||||||
|
const overlaps = (a: Span, b: Span) => a[0] < b[1] && b[0] < a[1];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leg board: rows = physical wagons in coupling order, columns = corridor legs
|
||||||
|
* (A→B, B→C, …). A wagon reused on disjoint legs shows one load per leg on the
|
||||||
|
* same row, so a "53 full on A→B, 53 full on C→D" train reads at a glance.
|
||||||
|
* Loads move by click: pick a load, then click a wagon that is free on that
|
||||||
|
* load's legs (move) or another load (swap). Same API as the consist strip.
|
||||||
|
*/
|
||||||
|
export function LegLoadBoardPanel({
|
||||||
|
schedule,
|
||||||
|
onChanged,
|
||||||
|
}: {
|
||||||
|
schedule: TrainScheduleDetail;
|
||||||
|
onChanged?: () => void;
|
||||||
|
}) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const stops: Stop[] = schedule.stops ?? [];
|
||||||
|
const legs = useMemo(
|
||||||
|
() => stops.slice(0, -1).map((from, i) => ({ from, to: stops[i + 1], idx: i })),
|
||||||
|
[stops],
|
||||||
|
);
|
||||||
|
const canRearrange = !["DISPATCHED", "ARRIVED", "CANCELLED"].includes(schedule.status);
|
||||||
|
|
||||||
|
const spanOf = (slot: Slot): Span => {
|
||||||
|
const from = slot.boardYardId ? stops.findIndex((s) => s.yardId === slot.boardYardId) : 0;
|
||||||
|
const to = slot.alightYardId
|
||||||
|
? stops.findIndex((s) => s.yardId === slot.alightYardId)
|
||||||
|
: stops.length - 1;
|
||||||
|
return [from < 0 ? 0 : from, to < 0 ? Math.max(1, stops.length - 1) : to];
|
||||||
|
};
|
||||||
|
|
||||||
|
const rows: WagonRow[] = useMemo(() => {
|
||||||
|
const byKey = new Map<string, WagonRow>();
|
||||||
|
for (const slot of schedule.trainSet?.wagons ?? []) {
|
||||||
|
const key = slot.physicalWagonId ?? `slot:${slot.id}`;
|
||||||
|
let row = byKey.get(key);
|
||||||
|
if (!row) {
|
||||||
|
row = {
|
||||||
|
key,
|
||||||
|
physicalWagonId: slot.physicalWagonId ?? null,
|
||||||
|
label: slot.physicalWagonNumber ?? `#${slot.position ?? slot.sequenceNo}`,
|
||||||
|
position: slot.position ?? slot.sequenceNo,
|
||||||
|
typeCode: slot.wagonType?.code ?? null,
|
||||||
|
capacityTons: slot.capacityTons ?? 0,
|
||||||
|
slots: [],
|
||||||
|
};
|
||||||
|
byKey.set(key, row);
|
||||||
|
}
|
||||||
|
row.position = Math.min(row.position, slot.position ?? slot.sequenceNo);
|
||||||
|
// Coupled-but-empty consist wagons carry no slot row: they are a target only.
|
||||||
|
if (!slot.consistOnly) {
|
||||||
|
row.slots.push({
|
||||||
|
slot,
|
||||||
|
span: spanOf(slot),
|
||||||
|
loaded: (slot.allocations?.length ?? 0) > 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...byKey.values()].sort((a, b) => a.position - b.position);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [schedule.trainSet?.wagons, stops]);
|
||||||
|
|
||||||
|
const [picked, setPicked] = useState<{ slotId: string; rowKey: string; span: Span } | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!picked) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setPicked(null);
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [picked]);
|
||||||
|
|
||||||
|
const moveMutation = useMutation(api.trainScheduling.moveWagonLoad.mutationOptions());
|
||||||
|
const doMove = async (targetWagonId: string, swap: boolean) => {
|
||||||
|
if (!picked || moveMutation.isPending) return;
|
||||||
|
try {
|
||||||
|
await moveMutation.mutateAsync({
|
||||||
|
scheduleId: schedule.id,
|
||||||
|
wagonId: picked.slotId,
|
||||||
|
targetWagonId,
|
||||||
|
});
|
||||||
|
toast({ title: swap ? "Loads swapped" : "Load moved" });
|
||||||
|
setPicked(null);
|
||||||
|
onChanged?.();
|
||||||
|
} catch (error) {
|
||||||
|
const message = isAxiosError(error)
|
||||||
|
? ((error.response?.data as { message?: string | string[] } | undefined)?.message ??
|
||||||
|
null)
|
||||||
|
: null;
|
||||||
|
toast({
|
||||||
|
title: "Could not move the load",
|
||||||
|
description: Array.isArray(message)
|
||||||
|
? message.join(", ")
|
||||||
|
: (message ?? "The move was rejected — check wagon type, payload and leg."),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (stops.length < 2) {
|
||||||
|
return (
|
||||||
|
<Alert color="gray" icon={<Info size={16} />} radius="md">
|
||||||
|
This schedule has no corridor stops yet — the leg board needs a route with at least
|
||||||
|
two stops.
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!rows.length) {
|
||||||
|
return (
|
||||||
|
<Alert color="gray" icon={<Info size={16} />} radius="md">
|
||||||
|
No wagons on this train yet.
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sharedRows = rows.filter((r) => r.slots.filter((s) => s.loaded).length > 1).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Text fw={700} size="sm">
|
||||||
|
Loads per wagon per leg
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
One row per physical wagon, one column per leg. A wagon reused on different legs
|
||||||
|
shows one load per leg.{" "}
|
||||||
|
{canRearrange
|
||||||
|
? "Click a load to pick it up, then click a wagon free on those legs to move it, or another load to swap."
|
||||||
|
: "Read-only — the train has departed."}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Group gap="xs">
|
||||||
|
{sharedRows > 0 ? (
|
||||||
|
<Badge variant="light" color="violet" radius="sm">
|
||||||
|
{sharedRows} wagon{sharedRows === 1 ? "" : "s"} shared across legs
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
{picked ? (
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="default"
|
||||||
|
leftSection={<X size={14} />}
|
||||||
|
onClick={() => setPicked(null)}
|
||||||
|
>
|
||||||
|
Cancel move (Esc)
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Paper withBorder radius="md" style={{ overflowX: "auto" }}>
|
||||||
|
<Table verticalSpacing={6} horizontalSpacing="sm" style={{ minWidth: 640 }}>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th style={{ position: "sticky", left: 0, background: "var(--mantine-color-body)", zIndex: 1, width: 180 }}>
|
||||||
|
Wagon
|
||||||
|
</Table.Th>
|
||||||
|
{legs.map((leg) => (
|
||||||
|
<Table.Th key={leg.idx} style={{ minWidth: 200 }}>
|
||||||
|
<Group gap={4} wrap="nowrap">
|
||||||
|
<Text size="xs" fw={700} truncate>
|
||||||
|
{leg.from.label}
|
||||||
|
</Text>
|
||||||
|
<MoveRight size={12} />
|
||||||
|
<Text size="xs" fw={700} truncate>
|
||||||
|
{leg.to.label}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Table.Th>
|
||||||
|
))}
|
||||||
|
<Table.Th style={{ width: 110 }}>Cargo</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{rows.map((row) => {
|
||||||
|
const cargoTons = row.slots.reduce(
|
||||||
|
(s, x) =>
|
||||||
|
s +
|
||||||
|
((x.slot.allocations ?? []).reduce(
|
||||||
|
(a, al) => a + (al.allocatedWeightTons ?? 0),
|
||||||
|
0,
|
||||||
|
) || x.slot.assignedWeightTons || 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const isPickedRow = picked?.rowKey === row.key;
|
||||||
|
// A row can take the picked load when nothing loaded on it rides
|
||||||
|
// any of the picked load's legs.
|
||||||
|
const rowFreeForPicked =
|
||||||
|
!!picked &&
|
||||||
|
!isPickedRow &&
|
||||||
|
!row.slots.some((s) => s.loaded && overlaps(s.span, picked.span));
|
||||||
|
// Where a "move here" lands: an existing empty slot on those legs,
|
||||||
|
// else the physical wagon itself (the API mints the slot).
|
||||||
|
const emptyTargetSlot = picked
|
||||||
|
? row.slots.find((s) => !s.loaded && overlaps(s.span, picked.span))
|
||||||
|
: undefined;
|
||||||
|
const moveTargetId = emptyTargetSlot?.slot.id ?? row.physicalWagonId ?? null;
|
||||||
|
|
||||||
|
// Lay slots into leg columns; uncovered legs render as empty cells.
|
||||||
|
const cells: React.ReactNode[] = [];
|
||||||
|
let col = 0;
|
||||||
|
const sorted = [...row.slots].sort((a, b) => a.span[0] - b.span[0]);
|
||||||
|
// Empty cell = uncovered leg (target: the physical wagon) or an
|
||||||
|
// empty slot (target: that slot). Both take the picked load when
|
||||||
|
// the row is free on its legs.
|
||||||
|
const emptyCell = (from: number, to: number, targetId = moveTargetId) => {
|
||||||
|
const droppable = rowFreeForPicked && canRearrange && !!targetId &&
|
||||||
|
!!picked && overlaps([from, to], picked.span);
|
||||||
|
return (
|
||||||
|
<Table.Td
|
||||||
|
key={`e-${from}`}
|
||||||
|
colSpan={Math.max(1, to - from)}
|
||||||
|
onClick={droppable ? () => void doMove(targetId!, false) : undefined}
|
||||||
|
style={{
|
||||||
|
cursor: droppable ? "pointer" : "default",
|
||||||
|
background: droppable ? "var(--mantine-color-teal-0)" : undefined,
|
||||||
|
outline: droppable ? "1px dashed var(--mantine-color-teal-5)" : undefined,
|
||||||
|
outlineOffset: -3,
|
||||||
|
borderRadius: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{droppable ? (
|
||||||
|
<Text size="xs" c="teal.7" fw={600} ta="center">
|
||||||
|
Move here
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text size="xs" c="dimmed" ta="center">
|
||||||
|
—
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
for (const s of sorted) {
|
||||||
|
if (s.span[0] > col) cells.push(emptyCell(col, s.span[0]));
|
||||||
|
if (!s.loaded) {
|
||||||
|
cells.push(emptyCell(s.span[0], s.span[1], s.slot.id));
|
||||||
|
col = Math.max(col, s.span[1]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const isPicked = picked?.slotId === s.slot.id;
|
||||||
|
const swappable =
|
||||||
|
!!picked && !isPicked && !isPickedRow && s.loaded && canRearrange;
|
||||||
|
const allocs = s.slot.allocations ?? [];
|
||||||
|
const bulk = allocs.some((a) => (a.loadType ?? "CONTAINER").toUpperCase() === "BULK");
|
||||||
|
const containers = allocs.flatMap((a) => a.containerItems ?? []);
|
||||||
|
cells.push(
|
||||||
|
<Table.Td
|
||||||
|
key={s.slot.id}
|
||||||
|
colSpan={Math.max(1, s.span[1] - s.span[0])}
|
||||||
|
onClick={
|
||||||
|
!canRearrange
|
||||||
|
? undefined
|
||||||
|
: s.loaded && !picked
|
||||||
|
? () => setPicked({ slotId: s.slot.id, rowKey: row.key, span: s.span })
|
||||||
|
: swappable
|
||||||
|
? () => void doMove(s.slot.id, true)
|
||||||
|
: isPicked
|
||||||
|
? () => setPicked(null)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
cursor: canRearrange && (s.loaded || swappable) ? "pointer" : "default",
|
||||||
|
padding: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{s.loaded ? (
|
||||||
|
<Paper
|
||||||
|
radius="sm"
|
||||||
|
px={8}
|
||||||
|
py={6}
|
||||||
|
style={{
|
||||||
|
background: bulk
|
||||||
|
? "var(--mantine-color-orange-0)"
|
||||||
|
: "var(--mantine-color-cyan-0)",
|
||||||
|
borderLeft: `4px solid ${
|
||||||
|
bulk ? "var(--mantine-color-orange-6)" : "var(--mantine-color-cyan-6)"
|
||||||
|
}`,
|
||||||
|
outline: isPicked
|
||||||
|
? "2px solid var(--mantine-color-edr-green-6)"
|
||||||
|
: swappable
|
||||||
|
? "1px dashed var(--mantine-color-orange-6)"
|
||||||
|
: undefined,
|
||||||
|
outlineOffset: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group gap={6} wrap="nowrap" justify="space-between">
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
{bulk ? <Wheat size={13} /> : <Boxes size={13} />}
|
||||||
|
<Text size="xs" fw={700} truncate>
|
||||||
|
{[...new Set(allocs.map((a) => a.bookingReference ?? "—"))].join(", ")}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||||
|
{round1(
|
||||||
|
allocs.reduce((a, al) => a + (al.allocatedWeightTons ?? 0), 0),
|
||||||
|
)}{" "}
|
||||||
|
t
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Group gap={4} mt={2} wrap="wrap">
|
||||||
|
{bulk
|
||||||
|
? allocs.map((a) =>
|
||||||
|
a.bulkLoad ? (
|
||||||
|
<Badge key={a.id} size="xs" variant="light" color="orange" radius="sm">
|
||||||
|
{a.bulkLoad.cargoDescription ?? "Bulk"} · {round1(a.bulkLoad.weightTons)} t
|
||||||
|
</Badge>
|
||||||
|
) : null,
|
||||||
|
)
|
||||||
|
: containers.map((c) => (
|
||||||
|
<Badge key={c.id} size="xs" variant="light" color="cyan" radius="sm">
|
||||||
|
{c.containerNumber ?? "no number"}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{swappable ? (
|
||||||
|
<Badge size="xs" color="orange" radius="sm" leftSection={<ArrowLeftRight size={10} />}>
|
||||||
|
swap
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
) : (
|
||||||
|
<Text size="xs" c="dimmed" ta="center">
|
||||||
|
empty
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Table.Td>,
|
||||||
|
);
|
||||||
|
col = Math.max(col, s.span[1]);
|
||||||
|
}
|
||||||
|
if (col < legs.length) cells.push(emptyCell(col, legs.length));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table.Tr
|
||||||
|
key={row.key}
|
||||||
|
style={{
|
||||||
|
background: isPickedRow
|
||||||
|
? "var(--mantine-color-green-0)"
|
||||||
|
: rowFreeForPicked
|
||||||
|
? undefined
|
||||||
|
: picked
|
||||||
|
? "var(--mantine-color-gray-0)"
|
||||||
|
: undefined,
|
||||||
|
opacity: picked && !isPickedRow && !rowFreeForPicked ? 0.55 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Table.Td style={{ position: "sticky", left: 0, background: "var(--mantine-color-body)", zIndex: 1 }}>
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
<Badge variant="outline" color="gray" radius="sm" size="sm">
|
||||||
|
#{row.position}
|
||||||
|
</Badge>
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text size="sm" fw={700}>
|
||||||
|
{row.label}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{row.typeCode ?? "—"} · {round1(row.capacityTons)} t
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
{row.slots.filter((s) => s.loaded).length > 1 ? (
|
||||||
|
<Tooltip label="This wagon carries different loads on different legs">
|
||||||
|
<Badge size="xs" color="violet" variant="light" radius="sm">
|
||||||
|
shared
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
|
{cells.map((c, i) => (
|
||||||
|
<Fragment key={i}>{c}</Fragment>
|
||||||
|
))}
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="xs" fw={600} c={cargoTons > row.capacityTons + 0.001 ? "red.7" : undefined}>
|
||||||
|
{round1(cargoTons)} / {round1(row.capacityTons)} t
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
|
import { DateTimePicker } from "@mantine/dates";
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
@@ -111,7 +112,12 @@ export function LogPassYardWorkModal({
|
|||||||
}) {
|
}) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [justLogged, setJustLogged] = useState(false);
|
const [justLogged, setJustLogged] = useState(false);
|
||||||
useEffect(() => setJustLogged(false), [station?.sequenceNo, opened]);
|
// When the train was here — defaults to now, past allowed (recorded after the fact).
|
||||||
|
const [passAt, setPassAt] = useState<Date | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
setJustLogged(false);
|
||||||
|
setPassAt(new Date());
|
||||||
|
}, [station?.sequenceNo, opened]);
|
||||||
const logged = alreadyLogged || justLogged;
|
const logged = alreadyLogged || justLogged;
|
||||||
|
|
||||||
const yardWorkQuery = useQuery(
|
const yardWorkQuery = useQuery(
|
||||||
@@ -133,7 +139,13 @@ export function LogPassYardWorkModal({
|
|||||||
const doLogPass = () => {
|
const doLogPass = () => {
|
||||||
if (!station) return;
|
if (!station) return;
|
||||||
recordCheckpoint.mutate(
|
recordCheckpoint.mutate(
|
||||||
{ id: scheduleId, payload: { sequenceNo: station.sequenceNo } },
|
{
|
||||||
|
id: scheduleId,
|
||||||
|
payload: {
|
||||||
|
sequenceNo: station.sequenceNo,
|
||||||
|
...(passAt ? { occurredAt: passAt.toISOString() } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setJustLogged(true);
|
setJustLogged(true);
|
||||||
@@ -373,6 +385,20 @@ export function LogPassYardWorkModal({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!logged ? (
|
||||||
|
<DateTimePicker
|
||||||
|
label={isFinal ? "Arrival time" : "Time at station"}
|
||||||
|
description="Defaults to now — pick an earlier time if you are recording after the fact."
|
||||||
|
value={passAt}
|
||||||
|
onChange={(v) => setPassAt(v ? new Date(v) : null)}
|
||||||
|
maxDate={new Date()}
|
||||||
|
valueFormat="DD MMM YYYY HH:mm"
|
||||||
|
clearable={false}
|
||||||
|
radius="md"
|
||||||
|
maw={320}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Group justify="space-between" mt="xs">
|
<Group justify="space-between" mt="xs">
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
{logged && pendingBoarders.length > 0
|
{logged && pendingBoarders.length > 0
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Fragment } from "react";
|
import { Fragment } from "react";
|
||||||
import { Badge, Box, Button, Group, Stack, Text } from "@mantine/core";
|
import { Badge, Box, Button, Group, Stack, Text } from "@mantine/core";
|
||||||
import { Check, Flag, MapPin, Train } from "lucide-react";
|
import { Check, Flag, MapPin, Pencil, Train } from "lucide-react";
|
||||||
|
|
||||||
import { freightBrand } from "@/theme/freight-brand";
|
import { freightBrand } from "@/theme/freight-brand";
|
||||||
import type { TrainCheckpoint, TrackStation } from "@/types/trainScheduling";
|
import type { TrainCheckpoint, TrackStation } from "@/types/trainScheduling";
|
||||||
@@ -14,6 +14,8 @@ export interface RouteCorridorTrackProps {
|
|||||||
canLog: boolean;
|
canLog: boolean;
|
||||||
loggingSeq?: number | null;
|
loggingSeq?: number | null;
|
||||||
onLogCheckpoint?: (sequenceNo: number) => void;
|
onLogCheckpoint?: (sequenceNo: number) => void;
|
||||||
|
/** Present when logged legs may be corrected (dispatched or arrived). */
|
||||||
|
onEditCheckpoint?: (checkpoint: TrainCheckpoint) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const COLUMN_WIDTH = 150;
|
const COLUMN_WIDTH = 150;
|
||||||
@@ -31,6 +33,7 @@ export function RouteCorridorTrack({
|
|||||||
canLog,
|
canLog,
|
||||||
loggingSeq,
|
loggingSeq,
|
||||||
onLogCheckpoint,
|
onLogCheckpoint,
|
||||||
|
onEditCheckpoint,
|
||||||
}: RouteCorridorTrackProps) {
|
}: RouteCorridorTrackProps) {
|
||||||
const bySeq = new Map(checkpoints.map((c) => [c.sequenceNo, c]));
|
const bySeq = new Map(checkpoints.map((c) => [c.sequenceNo, c]));
|
||||||
const lastIndex = stations.length - 1;
|
const lastIndex = stations.length - 1;
|
||||||
@@ -160,14 +163,28 @@ export function RouteCorridorTrack({
|
|||||||
|
|
||||||
{/* checkpoint time or action */}
|
{/* checkpoint time or action */}
|
||||||
{checkpoint ? (
|
{checkpoint ? (
|
||||||
<Text size="10px" c="dimmed" ta="center">
|
<Stack gap={2} align="center">
|
||||||
{new Date(checkpoint.occurredAt).toLocaleString(undefined, {
|
<Text size="10px" c="dimmed" ta="center">
|
||||||
month: "short",
|
{new Date(checkpoint.occurredAt).toLocaleString(undefined, {
|
||||||
day: "numeric",
|
month: "short",
|
||||||
hour: "2-digit",
|
day: "numeric",
|
||||||
minute: "2-digit",
|
hour: "2-digit",
|
||||||
})}
|
minute: "2-digit",
|
||||||
</Text>
|
})}
|
||||||
|
</Text>
|
||||||
|
{onEditCheckpoint ? (
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
radius="md"
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
leftSection={<Pencil size={11} />}
|
||||||
|
onClick={() => onEditCheckpoint(checkpoint)}
|
||||||
|
>
|
||||||
|
Edit time
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
) : isNext ? (
|
) : isNext ? (
|
||||||
<Button
|
<Button
|
||||||
size="compact-xs"
|
size="compact-xs"
|
||||||
|
|||||||
@@ -483,6 +483,8 @@ export const URL_CONSTANTS = {
|
|||||||
`/train-scheduling/schedules/${id}/intercity/marshalling/document`,
|
`/train-scheduling/schedules/${id}/intercity/marshalling/document`,
|
||||||
CHECKPOINTS: (id: string) =>
|
CHECKPOINTS: (id: string) =>
|
||||||
`/train-scheduling/schedules/${id}/checkpoints`,
|
`/train-scheduling/schedules/${id}/checkpoints`,
|
||||||
|
CHECKPOINT: (id: string, sequenceNo: number) =>
|
||||||
|
`/train-scheduling/schedules/${id}/checkpoints/${sequenceNo}`,
|
||||||
ARRIVE: (id: string) => `/train-scheduling/schedules/${id}/arrive`,
|
ARRIVE: (id: string) => `/train-scheduling/schedules/${id}/arrive`,
|
||||||
RESCHEDULE_PREVIEW: (id: string) =>
|
RESCHEDULE_PREVIEW: (id: string) =>
|
||||||
`/train-scheduling/schedules/${id}/reschedule/preview`,
|
`/train-scheduling/schedules/${id}/reschedule/preview`,
|
||||||
|
|||||||
@@ -353,6 +353,10 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
|
|||||||
{ id: "importTrainNumber", header: "Import train no.", accessorKey: "importTrainNumber", format: "code" },
|
{ id: "importTrainNumber", header: "Import train no.", accessorKey: "importTrainNumber", format: "code" },
|
||||||
{ id: "currentYard", header: "Current Yard", accessorKey: "currentYard", format: "entityLabel" },
|
{ id: "currentYard", header: "Current Yard", accessorKey: "currentYard", format: "entityLabel" },
|
||||||
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge" },
|
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge" },
|
||||||
|
// From the status-flip log: last time the wagon went to maintenance, and
|
||||||
|
// last time it became available again (dash = never logged).
|
||||||
|
{ id: "lastMaintenanceAt", header: "Last to maintenance", accessorKey: "lastMaintenanceAt", format: "date" },
|
||||||
|
{ id: "lastAvailableAt", header: "Available since", accessorKey: "lastAvailableAt", format: "date" },
|
||||||
],
|
],
|
||||||
formFields: [
|
formFields: [
|
||||||
// Run numbers are optional — a wagon sits in the fleet unassigned to any
|
// Run numbers are optional — a wagon sits in the fleet unassigned to any
|
||||||
|
|||||||
@@ -77,9 +77,31 @@ function InfoField({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Billed-to company, with its contact/registration details as quick-info rows. */
|
/**
|
||||||
|
* Billed-to party: a customer company, or — for shipping-line credit invoices
|
||||||
|
* (`companyId` null) — the shipping line itself. The two payers are mutually
|
||||||
|
* exclusive (DB-enforced), so exactly one branch has data.
|
||||||
|
*/
|
||||||
function RecipientCard({ invoice }: { invoice: Invoice }) {
|
function RecipientCard({ invoice }: { invoice: Invoice }) {
|
||||||
const company = invoice.company;
|
const company = invoice.company;
|
||||||
|
const shippingLine = invoice.shippingLineCompany;
|
||||||
|
|
||||||
|
if (!company && shippingLine) {
|
||||||
|
const rows: FieldRowProps[] = [
|
||||||
|
{ label: "Phone", value: shippingLine.phoneNumber },
|
||||||
|
{ label: "Email", value: shippingLine.email },
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<LinkedEntityCard
|
||||||
|
icon={Building2}
|
||||||
|
title="Billed to"
|
||||||
|
name={shippingLine.name}
|
||||||
|
rows={rows}
|
||||||
|
emptyMessage="No additional shipping line details available."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const rows: FieldRowProps[] = [
|
const rows: FieldRowProps[] = [
|
||||||
{ label: "Profile", value: invoice.companyProfile?.reference },
|
{ label: "Profile", value: invoice.companyProfile?.reference },
|
||||||
{ label: "TIN", value: company?.tin },
|
{ label: "TIN", value: company?.tin },
|
||||||
@@ -91,7 +113,7 @@ function RecipientCard({ invoice }: { invoice: Invoice }) {
|
|||||||
return (
|
return (
|
||||||
<LinkedEntityCard
|
<LinkedEntityCard
|
||||||
icon={Building2}
|
icon={Building2}
|
||||||
title="Recipient"
|
title="Billed to"
|
||||||
name={company?.name ?? "Unnamed company"}
|
name={company?.name ?? "Unnamed company"}
|
||||||
to={invoice.companyId ? `/dashboard/customers/${invoice.companyId}` : null}
|
to={invoice.companyId ? `/dashboard/customers/${invoice.companyId}` : null}
|
||||||
rows={rows}
|
rows={rows}
|
||||||
|
|||||||
@@ -112,7 +112,9 @@ export default function InvoicesPanel() {
|
|||||||
header: "Billed to",
|
header: "Billed to",
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Text size="sm" c="edr-text" truncate maw={200}>
|
<Text size="sm" c="edr-text" truncate maw={200}>
|
||||||
{row.original.company?.name ?? "—"}
|
{row.original.company?.name ??
|
||||||
|
row.original.shippingLineCompany?.name ??
|
||||||
|
"—"}
|
||||||
</Text>
|
</Text>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -580,14 +580,10 @@ const RuleEngineResourcePage = () => {
|
|||||||
config={config}
|
config={config}
|
||||||
layout="row"
|
layout="row"
|
||||||
readOnly={!canUpdateControls}
|
readOnly={!canUpdateControls}
|
||||||
onEdit={
|
onEdit={(record) => {
|
||||||
config.slug === "container-types"
|
setEditing(record);
|
||||||
? undefined
|
setFormOpen(true);
|
||||||
: (record) => {
|
}}
|
||||||
setEditing(record);
|
|
||||||
setFormOpen(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onDelete={setDeleteTarget}
|
onDelete={setDeleteTarget}
|
||||||
onViewChain={
|
onViewChain={
|
||||||
config.slug === "approval-rules"
|
config.slug === "approval-rules"
|
||||||
@@ -958,9 +954,7 @@ const RuleEngineResourcePage = () => {
|
|||||||
totalCount={totalCount}
|
totalCount={totalCount}
|
||||||
onPaginationChange={setPagination}
|
onPaginationChange={setPagination}
|
||||||
readOnly={!canUpdate && !canDelete}
|
readOnly={!canUpdate && !canDelete}
|
||||||
onEdit={
|
onEdit={canUpdate ? openEdit : undefined}
|
||||||
canUpdate && config.slug !== "container-types" ? openEdit : undefined
|
|
||||||
}
|
|
||||||
onDelete={canDelete ? setDeleteTarget : undefined}
|
onDelete={canDelete ? setDeleteTarget : undefined}
|
||||||
onViewChain={
|
onViewChain={
|
||||||
config.slug === "approval-rules"
|
config.slug === "approval-rules"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
MapPin,
|
MapPin,
|
||||||
Navigation,
|
Navigation,
|
||||||
PackageCheck,
|
PackageCheck,
|
||||||
|
Pencil,
|
||||||
Train,
|
Train,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
@@ -29,9 +30,10 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
|
|
||||||
import { PageContainer } from "@/components/page";
|
import { PageContainer } from "@/components/page";
|
||||||
|
import { CheckpointTimeModal } from "@/components/trainScheduling/CheckpointTimeModal";
|
||||||
import { LogPassYardWorkModal } from "@/components/trainScheduling/LogPassYardWorkModal";
|
import { LogPassYardWorkModal } from "@/components/trainScheduling/LogPassYardWorkModal";
|
||||||
import { RouteCorridorTrack } from "@/components/trainScheduling/RouteCorridorTrack";
|
import { RouteCorridorTrack } from "@/components/trainScheduling/RouteCorridorTrack";
|
||||||
import type { TrackStation } from "@/types/trainScheduling";
|
import type { TrackStation, TrainCheckpoint } from "@/types/trainScheduling";
|
||||||
import {
|
import {
|
||||||
RouteCorridor,
|
RouteCorridor,
|
||||||
StatusPill,
|
StatusPill,
|
||||||
@@ -156,6 +158,16 @@ export default function TrainScheduleTrackPage() {
|
|||||||
const recordCheckpoint = useMutation(
|
const recordCheckpoint = useMutation(
|
||||||
api.trainScheduling.recordCheckpoint.mutationOptions(),
|
api.trainScheduling.recordCheckpoint.mutationOptions(),
|
||||||
);
|
);
|
||||||
|
const updateCheckpoint = useMutation(
|
||||||
|
api.trainScheduling.updateCheckpoint.mutationOptions(),
|
||||||
|
);
|
||||||
|
// Time-entry dialogs: logging a pass at a yard with no work (the yard-work
|
||||||
|
// modal carries its own picker), and correcting an already-logged leg.
|
||||||
|
const [logModal, setLogModal] = useState<{
|
||||||
|
station: TrackStation;
|
||||||
|
isFinal: boolean;
|
||||||
|
} | null>(null);
|
||||||
|
const [editModal, setEditModal] = useState<TrainCheckpoint | null>(null);
|
||||||
// Yard work drives the log-pass modal: which bookings board/alight per stop.
|
// Yard work drives the log-pass modal: which bookings board/alight per stop.
|
||||||
const yardWorkQuery = useQuery(
|
const yardWorkQuery = useQuery(
|
||||||
api.trainScheduling.yardWork.queryOptions({
|
api.trainScheduling.yardWork.queryOptions({
|
||||||
@@ -241,15 +253,30 @@ export default function TrainScheduleTrackPage() {
|
|||||||
|
|
||||||
const handleLog = (sequenceNo: number) => {
|
const handleLog = (sequenceNo: number) => {
|
||||||
const station = track.stations.find((s) => s.sequenceNo === sequenceNo);
|
const station = track.stations.find((s) => s.sequenceNo === sequenceNo);
|
||||||
|
if (!station) return;
|
||||||
const isFinal = sequenceNo === track.stations[totalStations - 1]?.sequenceNo;
|
const isFinal = sequenceNo === track.stations[totalStations - 1]?.sequenceNo;
|
||||||
if (station && stationHasWork(station)) {
|
if (stationHasWork(station)) {
|
||||||
setYardModal({ station, isFinal, alreadyLogged: false });
|
setYardModal({ station, isFinal, alreadyLogged: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setLogModal({ station, isFinal });
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitLog = (values: { occurredAt: string; note: string }) => {
|
||||||
|
if (!logModal) return;
|
||||||
|
const { station, isFinal } = logModal;
|
||||||
recordCheckpoint.mutate(
|
recordCheckpoint.mutate(
|
||||||
{ id: scheduleId, payload: { sequenceNo } },
|
{
|
||||||
|
id: scheduleId,
|
||||||
|
payload: {
|
||||||
|
sequenceNo: station.sequenceNo,
|
||||||
|
occurredAt: values.occurredAt,
|
||||||
|
...(values.note ? { note: values.note } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
setLogModal(null);
|
||||||
toast({
|
toast({
|
||||||
title: isFinal
|
title: isFinal
|
||||||
? "Train arrived — assets freed, moved to destination yard"
|
? "Train arrived — assets freed, moved to destination yard"
|
||||||
@@ -266,6 +293,32 @@ export default function TrainScheduleTrackPage() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const submitEdit = (values: { occurredAt: string; note: string }) => {
|
||||||
|
if (!editModal) return;
|
||||||
|
updateCheckpoint.mutate(
|
||||||
|
{
|
||||||
|
id: scheduleId,
|
||||||
|
sequenceNo: editModal.sequenceNo,
|
||||||
|
payload: { occurredAt: values.occurredAt, note: values.note || null },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setEditModal(null);
|
||||||
|
toast({ title: "Checkpoint updated" });
|
||||||
|
},
|
||||||
|
onError: (err) =>
|
||||||
|
toast({
|
||||||
|
title: "Could not update checkpoint",
|
||||||
|
description: parseError(err, "Please try again"),
|
||||||
|
variant: "destructive",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
// Legs stay correctable for as long as the journey exists — while rolling
|
||||||
|
// and after arrival.
|
||||||
|
const canEdit = track.status === "DISPATCHED" || track.status === "ARRIVED";
|
||||||
|
|
||||||
// "Forgot to load" catch: while the train sits at the current station, any
|
// "Forgot to load" catch: while the train sits at the current station, any
|
||||||
// boarder there that is still unloaded can be loaded until the next pass.
|
// boarder there that is still unloaded can be loaded until the next pass.
|
||||||
const currentStationObj = track.stations.find(
|
const currentStationObj = track.stations.find(
|
||||||
@@ -502,6 +555,7 @@ export default function TrainScheduleTrackPage() {
|
|||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
onLogCheckpoint={handleLog}
|
onLogCheckpoint={handleLog}
|
||||||
|
onEditCheckpoint={canEdit ? setEditModal : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Cargo the operator forgot: boarders at the CURRENT station stay
|
{/* Cargo the operator forgot: boarders at the CURRENT station stay
|
||||||
@@ -595,24 +649,38 @@ export default function TrainScheduleTrackPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
title={
|
title={
|
||||||
<Group gap="sm">
|
<Group gap="sm" justify="space-between" wrap="nowrap">
|
||||||
<Text fw={700} size="sm">
|
<Group gap="sm">
|
||||||
{cp.label ?? `Station ${cp.sequenceNo}`}
|
<Text fw={700} size="sm">
|
||||||
</Text>
|
{cp.label ?? `Station ${cp.sequenceNo}`}
|
||||||
<Badge
|
</Text>
|
||||||
size="xs"
|
<Badge
|
||||||
radius="sm"
|
size="xs"
|
||||||
variant="light"
|
radius="sm"
|
||||||
color={
|
variant="light"
|
||||||
cp.kind === "ARRIVED"
|
color={
|
||||||
? "teal"
|
cp.kind === "ARRIVED"
|
||||||
: cp.kind === "DEPARTED"
|
? "teal"
|
||||||
? "blue"
|
: cp.kind === "DEPARTED"
|
||||||
: "edr-green"
|
? "blue"
|
||||||
}
|
: "edr-green"
|
||||||
>
|
}
|
||||||
{cp.kind}
|
>
|
||||||
</Badge>
|
{cp.kind}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
{canEdit ? (
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
radius="md"
|
||||||
|
variant="light"
|
||||||
|
color="gray"
|
||||||
|
leftSection={<Pencil size={12} />}
|
||||||
|
onClick={() => setEditModal(cp)}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</Group>
|
</Group>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -630,6 +698,39 @@ export default function TrainScheduleTrackPage() {
|
|||||||
)}
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
|
<CheckpointTimeModal
|
||||||
|
opened={logModal !== null}
|
||||||
|
onClose={() => setLogModal(null)}
|
||||||
|
title={
|
||||||
|
logModal?.isFinal
|
||||||
|
? `Mark arrived at ${logModal.station.label}`
|
||||||
|
: `Log pass at ${logModal?.station.label ?? "station"}`
|
||||||
|
}
|
||||||
|
icon={logModal?.isFinal ? <Flag size={18} /> : <MapPin size={18} />}
|
||||||
|
description={
|
||||||
|
logModal?.isFinal
|
||||||
|
? "Marks the train arrived: remaining bookings arrive, assets are freed."
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
submitLabel={logModal?.isFinal ? "Mark arrived" : "Log pass"}
|
||||||
|
submitColor={logModal?.isFinal ? "teal" : "edr-green"}
|
||||||
|
loading={recordCheckpoint.isPending}
|
||||||
|
onSubmit={submitLog}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CheckpointTimeModal
|
||||||
|
opened={editModal !== null}
|
||||||
|
onClose={() => setEditModal(null)}
|
||||||
|
title={`Edit ${editModal?.label ?? "checkpoint"}`}
|
||||||
|
icon={<Pencil size={18} />}
|
||||||
|
description="Corrects this leg's time and note only — nothing else changes."
|
||||||
|
initialOccurredAt={editModal?.occurredAt}
|
||||||
|
initialNote={editModal?.note}
|
||||||
|
submitLabel="Save"
|
||||||
|
loading={updateCheckpoint.isPending}
|
||||||
|
onSubmit={submitEdit}
|
||||||
|
/>
|
||||||
|
|
||||||
<LogPassYardWorkModal
|
<LogPassYardWorkModal
|
||||||
opened={yardModal !== null}
|
opened={yardModal !== null}
|
||||||
onClose={() => setYardModal(null)}
|
onClose={() => setYardModal(null)}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
Navigation,
|
Navigation,
|
||||||
Package,
|
Package,
|
||||||
PackageCheck,
|
PackageCheck,
|
||||||
|
Grid3x3,
|
||||||
Route as RouteIcon,
|
Route as RouteIcon,
|
||||||
Ruler,
|
Ruler,
|
||||||
Send,
|
Send,
|
||||||
@@ -41,6 +42,7 @@ import {
|
|||||||
Weight,
|
Weight,
|
||||||
Workflow as WorkflowIcon,
|
Workflow as WorkflowIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { DateTimePicker } from "@mantine/dates";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Link, useParams } from "react-router-dom";
|
import { Link, useParams } from "react-router-dom";
|
||||||
|
|
||||||
@@ -57,6 +59,7 @@ import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPl
|
|||||||
import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary";
|
import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary";
|
||||||
import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityRideAlongPanel";
|
import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityRideAlongPanel";
|
||||||
import { LegCapacityPanel } from "@/components/trainScheduling/LegCapacityPanel";
|
import { LegCapacityPanel } from "@/components/trainScheduling/LegCapacityPanel";
|
||||||
|
import { LegLoadBoardPanel } from "@/components/trainScheduling/LegLoadBoardPanel";
|
||||||
import { LoadEmptyContainersModal } from "@/components/trainScheduling/LoadEmptyContainersModal";
|
import { LoadEmptyContainersModal } from "@/components/trainScheduling/LoadEmptyContainersModal";
|
||||||
import MergeScheduleTrainModal from "@/components/trainScheduling/MergeScheduleTrainModal";
|
import MergeScheduleTrainModal from "@/components/trainScheduling/MergeScheduleTrainModal";
|
||||||
import ScheduleHistoryPanel from "@/components/trainScheduling/ScheduleHistoryPanel";
|
import ScheduleHistoryPanel from "@/components/trainScheduling/ScheduleHistoryPanel";
|
||||||
@@ -125,6 +128,13 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
const [gatepassFileUrl, setGatepassFileUrl] = useState("");
|
const [gatepassFileUrl, setGatepassFileUrl] = useState("");
|
||||||
const [gatepassNotes, setGatepassNotes] = useState("");
|
const [gatepassNotes, setGatepassNotes] = useState("");
|
||||||
const [dispatchConfirmOpen, setDispatchConfirmOpen] = useState(false);
|
const [dispatchConfirmOpen, setDispatchConfirmOpen] = useState(false);
|
||||||
|
// Actual departure — staff often dispatch on paper first and record it later,
|
||||||
|
// so the time is picked (defaults to now when the dialog opens).
|
||||||
|
const [dispatchAt, setDispatchAt] = useState<Date | null>(null);
|
||||||
|
const openDispatchConfirm = () => {
|
||||||
|
setDispatchAt(new Date());
|
||||||
|
setDispatchConfirmOpen(true);
|
||||||
|
};
|
||||||
const [switchTarget, setSwitchTarget] = useState<EligibleContainerBooking | null>(null);
|
const [switchTarget, setSwitchTarget] = useState<EligibleContainerBooking | null>(null);
|
||||||
const [visualization3DOpen, setVisualization3DOpen] = useState(false);
|
const [visualization3DOpen, setVisualization3DOpen] = useState(false);
|
||||||
const [loadEmptiesOpen, setLoadEmptiesOpen] = useState(false);
|
const [loadEmptiesOpen, setLoadEmptiesOpen] = useState(false);
|
||||||
@@ -477,7 +487,10 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
const runDispatch = async () => {
|
const runDispatch = async () => {
|
||||||
setDispatchConfirmOpen(false);
|
setDispatchConfirmOpen(false);
|
||||||
try {
|
try {
|
||||||
await dispatch.mutateAsync(scheduleId);
|
await dispatch.mutateAsync({
|
||||||
|
id: scheduleId,
|
||||||
|
payload: dispatchAt ? { actualDepartureAt: dispatchAt.toISOString() } : {},
|
||||||
|
});
|
||||||
await openMarshallingDocument({
|
await openMarshallingDocument({
|
||||||
title: "Train dispatched",
|
title: "Train dispatched",
|
||||||
successDescription: "Marshalling document generated for the dispatched train.",
|
successDescription: "Marshalling document generated for the dispatched train.",
|
||||||
@@ -873,7 +886,7 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
radius="md"
|
radius="md"
|
||||||
leftSection={<Send size={18} />}
|
leftSection={<Send size={18} />}
|
||||||
loading={dispatch.isPending}
|
loading={dispatch.isPending}
|
||||||
onClick={() => setDispatchConfirmOpen(true)}
|
onClick={openDispatchConfirm}
|
||||||
>
|
>
|
||||||
Dispatch train
|
Dispatch train
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1271,6 +1284,9 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
<Tabs.Tab value="legs" leftSection={<RouteIcon size={16} />}>
|
<Tabs.Tab value="legs" leftSection={<RouteIcon size={16} />}>
|
||||||
Leg capacity
|
Leg capacity
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="leg-board" leftSection={<Grid3x3 size={16} />}>
|
||||||
|
Leg board
|
||||||
|
</Tabs.Tab>
|
||||||
<Tabs.Tab value="history" leftSection={<HistoryIcon size={16} />}>
|
<Tabs.Tab value="history" leftSection={<HistoryIcon size={16} />}>
|
||||||
History
|
History
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
@@ -1359,6 +1375,13 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
<LegCapacityPanel schedule={schedule} />
|
<LegCapacityPanel schedule={schedule} />
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
<Tabs.Panel value="leg-board">
|
||||||
|
<LegLoadBoardPanel
|
||||||
|
schedule={schedule}
|
||||||
|
onChanged={() => void detailQuery.refetch()}
|
||||||
|
/>
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
<Tabs.Panel value="history">
|
<Tabs.Panel value="history">
|
||||||
{scheduleId ? <ScheduleHistoryPanel scheduleId={scheduleId} /> : null}
|
{scheduleId ? <ScheduleHistoryPanel scheduleId={scheduleId} /> : null}
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
@@ -1444,6 +1467,17 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
undone.
|
undone.
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
|
<DateTimePicker
|
||||||
|
label="Actual departure"
|
||||||
|
description="When the train left — defaults to now; a past time is fine."
|
||||||
|
value={dispatchAt}
|
||||||
|
onChange={(v) => setDispatchAt(v ? new Date(v) : null)}
|
||||||
|
maxDate={new Date()}
|
||||||
|
valueFormat="DD MMM YYYY HH:mm"
|
||||||
|
clearable={false}
|
||||||
|
radius="md"
|
||||||
|
/>
|
||||||
|
|
||||||
{hasDispatchWarnings ? (
|
{hasDispatchWarnings ? (
|
||||||
<Alert
|
<Alert
|
||||||
color="orange"
|
color="orange"
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
TextInput,
|
TextInput,
|
||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
|
import { DateTimePicker } from "@mantine/dates";
|
||||||
import { useDebouncedValue } from "@mantine/hooks";
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
import { isAxiosError } from "axios";
|
import { isAxiosError } from "axios";
|
||||||
import {
|
import {
|
||||||
@@ -134,6 +135,8 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
// confirmation.
|
// confirmation.
|
||||||
const [dispatchTarget, setDispatchTarget] =
|
const [dispatchTarget, setDispatchTarget] =
|
||||||
useState<TrainScheduleListItem | null>(null);
|
useState<TrainScheduleListItem | null>(null);
|
||||||
|
// Actual departure — defaults to now when the dialog opens; past is fine.
|
||||||
|
const [dispatchAt, setDispatchAt] = useState<Date | null>(null);
|
||||||
// Cancelling is likewise irreversible — confirmed before the mutation fires.
|
// Cancelling is likewise irreversible — confirmed before the mutation fires.
|
||||||
const [cancelTarget, setCancelTarget] =
|
const [cancelTarget, setCancelTarget] =
|
||||||
useState<TrainScheduleListItem | null>(null);
|
useState<TrainScheduleListItem | null>(null);
|
||||||
@@ -508,7 +511,10 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
{canDispatch && schedule.status === "SCHEDULED" ? (
|
{canDispatch && schedule.status === "SCHEDULED" ? (
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<Play size={15} />}
|
leftSection={<Play size={15} />}
|
||||||
onClick={() => setDispatchTarget(schedule)}
|
onClick={() => {
|
||||||
|
setDispatchAt(new Date());
|
||||||
|
setDispatchTarget(schedule);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Start (dispatch) train
|
Start (dispatch) train
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
@@ -959,6 +965,16 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
wagons or cargo not yet marked loaded — those warnings are shown
|
wagons or cargo not yet marked loaded — those warnings are shown
|
||||||
there, not here.
|
there, not here.
|
||||||
</Text>
|
</Text>
|
||||||
|
<DateTimePicker
|
||||||
|
label="Actual departure"
|
||||||
|
description="When the train left — defaults to now; a past time is fine."
|
||||||
|
value={dispatchAt}
|
||||||
|
onChange={(v) => setDispatchAt(v ? new Date(v) : null)}
|
||||||
|
maxDate={new Date()}
|
||||||
|
valueFormat="DD MMM YYYY HH:mm"
|
||||||
|
clearable={false}
|
||||||
|
radius="md"
|
||||||
|
/>
|
||||||
<Group justify="flex-end" gap="sm">
|
<Group justify="flex-end" gap="sm">
|
||||||
<Button variant="default" onClick={() => setDispatchTarget(null)}>
|
<Button variant="default" onClick={() => setDispatchTarget(null)}>
|
||||||
Cancel
|
Cancel
|
||||||
@@ -970,7 +986,12 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (!dispatchTarget) return;
|
if (!dispatchTarget) return;
|
||||||
try {
|
try {
|
||||||
await dispatchSchedule.mutateAsync(dispatchTarget.id);
|
await dispatchSchedule.mutateAsync({
|
||||||
|
id: dispatchTarget.id,
|
||||||
|
payload: dispatchAt
|
||||||
|
? { actualDepartureAt: dispatchAt.toISOString() }
|
||||||
|
: {},
|
||||||
|
});
|
||||||
toast({ title: "Train dispatched" });
|
toast({ title: "Train dispatched" });
|
||||||
setDispatchTarget(null);
|
setDispatchTarget(null);
|
||||||
void schedulesQuery.refetch();
|
void schedulesQuery.refetch();
|
||||||
|
|||||||
@@ -81,6 +81,8 @@ import type {
|
|||||||
LocomotiveRecord,
|
LocomotiveRecord,
|
||||||
PinWagonsPayload,
|
PinWagonsPayload,
|
||||||
RecordCheckpointPayload,
|
RecordCheckpointPayload,
|
||||||
|
UpdateCheckpointPayload,
|
||||||
|
DispatchSchedulePayload,
|
||||||
StaffBookingWindow,
|
StaffBookingWindow,
|
||||||
ScheduleMergePreview,
|
ScheduleMergePreview,
|
||||||
TrainScheduleDetail,
|
TrainScheduleDetail,
|
||||||
@@ -797,10 +799,13 @@ export const api = {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
dispatchSchedule: endpoint<string, TrainScheduleDetail>(
|
dispatchSchedule: endpoint<
|
||||||
|
{ id: string; payload?: DispatchSchedulePayload },
|
||||||
|
TrainScheduleDetail
|
||||||
|
>(
|
||||||
"train-scheduling",
|
"train-scheduling",
|
||||||
"dispatch-schedule",
|
"dispatch-schedule",
|
||||||
(id) => trainSchedulingService.dispatchSchedule(id),
|
({ id, payload }) => trainSchedulingService.dispatchSchedule(id, payload),
|
||||||
undefined,
|
undefined,
|
||||||
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
||||||
),
|
),
|
||||||
@@ -918,6 +923,18 @@ export const api = {
|
|||||||
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
||||||
),
|
),
|
||||||
|
|
||||||
|
updateCheckpoint: endpoint<
|
||||||
|
{ id: string; sequenceNo: number; payload: UpdateCheckpointPayload },
|
||||||
|
TrainTrackResponse
|
||||||
|
>(
|
||||||
|
"train-scheduling",
|
||||||
|
"update-checkpoint",
|
||||||
|
({ id, sequenceNo, payload }) =>
|
||||||
|
trainSchedulingService.updateCheckpoint(id, sequenceNo, payload),
|
||||||
|
undefined,
|
||||||
|
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
||||||
|
),
|
||||||
|
|
||||||
arriveSchedule: endpoint<string, TrainScheduleDetail>(
|
arriveSchedule: endpoint<string, TrainScheduleDetail>(
|
||||||
"train-scheduling",
|
"train-scheduling",
|
||||||
"arrive-schedule",
|
"arrive-schedule",
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ import type {
|
|||||||
LocomotiveRecord,
|
LocomotiveRecord,
|
||||||
PinWagonsPayload,
|
PinWagonsPayload,
|
||||||
RecordCheckpointPayload,
|
RecordCheckpointPayload,
|
||||||
|
UpdateCheckpointPayload,
|
||||||
|
DispatchSchedulePayload,
|
||||||
StaffBookingWindow,
|
StaffBookingWindow,
|
||||||
ScheduleMergePreview,
|
ScheduleMergePreview,
|
||||||
TrainScheduleDetail,
|
TrainScheduleDetail,
|
||||||
@@ -524,10 +526,11 @@ export const trainSchedulingService = {
|
|||||||
|
|
||||||
dispatchSchedule: async (
|
dispatchSchedule: async (
|
||||||
scheduleId: string,
|
scheduleId: string,
|
||||||
|
payload: DispatchSchedulePayload = {},
|
||||||
): Promise<TrainScheduleDetail> => {
|
): Promise<TrainScheduleDetail> => {
|
||||||
const response = await client.post<TrainScheduleDetail>(
|
const response = await client.post<TrainScheduleDetail>(
|
||||||
URL_CONSTANTS.TRAIN_SCHEDULING.DISPATCH(scheduleId),
|
URL_CONSTANTS.TRAIN_SCHEDULING.DISPATCH(scheduleId),
|
||||||
{},
|
payload,
|
||||||
);
|
);
|
||||||
return unwrap(response.data);
|
return unwrap(response.data);
|
||||||
},
|
},
|
||||||
@@ -696,6 +699,18 @@ export const trainSchedulingService = {
|
|||||||
return unwrap(response.data);
|
return unwrap(response.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
updateCheckpoint: async (
|
||||||
|
scheduleId: string,
|
||||||
|
sequenceNo: number,
|
||||||
|
payload: UpdateCheckpointPayload,
|
||||||
|
): Promise<TrainTrackResponse> => {
|
||||||
|
const response = await client.patch<TrainTrackResponse>(
|
||||||
|
URL_CONSTANTS.TRAIN_SCHEDULING.CHECKPOINT(scheduleId, sequenceNo),
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
arriveSchedule: async (scheduleId: string): Promise<TrainScheduleDetail> => {
|
arriveSchedule: async (scheduleId: string): Promise<TrainScheduleDetail> => {
|
||||||
const response = await client.post<TrainScheduleDetail>(
|
const response = await client.post<TrainScheduleDetail>(
|
||||||
URL_CONSTANTS.TRAIN_SCHEDULING.ARRIVE(scheduleId),
|
URL_CONSTANTS.TRAIN_SCHEDULING.ARRIVE(scheduleId),
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ export interface Wagon {
|
|||||||
lengthMeters?: number;
|
lengthMeters?: number;
|
||||||
} | null;
|
} | null;
|
||||||
status: Freight.WagonStatus;
|
status: Freight.WagonStatus;
|
||||||
|
/** Latest status-log flip to MAINTENANCE / to AVAILABLE (list endpoint only). */
|
||||||
|
lastMaintenanceAt?: string | null;
|
||||||
|
lastAvailableAt?: string | null;
|
||||||
currentYardId: string | null;
|
currentYardId: string | null;
|
||||||
currentYard?: { id: string; label?: string; code?: string } | null;
|
currentYard?: { id: string; label?: string; code?: string } | null;
|
||||||
/** Odd EXPORT run (Ethiopia → Djibouti); null when the wagon is not on a run. */
|
/** Odd EXPORT run (Ethiopia → Djibouti); null when the wagon is not on a run. */
|
||||||
|
|||||||
@@ -910,10 +910,22 @@ export interface TrainTrackResponse {
|
|||||||
export interface RecordCheckpointPayload {
|
export interface RecordCheckpointPayload {
|
||||||
sequenceNo: number;
|
sequenceNo: number;
|
||||||
kind?: TrainCheckpointKind;
|
kind?: TrainCheckpointKind;
|
||||||
|
/** When the train was at the station; defaults to now. Past OK, future rejected. */
|
||||||
occurredAt?: string;
|
occurredAt?: string;
|
||||||
note?: string;
|
note?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Edit an already-logged leg — pure correction, no side effects. */
|
||||||
|
export interface UpdateCheckpointPayload {
|
||||||
|
occurredAt?: string;
|
||||||
|
note?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DispatchSchedulePayload {
|
||||||
|
/** Actual departure; defaults to now. Past OK, future rejected. */
|
||||||
|
actualDepartureAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TrainScheduleFilters {
|
export interface TrainScheduleFilters {
|
||||||
originStationId?: string;
|
originStationId?: string;
|
||||||
destinationStationId?: string;
|
destinationStationId?: string;
|
||||||
|
|||||||
@@ -423,7 +423,7 @@ export function AppLayout({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Search pill */}
|
{/* Search pill */}
|
||||||
<Group
|
{/* <Group
|
||||||
gap={8}
|
gap={8}
|
||||||
align="center"
|
align="center"
|
||||||
visibleFrom="sm"
|
visibleFrom="sm"
|
||||||
@@ -441,7 +441,7 @@ export function AppLayout({
|
|||||||
<Text size="sm" style={{ color: mutedColor, userSelect: "none" }}>
|
<Text size="sm" style={{ color: mutedColor, userSelect: "none" }}>
|
||||||
Search shipments, bookings…
|
Search shipments, bookings…
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group> */}
|
||||||
|
|
||||||
{/* Notifications */}
|
{/* Notifications */}
|
||||||
<NotificationBellContainer />
|
<NotificationBellContainer />
|
||||||
@@ -549,7 +549,7 @@ export function AppLayout({
|
|||||||
navigate("/bookings/new", { state: { fresh: true } })
|
navigate("/bookings/new", { state: { fresh: true } })
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
New Booking
|
New Contract
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Divider />
|
<Divider />
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
|
|||||||
@@ -742,7 +742,7 @@ export default function BookingsListPage() {
|
|||||||
Track every cargo booking — from draft to delivery.
|
Track every cargo booking — from draft to delivery.
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Button
|
{/* <Button
|
||||||
component={Link}
|
component={Link}
|
||||||
to="/contracts"
|
to="/contracts"
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
@@ -750,7 +750,7 @@ export default function BookingsListPage() {
|
|||||||
leftSection={<Plus size={16} />}
|
leftSection={<Plus size={16} />}
|
||||||
>
|
>
|
||||||
New booking
|
New booking
|
||||||
</Button>
|
</Button> */}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{/* ── Summary stat cards ──────────────────────────────────────── */}
|
{/* ── Summary stat cards ──────────────────────────────────────── */}
|
||||||
|
|||||||
@@ -114,6 +114,17 @@ export default function ShippingLineBookingsPage() {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "scheduledDate",
|
||||||
|
header: () => <ColHeader label="Shipment date" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text fz={13} c={row.original.scheduledDate ? undefined : "edr-muted"}>
|
||||||
|
{row.original.scheduledDate
|
||||||
|
? new Date(row.original.scheduledDate).toLocaleDateString()
|
||||||
|
: "—"}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "status",
|
id: "status",
|
||||||
header: () => <ColHeader label="Status" />,
|
header: () => <ColHeader label="Status" />,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Center,
|
Center,
|
||||||
|
Divider,
|
||||||
FileButton,
|
FileButton,
|
||||||
Group,
|
Group,
|
||||||
List,
|
List,
|
||||||
@@ -26,14 +27,15 @@ import {
|
|||||||
Select,
|
Select,
|
||||||
Stack,
|
Stack,
|
||||||
Switch,
|
Switch,
|
||||||
Table,
|
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
Textarea,
|
Textarea,
|
||||||
|
ThemeIcon,
|
||||||
Title,
|
Title,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
AlertTriangle,
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
@@ -43,6 +45,7 @@ import {
|
|||||||
MapPin,
|
MapPin,
|
||||||
Package,
|
Package,
|
||||||
PackageCheck,
|
PackageCheck,
|
||||||
|
Receipt,
|
||||||
Snowflake,
|
Snowflake,
|
||||||
Train,
|
Train,
|
||||||
X,
|
X,
|
||||||
@@ -55,6 +58,7 @@ import {
|
|||||||
StepLabel,
|
StepLabel,
|
||||||
fieldStyles,
|
fieldStyles,
|
||||||
} from "../contracts/new-contract-form/shared";
|
} from "../contracts/new-contract-form/shared";
|
||||||
|
import { formatRateUnit } from "../contracts/new-contract-form/unit-rates";
|
||||||
import {
|
import {
|
||||||
ShipmentFormInputValues,
|
ShipmentFormInputValues,
|
||||||
ShipmentFormValues,
|
ShipmentFormValues,
|
||||||
@@ -65,6 +69,7 @@ import {
|
|||||||
downloadContainerImportTemplate,
|
downloadContainerImportTemplate,
|
||||||
parseContainerExcel,
|
parseContainerExcel,
|
||||||
} from "../contracts/new-shipment-form/container-excel";
|
} from "../contracts/new-shipment-form/container-excel";
|
||||||
|
import { formatAmount } from "../contracts/new-shipment-form/total";
|
||||||
import {
|
import {
|
||||||
shippingLineBookingsService,
|
shippingLineBookingsService,
|
||||||
type CompleteBookingContainerLine,
|
type CompleteBookingContainerLine,
|
||||||
@@ -213,21 +218,21 @@ function CompleteBookingForm({ booking }: { booking: ShippingLineBooking }) {
|
|||||||
: 0;
|
: 0;
|
||||||
const hasOdd20ft = ft20Total % 2 === 1;
|
const hasOdd20ft = ft20Total % 2 === 1;
|
||||||
|
|
||||||
// Two-step submit: the payload is priced first (authoritative quote, saved
|
// Two-step submit, same shape as the customer shipment form: the confirm
|
||||||
// server-side with fresh rate snapshots on every preview), the shipping line
|
// modal opens at once with the payload pending, the server prices it (and
|
||||||
// confirms the figure, and only then does the booking submit.
|
// runs the 20ft pairing check) while the modal shows a loader, the shipping
|
||||||
|
// line confirms the figure, and only then does the booking submit.
|
||||||
const [pendingPayload, setPendingPayload] =
|
const [pendingPayload, setPendingPayload] =
|
||||||
useState<CompleteShippingLineBookingPayload | null>(null);
|
useState<CompleteShippingLineBookingPayload | null>(null);
|
||||||
const [quote, setQuote] = useState<ShippingLinePriceQuote | null>(null);
|
|
||||||
|
|
||||||
const previewMutation = useMutation({
|
const previewMutation = useMutation({
|
||||||
mutationFn: (payload: CompleteShippingLineBookingPayload) =>
|
mutationFn: (payload: CompleteShippingLineBookingPayload) =>
|
||||||
shippingLineBookingsService.pricePreview(booking.id, payload),
|
shippingLineBookingsService.pricePreview(booking.id, payload),
|
||||||
onSuccess: (result, payload) => {
|
// A pricing failure (no rate configured) closes the confirm dialog — the
|
||||||
setPendingPayload(payload);
|
// error modal takes over with the server's message.
|
||||||
setQuote(result);
|
onError: () => setPendingPayload(null),
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
const quote = previewMutation.data ?? null;
|
||||||
|
|
||||||
const submitMutation = useMutation({
|
const submitMutation = useMutation({
|
||||||
mutationFn: (payload: CompleteShippingLineBookingPayload) =>
|
mutationFn: (payload: CompleteShippingLineBookingPayload) =>
|
||||||
@@ -241,11 +246,17 @@ function CompleteBookingForm({ booking }: { booking: ShippingLineBooking }) {
|
|||||||
// A submit failure (day filled up, window closed meanwhile) must not leave
|
// A submit failure (day filled up, window closed meanwhile) must not leave
|
||||||
// a stale confirm dialog on screen — the error modal takes over.
|
// a stale confirm dialog on screen — the error modal takes over.
|
||||||
onError: () => {
|
onError: () => {
|
||||||
setQuote(null);
|
previewMutation.reset();
|
||||||
setPendingPayload(null);
|
setPendingPayload(null);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const closeConfirm = () => {
|
||||||
|
if (submitMutation.isPending) return;
|
||||||
|
previewMutation.reset();
|
||||||
|
setPendingPayload(null);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map a container size to the configured container type: reefer type when
|
* Map a container size to the configured container type: reefer type when
|
||||||
* any container on the line is refrigerated, standard type otherwise —
|
* any container on the line is refrigerated, standard type otherwise —
|
||||||
@@ -313,11 +324,21 @@ function CompleteBookingForm({ booking }: { booking: ShippingLineBooking }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setPayloadError(null);
|
setPayloadError(null);
|
||||||
// Price first — the confirm dialog opens when the quote arrives; the
|
// Open the confirm dialog now and price into it; the booking submits only
|
||||||
// booking submits only after the shipping line confirms the figure.
|
// after the shipping line confirms the figure.
|
||||||
|
setPendingPayload(payload);
|
||||||
|
previewMutation.reset();
|
||||||
previewMutation.mutate(payload);
|
previewMutation.mutate(payload);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (!pendingPayload || !quote) return;
|
||||||
|
// Guard: never let unresolved 20ft pairing errors submit — the server
|
||||||
|
// rejects them anyway; the disabled button just says so first.
|
||||||
|
if (quote.pairingErrors.length > 0) return;
|
||||||
|
submitMutation.mutate(pendingPayload);
|
||||||
|
};
|
||||||
|
|
||||||
const showValidationSummary =
|
const showValidationSummary =
|
||||||
form.formState.isSubmitted && !form.formState.isValid;
|
form.formState.isSubmitted && !form.formState.isValid;
|
||||||
|
|
||||||
@@ -446,112 +467,14 @@ function CompleteBookingForm({ booking }: { booking: ShippingLineBooking }) {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* Price confirmation: the quote just computed (and snapshotted)
|
<PriceConfirmModal
|
||||||
server-side. Nothing submits until the figure is confirmed. */}
|
opened={Boolean(pendingPayload)}
|
||||||
<Modal
|
quote={quote}
|
||||||
opened={Boolean(quote)}
|
quoteLoading={previewMutation.isPending}
|
||||||
onClose={() => {
|
loading={submitMutation.isPending}
|
||||||
setQuote(null);
|
onConfirm={handleConfirm}
|
||||||
setPendingPayload(null);
|
onReject={closeConfirm}
|
||||||
}}
|
/>
|
||||||
centered
|
|
||||||
radius="md"
|
|
||||||
size="lg"
|
|
||||||
title={
|
|
||||||
<Group gap={8}>
|
|
||||||
<PackageCheck size={18} color="var(--mantine-color-teal-6)" />
|
|
||||||
<Text fw={700} fz={16}>
|
|
||||||
Confirm your booking price
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
}
|
|
||||||
overlayProps={{ blur: 2, backgroundOpacity: 0.55 }}
|
|
||||||
>
|
|
||||||
{quote && (
|
|
||||||
<Stack gap="md">
|
|
||||||
<Box style={{ overflowX: "auto" }}>
|
|
||||||
<Table verticalSpacing="xs" horizontalSpacing="md">
|
|
||||||
<Table.Thead>
|
|
||||||
<Table.Tr>
|
|
||||||
<Table.Th>Charge</Table.Th>
|
|
||||||
<Table.Th ta="right">Qty</Table.Th>
|
|
||||||
<Table.Th ta="right">Amount</Table.Th>
|
|
||||||
</Table.Tr>
|
|
||||||
</Table.Thead>
|
|
||||||
<Table.Tbody>
|
|
||||||
{quote.lineItems.map((item, i) => (
|
|
||||||
<Table.Tr key={i}>
|
|
||||||
<Table.Td>
|
|
||||||
<Text size="sm">{item.description}</Text>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td ta="right">
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{item.quantity ?? 1}
|
|
||||||
</Text>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td ta="right">
|
|
||||||
<Text size="sm" fw={600}>
|
|
||||||
{Number(item.amount).toLocaleString()}{" "}
|
|
||||||
{item.currency}
|
|
||||||
</Text>
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
))}
|
|
||||||
</Table.Tbody>
|
|
||||||
</Table>
|
|
||||||
</Box>
|
|
||||||
<Group
|
|
||||||
justify="space-between"
|
|
||||||
p="sm"
|
|
||||||
style={{
|
|
||||||
borderRadius: 10,
|
|
||||||
background: "var(--mantine-color-teal-0)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text fw={700}>Total</Text>
|
|
||||||
<Text fw={800} fz={18}>
|
|
||||||
{Number(quote.totalAmount).toLocaleString()} {quote.currency}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
The amount is charged to your credit account — no payment is
|
|
||||||
due now. EDR bills your accumulated charges periodically.
|
|
||||||
</Text>
|
|
||||||
{quote.warnings.length > 0 && (
|
|
||||||
<Alert
|
|
||||||
color="yellow"
|
|
||||||
radius="md"
|
|
||||||
icon={<AlertCircle size={16} />}
|
|
||||||
>
|
|
||||||
{quote.warnings.join(" ")}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
<Group justify="flex-end" gap="sm">
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
radius="md"
|
|
||||||
onClick={() => {
|
|
||||||
setQuote(null);
|
|
||||||
setPendingPayload(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Go back
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color="edr-green"
|
|
||||||
radius="md"
|
|
||||||
leftSection={<PackageCheck size={16} />}
|
|
||||||
loading={submitMutation.isPending}
|
|
||||||
onClick={() =>
|
|
||||||
pendingPayload && submitMutation.mutate(pendingPayload)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Confirm & book
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<Box flex={1} p="24px">
|
<Box flex={1} p="24px">
|
||||||
<Stack gap="lg" className="mx-auto max-w-4xl">
|
<Stack gap="lg" className="mx-auto max-w-4xl">
|
||||||
@@ -618,6 +541,225 @@ function CompleteBookingForm({ booking }: { booking: ShippingLineBooking }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Price confirmation — the customer shipment form's modal, one-to-one: opens
|
||||||
|
* the moment the form submits, shows a loader while the server prices and
|
||||||
|
* checks 20ft pairing, then the authoritative breakdown. Pairing violations
|
||||||
|
* hard-block confirm (the server rejects them on /complete too); overweight
|
||||||
|
* containers only warn — the surcharge is already inside the total.
|
||||||
|
*/
|
||||||
|
function PriceConfirmModal({
|
||||||
|
opened,
|
||||||
|
quote,
|
||||||
|
quoteLoading,
|
||||||
|
loading,
|
||||||
|
onConfirm,
|
||||||
|
onReject,
|
||||||
|
}: {
|
||||||
|
opened: boolean;
|
||||||
|
quote: ShippingLinePriceQuote | null;
|
||||||
|
quoteLoading: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onReject: () => void;
|
||||||
|
}) {
|
||||||
|
const pairingErrors = quote?.pairingErrors ?? [];
|
||||||
|
const hasPairingBlock = pairingErrors.length > 0;
|
||||||
|
const overweightLines = quote?.overweightLines ?? [];
|
||||||
|
const overweightSurchargeAmount =
|
||||||
|
quote?.lineItems.find((li) => li.code === "OVERWEIGHT_PER_TON")?.amount ??
|
||||||
|
0;
|
||||||
|
|
||||||
|
// Confirm waits for the authoritative price and a clean pairing check.
|
||||||
|
const confirmDisabled =
|
||||||
|
loading || quoteLoading || hasPairingBlock || !quote;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={onReject}
|
||||||
|
closeOnClickOutside={!loading}
|
||||||
|
closeOnEscape={!loading}
|
||||||
|
withCloseButton={!loading}
|
||||||
|
centered
|
||||||
|
radius="lg"
|
||||||
|
size="lg"
|
||||||
|
title={
|
||||||
|
<Group gap={10}>
|
||||||
|
<ThemeIcon variant="light" color="edr-green" radius="md" size={34}>
|
||||||
|
<Receipt size={18} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Box>
|
||||||
|
<Text fw={800} fz={16} c="#10202F">
|
||||||
|
Confirm shipment price
|
||||||
|
</Text>
|
||||||
|
<Text fz="xs" c="dimmed">
|
||||||
|
Review the total before booking this shipment.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
{quoteLoading && (
|
||||||
|
<Group gap={8} c="dimmed">
|
||||||
|
<Loader size="xs" color="edr-green" />
|
||||||
|
<Text fz="sm" c="dimmed">
|
||||||
|
Computing the final price breakdown and checking container
|
||||||
|
weights…
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasPairingBlock && (
|
||||||
|
<Alert
|
||||||
|
color="red"
|
||||||
|
variant="light"
|
||||||
|
radius="md"
|
||||||
|
icon={<AlertCircle size={16} />}
|
||||||
|
title="Cannot complete booking — 20ft wagon pairing"
|
||||||
|
>
|
||||||
|
<Stack gap={6}>
|
||||||
|
{pairingErrors.map((msg, i) => (
|
||||||
|
<Text key={i} fz="sm" c="red.8">
|
||||||
|
{msg}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
<Text fz="xs" c="red.7" mt={2}>
|
||||||
|
Adjust the 20ft container weights or quantities so pairs differ
|
||||||
|
by no more than 10 tons.
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{overweightLines.length > 0 && (
|
||||||
|
<Alert
|
||||||
|
color="yellow"
|
||||||
|
variant="light"
|
||||||
|
radius="md"
|
||||||
|
icon={<AlertTriangle size={16} />}
|
||||||
|
title="Overweight containers"
|
||||||
|
>
|
||||||
|
<Stack gap={6}>
|
||||||
|
{overweightLines.map((line, i) => (
|
||||||
|
<Text key={i} fz="sm" c="#9A5B00">
|
||||||
|
{line.containerTypeCode}: {line.totalVgmTons}t exceeds limit{" "}
|
||||||
|
{line.maxAllowedTons}t (+{line.excessTons}t overweight)
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
<Text fz="xs" c="#9A5B00" mt={2}>
|
||||||
|
{overweightSurchargeAmount > 0
|
||||||
|
? `An overweight surcharge of ${formatAmount(overweightSurchargeAmount)} ${
|
||||||
|
quote?.currency ?? ""
|
||||||
|
} applies (included in the total below). You can still submit, or go back and adjust weights.`
|
||||||
|
: "An overweight surcharge applies. You can still submit, or go back and adjust weights."}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{quote && quote.warnings.length > 0 && (
|
||||||
|
<Alert
|
||||||
|
color="yellow"
|
||||||
|
variant="light"
|
||||||
|
radius="md"
|
||||||
|
icon={<AlertTriangle size={16} />}
|
||||||
|
>
|
||||||
|
{quote.warnings.join(" ")}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{quote && (
|
||||||
|
<Paper
|
||||||
|
withBorder
|
||||||
|
radius={16}
|
||||||
|
p="lg"
|
||||||
|
style={{ borderColor: "#E6ECF2" }}
|
||||||
|
>
|
||||||
|
<Stack gap={10}>
|
||||||
|
{quote.lineItems.map((line, i) => (
|
||||||
|
<Group key={i} justify="space-between" wrap="nowrap" gap="sm">
|
||||||
|
<Box style={{ minWidth: 0 }}>
|
||||||
|
<Text fz="sm" c="#10202F" fw={500}>
|
||||||
|
{line.description}
|
||||||
|
</Text>
|
||||||
|
<Text fz="xs" c="dimmed">
|
||||||
|
{(line.quantity ?? 1).toLocaleString()} ×{" "}
|
||||||
|
{formatAmount(line.unitAmount ?? line.amount)}{" "}
|
||||||
|
{quote.currency}
|
||||||
|
{line.unit
|
||||||
|
? ` · ${formatRateUnit(line.unit.toLowerCase())}`
|
||||||
|
: ""}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Text
|
||||||
|
fz="sm"
|
||||||
|
fw={600}
|
||||||
|
c="#10202F"
|
||||||
|
style={{ whiteSpace: "nowrap" }}
|
||||||
|
>
|
||||||
|
{formatAmount(line.amount)} {quote.currency}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
{quote.lineItems.length === 0 && (
|
||||||
|
<Text fz="sm" c="dimmed">
|
||||||
|
No priced lines — check the cargo details.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
<Divider my="md" />
|
||||||
|
<Group justify="space-between" align="flex-end">
|
||||||
|
<Text
|
||||||
|
fz="xs"
|
||||||
|
fw={700}
|
||||||
|
tt="uppercase"
|
||||||
|
c="edr-green"
|
||||||
|
style={{ letterSpacing: "0.06em" }}
|
||||||
|
>
|
||||||
|
Total
|
||||||
|
</Text>
|
||||||
|
<Text fw={800} fz={28} c="#10202F">
|
||||||
|
{formatAmount(quote.totalAmount)}{" "}
|
||||||
|
<Text span fz={16} fw={700} c="edr-muted">
|
||||||
|
{quote.currency}
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Text fz="xs" c="dimmed" mt="sm">
|
||||||
|
The amount is charged to your credit account — no payment is due
|
||||||
|
now. EDR bills your accumulated charges periodically.
|
||||||
|
</Text>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group justify="space-between" mt="xs">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<X size={16} />}
|
||||||
|
onClick={onReject}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Reject & edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<CheckCircle2 size={16} />}
|
||||||
|
onClick={onConfirm}
|
||||||
|
loading={loading}
|
||||||
|
disabled={confirmDisabled}
|
||||||
|
>
|
||||||
|
Confirm & book
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** The booking's lane — fixed at initiate time from the chosen route. */
|
/** The booking's lane — fixed at initiate time from the chosen route. */
|
||||||
function RouteCard({ booking }: { booking: ShippingLineBooking }) {
|
function RouteCard({ booking }: { booking: ShippingLineBooking }) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -67,6 +67,15 @@ export interface ShippingLinePriceQuote {
|
|||||||
currency: string;
|
currency: string;
|
||||||
lineItems: Freight.PricingBreakdownLineItem[];
|
lineItems: Freight.PricingBreakdownLineItem[];
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
|
/** Containers over their type's weight limit — a surcharge, not a block. */
|
||||||
|
overweightLines: {
|
||||||
|
containerTypeCode: string;
|
||||||
|
totalVgmTons: number;
|
||||||
|
maxAllowedTons: number;
|
||||||
|
excessTons: number;
|
||||||
|
}[];
|
||||||
|
/** 20ft wagon-pairing violations (pair weight diff over the cap) — hard block. */
|
||||||
|
pairingErrors: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One wagon the batch engine allocated to the booking. */
|
/** One wagon the batch engine allocated to the booking. */
|
||||||
|
|||||||
@@ -959,6 +959,14 @@ export interface IInvoiceCompanyProfile {
|
|||||||
reference: string | null;
|
reference: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Shipping line an invoice is billed to, when `companyId` is null (see `IInvoice`). */
|
||||||
|
export interface IInvoiceShippingLineCompany {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email?: string | null;
|
||||||
|
phoneNumber?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
/** A single billed line on an invoice. */
|
/** A single billed line on an invoice. */
|
||||||
export interface IInvoiceLine extends BaseEntity {
|
export interface IInvoiceLine extends BaseEntity {
|
||||||
invoiceId: string;
|
invoiceId: string;
|
||||||
@@ -975,12 +983,15 @@ export interface IInvoiceLine extends BaseEntity {
|
|||||||
|
|
||||||
export interface IInvoice extends BaseEntity {
|
export interface IInvoice extends BaseEntity {
|
||||||
invoiceNumber: string;
|
invoiceNumber: string;
|
||||||
/** Customer (company) the invoice is billed to. */
|
/** Customer (company) the invoice is billed to. Null on a shipping-line invoice — see `shippingLineCompanyId`. */
|
||||||
companyId: string;
|
companyId: string | null;
|
||||||
company?: IInvoiceCompany;
|
company?: IInvoiceCompany;
|
||||||
/** Specific company profile billed. */
|
/** Specific company profile billed. */
|
||||||
companyProfileId: string;
|
companyProfileId: string;
|
||||||
companyProfile?: IInvoiceCompanyProfile;
|
companyProfile?: IInvoiceCompanyProfile;
|
||||||
|
/** The shipping line billed, when this invoice bills batched shipping-line credits. Mutually exclusive with `companyId`. */
|
||||||
|
shippingLineCompanyId?: string | null;
|
||||||
|
shippingLineCompany?: IInvoiceShippingLineCompany;
|
||||||
totalAmount: number;
|
totalAmount: number;
|
||||||
/** Cumulative amount settled so far (supports partial payment). */
|
/** Cumulative amount settled so far (supports partial payment). */
|
||||||
paidAmount: number;
|
paidAmount: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user