mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 11:08:12 +00:00
@@ -114,6 +114,8 @@ interface CarriageAcceptanceWagonRow {
|
|||||||
arrivalAt: string | null;
|
arrivalAt: string | null;
|
||||||
containerNumbers: string | null;
|
containerNumbers: string | null;
|
||||||
sealNumbers: string | null;
|
sealNumbers: string | null;
|
||||||
|
/** Allocation status — LOADED/DEPARTED means EDR has the cargo. */
|
||||||
|
status: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A received-but-not-yet-marshalled export line, standing in for a wagon row. */
|
/** A received-but-not-yet-marshalled export line, standing in for a wagon row. */
|
||||||
@@ -263,9 +265,13 @@ export class BookingsService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Carriage acceptance sheet — one per booking, listing every wagon the booking
|
* Carriage acceptance sheet — one per booking, listing every wagon the booking
|
||||||
* occupies. Handed to the customer when EDR accepts the cargo (export) and when
|
* occupies. A booking is routinely loaded in parts (some containers go, the
|
||||||
* the wagons are allocated before marshalling (import), so it is only available
|
* rest wait for the next train), so each row carries a Status of Loaded or
|
||||||
* once the booking has wagon allocations.
|
* Not loaded and the totals count only the loaded ones: the customer sees the
|
||||||
|
* whole plan on one page without the sheet overstating what EDR has taken.
|
||||||
|
*
|
||||||
|
* Handed to the customer when EDR accepts the cargo (export) and when the
|
||||||
|
* wagons are allocated before marshalling (import).
|
||||||
*/
|
*/
|
||||||
async carriageAcceptanceSheet(bookingId: string): Promise<{ filename: string; buffer: Buffer }> {
|
async carriageAcceptanceSheet(bookingId: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||||
const booking = await this.findById(bookingId);
|
const booking = await this.findById(bookingId);
|
||||||
@@ -285,6 +291,7 @@ export class BookingsService {
|
|||||||
s.scheduled_departure_date AS "departureAt",
|
s.scheduled_departure_date AS "departureAt",
|
||||||
so.label AS "marshalledAt",
|
so.label AS "marshalledAt",
|
||||||
sd.label AS "arrivalAt",
|
sd.label AS "arrivalAt",
|
||||||
|
a.status AS "status",
|
||||||
string_agg(DISTINCT ci.container_number, ', ') AS "containerNumbers",
|
string_agg(DISTINCT ci.container_number, ', ') AS "containerNumbers",
|
||||||
string_agg(DISTINCT ci.seal_number, ', ') AS "sealNumbers"
|
string_agg(DISTINCT ci.seal_number, ', ') AS "sealNumbers"
|
||||||
FROM freight.wagon_booking_allocations a
|
FROM freight.wagon_booking_allocations a
|
||||||
@@ -299,7 +306,7 @@ export class BookingsService {
|
|||||||
LEFT JOIN freight.wagon_allocation_container_items ci
|
LEFT JOIN freight.wagon_allocation_container_items ci
|
||||||
ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL
|
ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL
|
||||||
WHERE a.booking_id = $1 AND a.deleted_at IS NULL
|
WHERE a.booking_id = $1 AND a.deleted_at IS NULL
|
||||||
GROUP BY tsw.id, a.id, wt.code, wt.name, w.wagon_number, wt.tare_weight_tons,
|
GROUP BY tsw.id, a.id, a.status, wt.code, wt.name, w.wagon_number, wt.tare_weight_tons,
|
||||||
s.train_number, s.scheduled_departure_date, so.label, sd.label
|
s.train_number, s.scheduled_departure_date, so.label, sd.label
|
||||||
ORDER BY tsw.sequence_no`,
|
ORDER BY tsw.sequence_no`,
|
||||||
[bookingId],
|
[bookingId],
|
||||||
@@ -383,6 +390,8 @@ export class BookingsService {
|
|||||||
arrivalAt: null,
|
arrivalAt: null,
|
||||||
containerNumbers: row.containerNumbers,
|
containerNumbers: row.containerNumbers,
|
||||||
sealNumbers: row.sealNumbers ?? null,
|
sealNumbers: row.sealNumbers ?? null,
|
||||||
|
// A received line has no allocation; it is cargo EDR already holds.
|
||||||
|
status: null,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -497,7 +506,17 @@ export class BookingsService {
|
|||||||
const header = wagons[0];
|
const header = wagons[0];
|
||||||
const sheetDate = header.departureAt ? new Date(header.departureAt) : new Date();
|
const sheetDate = header.departureAt ? new Date(header.departureAt) : new Date();
|
||||||
|
|
||||||
const totals = wagons.reduce(
|
// Loaded = EDR has the cargo. A booking is routinely loaded in parts, so the
|
||||||
|
// totals count only those: the sheet shows the whole plan, but must never
|
||||||
|
// total up cargo still sitting in the yard. A received-line sheet
|
||||||
|
// (pendingWagons) has no allocation status, and every line on it is cargo
|
||||||
|
// already accepted, so it counts in full.
|
||||||
|
const isLoaded = (w: CarriageAcceptanceWagonRow) =>
|
||||||
|
pendingWagons || w.status === 'LOADED' || w.status === 'DEPARTED';
|
||||||
|
const loadedWagons = wagons.filter(isLoaded);
|
||||||
|
const notLoadedCount = wagons.length - loadedWagons.length;
|
||||||
|
|
||||||
|
const totals = loadedWagons.reduce(
|
||||||
(acc, w) => ({
|
(acc, w) => ({
|
||||||
tare: acc.tare + (Number(w.tareWeightTons) || 0),
|
tare: acc.tare + (Number(w.tareWeightTons) || 0),
|
||||||
capacity: acc.capacity + (Number(w.loadCapacityTons) || 0),
|
capacity: acc.capacity + (Number(w.loadCapacityTons) || 0),
|
||||||
@@ -507,7 +526,7 @@ export class BookingsService {
|
|||||||
{ tare: 0, capacity: 0, load: 0, length: 0 },
|
{ tare: 0, capacity: 0, load: 0, length: 0 },
|
||||||
);
|
);
|
||||||
// A wagon carrying no weight and no container is running empty under this booking.
|
// A wagon carrying no weight and no container is running empty under this booking.
|
||||||
const fullWagons = wagons.filter(
|
const fullWagons = loadedWagons.filter(
|
||||||
(w) => (Number(w.allocatedWeightTons) || 0) > 0 || Boolean(w.containerNumbers),
|
(w) => (Number(w.allocatedWeightTons) || 0) > 0 || Boolean(w.containerNumbers),
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
@@ -525,6 +544,9 @@ export class BookingsService {
|
|||||||
<td>${esc(departureStation)}</td>
|
<td>${esc(departureStation)}</td>
|
||||||
<td>${esc(w.containerNumbers)}</td>
|
<td>${esc(w.containerNumbers)}</td>
|
||||||
<td>${esc(w.sealNumbers)}</td>
|
<td>${esc(w.sealNumbers)}</td>
|
||||||
|
<td class="${isLoaded(w) ? 'loaded' : 'pending'}">${
|
||||||
|
pendingWagons ? 'Accepted' : isLoaded(w) ? 'Loaded' : 'Not loaded'
|
||||||
|
}</td>
|
||||||
<td class="num">${money(prices[i])}</td>
|
<td class="num">${money(prices[i])}</td>
|
||||||
</tr>`,
|
</tr>`,
|
||||||
)
|
)
|
||||||
@@ -535,11 +557,11 @@ export class BookingsService {
|
|||||||
// figure from the printed sheet.
|
// figure from the printed sheet.
|
||||||
const totalsRow = `<tr class="totals">
|
const totalsRow = `<tr class="totals">
|
||||||
<td>TOT</td>
|
<td>TOT</td>
|
||||||
<td>${wagons.length} ${pendingWagons ? 'received lines' : 'wagons'}</td>
|
<td>${loadedWagons.length} ${pendingWagons ? 'received lines' : 'wagons loaded'}</td>
|
||||||
<td>${
|
<td>${
|
||||||
pendingWagons
|
pendingWagons
|
||||||
? 'pending marshalling'
|
? 'pending marshalling'
|
||||||
: `full ${fullWagons} / empty ${wagons.length - fullWagons}`
|
: `full ${fullWagons} / empty ${loadedWagons.length - fullWagons}`
|
||||||
}</td>
|
}</td>
|
||||||
<td class="num">${num(totals.tare, 2)}</td>
|
<td class="num">${num(totals.tare, 2)}</td>
|
||||||
<td class="num">${num(totals.length)}</td>
|
<td class="num">${num(totals.length)}</td>
|
||||||
@@ -549,6 +571,7 @@ export class BookingsService {
|
|||||||
<td></td>
|
<td></td>
|
||||||
<td></td>
|
<td></td>
|
||||||
<td></td>
|
<td></td>
|
||||||
|
<td>${notLoadedCount > 0 ? `loaded only (${notLoadedCount} not loaded)` : ''}</td>
|
||||||
<td class="num">${money(totalAmount)}</td>
|
<td class="num">${money(totalAmount)}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
|
|
||||||
@@ -575,6 +598,8 @@ export class BookingsService {
|
|||||||
th { background: #f8fafc; color: #475569; text-align: left; }
|
th { background: #f8fafc; color: #475569; text-align: left; }
|
||||||
th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; }
|
th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; }
|
||||||
.num { text-align: right; }
|
.num { text-align: right; }
|
||||||
|
.loaded { color: #0f766e; font-weight: 700; }
|
||||||
|
.pending { color: #b45309; font-weight: 700; }
|
||||||
tr.totals td { background: #f8fafc; font-weight: 700; }
|
tr.totals td { background: #f8fafc; font-weight: 700; }
|
||||||
.notice { margin-top: 10px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 8px 10px; font-size: 10px; color: #134e4a; }
|
.notice { margin-top: 10px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 8px 10px; font-size: 10px; color: #134e4a; }
|
||||||
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 34px; }
|
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 34px; }
|
||||||
@@ -618,6 +643,7 @@ export class BookingsService {
|
|||||||
<th>Departure Station</th>
|
<th>Departure Station</th>
|
||||||
<th>Container No.</th>
|
<th>Container No.</th>
|
||||||
<th>Seal No.</th>
|
<th>Seal No.</th>
|
||||||
|
<th>Status</th>
|
||||||
<th class="num">Price (${esc(currency)})</th>
|
<th class="num">Price (${esc(currency)})</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|||||||
@@ -176,6 +176,18 @@ export class ContractNotifierService {
|
|||||||
this.inApp(c, 'Contract suspension lifted', msg);
|
this.inApp(c, 'Contract suspension lifted', msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backoffice cancelled the contract. Terminal — the customer is told they may
|
||||||
|
* submit a new contract with the same details if they still need the service.
|
||||||
|
*/
|
||||||
|
cancelledByStaff(c: Contract, reason: string): void {
|
||||||
|
const msg =
|
||||||
|
`Your contract ${c.reference} has been cancelled. Reason: ${reason}. ` +
|
||||||
|
`If you still need this service you can submit a new contract request with the same details.`;
|
||||||
|
void this.notifyContact(c, msg, 'CANCELLED');
|
||||||
|
this.inApp(c, 'Contract cancelled', msg);
|
||||||
|
}
|
||||||
|
|
||||||
/** Customer cancelled their own contract — staff-side record. */
|
/** Customer cancelled their own contract — staff-side record. */
|
||||||
cancelledByCustomer(c: Contract, reason: string): void {
|
cancelledByCustomer(c: Contract, reason: string): void {
|
||||||
this.inAppStaff(
|
this.inAppStaff(
|
||||||
|
|||||||
@@ -120,3 +120,34 @@ describe('contract base freight is priced on the contract lane only', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('contract base freight ignores shipping-line rates', () => {
|
||||||
|
it("never prices a customer contract off a line's negotiated rate (CTR-2026-00049)", async () => {
|
||||||
|
// Both LIVE on the contract's own lane: the line rate sorted first and won,
|
||||||
|
// so the contract quoted 32 USD/wagon instead of the standard 1690.
|
||||||
|
const breakdown = await service([
|
||||||
|
rate({
|
||||||
|
containerTypeId: CT20,
|
||||||
|
rateValue: 32,
|
||||||
|
rateUnit: 'PER_WAGON',
|
||||||
|
shippingLineCompanyId: 'line-1',
|
||||||
|
}),
|
||||||
|
rate({ containerTypeId: CT20, rateValue: 1690, rateUnit: 'PER_WAGON' }),
|
||||||
|
]).buildBreakdown(contract({}));
|
||||||
|
expect(breakdown.lineItems).toEqual([
|
||||||
|
expect.objectContaining({ code: 'CONTAINER_20FT', unitPrice: 1690 }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks when the only rate on the lane belongs to a shipping line', async () => {
|
||||||
|
await expect(
|
||||||
|
service([
|
||||||
|
rate({
|
||||||
|
containerTypeId: CT20,
|
||||||
|
rateValue: 32,
|
||||||
|
shippingLineCompanyId: 'line-1',
|
||||||
|
}),
|
||||||
|
]).buildBreakdown(contract({})),
|
||||||
|
).rejects.toThrow(UnprocessableEntityException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -85,7 +85,15 @@ export class ContractPricingService {
|
|||||||
* commodity rate) — NO totals or quantities (doc §9.1).
|
* commodity rate) — NO totals or quantities (doc §9.1).
|
||||||
*/
|
*/
|
||||||
async buildBreakdown(contract: Contract): Promise<ContractPricingBreakdown> {
|
async buildBreakdown(contract: Contract): Promise<ContractPricingBreakdown> {
|
||||||
const liveRates = await this.ratesService.findLiveRates();
|
// Contracts belong to a customer company — there is no shipping-line
|
||||||
|
// contract (no shipping_line_company_id on the entity), so a contract may
|
||||||
|
// only ever price off the standard rates. Without this filter a line's
|
||||||
|
// negotiated rate on the same lane matched first and the contract froze it
|
||||||
|
// for a customer: CTR-2026-00049 quoted a line's 32 USD/wagon 20ft and
|
||||||
|
// 23 USD/container 40ft instead of the standard 1690 / 1676.
|
||||||
|
const liveRates = (await this.ratesService.findLiveRates()).filter(
|
||||||
|
(r) => !r.shippingLineCompanyId,
|
||||||
|
);
|
||||||
const currency = contract.paymentCurrency;
|
const currency = contract.paymentCurrency;
|
||||||
const isEtb = currency === 'ETB';
|
const isEtb = currency === 'ETB';
|
||||||
const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1;
|
const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1;
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { ContractTransitionService } from './contract-transition.service';
|
||||||
|
import type { Contract } from './entities/contract.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Staff cancel is terminal, so the rules that matter are: it needs its own
|
||||||
|
* permission (suspend must NOT imply it), it refuses to strand live shipments,
|
||||||
|
* it works on a suspended contract, and it cannot be applied twice.
|
||||||
|
*/
|
||||||
|
describe('ContractTransitionService — staff cancel', () => {
|
||||||
|
const contract = (over: Partial<Contract> = {}): Contract =>
|
||||||
|
({
|
||||||
|
id: 'c-1',
|
||||||
|
reference: 'CTR-2026-00042',
|
||||||
|
companyId: 'co-1',
|
||||||
|
status: 'CONTRACT_ACTIVE',
|
||||||
|
freightType: 'CONTAINER',
|
||||||
|
...over,
|
||||||
|
}) as Contract;
|
||||||
|
|
||||||
|
let current: Contract;
|
||||||
|
let repo: {
|
||||||
|
update: jest.Mock;
|
||||||
|
createReviewNote: jest.Mock;
|
||||||
|
countActiveBookings: jest.Mock;
|
||||||
|
};
|
||||||
|
let notifier: { cancelledByStaff: jest.Mock };
|
||||||
|
let service: ContractTransitionService;
|
||||||
|
|
||||||
|
const staff = {
|
||||||
|
permissions: [{ key: 'edr_freight_app:contracts:cancel' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
current = contract();
|
||||||
|
repo = {
|
||||||
|
update: jest.fn().mockImplementation((_id: string, patch: object) => {
|
||||||
|
current = { ...current, ...patch } as Contract;
|
||||||
|
return Promise.resolve(current);
|
||||||
|
}),
|
||||||
|
createReviewNote: jest.fn().mockResolvedValue(undefined),
|
||||||
|
countActiveBookings: jest.fn().mockResolvedValue(0),
|
||||||
|
};
|
||||||
|
notifier = { cancelledByStaff: jest.fn() };
|
||||||
|
service = Object.create(
|
||||||
|
ContractTransitionService.prototype,
|
||||||
|
) as ContractTransitionService;
|
||||||
|
Object.assign(service, {
|
||||||
|
contractsRepository: repo,
|
||||||
|
contractsService: { findById: () => Promise.resolve(current) },
|
||||||
|
notifier,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels, records the reason as a staff note, and notifies the customer', async () => {
|
||||||
|
await service.cancelByStaff('c-1', 'Duplicate request', 'staff-1', staff as never);
|
||||||
|
|
||||||
|
expect(repo.update).toHaveBeenCalledWith('c-1', {
|
||||||
|
status: 'CANCELLED',
|
||||||
|
statusBeforeSuspension: null,
|
||||||
|
});
|
||||||
|
expect(repo.createReviewNote).toHaveBeenCalledWith(
|
||||||
|
'c-1',
|
||||||
|
'Duplicate request',
|
||||||
|
'CANCELLATION',
|
||||||
|
'staff-1',
|
||||||
|
'STAFF',
|
||||||
|
);
|
||||||
|
expect(notifier.cancelledByStaff).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels a suspended contract — freezing it is exactly when staff kill it', async () => {
|
||||||
|
current = contract({
|
||||||
|
status: 'SUSPENDED',
|
||||||
|
statusBeforeSuspension: 'CONTRACT_ACTIVE',
|
||||||
|
} as Partial<Contract>);
|
||||||
|
|
||||||
|
await service.cancelByStaff('c-1', 'Customer withdrew', 'staff-1', staff as never);
|
||||||
|
|
||||||
|
expect(repo.update).toHaveBeenCalledWith('c-1', {
|
||||||
|
status: 'CANCELLED',
|
||||||
|
statusBeforeSuspension: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses while a shipment is still running', async () => {
|
||||||
|
repo.countActiveBookings.mockResolvedValue(2);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.cancelByStaff('c-1', 'Change of plan', 'staff-1', staff as never),
|
||||||
|
).rejects.toThrow('2 active shipments');
|
||||||
|
expect(repo.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to cancel an already-terminal contract', async () => {
|
||||||
|
current = contract({ status: 'CANCELLED' });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.cancelByStaff('c-1', 'Again', 'staff-1', staff as never),
|
||||||
|
).rejects.toThrow(/already cancelled/i);
|
||||||
|
expect(repo.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a user holding only the suspend key — cancel is a separate permission', async () => {
|
||||||
|
const suspender = {
|
||||||
|
permissions: [{ key: 'edr_freight_app:contracts:suspend' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.cancelByStaff('c-1', 'Not allowed', 'staff-1', suspender as never),
|
||||||
|
).rejects.toThrow();
|
||||||
|
expect(repo.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1452,6 +1452,54 @@ export class ContractTransitionService {
|
|||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Staff cancel — terminal, unlike suspend. The contract is dead; a fresh one
|
||||||
|
* with the same parameters can be submitted afterwards (references are minted
|
||||||
|
* per contract, so nothing about the old row blocks the new one).
|
||||||
|
*
|
||||||
|
* Cancellable from ANY non-terminal status, including SUSPENDED: a frozen
|
||||||
|
* contract is exactly the one staff most often need to kill outright.
|
||||||
|
*/
|
||||||
|
async cancelByStaff(
|
||||||
|
contractId: string,
|
||||||
|
reason: string,
|
||||||
|
actorId: string,
|
||||||
|
user?: TCurrentUser | null,
|
||||||
|
): Promise<Contract> {
|
||||||
|
const contract = await this.contractsService.findById(contractId);
|
||||||
|
assertFreightPermission(user, FREIGHT_PERMS.contracts.cancel);
|
||||||
|
if ((TERMINAL_CONTRACT_STATUSES as readonly string[]).includes(contract.status)) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Contract is already ${contract.status.toLowerCase().replace(/_/g, ' ')}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same guard as the customer path: live shipments must be settled first,
|
||||||
|
// otherwise cancelling the contract orphans cargo already in motion.
|
||||||
|
const active = await this.contractsRepository.countActiveBookings(contractId);
|
||||||
|
if (active > 0) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`This contract has ${active} active shipment${active === 1 ? '' : 's'}. ` +
|
||||||
|
'Cancel or complete them before cancelling the contract.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.contractsRepository.createReviewNote(
|
||||||
|
contractId,
|
||||||
|
reason,
|
||||||
|
'CANCELLATION',
|
||||||
|
actorId,
|
||||||
|
'STAFF',
|
||||||
|
);
|
||||||
|
await this.contractsRepository.update(contractId, {
|
||||||
|
status: 'CANCELLED',
|
||||||
|
statusBeforeSuspension: null,
|
||||||
|
} as never);
|
||||||
|
const updated = await this.contractsService.findById(contractId);
|
||||||
|
this.notifier.cancelledByStaff(updated, reason);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
async renew(contractId: string, userId?: string): Promise<Contract> {
|
async renew(contractId: string, userId?: string): Promise<Contract> {
|
||||||
const source = await this.contractsService.findById(contractId);
|
const source = await this.contractsService.findById(contractId);
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ import {
|
|||||||
RequestChangesDto,
|
RequestChangesDto,
|
||||||
ResumeContractDto,
|
ResumeContractDto,
|
||||||
SuspendContractDto,
|
SuspendContractDto,
|
||||||
|
CancelContractByStaffDto,
|
||||||
} from './dto/approve-step.dto';
|
} from './dto/approve-step.dto';
|
||||||
import { SignContractDto } from './dto/sign-contract.dto';
|
import { SignContractDto } from './dto/sign-contract.dto';
|
||||||
import { ReviewClearanceDocumentDto } from './dto/review-clearance-document.dto';
|
import { ReviewClearanceDocumentDto } from './dto/review-clearance-document.dto';
|
||||||
@@ -552,6 +553,25 @@ export class ContractsController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post(':id/staff/cancel')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.contracts.cancel)
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
'Staff cancel a contract (terminal — a new contract with the same details may be submitted after)',
|
||||||
|
})
|
||||||
|
cancelByStaff(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: CancelContractByStaffDto,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
return this.transitionService.cancelByStaff(
|
||||||
|
id,
|
||||||
|
dto.reason,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
user,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Post(':id/approval-steps/:stepId/approve')
|
@Post(':id/approval-steps/:stepId/approve')
|
||||||
@BookingStaff(FREIGHT_PERMS.contracts.view)
|
@BookingStaff(FREIGHT_PERMS.contracts.view)
|
||||||
@ApiOperation({ summary: 'Approve one approval step in sequence' })
|
@ApiOperation({ summary: 'Approve one approval step in sequence' })
|
||||||
|
|||||||
@@ -51,6 +51,14 @@ export class CancelContractDto {
|
|||||||
reason?: string;
|
reason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Staff cancel is terminal, so the reason is mandatory — it is the audit record. */
|
||||||
|
export class CancelContractByStaffDto {
|
||||||
|
@ApiProperty({ description: 'Why the contract is being cancelled — shown to the customer' })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
reason!: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class SuspendContractDto {
|
export class SuspendContractDto {
|
||||||
@ApiProperty({ description: 'Why the contract is being frozen — shown to the customer' })
|
@ApiProperty({ description: 'Why the contract is being frozen — shown to the customer' })
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -83,3 +83,184 @@ export async function notifyCarriageAcceptanceReady(
|
|||||||
logger.warn(`Carriage acceptance ready notify failed for ${bookingId}: ${(err as Error).message}`);
|
logger.warn(`Carriage acceptance ready notify failed for ${bookingId}: ${(err as Error).message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One container line on the load manifest notice. */
|
||||||
|
interface LoadManifestLists {
|
||||||
|
reference: string;
|
||||||
|
companyId: string | null;
|
||||||
|
trainNumber: string | null;
|
||||||
|
originStation: string | null;
|
||||||
|
destinationStation: string | null;
|
||||||
|
departureAt: Date | null;
|
||||||
|
loaded: string[];
|
||||||
|
leftBehind: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** At most `max` numbers, then "+N more" — an SMS must not carry 44 of them. */
|
||||||
|
function summarizeNumbers(numbers: string[], max = 5): string {
|
||||||
|
if (numbers.length === 0) return 'none';
|
||||||
|
const shown = numbers.slice(0, max).join(', ');
|
||||||
|
const rest = numbers.length - max;
|
||||||
|
return rest > 0 ? `${shown} +${rest} more` : shown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read what actually went on the train and what did not. Left behind = every
|
||||||
|
* container the customer declared minus the ones sitting on a LOADED/DEPARTED
|
||||||
|
* wagon, so a booking loaded in parts reports honestly on both halves.
|
||||||
|
*/
|
||||||
|
export async function loadManifestLists(
|
||||||
|
dataSource: DataSource,
|
||||||
|
bookingId: string,
|
||||||
|
trainScheduleId: string,
|
||||||
|
): Promise<LoadManifestLists | null> {
|
||||||
|
const [booking]: Array<{ reference: string; companyId: string | null }> =
|
||||||
|
await dataSource.query(
|
||||||
|
`SELECT reference, company_id AS "companyId"
|
||||||
|
FROM freight.bookings
|
||||||
|
WHERE id = $1 AND deleted_at IS NULL`,
|
||||||
|
[bookingId],
|
||||||
|
);
|
||||||
|
if (!booking) return null;
|
||||||
|
|
||||||
|
const [train]: Array<{
|
||||||
|
trainNumber: string | null;
|
||||||
|
originStation: string | null;
|
||||||
|
destinationStation: string | null;
|
||||||
|
departureAt: Date | null;
|
||||||
|
}> = await dataSource.query(
|
||||||
|
`SELECT s.train_number AS "trainNumber",
|
||||||
|
so.label AS "originStation",
|
||||||
|
sd.label AS "destinationStation",
|
||||||
|
s.scheduled_departure_date AS "departureAt"
|
||||||
|
FROM freight.train_schedules s
|
||||||
|
LEFT JOIN freight.yards so ON so.id = s.origin_station_id
|
||||||
|
LEFT JOIN freight.yards sd ON sd.id = s.destination_station_id
|
||||||
|
WHERE s.id = $1 AND s.deleted_at IS NULL`,
|
||||||
|
[trainScheduleId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadedRows: Array<{ containerNumber: string | null }> = await dataSource.query(
|
||||||
|
`SELECT DISTINCT ci.container_number AS "containerNumber"
|
||||||
|
FROM freight.wagon_allocation_container_items ci
|
||||||
|
JOIN freight.wagon_booking_allocations a
|
||||||
|
ON a.id = ci.wagon_booking_allocation_id AND a.deleted_at IS NULL
|
||||||
|
WHERE a.booking_id = $1
|
||||||
|
AND ci.deleted_at IS NULL
|
||||||
|
AND a.status IN ('LOADED', 'DEPARTED')
|
||||||
|
ORDER BY 1`,
|
||||||
|
[bookingId],
|
||||||
|
);
|
||||||
|
const declaredRows: Array<{ containerNumber: string | null }> = await dataSource.query(
|
||||||
|
`SELECT DISTINCT u.container_number AS "containerNumber"
|
||||||
|
FROM freight.booking_container_units u
|
||||||
|
JOIN freight.booking_container l
|
||||||
|
ON l.id = u.booking_container_id AND l.deleted_at IS NULL
|
||||||
|
WHERE l.booking_id = $1 AND u.deleted_at IS NULL
|
||||||
|
ORDER BY 1`,
|
||||||
|
[bookingId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const loaded = loadedRows.map((r) => r.containerNumber).filter(Boolean) as string[];
|
||||||
|
const loadedSet = new Set(loaded);
|
||||||
|
const leftBehind = (declaredRows.map((r) => r.containerNumber).filter(Boolean) as string[]).filter(
|
||||||
|
(n) => !loadedSet.has(n),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
reference: booking.reference,
|
||||||
|
companyId: booking.companyId,
|
||||||
|
trainNumber: train?.trainNumber ?? null,
|
||||||
|
originStation: train?.originStation ?? null,
|
||||||
|
destinationStation: train?.destinationStation ?? null,
|
||||||
|
departureAt: train?.departureAt ?? null,
|
||||||
|
loaded,
|
||||||
|
leftBehind,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tell the customer what boarded the train and what did not, over in-app + SMS
|
||||||
|
* + email, and raise a warehouse-desk notice for anything left behind so
|
||||||
|
* somebody owns finding it space. A booking is routinely loaded in parts, and
|
||||||
|
* before this the customer learnt about it only by reading the sheet.
|
||||||
|
*
|
||||||
|
* Best-effort throughout: loading must never roll back because a provider is
|
||||||
|
* down.
|
||||||
|
*/
|
||||||
|
export async function notifyLoadManifest(
|
||||||
|
dataSource: DataSource,
|
||||||
|
notifications: NotificationsService,
|
||||||
|
inbox: NotificationInboxService,
|
||||||
|
bookingId: string,
|
||||||
|
trainScheduleId: string,
|
||||||
|
warehouseNotificationPermission: string,
|
||||||
|
logger: Logger,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const m = await loadManifestLists(dataSource, bookingId, trainScheduleId);
|
||||||
|
if (!m) return;
|
||||||
|
|
||||||
|
const route =
|
||||||
|
m.originStation && m.destinationStation
|
||||||
|
? ` ${m.originStation} → ${m.destinationStation}`
|
||||||
|
: '';
|
||||||
|
const departs = m.departureAt
|
||||||
|
? `, departs ${new Date(m.departureAt).toLocaleString('en-GB')}`
|
||||||
|
: '';
|
||||||
|
const train = m.trainNumber ? `train ${m.trainNumber}` : 'the train';
|
||||||
|
|
||||||
|
const headline =
|
||||||
|
`Booking ${m.reference}: ${m.loaded.length} container(s) loaded on ${train}` +
|
||||||
|
`${route}${departs}.`;
|
||||||
|
const loadedLine = m.loaded.length > 0 ? ` Loaded: ${summarizeNumbers(m.loaded)}.` : '';
|
||||||
|
const leftLine =
|
||||||
|
m.leftBehind.length > 0
|
||||||
|
? ` Not loaded (${m.leftBehind.length}): ${summarizeNumbers(m.leftBehind)}.` +
|
||||||
|
' These stay with EDR — once a warehouse is assigned you will receive the GRN.'
|
||||||
|
: '';
|
||||||
|
const body = headline + loadedLine + leftLine;
|
||||||
|
|
||||||
|
if (m.companyId) {
|
||||||
|
await inbox.notify({
|
||||||
|
recipients: { companyId: m.companyId },
|
||||||
|
audience: NotificationAudience.PORTAL,
|
||||||
|
type: NotificationType.BOOKING_STATUS,
|
||||||
|
title: m.leftBehind.length > 0 ? 'Cargo partly loaded' : 'Cargo loaded',
|
||||||
|
// The in-app copy carries every number; SMS and email get the summary.
|
||||||
|
body:
|
||||||
|
headline +
|
||||||
|
(m.loaded.length > 0 ? `\nLoaded: ${m.loaded.join(', ')}` : '') +
|
||||||
|
(m.leftBehind.length > 0
|
||||||
|
? `\nNot loaded: ${m.leftBehind.join(', ')}\nThese stay with EDR — once a warehouse is assigned you will receive the GRN.`
|
||||||
|
: ''),
|
||||||
|
link: `/bookings/${bookingId}`,
|
||||||
|
data: {
|
||||||
|
bookingId,
|
||||||
|
reference: m.reference,
|
||||||
|
trainNumber: m.trainNumber,
|
||||||
|
loaded: m.loaded,
|
||||||
|
leftBehind: m.leftBehind,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await sendCompanyChannels(dataSource, notifications, m.companyId, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing left behind is nothing for the warehouse desk to place.
|
||||||
|
if (m.leftBehind.length > 0) {
|
||||||
|
await inbox.notify({
|
||||||
|
recipients: { permissionKeys: [warehouseNotificationPermission] },
|
||||||
|
audience: NotificationAudience.BACKOFFICE,
|
||||||
|
type: NotificationType.REQUEST_SUBMITTED,
|
||||||
|
title: `${m.leftBehind.length} container(s) left behind — ${m.reference}`,
|
||||||
|
body:
|
||||||
|
`${train} departed without ${m.leftBehind.length} container(s) of booking ${m.reference}: ` +
|
||||||
|
`${m.leftBehind.join(', ')}. Assign warehouse space and raise the GRN.`,
|
||||||
|
link: `/dashboard/booking-requests/${bookingId}`,
|
||||||
|
data: { bookingId, reference: m.reference, leftBehind: m.leftBehind },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`Load manifest notify failed for ${bookingId}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -508,12 +508,12 @@ export class RuleEngineService {
|
|||||||
|
|
||||||
if (input.tradeDirection === 'IMPORT') {
|
if (input.tradeDirection === 'IMPORT') {
|
||||||
appliedModifiers.push(
|
appliedModifiers.push(
|
||||||
...this.derivedImportOverweight(
|
...(await this.derivedImportOverweight(
|
||||||
input,
|
input,
|
||||||
containerWeightResults,
|
containerWeightResults,
|
||||||
lineMaxVgmTons,
|
lineMaxVgmTons,
|
||||||
liveRates,
|
liveRates,
|
||||||
),
|
)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -540,23 +540,37 @@ export class RuleEngineService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Import overweight — derived, never configured. Each overweight container
|
* Import overweight — derived, never configured. Excess tons are billed on a
|
||||||
* line bills its excess tons at (its own base import freight on the booking's
|
* PER-WAGON basis: (the wagon's base import freight on the booking's route)
|
||||||
* route) ÷ (2 × its weight limit): 20ft at 1000 USD with a 20 t limit →
|
* ÷ (2 × the container's weight limit).
|
||||||
* 25 USD per excess ton. Export keeps the configured OVERWEIGHT rate.
|
*
|
||||||
* Note: derives from the LIVE route rate even for frozen-rate contract
|
* The rate is normalised to a wagon before dividing, because a 20ft rate
|
||||||
* bookings — the frozen snapshot has no route-scoped container price to
|
* quoted PER_CONTAINER prices only HALF a wagon — two 20ft ride one wagon —
|
||||||
* divide.
|
* while a 40ft container IS the whole wagon. So a PER_CONTAINER 20ft rate is
|
||||||
|
* doubled first; 40ft (and any rate already quoted PER_WAGON) is taken as is:
|
||||||
|
* - 20ft PER_CONTAINER 845 USD, 20 t limit → (845 × 2) / (2 × 20) = 42.25
|
||||||
|
* - 40ft PER_CONTAINER 1676 USD, 40 t limit → 1676 / (2 × 40) = 20.95
|
||||||
|
* Halving over 2 × the limit keeps the original meaning: filling one wagon's
|
||||||
|
* worth of excess costs one extra wagon of freight.
|
||||||
|
*
|
||||||
|
* Export keeps the configured OVERWEIGHT rate. Note: derives from the LIVE
|
||||||
|
* route rate even for frozen-rate contract bookings — the frozen snapshot has
|
||||||
|
* no route-scoped container price to divide.
|
||||||
*/
|
*/
|
||||||
private derivedImportOverweight(
|
private async derivedImportOverweight(
|
||||||
input: BookingEvaluationInput,
|
input: BookingEvaluationInput,
|
||||||
weightResults: ContainerWeightResult[],
|
weightResults: ContainerWeightResult[],
|
||||||
lineMaxVgmTons: Array<number | null>,
|
lineMaxVgmTons: Array<number | null>,
|
||||||
liveRates: Rate[],
|
liveRates: Rate[],
|
||||||
): AppliedCargoModifier[] {
|
): Promise<AppliedCargoModifier[]> {
|
||||||
const modifiers: AppliedCargoModifier[] = [];
|
const modifiers: AppliedCargoModifier[] = [];
|
||||||
if (!input.originYardId || !input.destinationYardId) return modifiers;
|
if (!input.originYardId || !input.destinationYardId) return modifiers;
|
||||||
|
|
||||||
|
// How many of each container type ride one wagon: a 40ft fills a wagon,
|
||||||
|
// two 20ft share one. Keyed by container type so a PER_CONTAINER rate can
|
||||||
|
// be scaled up to the wagon the overweight formula prices against.
|
||||||
|
const sizeByTypeId = await this.containersPerWagonByTypeId(weightResults);
|
||||||
|
|
||||||
for (let i = 0; i < weightResults.length; i++) {
|
for (let i = 0; i < weightResults.length; i++) {
|
||||||
const wr = weightResults[i];
|
const wr = weightResults[i];
|
||||||
const excess = Number(wr?.overweightExcessTons ?? 0);
|
const excess = Number(wr?.overweightExcessTons ?? 0);
|
||||||
@@ -578,7 +592,16 @@ export class RuleEngineService {
|
|||||||
// No base rate → the base-freight line hard-blocks this booking anyway.
|
// No base rate → the base-freight line hard-blocks this booking anyway.
|
||||||
if (!base) continue;
|
if (!base) continue;
|
||||||
|
|
||||||
const perTon = Number(base.rateValue) / (2 * maxVgm);
|
// Normalise the rate to ONE WAGON before dividing. A PER_CONTAINER 20ft
|
||||||
|
// rate covers half a wagon, so it is scaled by the 2 containers that ride
|
||||||
|
// one; 40ft scales by 1. A rate already quoted PER_WAGON is the wagon
|
||||||
|
// price already — never scale it again.
|
||||||
|
const perWagonRate =
|
||||||
|
base.rateUnit === 'PER_CONTAINER'
|
||||||
|
? Number(base.rateValue) * (sizeByTypeId.get(wr.containerTypeId) ?? 1)
|
||||||
|
: Number(base.rateValue);
|
||||||
|
|
||||||
|
const perTon = perWagonRate / (2 * maxVgm);
|
||||||
const amount = excess * perTon;
|
const amount = excess * perTon;
|
||||||
if (!(amount > 0)) continue;
|
if (!(amount > 0)) continue;
|
||||||
|
|
||||||
@@ -595,6 +618,42 @@ export class RuleEngineService {
|
|||||||
return modifiers;
|
return modifiers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Containers of each type that ride ONE wagon, derived from the type's
|
||||||
|
* size_ft against a 40ft wagon slot: 20ft → 2, 40ft → 1. Only the types the
|
||||||
|
* caller actually needs are looked up. Unknown or non-positive sizes fall
|
||||||
|
* back to 1, which leaves a PER_CONTAINER rate unscaled — the pre-existing
|
||||||
|
* behaviour, so a missing size can never inflate a bill.
|
||||||
|
*/
|
||||||
|
private async containersPerWagonByTypeId(
|
||||||
|
weightResults: ContainerWeightResult[],
|
||||||
|
): Promise<Map<string, number>> {
|
||||||
|
const perWagon = new Map<string, number>();
|
||||||
|
const ids = [...new Set(weightResults.map((w) => w.containerTypeId).filter(Boolean))];
|
||||||
|
if (ids.length === 0) return perWagon;
|
||||||
|
|
||||||
|
let rows: Array<{ id: string; size_ft: string | number | null }> = [];
|
||||||
|
try {
|
||||||
|
rows = await this.dataSource.query(
|
||||||
|
'SELECT id, size_ft FROM freight.container_types WHERE id = ANY($1)',
|
||||||
|
[ids],
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Size lookup unavailable — fall back to an unscaled (×1) rate, the
|
||||||
|
// behaviour before per-wagon normalisation. Never fail pricing over it.
|
||||||
|
return perWagon;
|
||||||
|
}
|
||||||
|
const WAGON_SLOT_FT = 40;
|
||||||
|
for (const row of rows) {
|
||||||
|
const sizeFt = Number(row.size_ft ?? 0);
|
||||||
|
perWagon.set(
|
||||||
|
row.id,
|
||||||
|
sizeFt > 0 ? Math.max(1, Math.floor(WAGON_SLOT_FT / sizeFt)) : 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return perWagon;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Empty-container return — sold per direction + route + container type, like
|
* Empty-container return — sold per direction + route + container type, like
|
||||||
* base freight. Each container line that opted in (returnQuantity, or every
|
* base freight. Each container line that opted in (returnQuantity, or every
|
||||||
|
|||||||
@@ -31,7 +31,11 @@ import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
|||||||
import { assertExportReceivedWithGrn, DIRECT_TO_TRAIN } from '../../common/export-received-gate';
|
import { assertExportReceivedWithGrn, DIRECT_TO_TRAIN } from '../../common/export-received-gate';
|
||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||||
import { notifyCarriageAcceptanceReady } from '../notifications/notify-company.util';
|
import {
|
||||||
|
notifyCarriageAcceptanceReady,
|
||||||
|
notifyLoadManifest,
|
||||||
|
} from '../notifications/notify-company.util';
|
||||||
|
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-booking journey along a train's corridor — for EVERY trade direction.
|
* Per-booking journey along a train's corridor — for EVERY trade direction.
|
||||||
@@ -267,6 +271,20 @@ export class BookingJourneyService {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// What actually boarded, and what did not. A booking is routinely loaded in
|
||||||
|
// parts; the customer is told both halves, and the warehouse desk is told
|
||||||
|
// about the leftovers so somebody owns placing them. After the transaction:
|
||||||
|
// the lists are read back from the allocation statuses it just wrote.
|
||||||
|
void notifyLoadManifest(
|
||||||
|
this.dataSource,
|
||||||
|
this.notifications,
|
||||||
|
this.inbox,
|
||||||
|
bookingId,
|
||||||
|
scheduleId,
|
||||||
|
FREIGHT_PERMS.warehouseInventory.getNotification,
|
||||||
|
this.logger,
|
||||||
|
);
|
||||||
|
|
||||||
// Customer tracking: cargo is on the train — loading milestones plus the
|
// Customer tracking: cargo is on the train — loading milestones plus the
|
||||||
// direction's "departed" handoff. Doc-trigger path no-ops non-customs
|
// direction's "departed" handoff. Doc-trigger path no-ops non-customs
|
||||||
// bookings (intercity) and already-completed codes.
|
// bookings (intercity) and already-completed codes.
|
||||||
|
|||||||
@@ -1134,7 +1134,7 @@ describe('TrainSchedulingService', () => {
|
|||||||
expect(html).toContain('2 (1 empty)');
|
expect(html).toContain('2 (1 empty)');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('marks a leg slot on the import document as TO BE LOADED and keeps it out of the loaded tallies', () => {
|
it('drops a leg slot entirely from the import document — not part of the departing consist', () => {
|
||||||
const loadList = {
|
const loadList = {
|
||||||
generatedAt: '2026-07-17T08:00:00.000Z',
|
generatedAt: '2026-07-17T08:00:00.000Z',
|
||||||
trainScheduleId: 'schedule-1',
|
trainScheduleId: 'schedule-1',
|
||||||
@@ -1176,15 +1176,16 @@ describe('TrainSchedulingService', () => {
|
|||||||
buildImportLoadListHtml: (l: unknown) => string;
|
buildImportLoadListHtml: (l: unknown) => string;
|
||||||
}).buildImportLoadListHtml(loadList);
|
}).buildImportLoadListHtml(loadList);
|
||||||
|
|
||||||
expect(html).toContain('TO BE LOADED AT DIRE DAWA PORT');
|
// The leg slot (W-ICY, boards later at Dire Dawa) gets no row at all —
|
||||||
// Departure station of the leg slot is its board yard, not the origin.
|
// it isn't on the departing consist. Only W-IMP appears.
|
||||||
expect(html).toContain('<td>Dire Dawa Port</td>');
|
expect(html).not.toContain('W-ICY');
|
||||||
// Only the origin-loaded container counts; the leg slot's tallies separately.
|
expect(html).not.toContain('ICY-001');
|
||||||
|
expect(html).toContain('W-IMP');
|
||||||
|
expect(html).toContain('<span>Wagons</span><strong>1</strong>');
|
||||||
expect(html).toContain('<span>Total containers</span><strong>1</strong>');
|
expect(html).toContain('<span>Total containers</span><strong>1</strong>');
|
||||||
expect(html).toContain('<span>To load en route</span><strong>1 containers</strong>');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('marks a leg slot on the export document as TO LOAD AT its board yard and keeps it out of the tallies', () => {
|
it('drops a leg slot entirely from the export document — not part of the departing consist', () => {
|
||||||
const sizedAllocation = {
|
const sizedAllocation = {
|
||||||
...loadedAllocation,
|
...loadedAllocation,
|
||||||
containerItems: [{ containerNumber: 'CONT-001', containerType: { sizeFt: 20 } }],
|
containerItems: [{ containerNumber: 'CONT-001', containerType: { sizeFt: 20 } }],
|
||||||
@@ -1204,9 +1205,10 @@ describe('TrainSchedulingService', () => {
|
|||||||
pendingBoardYardLabelBySlot: new Map([['slot-leg', 'Dire Dawa Port']]),
|
pendingBoardYardLabelBySlot: new Map([['slot-leg', 'Dire Dawa Port']]),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(html).toContain('TO LOAD AT DIRE DAWA PORT');
|
// The leg slot (W-LEG, boards later at Dire Dawa) gets no row at all.
|
||||||
|
expect(html).not.toContain('W-LEG');
|
||||||
|
expect(html).toContain('<span>Wagons</span><strong>1</strong>');
|
||||||
expect(html).toContain('<span>Total containers</span><strong>1</strong>');
|
expect(html).toContain('<span>Total containers</span><strong>1</strong>');
|
||||||
expect(html).toContain('<span>To load en route</span><strong>1 containers</strong>');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('prints the consist-changes table for this stop, and omits it when there are none', () => {
|
it('prints the consist-changes table for this stop, and omits it when there are none', () => {
|
||||||
@@ -1240,6 +1242,44 @@ describe('TrainSchedulingService', () => {
|
|||||||
expect(withoutChanges).not.toContain('Consist Changed At This Stop');
|
expect(withoutChanges).not.toContain('Consist Changed At This Stop');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows per-row Departure/Arrival Station — schedule endpoints for a whole-route wagon, its own board/alight yard for a leg slot', () => {
|
||||||
|
const wholeRoute = { ...makeWagon(1, 'W-001', [loadedAllocation]), id: 'slot-1' };
|
||||||
|
const legSlot = {
|
||||||
|
...makeWagon(2, 'W-LEG', [loadedAllocation]),
|
||||||
|
id: 'slot-leg',
|
||||||
|
boardYardId: 'yard-dire',
|
||||||
|
alightYardId: 'yard-adama',
|
||||||
|
};
|
||||||
|
const schedule = {
|
||||||
|
id: 'schedule-1',
|
||||||
|
trainNumber: '8302',
|
||||||
|
direction: 'EXPORT',
|
||||||
|
originStation: { label: 'DCT/SGTD' },
|
||||||
|
destinationStation: { label: 'GMP (Gelan Multipurpose Port)' },
|
||||||
|
trainSet: { wagons: [wholeRoute, legSlot] },
|
||||||
|
scheduleBookings: [],
|
||||||
|
};
|
||||||
|
const build = (service as never as {
|
||||||
|
buildExportLoadListHtml: (s: unknown, o?: unknown) => string;
|
||||||
|
}).buildExportLoadListHtml.bind(service);
|
||||||
|
|
||||||
|
const html = build(schedule, {
|
||||||
|
yardLabelById: new Map([
|
||||||
|
['yard-dire', 'Dire Dawa Port'],
|
||||||
|
['yard-adama', 'Adama'],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(html).toContain('<th>Departure Station</th>');
|
||||||
|
expect(html).toContain('<th>Arrival Station</th>');
|
||||||
|
// Whole-route wagon: schedule's own endpoints.
|
||||||
|
expect(html).toContain('<td>DCT/SGTD</td>');
|
||||||
|
expect(html).toContain('<td>GMP (Gelan Multipurpose Port)</td>');
|
||||||
|
// Leg slot: its own board/alight yard, not the schedule's endpoints.
|
||||||
|
expect(html).toContain('<td>Dire Dawa Port</td>');
|
||||||
|
expect(html).toContain('<td>Adama</td>');
|
||||||
|
});
|
||||||
|
|
||||||
it('lists loaded empty containers by number and states they are empty', () => {
|
it('lists loaded empty containers by number and states they are empty', () => {
|
||||||
const schedule = {
|
const schedule = {
|
||||||
id: 'schedule-1',
|
id: 'schedule-1',
|
||||||
@@ -1404,6 +1444,30 @@ describe('TrainSchedulingService', () => {
|
|||||||
expect(numbers).toEqual(['W-LEG2']);
|
expect(numbers).toEqual(['W-LEG2']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('drops a leg slot LOADED by generation time but not yet coupled as of this stop', () => {
|
||||||
|
// Both W-DIRE (coupled+loaded at Dire Dawa) and W-ADAMA (coupled+loaded
|
||||||
|
// at Adama, a LATER stop) read identically to intercityOnBoardView by
|
||||||
|
// the time this runs — both LOADED right now. Only the adjustment log
|
||||||
|
// knows W-ADAMA hadn't coupled yet as of Dire Dawa's own timestamp.
|
||||||
|
const wholeRoute = makeWagon(1, 'W-001', [allocWith({ status: 'LOADED' })]);
|
||||||
|
const legDireDawa = { ...makeWagon(2, 'W-DIRE', [allocWith({ status: 'LOADED' })]), boardYardId: 'yard-dire' };
|
||||||
|
const legAdama = { ...makeWagon(3, 'W-ADAMA', [allocWith({ status: 'LOADED' })]), boardYardId: 'yard-adama' };
|
||||||
|
const schedule = { trainSet: { wagons: [wholeRoute, legDireDawa, legAdama] }, scheduleBookings: [] };
|
||||||
|
|
||||||
|
const { wagons } = onBoardView(schedule);
|
||||||
|
const boardedByDireDawa = new Set(['W-DIRE']); // logged ADD only up to Dire Dawa's stop
|
||||||
|
const wagonsAsOfStop = (service as never as {
|
||||||
|
wagonsAsOfStop: (w: unknown, s: Set<string>) => Array<{ physicalWagon: { wagonNumber: string } }>;
|
||||||
|
}).wagonsAsOfStop.bind(service);
|
||||||
|
|
||||||
|
const asOfDireDawa = wagonsAsOfStop(wagons, boardedByDireDawa);
|
||||||
|
expect(asOfDireDawa.map((w) => w.physicalWagon.wagonNumber)).toEqual(['W-001', 'W-DIRE']);
|
||||||
|
|
||||||
|
const boardedByAdama = new Set(['W-DIRE', 'W-ADAMA']); // both stops have now happened
|
||||||
|
const asOfAdama = wagonsAsOfStop(wagons, boardedByAdama);
|
||||||
|
expect(asOfAdama.map((w) => w.physicalWagon.wagonNumber)).toEqual(['W-001', 'W-DIRE', 'W-ADAMA']);
|
||||||
|
});
|
||||||
|
|
||||||
it('lists an IN_TRANSIT booking with no wagon allocation in the unassigned section', () => {
|
it('lists an IN_TRANSIT booking with no wagon allocation in the unassigned section', () => {
|
||||||
const rider = {
|
const rider = {
|
||||||
id: 'booking-9',
|
id: 'booking-9',
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
ILike,
|
ILike,
|
||||||
In,
|
In,
|
||||||
IsNull,
|
IsNull,
|
||||||
|
LessThanOrEqual,
|
||||||
Not,
|
Not,
|
||||||
QueryFailedError,
|
QueryFailedError,
|
||||||
Raw,
|
Raw,
|
||||||
@@ -3556,17 +3557,19 @@ export class TrainSchedulingService {
|
|||||||
|
|
||||||
// Leg slots couple mid-corridor — this origin document must say where their
|
// Leg slots couple mid-corridor — this origin document must say where their
|
||||||
// cargo boards instead of listing it as loaded here (see the import list).
|
// cargo boards instead of listing it as loaded here (see the import list).
|
||||||
const slotYardLabels = await this.yardLabelsById(
|
// Also doubles as the per-row Departure/Arrival Station lookup below.
|
||||||
(schedule.trainSet?.wagons ?? []).map((wagon) => wagon.boardYardId),
|
const yardLabelById = await this.yardLabelsById(
|
||||||
|
(schedule.trainSet?.wagons ?? []).flatMap((wagon) => [wagon.boardYardId, wagon.alightYardId]),
|
||||||
);
|
);
|
||||||
const pendingBoardYardLabelBySlot = new Map(
|
const pendingBoardYardLabelBySlot = new Map(
|
||||||
(schedule.trainSet?.wagons ?? [])
|
(schedule.trainSet?.wagons ?? [])
|
||||||
.filter((wagon) => wagon.boardYardId)
|
.filter((wagon) => wagon.boardYardId)
|
||||||
.map((wagon) => [wagon.id, slotYardLabels.get(wagon.boardYardId!) ?? 'en route']),
|
.map((wagon) => [wagon.id, yardLabelById.get(wagon.boardYardId!) ?? 'en route']),
|
||||||
);
|
);
|
||||||
|
|
||||||
const html = this.buildExportLoadListHtml(schedule, {
|
const html = this.buildExportLoadListHtml(schedule, {
|
||||||
pendingBoardYardLabelBySlot,
|
pendingBoardYardLabelBySlot,
|
||||||
|
yardLabelById,
|
||||||
emptyContainers: await this.loadedEmptyContainers(scheduleId),
|
emptyContainers: await this.loadedEmptyContainers(scheduleId),
|
||||||
logoImageUrl: await this.logoSettings.getLogoImageUrl(),
|
logoImageUrl: await this.logoSettings.getLogoImageUrl(),
|
||||||
});
|
});
|
||||||
@@ -3623,6 +3626,24 @@ export class TrainSchedulingService {
|
|||||||
return { wagons, unassignedBookings };
|
return { wagons, unassignedBookings };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Corrects intercityOnBoardView's CURRENT-state wagon list against a
|
||||||
|
* specific stop's document. intercityOnBoardView's "boardYardId == null ||
|
||||||
|
* hasLoaded" test reads whatever is true RIGHT NOW — it can't distinguish
|
||||||
|
* "this leg slot coupled at THIS stop" from "it coupled at a LATER stop
|
||||||
|
* that has, by generation time, also already happened" (both look LOADED).
|
||||||
|
* Reprinting an earlier stop's document after a later one has run would
|
||||||
|
* otherwise leak the later stop's wagons in. `boardedWagonNumbers` is the
|
||||||
|
* set of physical wagon numbers with a logged ADD at or before this stop
|
||||||
|
* (see marshallingDocumentAt) — the ground truth a real-time heuristic
|
||||||
|
* can't provide once multiple stops have already happened.
|
||||||
|
*/
|
||||||
|
private wagonsAsOfStop(wagons: TrainSetWagon[], boardedWagonNumbers: Set<string>): TrainSetWagon[] {
|
||||||
|
return wagons.filter(
|
||||||
|
(wagon) => wagon.boardYardId == null || boardedWagonNumbers.has(wagon.physicalWagon?.wagonNumber ?? ''),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every corridor stop where the consist actually changed for this schedule
|
* Every corridor stop where the consist actually changed for this schedule
|
||||||
* (coupled, uncoupled, or switched — any flavor), in the order the train
|
* (coupled, uncoupled, or switched — any flavor), in the order the train
|
||||||
@@ -3717,11 +3738,37 @@ export class TrainSchedulingService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { wagons, unassignedBookings } = this.intercityOnBoardView(schedule);
|
const { wagons: currentWagons, unassignedBookings } = this.intercityOnBoardView(schedule);
|
||||||
|
// intercityOnBoardView's "boardYardId == null || hasLoaded" test reads
|
||||||
|
// CURRENT state — it can't tell "coupled here" from "coupled at a LATER
|
||||||
|
// stop that has since also happened" (both look LOADED by generation
|
||||||
|
// time once the trip has moved past this stop). Reprinting Marshalling 2
|
||||||
|
// after Marshalling 3's stop already ran would otherwise show Marshalling
|
||||||
|
// 3's coupled wagons too. Correct it against the log: a leg-slot wagon
|
||||||
|
// belongs on THIS stop's document only if it actually has a logged ADD
|
||||||
|
// at or before THIS stop's own timestamp.
|
||||||
|
const boardedByThisStop = new Set(
|
||||||
|
(
|
||||||
|
await this.dataSource.getRepository(ScheduleWagonAdjustmentLog).find({
|
||||||
|
where: {
|
||||||
|
trainScheduleId: scheduleId,
|
||||||
|
action: 'ADD',
|
||||||
|
occurredAt: LessThanOrEqual(new Date(stop.firstOccurredAt)),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).map((row) => row.wagonNumber),
|
||||||
|
);
|
||||||
|
const wagons = this.wagonsAsOfStop(currentWagons, boardedByThisStop);
|
||||||
const logRows = await this.dataSource.getRepository(ScheduleWagonAdjustmentLog).find({
|
const logRows = await this.dataSource.getRepository(ScheduleWagonAdjustmentLog).find({
|
||||||
where: { trainScheduleId: scheduleId, yardId: stop.yardId },
|
where: { trainScheduleId: scheduleId, yardId: stop.yardId },
|
||||||
order: { occurredAt: 'ASC' },
|
order: { occurredAt: 'ASC' },
|
||||||
});
|
});
|
||||||
|
// Per-row Departure/Arrival Station: a whole-route wagon reads the
|
||||||
|
// schedule's own origin/destination, a leg-slot wagon reads where IT
|
||||||
|
// boards/alights instead.
|
||||||
|
const yardLabelById = await this.yardLabelsById(
|
||||||
|
(schedule.trainSet?.wagons ?? []).flatMap((wagon) => [wagon.boardYardId, wagon.alightYardId]),
|
||||||
|
);
|
||||||
const html = this.buildExportLoadListHtml(schedule, {
|
const html = this.buildExportLoadListHtml(schedule, {
|
||||||
title: `Intercity Marshalling Document / Load List (Marshalling ${stopIndex})`,
|
title: `Intercity Marshalling Document / Load List (Marshalling ${stopIndex})`,
|
||||||
positionLabel: `At ${stop.yardLabel}`,
|
positionLabel: `At ${stop.yardLabel}`,
|
||||||
@@ -3730,6 +3777,7 @@ export class TrainSchedulingService {
|
|||||||
emptyContainers: await this.loadedEmptyContainers(scheduleId),
|
emptyContainers: await this.loadedEmptyContainers(scheduleId),
|
||||||
logoImageUrl: await this.logoSettings.getLogoImageUrl(),
|
logoImageUrl: await this.logoSettings.getLogoImageUrl(),
|
||||||
consistChangesAtStop: this.consistChangesAt(schedule, logRows),
|
consistChangesAtStop: this.consistChangesAt(schedule, logRows),
|
||||||
|
yardLabelById,
|
||||||
});
|
});
|
||||||
// Styled table-aware fallback (marshalling grid) — see importLoadListDocument.
|
// Styled table-aware fallback (marshalling grid) — see importLoadListDocument.
|
||||||
const buffer = await this.pdfDocuments.renderTabularDocument(html, `Marshalling ${stopIndex}`);
|
const buffer = await this.pdfDocuments.renderTabularDocument(html, `Marshalling ${stopIndex}`);
|
||||||
@@ -3769,6 +3817,9 @@ export class TrainSchedulingService {
|
|||||||
? `After ${last.yard?.label ?? last.yard?.code ?? 'checkpoint'}`
|
? `After ${last.yard?.label ?? last.yard?.code ?? 'checkpoint'}`
|
||||||
: `At ${schedule.originStation?.label ?? schedule.originStation?.code ?? 'origin'} — no checkpoint recorded`;
|
: `At ${schedule.originStation?.label ?? schedule.originStation?.code ?? 'origin'} — no checkpoint recorded`;
|
||||||
const { wagons, unassignedBookings } = this.intercityOnBoardView(schedule);
|
const { wagons, unassignedBookings } = this.intercityOnBoardView(schedule);
|
||||||
|
const yardLabelById = await this.yardLabelsById(
|
||||||
|
(schedule.trainSet?.wagons ?? []).flatMap((wagon) => [wagon.boardYardId, wagon.alightYardId]),
|
||||||
|
);
|
||||||
const html = this.buildExportLoadListHtml(schedule, {
|
const html = this.buildExportLoadListHtml(schedule, {
|
||||||
title: 'Intercity Marshalling Document / Load List (Marshalling 2)',
|
title: 'Intercity Marshalling Document / Load List (Marshalling 2)',
|
||||||
positionLabel,
|
positionLabel,
|
||||||
@@ -3776,6 +3827,7 @@ export class TrainSchedulingService {
|
|||||||
unassignedBookings,
|
unassignedBookings,
|
||||||
emptyContainers: await this.loadedEmptyContainers(scheduleId),
|
emptyContainers: await this.loadedEmptyContainers(scheduleId),
|
||||||
logoImageUrl: await this.logoSettings.getLogoImageUrl(),
|
logoImageUrl: await this.logoSettings.getLogoImageUrl(),
|
||||||
|
yardLabelById,
|
||||||
});
|
});
|
||||||
// Styled table-aware fallback (marshalling grid) — see importLoadListDocument.
|
// Styled table-aware fallback (marshalling grid) — see importLoadListDocument.
|
||||||
const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Intercity marshalling / load list');
|
const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Intercity marshalling / load list');
|
||||||
@@ -3845,6 +3897,10 @@ export class TrainSchedulingService {
|
|||||||
// Slots that couple to the train downstream (slot id → board yard label).
|
// Slots that couple to the train downstream (slot id → board yard label).
|
||||||
// Their cargo renders as TO LOAD AT and stays out of the loaded tallies.
|
// Their cargo renders as TO LOAD AT and stays out of the loaded tallies.
|
||||||
pendingBoardYardLabelBySlot?: Map<string, string>;
|
pendingBoardYardLabelBySlot?: Map<string, string>;
|
||||||
|
// yardId → label, for the per-row Departure/Arrival Station columns
|
||||||
|
// (falls back to the schedule's own origin/destination when a wagon's
|
||||||
|
// boardYardId/alightYardId is null — i.e. it rides the whole corridor).
|
||||||
|
yardLabelById?: Map<string, string>;
|
||||||
// Numbered marshalling docs only (see marshallingDocumentAt /
|
// Numbered marshalling docs only (see marshallingDocumentAt /
|
||||||
// consistChangesAt) — couples/uncouples/switches logged at THIS stop.
|
// consistChangesAt) — couples/uncouples/switches logged at THIS stop.
|
||||||
// Origin import/export docs never pass this, so they render no such box.
|
// Origin import/export docs never pass this, so they render no such box.
|
||||||
@@ -3866,10 +3922,15 @@ export class TrainSchedulingService {
|
|||||||
const time = (value: unknown) => (value ? new Date(value as string | Date).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) : '-');
|
const time = (value: unknown) => (value ? new Date(value as string | Date).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) : '-');
|
||||||
const bookingById = new Map((schedule.scheduleBookings ?? []).map((link) => [link.bookingId, link.booking]));
|
const bookingById = new Map((schedule.scheduleBookings ?? []).map((link) => [link.bookingId, link.booking]));
|
||||||
// The document is checked against the physical train, so it has to run in
|
// The document is checked against the physical train, so it has to run in
|
||||||
// consist order — the relation comes back unordered.
|
// consist order — the relation comes back unordered. Slots planned to
|
||||||
const wagons = [...(opts?.wagons ?? schedule.trainSet?.wagons ?? [])].sort(
|
// couple at a LATER stop (pendingBoardYardLabelBySlot, origin docs only —
|
||||||
(a, b) => Number(a.sequenceNo ?? 0) - Number(b.sequenceNo ?? 0),
|
// intercity calls never pass it, their wagons list is already on-board
|
||||||
);
|
// only) are dropped here, not just tallied around: they are not part of
|
||||||
|
// the departing consist, so they get no row and no count on this document.
|
||||||
|
// Their own coupling shows up on THAT stop's own marshalling document.
|
||||||
|
const wagons = [...(opts?.wagons ?? schedule.trainSet?.wagons ?? [])]
|
||||||
|
.filter((wagon) => !opts?.pendingBoardYardLabelBySlot?.get(wagon.id))
|
||||||
|
.sort((a, b) => Number(a.sequenceNo ?? 0) - Number(b.sequenceNo ?? 0));
|
||||||
// Empties sit on wagons that carry no booking allocation, keyed by the wagon
|
// Empties sit on wagons that carry no booking allocation, keyed by the wagon
|
||||||
// slot recorded when they were loaded.
|
// slot recorded when they were loaded.
|
||||||
const emptiesByWagon = new Map<number, EmptyContainerReturn[]>();
|
const emptiesByWagon = new Map<number, EmptyContainerReturn[]>();
|
||||||
@@ -3880,15 +3941,28 @@ export class TrainSchedulingService {
|
|||||||
empty,
|
empty,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
const originLabel = schedule.originStation?.label ?? schedule.originStation?.code;
|
||||||
|
const destinationLabel = schedule.destinationStation?.label ?? schedule.destinationStation?.code;
|
||||||
const rows = wagons
|
const rows = wagons
|
||||||
.flatMap((wagon) => {
|
.flatMap((wagon) => {
|
||||||
|
// Departure/Arrival Station per row: a leg-slot wagon boards/alights
|
||||||
|
// somewhere other than the schedule's own endpoints; a whole-route
|
||||||
|
// wagon just reads origin/destination.
|
||||||
|
const departureLabel = wagon.boardYardId
|
||||||
|
? (opts?.yardLabelById?.get(wagon.boardYardId) ?? 'en route')
|
||||||
|
: originLabel;
|
||||||
|
const arrivalLabel = wagon.alightYardId
|
||||||
|
? (opts?.yardLabelById?.get(wagon.alightYardId) ?? 'en route')
|
||||||
|
: destinationLabel;
|
||||||
// Wagon identity is the same on every row the wagon produces, loaded or not.
|
// Wagon identity is the same on every row the wagon produces, loaded or not.
|
||||||
const wagonCells = `<td>${esc(wagon.sequenceNo)}</td>
|
const wagonCells = `<td>${esc(wagon.sequenceNo)}</td>
|
||||||
<td>${esc(wagon.physicalWagon?.wagonNumber)}</td>
|
<td>${esc(wagon.physicalWagon?.wagonNumber)}</td>
|
||||||
<td>${esc(wagon.wagonType?.code ?? wagon.wagonType?.name)}</td>
|
<td>${esc(wagon.wagonType?.code ?? wagon.wagonType?.name)}</td>
|
||||||
<td class="num">${esc(Number(wagon.lengthMeters || 0).toFixed(3))}</td>
|
<td class="num">${esc(Number(wagon.lengthMeters || 0).toFixed(3))}</td>
|
||||||
<td class="num">${esc(Number(wagon.wagonType?.tareWeightTons ?? 0).toFixed(2))}</td>
|
<td class="num">${esc(Number(wagon.wagonType?.tareWeightTons ?? 0).toFixed(2))}</td>
|
||||||
<td class="num">${esc(Number(wagon.capacityTons || 0).toFixed(3))}</td>`;
|
<td class="num">${esc(Number(wagon.capacityTons || 0).toFixed(3))}</td>
|
||||||
|
<td>${esc(departureLabel)}</td>
|
||||||
|
<td>${esc(arrivalLabel)}</td>`;
|
||||||
const allocations = wagon.allocations ?? [];
|
const allocations = wagon.allocations ?? [];
|
||||||
// An empty wagon still runs in the consist, so it still gets a line. Staff
|
// An empty wagon still runs in the consist, so it still gets a line. Staff
|
||||||
// check this document against the physical train — a wagon with no row
|
// check this document against the physical train — a wagon with no row
|
||||||
@@ -3912,11 +3986,10 @@ export class TrainSchedulingService {
|
|||||||
return [
|
return [
|
||||||
`<tr class="empty">
|
`<tr class="empty">
|
||||||
${wagonCells}
|
${wagonCells}
|
||||||
<td colspan="4">EMPTY — no cargo allocated</td>
|
<td colspan="5">EMPTY — no cargo allocated</td>
|
||||||
</tr>`,
|
</tr>`,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
const pendingAt = opts?.pendingBoardYardLabelBySlot?.get(wagon.id);
|
|
||||||
return allocations.map((allocation) => {
|
return allocations.map((allocation) => {
|
||||||
const booking = allocation.booking ?? bookingById.get(allocation.bookingId);
|
const booking = allocation.booking ?? bookingById.get(allocation.bookingId);
|
||||||
const cargoType = (booking as unknown as { cargoType?: { name?: string; code?: string } } | undefined)?.cargoType;
|
const cargoType = (booking as unknown as { cargoType?: { name?: string; code?: string } } | undefined)?.cargoType;
|
||||||
@@ -3928,7 +4001,7 @@ export class TrainSchedulingService {
|
|||||||
const chassisNumbers = containerItems.map((item) => item.chassisNumber).filter(Boolean).join(', ');
|
const chassisNumbers = containerItems.map((item) => item.chassisNumber).filter(Boolean).join(', ');
|
||||||
return `<tr>
|
return `<tr>
|
||||||
${wagonCells}
|
${wagonCells}
|
||||||
<td>${pendingAt ? `TO LOAD AT ${esc(pendingAt).toUpperCase()} — ` : ''}${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)}</td>
|
<td>${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)}</td>
|
||||||
<td>${esc(companyName)}</td>
|
<td>${esc(companyName)}</td>
|
||||||
<td>${esc(containerNumbers || firstContainer?.containerNumber)}</td>
|
<td>${esc(containerNumbers || firstContainer?.containerNumber)}</td>
|
||||||
<td>${esc(chassisNumbers)}</td>
|
<td>${esc(chassisNumbers)}</td>
|
||||||
@@ -3941,7 +4014,7 @@ export class TrainSchedulingService {
|
|||||||
// they are still physically on the train, so they get rows of their own.
|
// they are still physically on the train, so they get rows of their own.
|
||||||
const unassigned = opts?.unassignedBookings ?? [];
|
const unassigned = opts?.unassignedBookings ?? [];
|
||||||
const unassignedRows = unassigned.length
|
const unassignedRows = unassigned.length
|
||||||
? `<tr class="empty"><td colspan="11">ON BOARD — WAGON NOT RECORDED</td></tr>` +
|
? `<tr class="empty"><td colspan="13">ON BOARD — WAGON NOT RECORDED</td></tr>` +
|
||||||
unassigned
|
unassigned
|
||||||
.map((booking) => {
|
.map((booking) => {
|
||||||
const containerNumbers = (booking.bookingContainers ?? [])
|
const containerNumbers = (booking.bookingContainers ?? [])
|
||||||
@@ -3950,7 +4023,7 @@ export class TrainSchedulingService {
|
|||||||
.join(', ');
|
.join(', ');
|
||||||
const leg = `${booking.originYard?.label ?? booking.originYard?.code ?? '-'} → ${booking.destinationYard?.label ?? booking.destinationYard?.code ?? '-'}`;
|
const leg = `${booking.originYard?.label ?? booking.originYard?.code ?? '-'} → ${booking.destinationYard?.label ?? booking.destinationYard?.code ?? '-'}`;
|
||||||
return `<tr>
|
return `<tr>
|
||||||
<td colspan="6">${esc(booking.reference)} — ${esc(leg)}</td>
|
<td colspan="8">${esc(booking.reference)} — ${esc(leg)}</td>
|
||||||
<td>${esc(booking.cargoType?.cargoTypeName ?? booking.cargoType?.code)}</td>
|
<td>${esc(booking.cargoType?.cargoTypeName ?? booking.cargoType?.code)}</td>
|
||||||
<td>${esc(booking.company?.name)}</td>
|
<td>${esc(booking.company?.name)}</td>
|
||||||
<td>${esc(containerNumbers)}</td>
|
<td>${esc(containerNumbers)}</td>
|
||||||
@@ -3965,27 +4038,20 @@ export class TrainSchedulingService {
|
|||||||
(wagon.allocations ?? []).length === 0 &&
|
(wagon.allocations ?? []).length === 0 &&
|
||||||
!emptiesByWagon.get(Number(wagon.sequenceNo))?.length,
|
!emptiesByWagon.get(Number(wagon.sequenceNo))?.length,
|
||||||
).length;
|
).length;
|
||||||
const loadsHere = (wagon: TrainSetWagon) => !opts?.pendingBoardYardLabelBySlot?.get(wagon.id);
|
|
||||||
const totalWeight = wagons.reduce(
|
const totalWeight = wagons.reduce(
|
||||||
(sum, wagon) =>
|
(sum, wagon) =>
|
||||||
sum +
|
sum + (wagon.allocations ?? []).reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0),
|
||||||
(loadsHere(wagon)
|
|
||||||
? (wagon.allocations ?? []).reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0)
|
|
||||||
: 0),
|
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Container count summary (40ft, 20ft) — empties returning to Djibouti are
|
// Container count summary (40ft, 20ft) — empties returning to Djibouti are
|
||||||
// physically on the train, so they count, and are called out on their own tile.
|
// physically on the train, so they count, and are called out on their own
|
||||||
// Cargo boarding downstream is not on this train yet — it tallies separately.
|
// tile. Cargo boarding downstream never enters this loop — `wagons` above
|
||||||
let count40ft = 0, count20ft = 0, pendingContainers = 0;
|
// already excludes those slots.
|
||||||
|
let count40ft = 0, count20ft = 0;
|
||||||
wagons.forEach((wagon) => {
|
wagons.forEach((wagon) => {
|
||||||
(wagon.allocations ?? []).forEach((allocation) => {
|
(wagon.allocations ?? []).forEach((allocation) => {
|
||||||
(allocation.containerItems ?? []).forEach((item) => {
|
(allocation.containerItems ?? []).forEach((item) => {
|
||||||
if (!loadsHere(wagon)) {
|
|
||||||
pendingContainers++;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const size = this.resolveContainerItemSize(item);
|
const size = this.resolveContainerItemSize(item);
|
||||||
if (size === 40) count40ft++;
|
if (size === 40) count40ft++;
|
||||||
else if (size === 20) count20ft++;
|
else if (size === 20) count20ft++;
|
||||||
@@ -4053,7 +4119,6 @@ export class TrainSchedulingService {
|
|||||||
<div class="tile"><span>Containers 40ft</span><strong>${esc(count40ft)}</strong></div>
|
<div class="tile"><span>Containers 40ft</span><strong>${esc(count40ft)}</strong></div>
|
||||||
<div class="tile"><span>Containers 20ft</span><strong>${esc(count20ft)}</strong></div>
|
<div class="tile"><span>Containers 20ft</span><strong>${esc(count20ft)}</strong></div>
|
||||||
<div class="tile"><span>Total containers</span><strong>${esc(count40ft + count20ft)}</strong></div>
|
<div class="tile"><span>Total containers</span><strong>${esc(count40ft + count20ft)}</strong></div>
|
||||||
${pendingContainers ? `<div class="tile"><span>To load en route</span><strong>${esc(pendingContainers)} containers</strong></div>` : ''}
|
|
||||||
${emptyContainers.length ? `<div class="tile"><span>Empty containers</span><strong>${esc(emptyContainers.length)}</strong></div>` : ''}
|
${emptyContainers.length ? `<div class="tile"><span>Empty containers</span><strong>${esc(emptyContainers.length)}</strong></div>` : ''}
|
||||||
<div class="tile"><span>Prepared person</span><strong>${esc(schedule.preparedByUserId)}</strong></div>
|
<div class="tile"><span>Prepared person</span><strong>${esc(schedule.preparedByUserId)}</strong></div>
|
||||||
<div class="tile"><span>Check person</span><strong>${esc(schedule.checkedByUserId)}</strong></div>
|
<div class="tile"><span>Check person</span><strong>${esc(schedule.checkedByUserId)}</strong></div>
|
||||||
@@ -4099,6 +4164,8 @@ export class TrainSchedulingService {
|
|||||||
<th class="num">Equated Length</th>
|
<th class="num">Equated Length</th>
|
||||||
<th class="num">Tare Weight</th>
|
<th class="num">Tare Weight</th>
|
||||||
<th class="num">Load Capacity</th>
|
<th class="num">Load Capacity</th>
|
||||||
|
<th>Departure Station</th>
|
||||||
|
<th>Arrival Station</th>
|
||||||
<th>Cargo Type</th>
|
<th>Cargo Type</th>
|
||||||
<th>Company</th>
|
<th>Company</th>
|
||||||
<th>Container No</th>
|
<th>Container No</th>
|
||||||
@@ -4107,7 +4174,7 @@ export class TrainSchedulingService {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
${rows || '<tr><td colspan="10">No wagons on this train set.</td></tr>'}
|
${rows || '<tr><td colspan="13">No wagons on this train set.</td></tr>'}
|
||||||
${unassignedRows}
|
${unassignedRows}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -4253,30 +4320,23 @@ export class TrainSchedulingService {
|
|||||||
.replace(/'/g, ''');
|
.replace(/'/g, ''');
|
||||||
const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleString('en-GB') : '-');
|
const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleString('en-GB') : '-');
|
||||||
const status = loadList.operation.status;
|
const status = loadList.operation.status;
|
||||||
// A leg slot (boardYard set) couples mid-corridor — its cargo is NOT on the
|
// A leg slot (boardYard set) couples mid-corridor — it is not part of the
|
||||||
// physical train this Djibouti-side document is checked against, so it must
|
// consist this Djibouti-side document is checked against yet, so it gets
|
||||||
// stay out of the loaded tallies or the gate count stops matching.
|
// no row and no count here at all. Its own coupling shows up on THAT
|
||||||
const loadsHere = (wagon: (typeof loadList.wagons)[number]) => !wagon.boardYard;
|
// stop's own marshalling document once it actually happens.
|
||||||
const totalAllocations = loadList.wagons.reduce((sum, wagon) => sum + wagon.allocations.length, 0);
|
const wagons = loadList.wagons.filter((wagon) => !wagon.boardYard);
|
||||||
const totalWeight = loadList.wagons.reduce(
|
const totalAllocations = wagons.reduce((sum, wagon) => sum + wagon.allocations.length, 0);
|
||||||
(sum, wagon) =>
|
const totalWeight = wagons.reduce(
|
||||||
sum +
|
(sum, wagon) => sum + wagon.allocations.reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0),
|
||||||
(loadsHere(wagon)
|
|
||||||
? wagon.allocations.reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0)
|
|
||||||
: 0),
|
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
const emptyWagons = loadList.wagons.filter((wagon) => wagon.allocations.length === 0).length;
|
const emptyWagons = wagons.filter((wagon) => wagon.allocations.length === 0).length;
|
||||||
|
|
||||||
// Container count summary (40ft, 20ft) — loaded at origin vs. en route
|
// Container count summary (40ft, 20ft)
|
||||||
let count40ft = 0, count20ft = 0, pendingContainers = 0;
|
let count40ft = 0, count20ft = 0;
|
||||||
loadList.wagons.forEach((wagon) => {
|
wagons.forEach((wagon) => {
|
||||||
wagon.allocations.forEach((allocation) => {
|
wagon.allocations.forEach((allocation) => {
|
||||||
(allocation.containerItems ?? []).forEach((item) => {
|
(allocation.containerItems ?? []).forEach((item) => {
|
||||||
if (!loadsHere(wagon)) {
|
|
||||||
pendingContainers++;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const size = this.resolveContainerItemSize(item);
|
const size = this.resolveContainerItemSize(item);
|
||||||
if (size === 40) count40ft++;
|
if (size === 40) count40ft++;
|
||||||
else if (size === 20) count20ft++;
|
else if (size === 20) count20ft++;
|
||||||
@@ -4284,7 +4344,7 @@ export class TrainSchedulingService {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const allocationRows = loadList.wagons
|
const allocationRows = wagons
|
||||||
.flatMap((wagon) => {
|
.flatMap((wagon) => {
|
||||||
const wagonCells = `<td>${esc(wagon.sequenceNo)}</td>
|
const wagonCells = `<td>${esc(wagon.sequenceNo)}</td>
|
||||||
<td>${esc(wagon.wagonNumber)}</td>
|
<td>${esc(wagon.wagonNumber)}</td>
|
||||||
@@ -4317,7 +4377,7 @@ export class TrainSchedulingService {
|
|||||||
<td>${esc(allocation.loadType)}</td>
|
<td>${esc(allocation.loadType)}</td>
|
||||||
<td>${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')}</td>
|
<td>${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')}</td>
|
||||||
<td>${esc(sealNumbers || '-')}</td>
|
<td>${esc(sealNumbers || '-')}</td>
|
||||||
<td>${wagon.boardYard ? `TO BE LOADED AT ${esc(wagon.boardYard).toUpperCase()}` : ''}</td>
|
<td></td>
|
||||||
<td class="num">${esc(Number(allocation.allocatedWeightTons || 0).toFixed(3))}</td>
|
<td class="num">${esc(Number(allocation.allocatedWeightTons || 0).toFixed(3))}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
},
|
},
|
||||||
@@ -4384,13 +4444,12 @@ export class TrainSchedulingService {
|
|||||||
<div class="tile"><span>Origin</span><strong>${esc(loadList.origin)}</strong></div>
|
<div class="tile"><span>Origin</span><strong>${esc(loadList.origin)}</strong></div>
|
||||||
<div class="tile"><span>Destination</span><strong>${esc(loadList.destination)}</strong></div>
|
<div class="tile"><span>Destination</span><strong>${esc(loadList.destination)}</strong></div>
|
||||||
<div class="tile"><span>Total bookings</span><strong>${esc(loadList.totalBookings)}</strong></div>
|
<div class="tile"><span>Total bookings</span><strong>${esc(loadList.totalBookings)}</strong></div>
|
||||||
<div class="tile"><span>Wagons</span><strong>${esc(loadList.wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}</strong></div>
|
<div class="tile"><span>Wagons</span><strong>${esc(wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}</strong></div>
|
||||||
<div class="tile"><span>Allocations</span><strong>${esc(totalAllocations)}</strong></div>
|
<div class="tile"><span>Allocations</span><strong>${esc(totalAllocations)}</strong></div>
|
||||||
<div class="tile"><span>Total weight</span><strong>${esc(totalWeight.toFixed(3))} T</strong></div>
|
<div class="tile"><span>Total weight</span><strong>${esc(totalWeight.toFixed(3))} T</strong></div>
|
||||||
<div class="tile"><span>Containers 40ft</span><strong>${esc(count40ft)}</strong></div>
|
<div class="tile"><span>Containers 40ft</span><strong>${esc(count40ft)}</strong></div>
|
||||||
<div class="tile"><span>Containers 20ft</span><strong>${esc(count20ft)}</strong></div>
|
<div class="tile"><span>Containers 20ft</span><strong>${esc(count20ft)}</strong></div>
|
||||||
<div class="tile"><span>Total containers</span><strong>${esc(count40ft + count20ft)}</strong></div>
|
<div class="tile"><span>Total containers</span><strong>${esc(count40ft + count20ft)}</strong></div>
|
||||||
${pendingContainers ? `<div class="tile"><span>To load en route</span><strong>${esc(pendingContainers)} containers</strong></div>` : ''}
|
|
||||||
<div class="tile"><span>Gatepass granted</span><strong>${esc(date(loadList.operation.gatepassGrantedAt))}</strong></div>
|
<div class="tile"><span>Gatepass granted</span><strong>${esc(date(loadList.operation.gatepassGrantedAt))}</strong></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { combinedLocomotiveLimits } from '../train-scheduling/train-capacity.uti
|
|||||||
import { ScheduleWagonAdjustmentLog } from '../train-schedules/entities/schedule-wagon-adjustment-log.entity';
|
import { ScheduleWagonAdjustmentLog } from '../train-schedules/entities/schedule-wagon-adjustment-log.entity';
|
||||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||||
import { TrainSet } from '../train-sets/entities/train-set.entity';
|
import { TrainSet } from '../train-sets/entities/train-set.entity';
|
||||||
|
import { TrainSetLocomotive } from '../train-sets/entities/train-set-locomotive.entity';
|
||||||
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
||||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||||
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
|
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
|
||||||
@@ -504,7 +505,7 @@ export class TrainBuilderService {
|
|||||||
if (locomotiveIds.length < 1) {
|
if (locomotiveIds.length < 1) {
|
||||||
throw new BadRequestException('A train must be pulled by at least one locomotive');
|
throw new BadRequestException('A train must be pulled by at least one locomotive');
|
||||||
}
|
}
|
||||||
await this.dataSource.transaction(async (manager) => {
|
const pending = await this.dataSource.transaction(async (manager) => {
|
||||||
const train = await this.getEditableTrain(manager, id);
|
const train = await this.getEditableTrain(manager, id);
|
||||||
const yard = await manager
|
const yard = await manager
|
||||||
.getRepository(Yard)
|
.getRepository(Yard)
|
||||||
@@ -523,10 +524,76 @@ export class TrainBuilderService {
|
|||||||
await manager
|
await manager
|
||||||
.getRepository(Train)
|
.getRepository(Train)
|
||||||
.update(train.id, { capacityTons: round(limits?.maxPullWeightTons ?? 0) });
|
.update(train.id, { capacityTons: round(limits?.maxPullWeightTons ?? 0) });
|
||||||
|
// Capacity math reads the SET's locomotives, not the train's — push the
|
||||||
|
// new pull weight onto the live runs too, or they keep the old ceiling.
|
||||||
|
return this.syncLiveSchedulesAfterLocomotiveChange(manager, train.id, locomotiveIds);
|
||||||
});
|
});
|
||||||
|
// Re-derive FULL/reopen once committed — a bigger pull weight can free room
|
||||||
|
// on a schedule that had closed as FULL.
|
||||||
|
await this.reconcileWindowsAfterConsistChange(pending);
|
||||||
return this.getComposition(id);
|
return this.getComposition(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirror a built train's locomotive change onto every LIVE (DRAFT/SCHEDULED)
|
||||||
|
* schedule formed from it. The three capacity axes are derived from
|
||||||
|
* `train_set_locomotives` (see trainSetLocomotiveLimits), which is snapshotted
|
||||||
|
* when the set is built and never re-synced — so adding a second locomotive
|
||||||
|
* raised `trains.capacity_tons` but left every existing schedule pulling on
|
||||||
|
* the old single-loco ceiling, still refusing bookings for want of weight.
|
||||||
|
*
|
||||||
|
* Only DRAFT/SCHEDULED runs follow the live train; DISPATCHED/ARRIVED render
|
||||||
|
* from their frozen snapshot and must not be disturbed (same rule as
|
||||||
|
* syncLiveScheduleAfterConsistChange).
|
||||||
|
*/
|
||||||
|
private async syncLiveSchedulesAfterLocomotiveChange(
|
||||||
|
manager: EntityManager,
|
||||||
|
trainId: string,
|
||||||
|
locomotiveIds: string[],
|
||||||
|
): Promise<PendingWindowCheck[]> {
|
||||||
|
const trainSets = await manager.getRepository(TrainSet).find({ where: { trainId } });
|
||||||
|
if (!trainSets.length) return [];
|
||||||
|
|
||||||
|
const schedules = await manager.getRepository(TrainSchedule).find({
|
||||||
|
where: {
|
||||||
|
trainSetId: In(trainSets.map((s) => s.id)),
|
||||||
|
status: In(['DRAFT', 'SCHEDULED']),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!schedules.length) return [];
|
||||||
|
|
||||||
|
// Only the sets still backing a live run — a set behind an ARRIVED schedule
|
||||||
|
// keeps the locomotives it actually ran with.
|
||||||
|
const liveSetIds = [...new Set(schedules.map((s) => s.trainSetId))];
|
||||||
|
const [primaryId] = locomotiveIds;
|
||||||
|
for (const trainSetId of liveSetIds) {
|
||||||
|
await manager.getRepository(TrainSetLocomotive).delete({ trainSetId });
|
||||||
|
await manager.getRepository(TrainSetLocomotive).save(
|
||||||
|
locomotiveIds.map((locomotiveId, index) =>
|
||||||
|
manager
|
||||||
|
.getRepository(TrainSetLocomotive)
|
||||||
|
.create({ trainSetId, locomotiveId, sequenceNo: index }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// `locomotiveId` is the primary-locomotive fallback for single-loco reads.
|
||||||
|
await manager.getRepository(TrainSet).update(trainSetId, { locomotiveId: primaryId });
|
||||||
|
}
|
||||||
|
|
||||||
|
return schedules.map((s) => ({
|
||||||
|
scheduleId: s.id,
|
||||||
|
wasFull: s.bookingWindowStatus === 'FULL',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@link reconcileWindowAfterConsistChange} over several schedules. */
|
||||||
|
private async reconcileWindowsAfterConsistChange(
|
||||||
|
pending: PendingWindowCheck[],
|
||||||
|
): Promise<void> {
|
||||||
|
for (const check of pending) {
|
||||||
|
await this.reconcileWindowAfterConsistChange(check);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Edit a built train's display identity: name and fixed import/export run
|
* Edit a built train's display identity: name and fixed import/export run
|
||||||
* numbers. Mirrors the build-time number rules — the pair may not collide
|
* numbers. Mirrors the build-time number rules — the pair may not collide
|
||||||
|
|||||||
@@ -1509,6 +1509,7 @@ export class WarehouseInventoryService {
|
|||||||
grnNumber: string;
|
grnNumber: string;
|
||||||
direction?: string | null;
|
direction?: string | null;
|
||||||
warehouseId?: string | null;
|
warehouseId?: string | null;
|
||||||
|
bookingId?: string | null;
|
||||||
};
|
};
|
||||||
booking: {
|
booking: {
|
||||||
companyId?: string | null;
|
companyId?: string | null;
|
||||||
@@ -1730,6 +1731,7 @@ export class WarehouseInventoryService {
|
|||||||
grnNumber,
|
grnNumber,
|
||||||
direction: dto.direction,
|
direction: dto.direction,
|
||||||
warehouseId: dto.warehouseId,
|
warehouseId: dto.warehouseId,
|
||||||
|
bookingId,
|
||||||
},
|
},
|
||||||
booking,
|
booking,
|
||||||
bookingId,
|
bookingId,
|
||||||
@@ -3107,6 +3109,7 @@ export class WarehouseInventoryService {
|
|||||||
grnNumber,
|
grnNumber,
|
||||||
direction: bookingDirection,
|
direction: bookingDirection,
|
||||||
warehouseId: dto.warehouseId,
|
warehouseId: dto.warehouseId,
|
||||||
|
bookingId: dto.bookingId ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
return saved.id;
|
return saved.id;
|
||||||
@@ -6614,10 +6617,9 @@ export class WarehouseInventoryService {
|
|||||||
grnNumber: string;
|
grnNumber: string;
|
||||||
direction?: string | null;
|
direction?: string | null;
|
||||||
warehouseId?: string | null;
|
warehouseId?: string | null;
|
||||||
|
/** Resolves the company, which unlocks in-app + email alongside the SMS. */
|
||||||
|
bookingId?: string | null;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const phone = params.phone?.trim();
|
|
||||||
if (!phone) return;
|
|
||||||
|
|
||||||
const ownerName = params.ownerName?.trim() || 'Customer';
|
const ownerName = params.ownerName?.trim() || 'Customer';
|
||||||
const bookingReference = params.bookingReference?.trim();
|
const bookingReference = params.bookingReference?.trim();
|
||||||
const message =
|
const message =
|
||||||
@@ -6627,6 +6629,47 @@ export class WarehouseInventoryService {
|
|||||||
(params.direction ? `Direction: ${params.direction}. ` : '') +
|
(params.direction ? `Direction: ${params.direction}. ` : '') +
|
||||||
`Thank you.`;
|
`Thank you.`;
|
||||||
|
|
||||||
|
// A booking gives us the company, and with it the customer's inbox and
|
||||||
|
// email — not just whatever phone number the gate clerk typed. Without one
|
||||||
|
// (manual or backlog receive) the typed phone is all there is, so the
|
||||||
|
// original SMS-only path stands.
|
||||||
|
let companyId: string | null = null;
|
||||||
|
if (params.bookingId) {
|
||||||
|
try {
|
||||||
|
const [row]: Array<{ companyId: string | null }> = await this.dataSource.query(
|
||||||
|
`SELECT company_id AS "companyId"
|
||||||
|
FROM freight.bookings
|
||||||
|
WHERE id = $1 AND deleted_at IS NULL`,
|
||||||
|
[params.bookingId],
|
||||||
|
);
|
||||||
|
companyId = row?.companyId ?? null;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`GRN ${params.grnNumber}: company lookup failed: ${String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (companyId) {
|
||||||
|
try {
|
||||||
|
await this.inbox.notify({
|
||||||
|
recipients: { companyId },
|
||||||
|
audience: NotificationAudience.PORTAL,
|
||||||
|
type: NotificationType.DOCUMENT_ACTION,
|
||||||
|
title: 'Cargo received — GRN issued',
|
||||||
|
body: message,
|
||||||
|
link: params.bookingId ? `/bookings/${params.bookingId}` : undefined,
|
||||||
|
data: { grnNumber: params.grnNumber, bookingId: params.bookingId ?? null },
|
||||||
|
});
|
||||||
|
// Sends SMS *and* email to the company's own contacts, so the typed
|
||||||
|
// phone below is skipped to avoid texting the customer twice.
|
||||||
|
await sendCompanyChannels(this.dataSource, this.notifications, companyId, message);
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to notify company for GRN ${params.grnNumber}: ${String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const phone = params.phone?.trim();
|
||||||
|
if (!phone) return;
|
||||||
try {
|
try {
|
||||||
await this.notifications.directSend('sms', phone, message);
|
await this.notifications.directSend('sms', phone, message);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -465,6 +465,13 @@ export const CONTRACT_PERMISSIONS: FreightPermissionSeed[] = [
|
|||||||
"edr_freight_app:contracts:suspend",
|
"edr_freight_app:contracts:suspend",
|
||||||
"Suspend / resume a signed contract",
|
"Suspend / resume a signed contract",
|
||||||
),
|
),
|
||||||
|
// Terminal kill switch. Unlike suspend this cannot be undone — the customer
|
||||||
|
// re-submits a fresh contract with the same parameters instead.
|
||||||
|
perm(
|
||||||
|
"a3000001-0001-4000-8000-00000000001c",
|
||||||
|
"edr_freight_app:contracts:cancel",
|
||||||
|
"Cancel a contract (terminal)",
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Historical ids. EdrOrgSeeder no longer sends them — it upserts on `key` and
|
// Historical ids. EdrOrgSeeder no longer sends them — it upserts on `key` and
|
||||||
@@ -1909,6 +1916,11 @@ export const NOTIFICATION_PERMISSIONS: FreightPermissionSeed[] = [
|
|||||||
"edr_freight_app:additional_charges:get_notification",
|
"edr_freight_app:additional_charges:get_notification",
|
||||||
"Receive additional charge notifications",
|
"Receive additional charge notifications",
|
||||||
),
|
),
|
||||||
|
perm(
|
||||||
|
"f3a00001-0001-4000-8000-00000000000a",
|
||||||
|
"edr_freight_app:warehouse_inventory:get_notification",
|
||||||
|
"Receive warehouse desk notifications (containers left behind at loading)",
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [
|
export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [
|
||||||
@@ -2061,6 +2073,7 @@ export const FREIGHT_PERMS = {
|
|||||||
clearanceDjActions: "edr_freight_app:contracts:clearance_dj_actions",
|
clearanceDjActions: "edr_freight_app:contracts:clearance_dj_actions",
|
||||||
clearanceDutyAdvise: "edr_freight_app:contracts:clearance_duty_advise",
|
clearanceDutyAdvise: "edr_freight_app:contracts:clearance_duty_advise",
|
||||||
suspend: "edr_freight_app:contracts:suspend",
|
suspend: "edr_freight_app:contracts:suspend",
|
||||||
|
cancel: "edr_freight_app:contracts:cancel",
|
||||||
editDocument: "edr_freight_app:contracts:edit_document",
|
editDocument: "edr_freight_app:contracts:edit_document",
|
||||||
finalInvoiceRaise: "edr_freight_app:contracts:final_invoice_raise",
|
finalInvoiceRaise: "edr_freight_app:contracts:final_invoice_raise",
|
||||||
finalInvoiceConfirm: "edr_freight_app:contracts:final_invoice_confirm",
|
finalInvoiceConfirm: "edr_freight_app:contracts:final_invoice_confirm",
|
||||||
@@ -2375,6 +2388,12 @@ export const FREIGHT_PERMS = {
|
|||||||
release: "edr_freight_app:warehouse_inventory:release",
|
release: "edr_freight_app:warehouse_inventory:release",
|
||||||
deliver: "edr_freight_app:warehouse_inventory:deliver",
|
deliver: "edr_freight_app:warehouse_inventory:deliver",
|
||||||
inspect: "edr_freight_app:warehouse_inventory:inspect",
|
inspect: "edr_freight_app:warehouse_inventory:inspect",
|
||||||
|
/**
|
||||||
|
* Notification selector, not a route guard — who gets pinged when cargo is
|
||||||
|
* left behind at loading and needs warehouse space. Assign it to whichever
|
||||||
|
* desk owns that; it grants access to nothing.
|
||||||
|
*/
|
||||||
|
getNotification: "edr_freight_app:warehouse_inventory:get_notification",
|
||||||
},
|
},
|
||||||
interchangeDocuments: {
|
interchangeDocuments: {
|
||||||
view: "edr_freight_app:interchange_documents:view",
|
view: "edr_freight_app:interchange_documents:view",
|
||||||
@@ -2865,6 +2884,9 @@ export const ROLE_PERMISSION_PRESETS = {
|
|||||||
FREIGHT_PERMS.contracts.generateContract,
|
FREIGHT_PERMS.contracts.generateContract,
|
||||||
...bothFreightTypes(FREIGHT_PERMS.contracts.signStaff),
|
...bothFreightTypes(FREIGHT_PERMS.contracts.signStaff),
|
||||||
FREIGHT_PERMS.contracts.suspend,
|
FREIGHT_PERMS.contracts.suspend,
|
||||||
|
// Terminal kill switch, granted alongside suspend on the same desk that
|
||||||
|
// already rejects contracts and cancels bookings.
|
||||||
|
FREIGHT_PERMS.contracts.cancel,
|
||||||
FREIGHT_PERMS.contracts.editDocument,
|
FREIGHT_PERMS.contracts.editDocument,
|
||||||
...BOOKING_DESK_NOTIFICATION_KEYS,
|
...BOOKING_DESK_NOTIFICATION_KEYS,
|
||||||
// Marketing follows up with the customer when a reviewer sends profile
|
// Marketing follows up with the customer when a reviewer sends profile
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Button, Group, Modal, Stack, Text, Textarea } from "@mantine/core";
|
import { Button, Group, Modal, Stack, Text, Textarea } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
|
Ban,
|
||||||
Check,
|
Check,
|
||||||
Eye,
|
Eye,
|
||||||
// FilePen, // ponytail: back with the "Edit contract articles" button
|
// FilePen, // ponytail: back with the "Edit contract articles" button
|
||||||
@@ -81,6 +82,8 @@ export function ContractActionsToolbar({
|
|||||||
const mayReject = hasPermission(user, FREIGHT_PERMS.contracts.reject[arm]);
|
const mayReject = hasPermission(user, FREIGHT_PERMS.contracts.reject[arm]);
|
||||||
// One key both ways — whoever can freeze a contract can unfreeze it.
|
// One key both ways — whoever can freeze a contract can unfreeze it.
|
||||||
const maySuspend = hasPermission(user, FREIGHT_PERMS.contracts.suspend);
|
const maySuspend = hasPermission(user, FREIGHT_PERMS.contracts.suspend);
|
||||||
|
// Cancel is its own key — it is terminal, so it is NOT implied by suspend.
|
||||||
|
const mayCancel = hasPermission(user, FREIGHT_PERMS.contracts.cancel);
|
||||||
|
|
||||||
const [editorOpen, setEditorOpen] = useState(false);
|
const [editorOpen, setEditorOpen] = useState(false);
|
||||||
const [editorMode, setEditorMode] = useState<"accept" | "edit">("accept");
|
const [editorMode, setEditorMode] = useState<"accept" | "edit">("accept");
|
||||||
@@ -93,6 +96,67 @@ export function ContractActionsToolbar({
|
|||||||
const [suspendReason, setSuspendReason] = useState("");
|
const [suspendReason, setSuspendReason] = useState("");
|
||||||
const [resumeOpen, setResumeOpen] = useState(false);
|
const [resumeOpen, setResumeOpen] = useState(false);
|
||||||
const [resumeNote, setResumeNote] = useState("");
|
const [resumeNote, setResumeNote] = useState("");
|
||||||
|
const [cancelOpen, setCancelOpen] = useState(false);
|
||||||
|
const [cancelReason, setCancelReason] = useState("");
|
||||||
|
|
||||||
|
// Shared by the suspended branch and the normal toolbar — both can cancel.
|
||||||
|
const cancelModal = (
|
||||||
|
<Modal
|
||||||
|
opened={cancelOpen}
|
||||||
|
onClose={() => setCancelOpen(false)}
|
||||||
|
title="Cancel this contract?"
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text size="sm">
|
||||||
|
Contract <b>{contract.reference}</b> will be cancelled permanently.
|
||||||
|
This cannot be undone — there is no way to reactivate it. A new
|
||||||
|
contract with the same details can be submitted afterwards. The
|
||||||
|
customer is notified.
|
||||||
|
</Text>
|
||||||
|
<Textarea
|
||||||
|
label="Reason for cancellation"
|
||||||
|
placeholder="Explain why this contract is being cancelled…"
|
||||||
|
autosize
|
||||||
|
minRows={3}
|
||||||
|
value={cancelReason}
|
||||||
|
onChange={(e) => setCancelReason(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
<Button variant="default" onClick={() => setCancelOpen(false)}>
|
||||||
|
Keep contract
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="red"
|
||||||
|
disabled={!cancelReason.trim()}
|
||||||
|
loading={mutations.cancelByStaff.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
mutations.cancelByStaff.mutate(cancelReason, {
|
||||||
|
onSuccess: () => {
|
||||||
|
setCancelOpen(false);
|
||||||
|
setCancelReason("");
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Cancel contract
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
|
||||||
|
const cancelButton = mayCancel ? (
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
variant="light"
|
||||||
|
color="red"
|
||||||
|
leftSection={<Ban size={16} />}
|
||||||
|
onClick={() => setCancelOpen(true)}
|
||||||
|
>
|
||||||
|
Cancel contract
|
||||||
|
</Button>
|
||||||
|
) : null;
|
||||||
|
|
||||||
// Whether the document is editable depends on WHO is viewing — only the
|
// Whether the document is editable depends on WHO is viewing — only the
|
||||||
// approver whose turn it is may edit — so the server decides, not the client.
|
// approver whose turn it is may edit — so the server decides, not the client.
|
||||||
@@ -126,9 +190,13 @@ export function ContractActionsToolbar({
|
|||||||
if (status === "CHANGES_REQUESTED") {
|
if (status === "CHANGES_REQUESTED") {
|
||||||
return (
|
return (
|
||||||
<SectionCard icon={Zap} title="Awaiting customer">
|
<SectionCard icon={Zap} title="Awaiting customer">
|
||||||
<Text size="sm" c="dimmed">
|
<Stack gap="sm">
|
||||||
No staff actions until the customer resubmits the contract.
|
<Text size="sm" c="dimmed">
|
||||||
</Text>
|
No staff actions until the customer resubmits the contract.
|
||||||
|
</Text>
|
||||||
|
{cancelButton}
|
||||||
|
</Stack>
|
||||||
|
{cancelModal}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -166,6 +234,7 @@ export function ContractActionsToolbar({
|
|||||||
You do not have permission to lift a suspension.
|
You do not have permission to lift a suspension.
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
{cancelButton}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
@@ -209,6 +278,8 @@ export function ContractActionsToolbar({
|
|||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{cancelModal}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -362,11 +433,16 @@ export function ContractActionsToolbar({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Available at every non-terminal status — the early returns above
|
||||||
|
already cover the statuses where cancelling makes no sense. */}
|
||||||
|
{cancelButton}
|
||||||
|
|
||||||
{!canAccept &&
|
{!canAccept &&
|
||||||
!inApproval &&
|
!inApproval &&
|
||||||
!canViewContract &&
|
!canViewContract &&
|
||||||
!canReviewClearance &&
|
!canReviewClearance &&
|
||||||
!canSuspend && (
|
!canSuspend &&
|
||||||
|
!mayCancel && (
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
No staff actions available for this status. Monitor until the
|
No staff actions available for this status. Monitor until the
|
||||||
workflow advances.
|
workflow advances.
|
||||||
@@ -515,6 +591,8 @@ export function ContractActionsToolbar({
|
|||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{cancelModal}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -281,6 +281,7 @@ export const URL_CONSTANTS = {
|
|||||||
STAFF_REQUEST_CHANGES: (id: string) =>
|
STAFF_REQUEST_CHANGES: (id: string) =>
|
||||||
`/contracts/${id}/staff/request-changes`,
|
`/contracts/${id}/staff/request-changes`,
|
||||||
STAFF_REJECT: (id: string) => `/contracts/${id}/staff/reject`,
|
STAFF_REJECT: (id: string) => `/contracts/${id}/staff/reject`,
|
||||||
|
STAFF_CANCEL: (id: string) => `/contracts/${id}/staff/cancel`,
|
||||||
SUSPEND: (id: string) => `/contracts/${id}/suspend`,
|
SUSPEND: (id: string) => `/contracts/${id}/suspend`,
|
||||||
RESUME: (id: string) => `/contracts/${id}/resume`,
|
RESUME: (id: string) => `/contracts/${id}/resume`,
|
||||||
APPROVE_STEP: (id: string, stepId: string) =>
|
APPROVE_STEP: (id: string, stepId: string) =>
|
||||||
|
|||||||
@@ -151,6 +151,14 @@ export function useContractMutations(contractId: string) {
|
|||||||
onError: (error) => toast.error(extractErrorMessage(error, "Failed to reject contract")),
|
onError: (error) => toast.error(extractErrorMessage(error, "Failed to reject contract")),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const cancelByStaff = useMutation({
|
||||||
|
mutationFn: (reason: string) =>
|
||||||
|
contractsService.cancelByStaff(contractId, reason),
|
||||||
|
onSuccess: (data) => onSuccess(data, "Contract cancelled"),
|
||||||
|
onError: (error) =>
|
||||||
|
toast.error(extractErrorMessage(error, "Failed to cancel contract")),
|
||||||
|
});
|
||||||
|
|
||||||
const suspend = useMutation({
|
const suspend = useMutation({
|
||||||
mutationFn: (reason: string) => contractsService.suspend(contractId, reason),
|
mutationFn: (reason: string) => contractsService.suspend(contractId, reason),
|
||||||
onSuccess: (data) => onSuccess(data, "Contract suspended"),
|
onSuccess: (data) => onSuccess(data, "Contract suspended"),
|
||||||
@@ -269,6 +277,7 @@ export function useContractMutations(contractId: string) {
|
|||||||
updateDocument,
|
updateDocument,
|
||||||
requestChanges,
|
requestChanges,
|
||||||
reject,
|
reject,
|
||||||
|
cancelByStaff,
|
||||||
suspend,
|
suspend,
|
||||||
resume,
|
resume,
|
||||||
approveStep,
|
approveStep,
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ export const FREIGHT_PERMS = {
|
|||||||
clearanceEtActions: "edr_freight_app:contracts:clearance_et_actions",
|
clearanceEtActions: "edr_freight_app:contracts:clearance_et_actions",
|
||||||
clearanceDjActions: "edr_freight_app:contracts:clearance_dj_actions",
|
clearanceDjActions: "edr_freight_app:contracts:clearance_dj_actions",
|
||||||
suspend: "edr_freight_app:contracts:suspend",
|
suspend: "edr_freight_app:contracts:suspend",
|
||||||
|
cancel: "edr_freight_app:contracts:cancel",
|
||||||
editDocument: "edr_freight_app:contracts:edit_document",
|
editDocument: "edr_freight_app:contracts:edit_document",
|
||||||
finalInvoiceRaise: "edr_freight_app:contracts:final_invoice_raise",
|
finalInvoiceRaise: "edr_freight_app:contracts:final_invoice_raise",
|
||||||
finalInvoiceConfirm: "edr_freight_app:contracts:final_invoice_confirm",
|
finalInvoiceConfirm: "edr_freight_app:contracts:final_invoice_confirm",
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
|
|
||||||
import { PageContainer, PageHeader, KpiStrip } from "@/components/page";
|
import { PageContainer, PageHeader, KpiStrip } from "@/components/page";
|
||||||
|
import { extractDownloadErrorMessage } from "@/components/warehouses/options";
|
||||||
import type { KpiItem } from "@/components/page";
|
import type { KpiItem } from "@/components/page";
|
||||||
import { EntityLink } from "@/components/detail";
|
import { EntityLink } from "@/components/detail";
|
||||||
import { BookingActionsToolbar } from "@/components/bookings/BookingActionsToolbar";
|
import { BookingActionsToolbar } from "@/components/bookings/BookingActionsToolbar";
|
||||||
@@ -374,11 +375,9 @@ export default function BookingRequestDetailPage() {
|
|||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(
|
// Blob response: the JSON reason is inside the Blob, so
|
||||||
error instanceof Error
|
// the sync path would show only "status code 400".
|
||||||
? error.message
|
toast.error(await extractDownloadErrorMessage(error));
|
||||||
: "Carriage acceptance sheet is not available yet",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -288,6 +288,13 @@ export const contractsService = {
|
|||||||
reject: (id: string, reason: string) =>
|
reject: (id: string, reason: string) =>
|
||||||
postContract<Freight.IContract>(C.STAFF_REJECT(id), { reason }),
|
postContract<Freight.IContract>(C.STAFF_REJECT(id), { reason }),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel a contract outright. Terminal — unlike {@link suspend} there is no
|
||||||
|
* way back; a new contract with the same details must be submitted instead.
|
||||||
|
*/
|
||||||
|
cancelByStaff: (id: string, reason: string) =>
|
||||||
|
postContract<Freight.IContract>(C.STAFF_CANCEL(id), { reason }),
|
||||||
|
|
||||||
/** Freeze a signed contract. Reversible — see {@link resume}. */
|
/** Freeze a signed contract. Reversible — see {@link resume}. */
|
||||||
suspend: (id: string, reason: string) =>
|
suspend: (id: string, reason: string) =>
|
||||||
postContract<Freight.IContract>(C.SUSPEND(id), { reason }),
|
postContract<Freight.IContract>(C.SUSPEND(id), { reason }),
|
||||||
|
|||||||
@@ -27,6 +27,25 @@ import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/Clearanc
|
|||||||
|
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A `responseType: "blob"` request delivers its JSON error body as a Blob, so
|
||||||
|
* reading `error.message` gives "Request failed with status code 400" instead
|
||||||
|
* of the reason. Read the blob back before falling back.
|
||||||
|
*/
|
||||||
|
async function downloadErrorMessage(error: unknown, fallback: string): Promise<string> {
|
||||||
|
const data = (error as { response?: { data?: unknown } })?.response?.data;
|
||||||
|
if (data instanceof Blob) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(await data.text()) as { message?: unknown };
|
||||||
|
if (parsed?.message) return String(parsed.message);
|
||||||
|
} catch {
|
||||||
|
/* not JSON — fall through */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return error instanceof Error ? error.message : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
import { bookingsService } from "@/services/bookings.service";
|
import { bookingsService } from "@/services/bookings.service";
|
||||||
import type { EmptyContainerReturn } from "@/services/bookings.service";
|
import type { EmptyContainerReturn } from "@/services/bookings.service";
|
||||||
import { saveBlob } from "@/utils/download";
|
import { saveBlob } from "@/utils/download";
|
||||||
@@ -308,6 +327,25 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) {
|
|||||||
|
|
||||||
// One-click warehouse-document bundle: GRN + gate clearance + handover.
|
// One-click warehouse-document bundle: GRN + gate clearance + handover.
|
||||||
const [bundleBusy, setBundleBusy] = useState(false);
|
const [bundleBusy, setBundleBusy] = useState(false);
|
||||||
|
|
||||||
|
// The carriage acceptance sheet is its own document, not warehouse paperwork:
|
||||||
|
// direct truck-to-train cargo never sees a warehouse, and this sheet IS its
|
||||||
|
// handover record. Hiding it inside the warehouse bundle made it unfindable.
|
||||||
|
const [casBusy, setCasBusy] = useState(false);
|
||||||
|
const downloadCarriageAcceptance = async () => {
|
||||||
|
setCasBusy(true);
|
||||||
|
const ref = booking.reference ?? booking.id;
|
||||||
|
try {
|
||||||
|
const blob = await bookingsService.downloadCarriageAcceptanceSheet(booking.id);
|
||||||
|
saveBlob(blob, `carriage-acceptance-${ref}.pdf`);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
await downloadErrorMessage(error, "Carriage acceptance sheet is not available yet."),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setCasBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
const downloadWarehouseDocuments = async () => {
|
const downloadWarehouseDocuments = async () => {
|
||||||
setBundleBusy(true);
|
setBundleBusy(true);
|
||||||
const ref = booking.reference ?? booking.id;
|
const ref = booking.reference ?? booking.id;
|
||||||
@@ -654,6 +692,24 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) {
|
|||||||
</SectionCard>
|
</SectionCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── Carriage acceptance sheet (its own document) ────────────────── */}
|
||||||
|
<SectionCard>
|
||||||
|
<CardTitle>Carriage acceptance sheet</CardTitle>
|
||||||
|
<Text fz="12.5px" c="dimmed" mt={4} mb="sm">
|
||||||
|
The record of the cargo EDR has accepted for carriage, listing each wagon and the
|
||||||
|
containers on it, and marking which have been loaded.
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
leftSection={<Download size={16} />}
|
||||||
|
color="edr-green"
|
||||||
|
variant="light"
|
||||||
|
loading={casBusy}
|
||||||
|
onClick={downloadCarriageAcceptance}
|
||||||
|
>
|
||||||
|
Download sheet
|
||||||
|
</Button>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
{/* ── Warehouse documents (one-click bundle) ──────────────────────── */}
|
{/* ── Warehouse documents (one-click bundle) ──────────────────────── */}
|
||||||
<SectionCard>
|
<SectionCard>
|
||||||
<CardTitle>Warehouse documents</CardTitle>
|
<CardTitle>Warehouse documents</CardTitle>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
ApiBody,
|
ApiBody,
|
||||||
} from "@nestjs/swagger";
|
} from "@nestjs/swagger";
|
||||||
import { IsPublic } from "@tria-plc/api-common/modules/auth/decorators/public.decorator";
|
import { IsPublic } from "@tria-plc/api-common/modules/auth/decorators/public.decorator";
|
||||||
import { BookingsService } from "./bookings.service";
|
import { BookingsService, BookingScope } from "./bookings.service";
|
||||||
import { GuestBookingService } from "./guest-booking.service";
|
import { GuestBookingService } from "./guest-booking.service";
|
||||||
import {
|
import {
|
||||||
CreateBookingDto,
|
CreateBookingDto,
|
||||||
@@ -38,6 +38,8 @@ import { PassengerAdmin, PassengerStaff, PassengerStaffStrict } from "../../comm
|
|||||||
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
|
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
|
||||||
import { SeatsService } from "../seats/seats.service";
|
import { SeatsService } from "../seats/seats.service";
|
||||||
|
|
||||||
|
const BOOKING_SCOPES: BookingScope[] = ["upcoming", "past", "cancelled", "all"];
|
||||||
|
|
||||||
@ApiTags("Booking")
|
@ApiTags("Booking")
|
||||||
@Controller("bookings")
|
@Controller("bookings")
|
||||||
// @Throttle({ strict: { limit: 20, ttl: 60_000 } })
|
// @Throttle({ strict: { limit: 20, ttl: 60_000 } })
|
||||||
@@ -66,6 +68,13 @@ export class BookingsController {
|
|||||||
required: false,
|
required: false,
|
||||||
description: "Filter by booking status",
|
description: "Filter by booking status",
|
||||||
})
|
})
|
||||||
|
@ApiQuery({
|
||||||
|
name: "scope",
|
||||||
|
required: false,
|
||||||
|
enum: ["upcoming", "past", "cancelled", "all"],
|
||||||
|
description:
|
||||||
|
"Which slice of the history to return. 'upcoming' and 'past' split on the schedule's departure and exclude cancelled/refunded bookings; 'cancelled' returns only those. Defaults to 'all'.",
|
||||||
|
})
|
||||||
@ApiQuery({ name: "page", required: false, description: "Page number" })
|
@ApiQuery({ name: "page", required: false, description: "Page number" })
|
||||||
@ApiQuery({
|
@ApiQuery({
|
||||||
name: "pageSize",
|
name: "pageSize",
|
||||||
@@ -80,6 +89,7 @@ export class BookingsController {
|
|||||||
@Req() req: any,
|
@Req() req: any,
|
||||||
@Query("search") search?: string,
|
@Query("search") search?: string,
|
||||||
@Query("status") status?: string,
|
@Query("status") status?: string,
|
||||||
|
@Query("scope") scope?: BookingScope,
|
||||||
@Query("page") page?: string,
|
@Query("page") page?: string,
|
||||||
@Query("pageSize") pageSize?: string,
|
@Query("pageSize") pageSize?: string,
|
||||||
) {
|
) {
|
||||||
@@ -88,6 +98,7 @@ export class BookingsController {
|
|||||||
return this.service.findByIamUserId(iamUserId, {
|
return this.service.findByIamUserId(iamUserId, {
|
||||||
search,
|
search,
|
||||||
status,
|
status,
|
||||||
|
scope: BOOKING_SCOPES.includes(scope as BookingScope) ? scope : "all",
|
||||||
page: page ? parseInt(page) : 1,
|
page: page ? parseInt(page) : 1,
|
||||||
pageSize: pageSize ? parseInt(pageSize) : 20,
|
pageSize: pageSize ? parseInt(pageSize) : 20,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { FareEngineService } from '../fare-engine/fare-engine.service';
|
|||||||
import { PaymentsService } from '../payments/payments.service';
|
import { PaymentsService } from '../payments/payments.service';
|
||||||
import { MAX_PAYMENT_HOURS } from '../../common/utils/payment-deadline.utils';
|
import { MAX_PAYMENT_HOURS } from '../../common/utils/payment-deadline.utils';
|
||||||
import { normalizePhoneVariants } from '../../common/utils/phone.utils';
|
import { normalizePhoneVariants } from '../../common/utils/phone.utils';
|
||||||
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
|
import { BookingStatus, Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
|
||||||
import { JourneyDirection } from '../seats/seats.dto';
|
import { JourneyDirection } from '../seats/seats.dto';
|
||||||
import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto';
|
import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto';
|
||||||
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
|
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
|
||||||
@@ -67,8 +67,15 @@ interface BookingFilters {
|
|||||||
dateTo?: string;
|
dateTo?: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
|
/** Portal "My bookings" tabs. Only honoured by findByPassengerId. */
|
||||||
|
scope?: BookingScope;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type BookingScope = 'upcoming' | 'past' | 'cancelled' | 'all';
|
||||||
|
|
||||||
|
/** Statuses that mean the reservation is off — used by the `cancelled` scope. */
|
||||||
|
const CLOSED_BOOKING_STATUSES: BookingStatus[] = ['CANCELLED', 'REFUNDED'];
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BookingsService {
|
export class BookingsService {
|
||||||
private readonly logger = new Logger(BookingsService.name);
|
private readonly logger = new Logger(BookingsService.name);
|
||||||
@@ -87,16 +94,34 @@ export class BookingsService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findByIamUserId(iamUserId: string, filters: BookingFilters = {}) {
|
async findByIamUserId(iamUserId: string, filters: BookingFilters = {}) {
|
||||||
const passenger = await this.prisma.passenger.findUniqueOrThrow({ where: { iamUserId }, select: { id: true } });
|
// An IAM user with no Passenger row is normal, not an error: a freshly registered
|
||||||
|
// account that has never booked, or a staff account. findUniqueOrThrow raised P2025
|
||||||
|
// here, which surfaced as a 500 on the portal's "My bookings" page. Empty page instead.
|
||||||
|
const passenger = await this.prisma.passenger.findUnique({ where: { iamUserId }, select: { id: true } });
|
||||||
|
if (!passenger) {
|
||||||
|
const page = filters.page ?? 1;
|
||||||
|
const pageSize = filters.pageSize ?? 20;
|
||||||
|
return { items: [], meta: { page, pageSize, total: 0, totalPages: 0 } };
|
||||||
|
}
|
||||||
return this.findByPassengerId(passenger.id, filters);
|
return this.findByPassengerId(passenger.id, filters);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The portal's authenticated "My bookings" history (GET /bookings/my).
|
||||||
|
*
|
||||||
|
* `scope` drives the Upcoming / Past / Cancelled tabs server-side so each tab paginates
|
||||||
|
* correctly, rather than the client filtering one page at a time. Note it filters on
|
||||||
|
* `schedule.departureAt` — the schedule's own origin departure — while each item's
|
||||||
|
* displayed `departureAt` comes from resolveBookingSegment, i.e. the passenger's own
|
||||||
|
* boarding stop. They differ by the run time to that stop; that is close enough for a
|
||||||
|
* tab filter and avoids a correlated stopTimes query per row.
|
||||||
|
*/
|
||||||
async findByPassengerId(passengerId: string, filters: BookingFilters = {}) {
|
async findByPassengerId(passengerId: string, filters: BookingFilters = {}) {
|
||||||
const { search, status, page = 1, pageSize = 20 } = filters;
|
const { search, status, scope = 'all', page = 1, pageSize = 20 } = filters;
|
||||||
const skip = (page - 1) * pageSize;
|
const skip = (page - 1) * pageSize;
|
||||||
|
|
||||||
const where: any = { passengerId };
|
const where: any = { passengerId };
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
where.OR = [
|
where.OR = [
|
||||||
{ bookingRef: { contains: search, mode: 'insensitive' } },
|
{ bookingRef: { contains: search, mode: 'insensitive' } },
|
||||||
@@ -104,22 +129,41 @@ export class BookingsService {
|
|||||||
{ schedule: { destinationStation: { name: { contains: search, mode: 'insensitive' } } } },
|
{ schedule: { destinationStation: { name: { contains: search, mode: 'insensitive' } } } },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status) {
|
// `status` used to be forwarded raw, so an unrecognised value threw a Prisma
|
||||||
|
// validation error (a 500) rather than being ignored. Only accept real enum members.
|
||||||
|
if (status && (Object.values(BookingStatus) as string[]).includes(status)) {
|
||||||
where.status = status;
|
where.status = status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
let orderBy: any = { createdAt: 'desc' };
|
||||||
|
if (scope === 'cancelled') {
|
||||||
|
where.status = { in: CLOSED_BOOKING_STATUSES };
|
||||||
|
} else if (scope === 'upcoming' || scope === 'past') {
|
||||||
|
// Don't clobber an explicit `status` filter — intersect with it.
|
||||||
|
if (!where.status) where.status = { notIn: CLOSED_BOOKING_STATUSES };
|
||||||
|
where.schedule = {
|
||||||
|
...(where.schedule ?? {}),
|
||||||
|
departureAt: scope === 'upcoming' ? { gte: now } : { lt: now },
|
||||||
|
};
|
||||||
|
orderBy = { schedule: { departureAt: scope === 'upcoming' ? 'asc' : 'desc' } };
|
||||||
|
}
|
||||||
|
|
||||||
const [items, total] = await Promise.all([
|
const [items, total] = await Promise.all([
|
||||||
this.prisma.booking.findMany({
|
this.prisma.booking.findMany({
|
||||||
where,
|
where,
|
||||||
skip,
|
skip,
|
||||||
take: pageSize,
|
take: pageSize,
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy,
|
||||||
include: {
|
include: {
|
||||||
schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
|
schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
|
||||||
paymentIntent: true,
|
paymentIntent: true,
|
||||||
seats: { include: { seat: true } },
|
seats: { include: { seat: { include: { coach: { select: { number: true } } } } } },
|
||||||
priceTier: { select: { priceMinor: true } },
|
priceTier: { select: { priceMinor: true } },
|
||||||
|
// A rescheduled booking stays CONFIRMED — there is no RESCHEDULED status — so the
|
||||||
|
// portal needs this to show a "Rescheduled" chip alongside the real status.
|
||||||
|
reschedules: { where: { status: 'APPLIED' }, select: { id: true } },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
this.prisma.booking.count({ where }),
|
this.prisma.booking.count({ where }),
|
||||||
@@ -150,6 +194,21 @@ export class BookingsService {
|
|||||||
},
|
},
|
||||||
paymentIntent: booking.paymentIntent,
|
paymentIntent: booking.paymentIntent,
|
||||||
seatCount: booking.seats.length,
|
seatCount: booking.seats.length,
|
||||||
|
// Seat/coach per passenger, so the history table can show a Seat / Coach column
|
||||||
|
// without a round trip to GET /bookings/:ref for every row. `leg` disambiguates
|
||||||
|
// outbound (1) from return (2) on a round trip.
|
||||||
|
seats: booking.seats.map((bs: any) => ({
|
||||||
|
leg: bs.leg ?? 1,
|
||||||
|
passengerName: bs.passengerName,
|
||||||
|
seatNumber: bs.seat?.seatNumber ?? null,
|
||||||
|
coachNumber: bs.seat?.coach?.number ?? null,
|
||||||
|
})),
|
||||||
|
rescheduled: ((booking as any).reschedules?.length ?? 0) > 0,
|
||||||
|
// These three let the portal apply the same coarse reschedule gate the booking
|
||||||
|
// detail page uses, without fetching each booking in full.
|
||||||
|
outboundBoardedAt: (booking as any).outboundBoardedAt ?? null,
|
||||||
|
isPackageBooking: !!(booking as any).packageId,
|
||||||
|
contactPhone: (booking as any).contactPhone ?? null,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
meta: {
|
meta: {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useState } from "react";
|
|||||||
import { apiClient } from "@/lib/api-client";
|
import { apiClient } from "@/lib/api-client";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { toZonedDate } from "@/utils/format";
|
import { toZonedDate } from "@/utils/format";
|
||||||
|
import { STATUS_LABELS } from "@/lib/api/bookings";
|
||||||
|
|
||||||
type SearchMode = "pnr" | "phone";
|
type SearchMode = "pnr" | "phone";
|
||||||
|
|
||||||
@@ -30,15 +31,6 @@ interface BookingListItem {
|
|||||||
seatCount: number;
|
seatCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, { label: string; className: string }> = {
|
|
||||||
CONFIRMED: { label: "Confirmed", className: "bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300" },
|
|
||||||
PENDING_PAYMENT: { label: "Pending Payment", className: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-300" },
|
|
||||||
CANCELLED: { label: "Cancelled", className: "bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300" },
|
|
||||||
BOARDED: { label: "Boarded", className: "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300" },
|
|
||||||
NO_SHOW: { label: "No Show", className: "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300" },
|
|
||||||
REFUNDED: { label: "Refunded", className: "bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300" },
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function BookingLookupPage() {
|
export default function BookingLookupPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [mode, setMode] = useState<SearchMode>("pnr");
|
const [mode, setMode] = useState<SearchMode>("pnr");
|
||||||
|
|||||||
62
apps/edr-passenger-web/portal/src/app/bookings/page.tsx
Normal file
62
apps/edr-passenger-web/portal/src/app/bookings/page.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { Loader2, Search } from 'lucide-react';
|
||||||
|
import { useAuthStore } from '@/lib/auth-store';
|
||||||
|
import MyBookingsTable from '@/components/MyBookingsTable';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "My Bookings" for a signed-in customer — every booking on the account, without the
|
||||||
|
* BRN / phone lookup a guest has to go through at /booking/lookup.
|
||||||
|
*
|
||||||
|
* The portal's middleware does no auth gating (it only sets the CSP nonce), so pages
|
||||||
|
* self-check. Same shape as /profile and /booking/reschedule.
|
||||||
|
*/
|
||||||
|
export default function MyBookingsPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { isAuthenticated, isInitialized, initialize } = useAuthStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
initialize();
|
||||||
|
}, [initialize]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isInitialized && !isAuthenticated) {
|
||||||
|
router.push('/login?redirect=/bookings');
|
||||||
|
}
|
||||||
|
}, [isInitialized, isAuthenticated, router]);
|
||||||
|
|
||||||
|
if (!isInitialized || !isAuthenticated) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-[60vh] flex items-center justify-center">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-[rgb(20,113,76)]" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-6xl mx-auto px-4 py-8">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3 mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">My Bookings</h1>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
Every trip booked on this account.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/* A customer can still hold a booking made under a different phone number as a
|
||||||
|
guest — that one is only reachable by reference, so keep the door open. */}
|
||||||
|
<Link
|
||||||
|
href="/booking/lookup"
|
||||||
|
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
<Search className="w-4 h-4" />
|
||||||
|
Look up another booking
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MyBookingsTable />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,32 +4,19 @@ import { useState, useEffect } from 'react';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useAuthStore } from '@/lib/auth-store';
|
import { useAuthStore } from '@/lib/auth-store';
|
||||||
import { useTheme } from '@/components/ThemeProvider';
|
import { useTheme } from '@/components/ThemeProvider';
|
||||||
import {
|
import {
|
||||||
User, Settings, Ticket, Calendar, MapPin,
|
User, Settings, Ticket,
|
||||||
Download, Trash2, Lock, Bell, CreditCard,
|
Download, Trash2, Lock, Bell,
|
||||||
MapPinned, Palette, CheckCircle,
|
MapPinned, Palette, CheckCircle,
|
||||||
Eye, Edit, LogOut, X
|
Edit, LogOut, X
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
import { useMutation } from '@tanstack/react-query';
|
||||||
import CustomModal from '@/components/CustomModal';
|
import CustomModal from '@/components/CustomModal';
|
||||||
|
import MyBookingsTable from '@/components/MyBookingsTable';
|
||||||
|
|
||||||
type Tab = 'bookings' | 'profile' | 'settings';
|
type Tab = 'bookings' | 'profile' | 'settings';
|
||||||
|
|
||||||
interface Booking {
|
|
||||||
id: string;
|
|
||||||
pnr: string;
|
|
||||||
status: string;
|
|
||||||
totalMinor: number;
|
|
||||||
createdAt: string;
|
|
||||||
trip?: {
|
|
||||||
trainNumber: string;
|
|
||||||
departureAt: string;
|
|
||||||
origin?: { name: string };
|
|
||||||
destination?: { name: string };
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ProfilePage() {
|
export default function ProfilePage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { user, isAuthenticated, logout, initialize, updateUser, isInitialized, fetchProfile } = useAuthStore();
|
const { user, isAuthenticated, logout, initialize, updateUser, isInitialized, fetchProfile } = useAuthStore();
|
||||||
@@ -82,18 +69,6 @@ export default function ProfilePage() {
|
|||||||
}
|
}
|
||||||
}, [isInitialized, isAuthenticated, user, router, fetchProfile]);
|
}, [isInitialized, isAuthenticated, user, router, fetchProfile]);
|
||||||
|
|
||||||
const { data: bookings, isLoading: loadingBookings } = useQuery({
|
|
||||||
queryKey: ['user-bookings'],
|
|
||||||
queryFn: async () => {
|
|
||||||
try {
|
|
||||||
return await apiClient.get('/bookings/my-bookings');
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
},
|
|
||||||
enabled: isAuthenticated && activeTab === 'bookings',
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateProfileMutation = useMutation({
|
const updateProfileMutation = useMutation({
|
||||||
mutationFn: (data: any) => apiClient.patch('/auth/profile', data),
|
mutationFn: (data: any) => apiClient.patch('/auth/profile', data),
|
||||||
onSuccess: (response) => {
|
onSuccess: (response) => {
|
||||||
@@ -229,16 +204,6 @@ export default function ProfilePage() {
|
|||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusBadge = (status: string) => {
|
|
||||||
const styles = {
|
|
||||||
CONFIRMED: 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300',
|
|
||||||
PENDING: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300',
|
|
||||||
CANCELLED: 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300',
|
|
||||||
COMPLETED: 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300',
|
|
||||||
};
|
|
||||||
return styles[status as keyof typeof styles] || styles.PENDING;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isInitialized || !user) {
|
if (!isInitialized || !user) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
|
||||||
@@ -322,71 +287,11 @@ export default function ProfilePage() {
|
|||||||
{activeTab === 'bookings' && (
|
{activeTab === 'bookings' && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">Bookings</h2>
|
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">Bookings</h2>
|
||||||
|
{/* Same component as /bookings, so the two never drift. It replaces a card
|
||||||
{loadingBookings ? (
|
list that called GET /bookings/my-bookings — a route that does not exist
|
||||||
<div className="card text-center py-12">
|
(the real one is GET /bookings/my), whose 404 was swallowed, so this tab
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[rgb(20_113_76)] mx-auto"></div>
|
always read "No bookings yet". */}
|
||||||
<p className="text-gray-600 dark:text-gray-400 mt-4">Loading bookings...</p>
|
<MyBookingsTable />
|
||||||
</div>
|
|
||||||
) : bookings && Array.isArray(bookings) && bookings.length > 0 ? (
|
|
||||||
bookings.map((booking: Booking) => (
|
|
||||||
<div key={booking.id} className="card hover:shadow-lg transition-shadow">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center gap-3 mb-3">
|
|
||||||
<span className={`badge ${getStatusBadge(booking.status)}`}>
|
|
||||||
{booking.status}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
|
||||||
PNR: {booking.pnr}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid md:grid-cols-3 gap-4 text-sm">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Calendar className="w-4 h-4 text-gray-400" />
|
|
||||||
<span className="text-gray-600 dark:text-gray-400">
|
|
||||||
{booking.trip?.departureAt
|
|
||||||
? new Date(booking.trip.departureAt).toLocaleDateString('en-US', { timeZone: 'Africa/Addis_Ababa' })
|
|
||||||
: 'N/A'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<MapPin className="w-4 h-4 text-gray-400" />
|
|
||||||
<span className="text-gray-600 dark:text-gray-400">
|
|
||||||
{booking.trip?.origin?.name} → {booking.trip?.destination?.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<CreditCard className="w-4 h-4 text-gray-400" />
|
|
||||||
<span className="text-gray-900 dark:text-gray-100 font-semibold">
|
|
||||||
ETB {((booking.totalMinor || 0) / 100).toFixed(2)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 ml-4">
|
|
||||||
<button
|
|
||||||
onClick={() => router.push(`/booking/confirmation?id=${booking.id}`)}
|
|
||||||
className="btn-secondary text-sm flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Eye className="w-4 h-4" />
|
|
||||||
View
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div className="card text-center py-12">
|
|
||||||
<Ticket className="w-16 h-16 text-gray-300 dark:text-gray-600 mx-auto mb-4" />
|
|
||||||
<p className="text-gray-600 dark:text-gray-400 mb-4">No bookings yet</p>
|
|
||||||
<button onClick={() => router.push('/booking/search')} className="btn-primary">
|
|
||||||
Book Your First Trip
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -39,9 +39,12 @@ const BOOKING_STEP_MAP: Record<string, string> = {
|
|||||||
'/booking/confirmation': 'confirmation',
|
'/booking/confirmation': 'confirmation',
|
||||||
};
|
};
|
||||||
|
|
||||||
const NAV_LINKS = [
|
// "My Bookings" resolves differently by session: a signed-in customer gets their own
|
||||||
|
// account history at /bookings, a guest gets the BRN / phone lookup form. Same label
|
||||||
|
// either way, because it is the same intent.
|
||||||
|
const navLinks = (isAuthenticated: boolean) => [
|
||||||
{ href: '/', label: 'Home', icon: Home },
|
{ href: '/', label: 'Home', icon: Home },
|
||||||
{ href: '/booking/lookup', label: 'My Bookings', icon: Ticket },
|
{ href: isAuthenticated ? '/bookings' : '/booking/lookup', label: 'My Bookings', icon: Ticket },
|
||||||
{ href: '/contact', label: 'Contact', icon: Phone },
|
{ href: '/contact', label: 'Contact', icon: Phone },
|
||||||
{ href: '/help', label: 'Help', icon: HelpCircle },
|
{ href: '/help', label: 'Help', icon: HelpCircle },
|
||||||
];
|
];
|
||||||
@@ -83,7 +86,7 @@ export default function AppSidebar() {
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<nav className="flex-1 overflow-y-auto px-3 py-4 space-y-1">
|
<nav className="flex-1 overflow-y-auto px-3 py-4 space-y-1">
|
||||||
{NAV_LINKS.map(({ href, label, icon: Icon }) => {
|
{navLinks(isAuthenticated).map(({ href, label, icon: Icon }) => {
|
||||||
const isActive = href === '/' ? pathname === '/' : pathname?.startsWith(href);
|
const isActive = href === '/' ? pathname === '/' : pathname?.startsWith(href);
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -28,7 +28,14 @@ export default function BottomTabBar() {
|
|||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ href: '/', label: 'Home', icon: Home, match: (p: string) => p === '/' },
|
{ href: '/', label: 'Home', icon: Home, match: (p: string) => p === '/' },
|
||||||
{ href: '/booking/lookup', label: 'Bookings', icon: Ticket, match: (p: string) => p.startsWith('/booking/lookup') || p.startsWith('/booking/detail') },
|
// Signed in: straight to the account's own history. Signed out: the guest lookup form.
|
||||||
|
{
|
||||||
|
href: isAuthenticated ? '/bookings' : '/booking/lookup',
|
||||||
|
label: 'Bookings',
|
||||||
|
icon: Ticket,
|
||||||
|
match: (p: string) =>
|
||||||
|
p.startsWith('/bookings') || p.startsWith('/booking/lookup') || p.startsWith('/booking/detail'),
|
||||||
|
},
|
||||||
{ href: '/contact', label: 'Contact', icon: Phone, match: (p: string) => p.startsWith('/contact') },
|
{ href: '/contact', label: 'Contact', icon: Phone, match: (p: string) => p.startsWith('/contact') },
|
||||||
{
|
{
|
||||||
href: isAuthenticated ? '/profile' : '/login',
|
href: isAuthenticated ? '/profile' : '/login',
|
||||||
|
|||||||
379
apps/edr-passenger-web/portal/src/components/MyBookingsTable.tsx
Normal file
379
apps/edr-passenger-web/portal/src/components/MyBookingsTable.tsx
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useQuery, keepPreviousData } from '@tanstack/react-query';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import {
|
||||||
|
Ticket,
|
||||||
|
Loader2,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Clock,
|
||||||
|
Eye,
|
||||||
|
CreditCard,
|
||||||
|
RefreshCw,
|
||||||
|
AlertCircle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { toZonedDate } from '@/utils/format';
|
||||||
|
import { samePhone } from '@/utils/phone';
|
||||||
|
import { useAuthStore } from '@/lib/auth-store';
|
||||||
|
import {
|
||||||
|
fetchMyBookings,
|
||||||
|
statusBadge,
|
||||||
|
type BookingScope,
|
||||||
|
type MyBookingItem,
|
||||||
|
} from '@/lib/api/bookings';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
|
const SCOPES: { id: BookingScope; label: string }[] = [
|
||||||
|
{ id: 'upcoming', label: 'Upcoming' },
|
||||||
|
{ id: 'past', label: 'Past' },
|
||||||
|
{ id: 'cancelled', label: 'Cancelled' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const EMPTY_COPY: Record<BookingScope, string> = {
|
||||||
|
upcoming: 'No upcoming trips.',
|
||||||
|
past: 'No past trips yet.',
|
||||||
|
cancelled: 'No cancelled bookings.',
|
||||||
|
all: 'No bookings yet.',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatTravelDate(iso: string) {
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return '—';
|
||||||
|
return format(toZonedDate(d), 'dd MMM yyyy, HH:mm');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "CRS-0002 · 12, 14" for one leg. Round trips carry leg-2 seats on the same booking.
|
||||||
|
* Coach.number is already a full code on this data (e.g. "CRS-0002"), so it is printed
|
||||||
|
* as-is rather than prefixed.
|
||||||
|
*/
|
||||||
|
function describeSeats(seats: MyBookingItem['seats'], leg: number) {
|
||||||
|
const forLeg = (seats ?? []).filter((s) => (s.leg ?? 1) === leg);
|
||||||
|
const numbers = forLeg.map((s) => s.seatNumber).filter(Boolean);
|
||||||
|
if (numbers.length === 0) return null;
|
||||||
|
const coaches = Array.from(new Set(forLeg.map((s) => s.coachNumber).filter(Boolean)));
|
||||||
|
const coachLabel = coaches.length > 0 ? `${coaches.join(' / ')} · ` : '';
|
||||||
|
return `${coachLabel}${numbers.join(', ')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RowActions {
|
||||||
|
canReschedule: boolean;
|
||||||
|
rescheduleBlocker: string | null;
|
||||||
|
isPendingPayment: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The coarse reschedule gate, mirroring booking/detail/page.tsx. The per-leg rules
|
||||||
|
* (fare-class policy, cutoff, seats still free) belong to the reschedule page, which
|
||||||
|
* names them as blockers — this only avoids sending the customer somewhere that is
|
||||||
|
* certain to reject them. The phone test matches the API's own ownership check
|
||||||
|
* (reschedule.service.ts loadOwnedBooking), which is phone-based, not account-based.
|
||||||
|
*/
|
||||||
|
function resolveActions(b: MyBookingItem, userPhone?: string): RowActions {
|
||||||
|
const isPendingPayment = b.status === 'PENDING_PAYMENT' || b.status === 'DRAFT';
|
||||||
|
|
||||||
|
let rescheduleBlocker: string | null = null;
|
||||||
|
if (b.status !== 'CONFIRMED') rescheduleBlocker = 'Only a confirmed booking can be rescheduled';
|
||||||
|
else if (b.isPackageBooking) rescheduleBlocker = 'Package bookings cannot be rescheduled online';
|
||||||
|
else if (!['ONE_WAY', 'ROUND_TRIP'].includes(b.bookingType))
|
||||||
|
rescheduleBlocker = 'Transit bookings cannot be rescheduled online';
|
||||||
|
else if (b.outboundBoardedAt) rescheduleBlocker = 'This trip has already been boarded';
|
||||||
|
else if (b.contactPhone && !samePhone(userPhone, b.contactPhone))
|
||||||
|
rescheduleBlocker = 'Only the person who made this booking can reschedule it';
|
||||||
|
|
||||||
|
return { canReschedule: rescheduleBlocker === null, rescheduleBlocker, isPendingPayment };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The signed-in customer's own booking history, from GET /bookings/my — no BRN or
|
||||||
|
* phone lookup. Rendered by /bookings and by the /profile Bookings tab, so both stay
|
||||||
|
* one implementation.
|
||||||
|
*/
|
||||||
|
export default function MyBookingsTable() {
|
||||||
|
const router = useRouter();
|
||||||
|
const userPhone = useAuthStore((s) => s.user?.phone);
|
||||||
|
const [scope, setScope] = useState<BookingScope>('upcoming');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
|
const { data, isLoading, isError, refetch, isFetching } = useQuery({
|
||||||
|
queryKey: ['my-bookings', scope, page],
|
||||||
|
queryFn: () => fetchMyBookings({ scope, page, pageSize: PAGE_SIZE }),
|
||||||
|
// Keeps the current rows on screen while a tab or page change is in flight instead
|
||||||
|
// of collapsing to the spinner and jumping the scroll position.
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const items = data?.items ?? [];
|
||||||
|
const meta = data?.meta;
|
||||||
|
const totalPages = meta?.totalPages ?? 0;
|
||||||
|
|
||||||
|
const switchScope = (next: BookingScope) => {
|
||||||
|
setScope(next);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openDetail = (b: MyBookingItem) => router.push(`/booking/detail?ref=${b.bookingRef}`);
|
||||||
|
const openReschedule = (b: MyBookingItem) => router.push(`/booking/reschedule?ref=${b.bookingRef}`);
|
||||||
|
|
||||||
|
const cardClass =
|
||||||
|
'bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Scope tabs */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2 mb-5">
|
||||||
|
{SCOPES.map(({ id, label }) => (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
onClick={() => switchScope(id)}
|
||||||
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
scope === id
|
||||||
|
? 'bg-[rgb(20,113,76)] text-white'
|
||||||
|
: 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{isFetching && !isLoading && (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin text-gray-400" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className={`${cardClass} py-16 text-center`}>
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-[rgb(20,113,76)] mx-auto" />
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mt-3 text-sm">Loading your bookings…</p>
|
||||||
|
</div>
|
||||||
|
) : isError ? (
|
||||||
|
<div className={`${cardClass} py-16 text-center`}>
|
||||||
|
<AlertCircle className="w-10 h-10 text-red-400 mx-auto mb-3" />
|
||||||
|
<p className="text-gray-700 dark:text-gray-300 mb-4 text-sm">
|
||||||
|
We could not load your bookings just now.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => refetch()}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 text-sm font-medium"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className={`${cardClass} py-16 text-center`}>
|
||||||
|
<Ticket className="w-14 h-14 text-gray-300 dark:text-gray-600 mx-auto mb-4" />
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-4">{EMPTY_COPY[scope]}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/booking/search')}
|
||||||
|
className="px-4 py-2 rounded-lg bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Book a trip
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Desktop: the tabular view */}
|
||||||
|
<div className={`hidden lg:block ${cardClass} overflow-x-auto`}>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200 dark:border-gray-700 text-left text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||||
|
<th scope="col" className="px-4 py-3 font-semibold">Booking Ref</th>
|
||||||
|
<th scope="col" className="px-4 py-3 font-semibold">Travel date & time</th>
|
||||||
|
<th scope="col" className="px-4 py-3 font-semibold">Route</th>
|
||||||
|
<th scope="col" className="px-4 py-3 font-semibold">Seat / Coach</th>
|
||||||
|
<th scope="col" className="px-4 py-3 font-semibold">Status</th>
|
||||||
|
<th scope="col" className="px-4 py-3 font-semibold text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((b) => {
|
||||||
|
const badge = statusBadge(b.status);
|
||||||
|
const actions = resolveActions(b, userPhone);
|
||||||
|
const outbound = describeSeats(b.seats, 1);
|
||||||
|
const inbound = describeSeats(b.seats, 2);
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={b.id}
|
||||||
|
className="border-b border-gray-100 dark:border-gray-700/60 last:border-0 hover:bg-gray-50 dark:hover:bg-gray-700/40 transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3 font-mono font-semibold tracking-wider text-gray-900 dark:text-white whitespace-nowrap">
|
||||||
|
{b.bookingRef}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-gray-700 dark:text-gray-300 whitespace-nowrap">
|
||||||
|
{formatTravelDate(b.schedule.departureAt)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-gray-700 dark:text-gray-300">
|
||||||
|
{b.schedule.originStation.name} → {b.schedule.destinationStation.name}
|
||||||
|
{b.bookingType === 'ROUND_TRIP' && (
|
||||||
|
<span className="ml-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
(round trip)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-gray-700 dark:text-gray-300 whitespace-nowrap">
|
||||||
|
{outbound ?? <span className="text-gray-400">—</span>}
|
||||||
|
{inbound && (
|
||||||
|
<span className="block text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Return: {inbound}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 whitespace-nowrap">
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${badge.className}`}>
|
||||||
|
{badge.label}
|
||||||
|
</span>
|
||||||
|
{b.rescheduled && (
|
||||||
|
<span className="ml-1.5 text-xs px-2 py-0.5 rounded-full font-medium bg-indigo-100 text-indigo-800 dark:bg-indigo-900/40 dark:text-indigo-300">
|
||||||
|
Rescheduled
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => openDetail(b)}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 text-xs font-medium transition-colors whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{actions.isPendingPayment ? (
|
||||||
|
<>
|
||||||
|
<CreditCard className="w-3.5 h-3.5" />
|
||||||
|
Complete payment
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Eye className="w-3.5 h-3.5" />
|
||||||
|
View ticket
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{!actions.isPendingPayment && (
|
||||||
|
<button
|
||||||
|
onClick={() => openReschedule(b)}
|
||||||
|
disabled={!actions.canReschedule}
|
||||||
|
title={
|
||||||
|
actions.rescheduleBlocker ??
|
||||||
|
'Change the date, train or seats on this booking'
|
||||||
|
}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed text-xs font-medium transition-colors whitespace-nowrap"
|
||||||
|
>
|
||||||
|
<Clock className="w-3.5 h-3.5" />
|
||||||
|
Reschedule
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile: the same rows as cards — the portal's established list pattern */}
|
||||||
|
<div className="lg:hidden space-y-3">
|
||||||
|
{items.map((b) => {
|
||||||
|
const badge = statusBadge(b.status);
|
||||||
|
const actions = resolveActions(b, userPhone);
|
||||||
|
const outbound = describeSeats(b.seats, 1);
|
||||||
|
const inbound = describeSeats(b.seats, 2);
|
||||||
|
return (
|
||||||
|
<div key={b.id} className={`${cardClass} p-4`}>
|
||||||
|
<div className="flex items-start justify-between gap-2 mb-2">
|
||||||
|
<span className="font-mono font-bold tracking-wider text-gray-900 dark:text-white">
|
||||||
|
{b.bookingRef}
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-wrap justify-end gap-1">
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${badge.className}`}>
|
||||||
|
{badge.label}
|
||||||
|
</span>
|
||||||
|
{b.rescheduled && (
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded-full font-medium bg-indigo-100 text-indigo-800 dark:bg-indigo-900/40 dark:text-indigo-300">
|
||||||
|
Rescheduled
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
{b.schedule.originStation.name} → {b.schedule.destinationStation.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||||
|
{formatTravelDate(b.schedule.departureAt)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||||
|
Seat / Coach: {outbound ?? '—'}
|
||||||
|
{inbound && ` · Return: ${inbound}`}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2 mt-3">
|
||||||
|
<button
|
||||||
|
onClick={() => openDetail(b)}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 text-xs font-medium"
|
||||||
|
>
|
||||||
|
{actions.isPendingPayment ? (
|
||||||
|
<>
|
||||||
|
<CreditCard className="w-3.5 h-3.5" />
|
||||||
|
Complete payment
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Eye className="w-3.5 h-3.5" />
|
||||||
|
View ticket
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{!actions.isPendingPayment && (
|
||||||
|
<button
|
||||||
|
onClick={() => openReschedule(b)}
|
||||||
|
disabled={!actions.canReschedule}
|
||||||
|
title={
|
||||||
|
actions.rescheduleBlocker ??
|
||||||
|
'Change the date, train or seats on this booking'
|
||||||
|
}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 disabled:opacity-40 disabled:cursor-not-allowed text-xs font-medium"
|
||||||
|
>
|
||||||
|
<Clock className="w-3.5 h-3.5" />
|
||||||
|
Reschedule
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between gap-3 mt-4">
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Page {meta?.page ?? page} of {totalPages} · {meta?.total ?? items.length} booking
|
||||||
|
{(meta?.total ?? items.length) !== 1 ? 's' : ''}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={page <= 1}
|
||||||
|
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed text-xs font-medium"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed text-xs font-medium"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
apps/edr-passenger-web/portal/src/lib/api/bookings.ts
Normal file
94
apps/edr-passenger-web/portal/src/lib/api/bookings.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The authenticated booking history: GET /bookings/my (JwtGuard). This is the
|
||||||
|
* account-linked list — no BRN or phone lookup — as opposed to the public
|
||||||
|
* /bookings/by-phone and /bookings/:bookingRef used by the guest lookup page.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type BookingScope = 'upcoming' | 'past' | 'cancelled' | 'all';
|
||||||
|
|
||||||
|
export interface MyBookingSeat {
|
||||||
|
leg: number;
|
||||||
|
passengerName: string;
|
||||||
|
seatNumber: string | null;
|
||||||
|
coachNumber: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MyBookingItem {
|
||||||
|
id: string;
|
||||||
|
bookingRef: string;
|
||||||
|
status: string;
|
||||||
|
totalMinor: number;
|
||||||
|
currency?: string | null;
|
||||||
|
displayCurrency?: string | null;
|
||||||
|
displayTotalMinor?: number | null;
|
||||||
|
adultCount: number;
|
||||||
|
childCount: number;
|
||||||
|
bookingType: string;
|
||||||
|
returnLegStatus?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
schedule: {
|
||||||
|
train?: { number?: string; name?: string } | null;
|
||||||
|
originStation: { id?: string; name: string; code?: string; city?: string };
|
||||||
|
destinationStation: { id?: string; name: string; code?: string; city?: string };
|
||||||
|
departureAt: string;
|
||||||
|
arrivalAt?: string | null;
|
||||||
|
};
|
||||||
|
paymentIntent?: { method?: string; status?: string } | null;
|
||||||
|
seatCount: number;
|
||||||
|
seats: MyBookingSeat[];
|
||||||
|
rescheduled: boolean;
|
||||||
|
outboundBoardedAt: string | null;
|
||||||
|
isPackageBooking: boolean;
|
||||||
|
contactPhone: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MyBookingsResponse {
|
||||||
|
items: MyBookingItem[];
|
||||||
|
meta: { page: number; pageSize: number; total: number; totalPages: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_META = { page: 1, pageSize: 20, total: 0, totalPages: 0 };
|
||||||
|
|
||||||
|
export async function fetchMyBookings(params: {
|
||||||
|
scope?: BookingScope;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
search?: string;
|
||||||
|
}): Promise<MyBookingsResponse> {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (params.scope) query.set('scope', params.scope);
|
||||||
|
if (params.page) query.set('page', String(params.page));
|
||||||
|
if (params.pageSize) query.set('pageSize', String(params.pageSize));
|
||||||
|
if (params.search?.trim()) query.set('search', params.search.trim());
|
||||||
|
|
||||||
|
// apiClient.get already unwraps `response.data?.data || response.data`, but the API
|
||||||
|
// has been seen to return both shapes for list endpoints — mirror the defensive read
|
||||||
|
// the guest lookup page uses for /bookings/by-phone.
|
||||||
|
const resp: any = await apiClient.get(`/bookings/my?${query.toString()}`);
|
||||||
|
return {
|
||||||
|
items: resp?.items ?? resp?.data?.items ?? [],
|
||||||
|
meta: resp?.meta ?? resp?.data?.meta ?? { ...EMPTY_META, pageSize: params.pageSize ?? 20 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Badge presentation for `BookingStatus`. The enum has no COMPLETED and no
|
||||||
|
* RESCHEDULED member (schema.prisma) — BOARDED is what "travelled" looks like, and a
|
||||||
|
* rescheduled booking stays CONFIRMED, so that is shown as a separate chip.
|
||||||
|
* Shared with the guest lookup page so the two lists cannot drift.
|
||||||
|
*/
|
||||||
|
export const STATUS_LABELS: Record<string, { label: string; className: string }> = {
|
||||||
|
CONFIRMED: { label: 'Confirmed', className: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300' },
|
||||||
|
PENDING_PAYMENT: { label: 'Pending Payment', className: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-300' },
|
||||||
|
DRAFT: { label: 'Draft', className: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300' },
|
||||||
|
CANCELLED: { label: 'Cancelled', className: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300' },
|
||||||
|
BOARDED: { label: 'Completed', className: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300' },
|
||||||
|
NO_SHOW: { label: 'No Show', className: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300' },
|
||||||
|
REFUNDED: { label: 'Refunded', className: 'bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function statusBadge(status: string) {
|
||||||
|
return STATUS_LABELS[status] ?? { label: status, className: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300' };
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
// Routes that should NOT redirect to home on hard refresh
|
// Routes that should NOT redirect to home on hard refresh
|
||||||
const PRESERVED_ROUTES = [
|
const PRESERVED_ROUTES = [
|
||||||
'/booking/',
|
'/booking/',
|
||||||
|
// Note the trailing slash above: '/booking/' does not match '/bookings'.
|
||||||
|
'/bookings',
|
||||||
'/login',
|
'/login',
|
||||||
'/register',
|
'/register',
|
||||||
'/forgot-password',
|
'/forgot-password',
|
||||||
@@ -117,6 +119,7 @@ export function middleware(request: NextRequest) {
|
|||||||
// Tell crawlers not to index private/transactional routes.
|
// Tell crawlers not to index private/transactional routes.
|
||||||
const NOINDEX_PREFIXES = [
|
const NOINDEX_PREFIXES = [
|
||||||
'/booking/',
|
'/booking/',
|
||||||
|
'/bookings',
|
||||||
'/login',
|
'/login',
|
||||||
'/register',
|
'/register',
|
||||||
'/forgot-password',
|
'/forgot-password',
|
||||||
|
|||||||
Reference in New Issue
Block a user