mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 20:40:55 +00:00
@@ -0,0 +1,65 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Per-wagon loading. Staff may confirm loading wagon-by-wagon instead of the
|
||||
* whole booking at once:
|
||||
* - bookings.loading_started_at — first wagon loaded; the booking stays PAID
|
||||
* until every remaining wagon is LOADED (remaining = allocated − cancelled).
|
||||
* Also shields a mid-load booking from the dispatch "left behind" unassign.
|
||||
* - wagon_booking_allocations.loaded_at / loaded_by_user_id — per-wagon
|
||||
* confirmation audit.
|
||||
* - booking_wagon_cancellations.fault — who caused an at-loading cancel of
|
||||
* the never-loaded remainder: CUSTOMER (fee applies) or EDR (no fee, credit
|
||||
* rebookable in full).
|
||||
*/
|
||||
export class PerWagonLoading3760000000000 implements MigrationInterface {
|
||||
name = 'PerWagonLoading3760000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS loading_started_at timestamptz`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.wagon_booking_allocations
|
||||
ADD COLUMN IF NOT EXISTS loaded_at timestamptz`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.wagon_booking_allocations
|
||||
ADD COLUMN IF NOT EXISTS loaded_by_user_id uuid`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.wagon_booking_allocations
|
||||
ADD COLUMN IF NOT EXISTS unloaded_at timestamptz`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.wagon_booking_allocations
|
||||
ADD COLUMN IF NOT EXISTS unloaded_by_user_id uuid`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.booking_wagon_cancellations
|
||||
ADD COLUMN IF NOT EXISTS fault varchar(16)`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.booking_wagon_cancellations DROP COLUMN IF EXISTS fault`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.wagon_booking_allocations DROP COLUMN IF EXISTS loaded_by_user_id`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.wagon_booking_allocations DROP COLUMN IF EXISTS unloaded_by_user_id`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.wagon_booking_allocations DROP COLUMN IF EXISTS unloaded_at`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.wagon_booking_allocations DROP COLUMN IF EXISTS loaded_at`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS loading_started_at`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Why a wagon left (or joined) the consist, on the adjustment log itself.
|
||||
*
|
||||
* SCHEDULED-run detach / send-to-maintenance now requires a reason instead of
|
||||
* a second staffer's approval, so the reason has to read back where the change
|
||||
* reads back: the train-builder History tab. Nullable — every other writer
|
||||
* (trip cuts, couples, arrival returns) keeps logging without one.
|
||||
*/
|
||||
export class WagonAdjustmentReason3770000000000 implements MigrationInterface {
|
||||
name = 'WagonAdjustmentReason3770000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.schedule_wagon_adjustment_logs
|
||||
ADD COLUMN IF NOT EXISTS reason varchar(500)`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.schedule_wagon_adjustment_logs
|
||||
DROP COLUMN IF EXISTS reason`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -939,8 +939,12 @@ describe("BillingService.document", () => {
|
||||
const build = (invoice: Record<string, unknown>) => {
|
||||
const render = jest.fn().mockResolvedValue({ filename: "x.pdf", buffer: Buffer.from("") });
|
||||
const renderThermal = jest.fn().mockResolvedValue({ filename: "x-thermal.pdf", buffer: Buffer.from("") });
|
||||
// `toDocumentModel` reads the booking (route/wagons, PNR) straight off the
|
||||
// data source for a booking-sourced invoice — a stub that answers "no such
|
||||
// booking" keeps these summary assertions about the invoice itself.
|
||||
const dataSource = { getRepository: () => ({ findOne: jest.fn().mockResolvedValue(null) }) };
|
||||
const service = new BillingService(
|
||||
{} as never,
|
||||
dataSource as never,
|
||||
{ findById: jest.fn().mockResolvedValue(invoice) } as never,
|
||||
{ findAll: jest.fn().mockResolvedValue([]) } as never,
|
||||
{} as never,
|
||||
@@ -1014,6 +1018,34 @@ describe("BillingService.document", () => {
|
||||
expect(model.qrImageUrl).toBe("data:image/png;base64,signed-payload");
|
||||
});
|
||||
|
||||
it("prints the provider transaction reference of a settled invoice", async () => {
|
||||
const { service, render } = build(
|
||||
invoiceRow({
|
||||
status: Freight.InvoiceStatus.Paid,
|
||||
paidAmount: 100,
|
||||
balanceAmount: 0,
|
||||
payments: [{ amount: 100, method: "GATEWAY", reference: "FT26082700123", paidAt: "2026-08-27T09:00:00.000Z" }],
|
||||
payment: { transactionId: "FT26082700123" },
|
||||
}),
|
||||
);
|
||||
|
||||
await service.document("inv-1");
|
||||
|
||||
const model = render.mock.calls[0][0];
|
||||
expect(model.summary).toContainEqual({ label: "Transaction ref", value: "FT26082700123" });
|
||||
});
|
||||
|
||||
it("adds no transaction reference row to an unpaid invoice", async () => {
|
||||
const { service, render } = build(invoiceRow());
|
||||
|
||||
await service.document("inv-1");
|
||||
|
||||
const model = render.mock.calls[0][0];
|
||||
expect(
|
||||
model.summary.find((r: { label: string }) => r.label === "Transaction ref"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("calls render (not renderThermal) for the default format", async () => {
|
||||
const { service, render, renderThermal } = build(invoiceRow());
|
||||
jest.spyOn(service as never, "toDocumentModel").mockResolvedValue({} as never);
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
applySettlement,
|
||||
invoicePaymentMethodExpr,
|
||||
round2,
|
||||
settlementReferences,
|
||||
} from "./invoice-settlement.util";
|
||||
import { InvoiceRepository } from "./invoice.repository";
|
||||
|
||||
@@ -268,7 +269,7 @@ export class BillingService {
|
||||
private readonly files: FilesService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly manualPaymentSettings: ManualPaymentSettingsService,
|
||||
) { }
|
||||
) {}
|
||||
|
||||
// ── Reads ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -301,7 +302,9 @@ export class BillingService {
|
||||
});
|
||||
}
|
||||
if (filter.sources?.length) {
|
||||
qb.andWhere("invoice.source IN (:...sources)", { sources: filter.sources });
|
||||
qb.andWhere("invoice.source IN (:...sources)", {
|
||||
sources: filter.sources,
|
||||
});
|
||||
}
|
||||
if (filter.eimsStatuses?.length) {
|
||||
qb.andWhere("invoice.eimsStatus IN (:...eimsStatuses)", {
|
||||
@@ -327,7 +330,9 @@ export class BillingService {
|
||||
});
|
||||
}
|
||||
if (filter.issuedTo) {
|
||||
qb.andWhere("invoice.issuedAt <= :issuedTo", { issuedTo: filter.issuedTo });
|
||||
qb.andWhere("invoice.issuedAt <= :issuedTo", {
|
||||
issuedTo: filter.issuedTo,
|
||||
});
|
||||
}
|
||||
if (filter.dueFrom) {
|
||||
qb.andWhere("invoice.dueAt >= :dueFrom", { dueFrom: filter.dueFrom });
|
||||
@@ -590,21 +595,28 @@ export class BillingService {
|
||||
/**
|
||||
* Finance's manual-settlement worklist: USD invoices (paid by bank transfer,
|
||||
* never through the gateway) and ETB invoices Finance settles by hand (bank
|
||||
* transfer / counter) instead of the customer paying online. Open ones by
|
||||
* default or a single status when filtered; both currencies unless
|
||||
* `currency` narrows it. Booking-sourced rows carry the booking's reference,
|
||||
* trade direction and pay-window deadline so the UI can show the countdown
|
||||
* and link to the booking.
|
||||
* transfer / counter) instead of the customer paying online. Both currencies
|
||||
* unless `currency` narrows it, and only ones whose manual-payment channel is
|
||||
* switched on. Open ones by default — pin `status` or `statuses` to widen
|
||||
* that. Every other dimension is the invoice list's own (`applyInvoiceFilters`
|
||||
* + `INVOICE_SORT_COLUMNS`), so the two screens filter and sort alike.
|
||||
* Booking-sourced rows carry the booking's reference, trade direction and
|
||||
* pay-window deadline so the UI can show the countdown and link to the
|
||||
* booking.
|
||||
*/
|
||||
async findOfflineUsdPaginated(
|
||||
filter: {
|
||||
status?: Freight.InvoiceStatus;
|
||||
search?: string;
|
||||
currency?: "USD" | "ETB";
|
||||
filter: InvoiceListFilters & {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: "ASC" | "DESC";
|
||||
} = {},
|
||||
): Promise<{ items: OfflineUsdInvoiceRow[]; total: number }> {
|
||||
): Promise<{
|
||||
items: OfflineUsdInvoiceRow[];
|
||||
total: number;
|
||||
/** Sum of `balanceAmount` over the WHOLE filtered set, by currency. */
|
||||
outstanding: Record<string, number>;
|
||||
}> {
|
||||
const page = filter.page && filter.page > 0 ? filter.page : 1;
|
||||
const pageSize =
|
||||
filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20;
|
||||
@@ -613,33 +625,75 @@ export class BillingService {
|
||||
// a row Finance cannot act on is noise, and the confirm endpoint would
|
||||
// refuse it anyway. All off → nothing to work.
|
||||
const enabled = await this.manualPaymentSettings.enabledCurrencies();
|
||||
if (!enabled.length) return { items: [], total: 0 };
|
||||
const currencies = filter.currency
|
||||
? enabled.filter((c) => c === filter.currency)
|
||||
: enabled;
|
||||
if (!currencies.length) return { items: [], total: 0 };
|
||||
const empty = { items: [], total: 0, outstanding: {} };
|
||||
if (!enabled.length) return empty;
|
||||
const wanted = filter.currency?.toUpperCase();
|
||||
const currencies = wanted ? enabled.filter((c) => c === wanted) : enabled;
|
||||
if (!currencies.length) return empty;
|
||||
|
||||
const qb = this.dataSource
|
||||
.getRepository(Invoice)
|
||||
.createQueryBuilder("invoice")
|
||||
.leftJoinAndSelect("invoice.company", "company")
|
||||
.where("UPPER(invoice.currency) IN (:...currencies)", { currencies })
|
||||
.orderBy("invoice.issuedAt", "DESC")
|
||||
/**
|
||||
* The worklist narrows by the same vocabulary as the main invoice list, so
|
||||
* both share `applyInvoiceFilters` — which references the `company` and
|
||||
* `payment` aliases, hence the unconditional joins. `select` is false for
|
||||
* the aggregate pass, where joined columns would break the GROUP BY.
|
||||
*/
|
||||
const buildQb = (select: boolean) => {
|
||||
const qb = this.dataSource
|
||||
.getRepository(Invoice)
|
||||
.createQueryBuilder("invoice");
|
||||
if (select) {
|
||||
qb.leftJoinAndSelect("invoice.company", "company").leftJoinAndSelect(
|
||||
"invoice.payment",
|
||||
"payment",
|
||||
);
|
||||
} else {
|
||||
qb.leftJoin("invoice.company", "company").leftJoin(
|
||||
"invoice.payment",
|
||||
"payment",
|
||||
);
|
||||
}
|
||||
qb.where("UPPER(invoice.currency) IN (:...currencies)", { currencies });
|
||||
// "What still needs settling" is the default cut, but only until the
|
||||
// caller pins a status — either the single-status param or the filter
|
||||
// bar's multi-select.
|
||||
if (!filter.status && !filter.statuses?.length) {
|
||||
qb.andWhere("invoice.status IN (:...open)", { open: OPEN_STATUSES });
|
||||
}
|
||||
// `currency` is already enforced by the enabled-currency IN above, and
|
||||
// re-applying it would only repeat the same predicate.
|
||||
this.applyInvoiceFilters(qb, { ...filter, currency: undefined });
|
||||
return qb;
|
||||
};
|
||||
|
||||
const qb = buildQb(true)
|
||||
// sortBy is whitelisted through INVOICE_SORT_COLUMNS, never interpolated
|
||||
// raw; the id tiebreaker keeps paging stable when the column ties.
|
||||
.orderBy(
|
||||
INVOICE_SORT_COLUMNS[filter.sortBy ?? ""] ?? "invoice.issuedAt",
|
||||
filter.sortOrder ?? "DESC",
|
||||
)
|
||||
.addOrderBy("invoice.id", "ASC")
|
||||
.skip((page - 1) * pageSize)
|
||||
.take(pageSize);
|
||||
if (filter.status) {
|
||||
qb.andWhere("invoice.status = :status", { status: filter.status });
|
||||
} else {
|
||||
qb.andWhere("invoice.status IN (:...open)", { open: OPEN_STATUSES });
|
||||
}
|
||||
if (filter.search) {
|
||||
qb.andWhere(
|
||||
"(invoice.invoiceNumber ILIKE :search OR invoice.sourceId ILIKE :search)",
|
||||
{ search: `%${filter.search}%` },
|
||||
);
|
||||
}
|
||||
|
||||
const [rawItems, total] = await qb.getManyAndCount();
|
||||
|
||||
// Outstanding across the whole filtered set, not the visible page — the
|
||||
// KPI must not change as Finance pages through the worklist.
|
||||
const outstandingRows: { currency: string; outstanding: string }[] =
|
||||
await buildQb(false)
|
||||
.select("invoice.currency", "currency")
|
||||
.addSelect("SUM(invoice.balanceAmount)", "outstanding")
|
||||
.groupBy("invoice.currency")
|
||||
.getRawMany();
|
||||
// Folded case-insensitively on the way out: stored casing has drifted
|
||||
// ("usd" rows exist), so two groups can address the same currency.
|
||||
const outstanding: Record<string, number> = {};
|
||||
for (const row of outstandingRows) {
|
||||
const key = (row.currency ?? "").toUpperCase();
|
||||
outstanding[key] =
|
||||
(outstanding[key] ?? 0) + (Number(row.outstanding) || 0);
|
||||
}
|
||||
const items = await this.attachShippingLineCompanies(rawItems);
|
||||
|
||||
const bookingIds = items
|
||||
@@ -703,6 +757,7 @@ export class BillingService {
|
||||
} as OfflineUsdInvoiceRow;
|
||||
}),
|
||||
total,
|
||||
outstanding,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -849,7 +904,9 @@ export class BillingService {
|
||||
{
|
||||
label: "Wagons",
|
||||
value:
|
||||
booking.wagonsRequired != null ? String(booking.wagonsRequired) : null,
|
||||
booking.wagonsRequired != null
|
||||
? String(booking.wagonsRequired)
|
||||
: null,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -919,11 +976,24 @@ export class BillingService {
|
||||
const eimsCfg = this.config.get<EimsConfig>("eims");
|
||||
if (eimsCfg?.tin) summary.push({ label: "Seller TIN", value: eimsCfg.tin });
|
||||
if (eimsCfg?.invoice?.sellerVatNumber) {
|
||||
summary.push({ label: "Seller VAT No.", value: eimsCfg.invoice.sellerVatNumber });
|
||||
summary.push({
|
||||
label: "Seller VAT No.",
|
||||
value: eimsCfg.invoice.sellerVatNumber,
|
||||
});
|
||||
}
|
||||
|
||||
// MoR EIMS reference — only once actually registered, never a placeholder row.
|
||||
if (invoice.eimsIrn) summary.push({ label: "EIMS IRN", value: invoice.eimsIrn });
|
||||
if (invoice.eimsIrn)
|
||||
summary.push({ label: "EIMS IRN", value: invoice.eimsIrn });
|
||||
|
||||
// The provider's transaction number for the money actually received — CBE's `FT…`,
|
||||
// telebirr's receipt number, or the bank-slip reference a teller recorded manually.
|
||||
// It is what a payer holding a receipt can match this invoice against, and what
|
||||
// finance reconciles a bank statement with; without it a PAID invoice proves only
|
||||
// that EDR says it was paid. `findById` already loads the `payment` relation, so both
|
||||
// sources are in hand here — see settlementReferences for why both are read.
|
||||
const txnRefs = settlementReferences(invoice);
|
||||
if (txnRefs) summary.push({ label: "Transaction ref", value: txnRefs });
|
||||
|
||||
// PNR — the CBE_BILL reference the customer pays against, written onto the booking at
|
||||
// payment-initiation time (see initiatePayment()). Not a column on Invoice/Payment, so
|
||||
@@ -933,7 +1003,8 @@ export class BillingService {
|
||||
where: { id: invoice.sourceId },
|
||||
select: ["id", "pnrCode"],
|
||||
});
|
||||
if (booking?.pnrCode) summary.push({ label: "PNR", value: booking.pnrCode });
|
||||
if (booking?.pnrCode)
|
||||
summary.push({ label: "PNR", value: booking.pnrCode });
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -954,7 +1025,9 @@ export class BillingService {
|
||||
currency: l.currency,
|
||||
})),
|
||||
totals,
|
||||
qrImageUrl: invoice.eimsSignedQr ? pngDataUrl(invoice.eimsSignedQr) : null,
|
||||
qrImageUrl: invoice.eimsSignedQr
|
||||
? pngDataUrl(invoice.eimsSignedQr)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1213,7 +1286,9 @@ export class BillingService {
|
||||
metadata: l.metadata ?? null,
|
||||
}));
|
||||
|
||||
const total = round2(lines.reduce((sum, l) => sum + Number(l.amount ?? 0), 0));
|
||||
const total = round2(
|
||||
lines.reduce((sum, l) => sum + Number(l.amount ?? 0), 0),
|
||||
);
|
||||
if (!(total > 0)) {
|
||||
throw new BadRequestException("A memo must have a positive total.");
|
||||
}
|
||||
@@ -1243,7 +1318,9 @@ export class BillingService {
|
||||
subtotalAmount: total,
|
||||
taxAmount: 0,
|
||||
totalAmount: total,
|
||||
...(settled ? { status: Freight.InvoiceStatus.Paid, dueAt: new Date() } : {}),
|
||||
...(settled
|
||||
? { status: Freight.InvoiceStatus.Paid, dueAt: new Date() }
|
||||
: {}),
|
||||
},
|
||||
mg,
|
||||
code,
|
||||
@@ -1254,7 +1331,11 @@ export class BillingService {
|
||||
eimsReason: reason,
|
||||
relatedInvoiceId: original.id,
|
||||
...(settled
|
||||
? { paidAmount: memo.totalAmount, balanceAmount: 0, paidAt: new Date() }
|
||||
? {
|
||||
paidAmount: memo.totalAmount,
|
||||
balanceAmount: 0,
|
||||
paidAt: new Date(),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
await mg.update(Invoice, memo.id, patch);
|
||||
@@ -1314,7 +1395,7 @@ export class BillingService {
|
||||
input.dueAt ??
|
||||
new Date(
|
||||
Date.now() +
|
||||
(input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000,
|
||||
(input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
|
||||
const invoiceNumber = await this.nextInvoiceNumber(mg, code);
|
||||
@@ -1835,9 +1916,9 @@ export class BillingService {
|
||||
dueAt,
|
||||
...(issuing
|
||||
? {
|
||||
status: Freight.InvoiceStatus.Pending,
|
||||
issuedAt: invoice.issuedAt ?? new Date(),
|
||||
}
|
||||
status: Freight.InvoiceStatus.Pending,
|
||||
issuedAt: invoice.issuedAt ?? new Date(),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
await mg.update(Invoice, { id: invoice.id }, patch);
|
||||
@@ -1901,10 +1982,7 @@ export class BillingService {
|
||||
const repo = this.dataSource.getRepository(Invoice);
|
||||
const invoices = await repo.findBy({
|
||||
paymentId,
|
||||
status: In([
|
||||
Freight.InvoiceStatus.Issued,
|
||||
Freight.InvoiceStatus.Pending,
|
||||
]),
|
||||
status: In([Freight.InvoiceStatus.Issued, Freight.InvoiceStatus.Pending]),
|
||||
});
|
||||
for (const invoice of invoices) {
|
||||
await repo.update(
|
||||
@@ -2081,7 +2159,10 @@ export class BillingService {
|
||||
// Same reference, for an ad-hoc additional charge — its own column, since
|
||||
// an AdditionalCharge doesn't own a Booking-scoped `pnrCode` and a booking
|
||||
// can carry many of these at once.
|
||||
if (billReference && invoice.source === Freight.InvoiceSource.AdditionalCharge) {
|
||||
if (
|
||||
billReference &&
|
||||
invoice.source === Freight.InvoiceSource.AdditionalCharge
|
||||
) {
|
||||
await this.dataSource
|
||||
.getRepository(AdditionalCharge)
|
||||
.update({ id: invoice.sourceId }, { paymentReference: billReference });
|
||||
|
||||
@@ -337,7 +337,11 @@ export class InvoiceDocumentService {
|
||||
let y = 700;
|
||||
const colX = [36, 300];
|
||||
const colW = 250;
|
||||
model.summary.slice(0, 16).forEach((row, i) => {
|
||||
// 20, not 16: a booking invoice already fills 16 rows with every optional one present
|
||||
// (buyer trade name, buyer VAT, seller TIN/VAT, IRN, PNR) and the transaction ref is the
|
||||
// 17th — the old cap silently dropped whichever row landed last. Still fits: 20 rows end
|
||||
// at y=423, leaving the line-item table its full run down to the y<190 cut-off.
|
||||
model.summary.slice(0, 20).forEach((row, i) => {
|
||||
const x = colX[i % 2];
|
||||
if (i % 2 === 0 && i > 0) y -= 27;
|
||||
ops.push(textOp((row.label ?? "").toUpperCase(), x, y, 7, "F1", PdfColor.gray));
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { settlementReferences } from "./invoice-settlement.util";
|
||||
|
||||
describe("settlementReferences", () => {
|
||||
it("returns the provider reference recorded on the invoice ledger", () => {
|
||||
expect(
|
||||
settlementReferences({
|
||||
payments: [{ reference: "FT26082700123" }],
|
||||
}),
|
||||
).toBe("FT26082700123");
|
||||
});
|
||||
|
||||
it("reads the linked gateway payment row when the ledger has no reference", () => {
|
||||
expect(
|
||||
settlementReferences({
|
||||
payments: [{ reference: null }],
|
||||
payment: { transactionId: "TB998877" },
|
||||
}),
|
||||
).toBe("TB998877");
|
||||
});
|
||||
|
||||
it("does not repeat a reference that both sources carry", () => {
|
||||
expect(
|
||||
settlementReferences({
|
||||
payments: [{ reference: "FT26082700123" }],
|
||||
payment: { transactionId: "FT26082700123" },
|
||||
}),
|
||||
).toBe("FT26082700123");
|
||||
});
|
||||
|
||||
it("lists every leg of a partially-then-fully paid invoice, oldest first", () => {
|
||||
expect(
|
||||
settlementReferences({
|
||||
payments: [{ reference: "SLIP-001" }, { reference: "FT26082700123" }],
|
||||
}),
|
||||
).toBe("SLIP-001, FT26082700123");
|
||||
});
|
||||
|
||||
it("drops the internal intent id the gateway path falls back to", () => {
|
||||
expect(
|
||||
settlementReferences({
|
||||
payments: [{ reference: "3f8a1c2e-9b4d-4a71-8c6e-2d5f7a9b1c30" }],
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("is null for an unpaid invoice", () => {
|
||||
expect(settlementReferences({ payments: [] })).toBeNull();
|
||||
expect(settlementReferences({})).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -74,3 +74,44 @@ export const INVOICE_PAYMENT_METHODS = [
|
||||
/** Settled at a gateway whose provider row is no longer linked. */
|
||||
"GATEWAY",
|
||||
] as const;
|
||||
|
||||
/** Anything shaped enough to read settlement references off. */
|
||||
interface SettlementReferenceSource {
|
||||
payments?: Array<{ reference?: string | null }> | null;
|
||||
payment?: { transactionId?: string | null } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A settlement reference is the PROVIDER's own transaction number, never ours.
|
||||
* The gateway path falls back to the intent id when a provider returns no txn
|
||||
* ref (`markInvoiceAsPaid`: `providerTxnId ?? paymentId`), and that id is a
|
||||
* uuid — an internal correlation key that means nothing to a payer holding a
|
||||
* bank slip, so it is dropped rather than printed. No provider's reference is
|
||||
* uuid-shaped: CBE sends `FT…`, telebirr/ebirr/waafi send digit strings.
|
||||
*/
|
||||
const INTERNAL_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/**
|
||||
* Every provider transaction reference recorded against an invoice, oldest
|
||||
* first, joined for display — CBE's `FT…`, telebirr's receipt number, or the
|
||||
* bank-slip number a teller typed into a manual settlement. Null when nothing
|
||||
* identifiable was recorded.
|
||||
*
|
||||
* Reads BOTH sources because neither alone is complete: the invoice's own
|
||||
* ledger is the only record of manual settlements and of each leg of a
|
||||
* partially-paid invoice, while the linked `freight.payments` row is the only
|
||||
* place a provider txn id lands when it arrives after settlement (a webhook
|
||||
* that stamps `transactionId` on an already-settled intent). Deduped, since
|
||||
* the ordinary gateway path writes the same value to both.
|
||||
*/
|
||||
export function settlementReferences(
|
||||
invoice: SettlementReferenceSource,
|
||||
): string | null {
|
||||
const refs = [
|
||||
...(invoice.payments ?? []).map((p) => p.reference),
|
||||
invoice.payment?.transactionId,
|
||||
].filter(
|
||||
(ref): ref is string => Boolean(ref) && !INTERNAL_ID.test(ref as string),
|
||||
);
|
||||
return [...new Set(refs)].join(", ") || null;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
import { BookingWagonCancellationService } from './booking-wagon-cancellation.service';
|
||||
import {
|
||||
bulkTonWagonsRequired,
|
||||
bulkTonsPerWagonFor,
|
||||
} from '../train-scheduling/train-capacity.util';
|
||||
|
||||
/**
|
||||
* Sizing of a bulk quantity cut (no DB touched on this branch): a whole-booking
|
||||
@@ -107,3 +111,91 @@ describe('BookingWagonCancellationService.rebook (odd-20ft consolidation)', () =
|
||||
).rejects.toThrow(/already shares a wagon/i);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A NUMBER_OF_WAGONS booking pins its count in `bulkRequestedWagons`, and
|
||||
* bulkTonWagonsRequired honours that verbatim. Partial cancel must shrink it
|
||||
* alongside wagonsRequired/cargoTotalWeightVgm — left stale, the booking
|
||||
* re-inflates to its pre-cancel count on the next allocation and each wagon
|
||||
* carries tons / stale-count instead of the real even share.
|
||||
*/
|
||||
describe('partial cancel of a NUMBER_OF_WAGONS bulk booking', () => {
|
||||
// 980T over 14 wagons (70T each), 2 wagons cancelled.
|
||||
const before = { freightType: 'BULK', cargoTotalWeightVgm: 980, bulkRequestedWagons: 14 };
|
||||
const droppedWeight = 140;
|
||||
const wagonsCancelled = 2;
|
||||
|
||||
// The decrement applied in applyPaidCut's booking update.
|
||||
const after = {
|
||||
...before,
|
||||
cargoTotalWeightVgm: before.cargoTotalWeightVgm - droppedWeight,
|
||||
bulkRequestedWagons: Math.max(
|
||||
0,
|
||||
Math.floor(before.bulkRequestedWagons - wagonsCancelled),
|
||||
),
|
||||
};
|
||||
|
||||
it('reallocates at the reduced count, not the pre-cancel one', () => {
|
||||
expect(bulkTonWagonsRequired(before, undefined, 'nw5', 70)).toBe(14);
|
||||
expect(bulkTonWagonsRequired(after, undefined, 'nw5', 70)).toBe(12);
|
||||
});
|
||||
|
||||
it('keeps tons-per-wagon at the real even share', () => {
|
||||
// Stale count would spread 840T over 14 wagons → 60T each.
|
||||
expect(bulkTonsPerWagonFor(after, undefined, 'nw5', 70)).toBe(70);
|
||||
});
|
||||
|
||||
it('cancelling every wagon leaves no requested count behind', () => {
|
||||
const all = Math.max(0, Math.floor(before.bulkRequestedWagons - 14));
|
||||
expect(all).toBe(0);
|
||||
expect(bulkTonWagonsRequired(
|
||||
{ ...before, cargoTotalWeightVgm: 0, bulkRequestedWagons: all },
|
||||
undefined,
|
||||
'nw5',
|
||||
70,
|
||||
)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Rebooking a NUMBER_OF_WAGONS bulk credit: the create path rejects the rebook
|
||||
* unless the DTO carries a wagon count ("<cargo> is booked by wagons — enter
|
||||
* the number of wagons needed"), and the quantities snapshot holds tons only.
|
||||
* The count therefore has to come off the cancellation row itself.
|
||||
*/
|
||||
describe('BookingWagonCancellationService.buildRebookDto (bulk wagon count)', () => {
|
||||
const svc = Object.create(BookingWagonCancellationService.prototype) as {
|
||||
buildRebookDto(
|
||||
row: unknown,
|
||||
scheduledDate: string,
|
||||
overrides?: unknown,
|
||||
): { bulkLines?: { cargoWeightTons: number }[]; requestedWagons?: number };
|
||||
};
|
||||
|
||||
it('carries the cancelled wagon count onto the rebook', () => {
|
||||
const dto = svc.buildRebookDto(
|
||||
{ wagonsCancelled: 2, weightTons: 140, cancelledQuantities: { bulkTons: 140 } },
|
||||
'2026-09-10',
|
||||
);
|
||||
expect(dto.bulkLines).toEqual([{ cargoWeightTons: 140 }]);
|
||||
// Without this the create path throws before the booking is ever made.
|
||||
expect(dto.requestedWagons).toBe(2);
|
||||
});
|
||||
|
||||
it('rounds a fractional cut up to a whole wagon', () => {
|
||||
const dto = svc.buildRebookDto(
|
||||
{ wagonsCancelled: 0.5, weightTons: 35, cancelledQuantities: { bulkTons: 35 } },
|
||||
'2026-09-10',
|
||||
);
|
||||
// Flooring would send 0 into a check that demands >= 1.
|
||||
expect(dto.requestedWagons).toBe(1);
|
||||
});
|
||||
|
||||
it('leaves the count off when nothing was cancelled', () => {
|
||||
const dto = svc.buildRebookDto(
|
||||
{ wagonsCancelled: 0, weightTons: 0, cancelledQuantities: { bulkTons: 12 } },
|
||||
'2026-09-10',
|
||||
);
|
||||
expect(dto.requestedWagons).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,6 +25,8 @@ import { Rate } from '../rule-engine/entities/rate.entity';
|
||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||
import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service';
|
||||
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
||||
import { WagonAllocationBulkLoad } from '../train-schedules/entities/wagon-allocation-bulk-load.entity';
|
||||
import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity';
|
||||
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
|
||||
@@ -35,6 +37,7 @@ import {
|
||||
} from './booking-wagon-cancellations.repository';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import {
|
||||
CancelRemainingWagonsDto,
|
||||
RebookCancelledWagonsDto,
|
||||
RebookContainerLineDto,
|
||||
RequestWagonCancellationDto,
|
||||
@@ -625,7 +628,14 @@ export class BookingWagonCancellationService {
|
||||
this.logger.warn(`No wagon cancellation for paid fee invoice ${feeInvoiceId}.`);
|
||||
return;
|
||||
}
|
||||
if (row.status !== 'FEE_PENDING') return;
|
||||
if (row.status !== 'FEE_PENDING') {
|
||||
// At-loading cancels apply the cut immediately and leave the invoice
|
||||
// open — settle only the payment stamp when the customer pays later.
|
||||
if (!row.feePaidAt) {
|
||||
await this.repo.update(row.id, { feePaidAt: new Date() });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// The fee can settle after loading started (slow payment). Never cut
|
||||
// loaded cargo: leave the row FEE_PENDING and alert staff to resolve
|
||||
@@ -653,6 +663,25 @@ export class BookingWagonCancellationService {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.applyCut(row, releasedEarly, { feeSettled: true });
|
||||
this.logger.log(
|
||||
`Wagon cancellation ${row.id}: fee paid, booking ${row.bookingId} reduced by ${row.wagonsCancelled} wagon(s).`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the cut to the booking: reduce quantities/wagons/amount, release the
|
||||
* cancelled allocations, flip the row to CREDIT_AVAILABLE. Runs at fee
|
||||
* settlement for the customer-requested flow (feeSettled: true) and
|
||||
* immediately for at-loading cancels (feeSettled only when no fee is owed —
|
||||
* EDR fault; a customer-fault cut leaves feePaidAt null until the open
|
||||
* invoice settles via onFeePaid).
|
||||
*/
|
||||
private async applyCut(
|
||||
row: BookingWagonCancellation,
|
||||
releasedEarly: boolean,
|
||||
opts: { feeSettled: boolean },
|
||||
): Promise<void> {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const booking = await manager.getRepository(Booking).findOne({
|
||||
where: { id: row.bookingId },
|
||||
@@ -704,8 +733,21 @@ export class BookingWagonCancellationService {
|
||||
Number(booking.wagonsRequired ?? 0) - Number(row.wagonsCancelled),
|
||||
);
|
||||
const isFull = wagonsLeft <= 0;
|
||||
// NUMBER_OF_WAGONS bookings pin their count in bulkRequestedWagons, which
|
||||
// bulkTonWagonsRequired honours verbatim. Left stale it re-inflates the
|
||||
// booking to its pre-cancel count on the next allocation (and shrinks
|
||||
// tons-per-wagon to tons / stale-count), so shrink it with the cut.
|
||||
const requestedWagonsLeft = booking.bulkRequestedWagons
|
||||
? Math.max(
|
||||
0,
|
||||
Math.floor(Number(booking.bulkRequestedWagons) - Number(row.wagonsCancelled)),
|
||||
)
|
||||
: null;
|
||||
await manager.getRepository(Booking).update(booking.id, {
|
||||
wagonsRequired: Math.max(0, wagonsLeft),
|
||||
...(requestedWagonsLeft !== null
|
||||
? { bulkRequestedWagons: requestedWagonsLeft }
|
||||
: {}),
|
||||
cargoTotalWeightVgm: Math.max(
|
||||
0,
|
||||
round3(Number(booking.cargoTotalWeightVgm) - droppedWeight),
|
||||
@@ -723,7 +765,7 @@ export class BookingWagonCancellationService {
|
||||
|
||||
await manager.getRepository(BookingWagonCancellation).update(row.id, {
|
||||
status: 'CREDIT_AVAILABLE',
|
||||
feePaidAt: new Date(),
|
||||
...(opts.feeSettled ? { feePaidAt: new Date() } : {}),
|
||||
weightTons: droppedWeight,
|
||||
cancelledQuantities: quantities,
|
||||
});
|
||||
@@ -741,9 +783,145 @@ export class BookingWagonCancellationService {
|
||||
: `${row.wagonsCancelled} wagon(s) of ${booking.reference} are cancelled. Your paid freight is kept as credit — rebook it on any coming train day.`,
|
||||
);
|
||||
}
|
||||
this.logger.log(
|
||||
`Wagon cancellation ${row.id}: fee paid, booking ${row.bookingId} reduced by ${row.wagonsCancelled} wagon(s).`,
|
||||
}
|
||||
|
||||
/**
|
||||
* Staff cancel of the never-loaded remainder mid-load: the operator loaded
|
||||
* what physically rides and cuts the rest, so the booking shrinks to its
|
||||
* loaded wagons, dispatch unblocks, and the warehouse only ever sees the
|
||||
* final (smaller) booking. Unlike the customer flow the cut applies
|
||||
* IMMEDIATELY — the train cannot wait for a fee payment:
|
||||
* - CUSTOMER fault: cancellation fee invoiced, payable after; the credit
|
||||
* row opens right away (feePaidAt stamps when the invoice settles).
|
||||
* - EDR fault: no fee at all; the credit is rebookable in full.
|
||||
*/
|
||||
async cancelRemainingAtLoading(
|
||||
bookingId: string,
|
||||
dto: CancelRemainingWagonsDto,
|
||||
userId?: string,
|
||||
): Promise<BookingWagonCancellation> {
|
||||
const booking = await this.bookingsRepository.findById(bookingId);
|
||||
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found.`);
|
||||
if (booking.paymentStatus !== 'PAID' || booking.status !== 'PAID') {
|
||||
throw new BadRequestException(
|
||||
'Only a paid booking still loading can cancel its remaining wagons.',
|
||||
);
|
||||
}
|
||||
if (booking.loadedAt) {
|
||||
throw new BadRequestException(
|
||||
'This booking is already fully loaded — there is nothing left to cancel.',
|
||||
);
|
||||
}
|
||||
if (!booking.contractId) {
|
||||
throw new BadRequestException(
|
||||
'Wagon cancellation needs a contract booking (the credit is rebooked under the contract).',
|
||||
);
|
||||
}
|
||||
const open = await this.repo.findOpenForBooking(bookingId);
|
||||
if (open) {
|
||||
throw new ConflictException(
|
||||
'This booking already has a cancellation awaiting its fee. Pay or withdraw it first.',
|
||||
);
|
||||
}
|
||||
|
||||
const allocations = await this.dataSource
|
||||
.getRepository(WagonBookingAllocation)
|
||||
.createQueryBuilder('alloc')
|
||||
.innerJoin(TrainSetWagon, 'slot', 'slot.id = alloc.train_set_wagon_id')
|
||||
.innerJoin(
|
||||
TrainSchedule,
|
||||
'schedule',
|
||||
'schedule.train_set_id = slot.train_set_id AND schedule.id = :scheduleId',
|
||||
{ scheduleId: dto.scheduleId },
|
||||
)
|
||||
.where('alloc.booking_id = :bookingId', { bookingId })
|
||||
.getMany();
|
||||
const loaded = allocations.filter(
|
||||
(a) => a.status === 'LOADED' || a.status === 'DEPARTED',
|
||||
);
|
||||
const remaining = allocations.filter(
|
||||
(a) => a.status !== 'LOADED' && a.status !== 'DEPARTED',
|
||||
);
|
||||
if (!loaded.length) {
|
||||
throw new BadRequestException(
|
||||
'Loading has not started for this booking — use the normal wagon cancellation flow.',
|
||||
);
|
||||
}
|
||||
if (!remaining.length) {
|
||||
throw new BadRequestException(
|
||||
'Every wagon of this booking is loaded — there is nothing to cancel.',
|
||||
);
|
||||
}
|
||||
|
||||
const cut = await this.resolveRequestedCut(booking, {
|
||||
wagonAllocationIds: remaining.map((r) => r.id),
|
||||
} as RequestWagonCancellationDto);
|
||||
if (booking.consolidationPartnerId) this.assertCutSparesSharedWagon(cut);
|
||||
|
||||
const edrFault = !!dto.edrFault;
|
||||
const fee = edrFault ? null : await this.priceFee(booking, cut);
|
||||
const creditAmount = this.creditFor(booking, cut.wagons);
|
||||
|
||||
const row = await this.repo.create({
|
||||
bookingId,
|
||||
wagonsCancelled: cut.wagons,
|
||||
weightTons: cut.weightTons,
|
||||
cancelledQuantities: cut.quantities,
|
||||
creditAmount,
|
||||
feeRateId: fee?.rates[0]?.id ?? null,
|
||||
feeAmount: fee?.amount ?? 0,
|
||||
feeCurrency: fee?.currency ?? booking.paymentCurrency ?? 'ETB',
|
||||
status: 'FEE_PENDING',
|
||||
reason: dto.reason,
|
||||
fault: edrFault ? 'EDR' : 'CUSTOMER',
|
||||
requestedByUserId: userId ?? null,
|
||||
});
|
||||
|
||||
let current = row;
|
||||
if (fee && fee.amount > 0) {
|
||||
const invoice = await this.billing.generateInvoice({
|
||||
source: Freight.InvoiceSource.Booking,
|
||||
sourceId: bookingId,
|
||||
type: WAGON_CANCEL_FEE_INVOICE_TYPE,
|
||||
companyId: booking.companyId,
|
||||
companyProfileId: booking.companyProfileId,
|
||||
currency: fee.currency,
|
||||
lines: [
|
||||
{
|
||||
chargeType: 'CANCELLATION_FEE',
|
||||
description: `Wagon cancellation fee — ${cut.wagons} wagon(s) of booking ${booking.reference} cancelled at loading`,
|
||||
quantity: cut.wagons,
|
||||
unitRate: fee.perWagon,
|
||||
amount: fee.amount,
|
||||
currency: fee.currency,
|
||||
metadata: { wagonCancellationId: row.id },
|
||||
},
|
||||
],
|
||||
totalAmount: fee.amount,
|
||||
status: Freight.InvoiceStatus.Issued,
|
||||
});
|
||||
current = (await this.repo.update(row.id, { feeInvoiceId: invoice.id })) ?? row;
|
||||
}
|
||||
|
||||
// The cut applies NOW — booking shrinks, allocations release, credit opens.
|
||||
// EDR fault (or a zero fee) settles the fee side immediately; a customer-
|
||||
// fault fee stays owed and stamps feePaidAt via onFeePaid when it settles.
|
||||
await this.applyCut(current, false, { feeSettled: edrFault || !fee || fee.amount <= 0 });
|
||||
|
||||
// The booking now holds only loaded wagons — let the journey complete the
|
||||
// load (PAID → IN_TRANSIT, warehouse inventory, milestones).
|
||||
this.events.emit('booking.wagonsCancelledAtLoading', {
|
||||
bookingId,
|
||||
scheduleId: dto.scheduleId,
|
||||
userId: userId ?? null,
|
||||
});
|
||||
|
||||
this.notifyStaff(
|
||||
booking,
|
||||
'Wagons cancelled at loading',
|
||||
`${booking.reference}: ${cut.wagons} unloaded wagon(s) cancelled (${edrFault ? 'EDR fault — no fee' : `customer fault — fee invoiced`}). Reason: ${dto.reason}`,
|
||||
);
|
||||
return this.mustFind(row.id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1712,6 +1890,14 @@ export class BookingWagonCancellationService {
|
||||
}
|
||||
|
||||
dto.bulkLines = [{ cargoWeightTons: Number(q.bulkTons ?? row.weightTons) }];
|
||||
// NUMBER_OF_WAGONS cargo is booked by wagon count, not by tons: the create
|
||||
// path rejects the rebook outright without it. The count is not in the
|
||||
// quantities snapshot (which only carries tons) — it is the cancellation's
|
||||
// own wagonsCancelled, so every existing credit rebooks without a backfill.
|
||||
// Rounded UP: a fractional cut still needs a whole wagon to ride on, and
|
||||
// flooring 0.5 would send 0 into a check that demands >= 1.
|
||||
const cancelledWagons = Math.ceil(Number(row.wagonsCancelled ?? 0));
|
||||
if (cancelledWagons >= 1) dto.requestedWagons = cancelledWagons;
|
||||
return dto;
|
||||
}
|
||||
|
||||
|
||||
@@ -100,6 +100,7 @@ import { BookingWagonCancellationService } from "./booking-wagon-cancellation.se
|
||||
import {
|
||||
FilterWagonCancellationsDto,
|
||||
RebookCancelledWagonsDto,
|
||||
CancelRemainingWagonsDto,
|
||||
RequestWagonCancellationDto,
|
||||
} from "./dto/wagon-cancellation.dto";
|
||||
import {
|
||||
@@ -640,6 +641,20 @@ export class BookingsController {
|
||||
return this.wagonCancellationService.requestCancellation(id, dto, user?.id);
|
||||
}
|
||||
|
||||
@Post(":id/wagon-cancellations/at-loading")
|
||||
@BookingStaff(FREIGHT_PERMS.trainScheduling.load)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Staff: cancel the never-loaded remainder of a booking mid-load. The cut applies immediately (the train cannot wait); CUSTOMER fault invoices the fee to pay after, EDR fault charges nothing.",
|
||||
})
|
||||
async cancelRemainingWagonsAtLoading(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: CancelRemainingWagonsDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.wagonCancellationService.cancelRemainingAtLoading(id, dto, user?.id);
|
||||
}
|
||||
|
||||
@Get(":id/wagon-cancellations")
|
||||
@ApiOperation({
|
||||
summary: "Wagon-cancellation history of one booking (owner or staff)",
|
||||
|
||||
@@ -3,9 +3,11 @@ import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayNotEmpty,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
@@ -169,3 +171,28 @@ export class FilterWagonCancellationsDto {
|
||||
@Min(1)
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Staff cancel of the never-loaded remainder of a booking mid-load: everything
|
||||
* not yet LOADED on the schedule is cut, the booking shrinks to its loaded
|
||||
* wagons, and the freed credit is rebookable. Fault decides the fee: CUSTOMER
|
||||
* — cancellation fee invoiced (payable after the cut); EDR — no fee.
|
||||
*/
|
||||
export class CancelRemainingWagonsDto {
|
||||
@ApiProperty({ description: 'Schedule the booking is being loaded on' })
|
||||
@IsUUID('4')
|
||||
scheduleId!: string;
|
||||
|
||||
@ApiProperty({ description: 'Why the remaining wagons are not riding' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(2000)
|
||||
reason!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'The shortfall is EDR\'s fault (wagon shortage, yard problem) — no fee charged',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
edrFault?: boolean;
|
||||
}
|
||||
|
||||
@@ -132,6 +132,15 @@ export class BookingWagonCancellation extends BaseEntity {
|
||||
@Column({ name: 'reason', type: 'text', nullable: true })
|
||||
reason?: string | null;
|
||||
|
||||
/**
|
||||
* At-loading cancels of the never-loaded remainder: who caused it.
|
||||
* CUSTOMER — cancellation fee applies (invoice payable after the cut);
|
||||
* EDR — no fee, the full credit is rebookable. Null for customer-requested
|
||||
* cancellations (the pre-loading flow).
|
||||
*/
|
||||
@Column({ name: 'fault', type: 'varchar', length: 16, nullable: true })
|
||||
fault?: 'CUSTOMER' | 'EDR' | null;
|
||||
|
||||
@Column({ name: 'requested_by_user_id', type: 'uuid', nullable: true })
|
||||
requestedByUserId?: string | null;
|
||||
|
||||
|
||||
@@ -588,6 +588,15 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'loaded_at', type: 'timestamptz', nullable: true })
|
||||
loadedAt?: Date | null;
|
||||
|
||||
/**
|
||||
* First wagon of this booking confirmed loaded (per-wagon loading). The
|
||||
* booking stays PAID until every remaining wagon is LOADED — loadedAt then
|
||||
* stamps the completion. Also shields the booking from the dispatch
|
||||
* "left behind" unassign while mid-load.
|
||||
*/
|
||||
@Column({ name: 'loading_started_at', type: 'timestamptz', nullable: true })
|
||||
loadingStartedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'loaded_by_user_id', type: 'uuid', nullable: true })
|
||||
loadedByUserId?: string | null;
|
||||
|
||||
|
||||
@@ -201,6 +201,9 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
.leftJoinAndSelect('routes.destinationYard', 'routeDestination')
|
||||
.leftJoinAndSelect('contract.cargoScope', 'cargoScope')
|
||||
.leftJoinAndSelect('cargoScope.cargoType', 'cargoType')
|
||||
// Wagon types carry the rated capacity the booking forms need to reject
|
||||
// a wagon count whose even share overloads a wagon (see maxTonsPerWagon).
|
||||
.leftJoinAndSelect('cargoType.wagonTypes', 'cargoTypeWagonTypes')
|
||||
.leftJoinAndSelect('contract.rateSnapshots', 'rateSnapshots')
|
||||
.leftJoinAndSelect('contract.signatures', 'signatures')
|
||||
.leftJoinAndSelect('signatures.signatureFile', 'signatureFile')
|
||||
|
||||
@@ -13,7 +13,9 @@ import { YardCountry } from '@edr/types';
|
||||
//
|
||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||
import { CompaniesService } from '../companies/companies.service';
|
||||
import { CargoType } from '../rule-engine/entities/cargo-type.entity';
|
||||
import { ServiceType } from '../rule-engine/entities/service-type.entity';
|
||||
import { bulkTonsPerWagon } from '../train-scheduling/train-capacity.util';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { MinioService } from '../minio/minio.service';
|
||||
@@ -858,6 +860,29 @@ export class ContractsService {
|
||||
);
|
||||
}
|
||||
|
||||
// NUMBER_OF_WAGONS booking forms need the heaviest load one wagon may take
|
||||
// so they can reject a wagon count whose even share overloads a wagon —
|
||||
// the client-side twin of ContractBookingService.assertWagonShareFits.
|
||||
// The raw wagonTypes join rows are dropped: only the derived cap ships.
|
||||
for (const scope of contract.cargoScope ?? []) {
|
||||
const cargoType = scope.cargoType as
|
||||
| (CargoType & { maxTonsPerWagon?: number | null })
|
||||
| null
|
||||
| undefined;
|
||||
if (!cargoType) continue;
|
||||
const allowed = (cargoType.wagonTypes ?? []).filter(
|
||||
(wt) => Number(wt.capacityTons) > 0,
|
||||
);
|
||||
cargoType.maxTonsPerWagon = allowed.length
|
||||
? Math.max(
|
||||
...allowed.map((wt) =>
|
||||
bulkTonsPerWagon(cargoType, wt.id, Number(wt.capacityTons)),
|
||||
),
|
||||
)
|
||||
: null;
|
||||
delete cargoType.wagonTypes;
|
||||
}
|
||||
|
||||
// Surface the staff "request changes" note so the portal can show the
|
||||
// customer what to fix. Degrade to null on lookup failure — a missing note
|
||||
// must never 500 a contract fetch.
|
||||
|
||||
@@ -39,6 +39,14 @@ export class ScheduleWagonAdjustmentLog extends BaseEntity {
|
||||
@Column({ name: 'yard_id', type: 'uuid', nullable: true })
|
||||
yardId!: string | null;
|
||||
|
||||
/**
|
||||
* Why the wagon left/joined the consist. Required by the builder for a
|
||||
* detach or maintenance move on a SCHEDULED run (that reason replaced the
|
||||
* old second-staff approval); null for every other adjustment.
|
||||
*/
|
||||
@Column({ name: 'reason', type: 'varchar', length: 500, nullable: true })
|
||||
reason?: string | null;
|
||||
|
||||
@Column({ name: 'occurred_at', type: 'timestamptz', default: () => 'now()' })
|
||||
occurredAt!: Date;
|
||||
}
|
||||
|
||||
@@ -50,6 +50,20 @@ export class WagonBookingAllocation extends BaseEntity {
|
||||
@Column({ name: 'confirmed_by_user_id', type: 'uuid', nullable: true })
|
||||
confirmedByUserId?: string | null;
|
||||
|
||||
/** Per-wagon loading confirmation (status LOADED) — when and by whom. */
|
||||
@Column({ name: 'loaded_at', type: 'timestamptz', nullable: true })
|
||||
loadedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'loaded_by_user_id', type: 'uuid', nullable: true })
|
||||
loadedByUserId?: string | null;
|
||||
|
||||
/** Per-wagon unloading confirmation (status DEPARTED) — when and by whom. */
|
||||
@Column({ name: 'unloaded_at', type: 'timestamptz', nullable: true })
|
||||
unloadedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'unloaded_by_user_id', type: 'uuid', nullable: true })
|
||||
unloadedByUserId?: string | null;
|
||||
|
||||
@OneToMany(() => WagonAllocationContainerItem, (item) => item.allocation)
|
||||
containerItems?: WagonAllocationContainerItem[];
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
NotFoundException,
|
||||
Optional,
|
||||
} from '@nestjs/common';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource, EntityManager, In } from 'typeorm';
|
||||
import { Freight } from '@edr/types';
|
||||
@@ -72,6 +72,117 @@ export class BookingJourneyService {
|
||||
|
||||
async loadBooking(scheduleId: string, bookingId: string, userId?: string | null) {
|
||||
const { schedule, booking } = await this.getScheduleBooking(scheduleId, bookingId);
|
||||
await this.assertBookingLoadable(schedule, booking);
|
||||
return this.completeLoad(schedule, booking, userId ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm ONE wagon of the booking loaded (per-wagon loading). The booking
|
||||
* stays PAID while wagons remain; loading the last remaining wagon runs the
|
||||
* whole-booking completion (IN_TRANSIT, warehouse inventory, GRN,
|
||||
* milestones) exactly as the one-shot load does. Wagons that will NOT ride
|
||||
* must be cancelled via the at-loading cancellation before the booking can
|
||||
* complete (and before the train may dispatch).
|
||||
*/
|
||||
async loadWagon(
|
||||
scheduleId: string,
|
||||
bookingId: string,
|
||||
allocationId: string,
|
||||
userId?: string | null,
|
||||
) {
|
||||
const { schedule, booking } = await this.getScheduleBooking(scheduleId, bookingId);
|
||||
await this.assertBookingLoadable(schedule, booking);
|
||||
const allocations = await this.allocationsForBooking(
|
||||
this.dataSource.manager,
|
||||
scheduleId,
|
||||
bookingId,
|
||||
);
|
||||
if (!allocations.length) {
|
||||
throw new BadRequestException(
|
||||
'This booking has no wagon allocations on the schedule — use the whole-booking load.',
|
||||
);
|
||||
}
|
||||
const target = allocations.find((a) => a.id === allocationId);
|
||||
if (!target) {
|
||||
throw new NotFoundException('Wagon allocation not found on this booking/schedule');
|
||||
}
|
||||
if (target.status === 'LOADED' || target.status === 'DEPARTED') {
|
||||
throw new BadRequestException('This wagon is already loaded');
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(WagonBookingAllocation).update(target.id, {
|
||||
status: 'LOADED',
|
||||
loadedAt: now,
|
||||
loadedByUserId: userId ?? null,
|
||||
});
|
||||
if (!booking.loadingStartedAt) {
|
||||
await manager
|
||||
.getRepository(Booking)
|
||||
.update(bookingId, { loadingStartedAt: now } as never);
|
||||
}
|
||||
// PARTIAL keeps the dispatch-readiness badge honest; completion below
|
||||
// flips it to LOADED.
|
||||
await manager
|
||||
.getRepository(TrainScheduleBooking)
|
||||
.update({ trainScheduleId: scheduleId, bookingId }, { loadingStatus: 'PARTIAL' });
|
||||
});
|
||||
|
||||
const remaining = allocations.filter(
|
||||
(a) => a.id !== target.id && a.status !== 'LOADED' && a.status !== 'DEPARTED',
|
||||
).length;
|
||||
if (remaining === 0) {
|
||||
const done = await this.completeLoad(schedule, booking, userId ?? null);
|
||||
return {
|
||||
...done,
|
||||
allocationId,
|
||||
loadedWagons: allocations.length,
|
||||
totalWagons: allocations.length,
|
||||
completed: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
bookingId,
|
||||
allocationId,
|
||||
status: booking.status,
|
||||
loadedWagons: allocations.length - remaining,
|
||||
totalWagons: allocations.length,
|
||||
completed: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The at-loading cancel shrank the booking to its loaded wagons — if every
|
||||
* wagon left on it is LOADED, the load is complete: run the whole-booking
|
||||
* completion. Fired by BookingWagonCancellationService.cancelRemainingAtLoading.
|
||||
*/
|
||||
@OnEvent('booking.wagonsCancelledAtLoading')
|
||||
async onWagonsCancelledAtLoading(payload: {
|
||||
bookingId: string;
|
||||
scheduleId: string;
|
||||
userId?: string | null;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const allocations = await this.allocationsForBooking(
|
||||
this.dataSource.manager,
|
||||
payload.scheduleId,
|
||||
payload.bookingId,
|
||||
);
|
||||
const loaded = allocations.filter(
|
||||
(a) => a.status === 'LOADED' || a.status === 'DEPARTED',
|
||||
).length;
|
||||
if (!allocations.length || loaded < allocations.length) return;
|
||||
await this.loadBooking(payload.scheduleId, payload.bookingId, payload.userId);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Post-cancel load completion failed for booking ${payload.bookingId}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** The pre-load gates shared by whole-booking and per-wagon loading. */
|
||||
private async assertBookingLoadable(schedule: TrainSchedule, booking: Booking): Promise<void> {
|
||||
if (booking.loadedAt || booking.status === 'IN_TRANSIT') {
|
||||
throw new BadRequestException('Booking is already loaded');
|
||||
}
|
||||
@@ -86,6 +197,16 @@ export class BookingJourneyService {
|
||||
// Export cargo must be in the warehouse with a GRN before it can be loaded,
|
||||
// however it arrived and whatever it is allocated to.
|
||||
await assertExportReceivedWithGrn(this.dataSource, booking);
|
||||
}
|
||||
|
||||
/** The whole-booking load side effects — gates already passed. */
|
||||
private async completeLoad(
|
||||
schedule: TrainSchedule,
|
||||
booking: Booking,
|
||||
userId: string | null,
|
||||
) {
|
||||
const scheduleId = schedule.id;
|
||||
const bookingId = booking.id;
|
||||
// Direct truck-to-train cargo never sees the warehouse, so loading IS its
|
||||
// handover moment — the carriage acceptance sheet must go out to the
|
||||
// customer right here, not on a receive event that will never fire.
|
||||
@@ -165,6 +286,77 @@ export class BookingJourneyService {
|
||||
|
||||
async unloadBooking(scheduleId: string, bookingId: string, userId?: string | null) {
|
||||
const { schedule, booking } = await this.getScheduleBooking(scheduleId, bookingId);
|
||||
await this.assertBookingUnloadable(schedule, booking);
|
||||
return this.completeUnload(schedule, booking, userId ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm ONE wagon of the booking unloaded (per-wagon unloading). Tracking
|
||||
* only while wagons remain on the train — the booking stays IN_TRANSIT;
|
||||
* unloading the last wagon runs the whole-booking completion (ARRIVED/
|
||||
* COMPLETED, wagon settlement, events) exactly as the one-shot unload does.
|
||||
*/
|
||||
async unloadWagon(
|
||||
scheduleId: string,
|
||||
bookingId: string,
|
||||
allocationId: string,
|
||||
userId?: string | null,
|
||||
) {
|
||||
const { schedule, booking } = await this.getScheduleBooking(scheduleId, bookingId);
|
||||
await this.assertBookingUnloadable(schedule, booking);
|
||||
const allocations = await this.allocationsForBooking(
|
||||
this.dataSource.manager,
|
||||
scheduleId,
|
||||
bookingId,
|
||||
);
|
||||
if (!allocations.length) {
|
||||
throw new BadRequestException(
|
||||
'This booking has no wagon allocations on the schedule — use the whole-booking unload.',
|
||||
);
|
||||
}
|
||||
const target = allocations.find((a) => a.id === allocationId);
|
||||
if (!target) {
|
||||
throw new NotFoundException('Wagon allocation not found on this booking/schedule');
|
||||
}
|
||||
if (target.status === 'DEPARTED') {
|
||||
throw new BadRequestException('This wagon is already unloaded');
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
await this.dataSource.getRepository(WagonBookingAllocation).update(target.id, {
|
||||
status: 'DEPARTED',
|
||||
unloadedAt: now,
|
||||
unloadedByUserId: userId ?? null,
|
||||
});
|
||||
|
||||
const remaining = allocations.filter(
|
||||
(a) => a.id !== target.id && a.status !== 'DEPARTED',
|
||||
).length;
|
||||
if (remaining === 0) {
|
||||
const done = await this.completeUnload(schedule, booking, userId ?? null);
|
||||
return {
|
||||
...done,
|
||||
allocationId,
|
||||
unloadedWagons: allocations.length,
|
||||
totalWagons: allocations.length,
|
||||
completed: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
bookingId,
|
||||
allocationId,
|
||||
status: booking.status,
|
||||
unloadedWagons: allocations.length - remaining,
|
||||
totalWagons: allocations.length,
|
||||
completed: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** The pre-unload gates shared by whole-booking and per-wagon unloading. */
|
||||
private async assertBookingUnloadable(
|
||||
schedule: TrainSchedule,
|
||||
booking: Booking,
|
||||
): Promise<void> {
|
||||
if (booking.status !== 'IN_TRANSIT') {
|
||||
throw new BadRequestException(
|
||||
`Booking must be loaded/in transit before unloading (currently ${booking.status})`,
|
||||
@@ -173,7 +365,16 @@ export class BookingJourneyService {
|
||||
await this.assertTrainAtYard(schedule, booking.destinationYardId, 'destination');
|
||||
this.assertStationWorkStarted(schedule, booking.destinationYardId, 'unloading');
|
||||
await this.assertYardCanHandleCargo(booking, booking.destinationYardId, 'destination');
|
||||
}
|
||||
|
||||
/** The whole-booking unload side effects — gates already passed. */
|
||||
private async completeUnload(
|
||||
schedule: TrainSchedule,
|
||||
booking: Booking,
|
||||
userId: string | null,
|
||||
) {
|
||||
const scheduleId = schedule.id;
|
||||
const bookingId = booking.id;
|
||||
// Intercity has no clearance/delivery tail — unloading completes it. Import/
|
||||
// export continue into clearance, keyed on the booking's own arrival.
|
||||
const nextStatus = booking.tradeDirection === 'DOMESTIC' ? 'COMPLETED' : 'ARRIVED';
|
||||
@@ -498,7 +699,17 @@ export class BookingJourneyService {
|
||||
.findOne({ where: { id: bookingId } });
|
||||
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
if (booking.trainScheduleId !== scheduleId) {
|
||||
throw new BadRequestException('Booking is not assigned to this schedule');
|
||||
// The schedule↔booking LINK is the same authority the workspace list
|
||||
// (listYardWork) renders from — some flows (export train pick) create it
|
||||
// with wagon allocations before bookings.train_schedule_id is stamped.
|
||||
// Trusting only the column made those rows show a Load button that
|
||||
// always 400'd.
|
||||
const linked = await this.dataSource.getRepository(TrainScheduleBooking).findOne({
|
||||
where: { trainScheduleId: scheduleId, bookingId },
|
||||
});
|
||||
if (!linked) {
|
||||
throw new BadRequestException('Booking is not assigned to this schedule');
|
||||
}
|
||||
}
|
||||
return { schedule, booking };
|
||||
}
|
||||
|
||||
@@ -707,6 +707,46 @@ export class TrainSchedulingController {
|
||||
return this.bookingJourneyService.unloadBooking(id, bookingId);
|
||||
}
|
||||
|
||||
@Post("schedules/:id/bookings/:bookingId/wagons/:allocationId/load")
|
||||
@TrainSchedulingLoad()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Confirm ONE wagon of the booking loaded (per-wagon loading). The booking stays PAID until every remaining wagon is LOADED; the last wagon runs the whole-booking load completion.",
|
||||
})
|
||||
loadScheduleBookingWagon(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("bookingId", ParseUUIDPipe) bookingId: string,
|
||||
@Param("allocationId", ParseUUIDPipe) allocationId: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.bookingJourneyService.loadWagon(
|
||||
id,
|
||||
bookingId,
|
||||
allocationId,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Post("schedules/:id/bookings/:bookingId/wagons/:allocationId/unload")
|
||||
@TrainSchedulingUnload()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Confirm ONE wagon of the booking unloaded (per-wagon unloading). The booking stays IN_TRANSIT until the last wagon, which runs the whole-booking unload completion.",
|
||||
})
|
||||
unloadScheduleBookingWagon(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("bookingId", ParseUUIDPipe) bookingId: string,
|
||||
@Param("allocationId", ParseUUIDPipe) allocationId: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.bookingJourneyService.unloadWagon(
|
||||
id,
|
||||
bookingId,
|
||||
allocationId,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Post("schedules/:id/intercity/:bookingId/load")
|
||||
@TrainSchedulingLoad()
|
||||
@ApiOperation({
|
||||
|
||||
@@ -2946,6 +2946,10 @@ export class TrainSchedulingService {
|
||||
// The yard plan this departure was SOLD against must match where the steel
|
||||
// actually stands: a wagon sold from Dire but still in Mojo cannot board.
|
||||
await this.assertPlannedYardsAligned(schedule);
|
||||
// Per-wagon loading: a booking mid-load is neither ridable nor removable —
|
||||
// every wagon must be LOADED, or the never-loaded remainder cancelled
|
||||
// (at-loading cancellation), before the train departs.
|
||||
await this.assertNoPartiallyLoadedBookings(schedule);
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const trainNumber = await this.assignTrainNumber(manager, schedule);
|
||||
@@ -3144,6 +3148,42 @@ export class TrainSchedulingService {
|
||||
* PAID — or FULLY_EXECUTED for shipping-line bookings, which never prepay
|
||||
* (their charge sits on the credit ledger) yet ride from accept.
|
||||
*/
|
||||
/**
|
||||
* Per-wagon loading dispatch gate: a booking with SOME wagons LOADED and
|
||||
* SOME still PLANNED/RESERVED must resolve before departure — load the rest
|
||||
* or cancel it (which shrinks the booking to its loaded wagons). Blocking
|
||||
* here beats silently unassigning: unassign would delete LOADED allocations
|
||||
* and strand cargo that is physically on the train.
|
||||
*/
|
||||
private async assertNoPartiallyLoadedBookings(schedule: TrainSchedule): Promise<void> {
|
||||
if (!schedule.trainSetId) return;
|
||||
const rows: Array<{ reference: string; loaded: string; total: string }> =
|
||||
await this.dataSource.query(
|
||||
`SELECT b.reference,
|
||||
COUNT(*) FILTER (WHERE a.status IN ('LOADED', 'DEPARTED')) AS loaded,
|
||||
COUNT(*) AS total
|
||||
FROM freight.wagon_booking_allocations a
|
||||
JOIN freight.train_set_wagons tsw ON tsw.id = a.train_set_wagon_id
|
||||
JOIN freight.bookings b ON b.id = a.booking_id
|
||||
WHERE tsw.train_set_id = $1
|
||||
AND a.deleted_at IS NULL
|
||||
AND tsw.deleted_at IS NULL
|
||||
AND b.deleted_at IS NULL
|
||||
GROUP BY b.id, b.reference
|
||||
HAVING COUNT(*) FILTER (WHERE a.status IN ('LOADED', 'DEPARTED')) > 0
|
||||
AND COUNT(*) FILTER (WHERE a.status NOT IN ('LOADED', 'DEPARTED')) > 0`,
|
||||
[schedule.trainSetId],
|
||||
);
|
||||
if (rows.length) {
|
||||
const detail = rows
|
||||
.map((r) => `${r.reference} (${r.loaded}/${r.total} wagons loaded)`)
|
||||
.join(', ');
|
||||
throw new BadRequestException(
|
||||
`Cannot dispatch: booking(s) partially loaded — load every wagon or cancel the remainder first: ${detail}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async unloadedOriginBoarderIds(
|
||||
scheduleId: string,
|
||||
originYardId: string,
|
||||
@@ -3157,6 +3197,7 @@ export class TrainSchedulingService {
|
||||
AND b.deleted_at IS NULL
|
||||
AND b.origin_yard_id = $2
|
||||
AND b.loaded_at IS NULL
|
||||
AND b.loading_started_at IS NULL
|
||||
AND COALESCE(tsb.loading_status, 'UNLOADED') <> 'LOADED'
|
||||
AND b.is_government = false
|
||||
AND (b.status = 'PAID'
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
export class SendWagonToMaintenanceDto {
|
||||
@ApiPropertyOptional({
|
||||
@ApiProperty({
|
||||
description:
|
||||
"Why the wagon is going to maintenance. Stored on the wagon's status-history " +
|
||||
'log alongside the train it was detached from, matching the fleet desk flow.',
|
||||
"Why the wagon is going to maintenance — required. Stored on the wagon's " +
|
||||
'status-history log alongside the train it was detached from, and on the ' +
|
||||
"train's wagon-adjustment history.",
|
||||
maxLength: 500,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(500)
|
||||
note?: string;
|
||||
note!: string;
|
||||
}
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
import { WagonDetachRequestAction } from '../entities/wagon-detach-request.entity';
|
||||
|
||||
export class CreateWagonDetachRequestDto {
|
||||
/**
|
||||
* Detach a wagon from the consist. The reason is always required — it is
|
||||
* recorded both as an auto-approved wagon_detach_requests audit row and on the
|
||||
* train's wagon-adjustment history (the History tab).
|
||||
*/
|
||||
export class DetachWagonDto {
|
||||
@ApiProperty({
|
||||
enum: WagonDetachRequestAction,
|
||||
description: 'What approval is being asked for: a plain detach, or detach + MAINTENANCE.',
|
||||
})
|
||||
@IsEnum(WagonDetachRequestAction)
|
||||
action!: WagonDetachRequestAction;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Why the wagon must leave the scheduled consist. Shown to the approver.',
|
||||
description: 'Why the wagon leaves the consist — required.',
|
||||
maxLength: 500,
|
||||
})
|
||||
@IsString()
|
||||
@@ -20,14 +16,3 @@ export class CreateWagonDetachRequestDto {
|
||||
@MaxLength(500)
|
||||
reason!: string;
|
||||
}
|
||||
|
||||
export class DecideWagonDetachRequestDto {
|
||||
@ApiPropertyOptional({
|
||||
description: 'Decision note — required when rejecting, optional when approving.',
|
||||
maxLength: 500,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
note?: string;
|
||||
}
|
||||
|
||||
@@ -25,10 +25,7 @@ import { BuildTrainDto } from './dto/build-train.dto';
|
||||
import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto';
|
||||
import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto';
|
||||
import { SendWagonToMaintenanceDto } from './dto/send-wagon-to-maintenance.dto';
|
||||
import {
|
||||
CreateWagonDetachRequestDto,
|
||||
DecideWagonDetachRequestDto,
|
||||
} from './dto/wagon-detach-request.dto';
|
||||
import { DetachWagonDto } from './dto/wagon-detach-request.dto';
|
||||
import { UpdateTrainDetailsDto } from './dto/update-train-details.dto';
|
||||
import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto';
|
||||
import { UpdateTrainYardDto } from './dto/update-train-yard.dto';
|
||||
@@ -51,7 +48,6 @@ import { TrainBuilderService } from './train-builder.service';
|
||||
FREIGHT_PERMS.trains.changeWagonYard,
|
||||
FREIGHT_PERMS.trains.toggleActive,
|
||||
FREIGHT_PERMS.trains.disband,
|
||||
FREIGHT_PERMS.trains.approveWagonDetach,
|
||||
])
|
||||
export class TrainBuilderController {
|
||||
constructor(private readonly trainBuilderService: TrainBuilderService) {}
|
||||
@@ -185,29 +181,38 @@ export class TrainBuilderController {
|
||||
|
||||
@Delete(':id/wagons/:wagonId')
|
||||
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
|
||||
@ApiOperation({ summary: 'Detach one wagon from the consist' })
|
||||
@ApiOperation({ summary: 'Detach one wagon from the consist — a reason is required' })
|
||||
removeWagon(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('wagonId', ParseUUIDPipe) wagonId: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@Body() dto: DetachWagonDto,
|
||||
) {
|
||||
return this.trainBuilderService.removeWagon(id, wagonId, resolveAuthUserId(user));
|
||||
return this.trainBuilderService.removeWagon(
|
||||
id,
|
||||
wagonId,
|
||||
resolveAuthUserId(user),
|
||||
dto.reason,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/wagons/:wagonId/maintenance')
|
||||
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
|
||||
@ApiOperation({ summary: 'Detach one wagon and move it to MAINTENANCE status' })
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Detach one wagon and move it to MAINTENANCE status — a reason (note) is required',
|
||||
})
|
||||
sendWagonToMaintenance(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('wagonId', ParseUUIDPipe) wagonId: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@Body() dto?: SendWagonToMaintenanceDto,
|
||||
@Body() dto: SendWagonToMaintenanceDto,
|
||||
) {
|
||||
return this.trainBuilderService.sendWagonToMaintenance(
|
||||
id,
|
||||
wagonId,
|
||||
resolveAuthUserId(user),
|
||||
dto?.note,
|
||||
dto.note,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -220,65 +225,6 @@ export class TrainBuilderController {
|
||||
return this.trainBuilderService.listDetachRequests(id);
|
||||
}
|
||||
|
||||
@Post(':id/wagons/:wagonId/detach-requests')
|
||||
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Request approval to detach a wagon (or send it to maintenance) while the train is on a SCHEDULED run',
|
||||
})
|
||||
createDetachRequest(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('wagonId', ParseUUIDPipe) wagonId: string,
|
||||
@Body() dto: CreateWagonDetachRequestDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.trainBuilderService.createDetachRequest(
|
||||
id,
|
||||
wagonId,
|
||||
dto,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/detach-requests/:requestId/approve')
|
||||
@FleetManage(FREIGHT_PERMS.trains.approveWagonDetach)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Approve a detach/maintenance request — the detach executes immediately; the approver must not be the requester',
|
||||
})
|
||||
approveDetachRequest(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('requestId', ParseUUIDPipe) requestId: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@Body() dto?: DecideWagonDetachRequestDto,
|
||||
) {
|
||||
return this.trainBuilderService.decideDetachRequest(
|
||||
id,
|
||||
requestId,
|
||||
'APPROVE',
|
||||
resolveAuthUserId(user),
|
||||
dto?.note,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/detach-requests/:requestId/reject')
|
||||
@FleetManage(FREIGHT_PERMS.trains.approveWagonDetach)
|
||||
@ApiOperation({ summary: 'Reject a detach/maintenance request — a note explaining why is required' })
|
||||
rejectDetachRequest(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('requestId', ParseUUIDPipe) requestId: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@Body() dto: DecideWagonDetachRequestDto,
|
||||
) {
|
||||
return this.trainBuilderService.decideDetachRequest(
|
||||
id,
|
||||
requestId,
|
||||
'REJECT',
|
||||
resolveAuthUserId(user),
|
||||
dto.note,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/reorder-wagons')
|
||||
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
|
||||
@ApiOperation({ summary: 'Persist a drag-reorder of the full consist' })
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
import { TrainBuilderService } from './train-builder.service';
|
||||
import { WagonDetachRequestAction } from './entities/wagon-detach-request.entity';
|
||||
|
||||
/**
|
||||
* Every detach / send-to-maintenance carries a reason — scheduled run or not
|
||||
* (the reason replaced the old second-staff approval). The recorder rejects a
|
||||
* missing/blank one before anything is written, and stores a trimmed, capped
|
||||
* copy on the audit row otherwise.
|
||||
*/
|
||||
describe('TrainBuilderService — detach reason is always required', () => {
|
||||
const svc = Object.create(TrainBuilderService.prototype) as {
|
||||
recordDetachReason(
|
||||
manager: unknown,
|
||||
trainId: string,
|
||||
wagonId: string,
|
||||
action: WagonDetachRequestAction,
|
||||
reason: string | null | undefined,
|
||||
userId?: string | null,
|
||||
): Promise<void>;
|
||||
};
|
||||
|
||||
/** Minimal EntityManager: records what the recorder would persist. */
|
||||
const managerSpy = () => {
|
||||
const saved: Array<Record<string, unknown>> = [];
|
||||
return {
|
||||
saved,
|
||||
getRepository: (entity: { name: string }) =>
|
||||
entity.name === 'Wagon'
|
||||
? { findOne: async () => ({ wagonNumber: 'NW5-0412' }) }
|
||||
: {
|
||||
create: (row: Record<string, unknown>) => row,
|
||||
save: async (row: Record<string, unknown>) => {
|
||||
saved.push(row);
|
||||
return row;
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
it.each([undefined, null, '', ' '])('refuses a blank reason (%p)', async (reason) => {
|
||||
const manager = managerSpy();
|
||||
await expect(
|
||||
svc.recordDetachReason(
|
||||
manager,
|
||||
'train-1',
|
||||
'wagon-1',
|
||||
WagonDetachRequestAction.Detach,
|
||||
reason,
|
||||
'user-1',
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
// Nothing is written when the reason is missing.
|
||||
expect(manager.saved).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('records the reason as an auto-approved audit row', async () => {
|
||||
const manager = managerSpy();
|
||||
await svc.recordDetachReason(
|
||||
manager,
|
||||
'train-1',
|
||||
'wagon-1',
|
||||
WagonDetachRequestAction.Maintenance,
|
||||
' Brake shoe worn through ',
|
||||
'user-1',
|
||||
);
|
||||
expect(manager.saved).toHaveLength(1);
|
||||
const row = manager.saved[0];
|
||||
expect(row).toMatchObject({
|
||||
trainId: 'train-1',
|
||||
wagonId: 'wagon-1',
|
||||
wagonNumber: 'NW5-0412',
|
||||
action: WagonDetachRequestAction.Maintenance,
|
||||
reason: 'Brake shoe worn through',
|
||||
// No second person: the actor is both requester and decider.
|
||||
status: 'APPROVED',
|
||||
requestedBy: 'user-1',
|
||||
decidedBy: 'user-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('caps an over-long reason at the column width', async () => {
|
||||
const manager = managerSpy();
|
||||
await svc.recordDetachReason(
|
||||
manager,
|
||||
'train-1',
|
||||
'wagon-1',
|
||||
WagonDetachRequestAction.Detach,
|
||||
'x'.repeat(900),
|
||||
null,
|
||||
);
|
||||
expect(String(manager.saved[0].reason)).toHaveLength(500);
|
||||
});
|
||||
});
|
||||
@@ -30,7 +30,6 @@ import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto';
|
||||
import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto';
|
||||
import { UpdateTrainDetailsDto } from './dto/update-train-details.dto';
|
||||
import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto';
|
||||
import { CreateWagonDetachRequestDto } from './dto/wagon-detach-request.dto';
|
||||
import { TrainLocomotive } from './entities/train-locomotive.entity';
|
||||
import { Train } from './entities/train.entity';
|
||||
import {
|
||||
@@ -253,6 +252,7 @@ export class TrainBuilderService {
|
||||
yardLabel: string | null;
|
||||
actor: string | null;
|
||||
scheduleReference: string | null;
|
||||
reason: string | null;
|
||||
occurredAt: Date;
|
||||
}>,
|
||||
] = await Promise.all([
|
||||
@@ -270,6 +270,7 @@ export class TrainBuilderService {
|
||||
COALESCE(y.label, y.code) AS "yardLabel",
|
||||
COALESCE(u.username, u.email) AS "actor",
|
||||
ts.reference AS "scheduleReference",
|
||||
l.reason,
|
||||
l.occurred_at AS "occurredAt"
|
||||
FROM freight.schedule_wagon_adjustment_logs l
|
||||
LEFT JOIN freight.yards y ON y.id = l.yard_id
|
||||
@@ -491,8 +492,9 @@ export class TrainBuilderService {
|
||||
: null,
|
||||
},
|
||||
activeSchedules: schedules,
|
||||
// Composition is frozen while the train is out on a dispatched run.
|
||||
editable: !schedules.some((s) => s.status === 'DISPATCHED'),
|
||||
// The built train is always editable — dispatched/arrived runs render from
|
||||
// their frozen snapshot, so consist edits reach only DRAFT/SCHEDULED runs.
|
||||
editable: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -754,22 +756,40 @@ export class TrainBuilderService {
|
||||
return this.getComposition(id);
|
||||
}
|
||||
|
||||
/** Detach one wagon and close the sequence gap it leaves. */
|
||||
async removeWagon(id: string, wagonId: string, userId?: string | null) {
|
||||
/**
|
||||
* Detach one wagon and close the sequence gap it leaves. On a SCHEDULED run
|
||||
* the detach still executes directly, but a reason is required and recorded
|
||||
* in wagon_detach_requests (auto-approved) — the audit trail without the
|
||||
* former second-staff approval step.
|
||||
*/
|
||||
async removeWagon(
|
||||
id: string,
|
||||
wagonId: string,
|
||||
userId?: string | null,
|
||||
reason?: string | null,
|
||||
) {
|
||||
const pending = await this.dataSource.transaction(async (manager) => {
|
||||
await this.assertDetachNeedsNoApproval(manager, id);
|
||||
return this.removeWagonCore(manager, id, wagonId, userId);
|
||||
await this.recordDetachReason(
|
||||
manager,
|
||||
id,
|
||||
wagonId,
|
||||
WagonDetachRequestAction.Detach,
|
||||
reason,
|
||||
userId,
|
||||
);
|
||||
return this.removeWagonCore(manager, id, wagonId, userId, reason);
|
||||
});
|
||||
await this.reconcileWindowAfterConsistChange(pending);
|
||||
return this.getComposition(id);
|
||||
}
|
||||
|
||||
/** Transactional body of removeWagon — also runs under an approved detach request. */
|
||||
/** Transactional body of removeWagon — `reason` rides into the history log. */
|
||||
private async removeWagonCore(
|
||||
manager: EntityManager,
|
||||
id: string,
|
||||
wagonId: string,
|
||||
userId?: string | null,
|
||||
reason?: string | null,
|
||||
): Promise<PendingWindowCheck | null> {
|
||||
const train = await this.getEditableTrain(manager, id);
|
||||
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
|
||||
@@ -791,6 +811,7 @@ export class TrainBuilderService {
|
||||
[{ action: 'REMOVE', wagonId: wagon.id, wagonNumber: wagon.wagonNumber }],
|
||||
userId ?? null,
|
||||
wagon.currentYardId ?? train.currentYardId ?? null,
|
||||
reason,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -805,8 +826,16 @@ export class TrainBuilderService {
|
||||
userId?: string | null,
|
||||
note?: string | null,
|
||||
) {
|
||||
// `note` is the required reason — recordDetachReason rejects it empty.
|
||||
const pending = await this.dataSource.transaction(async (manager) => {
|
||||
await this.assertDetachNeedsNoApproval(manager, id);
|
||||
await this.recordDetachReason(
|
||||
manager,
|
||||
id,
|
||||
wagonId,
|
||||
WagonDetachRequestAction.Maintenance,
|
||||
note,
|
||||
userId,
|
||||
);
|
||||
return this.sendWagonToMaintenanceCore(manager, id, wagonId, userId, note);
|
||||
});
|
||||
await this.reconcileWindowAfterConsistChange(pending);
|
||||
@@ -883,96 +912,54 @@ export class TrainBuilderService {
|
||||
[{ action: 'REMOVE', wagonId: wagon.id, wagonNumber: wagon.wagonNumber }],
|
||||
userId ?? null,
|
||||
yardId,
|
||||
note,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct-detach guard: while this train carries a live SCHEDULED run,
|
||||
* removing a wagon changes a departure customers already booked against, so
|
||||
* it is a two-person action — refuse here and point at the request flow.
|
||||
* DRAFT stays freely editable; DISPATCHED is already frozen by
|
||||
* getEditableTrain (the train is IN_SERVICE).
|
||||
* Every detach / send-to-maintenance carries a REASON — scheduled run or
|
||||
* not — and an auto-approved wagon_detach_requests row records who did it
|
||||
* and why (the audit trail that replaced the former second-staff approval).
|
||||
* DISPATCHED trains never reach here: getEditableTrain freezes them.
|
||||
*/
|
||||
private async assertDetachNeedsNoApproval(
|
||||
private async recordDetachReason(
|
||||
manager: EntityManager,
|
||||
trainId: string,
|
||||
): Promise<void> {
|
||||
const scheduled = await this.findScheduledRun(manager, trainId);
|
||||
if (scheduled) {
|
||||
throw new ConflictException(
|
||||
`Train is on scheduled run ${scheduled.reference ?? scheduled.id} — detaching a wagon needs an approved detach request`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async findScheduledRun(
|
||||
manager: EntityManager,
|
||||
trainId: string,
|
||||
): Promise<{ id: string; reference: string | null } | null> {
|
||||
const rows: { id: string; reference: string | null }[] = await manager.query(
|
||||
`SELECT ts.id, ts.reference
|
||||
FROM freight.train_schedules ts
|
||||
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
|
||||
WHERE tset.train_id = $1
|
||||
AND ts.status = 'SCHEDULED'
|
||||
AND ts.deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[trainId],
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* File a detach/maintenance approval request for a wagon on a SCHEDULED
|
||||
* train. The request carries the reason; a different staffer with
|
||||
* trains:approve_wagon_detach decides it (approval executes the detach).
|
||||
*/
|
||||
async createDetachRequest(
|
||||
id: string,
|
||||
wagonId: string,
|
||||
dto: CreateWagonDetachRequestDto,
|
||||
action: WagonDetachRequestAction,
|
||||
reason: string | null | undefined,
|
||||
userId?: string | null,
|
||||
) {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const train = await this.getEditableTrain(manager, id);
|
||||
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
|
||||
if (!wagon || wagon.trainId !== train.id) {
|
||||
throw new NotFoundException(`Wagon ${wagonId} is not part of this train`);
|
||||
}
|
||||
const scheduled = await this.findScheduledRun(manager, train.id);
|
||||
if (!scheduled) {
|
||||
throw new ConflictException(
|
||||
'This train has no SCHEDULED run — detach the wagon directly, no approval needed',
|
||||
);
|
||||
}
|
||||
// Refuse up front what an approval could never execute (booked
|
||||
// allocations pin the wagon) — but release nothing yet: slots are only
|
||||
// touched when the approved detach actually runs.
|
||||
await this.assertDetachableAndReleaseStaleSlots(manager, wagon, { checkOnly: true });
|
||||
const repo = manager.getRepository(WagonDetachRequest);
|
||||
const open = await repo.findOne({
|
||||
where: { trainId: train.id, wagonId: wagon.id, status: WagonDetachRequestStatus.Pending },
|
||||
});
|
||||
if (open) {
|
||||
throw new ConflictException(
|
||||
`Wagon ${wagon.wagonNumber} already has a pending detach request`,
|
||||
);
|
||||
}
|
||||
return repo.save(
|
||||
repo.create({
|
||||
trainId: train.id,
|
||||
wagonId: wagon.id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
action: dto.action,
|
||||
reason: dto.reason.trim(),
|
||||
requestedBy: userId ?? null,
|
||||
}),
|
||||
): Promise<void> {
|
||||
const trimmed = reason?.trim();
|
||||
if (!trimmed) {
|
||||
throw new BadRequestException(
|
||||
`Give a reason for ${
|
||||
action === WagonDetachRequestAction.Maintenance
|
||||
? 'sending this wagon to maintenance'
|
||||
: 'detaching this wagon'
|
||||
}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
|
||||
const repo = manager.getRepository(WagonDetachRequest);
|
||||
const now = new Date();
|
||||
await repo.save(
|
||||
repo.create({
|
||||
trainId,
|
||||
wagonId,
|
||||
wagonNumber: wagon?.wagonNumber ?? wagonId,
|
||||
action,
|
||||
reason: trimmed.slice(0, 500),
|
||||
status: WagonDetachRequestStatus.Approved,
|
||||
requestedBy: userId ?? null,
|
||||
decidedBy: userId ?? null,
|
||||
decidedAt: now,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** All detach/maintenance requests of this train, newest first — the approval audit trail. */
|
||||
/** All detach/maintenance records of this train, newest first — the audit trail. */
|
||||
async listDetachRequests(trainId: string) {
|
||||
const rows: Array<{
|
||||
id: string;
|
||||
@@ -1012,68 +999,6 @@ export class TrainBuilderService {
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide a pending request. Approve executes the detach (or maintenance
|
||||
* move) in the same transaction that stamps the decision, so an approved row
|
||||
* can never exist without its detach having happened. The requester cannot
|
||||
* approve their own request; a rejection must carry a note.
|
||||
*/
|
||||
async decideDetachRequest(
|
||||
id: string,
|
||||
requestId: string,
|
||||
decision: 'APPROVE' | 'REJECT',
|
||||
userId?: string | null,
|
||||
note?: string | null,
|
||||
) {
|
||||
const pending = await this.dataSource.transaction(async (manager) => {
|
||||
const repo = manager.getRepository(WagonDetachRequest);
|
||||
const request = await repo.findOne({
|
||||
where: { id: requestId, trainId: id },
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
});
|
||||
if (!request) {
|
||||
throw new NotFoundException(`Detach request ${requestId} not found on this train`);
|
||||
}
|
||||
if (request.status !== WagonDetachRequestStatus.Pending) {
|
||||
throw new ConflictException(
|
||||
`This request was already ${request.status.toLowerCase()}`,
|
||||
);
|
||||
}
|
||||
const decisionNote = note?.trim() || null;
|
||||
if (decision === 'REJECT') {
|
||||
if (!decisionNote) {
|
||||
throw new BadRequestException('A note explaining the rejection is required');
|
||||
}
|
||||
await repo.update(request.id, {
|
||||
status: WagonDetachRequestStatus.Rejected,
|
||||
decidedBy: userId ?? null,
|
||||
decidedAt: new Date(),
|
||||
decisionNote,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
// The 4-eyes point of the gate: requester and approver are different people.
|
||||
if (request.requestedBy && userId && request.requestedBy === userId) {
|
||||
throw new ConflictException(
|
||||
'You filed this request — a different staff member must approve it',
|
||||
);
|
||||
}
|
||||
const pendingCheck =
|
||||
request.action === WagonDetachRequestAction.Maintenance
|
||||
? await this.sendWagonToMaintenanceCore(manager, id, request.wagonId, userId, request.reason)
|
||||
: await this.removeWagonCore(manager, id, request.wagonId, userId);
|
||||
await repo.update(request.id, {
|
||||
status: WagonDetachRequestStatus.Approved,
|
||||
decidedBy: userId ?? null,
|
||||
decidedAt: new Date(),
|
||||
decisionNote,
|
||||
});
|
||||
return pendingCheck;
|
||||
});
|
||||
await this.reconcileWindowAfterConsistChange(pending);
|
||||
return this.getComposition(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule occupancy lives on TrainSetWagon slots (per-schedule snapshot),
|
||||
* not on the Wagon entity — a wagon is busy when any live (DRAFT/SCHEDULED/
|
||||
@@ -1113,6 +1038,9 @@ export class TrainBuilderService {
|
||||
wagon: Wagon,
|
||||
opts: { checkOnly?: boolean } = {},
|
||||
): Promise<void> {
|
||||
// Only DRAFT/SCHEDULED runs still follow the live consist, so only they can
|
||||
// pin a wagon. A DISPATCHED/ARRIVED run reads its frozen snapshot and is
|
||||
// unaffected by what happens to the physical train behind it.
|
||||
const rows: { id: string; train_set_id: string; status: string; allocs: string }[] =
|
||||
await manager.query(
|
||||
`SELECT tsw.id, tsw.train_set_id, ts.status,
|
||||
@@ -1123,15 +1051,18 @@ export class TrainBuilderService {
|
||||
FROM freight.train_set_wagons tsw
|
||||
JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id
|
||||
WHERE tsw.physical_wagon_id = $1
|
||||
AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')
|
||||
AND ts.status IN ('DRAFT', 'SCHEDULED')
|
||||
AND ts.deleted_at IS NULL
|
||||
AND tsw.deleted_at IS NULL`,
|
||||
[wagon.id],
|
||||
);
|
||||
if (!rows.length) return;
|
||||
if (rows.some((r) => Number(r.allocs) > 0 || r.status === 'DISPATCHED')) {
|
||||
// Cargo already allocated to a live run keeps its wagon: the booking must be
|
||||
// unassigned from the slot first, so a customer's shipment can never lose its
|
||||
// wagon as a side effect of editing the train.
|
||||
if (rows.some((r) => Number(r.allocs) > 0)) {
|
||||
throw new ConflictException(
|
||||
`Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be removed`,
|
||||
`Wagon ${wagon.wagonNumber} is carrying cargo on a live schedule — unassign its bookings before removing it`,
|
||||
);
|
||||
}
|
||||
if (opts.checkOnly) return;
|
||||
@@ -1162,25 +1093,9 @@ export class TrainBuilderService {
|
||||
if (current.size !== incoming.size || [...current].some((wid) => !incoming.has(wid))) {
|
||||
throw new BadRequestException('Reorder must include every wagon of the train exactly once');
|
||||
}
|
||||
// Only a rolling train is frozen. Pre-dispatch (DRAFT/SCHEDULED) reorder
|
||||
// is allowed — the pinned schedules' consists are resequenced below so
|
||||
// they can never desync from the built train's real order.
|
||||
const dispatched: { exists: boolean }[] = await manager.query(
|
||||
`SELECT TRUE AS exists
|
||||
FROM freight.train_set_wagons tsw
|
||||
JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id
|
||||
WHERE tsw.physical_wagon_id = ANY($1::uuid[])
|
||||
AND ts.status = 'DISPATCHED'
|
||||
AND ts.deleted_at IS NULL
|
||||
AND tsw.deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[[...current]],
|
||||
);
|
||||
if (dispatched.length > 0) {
|
||||
throw new ConflictException(
|
||||
'This train is dispatched — wagons cannot be reordered while it is rolling.',
|
||||
);
|
||||
}
|
||||
// Reorder is allowed at any time, dispatched runs included: a DISPATCHED
|
||||
// schedule renders the order frozen in its snapshot, and only the
|
||||
// DRAFT/SCHEDULED consists resequenced below follow the built train.
|
||||
for (let i = 0; i < dto.wagonIds.length; i++) {
|
||||
await manager.getRepository(Wagon).update(dto.wagonIds[i], { sequenceNumber: i + 1 });
|
||||
}
|
||||
@@ -1402,6 +1317,7 @@ export class TrainBuilderService {
|
||||
changes: Array<{ action: 'ADD' | 'REMOVE'; wagonId: string; wagonNumber: string }>,
|
||||
userId: string | null,
|
||||
yardId: string | null,
|
||||
reason?: string | null,
|
||||
): Promise<PendingWindowCheck | null> {
|
||||
if (!changes.length) return null;
|
||||
const trainSet = await manager
|
||||
@@ -1443,6 +1359,7 @@ export class TrainBuilderService {
|
||||
wagonNumber: c.wagonNumber,
|
||||
adjustedByUserId: userId,
|
||||
yardId,
|
||||
reason: reason?.trim() || null,
|
||||
occurredAt: now,
|
||||
}),
|
||||
),
|
||||
@@ -1481,17 +1398,21 @@ export class TrainBuilderService {
|
||||
}
|
||||
|
||||
/** Load + freeze the train row for edit; block edits while it is out on a run. */
|
||||
/**
|
||||
* The built train is editable at ANY time, including while it is out on a
|
||||
* dispatched run. A dispatched/arrived schedule froze its own wagon plan into
|
||||
* `wagonAllocationSnapshot` at the transition and renders from that, so it can
|
||||
* never be disturbed by later consist edits; only DRAFT/SCHEDULED runs follow
|
||||
* the live train (see syncLiveScheduleAfterConsistChange). Per-wagon safety
|
||||
* still applies — assertDetachableAndReleaseStaleSlots refuses to pull a wagon
|
||||
* whose cargo is allocated to a live run.
|
||||
*/
|
||||
private async getEditableTrain(manager: EntityManager, id: string): Promise<Train> {
|
||||
const train = await manager.getRepository(Train).findOne({
|
||||
where: { id },
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
});
|
||||
if (!train) throw new NotFoundException(`Train ${id} not found`);
|
||||
if (train.status === Freight.TrainStatus.InService) {
|
||||
throw new ConflictException(
|
||||
`Train ${train.code} is out on a dispatched run; its composition is frozen until arrival`,
|
||||
);
|
||||
}
|
||||
return train;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import { Invoice } from "../billing/entities/invoice.entity";
|
||||
import { InvoiceLine } from "../billing/entities/invoice-line.entity";
|
||||
|
||||
import { PayInvoiceDto as GatewayPayInvoiceDto } from "../billing/dto/pay-invoice.dto";
|
||||
import { settlementReferences } from "../billing/invoice-settlement.util";
|
||||
import {
|
||||
InvoiceDocumentModel,
|
||||
sameCompanyName,
|
||||
@@ -785,6 +786,16 @@ export class WarehouseInvoiceService {
|
||||
? `${lastPayment.method ?? "MANUAL"} / ${date(lastPayment.paidAt) ?? "-"}`
|
||||
: null,
|
||||
},
|
||||
// The provider's own transaction number (CBE `FT…`, telebirr receipt no., a
|
||||
// teller's bank-slip ref) — the row above says only HOW and WHEN it was paid,
|
||||
// which nobody can reconcile a bank statement against. The warehouse view
|
||||
// projects the invoice ledger but not the linked gateway `payments` row, so the
|
||||
// ledger is the only source here; it carries the provider ref on every path
|
||||
// that has one.
|
||||
{
|
||||
label: "Transaction ref",
|
||||
value: settlementReferences({ payments: invoice.payments }),
|
||||
},
|
||||
],
|
||||
categoryHeader: "Fee type",
|
||||
lines: invoice.items.map((item) => ({
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
Flame,
|
||||
Link2,
|
||||
MapPin,
|
||||
MoveRight,
|
||||
Package,
|
||||
Receipt,
|
||||
Repeat,
|
||||
@@ -204,6 +205,19 @@ function emptyLine(size: string): ContainerLineDraft {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Heaviest load one wagon of this contract's bulk cargo may take, as computed
|
||||
* by the API from the cargo type's allowed wagon types. Null/undefined when no
|
||||
* wagon type is configured — the wagon-count check then falls away.
|
||||
*/
|
||||
function bulkMaxTonsPerWagon(
|
||||
contract: Freight.IContract,
|
||||
): number | null | undefined {
|
||||
return contract.cargoScope?.find(
|
||||
(scope) => scope.cargoType?.maxTonsPerWagon != null,
|
||||
)?.cargoType?.maxTonsPerWagon;
|
||||
}
|
||||
|
||||
function bulkUnitOfMeasure(
|
||||
contract: Freight.IContract,
|
||||
): "PER_TON" | "PER_ITEM" | "NUMBER_OF_WAGONS" {
|
||||
@@ -1000,8 +1014,20 @@ export default function GlCreateBookingForm() {
|
||||
}
|
||||
if (bulkUom === "NUMBER_OF_WAGONS") {
|
||||
const wagons = Number(bulk.requestedWagons || 0);
|
||||
const maxPerWagon = Number(
|
||||
(contract && bulkMaxTonsPerWagon(contract)) || 0,
|
||||
);
|
||||
if (!Number.isInteger(wagons) || wagons < 1) {
|
||||
errs.wagons = "Enter the number of wagons needed (at least 1).";
|
||||
} else if (qty > 0 && maxPerWagon > 0 && qty / wagons > maxPerWagon) {
|
||||
// Too few wagons for the tonnage can never ride: 200T across 3 wagons
|
||||
// is 66.67T each on a 50T wagon. Mirrors the server's
|
||||
// assertWagonShareFits so the button blocks before the API 400s.
|
||||
errs.wagons =
|
||||
`${qty} tons across ${wagons} wagon${wagons === 1 ? "" : "s"} loads ` +
|
||||
`${Math.round((qty / wagons) * 1000) / 1000}T per wagon, but a wagon of ` +
|
||||
`this cargo carries at most ${maxPerWagon}T — request at least ` +
|
||||
`${Math.ceil(qty / maxPerWagon)} wagons.`;
|
||||
}
|
||||
}
|
||||
const h = Number(bulk.hazardousQuantity || 0);
|
||||
@@ -1017,7 +1043,7 @@ export default function GlCreateBookingForm() {
|
||||
errs.reefer = `Can't exceed the cargo quantity (${qty}).`;
|
||||
}
|
||||
return errs;
|
||||
}, [isContainer, bulk, bulkUom]);
|
||||
}, [isContainer, bulk, bulkUom, contract]);
|
||||
|
||||
const dateError =
|
||||
!isIntercity && !scheduledDate ? "Select a shipment date." : undefined;
|
||||
@@ -1479,12 +1505,12 @@ export default function GlCreateBookingForm() {
|
||||
const header = (
|
||||
<Group justify="space-between" align="flex-end" wrap="wrap" gap="md" mb="lg">
|
||||
<Box>
|
||||
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
|
||||
{completeBookingId ? "Complete Shipment Booking" : "New Shipment Booking"}
|
||||
<Title order={1} fw={800} fz={28} style={{ letterSpacing: "-0.01em" }}>
|
||||
{completeBookingId ? "Complete shipment booking" : "New Shipment Booking"}
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mt={4}>
|
||||
{completeBookingId
|
||||
? `Clearance is finalized — enter the cargo details and shipment day to complete the booking under contract ${contract.reference}.`
|
||||
? `Clearance is finalized. Enter cargo details and the binding shipment day to complete this booking under contract ${contract.reference}.`
|
||||
: `Book a shipment on behalf of the customer for contract ${contract.reference}.`}
|
||||
</Text>
|
||||
</Box>
|
||||
@@ -1603,20 +1629,49 @@ export default function GlCreateBookingForm() {
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
) : (
|
||||
<Paper withBorder radius="md" p="md" style={{ borderColor: "#E6ECF2" }}>
|
||||
<Text fz={14} fw={600}>
|
||||
{selectedRoute?.originYard?.label ??
|
||||
selectedRoute?.originYard?.code ??
|
||||
"—"}{" "}
|
||||
→{" "}
|
||||
{selectedRoute?.destinationYard?.label ??
|
||||
selectedRoute?.destinationYard?.code ??
|
||||
"—"}
|
||||
</Text>
|
||||
<Text fz={12} c="dimmed" mt={2}>
|
||||
<Group
|
||||
wrap="nowrap"
|
||||
gap={16}
|
||||
align="center"
|
||||
px={18}
|
||||
py={18}
|
||||
style={{
|
||||
borderRadius: 14,
|
||||
border: "1px solid #E6ECF2",
|
||||
background: "#FBFCFD",
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Text fz={15} fw={700} c="#10202F">
|
||||
{selectedRoute?.originYard?.label ??
|
||||
selectedRoute?.originYard?.code ??
|
||||
"—"}
|
||||
</Text>
|
||||
<Text fz={12} c="#6B7C8E" mt={2}>
|
||||
Origin yard
|
||||
</Text>
|
||||
</Box>
|
||||
<MoveRight size={20} color="#0A6F4D" style={{ flexShrink: 0 }} />
|
||||
<Box>
|
||||
<Text fz={15} fw={700} c="#10202F">
|
||||
{selectedRoute?.destinationYard?.label ??
|
||||
selectedRoute?.destinationYard?.code ??
|
||||
"—"}
|
||||
</Text>
|
||||
<Text fz={12} c="#6B7C8E" mt={2}>
|
||||
Destination yard
|
||||
</Text>
|
||||
</Box>
|
||||
<Box style={{ flex: 1 }} />
|
||||
<Badge
|
||||
variant="light"
|
||||
color="teal"
|
||||
radius={8}
|
||||
styles={{ label: { fontSize: 12, fontWeight: 600 } }}
|
||||
>
|
||||
{contract.tradeDirection}
|
||||
</Text>
|
||||
</Paper>
|
||||
</Badge>
|
||||
</Group>
|
||||
)}
|
||||
</StepCard>
|
||||
|
||||
@@ -2322,16 +2377,6 @@ export default function GlCreateBookingForm() {
|
||||
even numbers. Add one more 20ft container or remove one — book{" "}
|
||||
{ft20Total + 1} or {ft20Total - 1} instead of {ft20Total}.
|
||||
</Alert>
|
||||
) : showErrors && !formValid ? (
|
||||
<Alert
|
||||
color="red"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<AlertCircle size={16} />}
|
||||
mb="sm"
|
||||
>
|
||||
Fix the highlighted fields before reviewing the price.
|
||||
</Alert>
|
||||
) : partnerError ? (
|
||||
// The review button is disabled while the parent booking is
|
||||
// incomplete, so the click that would reveal the errors never
|
||||
@@ -2346,7 +2391,35 @@ export default function GlCreateBookingForm() {
|
||||
{partnerError}
|
||||
</Alert>
|
||||
) : null}
|
||||
<Group justify="flex-end">
|
||||
<Group justify="space-between" wrap="nowrap" gap="md">
|
||||
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
{showErrors && !formValid && !oddBlocksSubmit && (
|
||||
<>
|
||||
<AlertCircle
|
||||
size={15}
|
||||
color="#C0392B"
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
<Text fz={13} fw={500} c="#C0392B">
|
||||
Fix the highlighted fields to review the price.
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
completeBookingId
|
||||
? `/dashboard/clearance/${completeBookingId}`
|
||||
: "/dashboard/contracts/clearance",
|
||||
)
|
||||
}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Tooltip
|
||||
label={
|
||||
oddBlocksSubmit
|
||||
@@ -2376,6 +2449,7 @@ export default function GlCreateBookingForm() {
|
||||
</Button>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Group>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { Badge, Group, Pagination, Paper, Stack, Text, ThemeIcon, Timeline } from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ArrowLeftRight, History, MapPin, Minus, Plus, TrainFront, User } from "lucide-react";
|
||||
import {
|
||||
ArrowLeftRight,
|
||||
History,
|
||||
MapPin,
|
||||
MessageSquare,
|
||||
Minus,
|
||||
Plus,
|
||||
TrainFront,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
@@ -49,7 +58,8 @@ export default function TrainHistoryPanel({ trainId }: { trainId: string }) {
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Who attached, detached or switched which wagon on this train — from
|
||||
the builder and from its trips — newest first.
|
||||
the builder and from its trips — newest first, with the reason
|
||||
given for detaching off a scheduled run.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
@@ -120,6 +130,17 @@ export default function TrainHistoryPanel({ trainId }: { trainId: string }) {
|
||||
</Group>
|
||||
) : null}
|
||||
</Group>
|
||||
{entry.reason ? (
|
||||
<Group gap={4} wrap="nowrap" align="flex-start" mt={4}>
|
||||
<MessageSquare
|
||||
size={12}
|
||||
style={{ flexShrink: 0, marginTop: 3 }}
|
||||
/>
|
||||
<Text size="xs" c="dimmed" style={{ fontStyle: "italic" }}>
|
||||
{entry.reason}
|
||||
</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
</Timeline.Item>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
@@ -44,6 +46,7 @@ import { api } from "@/services/api";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type {
|
||||
BookingWagonRow,
|
||||
EligibleContainerBooking,
|
||||
FreightType,
|
||||
TrainScheduleDetail,
|
||||
@@ -298,6 +301,12 @@ export function ScheduleWorkspacePanel({
|
||||
);
|
||||
|
||||
const [moveBookingId, setMoveBookingId] = useState<string | null>(null);
|
||||
// Per-wagon loading/unloading modal for one booking.
|
||||
const [wagonModal, setWagonModal] = useState<{
|
||||
bookingId: string;
|
||||
ref: string;
|
||||
phase: "load" | "unload";
|
||||
} | null>(null);
|
||||
const [moveTarget, setMoveTarget] = useState<string | null>(null);
|
||||
|
||||
// Pool → pick a same-day schedule with free wagons and place the booking there.
|
||||
@@ -887,6 +896,25 @@ export function ScheduleWorkspacePanel({
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{showLoad && boardHere ? (
|
||||
<Tooltip
|
||||
label="Load wagon by wagon — and cancel any wagon that will not ride"
|
||||
withArrow
|
||||
>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
disabled={!canLoad || !loadWindowStarted}
|
||||
onClick={() =>
|
||||
setWagonModal({ bookingId: b.id, ref, phase: "load" })
|
||||
}
|
||||
>
|
||||
Wagons
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{showTruckToTrain ? (
|
||||
<Tooltip
|
||||
label={
|
||||
@@ -950,6 +978,22 @@ export function ScheduleWorkspacePanel({
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{showUnload && alightHere ? (
|
||||
<Tooltip label="Unload wagon by wagon" withArrow>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
color="orange"
|
||||
radius="md"
|
||||
disabled={!canUnload || !unloadWindowStarted}
|
||||
onClick={() =>
|
||||
setWagonModal({ bookingId: b.id, ref, phase: "unload" })
|
||||
}
|
||||
>
|
||||
Wagons
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{canManage && !riding && !done ? (
|
||||
journey?.isGovernment ? null : (
|
||||
<Tooltip label="Remove from this train" withArrow>
|
||||
@@ -984,6 +1028,18 @@ export function ScheduleWorkspacePanel({
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
{/* Per-wagon load/unload for one booking */}
|
||||
{wagonModal ? (
|
||||
<PerWagonModal
|
||||
scheduleId={schedule.id}
|
||||
bookingId={wagonModal.bookingId}
|
||||
reference={wagonModal.ref}
|
||||
phase={wagonModal.phase}
|
||||
onClose={() => setWagonModal(null)}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Pool → same-day train assignment modal */}
|
||||
<Modal
|
||||
opened={Boolean(poolAssign)}
|
||||
@@ -1355,3 +1411,246 @@ function BookingCard({
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-wagon loading/unloading of one booking. Load phase also offers the
|
||||
* at-loading cancel of everything not yet loaded: the booking shrinks to its
|
||||
* loaded wagons (CUSTOMER fault invoices the cancellation fee to pay after;
|
||||
* EDR fault charges nothing) — required before the train may dispatch.
|
||||
*/
|
||||
function PerWagonModal({
|
||||
scheduleId,
|
||||
bookingId,
|
||||
reference,
|
||||
phase,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
scheduleId: string;
|
||||
bookingId: string;
|
||||
reference: string;
|
||||
phase: "load" | "unload";
|
||||
onClose: () => void;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const [cancelOpen, setCancelOpen] = useState(false);
|
||||
const [reason, setReason] = useState("");
|
||||
const [edrFault, setEdrFault] = useState(false);
|
||||
|
||||
const wagonsQuery = useQuery(api.trainScheduling.bookingWagons.queryOptions({
|
||||
input: { bookingId },
|
||||
}));
|
||||
const wagons: BookingWagonRow[] = wagonsQuery.data ?? [];
|
||||
const isDone = (w: BookingWagonRow) =>
|
||||
phase === "load"
|
||||
? w.status === "LOADED" || w.status === "DEPARTED"
|
||||
: w.status === "DEPARTED";
|
||||
const doneCount = wagons.filter(isDone).length;
|
||||
const pending = wagons.filter((w) => !isDone(w));
|
||||
|
||||
const loadWagon = useMutation(api.trainScheduling.loadScheduleBookingWagon.mutationOptions());
|
||||
const unloadWagon = useMutation(
|
||||
api.trainScheduling.unloadScheduleBookingWagon.mutationOptions(),
|
||||
);
|
||||
const cancelRemaining = useMutation(
|
||||
api.trainScheduling.cancelRemainingWagons.mutationOptions(),
|
||||
);
|
||||
const act = phase === "load" ? loadWagon : unloadWagon;
|
||||
|
||||
const errText = (err: unknown) =>
|
||||
isAxiosError(err)
|
||||
? ((err.response?.data as { message?: string })?.message ?? err.message)
|
||||
: String(err);
|
||||
|
||||
const onWagon = (allocationId: string) => {
|
||||
act
|
||||
.mutateAsync({ scheduleId, bookingId, allocationId })
|
||||
.then((r) => {
|
||||
void wagonsQuery.refetch();
|
||||
if (r.completed) {
|
||||
toast({
|
||||
title: phase === "load" ? "Booking fully loaded" : "Booking fully unloaded",
|
||||
description: `${reference}: every wagon is ${phase === "load" ? "loaded — the booking is in transit" : "unloaded — the booking arrived"}.`,
|
||||
});
|
||||
onChanged();
|
||||
onClose();
|
||||
} else {
|
||||
onChanged();
|
||||
}
|
||||
})
|
||||
.catch((err) =>
|
||||
toast({
|
||||
title: phase === "load" ? "Wagon load failed" : "Wagon unload failed",
|
||||
description: errText(err),
|
||||
variant: "destructive",
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const onCancelRemaining = () => {
|
||||
cancelRemaining
|
||||
.mutateAsync({ bookingId, scheduleId, reason: reason.trim(), edrFault })
|
||||
.then(() => {
|
||||
toast({
|
||||
title: "Remaining wagons cancelled",
|
||||
description: edrFault
|
||||
? `${reference}: ${pending.length} wagon(s) cancelled at EDR's fault — no fee charged; the credit is rebookable.`
|
||||
: `${reference}: ${pending.length} wagon(s) cancelled — the cancellation fee was invoiced to the customer; the credit is rebookable.`,
|
||||
});
|
||||
onChanged();
|
||||
onClose();
|
||||
})
|
||||
.catch((err) =>
|
||||
toast({
|
||||
title: "Cancellation failed",
|
||||
description: errText(err),
|
||||
variant: "destructive",
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened
|
||||
onClose={onClose}
|
||||
title={
|
||||
<Group gap={8}>
|
||||
<Train size={18} />
|
||||
<Text fw={700}>
|
||||
{phase === "load" ? "Load" : "Unload"} {reference} wagon by wagon
|
||||
</Text>
|
||||
</Group>
|
||||
}
|
||||
centered
|
||||
radius="lg"
|
||||
size="lg"
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Group gap={8}>
|
||||
<Badge size="sm" radius="sm" variant="light" color={doneCount ? "edr-green" : "gray"}>
|
||||
{doneCount}/{wagons.length} {phase === "load" ? "loaded" : "unloaded"}
|
||||
</Badge>
|
||||
{phase === "load" && doneCount > 0 && pending.length > 0 ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
The train cannot dispatch until the rest are loaded or cancelled.
|
||||
</Text>
|
||||
) : null}
|
||||
</Group>
|
||||
{wagonsQuery.isLoading ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
Loading wagons…
|
||||
</Text>
|
||||
) : wagons.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No wagon allocations yet — use the whole-booking button instead.
|
||||
</Text>
|
||||
) : (
|
||||
wagons.map((w) => (
|
||||
<Paper key={w.allocationId} withBorder radius="md" p="xs">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Badge size="sm" radius="sm" variant="outline" color="gray">
|
||||
{w.sequenceNo != null ? `#${w.sequenceNo}` : "—"}
|
||||
</Badge>
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{w.wagonNumber ?? w.wagonType ?? "Wagon"}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{w.wagonTypeCode ?? ""}
|
||||
{w.allocatedWeightTons
|
||||
? ` · ${Number(w.allocatedWeightTons).toFixed(1)}T`
|
||||
: ""}
|
||||
{w.containers?.length ? ` · ${w.containers.length} ctr` : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
{isDone(w) ? (
|
||||
<Badge
|
||||
size="sm"
|
||||
radius="sm"
|
||||
variant="filled"
|
||||
color={phase === "load" ? "edr-green" : "orange"}
|
||||
leftSection={<CheckCircle2 size={11} />}
|
||||
>
|
||||
{phase === "load" ? "Loaded" : "Unloaded"}
|
||||
</Badge>
|
||||
) : (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="filled"
|
||||
color={phase === "load" ? "edr-green" : "orange"}
|
||||
radius="md"
|
||||
leftSection={
|
||||
phase === "load" ? <PackageCheck size={13} /> : <PackageOpen size={13} />
|
||||
}
|
||||
loading={
|
||||
act.isPending && act.variables?.allocationId === w.allocationId
|
||||
}
|
||||
onClick={() => onWagon(w.allocationId)}
|
||||
>
|
||||
{phase === "load" ? "Load" : "Unload"}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
))
|
||||
)}
|
||||
|
||||
{phase === "load" && doneCount > 0 && pending.length > 0 ? (
|
||||
!cancelOpen ? (
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
radius="md"
|
||||
leftSection={<X size={14} />}
|
||||
onClick={() => setCancelOpen(true)}
|
||||
>
|
||||
Cancel the {pending.length} remaining wagon{pending.length === 1 ? "" : "s"}…
|
||||
</Button>
|
||||
) : (
|
||||
<Paper withBorder radius="md" p="sm">
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={600}>
|
||||
Cancel {pending.length} unloaded wagon
|
||||
{pending.length === 1 ? "" : "s"} of {reference}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
The booking shrinks to its loaded wagons and the freed freight
|
||||
becomes a rebookable credit. Customer fault: the cancellation
|
||||
fee is invoiced, payable afterwards. EDR fault: no fee.
|
||||
</Text>
|
||||
<Textarea
|
||||
label="Reason"
|
||||
placeholder="Why are these wagons not riding?"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.currentTarget.value)}
|
||||
minRows={2}
|
||||
required
|
||||
/>
|
||||
<Checkbox
|
||||
label="EDR's fault (wagon shortage, yard problem) — charge no fee"
|
||||
checked={edrFault}
|
||||
onChange={(e) => setEdrFault(e.currentTarget.checked)}
|
||||
/>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" radius="md" onClick={() => setCancelOpen(false)}>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
radius="md"
|
||||
disabled={!reason.trim()}
|
||||
loading={cancelRemaining.isPending}
|
||||
onClick={onCancelRemaining}
|
||||
>
|
||||
Cancel wagons{edrFault ? " (no fee)" : " (fee applies)"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)
|
||||
) : null}
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -493,6 +493,13 @@ export const URL_CONSTANTS = {
|
||||
`/train-scheduling/schedules/${id}/bookings/${bookingId}/load`,
|
||||
BOOKING_UNLOAD: (id: string, bookingId: string) =>
|
||||
`/train-scheduling/schedules/${id}/bookings/${bookingId}/unload`,
|
||||
BOOKING_WAGON_LOAD: (id: string, bookingId: string, allocationId: string) =>
|
||||
`/train-scheduling/schedules/${id}/bookings/${bookingId}/wagons/${allocationId}/load`,
|
||||
BOOKING_WAGON_UNLOAD: (id: string, bookingId: string, allocationId: string) =>
|
||||
`/train-scheduling/schedules/${id}/bookings/${bookingId}/wagons/${allocationId}/unload`,
|
||||
BOOKING_WAGONS: (bookingId: string) => `/bookings/${bookingId}/wagons`,
|
||||
CANCEL_REMAINING_WAGONS: (bookingId: string) =>
|
||||
`/bookings/${bookingId}/wagon-cancellations/at-loading`,
|
||||
INTERCITY_BOOKINGS: "/train-scheduling/intercity/bookings",
|
||||
INTERCITY_CANDIDATES: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/intercity-candidates`,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Freight } from "@edr/types";
|
||||
import { Freight } from "@edr/types";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
@@ -7,15 +7,19 @@ import {
|
||||
Card,
|
||||
Group,
|
||||
Modal,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { CheckCircle2, ExternalLink, RefreshCw, Search, X } from "lucide-react";
|
||||
import {
|
||||
CheckCircle2,
|
||||
CircleDollarSign,
|
||||
ExternalLink,
|
||||
Receipt,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import toast from "react-hot-toast";
|
||||
@@ -25,18 +29,125 @@ import {
|
||||
formatMoney,
|
||||
humanize,
|
||||
} from "@/components/customers";
|
||||
import {
|
||||
FilterBar,
|
||||
dateRangeParams,
|
||||
isoToLocalDateStr,
|
||||
useFilters,
|
||||
type FilterDef,
|
||||
} from "@/components/filters";
|
||||
import { KpiStrip } from "@/components/page";
|
||||
import { ExportButton } from "@/components/export/ExportButton";
|
||||
import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { useManualPaymentSettingsQuery } from "@/hooks/useManualPaymentSettings";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { api } from "@/services/api";
|
||||
import type { OfflineUsdInvoice } from "@/types/invoice";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableFooter,
|
||||
usePagination,
|
||||
type ColumnDef,
|
||||
} from "@edr/ui-common";
|
||||
PAYMENT_METHOD_OPTIONS,
|
||||
type InvoiceListFilter,
|
||||
type OfflineUsdInvoice,
|
||||
} from "@/types/invoice";
|
||||
import { DataTable, DataTableFooter, type ColumnDef } from "@edr/ui-common";
|
||||
|
||||
const STATUS_OPTIONS = Object.values(Freight.InvoiceStatus).map((value) => ({
|
||||
value,
|
||||
label: humanize(value),
|
||||
}));
|
||||
|
||||
const SOURCE_OPTIONS = Object.values(Freight.InvoiceSource).map((value) => ({
|
||||
value,
|
||||
label: humanize(value),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Mirrors `OPEN_STATUSES` in the API's billing service — the implicit "still
|
||||
* needs settling" cut this worklist applies when no status pill is set. Only
|
||||
* the export needs it spelled out (see `exportParams`); the list gets it from
|
||||
* the server.
|
||||
*/
|
||||
const OPEN_STATUSES = [
|
||||
Freight.InvoiceStatus.Issued,
|
||||
Freight.InvoiceStatus.Pending,
|
||||
Freight.InvoiceStatus.PaymentProcessing,
|
||||
Freight.InvoiceStatus.PartiallyPaid,
|
||||
Freight.InvoiceStatus.Overdue,
|
||||
];
|
||||
|
||||
/**
|
||||
* The same filter vocabulary the invoices list uses, minus `currency` — this
|
||||
* panel is mounted once per currency and pins it from the prop, so offering it
|
||||
* as a pill could only contradict the tab you are on.
|
||||
*/
|
||||
const MANUAL_PAYMENT_FILTER_DEFS: FilterDef[] = [
|
||||
{ key: "statuses", label: "Status", type: "enum", options: STATUS_OPTIONS },
|
||||
{
|
||||
// Computed from the balance and due date rather than read off `status` —
|
||||
// nothing sweeps PENDING rows into OVERDUE, so the status under-reports.
|
||||
key: "settlement",
|
||||
label: "Settlement",
|
||||
type: "enum",
|
||||
multiple: false,
|
||||
options: [
|
||||
{ value: "outstanding", label: "Outstanding" },
|
||||
{ value: "overdue", label: "Overdue" },
|
||||
],
|
||||
toParams: (v) =>
|
||||
v.v[0] === "overdue" ? { overdue: "true" } : { hasBalance: "true" },
|
||||
},
|
||||
{
|
||||
key: "issued",
|
||||
label: "Issued",
|
||||
type: "date",
|
||||
operators: ["between", "before", "after"],
|
||||
toParams: dateRangeParams("issuedFrom", "issuedTo"),
|
||||
},
|
||||
{
|
||||
key: "sources",
|
||||
label: "Source",
|
||||
type: "enum",
|
||||
secondary: true,
|
||||
options: SOURCE_OPTIONS,
|
||||
},
|
||||
{
|
||||
key: "paymentMethods",
|
||||
label: "Payment method",
|
||||
type: "enum",
|
||||
secondary: true,
|
||||
options: PAYMENT_METHOD_OPTIONS,
|
||||
},
|
||||
{
|
||||
key: "due",
|
||||
label: "Due",
|
||||
type: "date",
|
||||
secondary: true,
|
||||
operators: ["between", "before", "after"],
|
||||
toParams: dateRangeParams("dueFrom", "dueTo"),
|
||||
},
|
||||
{
|
||||
key: "amount",
|
||||
label: "Amount",
|
||||
type: "number",
|
||||
secondary: true,
|
||||
operators: ["between", "is"],
|
||||
toParams: (v) =>
|
||||
v.op === "between"
|
||||
? { minAmount: v.v[0], maxAmount: v.v[1] }
|
||||
: { minAmount: v.v[0], maxAmount: v.v[0] },
|
||||
},
|
||||
];
|
||||
|
||||
const SORT_OPTIONS = [
|
||||
{ value: "issuedAt:DESC", label: "Newest issued" },
|
||||
{ value: "issuedAt:ASC", label: "Oldest issued" },
|
||||
{ value: "dueAt:ASC", label: "Due soonest" },
|
||||
{ value: "totalAmount:DESC", label: "Largest amount" },
|
||||
{ value: "balanceAmount:DESC", label: "Largest balance" },
|
||||
{ value: "invoiceNumber:ASC", label: "Invoice no. (A–Z)" },
|
||||
];
|
||||
|
||||
/** Date params the export's `daterange` coercion expects as calendar days. */
|
||||
const EXPORT_DAY_KEYS = ["issuedFrom", "issuedTo", "dueFrom", "dueTo"];
|
||||
|
||||
/**
|
||||
* The customer's pay window, counted down live. Finance must confirm the bank
|
||||
@@ -157,21 +268,19 @@ export default function UsdPaymentsPanel({
|
||||
currency: "USD" | "ETB";
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [query, setQuery] = useState("");
|
||||
const [debouncedQuery] = useDebouncedValue(query, 300);
|
||||
const [statusFilter, setStatusFilter] = useState<"" | Freight.InvoiceStatus>(
|
||||
"",
|
||||
);
|
||||
// Namespaced: the ETB and USD tabs share this panel and live on the same URL
|
||||
// as the Invoices tab, whose filter bar owns the bare `statuses`/`sort` keys.
|
||||
const controls = useFilters(MANUAL_PAYMENT_FILTER_DEFS, {
|
||||
defaultSort: "issuedAt:DESC",
|
||||
pageSize: 10,
|
||||
ns: "mp",
|
||||
});
|
||||
const [confirming, setConfirming] = useState<OfflineUsdInvoice | null>(null);
|
||||
const [slip, setSlip] = useState<File | null>(null);
|
||||
const [reference, setReference] = useState("");
|
||||
|
||||
const { user } = useAuth();
|
||||
const canConfirm = hasPermission(
|
||||
user,
|
||||
FREIGHT_PERMS.invoices.confirmOffline,
|
||||
);
|
||||
const canConfirm = hasPermission(user, FREIGHT_PERMS.invoices.confirmOffline);
|
||||
|
||||
// Manual settlement is switched on per currency in Configuration → Manual
|
||||
// payments. FinanceHubPage hides the tab for a disabled currency; this is
|
||||
@@ -184,20 +293,8 @@ export default function UsdPaymentsPanel({
|
||||
: true;
|
||||
|
||||
const filter = useMemo(
|
||||
() => ({
|
||||
page: pagination.pageIndex + 1,
|
||||
pageSize: pagination.pageSize,
|
||||
search: debouncedQuery,
|
||||
status: statusFilter || undefined,
|
||||
currency,
|
||||
}),
|
||||
[
|
||||
pagination.pageIndex,
|
||||
pagination.pageSize,
|
||||
debouncedQuery,
|
||||
statusFilter,
|
||||
currency,
|
||||
],
|
||||
() => ({ ...controls.params, currency }) as unknown as InvoiceListFilter,
|
||||
[controls.params, currency],
|
||||
);
|
||||
|
||||
const { data, isLoading, isError, refetch, isFetching } = useQuery({
|
||||
@@ -209,7 +306,24 @@ export default function UsdPaymentsPanel({
|
||||
|
||||
const rows = data?.items ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||
const outstanding = data?.outstanding?.[currency] ?? 0;
|
||||
|
||||
/**
|
||||
* The export's `daterange` filters are coerced from calendar days while the
|
||||
* list takes ISO instants, so each bound is handed over as the local day it
|
||||
* falls on. The worklist's implicit "still open" cut is not a URL param
|
||||
* either — spelled out here so an exported file covers the rows the screen
|
||||
* shows rather than every invoice ever raised in this currency.
|
||||
*/
|
||||
const exportParams = useMemo(() => {
|
||||
const out: Record<string, unknown> = { ...controls.params, currency };
|
||||
for (const key of EXPORT_DAY_KEYS) {
|
||||
if (typeof out[key] === "string")
|
||||
out[key] = isoToLocalDateStr(out[key] as string);
|
||||
}
|
||||
if (!out.statuses) out.statuses = OPEN_STATUSES.join(",");
|
||||
return out;
|
||||
}, [controls.params, currency]);
|
||||
|
||||
const closeConfirm = () => {
|
||||
setConfirming(null);
|
||||
@@ -340,7 +454,9 @@ export default function UsdPaymentsPanel({
|
||||
header: "Pay window",
|
||||
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
||||
cell: ({ row }) => (
|
||||
<PayWindowCell deadline={row.original.booking?.paymentDeadline ?? null} />
|
||||
<PayWindowCell
|
||||
deadline={row.original.booking?.paymentDeadline ?? null}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -358,47 +474,40 @@ export default function UsdPaymentsPanel({
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack gap="md">
|
||||
<KpiStrip
|
||||
loading={isLoading}
|
||||
items={[
|
||||
{
|
||||
label: `Outstanding in ${currency}`,
|
||||
hint: "all matching",
|
||||
value: formatMoney(outstanding, currency),
|
||||
icon: CircleDollarSign,
|
||||
color: "edr-green",
|
||||
},
|
||||
{
|
||||
label: "Invoices listed",
|
||||
value: total,
|
||||
icon: Receipt,
|
||||
color: "blue",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Card p={0}>
|
||||
<Stack gap={0}>
|
||||
<Box px="md" pt="md" pb="sm" w="100%">
|
||||
<Group justify="space-between" gap="md" wrap="wrap">
|
||||
<TextInput
|
||||
placeholder="Search by invoice number…"
|
||||
leftSection={<Search size={18} />}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
rightSection={
|
||||
query ? (
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
color="gray"
|
||||
radius="md"
|
||||
variant="transparent"
|
||||
onClick={() => setQuery("")}
|
||||
>
|
||||
<X size={16} />
|
||||
</ActionIcon>
|
||||
) : null
|
||||
}
|
||||
style={{ flex: 1, minWidth: "240px" }}
|
||||
radius="lg"
|
||||
/>
|
||||
<SegmentedControl
|
||||
<FilterBar
|
||||
defs={MANUAL_PAYMENT_FILTER_DEFS}
|
||||
controls={controls}
|
||||
searchPlaceholder="Search invoice, customer, booking ref, PNR, transaction ref, GRN or shipping line…"
|
||||
sortOptions={SORT_OPTIONS}
|
||||
viewId={`manual-payments-${currency.toLowerCase()}`}
|
||||
>
|
||||
<ExportButton
|
||||
datasetKey="invoices"
|
||||
params={exportParams}
|
||||
size="sm"
|
||||
radius="md"
|
||||
value={statusFilter || "open"}
|
||||
onChange={(v) => {
|
||||
setStatusFilter(
|
||||
v === "open" ? "" : (v as Freight.InvoiceStatus),
|
||||
);
|
||||
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
|
||||
}}
|
||||
data={[
|
||||
{ label: "Awaiting payment", value: "open" },
|
||||
{ label: "Paid", value: "PAID" },
|
||||
{ label: "Overdue", value: "OVERDUE" },
|
||||
]}
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
@@ -410,7 +519,7 @@ export default function UsdPaymentsPanel({
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</FilterBar>
|
||||
</Box>
|
||||
|
||||
<Box style={{ overflowX: "auto" }} w="100%">
|
||||
@@ -423,8 +532,8 @@ export default function UsdPaymentsPanel({
|
||||
emptyMessage={
|
||||
!currencyEnabled
|
||||
? `Manual payment is switched off for ${currency} invoices. Enable it in Configuration → Manual payments.`
|
||||
: debouncedQuery
|
||||
? "No invoices match your search."
|
||||
: controls.activeCount > 0
|
||||
? "No invoices match these filters."
|
||||
: `No ${currency} invoices awaiting manual payment confirmation.`
|
||||
}
|
||||
error={
|
||||
@@ -435,18 +544,7 @@ export default function UsdPaymentsPanel({
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount,
|
||||
totalCount: total,
|
||||
}}
|
||||
tableOptions={{
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
}}
|
||||
{...controls.tableProps(total)}
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
footer={DataTableFooter}
|
||||
/>
|
||||
@@ -458,9 +556,7 @@ export default function UsdPaymentsPanel({
|
||||
<Modal
|
||||
opened={confirming !== null}
|
||||
onClose={closeConfirm}
|
||||
title={
|
||||
<Text fw={700}>Confirm manual payment</Text>
|
||||
}
|
||||
title={<Text fw={700}>Confirm manual payment</Text>}
|
||||
radius="md"
|
||||
size="md"
|
||||
>
|
||||
@@ -510,6 +606,6 @@ export default function UsdPaymentsPanel({
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -90,17 +90,8 @@ export default function TrainBuilderDetailPage() {
|
||||
const [yardModalOpen, setYardModalOpen] = useState(false);
|
||||
const [disbandOpen, setDisbandOpen] = useState(false);
|
||||
const [deactivateOpen, setDeactivateOpen] = useState(false);
|
||||
const [maintenanceTarget, setMaintenanceTarget] =
|
||||
useState<TrainCompositionWagon | null>(null);
|
||||
const [maintenanceNote, setMaintenanceNote] = useState("");
|
||||
// Clearing the note with the target stops one wagon's reason being carried
|
||||
// over onto the next wagon sent to maintenance.
|
||||
const closeMaintenance = () => {
|
||||
setMaintenanceTarget(null);
|
||||
setMaintenanceNote("");
|
||||
};
|
||||
// Detach-approval flow: on a SCHEDULED run, detach/maintenance is filed as a
|
||||
// request (with reason) and executed by a second staffer's approval.
|
||||
// Every detach / maintenance move asks for a reason first — it is recorded
|
||||
// as an auto-approved audit row and on the train's wagon history.
|
||||
const [requestTarget, setRequestTarget] = useState<{
|
||||
wagon: TrainCompositionWagon;
|
||||
action: "DETACH" | "MAINTENANCE";
|
||||
@@ -110,15 +101,8 @@ export default function TrainBuilderDetailPage() {
|
||||
setRequestTarget(null);
|
||||
setRequestReason("");
|
||||
};
|
||||
const [rejectTarget, setRejectTarget] = useState<WagonDetachRequestRow | null>(null);
|
||||
const [rejectNote, setRejectNote] = useState("");
|
||||
const closeReject = () => {
|
||||
setRejectTarget(null);
|
||||
setRejectNote("");
|
||||
};
|
||||
const { user } = useAuth();
|
||||
const canAssign = hasPermission(user, FREIGHT_PERMS.trains.assignWagons);
|
||||
const canApproveDetach = hasPermission(user, FREIGHT_PERMS.trains.approveWagonDetach);
|
||||
const canChangeLocomotives = hasPermission(user, FREIGHT_PERMS.trains.changeLocomotives);
|
||||
const canChangeYard = hasPermission(user, FREIGHT_PERMS.trains.changeYard);
|
||||
const canChangeWagonYard = hasPermission(user, FREIGHT_PERMS.trains.changeWagonYard);
|
||||
@@ -149,35 +133,17 @@ export default function TrainBuilderDetailPage() {
|
||||
enabled: Boolean(id),
|
||||
}),
|
||||
);
|
||||
const createDetachRequest = useMutation(
|
||||
api.trainBuilder.createDetachRequest.mutationOptions(),
|
||||
);
|
||||
const approveDetachRequest = useMutation(
|
||||
api.trainBuilder.approveDetachRequest.mutationOptions(),
|
||||
);
|
||||
const rejectDetachRequest = useMutation(
|
||||
api.trainBuilder.rejectDetachRequest.mutationOptions(),
|
||||
);
|
||||
const disband = useMutation(api.trainBuilder.disband.mutationOptions());
|
||||
const deactivate = useMutation(api.trainBuilder.deactivate.mutationOptions());
|
||||
const activate = useMutation(api.trainBuilder.activate.mutationOptions());
|
||||
|
||||
const composition = compositionQuery.data;
|
||||
|
||||
// Approval kicks in once a run is SCHEDULED. DRAFT stays direct-edit; a
|
||||
// dispatched train is frozen outright (composition.editable is false).
|
||||
const requiresDetachApproval = (composition?.activeSchedules ?? []).some(
|
||||
(s) => s.status === "SCHEDULED",
|
||||
);
|
||||
const detachRequests = useMemo(
|
||||
() => detachRequestsQuery.data ?? [],
|
||||
[detachRequestsQuery.data],
|
||||
);
|
||||
const pendingDetachRequests = detachRequests.filter((r) => r.status === "PENDING");
|
||||
const pendingWagonIds = useMemo(
|
||||
() => new Set(detachRequests.filter((r) => r.status === "PENDING").map((r) => r.wagonId)),
|
||||
[detachRequests],
|
||||
);
|
||||
|
||||
// The diagram memoizes off its `locomotives`/`wagons` props; building those
|
||||
// arrays inline in JSX would hand it a new identity on every render and
|
||||
@@ -270,32 +236,19 @@ export default function TrainBuilderDetailPage() {
|
||||
[withToast, reorderWagons.mutateAsync, trainId],
|
||||
);
|
||||
const wagons = composition?.wagons;
|
||||
const openDetachRequest = useCallback(
|
||||
const openDetachReason = useCallback(
|
||||
(wagonId: string, action: "DETACH" | "MAINTENANCE") => {
|
||||
if (pendingWagonIds.has(wagonId)) {
|
||||
toast({
|
||||
title: "A detach request for this wagon is already pending approval",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const wagon = wagons?.find((w) => w.id === wagonId);
|
||||
if (wagon) setRequestTarget({ wagon, action });
|
||||
},
|
||||
[pendingWagonIds, wagons, toast],
|
||||
[wagons],
|
||||
);
|
||||
const handleRemove = useCallback(
|
||||
(wagonId: string) => {
|
||||
if (!trainId) return;
|
||||
if (requiresDetachApproval) {
|
||||
openDetachRequest(wagonId, "DETACH");
|
||||
return;
|
||||
}
|
||||
void withToast(
|
||||
() => removeWagon.mutateAsync({ id: trainId, wagonId }),
|
||||
"Could not detach wagon",
|
||||
);
|
||||
openDetachReason(wagonId, "DETACH");
|
||||
},
|
||||
[withToast, removeWagon.mutateAsync, trainId, requiresDetachApproval, openDetachRequest],
|
||||
[trainId, openDetachReason],
|
||||
);
|
||||
const handleChangeWagonYard = useCallback(
|
||||
(wagonId: string, currentYardId: string) => {
|
||||
@@ -319,13 +272,9 @@ export default function TrainBuilderDetailPage() {
|
||||
);
|
||||
const handleMaintenance = useCallback(
|
||||
(wagon: TrainCompositionWagon) => {
|
||||
if (requiresDetachApproval) {
|
||||
openDetachRequest(wagon.id, "MAINTENANCE");
|
||||
return;
|
||||
}
|
||||
setMaintenanceTarget(wagon);
|
||||
openDetachReason(wagon.id, "MAINTENANCE");
|
||||
},
|
||||
[requiresDetachApproval, openDetachRequest],
|
||||
[openDetachReason],
|
||||
);
|
||||
|
||||
if (compositionQuery.isLoading) {
|
||||
@@ -502,9 +451,11 @@ export default function TrainBuilderDetailPage() {
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{!composition.editable ? (
|
||||
<Alert color="yellow" icon={<AlertTriangle size={16} />}>
|
||||
This train is out on a dispatched run — its composition is frozen until arrival.
|
||||
{(composition.activeSchedules ?? []).some((s) => s.status === "DISPATCHED") ? (
|
||||
<Alert color="blue" icon={<AlertTriangle size={16} />}>
|
||||
This train is out on a dispatched run. You can still edit its composition —
|
||||
the dispatched run keeps the wagon plan it departed with, and your changes
|
||||
apply to scheduled (not yet departed) runs only.
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
@@ -563,7 +514,7 @@ export default function TrainBuilderDetailPage() {
|
||||
))}
|
||||
</Group>
|
||||
|
||||
{detachRequests.length ? (
|
||||
{/* {detachRequests.length ? (
|
||||
<Card>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between">
|
||||
@@ -576,8 +527,8 @@ export default function TrainBuilderDetailPage() {
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
While this train is on a scheduled run, detaching a wagon (or sending it to
|
||||
maintenance) needs a second staff member's approval. Decided requests stay
|
||||
here as the audit trail.
|
||||
maintenance) requires a reason — recorded here as the audit trail of who
|
||||
did it and why.
|
||||
</Text>
|
||||
{detachRequests.map((req) => {
|
||||
const isOwn = Boolean(req.requestedById && user?.id === req.requestedById);
|
||||
@@ -622,49 +573,9 @@ export default function TrainBuilderDetailPage() {
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
{req.status === "PENDING" && canApproveDetach ? (
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Tooltip
|
||||
label="You filed this request — a different staff member must approve it"
|
||||
disabled={!isOwn}
|
||||
withArrow
|
||||
>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="green"
|
||||
disabled={isOwn}
|
||||
loading={approveDetachRequest.isPending}
|
||||
onClick={() =>
|
||||
void withToast(async () => {
|
||||
await approveDetachRequest.mutateAsync({
|
||||
id: composition.id,
|
||||
requestId: req.id,
|
||||
});
|
||||
toast({
|
||||
title: `Wagon ${req.wagonNumber} ${
|
||||
req.action === "MAINTENANCE"
|
||||
? "sent to maintenance"
|
||||
: "detached"
|
||||
}`,
|
||||
});
|
||||
}, "Could not approve request")
|
||||
}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
color="red"
|
||||
onClick={() => setRejectTarget(req)}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</Group>
|
||||
) : req.status === "PENDING" ? (
|
||||
{req.status === "PENDING" ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
Awaiting approval
|
||||
Legacy request — approval flow removed
|
||||
</Text>
|
||||
) : null}
|
||||
</Group>
|
||||
@@ -672,7 +583,7 @@ export default function TrainBuilderDetailPage() {
|
||||
})}
|
||||
</Stack>
|
||||
</Card>
|
||||
) : null}
|
||||
) : null} */}
|
||||
|
||||
<Stack gap="sm">
|
||||
<TrainCompositionDiagram
|
||||
@@ -811,71 +722,14 @@ export default function TrainBuilderDetailPage() {
|
||||
onClose={() => setYardModalOpen(false)}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
opened={Boolean(maintenanceTarget)}
|
||||
onClose={closeMaintenance}
|
||||
title={<Text fw={600}>Send wagon to maintenance?</Text>}
|
||||
radius="lg"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Wagon{" "}
|
||||
<Text span fw={700} ff="monospace" c="dark">
|
||||
{maintenanceTarget?.wagonNumber}
|
||||
</Text>{" "}
|
||||
is detached from train{" "}
|
||||
<Text span fw={700} c="dark">
|
||||
{trainRunLabel}
|
||||
</Text>{" "}
|
||||
and set to MAINTENANCE — it stays out of the available pool until it
|
||||
clears. The detach is stamped with the time and this train's run
|
||||
numbers in the wagon's history.
|
||||
</Text>
|
||||
<Textarea
|
||||
label="Note"
|
||||
placeholder="Optional note (e.g. reason for maintenance)"
|
||||
value={maintenanceNote}
|
||||
onChange={(e) => setMaintenanceNote(e.currentTarget.value)}
|
||||
autosize
|
||||
minRows={2}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={closeMaintenance}>
|
||||
Keep in consist
|
||||
</Button>
|
||||
<Button
|
||||
color="orange"
|
||||
leftSection={<Wrench size={16} />}
|
||||
loading={maintenanceWagon.isPending}
|
||||
onClick={() =>
|
||||
void withToast(async () => {
|
||||
await maintenanceWagon.mutateAsync({
|
||||
id: composition.id,
|
||||
wagonId: maintenanceTarget!.id,
|
||||
note: maintenanceNote.trim() || undefined,
|
||||
});
|
||||
toast({
|
||||
title: `Wagon ${maintenanceTarget!.wagonNumber} sent to maintenance`,
|
||||
});
|
||||
closeMaintenance();
|
||||
}, "Could not send wagon to maintenance")
|
||||
}
|
||||
>
|
||||
Send to maintenance
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
opened={Boolean(requestTarget)}
|
||||
onClose={closeRequest}
|
||||
title={
|
||||
<Text fw={600}>
|
||||
{requestTarget?.action === "MAINTENANCE"
|
||||
? "Request maintenance approval?"
|
||||
: "Request detach approval?"}
|
||||
? "Send wagon to maintenance?"
|
||||
: "Detach wagon?"}
|
||||
</Text>
|
||||
}
|
||||
radius="lg"
|
||||
@@ -883,22 +737,28 @@ export default function TrainBuilderDetailPage() {
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Train{" "}
|
||||
<Text span fw={700} c="dark">
|
||||
{trainRunLabel}
|
||||
</Text>{" "}
|
||||
is on a scheduled run, so wagon{" "}
|
||||
Wagon{" "}
|
||||
<Text span fw={700} ff="monospace" c="dark">
|
||||
{requestTarget?.wagon.wagonNumber}
|
||||
</Text>{" "}
|
||||
is not detached now — your request goes to a staff member with approval
|
||||
rights, and the{" "}
|
||||
{requestTarget?.action === "MAINTENANCE" ? "maintenance move" : "detach"}{" "}
|
||||
happens the moment they approve it.
|
||||
{requestTarget?.action === "MAINTENANCE"
|
||||
? "leaves train "
|
||||
: "is detached from train "}
|
||||
<Text span fw={700} c="dark">
|
||||
{trainRunLabel}
|
||||
</Text>{" "}
|
||||
{requestTarget?.action === "MAINTENANCE"
|
||||
? "and is set to MAINTENANCE — it stays out of the available pool until it clears."
|
||||
: "immediately."}{" "}
|
||||
The reason is required and shows in this train's History tab.
|
||||
</Text>
|
||||
<Textarea
|
||||
label="Reason"
|
||||
placeholder="Why must this wagon leave the scheduled consist? (required)"
|
||||
placeholder={
|
||||
requestTarget?.action === "MAINTENANCE"
|
||||
? "Why is this wagon going to maintenance? (required)"
|
||||
: "Why is this wagon leaving the consist? (required)"
|
||||
}
|
||||
value={requestReason}
|
||||
onChange={(e) => setRequestReason(e.currentTarget.value)}
|
||||
autosize
|
||||
@@ -919,73 +779,36 @@ export default function TrainBuilderDetailPage() {
|
||||
)
|
||||
}
|
||||
disabled={!requestReason.trim()}
|
||||
loading={createDetachRequest.isPending}
|
||||
loading={removeWagon.isPending || maintenanceWagon.isPending}
|
||||
onClick={() =>
|
||||
void withToast(async () => {
|
||||
await createDetachRequest.mutateAsync({
|
||||
id: composition.id,
|
||||
wagonId: requestTarget!.wagon.id,
|
||||
action: requestTarget!.action,
|
||||
reason: requestReason.trim(),
|
||||
});
|
||||
if (requestTarget!.action === "MAINTENANCE") {
|
||||
await maintenanceWagon.mutateAsync({
|
||||
id: composition.id,
|
||||
wagonId: requestTarget!.wagon.id,
|
||||
note: requestReason.trim(),
|
||||
});
|
||||
} else {
|
||||
await removeWagon.mutateAsync({
|
||||
id: composition.id,
|
||||
wagonId: requestTarget!.wagon.id,
|
||||
reason: requestReason.trim(),
|
||||
});
|
||||
}
|
||||
toast({
|
||||
title: `Request for wagon ${requestTarget!.wagon.wagonNumber} filed — awaiting approval`,
|
||||
title: `Wagon ${requestTarget!.wagon.wagonNumber} ${
|
||||
requestTarget!.action === "MAINTENANCE"
|
||||
? "sent to maintenance"
|
||||
: "detached"
|
||||
}`,
|
||||
});
|
||||
closeRequest();
|
||||
}, "Could not file the request")
|
||||
}, "Could not detach the wagon")
|
||||
}
|
||||
>
|
||||
Request approval
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
opened={Boolean(rejectTarget)}
|
||||
onClose={closeReject}
|
||||
title={<Text fw={600}>Reject this request?</Text>}
|
||||
radius="lg"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Wagon{" "}
|
||||
<Text span fw={700} ff="monospace" c="dark">
|
||||
{rejectTarget?.wagonNumber}
|
||||
</Text>{" "}
|
||||
stays in the consist. The requester sees your note in the request history.
|
||||
</Text>
|
||||
<Textarea
|
||||
label="Why is it rejected?"
|
||||
placeholder="Required"
|
||||
value={rejectNote}
|
||||
onChange={(e) => setRejectNote(e.currentTarget.value)}
|
||||
autosize
|
||||
minRows={2}
|
||||
required
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={closeReject}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
disabled={!rejectNote.trim()}
|
||||
loading={rejectDetachRequest.isPending}
|
||||
onClick={() =>
|
||||
void withToast(async () => {
|
||||
await rejectDetachRequest.mutateAsync({
|
||||
id: composition.id,
|
||||
requestId: rejectTarget!.id,
|
||||
note: rejectNote.trim(),
|
||||
});
|
||||
toast({ title: `Request for wagon ${rejectTarget!.wagonNumber} rejected` });
|
||||
closeReject();
|
||||
}, "Could not reject the request")
|
||||
}
|
||||
>
|
||||
Reject request
|
||||
{requestTarget?.action === "MAINTENANCE"
|
||||
? "Send to maintenance"
|
||||
: "Detach wagon"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -941,6 +941,52 @@ export const api = {
|
||||
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
||||
),
|
||||
|
||||
loadScheduleBookingWagon: endpoint<
|
||||
{ scheduleId: string; bookingId: string; allocationId: string },
|
||||
import("@/types/trainScheduling").WagonLoadResult
|
||||
>(
|
||||
"train-scheduling",
|
||||
"booking-wagon-load",
|
||||
({ scheduleId, bookingId, allocationId }) =>
|
||||
trainSchedulingService.loadScheduleBookingWagon(scheduleId, bookingId, allocationId),
|
||||
undefined,
|
||||
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
||||
),
|
||||
|
||||
unloadScheduleBookingWagon: endpoint<
|
||||
{ scheduleId: string; bookingId: string; allocationId: string },
|
||||
import("@/types/trainScheduling").WagonLoadResult
|
||||
>(
|
||||
"train-scheduling",
|
||||
"booking-wagon-unload",
|
||||
({ scheduleId, bookingId, allocationId }) =>
|
||||
trainSchedulingService.unloadScheduleBookingWagon(scheduleId, bookingId, allocationId),
|
||||
undefined,
|
||||
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
||||
),
|
||||
|
||||
bookingWagons: endpoint<
|
||||
{ bookingId: string },
|
||||
import("@/types/trainScheduling").BookingWagonRow[]
|
||||
>(
|
||||
"train-scheduling",
|
||||
"booking-wagons",
|
||||
({ bookingId }) => trainSchedulingService.bookingWagons(bookingId),
|
||||
({ bookingId }) => ["train-scheduling", "booking-wagons", bookingId],
|
||||
),
|
||||
|
||||
cancelRemainingWagons: endpoint<
|
||||
{ bookingId: string; scheduleId: string; reason: string; edrFault?: boolean },
|
||||
unknown
|
||||
>(
|
||||
"train-scheduling",
|
||||
"cancel-remaining-wagons",
|
||||
({ bookingId, scheduleId, reason, edrFault }) =>
|
||||
trainSchedulingService.cancelRemainingWagons(bookingId, { scheduleId, reason, edrFault }),
|
||||
undefined,
|
||||
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
||||
),
|
||||
|
||||
intercityBookings: endpoint<
|
||||
void,
|
||||
import("@/types/trainScheduling").IntercityRideAlongRow[]
|
||||
@@ -2245,11 +2291,14 @@ export const api = {
|
||||
seedComposition,
|
||||
),
|
||||
|
||||
removeWagon: endpoint<{ id: string; wagonId: string }, TrainComposition>(
|
||||
removeWagon: endpoint<
|
||||
{ id: string; wagonId: string; reason?: string },
|
||||
TrainComposition
|
||||
>(
|
||||
"train-builder",
|
||||
"removeWagon",
|
||||
({ id, wagonId }) =>
|
||||
trainBuilderService.removeWagon(id, wagonId).then((r) => r.data),
|
||||
({ id, wagonId, reason }) =>
|
||||
trainBuilderService.removeWagon(id, wagonId, reason).then((r) => r.data),
|
||||
undefined,
|
||||
() => TRAIN_BUILDER_WAGON_INVALIDATIONS,
|
||||
seedComposition,
|
||||
@@ -2276,43 +2325,6 @@ export const api = {
|
||||
({ id }) => trainBuilderService.detachRequests(id).then((r) => r.data),
|
||||
),
|
||||
|
||||
createDetachRequest: endpoint<
|
||||
{ id: string; wagonId: string; action: "DETACH" | "MAINTENANCE"; reason: string },
|
||||
WagonDetachRequestRow
|
||||
>(
|
||||
"train-builder",
|
||||
"createDetachRequest",
|
||||
({ id, wagonId, action, reason }) =>
|
||||
trainBuilderService.createDetachRequest(id, wagonId, { action, reason }).then((r) => r.data),
|
||||
undefined,
|
||||
() => TRAIN_BUILDER_INVALIDATIONS,
|
||||
),
|
||||
|
||||
approveDetachRequest: endpoint<
|
||||
{ id: string; requestId: string; note?: string },
|
||||
TrainComposition
|
||||
>(
|
||||
"train-builder",
|
||||
"approveDetachRequest",
|
||||
({ id, requestId, note }) =>
|
||||
trainBuilderService.approveDetachRequest(id, requestId, note).then((r) => r.data),
|
||||
undefined,
|
||||
() => TRAIN_BUILDER_WAGON_INVALIDATIONS,
|
||||
seedComposition,
|
||||
),
|
||||
|
||||
rejectDetachRequest: endpoint<
|
||||
{ id: string; requestId: string; note: string },
|
||||
TrainComposition
|
||||
>(
|
||||
"train-builder",
|
||||
"rejectDetachRequest",
|
||||
({ id, requestId, note }) =>
|
||||
trainBuilderService.rejectDetachRequest(id, requestId, note).then((r) => r.data),
|
||||
undefined,
|
||||
() => TRAIN_BUILDER_INVALIDATIONS,
|
||||
),
|
||||
|
||||
reorderWagons: endpoint<{ id: string; wagonIds: string[] }, TrainComposition>(
|
||||
"train-builder",
|
||||
"reorderWagons",
|
||||
|
||||
@@ -311,6 +311,11 @@ export interface TrainHistoryEntry {
|
||||
actor: string | null;
|
||||
/** Set when the change came from a trip (schedule); null = train-builder edit. */
|
||||
scheduleReference: string | null;
|
||||
/**
|
||||
* Why the wagon left the consist — required for a detach / maintenance move
|
||||
* on a SCHEDULED run. Null for trip events and unscheduled builder edits.
|
||||
*/
|
||||
reason: string | null;
|
||||
occurredAt: string;
|
||||
}
|
||||
|
||||
@@ -436,37 +441,20 @@ export const trainBuilderService = {
|
||||
}),
|
||||
assignWagons: (id: string, wagonIds: string[]) =>
|
||||
apiClient.post<TrainComposition>(`${BASE}/${id}/wagons`, { wagonIds }),
|
||||
removeWagon: (id: string, wagonId: string) =>
|
||||
apiClient.delete<TrainComposition>(`${BASE}/${id}/wagons/${wagonId}`),
|
||||
/** `reason` is required by the API while the train is on a SCHEDULED run. */
|
||||
removeWagon: (id: string, wagonId: string, reason?: string) =>
|
||||
apiClient.delete<TrainComposition>(`${BASE}/${id}/wagons/${wagonId}`, {
|
||||
data: reason ? { reason } : undefined,
|
||||
}),
|
||||
/** Detach a wagon and move it to MAINTENANCE status. */
|
||||
/** `note` is the maintenance reason — recorded with the train it came off. */
|
||||
sendWagonToMaintenance: (id: string, wagonId: string, note?: string) =>
|
||||
apiClient.post<TrainComposition>(`${BASE}/${id}/wagons/${wagonId}/maintenance`, {
|
||||
note,
|
||||
}),
|
||||
/** Requests to detach a wagon from a SCHEDULED train, newest first. */
|
||||
/** Detach/maintenance audit rows of a SCHEDULED-run train, newest first. */
|
||||
detachRequests: (id: string) =>
|
||||
apiClient.get<WagonDetachRequestRow[]>(`${BASE}/${id}/detach-requests`),
|
||||
/** File a detach/maintenance approval request (reason required). */
|
||||
createDetachRequest: (
|
||||
id: string,
|
||||
wagonId: string,
|
||||
payload: { action: "DETACH" | "MAINTENANCE"; reason: string },
|
||||
) =>
|
||||
apiClient.post<WagonDetachRequestRow>(
|
||||
`${BASE}/${id}/wagons/${wagonId}/detach-requests`,
|
||||
payload,
|
||||
),
|
||||
/** Approve a pending request — executes the detach immediately. */
|
||||
approveDetachRequest: (id: string, requestId: string, note?: string) =>
|
||||
apiClient.post<TrainComposition>(`${BASE}/${id}/detach-requests/${requestId}/approve`, {
|
||||
note,
|
||||
}),
|
||||
/** Reject a pending request — a note explaining why is required. */
|
||||
rejectDetachRequest: (id: string, requestId: string, note: string) =>
|
||||
apiClient.post<TrainComposition>(`${BASE}/${id}/detach-requests/${requestId}/reject`, {
|
||||
note,
|
||||
}),
|
||||
reorderWagons: (id: string, wagonIds: string[]) =>
|
||||
apiClient.post<TrainComposition>(`${BASE}/${id}/reorder-wagons`, { wagonIds }),
|
||||
/** Park the train indefinitely — only allowed with no active schedule. */
|
||||
|
||||
@@ -11,6 +11,8 @@ import type {
|
||||
BookingWindow,
|
||||
AssignBookingsPayload,
|
||||
BookingLoadResult,
|
||||
BookingWagonRow,
|
||||
WagonLoadResult,
|
||||
BookingUnloadResult,
|
||||
CompositionRemovalEntry,
|
||||
DocReviewAlert,
|
||||
@@ -510,6 +512,48 @@ export const trainSchedulingService = {
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
loadScheduleBookingWagon: async (
|
||||
scheduleId: string,
|
||||
bookingId: string,
|
||||
allocationId: string,
|
||||
): Promise<WagonLoadResult> => {
|
||||
const response = await client.post<WagonLoadResult>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.BOOKING_WAGON_LOAD(scheduleId, bookingId, allocationId),
|
||||
{},
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
unloadScheduleBookingWagon: async (
|
||||
scheduleId: string,
|
||||
bookingId: string,
|
||||
allocationId: string,
|
||||
): Promise<WagonLoadResult> => {
|
||||
const response = await client.post<WagonLoadResult>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.BOOKING_WAGON_UNLOAD(scheduleId, bookingId, allocationId),
|
||||
{},
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
bookingWagons: async (bookingId: string): Promise<BookingWagonRow[]> => {
|
||||
const response = await client.get<BookingWagonRow[]>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.BOOKING_WAGONS(bookingId),
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
cancelRemainingWagons: async (
|
||||
bookingId: string,
|
||||
payload: { scheduleId: string; reason: string; edrFault?: boolean },
|
||||
): Promise<unknown> => {
|
||||
const response = await client.post(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.CANCEL_REMAINING_WAGONS(bookingId),
|
||||
payload,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
listIntercityBookings: async (): Promise<
|
||||
import("@/types/trainScheduling").IntercityRideAlongRow[]
|
||||
> => {
|
||||
|
||||
@@ -118,6 +118,11 @@ export interface OfflineUsdInvoice extends Invoice {
|
||||
export interface PaginatedOfflineUsdInvoices {
|
||||
items: OfflineUsdInvoice[];
|
||||
total: number;
|
||||
/**
|
||||
* Outstanding `balanceAmount` across the whole filtered set (not the visible
|
||||
* page), keyed by normalised currency — feeds the worklist's KPI strip.
|
||||
*/
|
||||
outstanding: Record<string, number>;
|
||||
}
|
||||
|
||||
/** Total collected (`paidAmount`) across every filtered invoice, keyed by currency. */
|
||||
|
||||
@@ -1206,6 +1206,34 @@ export interface BookingLoadResult {
|
||||
loadedAt: string;
|
||||
}
|
||||
|
||||
/** Per-wagon load/unload confirmation; `completed` = the booking finished with it. */
|
||||
export interface WagonLoadResult {
|
||||
bookingId: string;
|
||||
allocationId: string;
|
||||
status: string;
|
||||
loadedWagons?: number;
|
||||
unloadedWagons?: number;
|
||||
totalWagons: number;
|
||||
completed: boolean;
|
||||
}
|
||||
|
||||
/** One allocated wagon of a booking, from GET /bookings/:id/wagons. */
|
||||
export interface BookingWagonRow {
|
||||
allocationId: string;
|
||||
sequenceNo: number | null;
|
||||
wagonNumber: string | null;
|
||||
wagonType: string | null;
|
||||
wagonTypeCode: string | null;
|
||||
allocatedWeightTons: number | string | null;
|
||||
loadType: string | null;
|
||||
status: string;
|
||||
containers: Array<{
|
||||
containerNumber: string | null;
|
||||
sizeFt: number | null;
|
||||
grossWeightTons: number | string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface BookingUnloadResult {
|
||||
bookingId: string;
|
||||
/** 'ARRIVED' for import/export, 'COMPLETED' for intercity. */
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
@@ -41,6 +42,7 @@ import {
|
||||
FileUp,
|
||||
Flame,
|
||||
MapPin,
|
||||
MoveRight,
|
||||
Package,
|
||||
Receipt,
|
||||
Repeat,
|
||||
@@ -237,6 +239,19 @@ export default function NewShipmentPage() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Heaviest load one wagon of this contract's bulk cargo may take, as computed
|
||||
* by the API from the cargo type's allowed wagon types. Undefined when no
|
||||
* wagon type is configured — the wagon-count check then falls away.
|
||||
*/
|
||||
function bulkMaxTonsPerWagon(
|
||||
contract: Freight.IContract,
|
||||
): number | null | undefined {
|
||||
return contract.cargoScope?.find(
|
||||
(scope) => scope.cargoType?.maxTonsPerWagon != null,
|
||||
)?.cargoType?.maxTonsPerWagon;
|
||||
}
|
||||
|
||||
function bulkUnitOfMeasure(
|
||||
contract: Freight.IContract,
|
||||
): "PER_TON" | "PER_ITEM" | "NUMBER_OF_WAGONS" {
|
||||
@@ -434,6 +449,7 @@ function NewShipmentBookingForm({
|
||||
contract.freightType === "CONTAINER" &&
|
||||
contract.equipmentReturn === "WITH_RETURN",
|
||||
unitOfMeasure: bulkUnitOfMeasure(contract),
|
||||
maxTonsPerWagon: bulkMaxTonsPerWagon(contract),
|
||||
// Intercity rides a passing train staff pick later — no date to choose.
|
||||
requiresDate: contract.tradeDirection !== "DOMESTIC",
|
||||
// Export completion locks onto a specific train — the pick is required
|
||||
@@ -688,20 +704,20 @@ function NewShipmentBookingForm({
|
||||
<Title
|
||||
order={1}
|
||||
fw={800}
|
||||
fz={26}
|
||||
fz={28}
|
||||
style={{ letterSpacing: "-0.01em" }}
|
||||
>
|
||||
{completeBookingId
|
||||
? isResubmit
|
||||
? "Change Your Booking"
|
||||
: "Complete Your Booking"
|
||||
? "Change shipment booking"
|
||||
: "Complete shipment booking"
|
||||
: "New Shipment Booking"}
|
||||
</Title>
|
||||
<Text size="sm" c="edr-muted" mt={4}>
|
||||
{completeBookingId
|
||||
? isResubmit
|
||||
? `Update the details below and pick a new shipment day, then resubmit your booking under contract ${contract.reference}.`
|
||||
: `Clearance is finalized — enter the cargo details and shipment day to complete your booking under contract ${contract.reference}.`
|
||||
: `Clearance is finalized. Enter cargo details and the binding shipment day to complete ${completeBooking?.reference ?? "this booking"} under contract ${contract.reference}.`
|
||||
: `Book a shipment against contract ${contract.reference}.`}
|
||||
</Text>
|
||||
</Box>
|
||||
@@ -791,17 +807,6 @@ function NewShipmentBookingForm({
|
||||
}}
|
||||
>
|
||||
<Box className="mx-auto max-w-4xl">
|
||||
{showValidationSummary ? (
|
||||
<Alert
|
||||
color="red"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<AlertCircle size={16} />}
|
||||
mb="sm"
|
||||
>
|
||||
Fix the highlighted fields before reviewing the price.
|
||||
</Alert>
|
||||
) : null}
|
||||
{blockOdd20ft ? (
|
||||
<Alert
|
||||
color="red"
|
||||
@@ -823,16 +828,42 @@ function NewShipmentBookingForm({
|
||||
{`${ft20Total} is an odd number of 20ft containers — this booking will be paired with another customer's odd booking to share a wagon, or held until one is available.`}
|
||||
</Alert>
|
||||
) : null}
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
type="button"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Receipt size={16} />}
|
||||
onClick={handleReview}
|
||||
>
|
||||
{isResubmit ? "Change booking" : "Review price & book"}
|
||||
</Button>
|
||||
<Group justify="space-between" wrap="nowrap" gap="md">
|
||||
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
{showValidationSummary && (
|
||||
<>
|
||||
<AlertCircle
|
||||
size={15}
|
||||
color="#C0392B"
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
<Text fz={13} fw={500} c="#C0392B">
|
||||
Fix the highlighted fields to review the price.
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
radius="md"
|
||||
onClick={() =>
|
||||
navigate(`/contracts/${contract.id}`)
|
||||
}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Receipt size={16} />}
|
||||
onClick={handleReview}
|
||||
>
|
||||
{isResubmit ? "Change booking" : "Review price & book"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -1212,15 +1243,45 @@ function RouteStep({
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Paper withBorder radius="md" p="md" style={{ borderColor: "#E6ECF2" }}>
|
||||
<Text fz={14} fw={600} c="#10202F">
|
||||
{routes[0]?.originYard?.label ?? "—"} →{" "}
|
||||
{routes[0]?.destinationYard?.label ?? "—"}
|
||||
</Text>
|
||||
<Text fz={12} c="dimmed" mt={2}>
|
||||
<Group
|
||||
wrap="nowrap"
|
||||
gap={16}
|
||||
align="center"
|
||||
px={18}
|
||||
py={18}
|
||||
style={{
|
||||
borderRadius: 14,
|
||||
border: "1px solid #E6ECF2",
|
||||
background: "#FBFCFD",
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Text fz={15} fw={700} c="#10202F">
|
||||
{routes[0]?.originYard?.label ?? "—"}
|
||||
</Text>
|
||||
<Text fz={12} c="#6B7C8E" mt={2}>
|
||||
Origin yard
|
||||
</Text>
|
||||
</Box>
|
||||
<MoveRight size={20} color="#0A6F4D" style={{ flexShrink: 0 }} />
|
||||
<Box>
|
||||
<Text fz={15} fw={700} c="#10202F">
|
||||
{routes[0]?.destinationYard?.label ?? "—"}
|
||||
</Text>
|
||||
<Text fz={12} c="#6B7C8E" mt={2}>
|
||||
Destination yard
|
||||
</Text>
|
||||
</Box>
|
||||
<Box style={{ flex: 1 }} />
|
||||
<Badge
|
||||
variant="light"
|
||||
color="teal"
|
||||
radius={8}
|
||||
styles={{ label: { fontSize: 12, fontWeight: 600 } }}
|
||||
>
|
||||
{contract.tradeDirection}
|
||||
</Text>
|
||||
</Paper>
|
||||
</Badge>
|
||||
</Group>
|
||||
)}
|
||||
</StepCard>
|
||||
);
|
||||
@@ -1300,8 +1361,16 @@ function ScheduleStep({
|
||||
const selectedTrainId = form.watch("trainScheduleId");
|
||||
const isExportPick =
|
||||
contract.tradeDirection === "EXPORT" && Boolean(completeBookingId);
|
||||
const requestedWagonsValue = form.watch("requestedWagons");
|
||||
const wagonsEstimate = useMemo(() => {
|
||||
if (contract.freightType !== "CONTAINER") return undefined;
|
||||
// NUMBER_OF_WAGONS bulk states its wagon count outright — pass it through
|
||||
// so the train picker sizes fits/free against the real need instead of
|
||||
// falling back to the server's tonnage estimate.
|
||||
if (contract.freightType !== "CONTAINER") {
|
||||
if (bulkUnitOfMeasure(contract) !== "NUMBER_OF_WAGONS") return undefined;
|
||||
const wagons = Math.floor(Number(requestedWagonsValue || 0));
|
||||
return wagons >= 1 ? wagons : undefined;
|
||||
}
|
||||
const lines = containerLines ?? [];
|
||||
const ft20 = lines
|
||||
.filter((l) => l.containerSize === "20ft")
|
||||
@@ -1311,7 +1380,7 @@ function ScheduleStep({
|
||||
.reduce((s, l) => s + Number(l.quantity || 0), 0);
|
||||
const wagons = Math.ceil(ft20 / 2) + ft40;
|
||||
return wagons > 0 ? wagons : undefined;
|
||||
}, [contract.freightType, containerLines]);
|
||||
}, [contract, containerLines, requestedWagonsValue]);
|
||||
const exportTrainsQuery = useQuery({
|
||||
...api.bookings.getExportTrains.queryOptions({
|
||||
input: {
|
||||
|
||||
@@ -25,6 +25,13 @@ export interface ShipmentValidationContext {
|
||||
*/
|
||||
withReturnService?: boolean;
|
||||
unitOfMeasure?: "PER_TON" | "PER_ITEM" | "NUMBER_OF_WAGONS";
|
||||
/**
|
||||
* NUMBER_OF_WAGONS: the most tons one wagon of this cargo may carry. The
|
||||
* requested count must spread the tonnage no heavier than this, or the
|
||||
* server rejects the booking (assertWagonShareFits). Undefined when the
|
||||
* cargo type has no wagon type configured — the check then falls away.
|
||||
*/
|
||||
maxTonsPerWagon?: number | null;
|
||||
/**
|
||||
* Intercity (DOMESTIC) shipments ride a passing import/export train that
|
||||
* staff pick later, so no shipment day is chosen. Defaults to true.
|
||||
@@ -312,6 +319,23 @@ export function createShipmentFormSchema(ctx: ShipmentValidationContext) {
|
||||
path: ["requestedWagons"],
|
||||
message: "Enter the number of wagons needed (at least 1).",
|
||||
});
|
||||
} else {
|
||||
// Too few wagons for the tonnage can never ride: 200T across 3
|
||||
// wagons is 66.67T each on a 50T wagon. Mirrors the server's
|
||||
// assertWagonShareFits so the button blocks before the API 400s.
|
||||
const tons = Number(data.cargoWeightTons || 0);
|
||||
const maxPerWagon = Number(ctx.maxTonsPerWagon || 0);
|
||||
if (tons > 0 && maxPerWagon > 0 && tons / wagons > maxPerWagon) {
|
||||
refineCtx.addIssue({
|
||||
code: "custom",
|
||||
path: ["requestedWagons"],
|
||||
message:
|
||||
`${tons} tons across ${wagons} wagon${wagons === 1 ? "" : "s"} loads ` +
|
||||
`${Math.round((tons / wagons) * 1000) / 1000}T per wagon, but a wagon of ` +
|
||||
`this cargo carries at most ${maxPerWagon}T — request at least ` +
|
||||
`${Math.ceil(tons / maxPerWagon)} wagons.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
"helmet": "^8.0.0",
|
||||
"jose": "^5.10.0",
|
||||
"minio": "7.1.3",
|
||||
"multer": "^2.1.1",
|
||||
"pg": "^8.21.0",
|
||||
"qrcode": "^1.5.3",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "TrainSchedule" ADD COLUMN IF NOT EXISTS "isGroupBookingOnly" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "TravelPackage" ADD COLUMN IF NOT EXISTS "imageUrl" TEXT;
|
||||
@@ -372,6 +372,7 @@ model TrainSchedule {
|
||||
carbonRating String @default("A")
|
||||
notes String?
|
||||
isPackageOnly Boolean @default(false)
|
||||
isGroupBookingOnly Boolean @default(false)
|
||||
train Train @relation(fields: [trainId], references: [id])
|
||||
route Route? @relation(fields: [routeId], references: [id])
|
||||
originStation Station @relation("OriginTrips", fields: [originStationId], references: [id])
|
||||
@@ -1529,6 +1530,7 @@ model TravelPackage {
|
||||
code String @unique
|
||||
name String
|
||||
description String?
|
||||
imageUrl String?
|
||||
status PackageStatus @default(DRAFT)
|
||||
outboundScheduleId String
|
||||
returnScheduleId String
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
import "dotenv/config";
|
||||
import "reflect-metadata";
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Logger, ValidationPipe, VersioningType } from "@nestjs/common";
|
||||
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
|
||||
import helmet from "helmet";
|
||||
import { join } from "path";
|
||||
import { AppModule } from "./app.module";
|
||||
import { HttpExceptionFilter } from "./common/filters/http-exception.filter";
|
||||
import { ResponseTransformInterceptor } from "./common/interceptors/response-transform.interceptor";
|
||||
@@ -23,10 +25,20 @@ if (process.env.NODE_ENV === 'production' && process.env.WAAFI_INSECURE_TLS ===
|
||||
async function bootstrap() {
|
||||
// rawBody: true buffers the unparsed request body onto req.rawBody so webhook handlers
|
||||
// (e.g. Waafi HMAC verification) can sign over the exact bytes the provider signed.
|
||||
const app = await NestFactory.create(AppModule, { rawBody: true });
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, { rawBody: true });
|
||||
|
||||
// Security headers
|
||||
app.use(helmet());
|
||||
// Security headers. crossOriginResourcePolicy defaults to 'same-origin' in helmet, which
|
||||
// would make browsers refuse to actually render package images (served from this origin)
|
||||
// inside <img> tags on the portal/backoffice (different origins) even though the request
|
||||
// itself succeeds — relaxed to 'cross-origin' since this API already serves all its JSON to
|
||||
// those exact same origins per the CORS allowlist below; nothing new is being exposed.
|
||||
app.use(helmet({ crossOriginResourcePolicy: { policy: "cross-origin" } }));
|
||||
|
||||
// Serves apps/edr-passenger-api/public/* at the site root — package images live at
|
||||
// public/uploads/packages/<file>, reachable as GET /uploads/packages/<file>. Local-disk
|
||||
// storage is a deliberate, explicit stopgap (see packages.service.ts's uploadImage) rather
|
||||
// than this app's usual MinIO-backed upload pattern (see modules/support's attachments).
|
||||
app.useStaticAssets(join(__dirname, "..", "public"));
|
||||
|
||||
// URI versioning: the @tria-plc IAM controllers declare `version: "1"` so they register under
|
||||
// `/v1/...` (e.g. /v1/auth/login). Passenger controllers declare no version, so they stay
|
||||
|
||||
@@ -368,26 +368,22 @@ export class BookingsController {
|
||||
summary: "Create a group booking — staff bulk/group reservation, one PNR for the whole group",
|
||||
description: `Staff-only entry point for bulk/group bookings (e.g. tour groups booked via an uploaded passenger list and auto-assigned seats from POST /seats/auto-assign-hold).
|
||||
|
||||
Same body shape as POST /bookings/guest (CreateGuestBookingDto) and the same underlying pipeline — fare engine, ADULT/CHILD age pricing — just gated to staff and always ONE_WAY.
|
||||
Same body shape as POST /bookings/guest (CreateGuestBookingDto) and the same underlying pipeline — fare engine, ADULT/CHILD age pricing — just gated to staff. Supports ONE_WAY (default) and ROUND_TRIP via \`bookingType\`; for ROUND_TRIP, supply \`returnScheduleId\`/\`returnHoldId\`/\`returnOriginStationId\`/\`returnDestinationStationId\`/\`returnSeatClassId\` and each passenger's \`returnSeatId\`, exactly as POST /bookings/guest does.
|
||||
|
||||
Skips Verifayda national-ID verification: the roster comes from a staff-uploaded spreadsheet, not a live Fayda identity flow, so there is nothing to verify an ID number against. Passenger fields (name, DOB, nationality) are trusted exactly as uploaded.
|
||||
|
||||
Deliberately does NOT forward the staff caller's identity into booking creation: the acting staff member is not a Passenger, so the underlying guest-booking flow (which tries to resolve an authenticated caller as an existing Passenger profile) would reject the request. The booking is created exactly like a guest booking — a fresh passenger record, contact info from the first passenger in the list — with staff authorization enforced only at this route.
|
||||
|
||||
If booking creation fails after the seats were already held, the hold is released immediately so the seats don't sit locked for the rest of the hold TTL.`,
|
||||
If booking creation fails after the seats were already held, every hold involved (outbound and, for ROUND_TRIP, return) is released immediately so the seats don't sit locked for the rest of the hold TTL.`,
|
||||
})
|
||||
@ApiResponse({ status: 201, description: "Group booking created successfully with fareBreakdown" })
|
||||
@ApiResponse({ status: 400, description: "Missing required seat IDs" })
|
||||
async createGroup(@Body() dto: CreateGuestBookingDto) {
|
||||
try {
|
||||
return await this.guestService.createGuestBooking({ ...dto, bookingType: "ONE_WAY", skipIdentityVerification: true });
|
||||
return await this.guestService.createGuestBooking({ ...dto, bookingType: dto.bookingType || "ONE_WAY", skipIdentityVerification: true });
|
||||
} catch (err) {
|
||||
try {
|
||||
await this.seatsService.releaseHold(dto.holdId);
|
||||
} catch (releaseErr) {
|
||||
// Best-effort — the hold may already be gone (e.g. it expired mid-request). The
|
||||
// original booking-creation error is what the caller actually needs to see.
|
||||
}
|
||||
const holdIdsToRelease = [dto.holdId, dto.returnHoldId].filter((id): id is string => !!id);
|
||||
await Promise.allSettled(holdIdsToRelease.map((id) => this.seatsService.releaseHold(id)));
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -392,7 +392,10 @@ export class GuestBookingService {
|
||||
contactEmail: contact.contactEmail,
|
||||
contactPhone: contact.contactPhone,
|
||||
seats: {
|
||||
create: passengersWithFares.map((p) => ({
|
||||
// A free child (no seatId — see passengersWithFares above) has nothing to connect to;
|
||||
// `connect: { id: undefined }` throws PrismaClientValidationError immediately if this
|
||||
// filter is missing, so it's never optional here despite the map below looking safe.
|
||||
create: passengersWithFares.filter((p) => p.seatId).map((p) => ({
|
||||
seat: { connect: { id: p.seatId } },
|
||||
scheduleId: dto.scheduleId,
|
||||
passengerName: p.passengerName,
|
||||
@@ -763,7 +766,7 @@ export class GuestBookingService {
|
||||
passenger.idDocumentType === IdDocumentType.NATIONAL_ID;
|
||||
|
||||
if (isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID) {
|
||||
if (passenger.idDocumentNumber) {
|
||||
if (passenger.idDocumentNumber && !dto.skipIdentityVerification) {
|
||||
const verification = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber);
|
||||
if (!verification.verified) throw new BadRequestException(`Verifayda verification failed for ${passenger.passengerName}: ${verification.failureReason}`);
|
||||
passengerName = verification.passengerData?.fullName || passengerName;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { diskStorage } from 'multer';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { extname, join } from 'path';
|
||||
import { mkdirSync } from 'fs';
|
||||
import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
|
||||
|
||||
/** Multipart field name carrying the package image file. */
|
||||
export const PACKAGE_IMAGE_FIELD = 'image';
|
||||
|
||||
/** Local-disk stopgap (see packages.service.ts) — not this app's usual MinIO-backed upload pattern. */
|
||||
export const PACKAGE_IMAGE_UPLOAD_DIR = join(__dirname, '..', '..', '..', 'public', 'uploads', 'packages');
|
||||
|
||||
export const PACKAGE_IMAGE_MAX_BYTES = 5 * 1024 * 1024; // 5MB
|
||||
|
||||
const ALLOWED_MIME_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
|
||||
|
||||
export const packageImageMulterOptions: MulterOptions = {
|
||||
storage: diskStorage({
|
||||
destination: (_req, _file, cb) => {
|
||||
// mkdir on every request rather than once at module load — this directory is
|
||||
// gitignored (see public/uploads/packages/.gitignore) so a fresh checkout/deploy
|
||||
// won't have it yet, and recursive mkdir on an already-existing dir is a no-op.
|
||||
mkdirSync(PACKAGE_IMAGE_UPLOAD_DIR, { recursive: true });
|
||||
cb(null, PACKAGE_IMAGE_UPLOAD_DIR);
|
||||
},
|
||||
// Unique filename so two packages (or two uploads for the same package) never collide —
|
||||
// never trust or reuse the original filename.
|
||||
filename: (_req, file, cb) => {
|
||||
cb(null, `${randomUUID()}${extname(file.originalname).toLowerCase()}`);
|
||||
},
|
||||
}),
|
||||
limits: {
|
||||
fileSize: PACKAGE_IMAGE_MAX_BYTES,
|
||||
files: 1,
|
||||
},
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (!ALLOWED_MIME_TYPES.has(file.mimetype)) {
|
||||
cb(new BadRequestException('Image must be JPEG, PNG, WEBP, or GIF.'), false);
|
||||
return;
|
||||
}
|
||||
cb(null, true);
|
||||
},
|
||||
};
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Body, Controller, Get, Param, Post, Patch, Delete, UseGuards, Request, Query } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
||||
import { Body, Controller, Get, Param, Post, Patch, Delete, UseGuards, UseInterceptors, UploadedFile, Request, Query, BadRequestException } from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery, ApiConsumes, ApiBody } from '@nestjs/swagger';
|
||||
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
||||
import { PackagesService } from './packages.service';
|
||||
import { CreatePackageDto, BookPackageDto, CreatePriceTierDto, UpdatePriceTierDto, CreateInquiryDto, UpdateInquiryStatusDto, PackageBookingContextDto } from './packages.dto';
|
||||
import { PACKAGE_IMAGE_FIELD, packageImageMulterOptions } from './package-image-upload.options';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard';
|
||||
import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
|
||||
@@ -148,6 +150,29 @@ export class PackagesController {
|
||||
return this.service.remove(id, cascade === 'true');
|
||||
}
|
||||
|
||||
@Post(':id/image')
|
||||
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin])
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@UseInterceptors(FileInterceptor(PACKAGE_IMAGE_FIELD, packageImageMulterOptions))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiBody({ schema: { type: 'object', properties: { [PACKAGE_IMAGE_FIELD]: { type: 'string', format: 'binary' } } } })
|
||||
@ApiOperation({
|
||||
summary: 'Upload or replace a package image (admin)',
|
||||
description: 'JPEG/PNG/WEBP/GIF, max 5MB. Replaces and deletes the previous image file if one exists — works the same whether the package currently has an image or not, so this one route covers both the initial upload and later replacement.',
|
||||
})
|
||||
uploadImage(@Param('id') id: string, @UploadedFile() file?: Express.Multer.File) {
|
||||
if (!file) throw new BadRequestException('No image file provided.');
|
||||
return this.service.uploadImage(id, file);
|
||||
}
|
||||
|
||||
@Delete(':id/image')
|
||||
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin])
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Remove a package image without deleting the package (admin)' })
|
||||
removeImage(@Param('id') id: string) {
|
||||
return this.service.removeImage(id);
|
||||
}
|
||||
|
||||
@Patch(':id/activate')
|
||||
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin])
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { CurrencyService } from '../currency/currency.service';
|
||||
import { CreatePackageDto, BookPackageDto, UpdatePriceTierDto, CreatePriceTierDto, CreateInquiryDto } from './packages.dto';
|
||||
@@ -7,6 +7,9 @@ import { BookingsService } from '../bookings/bookings.service';
|
||||
import { GuestBookingService } from '../bookings/guest-booking.service';
|
||||
import { AuditService } from '../../common/audit.service';
|
||||
import { computePaymentDeadline, CUTOFF_MINUTES } from '../../common/utils/payment-deadline.utils';
|
||||
import { PACKAGE_IMAGE_UPLOAD_DIR } from './package-image-upload.options';
|
||||
import { join } from 'path';
|
||||
import { unlink } from 'fs/promises';
|
||||
|
||||
/** Package-specific fare rules */
|
||||
const PKG_MAX_ADULTS = 5;
|
||||
@@ -46,6 +49,8 @@ function generateRef(): string {
|
||||
|
||||
@Injectable()
|
||||
export class PackagesService {
|
||||
private readonly logger = new Logger(PackagesService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly currencyService: CurrencyService,
|
||||
@@ -325,6 +330,55 @@ export class PackagesService {
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Public URL prefix main.ts's app.useStaticAssets serves public/uploads/packages under. */
|
||||
private readonly PACKAGE_IMAGE_URL_PREFIX = '/uploads/packages/';
|
||||
|
||||
private packageImagePublicUrl(filename: string): string {
|
||||
const base = (process.env.APP_PUBLIC_URL || `http://localhost:${process.env.PORT || 4000}`).replace(/\/$/, '');
|
||||
return `${base}${this.PACKAGE_IMAGE_URL_PREFIX}${filename}`;
|
||||
}
|
||||
|
||||
/** Best-effort delete of the file backing a package's current imageUrl — never throws, since a
|
||||
* missing file (already deleted, moved, or from before this feature existed) shouldn't block
|
||||
* the DB update that's actually replacing/clearing the field. */
|
||||
private async deletePackageImageFile(imageUrl: string | null): Promise<void> {
|
||||
if (!imageUrl) return;
|
||||
const idx = imageUrl.indexOf(this.PACKAGE_IMAGE_URL_PREFIX);
|
||||
if (idx === -1) return; // not a file this app manages (e.g. an external URL) — nothing to delete
|
||||
const filename = imageUrl.slice(idx + this.PACKAGE_IMAGE_URL_PREFIX.length);
|
||||
if (!filename || filename.includes('/') || filename.includes('..')) return; // defensive: never touch paths outside the uploads dir
|
||||
try {
|
||||
await unlink(join(PACKAGE_IMAGE_UPLOAD_DIR, filename));
|
||||
} catch (err: any) {
|
||||
if (err?.code !== 'ENOENT') this.logger.warn(`Failed to delete package image file ${filename}: ${err?.message ?? err}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Multer's diskStorage has already written the file to PACKAGE_IMAGE_UPLOAD_DIR by the time
|
||||
* this runs (see package-image-upload.options.ts) — this just points the package at it and
|
||||
* cleans up whatever it's replacing. */
|
||||
async uploadImage(id: string, file: Express.Multer.File) {
|
||||
const pkg = await this.prisma.travelPackage.findUnique({ where: { id }, select: { imageUrl: true } });
|
||||
if (!pkg) throw new NotFoundException('Package not found');
|
||||
|
||||
const imageUrl = this.packageImagePublicUrl(file.filename);
|
||||
const updated = await this.prisma.travelPackage.update({ where: { id }, data: { imageUrl } });
|
||||
await this.deletePackageImageFile(pkg.imageUrl);
|
||||
await this.auditService.log({ action: 'UPDATE', entityType: 'Package', entityId: id, newData: { imageUrl } });
|
||||
return updated;
|
||||
}
|
||||
|
||||
async removeImage(id: string) {
|
||||
const pkg = await this.prisma.travelPackage.findUnique({ where: { id }, select: { imageUrl: true } });
|
||||
if (!pkg) throw new NotFoundException('Package not found');
|
||||
if (!pkg.imageUrl) return this.prisma.travelPackage.findUnique({ where: { id } });
|
||||
|
||||
const updated = await this.prisma.travelPackage.update({ where: { id }, data: { imageUrl: null } });
|
||||
await this.deletePackageImageFile(pkg.imageUrl);
|
||||
await this.auditService.log({ action: 'UPDATE', entityType: 'Package', entityId: id, newData: { imageUrl: null } });
|
||||
return updated;
|
||||
}
|
||||
|
||||
async addTier(packageId: string, dto: CreatePriceTierDto) {
|
||||
const pkg = await this.prisma.travelPackage.findUnique({ where: { id: packageId } });
|
||||
if (!pkg) throw new NotFoundException('Package not found');
|
||||
@@ -369,6 +423,7 @@ export class PackagesService {
|
||||
await this.prisma.packageInquiry.deleteMany({ where: { packageId: id } });
|
||||
await this.prisma.packagePriceTier.deleteMany({ where: { packageId: id } });
|
||||
await this.prisma.travelPackage.delete({ where: { id } });
|
||||
await this.deletePackageImageFile(pkg.imageUrl);
|
||||
await this.auditService.log({ action: 'DELETE', entityType: 'Package', entityId: id });
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsString, IsDateString, IsInt, IsOptional, IsEnum, IsArray, ValidateNested, IsObject, Min } from 'class-validator';
|
||||
import { IsString, IsDateString, IsInt, IsOptional, IsEnum, IsArray, ValidateNested, IsObject, IsBoolean, Min } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
@@ -56,6 +56,9 @@ export class CreateScheduleDto {
|
||||
@ApiPropertyOptional({ type: [String], description: 'Coach UUIDs to assign, in consist order. Overrides the route coach template if provided. A schedule must end up with at least one coach.' })
|
||||
@IsOptional() @IsArray() @IsString({ each: true })
|
||||
coachIds?: string[];
|
||||
|
||||
@ApiPropertyOptional({ example: false, description: 'Exclude from public search (reserved for packages)' }) @IsOptional() @IsBoolean() isPackageOnly?: boolean;
|
||||
@ApiPropertyOptional({ example: false, description: 'Exclude from public search (reserved for staff group bookings)' }) @IsOptional() @IsBoolean() isGroupBookingOnly?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateScheduleDto {
|
||||
@@ -64,6 +67,7 @@ export class UpdateScheduleDto {
|
||||
@ApiPropertyOptional({ enum: TripStatus, example: TripStatus.SCHEDULED }) @IsOptional() @IsEnum(TripStatus) status?: TripStatus;
|
||||
@ApiPropertyOptional({ type: Array, description: 'List of coaches to assign' }) @IsOptional() @IsArray() coaches?: Array<{ coachId: string; positionNumber: number }>;
|
||||
@ApiPropertyOptional({ example: false, description: 'Exclude from public search (reserved for packages)' }) @IsOptional() isPackageOnly?: boolean;
|
||||
@ApiPropertyOptional({ example: false, description: 'Exclude from public search (reserved for staff group bookings)' }) @IsOptional() @IsBoolean() isGroupBookingOnly?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateStopTimeDto {
|
||||
|
||||
@@ -263,6 +263,8 @@ export class SchedulesService {
|
||||
arrivalAt: arr,
|
||||
durationMinutes: Math.round((arr.getTime() - dep.getTime()) / 60_000),
|
||||
stopsCount: Math.max(0, route.stops.length - 2),
|
||||
isPackageOnly: dto.isPackageOnly ?? false,
|
||||
isGroupBookingOnly: dto.isGroupBookingOnly ?? false,
|
||||
},
|
||||
include: { train: true, originStation: true, destinationStation: true },
|
||||
});
|
||||
@@ -1000,6 +1002,7 @@ export class SchedulesService {
|
||||
|
||||
if (dto.status) updateData.status = dto.status;
|
||||
if (dto.isPackageOnly !== undefined) updateData.isPackageOnly = dto.isPackageOnly;
|
||||
if (dto.isGroupBookingOnly !== undefined) updateData.isGroupBookingOnly = dto.isGroupBookingOnly;
|
||||
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
await this.prisma.trainSchedule.update({ where: { id }, data: updateData });
|
||||
|
||||
@@ -27,6 +27,13 @@ export class SearchTripsDto {
|
||||
|
||||
@ApiPropertyOptional({ example: '2026-06-20', description: 'Return date (YYYY-MM-DD) — required for ROUND_TRIP, must be after outbound date' })
|
||||
@IsOptional() @IsDateString() returnDate?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: 'PORTAL',
|
||||
enum: ['PORTAL', 'GROUP_BOOKING'],
|
||||
description: 'Calling surface. Omit or PORTAL for normal ticket search (default) — only sees schedules with isGroupBookingOnly=false. GROUP_BOOKING sees only schedules with isGroupBookingOnly=true — the two are an exclusive partition, not additive; each channel sees a disjoint set of schedules.',
|
||||
})
|
||||
@IsOptional() @IsEnum(['PORTAL', 'GROUP_BOOKING']) channel?: string;
|
||||
}
|
||||
|
||||
export class AvailableDatesQueryDto {
|
||||
|
||||
@@ -82,6 +82,10 @@ export class SearchService {
|
||||
) {}
|
||||
|
||||
async searchTrips(dto: SearchTripsDto) {
|
||||
// GROUP_BOOKING is the staff-only bulk-booking wizard's own calling surface — isGroupBookingOnly
|
||||
// is an exclusive partition, not additive: this channel sees ONLY schedules explicitly created
|
||||
// for group booking, and the normal ticket channel (the default, PORTAL) sees only the rest.
|
||||
const forGroupBooking = dto.channel === "GROUP_BOOKING";
|
||||
const [direct, transit] = await Promise.all([
|
||||
this.searchSchedules(
|
||||
dto.originStationId,
|
||||
@@ -90,6 +94,7 @@ export class SearchService {
|
||||
dto.adultCount,
|
||||
dto.childCount,
|
||||
dto.nationality,
|
||||
forGroupBooking,
|
||||
),
|
||||
this.searchTransitOptions(
|
||||
dto.originStationId,
|
||||
@@ -98,6 +103,7 @@ export class SearchService {
|
||||
dto.adultCount,
|
||||
dto.childCount,
|
||||
dto.nationality,
|
||||
forGroupBooking,
|
||||
),
|
||||
]);
|
||||
|
||||
@@ -112,8 +118,9 @@ export class SearchService {
|
||||
dto.adultCount,
|
||||
dto.childCount,
|
||||
dto.nationality,
|
||||
forGroupBooking,
|
||||
),
|
||||
this.classifyEmptySearch(dto.originStationId, dto.destinationStationId, dto.date),
|
||||
this.classifyEmptySearch(dto.originStationId, dto.destinationStationId, dto.date, forGroupBooking),
|
||||
]);
|
||||
return {
|
||||
journeyType: "ONE_WAY",
|
||||
@@ -133,6 +140,7 @@ export class SearchService {
|
||||
dto.adultCount,
|
||||
dto.childCount,
|
||||
dto.nationality,
|
||||
forGroupBooking,
|
||||
),
|
||||
this.searchTransitOptions(
|
||||
dto.destinationStationId,
|
||||
@@ -141,6 +149,7 @@ export class SearchService {
|
||||
dto.adultCount,
|
||||
dto.childCount,
|
||||
dto.nationality,
|
||||
forGroupBooking,
|
||||
),
|
||||
]);
|
||||
|
||||
@@ -172,6 +181,7 @@ export class SearchService {
|
||||
dto.adultCount,
|
||||
dto.childCount,
|
||||
dto.nationality,
|
||||
forGroupBooking,
|
||||
)
|
||||
: Promise.resolve([]),
|
||||
inbound.length === 0
|
||||
@@ -182,13 +192,14 @@ export class SearchService {
|
||||
dto.adultCount,
|
||||
dto.childCount,
|
||||
dto.nationality,
|
||||
forGroupBooking,
|
||||
)
|
||||
: Promise.resolve([]),
|
||||
outbound.length === 0
|
||||
? this.classifyEmptySearch(dto.originStationId, dto.destinationStationId, dto.date)
|
||||
? this.classifyEmptySearch(dto.originStationId, dto.destinationStationId, dto.date, forGroupBooking)
|
||||
: Promise.resolve(undefined),
|
||||
inbound.length === 0
|
||||
? this.classifyEmptySearch(dto.destinationStationId, dto.originStationId, returnDate)
|
||||
? this.classifyEmptySearch(dto.destinationStationId, dto.originStationId, returnDate, forGroupBooking)
|
||||
: Promise.resolve(undefined),
|
||||
]);
|
||||
return {
|
||||
@@ -223,6 +234,7 @@ export class SearchService {
|
||||
adultCount: number,
|
||||
childCount?: number,
|
||||
nationality?: string,
|
||||
forGroupBooking = false,
|
||||
) {
|
||||
// Real millisecond arithmetic, not string-padded day-of-month increment — the latter
|
||||
// produces an invalid date (e.g. "2026-07-32") for any search on the last day of a month.
|
||||
@@ -241,6 +253,10 @@ export class SearchService {
|
||||
const baseWhere: Prisma.TrainScheduleWhereInput = {
|
||||
status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] },
|
||||
isPackageOnly: false,
|
||||
// Group Booking's search is exclusive, not additive: staff only ever see schedules
|
||||
// explicitly created for group booking, never the normal passenger-facing ones, and the
|
||||
// portal never sees group-only ones. Each channel is a strict partition of the other.
|
||||
isGroupBookingOnly: forGroupBooking,
|
||||
stopTimes: { some: { stationId: originStationId } },
|
||||
coachAssignments: { some: {} },
|
||||
};
|
||||
@@ -310,6 +326,7 @@ export class SearchService {
|
||||
adultCount: number,
|
||||
childCount?: number,
|
||||
nationality?: string,
|
||||
forGroupBooking = false,
|
||||
) {
|
||||
// Real millisecond arithmetic, not string-padded day-of-month increment — the latter
|
||||
// produces an invalid date (e.g. "2026-07-32") for any search on the last day of a month.
|
||||
@@ -329,6 +346,8 @@ export class SearchService {
|
||||
// statuses, not booking-closed signals (see comment on searchAlternatives' baseWhere).
|
||||
status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] },
|
||||
isPackageOnly: false,
|
||||
// Exclusive partition — see the comment on searchAlternatives' baseWhere.
|
||||
isGroupBookingOnly: forGroupBooking,
|
||||
departureAt: { gte: date, lt: nextDay },
|
||||
stopTimes: { some: { stationId: originStationId } },
|
||||
coachAssignments: { some: {} },
|
||||
@@ -363,6 +382,7 @@ export class SearchService {
|
||||
originStationId: string,
|
||||
destinationStationId: string,
|
||||
dateStr: string,
|
||||
forGroupBooking = false,
|
||||
): Promise<Passenger.ISearchEmptyReason> {
|
||||
const [origin, destination] = await Promise.all([
|
||||
this.prisma.station.findUnique({ where: { id: originStationId }, select: { name: true } }),
|
||||
@@ -393,6 +413,7 @@ export class SearchService {
|
||||
select: {
|
||||
status: true,
|
||||
isPackageOnly: true,
|
||||
isGroupBookingOnly: true,
|
||||
departureAt: true,
|
||||
route: {
|
||||
select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } },
|
||||
@@ -409,13 +430,20 @@ export class SearchService {
|
||||
if (sameDayForPair.length === 0) return withCode(Passenger.SearchEmptyReasonCode.NoScheduleOnDate);
|
||||
|
||||
// 3. Schedules exist that date — narrow to ones that would otherwise be bookable
|
||||
// (right status, not package-only, has at least one coach assigned).
|
||||
const bookable = sameDayForPair.filter((s) => this.isBookableSchedule(s));
|
||||
// (right status, not package-only, not group-booking-only unless this IS a group-booking
|
||||
// search, has at least one coach assigned).
|
||||
const bookable = sameDayForPair.filter((s) => this.isBookableSchedule(s, forGroupBooking));
|
||||
if (bookable.length === 0) {
|
||||
if (sameDayForPair.every((s) => s.status === "CANCELLED"))
|
||||
return withCode(Passenger.SearchEmptyReasonCode.Cancelled);
|
||||
if (sameDayForPair.every((s) => s.isPackageOnly))
|
||||
return withCode(Passenger.SearchEmptyReasonCode.PackageOnly);
|
||||
// isGroupBookingOnly is an exclusive partition (see isBookableSchedule) — this same reason
|
||||
// code covers both directions: the portal finding only group-reserved schedules, and Group
|
||||
// Booking finding only normal ones (nothing set up for it on this date). The frontend picks
|
||||
// the right copy per caller.
|
||||
if (sameDayForPair.every((s) => s.isGroupBookingOnly !== forGroupBooking))
|
||||
return withCode(Passenger.SearchEmptyReasonCode.GroupBookingOnly);
|
||||
return withCode(Passenger.SearchEmptyReasonCode.NoScheduleOnDate);
|
||||
}
|
||||
|
||||
@@ -483,11 +511,22 @@ export class SearchService {
|
||||
/** Bounds the stop-time fallback scan — connectivity is a yes/no, not a survey. */
|
||||
private readonly ROUTE_EXISTS_SCHEDULE_SCAN_LIMIT = 200;
|
||||
|
||||
/** Status/package/coach bookability only — ignores date, cutoff, and seat-level availability. */
|
||||
private isBookableSchedule(s: { status: string; isPackageOnly: boolean; coachAssignments: { id: string }[] }): boolean {
|
||||
/**
|
||||
* Status/package/group-booking/coach bookability only — ignores date, cutoff, and seat-level
|
||||
* availability. `forGroupBooking` defaults false so existing single-arg callers (e.g.
|
||||
* getAvailableDates, the portal's calendar) keep hiding group-booking-only schedules.
|
||||
* isGroupBookingOnly is an exclusive partition, not an additive one: a schedule is bookable
|
||||
* for a given channel only when its flag exactly matches that channel (normal schedules for
|
||||
* the portal, group-only schedules for Group Booking — never both from one channel).
|
||||
*/
|
||||
private isBookableSchedule(
|
||||
s: { status: string; isPackageOnly: boolean; isGroupBookingOnly: boolean; coachAssignments: { id: string }[] },
|
||||
forGroupBooking = false,
|
||||
): boolean {
|
||||
return (
|
||||
(["SCHEDULED", "BOARDING", "EN_ROUTE"] as string[]).includes(s.status) &&
|
||||
!s.isPackageOnly &&
|
||||
s.isGroupBookingOnly === forGroupBooking &&
|
||||
s.coachAssignments.length > 0
|
||||
);
|
||||
}
|
||||
@@ -542,6 +581,7 @@ export class SearchService {
|
||||
departureAt: true,
|
||||
status: true,
|
||||
isPackageOnly: true,
|
||||
isGroupBookingOnly: true,
|
||||
route: {
|
||||
select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } },
|
||||
},
|
||||
@@ -581,6 +621,7 @@ export class SearchService {
|
||||
adultCount: number,
|
||||
childCount?: number,
|
||||
nationality?: string,
|
||||
forGroupBooking = false,
|
||||
) {
|
||||
// Real millisecond arithmetic, not string-padded day-of-month increment — the latter
|
||||
// produces an invalid date (e.g. "2026-07-32") for any search on the last day of a month.
|
||||
@@ -599,6 +640,8 @@ export class SearchService {
|
||||
// BOARDING included alongside SCHEDULED — see comment on searchAlternatives' baseWhere.
|
||||
status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] },
|
||||
isPackageOnly: false,
|
||||
// Exclusive partition — see the comment on searchAlternatives' baseWhere.
|
||||
isGroupBookingOnly: forGroupBooking,
|
||||
departureAt: { gte: dayStart, lt: dayEnd },
|
||||
stopTimes: { some: { stationId: originStationId } },
|
||||
coachAssignments: { some: {} },
|
||||
@@ -609,6 +652,7 @@ export class SearchService {
|
||||
where: {
|
||||
status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] },
|
||||
isPackageOnly: false,
|
||||
isGroupBookingOnly: forGroupBooking,
|
||||
departureAt: { gte: dayStart, lt: leg2WindowEnd },
|
||||
coachAssignments: { some: {} },
|
||||
},
|
||||
|
||||
@@ -191,10 +191,12 @@ This makes it clear which segment of the route each seat is held for, enabling s
|
||||
@ApiBearerAuth("IAM-auth")
|
||||
@ApiOperation({
|
||||
summary: "Auto-assign and hold N seats of a class — staff bulk/group booking only",
|
||||
description: `Picks the requested number of available seats of the given class (preferring a contiguous row) and holds them in one step, so the caller never shows an assignment it could lose to a race before the passenger data is submitted.
|
||||
description: `Picks the requested number of available seats of the given class (filling Lower berths first, then Middle, then Upper, ascending seat number within each tier) and holds them in one step, so the caller never shows an assignment it could lose to a race before the passenger data is submitted.
|
||||
|
||||
No manual seat selection — this is for bulk/group booking flows where staff upload a passenger list rather than picking seats on a seat map. Returns the same hold shape as POST /seats/hold.
|
||||
|
||||
For a round-trip group booking, call this twice — once per leg — passing \`journeyDirection: 'OUTBOUND'\`/\`'RETURN'\` so a same-schedule turnaround round trip isn't mistaken for a double-hold conflict.
|
||||
|
||||
Throws 409 with no partial hold created if fewer than the requested seats are available in that class.`,
|
||||
})
|
||||
@ApiResponse({ status: 201, description: "Seats auto-assigned and held" })
|
||||
@@ -207,6 +209,7 @@ Throws 409 with no partial hold created if fewer than the requested seats are av
|
||||
dto.destinationStationId,
|
||||
dto.seatClassName,
|
||||
passengerCount,
|
||||
dto.journeyDirection,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +67,13 @@ export class AutoAssignHoldDto {
|
||||
|
||||
@ApiPropertyOptional({ example: 1, minimum: 0, description: 'Number of child passengers to assign seats for.' })
|
||||
@IsOptional() @IsInt() @Min(0) childCount?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: JourneyDirection,
|
||||
example: JourneyDirection.OUTBOUND,
|
||||
description: 'Round-trip leg direction — OUTBOUND or RETURN. Omit for a plain one-way group booking (the hold defaults to ONE_WAY), preserving current behavior.',
|
||||
})
|
||||
@IsOptional() @IsEnum(JourneyDirection) journeyDirection?: JourneyDirection;
|
||||
}
|
||||
|
||||
export class ReleaseHoldDto {
|
||||
|
||||
@@ -920,6 +920,7 @@ export class SeatsService {
|
||||
destinationStationId: string,
|
||||
seatClassName: string,
|
||||
passengerCount: number,
|
||||
journeyDirection?: JourneyDirection,
|
||||
) {
|
||||
const seatIds = await this.autoAssignSeats(scheduleId, passengerCount, seatClassName);
|
||||
// Scope the synthetic passengerId to this attempt (not just its row index) — a fixed
|
||||
@@ -933,6 +934,7 @@ export class SeatsService {
|
||||
scheduleId,
|
||||
originStationId,
|
||||
destinationStationId,
|
||||
journeyDirection,
|
||||
passengers,
|
||||
} as HoldSeatsDto);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
type SupportedPaymentMethod,
|
||||
} from '@/lib/api/group-booking';
|
||||
import { buildPassengerTemplate } from '@/lib/export/passenger-template';
|
||||
import { countByType, parsePassengerExcel, type ParsedPassengerRow } from '@/lib/import/passenger-excel';
|
||||
import { countByType, parsePassengerExcel, resolveFreeChildIndexes, type ParsedPassengerRow } from '@/lib/import/passenger-excel';
|
||||
import ActionButton from '@/components/ui/ActionButton';
|
||||
import DatePicker from '@/components/ui/DatePicker';
|
||||
import Skeleton from '@/components/ui/Skeleton';
|
||||
@@ -69,6 +69,11 @@ function emptySearchMessage(reason: SearchEmptyReason | undefined): string {
|
||||
return `Every departure from ${o} to ${d} on this date was cancelled.`;
|
||||
case 'PACKAGE_ONLY':
|
||||
return `Departures on this date are reserved for travel packages, not regular ticketing.`;
|
||||
case 'GROUP_BOOKING_ONLY':
|
||||
// isGroupBookingOnly is an exclusive partition — this page only ever sees group-booking
|
||||
// schedules, so an empty result here means a regular (non-group) train runs on this date
|
||||
// but nothing has been set up for group booking specifically.
|
||||
return `A regular train runs from ${o} to ${d} on this date, but no schedule has been set up for group booking yet — ask fleet/schedule management to create one, or try another date.`;
|
||||
case 'CHECKIN_CLOSED':
|
||||
return `Check-in has already closed for every departure on this date.`;
|
||||
case 'FULLY_BOOKED':
|
||||
@@ -246,6 +251,8 @@ function GroupBookingPageContent() {
|
||||
const [originStationId, setOriginStationId] = useState('');
|
||||
const [destinationStationId, setDestinationStationId] = useState('');
|
||||
const [travelDate, setTravelDate] = useState('');
|
||||
const [tripType, setTripType] = useState<'ONE_WAY' | 'ROUND_TRIP'>('ONE_WAY');
|
||||
const [returnDate, setReturnDate] = useState('');
|
||||
const [adultCount, setAdultCount] = useState<number>(1);
|
||||
const [childCount, setChildCount] = useState<number>(0);
|
||||
const [searchTouched, setSearchTouched] = useState(false);
|
||||
@@ -265,7 +272,7 @@ function GroupBookingPageContent() {
|
||||
|
||||
const totalPassengers = (adultCount || 0) + (childCount || 0);
|
||||
const searchValid = !!originStationId && !!destinationStationId && originStationId !== destinationStationId
|
||||
&& !!travelDate && totalPassengers > 0;
|
||||
&& !!travelDate && totalPassengers > 0 && (tripType === 'ONE_WAY' || !!returnDate);
|
||||
|
||||
const searchMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
@@ -275,14 +282,19 @@ function GroupBookingPageContent() {
|
||||
date: travelDate,
|
||||
adultCount: adultCount || 0,
|
||||
childCount: childCount || 0,
|
||||
journeyType: 'ONE_WAY',
|
||||
journeyType: tripType,
|
||||
returnDate: tripType === 'ROUND_TRIP' ? returnDate : undefined,
|
||||
nationality: fareTier === 'LOCAL' ? 'Ethiopian' : 'Other',
|
||||
channel: 'GROUP_BOOKING',
|
||||
}),
|
||||
});
|
||||
|
||||
const runSearch = () => {
|
||||
setSearchTouched(true);
|
||||
if (!searchValid) return;
|
||||
setResultsPhase('outbound');
|
||||
setSelectedReturnSchedule(null);
|
||||
setSelectedReturnClass(null);
|
||||
setStep('results');
|
||||
searchMutation.mutate();
|
||||
};
|
||||
@@ -290,13 +302,23 @@ function GroupBookingPageContent() {
|
||||
// ── Step 2: results / class selection ───────────────────────────────────
|
||||
const [selectedSchedule, setSelectedSchedule] = useState<ScheduleResult | null>(null);
|
||||
const [selectedClass, setSelectedClass] = useState<SelectedClass | null>(null);
|
||||
// Round trip only — the outbound/return picks happen as two phases of this same step,
|
||||
// mirroring the passenger portal's results page (search once, pick outbound, then return).
|
||||
const [resultsPhase, setResultsPhase] = useState<'outbound' | 'return'>('outbound');
|
||||
const [selectedReturnSchedule, setSelectedReturnSchedule] = useState<ScheduleResult | null>(null);
|
||||
const [selectedReturnClass, setSelectedReturnClass] = useState<SelectedClass | null>(null);
|
||||
|
||||
const seatClassId = useMemo(() => {
|
||||
if (!selectedClass) return null;
|
||||
return seatClassOptions.find((sc) => sc.name === selectedClass.className)?.id ?? null;
|
||||
}, [selectedClass, seatClassOptions]);
|
||||
|
||||
const chooseClass = (schedule: ScheduleResult, cls: ScheduleClassOption, category: string, totalAvailable: number) => {
|
||||
const returnSeatClassId = useMemo(() => {
|
||||
if (!selectedReturnClass) return null;
|
||||
return seatClassOptions.find((sc) => sc.name === selectedReturnClass.className)?.id ?? null;
|
||||
}, [selectedReturnClass, seatClassOptions]);
|
||||
|
||||
const chooseOutboundClass = (schedule: ScheduleResult, cls: ScheduleClassOption, category: string, totalAvailable: number) => {
|
||||
setSelectedSchedule(schedule);
|
||||
setSelectedClass({
|
||||
scheduleId: schedule.scheduleId,
|
||||
@@ -306,6 +328,26 @@ function GroupBookingPageContent() {
|
||||
displayCurrency: cls.displayCurrency,
|
||||
available: totalAvailable,
|
||||
});
|
||||
if (tripType === 'ROUND_TRIP') {
|
||||
// Force re-confirming the return leg if staff changes their mind on outbound later.
|
||||
setSelectedReturnSchedule(null);
|
||||
setSelectedReturnClass(null);
|
||||
setResultsPhase('return');
|
||||
} else {
|
||||
setStep('passengers');
|
||||
}
|
||||
};
|
||||
|
||||
const chooseReturnClass = (schedule: ScheduleResult, cls: ScheduleClassOption, category: string, totalAvailable: number) => {
|
||||
setSelectedReturnSchedule(schedule);
|
||||
setSelectedReturnClass({
|
||||
scheduleId: schedule.scheduleId,
|
||||
className: cls.name,
|
||||
category,
|
||||
fareMinor: cls.baseFareMinor,
|
||||
displayCurrency: cls.displayCurrency,
|
||||
available: totalAvailable,
|
||||
});
|
||||
setStep('passengers');
|
||||
};
|
||||
|
||||
@@ -322,6 +364,16 @@ function GroupBookingPageContent() {
|
||||
const countMismatch = passengerRows.length > 0 && (uploadedAdults !== adultCount || uploadedChildren !== childCount);
|
||||
const passengersValid = passengerRows.length > 0 && fileErrors.length === 0 && !rowsHaveErrors && !countMismatch;
|
||||
|
||||
// One-way only — the portal's own "1 free child per adult, no seat" rule (fare-utils.ts's
|
||||
// isFirstChild). Round trip can't offer this: createGuestRoundTripBooking hard-requires a
|
||||
// returnSeatId on every passenger, so every child there still needs a real seat both ways.
|
||||
const freeChildFlags = useMemo(
|
||||
() => (tripType === 'ONE_WAY' ? resolveFreeChildIndexes(passengerRows, adultCount) : passengerRows.map(() => false)),
|
||||
[passengerRows, adultCount, tripType],
|
||||
);
|
||||
const freeChildrenCount = freeChildFlags.filter(Boolean).length;
|
||||
const paidChildrenCount = uploadedChildren - freeChildrenCount;
|
||||
|
||||
const downloadTemplate = async () => {
|
||||
if (!selectedSchedule || !selectedClass) return;
|
||||
const blob = await buildPassengerTemplate({
|
||||
@@ -370,25 +422,52 @@ function GroupBookingPageContent() {
|
||||
// ── Step 4: auto-assign + hold ───────────────────────────────────────────
|
||||
const [assignError, setAssignError] = useState<string | null>(null);
|
||||
const [hold, setHold] = useState<AutoAssignHoldResponse | null>(null);
|
||||
const [returnHold, setReturnHold] = useState<AutoAssignHoldResponse | null>(null);
|
||||
|
||||
const autoAssignMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
mutationFn: async () => {
|
||||
if (!selectedSchedule || !selectedClass) throw new Error('No schedule/class selected');
|
||||
return groupBookingApi.autoAssignHold({
|
||||
// One-way's free children (see freeChildFlags above) need no seat at all — only ask for
|
||||
// seats covering adults + paid children. Round trip can't offer that (every passenger
|
||||
// needs both a seatId and a returnSeatId), so it still requests one seat per child.
|
||||
const outboundHold = await groupBookingApi.autoAssignHold({
|
||||
scheduleId: selectedSchedule.scheduleId,
|
||||
originStationId: selectedSchedule.origin.id,
|
||||
destinationStationId: selectedSchedule.destination.id,
|
||||
seatClassName: selectedClass.className,
|
||||
adultCount,
|
||||
childCount,
|
||||
childCount: tripType === 'ONE_WAY' ? paidChildrenCount : childCount,
|
||||
journeyDirection: tripType === 'ROUND_TRIP' ? 'OUTBOUND' : undefined,
|
||||
});
|
||||
if (tripType !== 'ROUND_TRIP') return { outboundHold, returnHold: null };
|
||||
if (!selectedReturnSchedule || !selectedReturnClass) throw new Error('No return schedule/class selected');
|
||||
try {
|
||||
const returnHoldResp = await groupBookingApi.autoAssignHold({
|
||||
scheduleId: selectedReturnSchedule.scheduleId,
|
||||
originStationId: selectedReturnSchedule.origin.id,
|
||||
destinationStationId: selectedReturnSchedule.destination.id,
|
||||
seatClassName: selectedReturnClass.className,
|
||||
adultCount,
|
||||
childCount,
|
||||
journeyDirection: 'RETURN',
|
||||
});
|
||||
return { outboundHold, returnHold: returnHoldResp };
|
||||
} catch (err) {
|
||||
// Return leg failed after outbound already succeeded — release the outbound hold
|
||||
// immediately instead of leaving it locked for the rest of the hold TTL.
|
||||
await groupBookingApi.releaseHold(outboundHold.holdId).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setHold(data);
|
||||
onSuccess: ({ outboundHold, returnHold: returnHoldResp }) => {
|
||||
setHold(outboundHold);
|
||||
setReturnHold(returnHoldResp);
|
||||
setAssignError(null);
|
||||
setStep('confirm');
|
||||
},
|
||||
onError: (err: any) => {
|
||||
setHold(null);
|
||||
setReturnHold(null);
|
||||
setAssignError(err?.response?.data?.message ?? err?.message ?? 'Not enough seats are available for this class.');
|
||||
},
|
||||
});
|
||||
@@ -399,14 +478,23 @@ function GroupBookingPageContent() {
|
||||
autoAssignMutation.mutate();
|
||||
};
|
||||
|
||||
// Pairs each validated passenger row (upload order) with its auto-assigned seat (same order).
|
||||
// Pairs each validated passenger row (upload order) with its auto-assigned seat(s). A free
|
||||
// child (freeChildFlags[i]) consumes no seat at all — it's skipped when walking the hold's
|
||||
// seat list, so seat N goes to the Nth non-free passenger, not the Nth row.
|
||||
const seatAssignments = useMemo(() => {
|
||||
if (!hold) return [];
|
||||
return passengerRows.map((row, i) => ({
|
||||
row,
|
||||
seat: hold.passengers[i]?.seat ?? null,
|
||||
}));
|
||||
}, [hold, passengerRows]);
|
||||
let seatIdx = 0;
|
||||
return passengerRows.map((row, i) => {
|
||||
const isFree = freeChildFlags[i];
|
||||
const seat = isFree ? null : (hold.passengers[seatIdx++]?.seat ?? null);
|
||||
return {
|
||||
row,
|
||||
seat,
|
||||
returnSeat: returnHold?.passengers[i]?.seat ?? null,
|
||||
isFree,
|
||||
};
|
||||
});
|
||||
}, [hold, returnHold, passengerRows, freeChildFlags]);
|
||||
|
||||
// ── Step 5: create booking ───────────────────────────────────────────────
|
||||
const [bookingError, setBookingError] = useState<string | null>(null);
|
||||
@@ -417,15 +505,29 @@ function GroupBookingPageContent() {
|
||||
if (!selectedSchedule || !selectedClass || !hold || !seatClassId) {
|
||||
throw new Error('Missing schedule, class, or hold — go back and try again.');
|
||||
}
|
||||
if (tripType === 'ROUND_TRIP' && (!selectedReturnSchedule || !selectedReturnClass || !returnHold || !returnSeatClassId)) {
|
||||
throw new Error('Missing return schedule, class, or hold — go back and try again.');
|
||||
}
|
||||
return groupBookingApi.createGroupBooking({
|
||||
scheduleId: selectedSchedule.scheduleId,
|
||||
holdId: hold.holdId,
|
||||
originStationId: selectedSchedule.origin.id,
|
||||
destinationStationId: selectedSchedule.destination.id,
|
||||
seatClassId,
|
||||
bookingType: 'ONE_WAY',
|
||||
passengers: seatAssignments.map(({ row, seat }) => ({
|
||||
seatId: seat!.id,
|
||||
bookingType: tripType,
|
||||
...(tripType === 'ROUND_TRIP' ? {
|
||||
returnScheduleId: selectedReturnSchedule!.scheduleId,
|
||||
returnHoldId: returnHold!.holdId,
|
||||
returnOriginStationId: selectedReturnSchedule!.origin.id,
|
||||
returnDestinationStationId: selectedReturnSchedule!.destination.id,
|
||||
returnSeatClassId: returnSeatClassId!,
|
||||
} : {}),
|
||||
passengers: seatAssignments.map(({ row, seat, returnSeat, isFree }) => ({
|
||||
// Free children (ONE_WAY only) have no seat at all — omit seatId entirely,
|
||||
// matching the portal's own convention (guest-booking.service.ts treats a missing
|
||||
// seatId as "unseated = free (0)").
|
||||
...(isFree ? {} : { seatId: seat!.id }),
|
||||
...(tripType === 'ROUND_TRIP' ? { returnSeatId: returnSeat!.id } : {}),
|
||||
passengerName: row.fullName,
|
||||
dateOfBirth: row.dateOfBirth,
|
||||
idDocumentType: row.idDocumentType as any,
|
||||
@@ -444,8 +546,9 @@ function GroupBookingPageContent() {
|
||||
setStep('success');
|
||||
},
|
||||
onError: (err: any) => {
|
||||
// The hold was released server-side on failure — a retry needs a fresh one.
|
||||
// Both holds were released server-side on failure — a retry needs fresh ones.
|
||||
setHold(null);
|
||||
setReturnHold(null);
|
||||
setBookingError(err?.response?.data?.message ?? err?.message ?? 'Could not create the booking.');
|
||||
},
|
||||
});
|
||||
@@ -456,12 +559,16 @@ function GroupBookingPageContent() {
|
||||
};
|
||||
|
||||
// ── Step 6: pay ───────────────────────────────────────────────────────────
|
||||
const { data: paymentMethods = [] } = useQuery<SupportedPaymentMethod[]>({
|
||||
const { data: paymentMethodsData } = useQuery<SupportedPaymentMethod[]>({
|
||||
queryKey: ['payment-methods'],
|
||||
queryFn: () => groupBookingApi.getPaymentMethods(),
|
||||
enabled: step === 'success',
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
// Defensive: never let a malformed/unexpected response shape (e.g. an unwrap mismatch, or a
|
||||
// proxy/error page returned in place of JSON) crash the page with a raw TypeError — an empty
|
||||
// list here just shows "Loading payment options…" a beat longer instead.
|
||||
const paymentMethods = Array.isArray(paymentMethodsData) ? paymentMethodsData : [];
|
||||
const enabledPaymentMethods = paymentMethods.filter((m) => m.enabled);
|
||||
|
||||
const [selectedPaymentType, setSelectedPaymentType] = useState<string | null>(null);
|
||||
@@ -506,8 +613,12 @@ function GroupBookingPageContent() {
|
||||
setStep('search');
|
||||
setSelectedSchedule(null);
|
||||
setSelectedClass(null);
|
||||
setResultsPhase('outbound');
|
||||
setSelectedReturnSchedule(null);
|
||||
setSelectedReturnClass(null);
|
||||
clearUpload();
|
||||
setHold(null);
|
||||
setReturnHold(null);
|
||||
setAssignError(null);
|
||||
setBooking(null);
|
||||
setBookingError(null);
|
||||
@@ -532,16 +643,31 @@ function GroupBookingPageContent() {
|
||||
{/* Selection summary bar — visible from Step 2 onward */}
|
||||
{selectedSchedule && selectedClass && step !== 'search' && step !== 'results' && (
|
||||
<div className="card flex items-center justify-between flex-wrap gap-3 animate-fade-up">
|
||||
<div className="flex items-center gap-3 text-sm flex-wrap">
|
||||
<div className="rounded-lg bg-primary/10 p-1.5 shrink-0">
|
||||
<Train className="h-4 w-4 text-primary" />
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-3 text-sm flex-wrap">
|
||||
<div className="rounded-lg bg-primary/10 p-1.5 shrink-0">
|
||||
<Train className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{tripType === 'ROUND_TRIP' ? 'Outbound' : 'Trip'}</span>
|
||||
<span className="font-semibold">{selectedSchedule.trainNumber}</span>
|
||||
<span className="text-muted-foreground">{selectedSchedule.origin.name} → {selectedSchedule.destination.name}</span>
|
||||
<span className="text-muted-foreground">· {formatDateTime(selectedSchedule.departureAt)}</span>
|
||||
<span className="text-muted-foreground">· {selectedClass.category}</span>
|
||||
<span className="text-muted-foreground">· {fareTier === 'LOCAL' ? 'Local' : 'International'} rates</span>
|
||||
<span className="text-muted-foreground">· {adultCount} Adult{adultCount === 1 ? '' : 's'}{childCount > 0 ? ` + ${childCount} Child${childCount === 1 ? '' : 'ren'}` : ''}</span>
|
||||
</div>
|
||||
<span className="font-semibold">{selectedSchedule.trainNumber}</span>
|
||||
<span className="text-muted-foreground">{selectedSchedule.origin.name} → {selectedSchedule.destination.name}</span>
|
||||
<span className="text-muted-foreground">· {formatDateTime(selectedSchedule.departureAt)}</span>
|
||||
<span className="text-muted-foreground">· {selectedClass.category}</span>
|
||||
<span className="text-muted-foreground">· {fareTier === 'LOCAL' ? 'Local' : 'International'} rates</span>
|
||||
<span className="text-muted-foreground">· {adultCount} Adult{adultCount === 1 ? '' : 's'}{childCount > 0 ? ` + ${childCount} Child${childCount === 1 ? '' : 'ren'}` : ''}</span>
|
||||
{tripType === 'ROUND_TRIP' && selectedReturnSchedule && selectedReturnClass && (
|
||||
<div className="flex items-center gap-3 text-sm flex-wrap">
|
||||
<div className="rounded-lg bg-primary/10 p-1.5 shrink-0 invisible">
|
||||
<Train className="h-4 w-4" />
|
||||
</div>
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Return</span>
|
||||
<span className="font-semibold">{selectedReturnSchedule.trainNumber}</span>
|
||||
<span className="text-muted-foreground">{selectedReturnSchedule.origin.name} → {selectedReturnSchedule.destination.name}</span>
|
||||
<span className="text-muted-foreground">· {formatDateTime(selectedReturnSchedule.departureAt)}</span>
|
||||
<span className="text-muted-foreground">· {selectedReturnClass.category}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" onClick={startOver} className="text-xs text-primary hover:underline shrink-0">
|
||||
Start over
|
||||
@@ -559,6 +685,24 @@ function GroupBookingPageContent() {
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Search Availability</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{(['ONE_WAY', 'ROUND_TRIP'] as const).map((tt) => (
|
||||
<button
|
||||
key={tt}
|
||||
type="button"
|
||||
onClick={() => { setTripType(tt); if (tt === 'ONE_WAY') setReturnDate(''); }}
|
||||
className={cn(
|
||||
'flex-1 rounded-lg border px-3 py-2 text-sm transition-colors',
|
||||
tripType === tt
|
||||
? 'border-primary bg-primary/5 text-foreground font-medium'
|
||||
: 'border-border text-muted-foreground hover:border-primary/50',
|
||||
)}
|
||||
>
|
||||
{tt === 'ONE_WAY' ? 'One Way' : 'Round Trip'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<div>
|
||||
<label className="label">Origin</label>
|
||||
@@ -610,6 +754,21 @@ function GroupBookingPageContent() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tripType === 'ROUND_TRIP' && (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<div>
|
||||
<label className="label">Return Date</label>
|
||||
<DatePicker
|
||||
value={returnDate}
|
||||
onChange={setReturnDate}
|
||||
placeholder="Pick a return date"
|
||||
minDate={travelDate ? new Date(`${travelDate}T00:00:00`) : new Date(new Date().setHours(0, 0, 0, 0))}
|
||||
className="w-full [&>button]:w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="label">Fare Tier</label>
|
||||
<div className="flex gap-2">
|
||||
@@ -648,7 +807,9 @@ function GroupBookingPageContent() {
|
||||
? 'Enter at least one adult or child.'
|
||||
: originStationId && originStationId === destinationStationId
|
||||
? 'Origin and destination must be different.'
|
||||
: 'Fill in origin, destination, and travel date.'}
|
||||
: tripType === 'ROUND_TRIP' && travelDate && !returnDate
|
||||
? 'Pick a return date.'
|
||||
: 'Fill in origin, destination, and travel date.'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -661,10 +822,35 @@ function GroupBookingPageContent() {
|
||||
{/* ── Step 2: Results ────────────────────────────────────────────── */}
|
||||
{step === 'results' && (
|
||||
<div className="space-y-4 animate-fade-up">
|
||||
<button type="button" onClick={() => setStep('search')} className="flex items-center gap-1 text-xs text-primary hover:underline">
|
||||
<ArrowLeft className="h-3.5 w-3.5" /> Back to search
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => tripType === 'ROUND_TRIP' && resultsPhase === 'return' ? setResultsPhase('outbound') : setStep('search')}
|
||||
className="flex items-center gap-1 text-xs text-primary hover:underline"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" /> {tripType === 'ROUND_TRIP' && resultsPhase === 'return' ? 'Back to outbound' : 'Back to search'}
|
||||
</button>
|
||||
|
||||
{tripType === 'ROUND_TRIP' && (
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{resultsPhase === 'outbound' ? 'Step 2a — Choose the outbound trip' : 'Step 2b — Choose the return trip'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{tripType === 'ROUND_TRIP' && resultsPhase === 'return' && selectedSchedule && selectedClass && (
|
||||
<div className="card flex items-center justify-between flex-wrap gap-3">
|
||||
<div className="flex items-center gap-3 text-sm flex-wrap">
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-600 dark:text-emerald-400 shrink-0" />
|
||||
<span className="font-semibold">{selectedSchedule.trainNumber}</span>
|
||||
<span className="text-muted-foreground">{selectedSchedule.origin.name} → {selectedSchedule.destination.name}</span>
|
||||
<span className="text-muted-foreground">· {formatDateTime(selectedSchedule.departureAt)}</span>
|
||||
<span className="text-muted-foreground">· {selectedClass.category}</span>
|
||||
</div>
|
||||
<button type="button" onClick={() => setResultsPhase('outbound')} className="text-xs text-primary hover:underline shrink-0">
|
||||
Change outbound
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{searchMutation.isPending && (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 2 }).map((_, i) => (
|
||||
@@ -696,28 +882,59 @@ function GroupBookingPageContent() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{searchMutation.isSuccess && searchMutation.data.outbound.length === 0 && (
|
||||
<div className="card py-12 text-center text-muted-foreground">
|
||||
<Train className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||
<p className="text-foreground font-medium">No schedules found for this search.</p>
|
||||
<p className="text-xs mt-1">{emptySearchMessage(searchMutation.data.outboundReason)}</p>
|
||||
</div>
|
||||
)}
|
||||
{searchMutation.isSuccess && resultsPhase === 'outbound' && (
|
||||
<>
|
||||
{searchMutation.data.outbound.length === 0 && (
|
||||
<div className="card py-12 text-center text-muted-foreground">
|
||||
<Train className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||
<p className="text-foreground font-medium">No schedules found for this search.</p>
|
||||
<p className="text-xs mt-1">{emptySearchMessage(searchMutation.data.outboundReason)}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{searchMutation.isSuccess && (searchMutation.data.alternativeOutbound?.length ?? 0) > 0 && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground flex items-center gap-1.5">
|
||||
<Sparkles className="h-3.5 w-3.5" /> Nearby schedules for the same route
|
||||
</p>
|
||||
{searchMutation.data!.alternativeOutbound!.map((schedule) => (
|
||||
<ScheduleCard key={schedule.scheduleId} schedule={schedule} totalPassengers={totalPassengers} onChoose={chooseClass} />
|
||||
{(searchMutation.data.alternativeOutbound?.length ?? 0) > 0 && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground flex items-center gap-1.5">
|
||||
<Sparkles className="h-3.5 w-3.5" /> Nearby schedules for the same route
|
||||
</p>
|
||||
{searchMutation.data.alternativeOutbound!.map((schedule) => (
|
||||
<ScheduleCard key={schedule.scheduleId} schedule={schedule} totalPassengers={totalPassengers} onChoose={chooseOutboundClass} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{searchMutation.data.outbound.map((schedule) => (
|
||||
<ScheduleCard key={schedule.scheduleId} schedule={schedule} totalPassengers={totalPassengers} onChoose={chooseOutboundClass} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(searchMutation.data?.outbound ?? []).map((schedule) => (
|
||||
<ScheduleCard key={schedule.scheduleId} schedule={schedule} totalPassengers={totalPassengers} onChoose={chooseClass} />
|
||||
))}
|
||||
{searchMutation.isSuccess && tripType === 'ROUND_TRIP' && resultsPhase === 'return' && (
|
||||
<>
|
||||
{(searchMutation.data.inbound?.length ?? 0) === 0 && (
|
||||
<div className="card py-12 text-center text-muted-foreground">
|
||||
<Train className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||
<p className="text-foreground font-medium">No return schedules found for this search.</p>
|
||||
<p className="text-xs mt-1">{emptySearchMessage(searchMutation.data.inboundReason)}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(searchMutation.data.alternativeInbound?.length ?? 0) > 0 && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground flex items-center gap-1.5">
|
||||
<Sparkles className="h-3.5 w-3.5" /> Nearby return schedules for the same route
|
||||
</p>
|
||||
{searchMutation.data.alternativeInbound!.map((schedule) => (
|
||||
<ScheduleCard key={schedule.scheduleId} schedule={schedule} totalPassengers={totalPassengers} onChoose={chooseReturnClass} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(searchMutation.data.inbound ?? []).map((schedule) => (
|
||||
<ScheduleCard key={schedule.scheduleId} schedule={schedule} totalPassengers={totalPassengers} onChoose={chooseReturnClass} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -735,10 +952,20 @@ function GroupBookingPageContent() {
|
||||
</div>
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Passenger Information</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-4 ml-9">
|
||||
<p className="text-sm text-muted-foreground mb-1 ml-9">
|
||||
Download the template, fill in one row per passenger, then upload the completed file.
|
||||
Need exactly <strong className="text-foreground">{totalPassengers}</strong> passenger{totalPassengers === 1 ? '' : 's'} ({adultCount} Adult{adultCount === 1 ? '' : 's'}{childCount > 0 ? `, ${childCount} Child${childCount === 1 ? '' : 'ren'}` : ''}).
|
||||
</p>
|
||||
{tripType === 'ONE_WAY' && childCount > 0 && (
|
||||
<p className="text-xs text-muted-foreground mb-4 ml-9">
|
||||
The first {Math.min(childCount, adultCount)} child{Math.min(childCount, adultCount) === 1 ? '' : 'ren'} under 5 (by row order) travel{Math.min(childCount, adultCount) === 1 ? 's' : ''} free with no assigned seat, matching the passenger portal's policy — one free child per adult. Any additional children get a seat and pay the child fare.
|
||||
</p>
|
||||
)}
|
||||
{tripType === 'ROUND_TRIP' && childCount > 0 && (
|
||||
<p className="text-xs text-muted-foreground mb-4 ml-9">
|
||||
Round trip requires a seat for every child on both legs — the free-child policy only applies to one-way bookings.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="ml-9 flex items-center gap-3 flex-wrap mb-4">
|
||||
<ActionButton icon={Download} variant="secondary" onClick={downloadTemplate}>
|
||||
@@ -810,12 +1037,19 @@ function GroupBookingPageContent() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{passengerRows.map((row) => (
|
||||
{passengerRows.map((row, i) => (
|
||||
<tr key={row.rowNumber} className={row.errors.length > 0 ? 'bg-red-50/60 dark:bg-red-950/20' : undefined}>
|
||||
<td className="px-4 py-2 text-xs text-muted-foreground">{row.rowNumber}</td>
|
||||
<td className="px-4 py-2 whitespace-nowrap">{row.fullName || '—'}</td>
|
||||
<td className="px-4 py-2 whitespace-nowrap text-muted-foreground">{row.dateOfBirth || '—'}</td>
|
||||
<td className="px-4 py-2 whitespace-nowrap">{row.passengerType || '—'}</td>
|
||||
<td className="px-4 py-2 whitespace-nowrap">
|
||||
{row.passengerType || '—'}
|
||||
{freeChildFlags[i] && (
|
||||
<span className="ml-1.5 inline-flex items-center rounded-full bg-emerald-100 dark:bg-emerald-900/30 px-1.5 py-0.5 text-[10px] font-semibold text-emerald-700 dark:text-emerald-400">
|
||||
Free · no seat
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 whitespace-nowrap text-muted-foreground">{row.idDocumentType || '—'}</td>
|
||||
<td className="px-4 py-2 whitespace-nowrap text-muted-foreground">{row.nationality || '—'}</td>
|
||||
<td className="px-4 py-2 text-xs">
|
||||
@@ -876,20 +1110,53 @@ function GroupBookingPageContent() {
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-4">Read-only — seats are assigned by the system, not selected manually.</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
{seatAssignments.map(({ row, seat }) => (
|
||||
{seatAssignments.map(({ row, seat, isFree }) => (
|
||||
<div key={row.rowNumber} className="flex items-center gap-3 rounded-lg border border-border p-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 font-mono text-xs font-bold text-primary">
|
||||
{seat ? (seat.seatNumber ?? seat.label ?? '?') : '—'}
|
||||
<div className={cn(
|
||||
'flex h-10 w-10 shrink-0 items-center justify-center rounded-lg font-mono text-xs font-bold',
|
||||
isFree ? 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-400' : 'bg-primary/10 text-primary',
|
||||
)}>
|
||||
{isFree ? 'Free' : seat ? (seat.seatNumber ?? seat.label ?? '?') : '—'}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{row.fullName}</p>
|
||||
<p className="text-xs text-muted-foreground">{row.passengerType} · {seat?.coach ?? '—'}</p>
|
||||
<p className="text-xs text-muted-foreground">{row.passengerType} · {isFree ? 'no seat' : (seat?.coach ?? '—')}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tripType === 'ROUND_TRIP' && returnHold && (
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="rounded-lg bg-primary/10 p-1.5">
|
||||
<ArmchairIcon className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Return Seats Assigned Automatically
|
||||
</h3>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">Held for {Math.floor(returnHold.ttlSeconds / 60)}m {returnHold.ttlSeconds % 60}s</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-4">Read-only — seats are assigned by the system, not selected manually.</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
{seatAssignments.map(({ row, returnSeat }) => (
|
||||
<div key={row.rowNumber} className="flex items-center gap-3 rounded-lg border border-border p-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 font-mono text-xs font-bold text-primary">
|
||||
{returnSeat ? (returnSeat.seatNumber ?? returnSeat.label ?? '?') : '—'}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{row.fullName}</p>
|
||||
<p className="text-xs text-muted-foreground">{row.passengerType} · {returnSeat?.coach ?? '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bookingError && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-950/30 p-3 text-sm text-red-700 dark:text-red-300 flex items-center justify-between gap-3">
|
||||
<span className="flex items-center gap-2"><AlertTriangle className="h-4 w-4 shrink-0" /> {bookingError}</span>
|
||||
@@ -899,7 +1166,16 @@ function GroupBookingPageContent() {
|
||||
|
||||
<div className="card flex items-center justify-between flex-wrap gap-3">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{totalPassengers} passenger{totalPassengers === 1 ? '' : 's'} · {selectedClass.category} · <span className="font-semibold text-foreground">{formatCurrency(selectedClass.fareMinor * totalPassengers, selectedClass.displayCurrency)}</span> estimated total
|
||||
{totalPassengers} passenger{totalPassengers === 1 ? '' : 's'}
|
||||
{tripType === 'ONE_WAY' && freeChildrenCount > 0 && <> ({freeChildrenCount} free)</>} · {selectedClass.category}
|
||||
{tripType === 'ROUND_TRIP' && selectedReturnClass && <> / {selectedReturnClass.category}</>} ·{' '}
|
||||
<span className="font-semibold text-foreground">
|
||||
{formatCurrency(
|
||||
selectedClass.fareMinor * (tripType === 'ONE_WAY' ? adultCount + paidChildrenCount : totalPassengers)
|
||||
+ (selectedReturnClass?.fareMinor ?? 0) * totalPassengers,
|
||||
selectedClass.displayCurrency,
|
||||
)}
|
||||
</span> estimated total
|
||||
</div>
|
||||
<ActionButton
|
||||
icon={CheckCircle2}
|
||||
@@ -947,7 +1223,10 @@ function GroupBookingPageContent() {
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Coach / Class</p>
|
||||
<p className="font-semibold">{selectedClass?.category ?? '—'}</p>
|
||||
<p className="font-semibold">
|
||||
{selectedClass?.category ?? '—'}
|
||||
{tripType === 'ROUND_TRIP' && selectedReturnClass && <> / {selectedReturnClass.category}</>}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Adults / Children</p>
|
||||
@@ -955,7 +1234,7 @@ function GroupBookingPageContent() {
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Total Passengers</p>
|
||||
<p className="font-semibold">{booking.seats.length}</p>
|
||||
<p className="font-semibold">{totalPassengers}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Total Fare</p>
|
||||
@@ -964,12 +1243,38 @@ function GroupBookingPageContent() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tripType === 'ROUND_TRIP' && selectedReturnSchedule && (
|
||||
<div className="card">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-3">Return Trip</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Train</p>
|
||||
<p className="font-semibold">{selectedReturnSchedule.trainNumber} · {selectedReturnSchedule.trainName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Origin → Destination</p>
|
||||
<p className="font-semibold">{selectedReturnSchedule.origin.name} → {selectedReturnSchedule.destination.name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Departure → Arrival</p>
|
||||
<p className="font-semibold">{formatDateTime(selectedReturnSchedule.departureAt)} → {formatDateTime(selectedReturnSchedule.arrivalAt)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Class</p>
|
||||
<p className="font-semibold">{selectedReturnClass?.category ?? '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card p-0">
|
||||
<div className="px-4 pt-4 pb-3">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Passenger List</h3>
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{tripType === 'ROUND_TRIP' ? 'Outbound Passenger List' : 'Passenger List'}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3 px-4 pb-4">
|
||||
{booking.seats.map((s, i) => (
|
||||
{booking.seats.filter((s) => s.leg !== 2).map((s, i) => (
|
||||
<div key={i} className="flex items-center gap-3 rounded-lg border border-border p-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 font-mono text-xs font-bold text-primary">
|
||||
{s.seat?.seatNumber ?? '?'}
|
||||
@@ -982,9 +1287,45 @@ function GroupBookingPageContent() {
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{/* Free children have no BookingSeat row at all — list them separately so they
|
||||
don't silently disappear from the confirmation. */}
|
||||
{seatAssignments.filter((a) => a.isFree).map(({ row }) => (
|
||||
<div key={row.rowNumber} className="flex items-center gap-3 rounded-lg border border-border p-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-emerald-100 dark:bg-emerald-900/30 font-mono text-xs font-bold text-emerald-700 dark:text-emerald-400">
|
||||
Free
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{row.fullName}</p>
|
||||
<p className="text-xs text-muted-foreground">CHILD · no seat (free)</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tripType === 'ROUND_TRIP' && (
|
||||
<div className="card p-0">
|
||||
<div className="px-4 pt-4 pb-3">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Return Passenger List</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3 px-4 pb-4">
|
||||
{booking.seats.filter((s) => s.leg === 2).map((s, i) => (
|
||||
<div key={i} className="flex items-center gap-3 rounded-lg border border-border p-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 font-mono text-xs font-bold text-primary">
|
||||
{s.seat?.seatNumber ?? '?'}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{s.passengerName}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{s.passengerCategory} · {seatTypeLabel(s.seat?.bedPosition)} · Seat {s.seat?.seatNumber ?? '—'} · Coach {s.seat?.coach?.number}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Pay now ─────────────────────────────────────────────────── */}
|
||||
{!paymentResult && (
|
||||
<div className="card">
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
TicketCheck, Users, TrendingUp, ShieldCheck, MailCheck,
|
||||
} from 'lucide-react';
|
||||
import { iamAuthApi } from '@/lib/api/auth';
|
||||
import { getErrorMessage } from '@/lib/api-client';
|
||||
|
||||
const EDR_GREEN = 'rgb(20, 113, 76)';
|
||||
|
||||
@@ -52,11 +53,11 @@ export default function LoginPage() {
|
||||
await login(identifier.trim(), password);
|
||||
router.push('/dashboard');
|
||||
} catch (err: any) {
|
||||
const msg = err.message || err.response?.data?.message || '';
|
||||
if (msg === 'ACCESS_DENIED') {
|
||||
const rawMessage = err.response?.data?.message;
|
||||
if (rawMessage === 'ACCESS_DENIED') {
|
||||
setError('This account does not have back-office access. Contact your administrator.');
|
||||
} else {
|
||||
setError(err.response?.data?.message || msg || 'Invalid credentials. Please try again.');
|
||||
setError(getErrorMessage(err, 'Invalid credentials. Please try again.'));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -71,11 +72,11 @@ export default function LoginPage() {
|
||||
await iamAuthApi.forgotPassword(forgotIdentifier.trim());
|
||||
setForgotSent(true);
|
||||
} catch (err: any) {
|
||||
const msg = err.response?.data?.message || err.message || '';
|
||||
const rawMessage = err.response?.data?.message;
|
||||
setForgotError(
|
||||
msg === 'user_not_found'
|
||||
rawMessage === 'user_not_found'
|
||||
? 'No account found with that email or phone number.'
|
||||
: msg || 'Failed to send the reset link. Please try again.'
|
||||
: getErrorMessage(err, 'Failed to send the reset link. Please try again.')
|
||||
);
|
||||
} finally {
|
||||
setForgotLoading(false);
|
||||
|
||||
@@ -10,6 +10,7 @@ import Modal from '@/components/ui/Modal';
|
||||
import Pagination from '@/components/ui/Pagination';
|
||||
import { packagesApi } from '@/lib/api';
|
||||
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
||||
import { getErrorMessage } from '@/lib/api-client';
|
||||
|
||||
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
|
||||
<div className="bg-muted/40 rounded-lg p-3">
|
||||
@@ -116,7 +117,7 @@ export default function PackageBookingsPage() {
|
||||
<div className="card">
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 p-4 text-sm text-red-800 dark:text-red-200">
|
||||
Error: {(error as any)?.response?.data?.message || (error as any)?.message || String(error)}
|
||||
Error: {getErrorMessage(error)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-3 mb-4">
|
||||
|
||||
@@ -2,15 +2,21 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Edit, CheckCircle, Eye, Layers, Trash2 } from 'lucide-react';
|
||||
import { Plus, Edit, CheckCircle, Eye, Layers, Trash2, ImagePlus, ImageOff, X } from 'lucide-react';
|
||||
import DataTable from '@/components/ui/DataTable';
|
||||
import Badge from '@/components/ui/Badge';
|
||||
import ActionButton from '@/components/ui/ActionButton';
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import { packagesApi, stationsApi, schedulesApi, seatClassesApi } from '@/lib/api';
|
||||
import { getErrorMessage } from '@/lib/api-client';
|
||||
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
||||
|
||||
// Mirrors the backend's own limits (packages/package-image-upload.options.ts) so a bad file is
|
||||
// rejected instantly client-side instead of round-tripping to the server first.
|
||||
const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
|
||||
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
const toLocal = (iso?: string) => {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
@@ -48,6 +54,15 @@ export default function PackagesPage() {
|
||||
const [deletePackageConfirm, setDeletePackageConfirm] = useState<any>(null);
|
||||
const [deletePackageError, setDeletePackageError] = useState<string | null>(null);
|
||||
const [deletePackageCascade, setDeletePackageCascade] = useState(false);
|
||||
// Image upload: `imageFile`/`imagePreviewUrl` track a newly-selected-but-not-yet-uploaded file
|
||||
// (local object URL preview); `existingImageUrl` is the package's current server-side image
|
||||
// when editing, shown until/unless the admin picks a replacement.
|
||||
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||
const [imagePreviewUrl, setImagePreviewUrl] = useState<string | null>(null);
|
||||
const [existingImageUrl, setExistingImageUrl] = useState<string | null>(null);
|
||||
const [imageError, setImageError] = useState<string | null>(null);
|
||||
const [imageUploadError, setImageUploadError] = useState<string | null>(null);
|
||||
const [removeImageConfirm, setRemoveImageConfirm] = useState<any>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
@@ -141,6 +156,23 @@ export default function PackagesPage() {
|
||||
onError: (e: any) => setTierError(e?.response?.data?.message || e?.message || 'Failed to delete tier'),
|
||||
});
|
||||
|
||||
const uploadImageMutation = useMutation({
|
||||
mutationFn: ({ id, file }: { id: string; file: File }) => packagesApi.uploadImage(id, file),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['packages'] }); setImageUploadError(null); },
|
||||
onError: (e: any) => setImageUploadError(getErrorMessage(e, 'Failed to upload package image')),
|
||||
});
|
||||
|
||||
const removeImageMutation = useMutation({
|
||||
mutationFn: (id: string) => packagesApi.removeImage(id),
|
||||
onSuccess: (updated: any) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['packages'] });
|
||||
setRemoveImageConfirm(null);
|
||||
setExistingImageUrl(updated?.imageUrl ?? null);
|
||||
setViewPackage((prev: any) => (prev && prev.id === updated?.id ? { ...prev, imageUrl: null } : prev));
|
||||
},
|
||||
onError: (e: any) => setImageUploadError(getErrorMessage(e, 'Failed to remove package image')),
|
||||
});
|
||||
|
||||
const openEditTier = (tier: any) => {
|
||||
setEditingTier(tier);
|
||||
setTierForm({ seatClassId: tier.seatClassId ?? '', seatType: tier.seatType, label: tier.label, priceMinor: String(tier.priceMinor), availableSeats: String(tier.availableSeats) });
|
||||
@@ -163,13 +195,29 @@ export default function PackagesPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// Deliberately does not touch imageUploadError — that's shown in a page-level banner (outside
|
||||
// this modal) precisely because it can still be set after the modal has already auto-closed
|
||||
// (see handleSubmit), and clearing it here would wipe it out before the user ever sees it.
|
||||
const resetImageSelection = () => {
|
||||
setImageFile(null);
|
||||
if (imagePreviewUrl) URL.revokeObjectURL(imagePreviewUrl);
|
||||
setImagePreviewUrl(null);
|
||||
setImageError(null);
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setForm(emptyForm);
|
||||
setEditingId(null);
|
||||
resetImageSelection();
|
||||
setImageUploadError(null);
|
||||
setExistingImageUrl(null);
|
||||
setModalMode('create');
|
||||
};
|
||||
|
||||
const openEdit = (pkg: any) => {
|
||||
resetImageSelection();
|
||||
setImageUploadError(null);
|
||||
setExistingImageUrl(pkg.imageUrl ?? null);
|
||||
setForm({
|
||||
code: pkg.code ?? '',
|
||||
name: pkg.name ?? '',
|
||||
@@ -216,11 +264,23 @@ export default function PackagesPage() {
|
||||
validUntil: form.validUntil,
|
||||
priceTiers: [],
|
||||
};
|
||||
// The image is uploaded as a separate follow-up call (the DTO here carries no image field —
|
||||
// see packages.service.ts's uploadImage) so it must run after the package itself exists.
|
||||
let targetId = editingId;
|
||||
if (modalMode === 'edit' && editingId) {
|
||||
await updateMutation.mutateAsync({ id: editingId, data: payload });
|
||||
} else {
|
||||
await createMutation.mutateAsync(payload);
|
||||
const created = await createMutation.mutateAsync(payload);
|
||||
targetId = created?.id ?? null;
|
||||
}
|
||||
if (imageFile && targetId) {
|
||||
try {
|
||||
await uploadImageMutation.mutateAsync({ id: targetId, file: imageFile });
|
||||
} catch {
|
||||
// surfaced via imageUploadError banner — the package itself was already saved successfully
|
||||
}
|
||||
}
|
||||
resetImageSelection();
|
||||
};
|
||||
|
||||
const field = (key: keyof typeof form) => ({
|
||||
@@ -229,6 +289,24 @@ export default function PackagesPage() {
|
||||
setForm((f) => ({ ...f, [key]: e.target.value })),
|
||||
});
|
||||
|
||||
const handleImageFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = ''; // allow re-selecting the same file after a validation error
|
||||
if (!file) return;
|
||||
if (!ALLOWED_IMAGE_TYPES.includes(file.type)) {
|
||||
setImageError('Image must be JPEG, PNG, WEBP, or GIF.');
|
||||
return;
|
||||
}
|
||||
if (file.size > MAX_IMAGE_BYTES) {
|
||||
setImageError(`Image must be ${Math.round(MAX_IMAGE_BYTES / (1024 * 1024))}MB or smaller.`);
|
||||
return;
|
||||
}
|
||||
setImageError(null);
|
||||
if (imagePreviewUrl) URL.revokeObjectURL(imagePreviewUrl);
|
||||
setImageFile(file);
|
||||
setImagePreviewUrl(URL.createObjectURL(file));
|
||||
};
|
||||
|
||||
const scheduleLabel = (s: any) => {
|
||||
const from = s.originStation?.name ?? s.originStationId ?? '?';
|
||||
const to = s.destinationStation?.name ?? s.destinationStationId ?? '?';
|
||||
@@ -237,7 +315,19 @@ export default function PackagesPage() {
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ key: 'code', label: 'Package',
|
||||
{
|
||||
key: 'image', label: '',
|
||||
render: (p: any) => (
|
||||
p.imageUrl ? (
|
||||
<img src={p.imageUrl} alt="" className="h-10 w-10 rounded object-cover border border-border" />
|
||||
) : (
|
||||
<div className="h-10 w-10 rounded border border-border bg-muted flex items-center justify-center">
|
||||
<ImageOff className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
),
|
||||
},
|
||||
{ key: 'code', label: 'Package',
|
||||
render: (pkg: any) => (
|
||||
<div className="text-sm">
|
||||
<div>{pkg.code}</div>
|
||||
@@ -299,7 +389,7 @@ export default function PackagesPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||
const isPending = createMutation.isPending || updateMutation.isPending || uploadImageMutation.isPending;
|
||||
|
||||
const allItems: any[] = data?.items || [];
|
||||
const filteredItems = allItems.filter((p) => {
|
||||
@@ -320,6 +410,18 @@ export default function PackagesPage() {
|
||||
<ActionButton icon={Plus} onClick={openCreate}>New Package</ActionButton>
|
||||
</div>
|
||||
|
||||
{/* The package itself may already be saved and this modal closed by the time an image
|
||||
upload/removal fails (see handleSubmit) — surfaced here rather than inside the modal
|
||||
so it's never silently lost. */}
|
||||
{imageUploadError && (
|
||||
<div className="flex items-center justify-between rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 text-sm text-red-800 dark:text-red-300">
|
||||
<span>{imageUploadError}</span>
|
||||
<button type="button" className="ml-3 shrink-0" onClick={() => setImageUploadError(null)}>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
<div className="mb-4 space-y-3">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
@@ -366,6 +468,14 @@ export default function PackagesPage() {
|
||||
<Modal isOpen={!!viewPackage} onClose={() => setViewPackage(null)} title="Package Details" size="lg">
|
||||
{viewPackage && (
|
||||
<div className="space-y-4 text-sm">
|
||||
{viewPackage.imageUrl ? (
|
||||
<img src={viewPackage.imageUrl} alt={viewPackage.name} className="w-full max-h-56 rounded-lg object-cover border border-border" />
|
||||
) : (
|
||||
<div className="w-full h-32 rounded-lg border border-dashed border-border bg-muted flex items-center justify-center gap-2 text-muted-foreground">
|
||||
<ImageOff className="h-5 w-5" />
|
||||
<span>No image</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div><span className="label">Code</span><p className="font-mono font-semibold">{viewPackage.code}</p></div>
|
||||
<div><span className="label">Status</span><p>{viewPackage.status}</p></div>
|
||||
@@ -549,10 +659,23 @@ export default function PackagesPage() {
|
||||
error={tierError ?? undefined}
|
||||
/>
|
||||
|
||||
{/* Remove Image Confirmation */}
|
||||
<ConfirmDialog
|
||||
isOpen={!!removeImageConfirm}
|
||||
onClose={() => setRemoveImageConfirm(null)}
|
||||
onConfirm={() => removeImageMutation.mutate(removeImageConfirm.id)}
|
||||
title="Remove Package Image"
|
||||
message={`Remove the image for "${removeImageConfirm?.name}"? The package itself will not be deleted.`}
|
||||
confirmText="Remove Image"
|
||||
isDanger
|
||||
isLoading={removeImageMutation.isPending}
|
||||
error={imageUploadError ?? undefined}
|
||||
/>
|
||||
|
||||
{/* Create / Edit Modal */}
|
||||
<Modal
|
||||
isOpen={modalMode !== null}
|
||||
onClose={() => setModalMode(null)}
|
||||
onClose={() => { setModalMode(null); resetImageSelection(); }}
|
||||
title={modalMode === 'edit' ? 'Edit Package' : 'New Package'}
|
||||
size="lg"
|
||||
>
|
||||
@@ -571,6 +694,40 @@ export default function PackagesPage() {
|
||||
<textarea className="input" rows={2} placeholder="Optional description" {...field('description')} />
|
||||
</div>
|
||||
|
||||
<div className="col-span-2">
|
||||
<label className="label">Package Image</label>
|
||||
<div className="flex items-start gap-4">
|
||||
{imagePreviewUrl ? (
|
||||
<img src={imagePreviewUrl} alt="Preview" className="h-24 w-24 rounded-lg object-cover border border-border" />
|
||||
) : existingImageUrl ? (
|
||||
<img src={existingImageUrl} alt="Current" className="h-24 w-24 rounded-lg object-cover border border-border" />
|
||||
) : (
|
||||
<div className="h-24 w-24 rounded-lg border border-dashed border-border bg-muted flex items-center justify-center">
|
||||
<ImagePlus className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 space-y-2">
|
||||
<input type="file" accept="image/jpeg,image/png,image/webp,image/gif" className="input" onChange={handleImageFileChange} />
|
||||
<p className="text-xs text-muted-foreground">JPEG, PNG, WEBP, or GIF. Max 5MB.</p>
|
||||
{imageError && <p className="text-xs text-red-600 dark:text-red-400">{imageError}</p>}
|
||||
{imageFile && (
|
||||
<button type="button" className="text-xs text-primary underline" onClick={resetImageSelection}>
|
||||
<X className="h-3 w-3 inline -mt-0.5 mr-0.5" />Clear selected file
|
||||
</button>
|
||||
)}
|
||||
{!imageFile && modalMode === 'edit' && existingImageUrl && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-red-600 dark:text-red-400 underline block"
|
||||
onClick={() => { setImageUploadError(null); setRemoveImageConfirm({ id: editingId, name: form.name }); }}
|
||||
>
|
||||
Remove current image
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Origin Station *</label>
|
||||
<select className="input" required {...field('originStationId')}>
|
||||
@@ -660,7 +817,7 @@ export default function PackagesPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<ActionButton type="button" variant="secondary" onClick={() => setModalMode(null)}>Cancel</ActionButton>
|
||||
<ActionButton type="button" variant="secondary" onClick={() => { setModalMode(null); resetImageSelection(); }}>Cancel</ActionButton>
|
||||
<ActionButton type="submit" loading={isPending}>
|
||||
{modalMode === 'edit' ? 'Update Package' : 'Create Package'}
|
||||
</ActionButton>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { Eye, EyeOff, ArrowRight, ArrowLeft, Loader2, CheckCircle2 } from 'lucide-react';
|
||||
import { iamAuthApi } from '@/lib/api/auth';
|
||||
import { getErrorMessage } from '@/lib/api-client';
|
||||
|
||||
function ResetPasswordForm() {
|
||||
const router = useRouter();
|
||||
@@ -41,8 +42,7 @@ function ResetPasswordForm() {
|
||||
setSuccess(true);
|
||||
setTimeout(() => router.push('/login'), 2000);
|
||||
} catch (err: any) {
|
||||
const msg = err.response?.data?.message || err.message || '';
|
||||
setError(msg || 'Failed to reset password. The link may have expired — request a new one from the sign-in page.');
|
||||
setError(getErrorMessage(err, 'Failed to reset password. The link may have expired — request a new one from the sign-in page.'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -30,9 +30,22 @@ interface Schedule {
|
||||
destinationStation?: { id: string; name: string };
|
||||
coachAssignments?: Array<{ coachId: string; positionNumber: number; coach?: { id: string; number: string } }>;
|
||||
isPackageOnly?: boolean;
|
||||
isGroupBookingOnly?: boolean;
|
||||
liveStatus?: { delayMinutes: number } | null;
|
||||
}
|
||||
|
||||
/** Mutually-exclusive UI view over the two independent isPackageOnly/isGroupBookingOnly flags
|
||||
* the API actually stores — same pair, just presented as one choice instead of two checkboxes. */
|
||||
type ScheduleVisibility = 'NORMAL' | 'PACKAGE_ONLY' | 'GROUP_ONLY';
|
||||
function visibilityOf(isPackageOnly?: boolean, isGroupBookingOnly?: boolean): ScheduleVisibility {
|
||||
if (isGroupBookingOnly) return 'GROUP_ONLY';
|
||||
if (isPackageOnly) return 'PACKAGE_ONLY';
|
||||
return 'NORMAL';
|
||||
}
|
||||
function visibilityFlags(v: ScheduleVisibility): { isPackageOnly: boolean; isGroupBookingOnly: boolean } {
|
||||
return { isPackageOnly: v === 'PACKAGE_ONLY', isGroupBookingOnly: v === 'GROUP_ONLY' };
|
||||
}
|
||||
|
||||
interface Train {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -84,7 +97,7 @@ function SchedulesPageContent() {
|
||||
|
||||
const [bulkCoachRows, setBulkCoachRows] = useState<{ coachId: string; positionNumber: number }[]>([]);
|
||||
|
||||
const [addForm, setAddForm] = useState({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' });
|
||||
const [addForm, setAddForm] = useState({ trainId: '', routeId: '', departureAt: '', arrivalAt: '', isPackageOnly: false, isGroupBookingOnly: false });
|
||||
const [addCoachRows, setAddCoachRows] = useState<{ coachId: string; positionNumber: number }[]>([]);
|
||||
|
||||
const { data: singleRouteTemplate, isLoading: singleTemplateLoading } = useQuery({
|
||||
@@ -122,6 +135,7 @@ function SchedulesPageContent() {
|
||||
status: 'SCHEDULED',
|
||||
coachIds: [] as string[],
|
||||
isPackageOnly: false,
|
||||
isGroupBookingOnly: false,
|
||||
});
|
||||
|
||||
const [filters, setFilters] = useState({
|
||||
@@ -188,7 +202,7 @@ function SchedulesPageContent() {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['schedules'] });
|
||||
setShowAddModal(false);
|
||||
setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' });
|
||||
setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '', isPackageOnly: false, isGroupBookingOnly: false });
|
||||
setAddCoachRows([]);
|
||||
setError(null);
|
||||
},
|
||||
@@ -288,6 +302,8 @@ function SchedulesPageContent() {
|
||||
routeId: addForm.routeId,
|
||||
departureAt: dep.toISOString(),
|
||||
arrivalAt: arr.toISOString(),
|
||||
isPackageOnly: addForm.isPackageOnly,
|
||||
isGroupBookingOnly: addForm.isGroupBookingOnly,
|
||||
...(validCoaches.length > 0 && { coachIds: validCoaches.map((r) => r.coachId) }),
|
||||
});
|
||||
};
|
||||
@@ -312,6 +328,7 @@ function SchedulesPageContent() {
|
||||
arrivalAt: eatLocalToISO(editForm.arrivalAt),
|
||||
status: editForm.status,
|
||||
isPackageOnly: editForm.isPackageOnly,
|
||||
isGroupBookingOnly: editForm.isGroupBookingOnly,
|
||||
coaches: editForm.coachIds.map((coachId: string, idx: number) => ({
|
||||
coachId,
|
||||
positionNumber: idx + 1,
|
||||
@@ -356,6 +373,7 @@ function SchedulesPageContent() {
|
||||
status: schedule.status,
|
||||
coachIds: schedule.coachAssignments?.map((ca: any) => ca.coachId) || [],
|
||||
isPackageOnly: schedule.isPackageOnly ?? false,
|
||||
isGroupBookingOnly: schedule.isGroupBookingOnly ?? false,
|
||||
});
|
||||
setError(null);
|
||||
setShowEditModal(true);
|
||||
@@ -494,6 +512,9 @@ function SchedulesPageContent() {
|
||||
{schedule.isPackageOnly && (
|
||||
<span className="edr-badge edr-badge-warning">PKG</span>
|
||||
)}
|
||||
{schedule.isGroupBookingOnly && (
|
||||
<span className="edr-badge edr-badge-warning">GROUP</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -778,7 +799,7 @@ function SchedulesPageContent() {
|
||||
|
||||
<Modal
|
||||
isOpen={showAddModal}
|
||||
onClose={() => { setShowAddModal(false); setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' }); setAddCoachRows([]); setError(null); }}
|
||||
onClose={() => { setShowAddModal(false); setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '', isPackageOnly: false, isGroupBookingOnly: false }); setAddCoachRows([]); setError(null); }}
|
||||
title="Add Schedule"
|
||||
size="xl"
|
||||
>
|
||||
@@ -817,6 +838,37 @@ function SchedulesPageContent() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<label className="label mb-2">Visibility</label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
{([
|
||||
{ value: 'NORMAL', title: 'Normal', desc: 'Bookable through the passenger portal like any other schedule' },
|
||||
{ value: 'PACKAGE_ONLY', title: 'Package Only', desc: 'Hide from public search — reserved for package bookings' },
|
||||
{ value: 'GROUP_ONLY', title: 'Group Booking Only', desc: 'Hide from public search — reserved for staff group bookings' },
|
||||
] as const).map((opt) => {
|
||||
const selected = visibilityOf(addForm.isPackageOnly, addForm.isGroupBookingOnly) === opt.value;
|
||||
return (
|
||||
<label
|
||||
key={opt.value}
|
||||
className={`flex items-start gap-2 p-3 rounded-lg border cursor-pointer transition-colors ${selected ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/50'}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="add-visibility"
|
||||
className="w-4 h-4 mt-0.5"
|
||||
checked={selected}
|
||||
onChange={() => setAddForm({ ...addForm, ...visibilityFlags(opt.value) })}
|
||||
/>
|
||||
<span className="text-sm">
|
||||
<span className="font-medium block">{opt.title}</span>
|
||||
<span className="block text-xs text-muted-foreground">{opt.desc}</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="label mb-0">Coaches</label>
|
||||
@@ -875,7 +927,7 @@ function SchedulesPageContent() {
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<ActionButton type="button" variant="secondary" onClick={() => { setShowAddModal(false); setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' }); setAddCoachRows([]); setError(null); }}>Cancel</ActionButton>
|
||||
<ActionButton type="button" variant="secondary" onClick={() => { setShowAddModal(false); setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '', isPackageOnly: false, isGroupBookingOnly: false }); setAddCoachRows([]); setError(null); }}>Cancel</ActionButton>
|
||||
<ActionButton type="submit" loading={createScheduleMutation.isPending}>Create Schedule</ActionButton>
|
||||
</div>
|
||||
</form>
|
||||
@@ -1155,18 +1207,35 @@ function SchedulesPageContent() {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg border border-border">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="isPackageOnly"
|
||||
checked={editForm.isPackageOnly}
|
||||
onChange={(e) => setEditForm({ ...editForm, isPackageOnly: e.target.checked })}
|
||||
className="w-4 h-4 rounded"
|
||||
/>
|
||||
<label htmlFor="isPackageOnly" className="text-sm cursor-pointer">
|
||||
<span className="font-medium">Package Only</span>
|
||||
<span className="block text-xs text-muted-foreground">Hide from public search — reserved for package bookings</span>
|
||||
</label>
|
||||
<div>
|
||||
<label className="label mb-2">Visibility</label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
{([
|
||||
{ value: 'NORMAL', title: 'Normal', desc: 'Bookable through the passenger portal like any other schedule' },
|
||||
{ value: 'PACKAGE_ONLY', title: 'Package Only', desc: 'Hide from public search — reserved for package bookings' },
|
||||
{ value: 'GROUP_ONLY', title: 'Group Booking Only', desc: 'Hide from public search — reserved for staff group bookings' },
|
||||
] as const).map((opt) => {
|
||||
const selected = visibilityOf(editForm.isPackageOnly, editForm.isGroupBookingOnly) === opt.value;
|
||||
return (
|
||||
<label
|
||||
key={opt.value}
|
||||
className={`flex items-start gap-2 p-3 rounded-lg border cursor-pointer transition-colors ${selected ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/50'}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="edit-visibility"
|
||||
className="w-4 h-4 mt-0.5"
|
||||
checked={selected}
|
||||
onChange={() => setEditForm({ ...editForm, ...visibilityFlags(opt.value) })}
|
||||
/>
|
||||
<span className="text-sm">
|
||||
<span className="font-medium block">{opt.title}</span>
|
||||
<span className="block text-xs text-muted-foreground">{opt.desc}</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -2,6 +2,30 @@ import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
|
||||
|
||||
const GENERIC_ERROR_MESSAGE = 'Something went wrong. Please try again.';
|
||||
const NETWORK_ERROR_MESSAGE = 'Could not reach the server. Please check your connection and try again.';
|
||||
|
||||
/**
|
||||
* Extracts a user-facing message from a failed request. Prefers a real backend-provided message
|
||||
* (joining a NestJS validation array into one line); otherwise falls back to a friendly generic
|
||||
* message — never raw client/network text like "Request failed with status code 500" or
|
||||
* "Network Error", which is what axios puts in `error.message` when there's nothing better.
|
||||
* Every page in this app should use this (or rely on the response interceptor below, which
|
||||
* normalizes the same error in place) instead of reading `err.message` directly.
|
||||
*/
|
||||
export function getErrorMessage(error: unknown, fallback: string = GENERIC_ERROR_MESSAGE): string {
|
||||
const err = error as any;
|
||||
const raw = err?.response?.data?.message;
|
||||
if (Array.isArray(raw) && raw.length > 0) {
|
||||
const joined = raw.filter((m: unknown) => typeof m === 'string' && m.trim()).join('; ');
|
||||
if (joined) return joined;
|
||||
} else if (typeof raw === 'string' && raw.trim()) {
|
||||
return raw;
|
||||
}
|
||||
if (err?.isAxiosError && !err.response) return NETWORK_ERROR_MESSAGE;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
class ApiClient {
|
||||
private client: AxiosInstance;
|
||||
|
||||
@@ -30,6 +54,20 @@ class ApiClient {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize in place so every existing `err?.response?.data?.message || err?.message ||
|
||||
// '<fallback>'` call site across the app picks up a friendly message automatically,
|
||||
// instead of raw axios/network text or an unjoined NestJS validation array.
|
||||
try {
|
||||
const friendly = getErrorMessage(error);
|
||||
if (error.response?.data && typeof error.response.data === 'object') {
|
||||
error.response.data.message = friendly;
|
||||
}
|
||||
error.message = friendly;
|
||||
} catch {
|
||||
// Best-effort — never let normalization itself break the original rejection.
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -8,9 +8,15 @@ export interface SearchTripsRequest {
|
||||
date: string;
|
||||
adultCount: number;
|
||||
childCount?: number;
|
||||
journeyType: 'ONE_WAY';
|
||||
journeyType: 'ONE_WAY' | 'ROUND_TRIP';
|
||||
/** Required when journeyType is ROUND_TRIP. */
|
||||
returnDate?: string;
|
||||
/** Drives which fare tier (Local vs International) gets quoted — see fareTier in the page component. */
|
||||
nationality?: string;
|
||||
/** Always 'GROUP_BOOKING' for this app — search returns ONLY schedules marked
|
||||
* isGroupBookingOnly (an exclusive partition, not additive): normal passenger-facing
|
||||
* schedules never show up here, and group-only schedules never show up in the portal. */
|
||||
channel?: 'PORTAL' | 'GROUP_BOOKING';
|
||||
}
|
||||
|
||||
export interface ScheduleClassOption {
|
||||
@@ -50,6 +56,7 @@ export type SearchEmptyReasonCode =
|
||||
| 'NO_SCHEDULE_ON_DATE'
|
||||
| 'CANCELLED'
|
||||
| 'PACKAGE_ONLY'
|
||||
| 'GROUP_BOOKING_ONLY'
|
||||
| 'CHECKIN_CLOSED'
|
||||
| 'FULLY_BOOKED';
|
||||
|
||||
@@ -67,6 +74,11 @@ export interface SearchTripsResponse {
|
||||
outboundReason?: SearchEmptyReason;
|
||||
/** Nearby schedules for the same station pair on a different date, offered when `outbound` is empty. */
|
||||
alternativeOutbound?: ScheduleResult[];
|
||||
/** Return-leg schedules — present when the request's journeyType was ROUND_TRIP. */
|
||||
inbound?: ScheduleResult[];
|
||||
requestedReturnDate?: string;
|
||||
inboundReason?: SearchEmptyReason;
|
||||
alternativeInbound?: ScheduleResult[];
|
||||
}
|
||||
|
||||
// ── Seat classes (GET /seat-classes) ───────────────────────────────────────
|
||||
@@ -85,6 +97,8 @@ export interface AutoAssignHoldRequest {
|
||||
seatClassName: string;
|
||||
adultCount: number;
|
||||
childCount?: number;
|
||||
/** Round-trip leg tag — omit for a one-way booking. */
|
||||
journeyDirection?: 'OUTBOUND' | 'RETURN';
|
||||
}
|
||||
|
||||
export interface HeldPassengerSeat {
|
||||
@@ -110,7 +124,8 @@ export interface AutoAssignHoldResponse {
|
||||
// ── Group booking creation (POST /bookings/group) ──────────────────────────
|
||||
|
||||
export interface GroupBookingPassengerInput {
|
||||
seatId: string;
|
||||
/** Omit for a free child (ONE_WAY only) — matches guest-booking.dto.ts's own optional seatId. */
|
||||
seatId?: string;
|
||||
passengerName: string;
|
||||
dateOfBirth: string;
|
||||
idDocumentType: 'NATIONAL_ID' | 'PASSPORT' | 'DRIVING_LICENSE' | 'OTHER';
|
||||
@@ -120,6 +135,8 @@ export interface GroupBookingPassengerInput {
|
||||
nationality?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
/** Return-leg seat ID — required when the booking is ROUND_TRIP. */
|
||||
returnSeatId?: string;
|
||||
}
|
||||
|
||||
export interface CreateGroupBookingRequest {
|
||||
@@ -128,14 +145,23 @@ export interface CreateGroupBookingRequest {
|
||||
originStationId: string;
|
||||
destinationStationId: string;
|
||||
seatClassId: string;
|
||||
bookingType: 'ONE_WAY';
|
||||
bookingType: 'ONE_WAY' | 'ROUND_TRIP';
|
||||
passengers: GroupBookingPassengerInput[];
|
||||
/** ROUND_TRIP only. */
|
||||
returnScheduleId?: string;
|
||||
returnHoldId?: string;
|
||||
returnOriginStationId?: string;
|
||||
returnDestinationStationId?: string;
|
||||
/** Falls back to seatClassId on the backend if omitted. */
|
||||
returnSeatClassId?: string;
|
||||
}
|
||||
|
||||
export interface GroupBookingSeat {
|
||||
seatId: string;
|
||||
passengerName: string;
|
||||
passengerCategory: 'ADULT' | 'CHILD';
|
||||
/** 1 = outbound leg, 2 = return leg. Absent on a plain ONE_WAY booking. */
|
||||
leg?: number;
|
||||
seat: { seatNumber: string; bedPosition?: string | null; coach: { number: string } };
|
||||
}
|
||||
|
||||
@@ -209,10 +235,23 @@ export const groupBookingApi = {
|
||||
autoAssignHold: (dto: AutoAssignHoldRequest) =>
|
||||
apiClient.post<AutoAssignHoldResponse>('/seats/auto-assign-hold', dto),
|
||||
|
||||
/** Best-effort early release — e.g. freeing an outbound hold when the return leg's auto-assign fails. */
|
||||
releaseHold: (holdId: string) => apiClient.delete(`/seats/hold/${holdId}`),
|
||||
|
||||
createGroupBooking: (dto: CreateGroupBookingRequest) =>
|
||||
apiClient.post<CreateGroupBookingResponse>('/bookings/group', dto),
|
||||
|
||||
getPaymentMethods: () => apiClient.get<SupportedPaymentMethod[]>('/payments/methods'),
|
||||
// Guards against a non-array response the same way dashboardApi.getPaymentMethods /
|
||||
// paymentsApi.getMethods already do elsewhere in this app — never lets a bad/unexpected
|
||||
// response shape reach a caller expecting a plain array.
|
||||
getPaymentMethods: async (): Promise<SupportedPaymentMethod[]> => {
|
||||
try {
|
||||
const response = await apiClient.get<SupportedPaymentMethod[]>('/payments/methods');
|
||||
return Array.isArray(response) ? response : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
initiatePayment: (dto: InitiatePaymentRequest) =>
|
||||
apiClient.post<InitiatePaymentResponse>('/payments/initiate', dto),
|
||||
|
||||
@@ -475,6 +475,15 @@ export const packagesApi = {
|
||||
addTier: (packageId: string, data: any) => apiClient.post<any>(`/packages/${packageId}/tiers`, data),
|
||||
updateTier: (tierId: string, data: any) => apiClient.patch<any>(`/packages/tiers/${tierId}`, data),
|
||||
deleteTier: (tierId: string) => apiClient.delete(`/packages/tiers/${tierId}`),
|
||||
// `apiClient` pins Content-Type: application/json on every request — clearing it (rather than
|
||||
// setting multipart/form-data by hand, which omits the boundary) is what lets the browser
|
||||
// generate a proper boundary of its own. Same pattern as features/support/supportApi.ts.
|
||||
uploadImage: (id: string, file: File) => {
|
||||
const form = new FormData();
|
||||
form.append('image', file);
|
||||
return apiClient.post<any>(`/packages/${id}/image`, form, { headers: { 'Content-Type': undefined } });
|
||||
},
|
||||
removeImage: (id: string) => apiClient.delete<any>(`/packages/${id}/image`),
|
||||
};
|
||||
|
||||
// Package Inquiries API
|
||||
|
||||
@@ -2,6 +2,16 @@ import ExcelJS from 'exceljs';
|
||||
|
||||
const VALID_ID_TYPES = ['NATIONAL_ID', 'PASSPORT', 'DRIVING_LICENSE', 'OTHER'];
|
||||
|
||||
/** Exact mirror of guest-booking.service.ts's calculateAge — calendar-based, not a 365.25-day
|
||||
* approximation, so this file's CHILD/ADULT determination never disagrees with the backend's. */
|
||||
function calculateAge(dateOfBirth: Date): number {
|
||||
const today = new Date();
|
||||
let age = today.getFullYear() - dateOfBirth.getFullYear();
|
||||
const monthDiff = today.getMonth() - dateOfBirth.getMonth();
|
||||
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < dateOfBirth.getDate())) age--;
|
||||
return age;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors guest-booking.service.ts's per-passenger nationality inference exactly (NATIONAL_ID
|
||||
* always forces 'Ethiopian'; PASSPORT falls back to Djiboutian/Other by passport country), so a
|
||||
@@ -25,6 +35,8 @@ export interface ParsedPassengerRow {
|
||||
fullName: string;
|
||||
dateOfBirth: string; // normalized YYYY-MM-DD, empty if invalid/missing
|
||||
passengerType: 'Adult' | 'Child' | '';
|
||||
/** Backend-authoritative category from DOB alone (age < 5), regardless of the Type column. */
|
||||
isChildByAge: boolean;
|
||||
idDocumentType: string;
|
||||
idDocumentNumber: string;
|
||||
passportNumber: string;
|
||||
@@ -145,7 +157,7 @@ export async function parsePassengerExcel(file: File, quotedFareTier?: 'LOCAL' |
|
||||
if (!fullName) errors.push('Full Name is required');
|
||||
|
||||
let dateOfBirth = '';
|
||||
let ageYears: number | null = null;
|
||||
let isChildByAge = false;
|
||||
if (!dobRaw) {
|
||||
errors.push('Date of Birth is required');
|
||||
} else {
|
||||
@@ -156,7 +168,7 @@ export async function parsePassengerExcel(file: File, quotedFareTier?: 'LOCAL' |
|
||||
errors.push('Date of Birth cannot be in the future');
|
||||
} else {
|
||||
dateOfBirth = parsed.toISOString().split('T')[0];
|
||||
ageYears = (Date.now() - parsed.getTime()) / (365.25 * 24 * 60 * 60 * 1000);
|
||||
isChildByAge = calculateAge(parsed) < 5;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,8 +180,8 @@ export async function parsePassengerExcel(file: File, quotedFareTier?: 'LOCAL' |
|
||||
|
||||
// The backend computes ADULT/CHILD from date of birth alone (under 5 = Child), regardless
|
||||
// of this column — flag a mismatch so the uploader notices before it surprises them later.
|
||||
if (passengerType && ageYears !== null) {
|
||||
const impliedType = ageYears < 5 ? 'Child' : 'Adult';
|
||||
if (passengerType && dateOfBirth) {
|
||||
const impliedType = isChildByAge ? 'Child' : 'Adult';
|
||||
if (impliedType !== passengerType) {
|
||||
warnings.push(`Date of Birth implies ${impliedType}, but Passenger Type is set to ${passengerType} — seats/fare are priced by age, not this column`);
|
||||
}
|
||||
@@ -202,6 +214,7 @@ export async function parsePassengerExcel(file: File, quotedFareTier?: 'LOCAL' |
|
||||
fullName,
|
||||
dateOfBirth,
|
||||
passengerType,
|
||||
isChildByAge,
|
||||
idDocumentType: docTypeRaw,
|
||||
idDocumentNumber: docNumber,
|
||||
passportNumber,
|
||||
@@ -223,7 +236,23 @@ export async function parsePassengerExcel(file: File, quotedFareTier?: 'LOCAL' |
|
||||
|
||||
export function countByType(rows: ParsedPassengerRow[]): { adults: number; children: number } {
|
||||
return {
|
||||
adults: rows.filter((r) => r.passengerType === 'Adult').length,
|
||||
children: rows.filter((r) => r.passengerType === 'Child').length,
|
||||
adults: rows.filter((r) => !r.isChildByAge).length,
|
||||
children: rows.filter((r) => r.isChildByAge).length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors the passenger portal's isFirstChild rule exactly (fare-utils.ts): the first
|
||||
* `adultCount` children in passenger order travel free with no assigned seat; any child
|
||||
* beyond that gets a real seat and pays the child fare. Returns one boolean per row, true
|
||||
* where that row is a free, unseated child.
|
||||
*/
|
||||
export function resolveFreeChildIndexes(rows: ParsedPassengerRow[], adultCount: number): boolean[] {
|
||||
let childrenSeen = 0;
|
||||
return rows.map((row) => {
|
||||
if (!row.isChildByAge) return false;
|
||||
const free = childrenSeen < adultCount;
|
||||
childrenSeen++;
|
||||
return free;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,20 @@
|
||||
// Package images are served by edr-passenger-api's own origin (public/uploads/packages via
|
||||
// app.useStaticAssets — see apps/edr-passenger-api/src/main.ts), a different origin than this
|
||||
// app, so next/image needs it explicitly whitelisted or it 400s every package image at runtime.
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
|
||||
let apiImagePattern;
|
||||
try {
|
||||
const apiUrl = new URL(API_URL);
|
||||
apiImagePattern = {
|
||||
protocol: apiUrl.protocol.replace(':', ''),
|
||||
hostname: apiUrl.hostname,
|
||||
port: apiUrl.port || '',
|
||||
pathname: '/uploads/**',
|
||||
};
|
||||
} catch {
|
||||
apiImagePattern = { protocol: 'http', hostname: 'localhost', port: '4000', pathname: '/uploads/**' };
|
||||
}
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: 'standalone',
|
||||
@@ -5,6 +22,7 @@ const nextConfig = {
|
||||
transpilePackages: ['@edr/types', '@edr/ui-common'],
|
||||
images: {
|
||||
unoptimized: false,
|
||||
remotePatterns: [apiImagePattern],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ const COUNTRIES = [
|
||||
'Bolivia','Bosnia and Herzegovina','Botswana','Brazil','Brunei','Bulgaria','Burkina Faso','Burundi','Cabo Verde','Cambodia',
|
||||
'Cameroon','Canada','Central African Republic','Chad','Chile','China','Colombia','Comoros','Congo','Costa Rica',
|
||||
'Croatia','Cuba','Cyprus','Czech Republic','Denmark','Dominica','Dominican Republic','Ecuador','Egypt',
|
||||
'El Salvador','Equatorial Guinea','Eritrea','Estonia','Eswatini','Fiji','Finland','France','Gabon',
|
||||
'El Salvador','Equatorial Guinea','Eritrea','Estonia','Eswatini','Ethiopia','Fiji','Finland','France','Gabon',
|
||||
'Gambia','Georgia','Germany','Ghana','Greece','Grenada','Guatemala','Guinea','Guinea-Bissau','Guyana',
|
||||
'Haiti','Honduras','Hungary','Iceland','India','Indonesia','Iran','Iraq','Ireland','Israel',
|
||||
'Italy','Jamaica','Japan','Jordan','Kazakhstan','Kenya','Kiribati','Kuwait','Kyrgyzstan','Laos',
|
||||
|
||||
@@ -93,6 +93,12 @@ function emptyReasonCopy(
|
||||
message: `The only train between ${originStationName} and ${destinationStationName} on ${date} is bookable as part of a travel package, not as a standalone ticket. Please choose another date below.`,
|
||||
showAlternatives: true,
|
||||
};
|
||||
case Passenger.SearchEmptyReasonCode.GroupBookingOnly:
|
||||
return {
|
||||
title: "Reserved for a group booking",
|
||||
message: `The only train between ${originStationName} and ${destinationStationName} on ${date} is reserved for a group booking, not individual tickets. Please choose another date below.`,
|
||||
showAlternatives: true,
|
||||
};
|
||||
case Passenger.SearchEmptyReasonCode.NoScheduleOnDate:
|
||||
default:
|
||||
return {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { apiClient } from '@/lib/api-client';
|
||||
import { format } from 'date-fns';
|
||||
import { formatTime, getTimePeriod, toZonedDate } from '@/utils/format';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { ChevronLeft, Loader2 } from 'lucide-react';
|
||||
import { isChild, isFirstChild, formatFare } from '@/utils/fare-utils';
|
||||
|
||||
// Helper function to decode JWT token and extract passengerId
|
||||
@@ -54,6 +54,17 @@ export default function ReviewPage() {
|
||||
const [fareBreakdown, setFareBreakdown] = useState<any>(null);
|
||||
const [returnFareBreakdown, setReturnFareBreakdown] = useState<any>(null);
|
||||
const [computedTotal, setComputedTotal] = useState<number>(0);
|
||||
// createBookingMutation.isPending only covers the mutation's own network call, but
|
||||
// handleConfirm does real async work (seat-class lookup, passengerId resolution) before ever
|
||||
// calling it — during that window the button showed no loading state and stayed clickable,
|
||||
// letting a double-click race through and create two bookings for the same seat hold. This
|
||||
// covers the whole handleConfirm run, not just the mutation's slice of it.
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
// setIsSubmitting alone isn't enough to stop a fast double-click: React state updates aren't
|
||||
// synchronous, so a second click event dispatched before the first setIsSubmitting(true) has
|
||||
// actually re-rendered the button as disabled would still slip through. A ref updates
|
||||
// immediately, closing that gap regardless of render timing.
|
||||
const isSubmittingRef = useRef(false);
|
||||
|
||||
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
|
||||
|
||||
@@ -228,6 +239,9 @@ const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p =>
|
||||
});
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (isSubmittingRef.current || createBookingMutation.isPending) return;
|
||||
isSubmittingRef.current = true;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const { searchCriteria } = useBookingStore.getState();
|
||||
|
||||
@@ -287,6 +301,8 @@ const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p =>
|
||||
|
||||
if (!seatClassId) {
|
||||
alert('Unable to determine seat class. Please go back and re-select your seats.');
|
||||
isSubmittingRef.current = false;
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -489,8 +505,16 @@ const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p =>
|
||||
bookingData.reviewedTotalMinor = computedTotal;
|
||||
|
||||
await createBookingMutation.mutateAsync(bookingData);
|
||||
// Deliberately not resetting isSubmitting here: every path that reaches this point is
|
||||
// about to navigate away (router.push, either here or inside onSuccess's setTimeout above).
|
||||
// Clearing it now would flip the button back to its idle label for the gap between the
|
||||
// booking actually being created and the navigation landing — exactly the "did it get
|
||||
// stuck?" flash this state exists to prevent. It only needs resetting on a genuine failure,
|
||||
// handled in the catch block below, so the user can retry.
|
||||
} catch (error) {
|
||||
alert(error instanceof Error ? error.message : 'An unexpected error occurred. Please try again.');
|
||||
isSubmittingRef.current = false;
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -691,10 +715,16 @@ const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p =>
|
||||
)}
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={createBookingMutation.isPending}
|
||||
className="btn-primary w-full"
|
||||
disabled={isSubmitting || createBookingMutation.isPending}
|
||||
className="btn-primary w-full disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{createBookingMutation.isPending ? 'Creating booking...' : `Confirm ${isAuthenticated ? '' : 'and pay'}`}
|
||||
{isSubmitting || createBookingMutation.isPending ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> Creating booking...
|
||||
</span>
|
||||
) : (
|
||||
`Confirm ${isAuthenticated ? '' : 'and pay'}`
|
||||
)}
|
||||
</button>
|
||||
<button onClick={() => router.back()} className="btn-secondary w-full flex items-center justify-center gap-2">
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
@@ -1032,10 +1062,16 @@ const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p =>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={createBookingMutation.isPending}
|
||||
className="btn-primary flex-1 py-2.5"
|
||||
disabled={isSubmitting || createBookingMutation.isPending}
|
||||
className="btn-primary flex-1 py-2.5 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{createBookingMutation.isPending ? 'Creating...' : `Confirm ${isAuthenticated ? '' : '& pay'}`}
|
||||
{isSubmitting || createBookingMutation.isPending ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> Creating...
|
||||
</span>
|
||||
) : (
|
||||
`Confirm ${isAuthenticated ? '' : '& pay'}`
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -83,6 +83,7 @@ interface PackageDetail {
|
||||
code: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
imageUrl?: string | null;
|
||||
status: string;
|
||||
boardingTime: string;
|
||||
departureTime: string;
|
||||
@@ -1042,7 +1043,7 @@ export default function PackageDetailPage() {
|
||||
{/* Hero */}
|
||||
<div className="relative h-56 md:h-80 bg-gradient-to-br from-[rgb(14,80,54)] to-[rgb(20,140,90)] overflow-hidden">
|
||||
<Image
|
||||
src="/packages/package.jpeg"
|
||||
src={pkg.imageUrl || "/packages/package.jpeg"}
|
||||
alt={pkg.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
|
||||
@@ -42,6 +42,7 @@ interface HolidayPackage {
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
imageUrl?: string | null;
|
||||
status: string;
|
||||
departureTime: string;
|
||||
validFrom: string;
|
||||
@@ -165,7 +166,7 @@ function FeaturedCard({ pkg }: { pkg: HolidayPackage }) {
|
||||
{/* ── Left: Image ── */}
|
||||
<div className="relative md:w-[46%] h-64 md:h-auto flex-shrink-0 overflow-hidden">
|
||||
<Image
|
||||
src="/packages/package.jpeg"
|
||||
src={pkg.imageUrl || "/packages/package.jpeg"}
|
||||
alt={pkg.name}
|
||||
fill
|
||||
className="object-cover group-hover:scale-105 transition-transform duration-700 ease-out"
|
||||
@@ -330,11 +331,21 @@ function PackageCard({ pkg }: { pkg: HolidayPackage }) {
|
||||
return (
|
||||
<Link href={`/packages/${pkg.id}`} className="group block h-full">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-2xl overflow-hidden border border-gray-200 dark:border-gray-800 hover:border-primary/60 hover:shadow-xl transition-all duration-300 flex flex-col h-full">
|
||||
{/* Image / Gradient */}
|
||||
{/* Image / Gradient fallback */}
|
||||
<div className="relative h-44 overflow-hidden bg-gradient-to-br from-[rgb(14,80,54)] to-[rgb(20,140,90)] flex-shrink-0">
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-6xl opacity-20">🌍</span>
|
||||
</div>
|
||||
{pkg.imageUrl ? (
|
||||
<Image
|
||||
src={pkg.imageUrl}
|
||||
alt={pkg.name}
|
||||
fill
|
||||
className="object-cover group-hover:scale-105 transition-transform duration-700 ease-out"
|
||||
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-6xl opacity-20">🌍</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/50 via-transparent to-transparent" />
|
||||
|
||||
<div className="absolute top-3 left-3 flex items-center gap-2">
|
||||
|
||||
@@ -179,6 +179,13 @@ export interface IContractCargoScope {
|
||||
code?: string | null;
|
||||
cargoTypeName?: string | null;
|
||||
unitOfMeasure?: string | null;
|
||||
/**
|
||||
* Most tons of this cargo one wagon may carry, across the cargo's allowed
|
||||
* wagon types (rated capacity, capped by the type's loading limit). Lets
|
||||
* the booking forms reject a wagon count whose even share overloads a
|
||||
* wagon before the server does. Null when no wagon type is configured.
|
||||
*/
|
||||
maxTonsPerWagon?: number | null;
|
||||
} | null;
|
||||
cargoFreeText?: string | null;
|
||||
/**
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { BaseEntity } from "../common";
|
||||
export * from "./support-chat";
|
||||
export * from "./blocked-seat-revenue-loss";
|
||||
|
||||
|
||||
export enum TicketStatus {
|
||||
Reserved = "RESERVED",
|
||||
Confirmed = "CONFIRMED",
|
||||
@@ -38,9 +39,9 @@ export enum ScheduleStatus {
|
||||
/**
|
||||
* Why a /search leg (outbound or inbound) came back with zero bookable schedules.
|
||||
* Priority order applied by the API when classifying: NoRoute > NoScheduleOnDate >
|
||||
* Cancelled > PackageOnly > CheckinClosed > FullyBooked (see search.service.ts
|
||||
* classifyEmptySearch). The frontend uses this to show a specific empty-state
|
||||
* message instead of a generic "no trains available".
|
||||
* Cancelled > PackageOnly > GroupBookingOnly > CheckinClosed > FullyBooked (see
|
||||
* search.service.ts classifyEmptySearch). The frontend uses this to show a specific
|
||||
* empty-state message instead of a generic "no trains available".
|
||||
*/
|
||||
export enum SearchEmptyReasonCode {
|
||||
/** No route (in either direction) ever connects these two stations. */
|
||||
@@ -51,6 +52,8 @@ export enum SearchEmptyReasonCode {
|
||||
Cancelled = "CANCELLED",
|
||||
/** Every schedule for this pair on this date is package-only (excluded from ticket search). */
|
||||
PackageOnly = "PACKAGE_ONLY",
|
||||
/** Every schedule for this pair on this date is reserved for staff group bookings (excluded from normal ticket search). */
|
||||
GroupBookingOnly = "GROUP_BOOKING_ONLY",
|
||||
/** A bookable schedule exists, but its check-in cutoff has already passed for every option. */
|
||||
CheckinClosed = "CHECKIN_CLOSED",
|
||||
/** A bookable, still-open schedule exists but has no seats left for the requested party. */
|
||||
|
||||
285
pnpm-lock.yaml
generated
285
pnpm-lock.yaml
generated
@@ -604,7 +604,7 @@ importers:
|
||||
version: 5.101.0(react@19.2.6)
|
||||
'@tria-plc/iamui':
|
||||
specifier: file:../../../local-packages/tria-plc-iamui-0.1.1.tgz
|
||||
version: file:local-packages/tria-plc-iamui-0.1.1.tgz(a5d0bdee55164ae56064fb273b530e00)
|
||||
version: file:local-packages/tria-plc-iamui-0.1.1.tgz(0ce39b7e349029277dcd938d06eeb0f7)
|
||||
'@vis.gl/react-google-maps':
|
||||
specifier: ^1.8.3
|
||||
version: 1.8.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
@@ -874,6 +874,9 @@ importers:
|
||||
minio:
|
||||
specifier: 7.1.3
|
||||
version: 7.1.3
|
||||
multer:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
pg:
|
||||
specifier: ^8.21.0
|
||||
version: 8.21.0
|
||||
@@ -13081,11 +13084,11 @@ snapshots:
|
||||
'@babel/helpers': 7.29.7
|
||||
'@babel/parser': 7.29.7
|
||||
'@babel/template': 7.29.7
|
||||
'@babel/traverse': 7.29.7
|
||||
'@babel/traverse': 7.29.7(supports-color@5.5.0)
|
||||
'@babel/types': 7.29.7
|
||||
'@jridgewell/remapping': 2.3.5
|
||||
convert-source-map: 2.0.0
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
gensync: 1.0.0-beta.2
|
||||
json5: 2.2.3
|
||||
semver: 6.3.1
|
||||
@@ -13120,7 +13123,7 @@ snapshots:
|
||||
'@babel/helper-optimise-call-expression': 7.29.7
|
||||
'@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7)
|
||||
'@babel/helper-skip-transparent-expression-wrappers': 7.29.7
|
||||
'@babel/traverse': 7.29.7
|
||||
'@babel/traverse': 7.29.7(supports-color@5.5.0)
|
||||
semver: 6.3.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -13129,14 +13132,7 @@ snapshots:
|
||||
|
||||
'@babel/helper-member-expression-to-functions@7.29.7':
|
||||
dependencies:
|
||||
'@babel/traverse': 7.29.7
|
||||
'@babel/types': 7.29.7
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@babel/helper-module-imports@7.29.7':
|
||||
dependencies:
|
||||
'@babel/traverse': 7.29.7
|
||||
'@babel/traverse': 7.29.7(supports-color@5.5.0)
|
||||
'@babel/types': 7.29.7
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -13151,9 +13147,9 @@ snapshots:
|
||||
'@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/helper-module-imports': 7.29.7
|
||||
'@babel/helper-module-imports': 7.29.7(supports-color@5.5.0)
|
||||
'@babel/helper-validator-identifier': 7.29.7
|
||||
'@babel/traverse': 7.29.7
|
||||
'@babel/traverse': 7.29.7(supports-color@5.5.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -13168,13 +13164,13 @@ snapshots:
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/helper-member-expression-to-functions': 7.29.7
|
||||
'@babel/helper-optimise-call-expression': 7.29.7
|
||||
'@babel/traverse': 7.29.7
|
||||
'@babel/traverse': 7.29.7(supports-color@5.5.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@babel/helper-skip-transparent-expression-wrappers@7.29.7':
|
||||
dependencies:
|
||||
'@babel/traverse': 7.29.7
|
||||
'@babel/traverse': 7.29.7(supports-color@5.5.0)
|
||||
'@babel/types': 7.29.7
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -13327,18 +13323,6 @@ snapshots:
|
||||
'@babel/parser': 7.29.7
|
||||
'@babel/types': 7.29.7
|
||||
|
||||
'@babel/traverse@7.29.7':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.7
|
||||
'@babel/generator': 7.29.7
|
||||
'@babel/helper-globals': 7.29.7
|
||||
'@babel/parser': 7.29.7
|
||||
'@babel/template': 7.29.7
|
||||
'@babel/types': 7.29.7
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@babel/traverse@7.29.7(supports-color@5.5.0)':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.7
|
||||
@@ -13823,7 +13807,7 @@ snapshots:
|
||||
|
||||
'@emotion/babel-plugin@11.13.5':
|
||||
dependencies:
|
||||
'@babel/helper-module-imports': 7.29.7
|
||||
'@babel/helper-module-imports': 7.29.7(supports-color@5.5.0)
|
||||
'@babel/runtime': 7.29.7
|
||||
'@emotion/hash': 0.9.2
|
||||
'@emotion/memoize': 0.9.0
|
||||
@@ -13989,7 +13973,7 @@ snapshots:
|
||||
'@eslint/eslintrc@2.1.4':
|
||||
dependencies:
|
||||
ajv: 6.15.0
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
espree: 9.6.1
|
||||
globals: 13.24.0
|
||||
ignore: 5.3.2
|
||||
@@ -14149,7 +14133,7 @@ snapshots:
|
||||
'@humanwhocodes/config-array@0.13.0':
|
||||
dependencies:
|
||||
'@humanwhocodes/object-schema': 2.0.3
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
minimatch: 3.1.5
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -15748,7 +15732,7 @@ snapshots:
|
||||
|
||||
'@puppeteer/browsers@2.13.2':
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
extract-zip: 2.0.1
|
||||
progress: 2.0.3
|
||||
proxy-agent: 6.5.0
|
||||
@@ -17822,7 +17806,7 @@ snapshots:
|
||||
|
||||
'@tokenizer/inflate@0.4.1':
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
token-types: 6.1.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -18109,130 +18093,6 @@ snapshots:
|
||||
- utf-8-validate
|
||||
- vite
|
||||
|
||||
'@tria-plc/iamui@file:local-packages/tria-plc-iamui-0.1.1.tgz(a5d0bdee55164ae56064fb273b530e00)':
|
||||
dependencies:
|
||||
'@emotion/react': 11.14.0(@types/react@18.3.31)(react@19.2.6)
|
||||
'@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react@19.2.6)
|
||||
'@hookform/resolvers': 5.4.0(react-hook-form@7.77.0(react@19.2.6))
|
||||
'@lottiefiles/react-lottie-player': 3.6.0(react@19.2.6)
|
||||
'@mantine/charts': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(recharts@3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1))
|
||||
'@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@mantine/dates': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@mantine/hooks': 7.17.8(react@19.2.6)
|
||||
'@mantine/notifications': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-accordion': 1.2.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-alert-dialog': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-avatar': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-checkbox': 1.3.4(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-collapsible': 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-context-menu': 2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-dialog': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-dropdown-menu': 2.1.17(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-hover-card': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-label': 2.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-navigation-menu': 1.2.15(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-popover': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-progress': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-radio-group': 1.4.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-scroll-area': 1.2.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-select': 2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-separator': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-slot': 1.2.5(@types/react@18.3.31)(react@19.2.6)
|
||||
'@radix-ui/react-switch': 1.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-tabs': 1.1.14(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-toast': 1.2.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-tooltip': 1.2.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@react-pdf-viewer/core': 3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@react-pdf-viewer/default-layout': 3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@react-pdf/renderer': 4.5.1(react@19.2.6)
|
||||
'@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@18.3.31)(react@19.2.6)(redux@5.0.1))(react@19.2.6)
|
||||
'@tabler/icons-react': 3.44.0(react@19.2.6)
|
||||
'@tailwindcss/vite': 4.3.0(vite@5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0))
|
||||
'@tanstack/react-query': 5.101.0(react@19.2.6)
|
||||
'@tanstack/react-query-devtools': 5.101.0(@tanstack/react-query@5.101.0(react@19.2.6))(react@19.2.6)
|
||||
'@tanstack/react-table': 8.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@tinymce/tinymce-react': 6.3.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tinymce@7.9.3)
|
||||
'@types/dompurify': 3.2.0
|
||||
'@types/node': 24.13.1
|
||||
'@types/tinymce': 4.6.9
|
||||
axios: 1.17.0
|
||||
class-variance-authority: 0.7.1
|
||||
clsx: 2.1.1
|
||||
cmdk: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
date-fns: 3.6.0
|
||||
dayjs: 1.11.21
|
||||
dompurify: 3.4.8
|
||||
ethiopian-calendar-date-converter: 2.1.6
|
||||
ethiopian-calendar-new: 1.1.0
|
||||
file-type: 18.7.0
|
||||
framer-motion: 12.40.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
html2canvas: 1.4.1
|
||||
i18next: 25.10.10(typescript@5.9.3)
|
||||
i18next-browser-languagedetector: 8.2.1
|
||||
jquery: 3.7.1
|
||||
js-cookie: 3.0.8
|
||||
jspdf: 3.0.4
|
||||
lodash: 4.18.1
|
||||
lucide-react: 0.513.0(react@19.2.6)
|
||||
mantine-react-table: 2.0.0-beta.9(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/dates@7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(@tabler/icons-react@3.44.0(react@19.2.6))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
mui-ethiopian-datepicker: 0.3.2(4b3af212eafdf0059f009b005d7e343d)
|
||||
next-themes: 0.4.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
path: 0.12.7
|
||||
pdf-lib: 1.17.1
|
||||
qs: 6.15.2
|
||||
react: 19.2.6
|
||||
react-cookie: 8.1.2(@types/react@18.3.31)(react@19.2.6)
|
||||
react-css-nocode-editor: 1.0.13(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)
|
||||
react-day-picker: 8.10.2(date-fns@3.6.0)(react@19.2.6)
|
||||
react-dom: 19.2.6(react@19.2.6)
|
||||
react-dropzone: 14.4.1(react@19.2.6)
|
||||
react-hook-form: 7.77.0(react@19.2.6)
|
||||
react-i18next: 15.7.4(i18next@25.10.10(typescript@5.9.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
|
||||
react-icons: 5.6.0(react@19.2.6)
|
||||
react-image-crop: 11.0.10(react@19.2.6)
|
||||
react-intersection-observer: 9.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
react-pdf: 10.4.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
react-pdf-html: 2.1.5(@react-pdf/renderer@4.5.1(react@19.2.6))(react@19.2.6)
|
||||
react-redux: 9.3.0(@types/react@18.3.31)(react@19.2.6)(redux@5.0.1)
|
||||
react-resizable-panels: 3.0.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
react-router-dom: 7.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
react-signature-canvas: 1.1.0-alpha.2(@types/prop-types@15.7.15)(@types/react@18.3.31)(prop-types@15.8.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
recharts: 3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1)
|
||||
rollup-plugin-visualizer: 7.0.1(rollup@4.61.1)
|
||||
socket.io-client: 4.8.3
|
||||
sonner: 2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
tailwind-merge: 3.6.0
|
||||
tailwind-scrollbar-hide: 4.0.0(tailwindcss@4.3.0)
|
||||
tailwindcss: 4.3.0
|
||||
tailwindcss-animate: 1.0.7(tailwindcss@4.3.0)
|
||||
tinymce: 7.9.3
|
||||
url: 0.11.4
|
||||
vaul: 1.1.2(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
xlsx: 0.18.5
|
||||
zod: 3.25.76
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
- '@emotion/is-prop-valid'
|
||||
- '@mui/icons-material'
|
||||
- '@mui/material'
|
||||
- '@mui/x-date-pickers'
|
||||
- '@types/prop-types'
|
||||
- '@types/react'
|
||||
- '@types/react-dom'
|
||||
- bufferutil
|
||||
- debug
|
||||
- pdfjs-dist
|
||||
- prop-types
|
||||
- react-is
|
||||
- react-native
|
||||
- redux
|
||||
- rolldown
|
||||
- rollup
|
||||
- supports-color
|
||||
- typescript
|
||||
- utf-8-validate
|
||||
- vite
|
||||
|
||||
'@ts-morph/common@0.27.0':
|
||||
dependencies:
|
||||
fast-glob: 3.3.3
|
||||
@@ -18630,7 +18490,7 @@ snapshots:
|
||||
'@typescript-eslint/types': 8.60.1
|
||||
'@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3)
|
||||
'@typescript-eslint/visitor-keys': 8.60.1
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
eslint: 8.57.1
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
@@ -18640,7 +18500,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3)
|
||||
'@typescript-eslint/types': 8.60.1
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -18659,7 +18519,7 @@ snapshots:
|
||||
'@typescript-eslint/types': 8.60.1
|
||||
'@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.60.1(eslint@8.57.1)(typescript@5.9.3)
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
eslint: 8.57.1
|
||||
ts-api-utils: 2.5.0(typescript@5.9.3)
|
||||
typescript: 5.9.3
|
||||
@@ -18674,7 +18534,7 @@ snapshots:
|
||||
'@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3)
|
||||
'@typescript-eslint/types': 8.60.1
|
||||
'@typescript-eslint/visitor-keys': 8.60.1
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
minimatch: 10.2.5
|
||||
semver: 7.8.2
|
||||
tinyglobby: 0.2.17
|
||||
@@ -18963,7 +18823,7 @@ snapshots:
|
||||
|
||||
agent-base@6.0.2:
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -19472,16 +19332,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
babel-plugin-styled-components@2.3.0(styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6))(supports-color@5.5.0):
|
||||
dependencies:
|
||||
'@babel/helper-annotate-as-pure': 7.29.7
|
||||
'@babel/helper-module-imports': 7.29.7(supports-color@5.5.0)
|
||||
'@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7)
|
||||
picomatch: 4.0.4
|
||||
styled-components: 5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
babel-polyfill@6.26.0:
|
||||
dependencies:
|
||||
babel-runtime: 6.26.0
|
||||
@@ -19637,7 +19487,7 @@ snapshots:
|
||||
dependencies:
|
||||
bytes: 3.1.2
|
||||
content-type: 1.0.5
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
http-errors: 2.0.1
|
||||
iconv-lite: 0.7.2
|
||||
on-finished: 2.4.1
|
||||
@@ -20686,7 +20536,7 @@ snapshots:
|
||||
engine.io-client@6.6.5:
|
||||
dependencies:
|
||||
'@socket.io/component-emitter': 3.1.2
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
engine.io-parser: 5.2.3
|
||||
ws: 8.20.1
|
||||
xmlhttprequest-ssl: 2.1.2
|
||||
@@ -20706,7 +20556,7 @@ snapshots:
|
||||
base64id: 2.0.0
|
||||
cookie: 0.7.2
|
||||
cors: 2.8.6
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
engine.io-parser: 5.2.3
|
||||
ws: 8.21.0
|
||||
transitivePeerDependencies:
|
||||
@@ -20937,7 +20787,7 @@ snapshots:
|
||||
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1):
|
||||
dependencies:
|
||||
'@nolyfill/is-core-module': 1.0.39
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
eslint: 8.57.1
|
||||
get-tsconfig: 4.14.0
|
||||
is-bun-module: 2.0.0
|
||||
@@ -21065,7 +20915,7 @@ snapshots:
|
||||
ajv: 6.15.0
|
||||
chalk: 4.1.2
|
||||
cross-spawn: 7.0.6
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
doctrine: 3.0.0
|
||||
escape-string-regexp: 4.0.0
|
||||
eslint-scope: 7.2.2
|
||||
@@ -21302,7 +21152,7 @@ snapshots:
|
||||
content-type: 1.0.5
|
||||
cookie: 0.7.2
|
||||
cookie-signature: 1.2.2
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
depd: 2.0.0
|
||||
encodeurl: 2.0.0
|
||||
escape-html: 1.0.3
|
||||
@@ -21355,7 +21205,7 @@ snapshots:
|
||||
|
||||
extract-zip@2.0.1:
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
get-stream: 5.2.0
|
||||
yauzl: 2.10.0
|
||||
optionalDependencies:
|
||||
@@ -21510,7 +21360,7 @@ snapshots:
|
||||
|
||||
finalhandler@2.1.1:
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
encodeurl: 2.0.0
|
||||
escape-html: 1.0.3
|
||||
on-finished: 2.4.1
|
||||
@@ -21758,7 +21608,7 @@ snapshots:
|
||||
dependencies:
|
||||
basic-ftp: 5.3.1
|
||||
data-uri-to-buffer: 6.0.2
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -22067,7 +21917,7 @@ snapshots:
|
||||
http-proxy-agent@7.0.2:
|
||||
dependencies:
|
||||
agent-base: 7.1.4
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -22080,14 +21930,14 @@ snapshots:
|
||||
https-proxy-agent@5.0.1:
|
||||
dependencies:
|
||||
agent-base: 6.0.2
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
https-proxy-agent@7.0.6:
|
||||
dependencies:
|
||||
agent-base: 7.1.4
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -22527,7 +22377,7 @@ snapshots:
|
||||
|
||||
istanbul-lib-source-maps@4.0.1:
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
istanbul-lib-coverage: 3.2.2
|
||||
source-map: 0.6.1
|
||||
transitivePeerDependencies:
|
||||
@@ -23187,7 +23037,7 @@ snapshots:
|
||||
dependencies:
|
||||
chalk: 5.6.2
|
||||
commander: 13.1.0
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
execa: 8.0.1
|
||||
lilconfig: 3.1.3
|
||||
listr2: 8.3.3
|
||||
@@ -23874,7 +23724,7 @@ snapshots:
|
||||
micromark@4.0.2:
|
||||
dependencies:
|
||||
'@types/debug': 4.1.13
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
decode-named-character-reference: 1.3.0
|
||||
devlop: 1.1.0
|
||||
micromark-core-commonmark: 2.0.3
|
||||
@@ -24400,7 +24250,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@tootallnate/quickjs-emscripten': 0.23.0
|
||||
agent-base: 7.1.4
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
get-uri: 6.0.5
|
||||
http-proxy-agent: 7.0.2
|
||||
https-proxy-agent: 7.0.6
|
||||
@@ -24750,7 +24600,7 @@ snapshots:
|
||||
proxy-agent@6.5.0:
|
||||
dependencies:
|
||||
agent-base: 7.1.4
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
http-proxy-agent: 7.0.2
|
||||
https-proxy-agent: 7.0.6
|
||||
lru-cache: 7.18.3
|
||||
@@ -24779,7 +24629,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@puppeteer/browsers': 2.13.2
|
||||
chromium-bidi: 14.0.0(devtools-protocol@0.0.1608973)
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
devtools-protocol: 0.0.1608973
|
||||
typed-query-selector: 2.12.2
|
||||
webdriver-bidi-protocol: 0.4.1
|
||||
@@ -25032,15 +24882,6 @@ snapshots:
|
||||
- '@babel/core'
|
||||
- react-is
|
||||
|
||||
react-css-nocode-editor@1.0.13(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6):
|
||||
dependencies:
|
||||
react: 19.2.6
|
||||
react-dom: 19.2.6(react@19.2.6)
|
||||
styled-components: 5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
- react-is
|
||||
|
||||
react-day-picker@8.10.2(date-fns@3.6.0)(react@19.2.6):
|
||||
dependencies:
|
||||
date-fns: 3.6.0
|
||||
@@ -25651,7 +25492,7 @@ snapshots:
|
||||
|
||||
router@2.2.0:
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
depd: 2.0.0
|
||||
is-promise: 4.0.0
|
||||
parseurl: 1.3.3
|
||||
@@ -25773,7 +25614,7 @@ snapshots:
|
||||
|
||||
send@1.2.1:
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
encodeurl: 2.0.0
|
||||
escape-html: 1.0.3
|
||||
etag: 1.8.1
|
||||
@@ -25989,7 +25830,7 @@ snapshots:
|
||||
|
||||
socket.io-adapter@2.5.8:
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
ws: 8.21.0
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
@@ -25999,7 +25840,7 @@ snapshots:
|
||||
socket.io-client@4.8.3:
|
||||
dependencies:
|
||||
'@socket.io/component-emitter': 3.1.2
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
engine.io-client: 6.6.5
|
||||
socket.io-parser: 4.2.6
|
||||
transitivePeerDependencies:
|
||||
@@ -26010,7 +25851,7 @@ snapshots:
|
||||
socket.io-parser@4.2.6:
|
||||
dependencies:
|
||||
'@socket.io/component-emitter': 3.1.2
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -26019,7 +25860,7 @@ snapshots:
|
||||
accepts: 1.3.8
|
||||
base64id: 2.0.0
|
||||
cors: 2.8.6
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
engine.io: 6.6.9
|
||||
socket.io-adapter: 2.5.8
|
||||
socket.io-parser: 4.2.6
|
||||
@@ -26031,7 +25872,7 @@ snapshots:
|
||||
socks-proxy-agent@8.0.5:
|
||||
dependencies:
|
||||
agent-base: 7.1.4
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
socks: 2.8.9
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -26326,24 +26167,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
|
||||
styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6):
|
||||
dependencies:
|
||||
'@babel/helper-module-imports': 7.29.7(supports-color@5.5.0)
|
||||
'@babel/traverse': 7.29.7(supports-color@5.5.0)
|
||||
'@emotion/is-prop-valid': 1.4.0
|
||||
'@emotion/stylis': 0.8.5
|
||||
'@emotion/unitless': 0.7.5
|
||||
babel-plugin-styled-components: 2.3.0(styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6))(supports-color@5.5.0)
|
||||
css-to-react-native: 3.2.0
|
||||
hoist-non-react-statics: 3.3.2
|
||||
react: 19.2.6
|
||||
react-dom: 19.2.6(react@19.2.6)
|
||||
react-is: 19.2.7
|
||||
shallowequal: 1.1.0
|
||||
supports-color: 5.5.0
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
|
||||
styled-jsx@5.1.1(babel-plugin-macros@3.1.0)(react@18.3.1):
|
||||
dependencies:
|
||||
client-only: 0.0.1
|
||||
@@ -26369,7 +26192,7 @@ snapshots:
|
||||
dependencies:
|
||||
component-emitter: 1.3.1
|
||||
cookiejar: 2.1.4
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
fast-safe-stringify: 2.1.1
|
||||
form-data: 4.0.5
|
||||
formidable: 3.5.4
|
||||
@@ -26882,7 +26705,7 @@ snapshots:
|
||||
app-root-path: 3.1.0
|
||||
buffer: 6.0.3
|
||||
dayjs: 1.11.21
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
dedent: 1.7.2(babel-plugin-macros@3.1.0)
|
||||
dotenv: 16.6.1
|
||||
glob: 10.5.0
|
||||
@@ -26906,7 +26729,7 @@ snapshots:
|
||||
app-root-path: 3.1.0
|
||||
buffer: 6.0.3
|
||||
dayjs: 1.11.21
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
dedent: 1.7.2(babel-plugin-macros@3.1.0)
|
||||
dotenv: 16.6.1
|
||||
glob: 10.5.0
|
||||
@@ -27267,7 +27090,7 @@ snapshots:
|
||||
vite-node@2.1.9(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0):
|
||||
dependencies:
|
||||
cac: 6.7.14
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
es-module-lexer: 1.7.0
|
||||
pathe: 1.1.2
|
||||
vite: 5.4.21(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0)
|
||||
@@ -27285,7 +27108,7 @@ snapshots:
|
||||
vite-node@2.1.9(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0):
|
||||
dependencies:
|
||||
cac: 6.7.14
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
es-module-lexer: 1.7.0
|
||||
pathe: 1.1.2
|
||||
vite: 5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0)
|
||||
@@ -27332,7 +27155,7 @@ snapshots:
|
||||
'@vitest/spy': 2.1.9
|
||||
'@vitest/utils': 2.1.9
|
||||
chai: 5.3.3
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
expect-type: 1.3.0
|
||||
magic-string: 0.30.21
|
||||
pathe: 1.1.2
|
||||
@@ -27368,7 +27191,7 @@ snapshots:
|
||||
'@vitest/spy': 2.1.9
|
||||
'@vitest/utils': 2.1.9
|
||||
chai: 5.3.3
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
expect-type: 1.3.0
|
||||
magic-string: 0.30.21
|
||||
pathe: 1.1.2
|
||||
|
||||
Reference in New Issue
Block a user