mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 02:58:11 +00:00
Merge branch 'dev' of https://github.com/Tria-plc/edr-platform into alpha
This commit is contained in:
@@ -0,0 +1,64 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EDR last-mile is multi-truck: a booking can be served by as many trucks as it
|
||||||
|
* has containers (bulk hauls until the tonnage is drawn down). Arrival/delivery
|
||||||
|
* were stamped once per `last_mile` record, so every truck shared one timestamp.
|
||||||
|
* These per-vehicle columns give each EDR truck its own arrival, leaving and
|
||||||
|
* weighed load — the same granularity self-haul trucks already have.
|
||||||
|
*
|
||||||
|
* Weights are TONNES (matching bookings.cargo_total_weight_vgm and the exit
|
||||||
|
* weighing UI). Named `*_tons` deliberately: the older
|
||||||
|
* customer_truck_assignments.gross_weight_kg is named kg but stores tonnes.
|
||||||
|
* All nullable — legacy rows predate per-truck tracking.
|
||||||
|
*/
|
||||||
|
export class AddLastMileTruckArrivalDeparture2260000000000 implements MigrationInterface {
|
||||||
|
name = 'AddLastMileTruckArrivalDeparture2260000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.last_mile_vehicle_assignments
|
||||||
|
ADD COLUMN IF NOT EXISTS arrived_at timestamptz NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS departed_at timestamptz NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS gross_weight_tons numeric(14, 3) NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS net_weight_tons numeric(14, 3) NULL
|
||||||
|
`);
|
||||||
|
|
||||||
|
// A truck carries 1x40ft OR 2x20ft, so an EDR truck needs MORE than the one
|
||||||
|
// container the legacy scalar `container_number` can hold. Mirrors the
|
||||||
|
// self-haul customer_truck_containers child table. The scalar stays in place
|
||||||
|
// (synced to the first container) for backward compatibility.
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.last_mile_vehicle_containers (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
assignment_id uuid NOT NULL REFERENCES freight.last_mile_vehicle_assignments(id) ON DELETE CASCADE,
|
||||||
|
last_mile_id uuid NOT NULL,
|
||||||
|
container_number varchar(32) NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS "IDX_last_mile_vehicle_containers_assignment"
|
||||||
|
ON freight.last_mile_vehicle_containers (assignment_id)
|
||||||
|
`);
|
||||||
|
// A container rides exactly one truck per delivery.
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_last_mile_vehicle_container"
|
||||||
|
ON freight.last_mile_vehicle_containers (last_mile_id, container_number)
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.last_mile_vehicle_containers`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.last_mile_vehicle_assignments
|
||||||
|
DROP COLUMN IF EXISTS arrived_at,
|
||||||
|
DROP COLUMN IF EXISTS departed_at,
|
||||||
|
DROP COLUMN IF EXISTS gross_weight_tons,
|
||||||
|
DROP COLUMN IF EXISTS net_weight_tons
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-seed the EDR wagon fleet onto the official ER numbering.
|
||||||
|
*
|
||||||
|
* Supersedes SeedWagonsWithYardAssignment1784000000001, which seeded 500 wagons
|
||||||
|
* on a `<CODE>-NNNN` scheme and wrote the status as 'Available' — mixed case
|
||||||
|
* that never matches WagonStatus.Available ('AVAILABLE'), so status filters
|
||||||
|
* silently returned nothing. This seed uses the enum value.
|
||||||
|
*
|
||||||
|
* Every wagon lands unassigned: current_yard_id NULL, status AVAILABLE. Wagon
|
||||||
|
* specs (capacity/length/tare) stay owned by wagon_types and are not touched —
|
||||||
|
* the types already exist and only the wagon↔type link is (re)established here.
|
||||||
|
*/
|
||||||
|
type FleetRow = {
|
||||||
|
code: string;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Official fleet: 1100 wagons, ER0001–ER1100, contiguous across 10 types. */
|
||||||
|
const FLEET: FleetRow[] = [
|
||||||
|
{ code: 'PW2', start: 1, end: 220, count: 220 },
|
||||||
|
{ code: 'CW4', start: 221, end: 330, count: 110 },
|
||||||
|
{ code: 'CW3', start: 331, end: 350, count: 20 },
|
||||||
|
{ code: 'KW2', start: 351, end: 370, count: 20 },
|
||||||
|
{ code: 'KW3', start: 371, end: 390, count: 20 },
|
||||||
|
{ code: 'NW5', start: 391, end: 940, count: 550 },
|
||||||
|
{ code: 'BW1', start: 941, end: 950, count: 10 },
|
||||||
|
{ code: 'GW2', start: 951, end: 1060, count: 110 },
|
||||||
|
{ code: 'NW6', start: 1061, end: 1080, count: 20 },
|
||||||
|
{ code: 'NW7', start: 1081, end: 1100, count: 20 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const wagonNumber = (sequence: number) => `ER${String(sequence).padStart(4, '0')}`;
|
||||||
|
|
||||||
|
export class SeedEdrWagonFleetErNumbering2260000000000 implements MigrationInterface {
|
||||||
|
name = 'SeedEdrWagonFleetErNumbering2260000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// Full replacement: the ER range is the fleet of record, so any wagon
|
||||||
|
// outside it is stale seed data. Safe to hard-delete — containers and
|
||||||
|
// train_set_wagons null their link, wagon_movements cascade.
|
||||||
|
await queryRunner.query(`DELETE FROM freight.wagons;`);
|
||||||
|
|
||||||
|
// Wagon.wagonNumber declares `unique: true`, but some environments never got
|
||||||
|
// the constraint. Repair it here — the table is empty at this point, so the
|
||||||
|
// index build cannot fail on pre-existing duplicates.
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS wagons_wagon_number_key
|
||||||
|
ON freight.wagons (wagon_number);
|
||||||
|
`);
|
||||||
|
|
||||||
|
for (const row of FLEET) {
|
||||||
|
if (row.end - row.start + 1 !== row.count) {
|
||||||
|
throw new Error(`wagon_range_mismatch:${row.code}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [typeRecord] = await queryRunner.query(
|
||||||
|
`SELECT id FROM freight.wagon_types WHERE code = $1 AND deleted_at IS NULL LIMIT 1;`,
|
||||||
|
[row.code],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!typeRecord?.id) {
|
||||||
|
throw new Error(`wagon_type_missing:${row.code}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// generate_series builds the range server-side — one round trip per type
|
||||||
|
// instead of 1100 individual INSERTs. No ON CONFLICT clause: every wagon
|
||||||
|
// was deleted above, so a plain INSERT cannot collide, and the clause would
|
||||||
|
// otherwise hard-require a unique index this table lacks on some envs.
|
||||||
|
await queryRunner.query(
|
||||||
|
`
|
||||||
|
INSERT INTO freight.wagons (
|
||||||
|
wagon_number,
|
||||||
|
wagon_type_id,
|
||||||
|
status,
|
||||||
|
current_yard_id,
|
||||||
|
train_id,
|
||||||
|
sequence_number,
|
||||||
|
notes,
|
||||||
|
train_set_wagon_id,
|
||||||
|
current_train_schedule_id
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'ER' || LPAD(seq::text, 4, '0'),
|
||||||
|
$1::uuid,
|
||||||
|
'AVAILABLE',
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
NULL
|
||||||
|
FROM generate_series($2::int, $3::int) AS seq;
|
||||||
|
`,
|
||||||
|
[typeRecord.id, row.start, row.end],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`DELETE FROM freight.wagons WHERE wagon_number BETWEEN $1 AND $2;`,
|
||||||
|
[wagonNumber(FLEET[0].start), wagonNumber(FLEET[FLEET.length - 1].end)],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -376,4 +376,97 @@ describe("BillingService.expirePayable — locked write runs in a transaction",
|
|||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
expect(transaction).not.toHaveBeenCalled();
|
expect(transaction).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("also retires a DRAFT invoice — a superseded/cancelled source must not leave one behind", async () => {
|
||||||
|
const { service, defaultManager } = build({
|
||||||
|
...openInvoice,
|
||||||
|
status: Freight.InvoiceStatus.Draft,
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.expirePayable(
|
||||||
|
Freight.InvoiceSource.Booking,
|
||||||
|
"booking-1",
|
||||||
|
"prepaid",
|
||||||
|
);
|
||||||
|
|
||||||
|
const { where } = defaultManager.findOne.mock.calls[0][1];
|
||||||
|
expect(where.status.value).toContain(Freight.InvoiceStatus.Draft);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("BillingService.issuePayable", () => {
|
||||||
|
const dueAt = new Date("2026-01-02T00:00:00.000Z");
|
||||||
|
|
||||||
|
const build = (found: Record<string, unknown> | null) => {
|
||||||
|
const manager = {
|
||||||
|
findOne: jest.fn().mockResolvedValue(found),
|
||||||
|
update: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
const service = new BillingService(
|
||||||
|
{ manager, transaction: jest.fn() } as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
makeEvents() as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
return { service, manager };
|
||||||
|
};
|
||||||
|
|
||||||
|
const issue = (service: BillingService) =>
|
||||||
|
service.issuePayable(
|
||||||
|
Freight.InvoiceSource.Booking,
|
||||||
|
"booking-1",
|
||||||
|
dueAt,
|
||||||
|
"PREPAID",
|
||||||
|
);
|
||||||
|
|
||||||
|
it("issues a DRAFT invoice to PENDING, stamping issuedAt and the pay-window dueAt", async () => {
|
||||||
|
const { service, manager } = build({
|
||||||
|
id: "inv-1",
|
||||||
|
invoiceNumber: "INV-20260101-00001",
|
||||||
|
status: Freight.InvoiceStatus.Draft,
|
||||||
|
issuedAt: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await issue(service);
|
||||||
|
|
||||||
|
const patch = manager.update.mock.calls[0][2];
|
||||||
|
expect(patch.status).toBe(Freight.InvoiceStatus.Pending);
|
||||||
|
expect(patch.dueAt).toBe(dueAt);
|
||||||
|
expect(patch.issuedAt).toBeInstanceOf(Date);
|
||||||
|
expect(result?.status).toBe(Freight.InvoiceStatus.Pending);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("looks up DRAFT invoices — a booking's invoice is minted DRAFT and this is what makes it payable", async () => {
|
||||||
|
const { service, manager } = build(null);
|
||||||
|
|
||||||
|
await issue(service);
|
||||||
|
|
||||||
|
const { where } = manager.findOne.mock.calls[0][1];
|
||||||
|
expect(where.status.value).toContain(Freight.InvoiceStatus.Draft);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only refreshes dueAt on an already-issued invoice, so a re-reserve never re-issues", async () => {
|
||||||
|
const issuedAt = new Date("2026-01-01T00:00:00.000Z");
|
||||||
|
const { service, manager } = build({
|
||||||
|
id: "inv-1",
|
||||||
|
invoiceNumber: "INV-20260101-00001",
|
||||||
|
status: Freight.InvoiceStatus.Pending,
|
||||||
|
issuedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await issue(service);
|
||||||
|
|
||||||
|
expect(manager.update.mock.calls[0][2]).toEqual({ dueAt });
|
||||||
|
expect(result?.issuedAt).toBe(issuedAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op (returns null, writes nothing) when the source has no draft-or-open invoice", async () => {
|
||||||
|
const { service, manager } = build(null);
|
||||||
|
|
||||||
|
await expect(issue(service)).resolves.toBeNull();
|
||||||
|
expect(manager.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ export class BillingService {
|
|||||||
private readonly payment: PaymentService,
|
private readonly payment: PaymentService,
|
||||||
private readonly companies: CompaniesService,
|
private readonly companies: CompaniesService,
|
||||||
private readonly invoiceDocuments: InvoiceDocumentService,
|
private readonly invoiceDocuments: InvoiceDocumentService,
|
||||||
) {}
|
) { }
|
||||||
|
|
||||||
// ── Reads ──────────────────────────────────────────────────────────────────
|
// ── Reads ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -432,7 +432,7 @@ export class BillingService {
|
|||||||
input.dueAt ??
|
input.dueAt ??
|
||||||
new Date(
|
new Date(
|
||||||
Date.now() +
|
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);
|
const invoiceNumber = await this.nextInvoiceNumber(mg);
|
||||||
@@ -826,8 +826,15 @@ export class BillingService {
|
|||||||
* Expire a source's currently-open invoice (its pay window closed before
|
* Expire a source's currently-open invoice (its pay window closed before
|
||||||
* settlement), then emit `${source}.invoice.expired`. Resolves the open invoice
|
* settlement), then emit `${source}.invoice.expired`. Resolves the open invoice
|
||||||
* and transitions it to EXPIRED — a terminal, non-payable status (kept out of
|
* and transitions it to EXPIRED — a terminal, non-payable status (kept out of
|
||||||
* `OPEN_STATUSES`). No-op (returns null) when the source has no open invoice
|
* `OPEN_STATUSES`). No-op (returns null) when the source has no invoice left to
|
||||||
* (already paid/cancelled/expired).
|
* retire (already paid/cancelled/expired).
|
||||||
|
*
|
||||||
|
* DRAFT invoices are matched too, even though they were never issued: this is
|
||||||
|
* also the "retire the invoice this source no longer needs" path (a cancelled
|
||||||
|
* booking, or a full-amount invoice superseded by a partial-offer one). Skipping
|
||||||
|
* drafts would leave the stale one behind for `findPayable` to hand back — the
|
||||||
|
* superseding invoice would then never be minted, and a cancelled booking would
|
||||||
|
* keep a draft that a later `issuePayable` could still make payable.
|
||||||
*
|
*
|
||||||
* Pass the caller's transaction `manager` (e.g. the booking pay-window expiry in
|
* Pass the caller's transaction `manager` (e.g. the booking pay-window expiry in
|
||||||
* the batch engine) to enlist in its DB transaction.
|
* the batch engine) to enlist in its DB transaction.
|
||||||
@@ -850,7 +857,7 @@ export class BillingService {
|
|||||||
where: {
|
where: {
|
||||||
source,
|
source,
|
||||||
sourceId,
|
sourceId,
|
||||||
status: In(OPEN_STATUSES),
|
status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]),
|
||||||
...(type ? { type } : {}),
|
...(type ? { type } : {}),
|
||||||
},
|
},
|
||||||
order: { issuedAt: "DESC" },
|
order: { issuedAt: "DESC" },
|
||||||
@@ -867,30 +874,58 @@ export class BillingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sync a source's open invoice `dueAt` to its real pay-window deadline. The
|
* Issue a source's invoice and stamp its real pay-window deadline — the single
|
||||||
* booking invoice is generated before the pay window opens (at booking
|
* transition that makes a source payable.
|
||||||
* creation/approval), so its printed due date is refreshed when the batch engine
|
*
|
||||||
* sets `paymentDeadline`. No-op when the source has no open invoice.
|
* A source's invoice is minted DRAFT, before any pay window exists (e.g. a
|
||||||
|
* booking invoice is generated at creation / operation-accept, long before the
|
||||||
|
* batch engine reserves a slot). DRAFT is deliberately outside `OPEN_STATUSES`,
|
||||||
|
* so such an invoice is not settleable and the portal renders no pay button.
|
||||||
|
* The domain calls this at the moment the pay window actually opens (booking →
|
||||||
|
* `reserve`, which sets SELECTED_FOR_BATCH + `paymentDeadline`), which issues
|
||||||
|
* the draft (→ PENDING, stamping `issuedAt`) and prints the real `dueAt`.
|
||||||
|
*
|
||||||
|
* Idempotent: an already-issued open invoice only has its `dueAt` refreshed, so
|
||||||
|
* a re-reserve never re-issues. No-op (returns null) when the source has no
|
||||||
|
* draft-or-open invoice (already paid/cancelled/expired).
|
||||||
*/
|
*/
|
||||||
async syncPayableDueDate(
|
async issuePayable(
|
||||||
source: Freight.InvoiceSource,
|
source: Freight.InvoiceSource,
|
||||||
sourceId: string,
|
sourceId: string,
|
||||||
dueAt: Date,
|
dueAt: Date,
|
||||||
type?: string,
|
type?: string,
|
||||||
manager?: EntityManager,
|
manager?: EntityManager,
|
||||||
): Promise<void> {
|
): Promise<Invoice | null> {
|
||||||
const mg = manager ?? this.dataSource.manager;
|
const mg = manager ?? this.dataSource.manager;
|
||||||
const invoice = await mg.findOne(Invoice, {
|
const invoice = await mg.findOne(Invoice, {
|
||||||
where: {
|
where: {
|
||||||
source,
|
source,
|
||||||
sourceId,
|
sourceId,
|
||||||
status: In(OPEN_STATUSES),
|
status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]),
|
||||||
...(type ? { type } : {}),
|
...(type ? { type } : {}),
|
||||||
},
|
},
|
||||||
order: { issuedAt: "DESC" },
|
order: { issuedAt: "DESC" },
|
||||||
});
|
});
|
||||||
if (!invoice) return;
|
if (!invoice) return null;
|
||||||
await mg.update(Invoice, { id: invoice.id }, { dueAt });
|
|
||||||
|
const issuing = invoice.status === Freight.InvoiceStatus.Draft;
|
||||||
|
const patch = {
|
||||||
|
dueAt,
|
||||||
|
...(issuing
|
||||||
|
? {
|
||||||
|
status: Freight.InvoiceStatus.Pending,
|
||||||
|
issuedAt: invoice.issuedAt ?? new Date(),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
await mg.update(Invoice, { id: invoice.id }, patch);
|
||||||
|
|
||||||
|
if (issuing) {
|
||||||
|
this.logger.log(
|
||||||
|
`Issued invoice ${invoice.invoiceNumber} (${invoice.id}) for ${source}:${sourceId} — payable until ${dueAt.toISOString()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { ...invoice, ...patch } as Invoice;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -991,26 +1026,26 @@ export class BillingService {
|
|||||||
returnUrl: opts.returnUrl,
|
returnUrl: opts.returnUrl,
|
||||||
failureUrl: opts.failureUrl,
|
failureUrl: opts.failureUrl,
|
||||||
});
|
});
|
||||||
//
|
//
|
||||||
// Link the intent to the invoice BEFORE any settlement can correlate against it.
|
// Link the intent to the invoice BEFORE any settlement can correlate against it.
|
||||||
await this.dataSource
|
await this.dataSource
|
||||||
.getRepository(Invoice)
|
.getRepository(Invoice)
|
||||||
.update({ id: invoice.id }, { paymentId: result.intentId });
|
.update({ id: invoice.id }, { paymentId: result.intentId });
|
||||||
|
|
||||||
// DEMO: manually fire the gateway `payment.succeeded` callback here, without
|
// // DEMO: manually fire the gateway `payment.succeeded` callback here, without
|
||||||
// waiting for real gateway settlement. Runs AFTER the paymentId link above so
|
// // waiting for real gateway settlement. Runs AFTER the paymentId link above so
|
||||||
// `handlePaymentEvent → settleByPaymentId` can correlate the invoice. TODO:
|
// // `handlePaymentEvent → settleByPaymentId` can correlate the invoice. TODO:
|
||||||
// remove — real settlement flips this via the `${source}.invoice.paid` handler.
|
// // remove — real settlement flips this via the `${source}.invoice.paid` handler.
|
||||||
if (!result.immediateSuccess) {
|
// if (!result.immediateSuccess) {
|
||||||
await this.payment.handlePaymentEvent({
|
// await this.payment.handlePaymentEvent({
|
||||||
eventType: "payment.succeeded",
|
// eventType: "payment.succeeded",
|
||||||
eventId: `demo-${result.intentId}`,
|
// eventId: `demo-${result.intentId}`,
|
||||||
referenceId: invoice.sourceId,
|
// referenceId: invoice.sourceId,
|
||||||
intentId: result.intentId,
|
// intentId: result.intentId,
|
||||||
providerTxnId: result.providerTxnId,
|
// providerTxnId: result.providerTxnId,
|
||||||
paidAt: (result.paidAt ?? new Date()).toISOString(),
|
// paidAt: (result.paidAt ?? new Date()).toISOString(),
|
||||||
});
|
// });
|
||||||
}
|
// }
|
||||||
|
|
||||||
if (result.immediateSuccess) {
|
if (result.immediateSuccess) {
|
||||||
await this.settleByPaymentId(
|
await this.settleByPaymentId(
|
||||||
|
|||||||
@@ -33,8 +33,6 @@ import { ClearanceMilestoneService } from '../contracts/clearance-milestone.serv
|
|||||||
import { ClearanceWorkflowService } from '../contracts/clearance-workflow.service';
|
import { ClearanceWorkflowService } from '../contracts/clearance-workflow.service';
|
||||||
import { ContractDocPhase } from '@edr/types';
|
import { ContractDocPhase } from '@edr/types';
|
||||||
|
|
||||||
|
|
||||||
import { Freight } from "@edr/types";
|
|
||||||
import { BookingInvoiceService } from "./booking-invoice.service";
|
import { BookingInvoiceService } from "./booking-invoice.service";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -1112,14 +1110,25 @@ export class BookingTransitionService {
|
|||||||
await this.bookingBatchService.pickExportSchedule(booking);
|
await this.bookingBatchService.pickExportSchedule(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mint the booking's invoice (DRAFT) so the priced order carries its billing
|
||||||
|
// record from accept onward. It is deliberately NOT issued here: accepting an
|
||||||
|
// operation only puts the booking in the batch holding pool — no slot has been
|
||||||
|
// offered and no pay window exists yet. Issuing at this point made the invoice
|
||||||
|
// payable straight away (portal invoice list/detail gate on invoice status
|
||||||
|
// alone), letting a customer pay before being selected for a batch, while the
|
||||||
|
// booking page correctly still showed it as not payable. The batch engine
|
||||||
|
// issues it in `reserve` (SELECTED_FOR_BATCH), which is where the pay window
|
||||||
|
// and the real deadline are created — matching the portal's `canPay` gate.
|
||||||
const invoice = await this.invoiceService.ensureInvoiceForBooking(booking);
|
const invoice = await this.invoiceService.ensureInvoiceForBooking(booking);
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Generated invoice ${invoice.invoiceNumber} (${invoice.id}) for ${booking.reference}:${booking.id}`,
|
`Generated draft invoice ${invoice.invoiceNumber} (${invoice.id}) for ${booking.reference}:${booking.id} — issued on batch selection`,
|
||||||
);
|
|
||||||
await this.invoiceService.updateStatus(
|
|
||||||
invoice.id,
|
|
||||||
Freight.InvoiceStatus.Pending,
|
|
||||||
);
|
);
|
||||||
|
// TODO: road (truck) orders are an incomplete feature — they stop at the
|
||||||
|
// dead-end ROAD_DISPATCH_PENDING status below (no dispatch transition, no
|
||||||
|
// per-km pricing wired via roadKmPrice, no pay surface in the portal). They
|
||||||
|
// skip the train batch, so they never reach `reserve` and their invoice stays
|
||||||
|
// DRAFT / unpayable. When the road flow is built, issue its invoice
|
||||||
|
// (billing.issuePayable) at whatever transition opens the road pay window.
|
||||||
if (isRoadService(booking.serviceType)) {
|
if (isRoadService(booking.serviceType)) {
|
||||||
await this.bookingsRepository.update(booking.id, {
|
await this.bookingsRepository.update(booking.id, {
|
||||||
status: "ROAD_DISPATCH_PENDING",
|
status: "ROAD_DISPATCH_PENDING",
|
||||||
|
|||||||
@@ -1,16 +1,36 @@
|
|||||||
import { IsArray, IsOptional, IsString, IsUUID, ValidateNested } from 'class-validator';
|
import {
|
||||||
|
ArrayMaxSize,
|
||||||
|
ArrayUnique,
|
||||||
|
IsArray,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
ValidateNested,
|
||||||
|
} from 'class-validator';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
export class LastMileVehicleInput {
|
export class LastMileVehicleInput {
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
vehicleId!: string;
|
vehicleId!: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Containers this truck carries: one 40ft, or up to two 20ft. Omit for bulk
|
||||||
|
* (the truck hauls loose tonnage and is weighed out on exit).
|
||||||
|
*/
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMaxSize(2)
|
||||||
|
@ArrayUnique()
|
||||||
|
@IsString({ each: true })
|
||||||
|
containerNumbers?: string[];
|
||||||
|
|
||||||
|
/** @deprecated Single-container form — use `containerNumbers`. Still accepted. */
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
containerNumber?: string;
|
containerNumber?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Replace the full set of vehicles (with their container numbers) on a delivery. */
|
/** Replace the full set of vehicles (with their containers) on a delivery. */
|
||||||
export class SetVehiclesDto {
|
export class SetVehiclesDto {
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@ValidateNested({ each: true })
|
@ValidateNested({ each: true })
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { BaseEntity } from '@edr/api-common';
|
import { BaseEntity } from '@edr/api-common';
|
||||||
import { Column, Entity, Index, JoinColumn, ManyToOne, Unique } from 'typeorm';
|
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, Unique } from 'typeorm';
|
||||||
|
|
||||||
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
|
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
|
||||||
import { LastMile } from './last-mile.entity';
|
import { LastMile } from './last-mile.entity';
|
||||||
|
import { LastMileVehicleContainer } from './last-mile-vehicle-container.entity';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One row per vehicle assigned to a last-mile delivery. A delivery can be
|
* One row per vehicle assigned to a last-mile delivery. A delivery can be
|
||||||
@@ -28,12 +29,34 @@ export class LastMileVehicleAssignment extends BaseEntity {
|
|||||||
@JoinColumn({ name: 'vehicle_id' })
|
@JoinColumn({ name: 'vehicle_id' })
|
||||||
vehicle?: Vehicle;
|
vehicle?: Vehicle;
|
||||||
|
|
||||||
/** Container this truck carries — auto-filled from the booking's container
|
/** Legacy single container this truck carries. Kept in sync with the FIRST
|
||||||
* number when known, else entered manually at assignment time. */
|
* entry of `containers` for backward compatibility — a truck can hold 1x40ft
|
||||||
|
* or 2x20ft, so `containers` is the authoritative list. */
|
||||||
@Column({ name: 'container_number', type: 'varchar', nullable: true })
|
@Column({ name: 'container_number', type: 'varchar', nullable: true })
|
||||||
containerNumber?: string | null;
|
containerNumber?: string | null;
|
||||||
|
|
||||||
|
/** Containers riding this truck (1x40ft, or up to 2x20ft). */
|
||||||
|
@OneToMany(() => LastMileVehicleContainer, (c) => c.assignment, { cascade: true })
|
||||||
|
containers?: LastMileVehicleContainer[];
|
||||||
|
|
||||||
/** Actual distance driven by this truck (km), entered per vehicle. */
|
/** Actual distance driven by this truck (km), entered per vehicle. */
|
||||||
@Column({ name: 'distance_km', type: 'numeric', precision: 10, scale: 2, nullable: true })
|
@Column({ name: 'distance_km', type: 'numeric', precision: 10, scale: 2, nullable: true })
|
||||||
distanceKm?: number | null;
|
distanceKm?: number | null;
|
||||||
|
|
||||||
|
/** This truck reached the warehouse (stamped by the arrival weighing step). */
|
||||||
|
@Column({ name: 'arrived_at', type: 'timestamptz', nullable: true })
|
||||||
|
arrivedAt?: Date | null;
|
||||||
|
|
||||||
|
/** This truck left the warehouse (stamped by the exit weighing step). */
|
||||||
|
@Column({ name: 'departed_at', type: 'timestamptz', nullable: true })
|
||||||
|
departedAt?: Date | null;
|
||||||
|
|
||||||
|
/** Weighed gross on exit, in TONNES (not kg — see the migration note). */
|
||||||
|
@Column({ name: 'gross_weight_tons', type: 'numeric', precision: 14, scale: 3, nullable: true })
|
||||||
|
grossWeightTons?: number | null;
|
||||||
|
|
||||||
|
/** Cargo actually taken by this truck (gross − tare), in TONNES. Drives the
|
||||||
|
* bulk drawdown: remaining = booking VGM − SUM(net) over departed trucks. */
|
||||||
|
@Column({ name: 'net_weight_tons', type: 'numeric', precision: 14, scale: 3, nullable: true })
|
||||||
|
netWeightTons?: number | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||||
|
|
||||||
|
import { LastMileVehicleAssignment } from './last-mile-vehicle-assignment.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A container riding a specific EDR last-mile truck. A truck carries 1x40ft OR
|
||||||
|
* 2x20ft, so the assignment needs more than the single legacy `container_number`
|
||||||
|
* scalar. Mirrors the self-haul `customer_truck_containers` child table.
|
||||||
|
*/
|
||||||
|
@Entity({ schema: 'freight', name: 'last_mile_vehicle_containers' })
|
||||||
|
@Index(['assignmentId'])
|
||||||
|
export class LastMileVehicleContainer extends BaseEntity {
|
||||||
|
@Column({ name: 'assignment_id', type: 'uuid' })
|
||||||
|
assignmentId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => LastMileVehicleAssignment, (a) => a.containers, {
|
||||||
|
nullable: false,
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
})
|
||||||
|
@JoinColumn({ name: 'assignment_id' })
|
||||||
|
assignment?: LastMileVehicleAssignment;
|
||||||
|
|
||||||
|
/** Denormalised for the "one container, one truck per delivery" unique index. */
|
||||||
|
@Column({ name: 'last_mile_id', type: 'uuid' })
|
||||||
|
lastMileId!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'container_number', type: 'varchar', length: 32 })
|
||||||
|
containerNumber!: string;
|
||||||
|
}
|
||||||
@@ -73,6 +73,12 @@ export class LastMileController {
|
|||||||
return this.lastMileService.arrivalTrucksForBooking(bookingId);
|
return this.lastMileService.arrivalTrucksForBooking(bookingId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('booking/:bookingId/remaining-tons')
|
||||||
|
@ApiOperation({ summary: 'Bulk drawdown: tonnage still to be hauled (total − departed trucks)' })
|
||||||
|
remainingTons(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||||
|
return this.lastMileService.remainingTonsForBooking(bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('accept/:reference')
|
@Post('accept/:reference')
|
||||||
@BookingStaff(FREIGHT_PERMS.lastMile.accept)
|
@BookingStaff(FREIGHT_PERMS.lastMile.accept)
|
||||||
@ApiOperation({ summary: 'Accept a paid booking and create a last-mile leg' })
|
@ApiOperation({ summary: 'Accept a paid booking and create a last-mile leg' })
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { VehiclesModule } from '../vehicles/vehicles.module';
|
|||||||
import { LastMile } from './entities/last-mile.entity';
|
import { LastMile } from './entities/last-mile.entity';
|
||||||
import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity';
|
import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity';
|
||||||
import { LastMileVehicleAssignment } from './entities/last-mile-vehicle-assignment.entity';
|
import { LastMileVehicleAssignment } from './entities/last-mile-vehicle-assignment.entity';
|
||||||
|
import { LastMileVehicleContainer } from './entities/last-mile-vehicle-container.entity';
|
||||||
import { LastMileController } from './last-mile.controller';
|
import { LastMileController } from './last-mile.controller';
|
||||||
import { LastMileInvoiceService } from './last-mile-invoice.service';
|
import { LastMileInvoiceService } from './last-mile-invoice.service';
|
||||||
import { LastMileRepository } from './last-mile.repository';
|
import { LastMileRepository } from './last-mile.repository';
|
||||||
@@ -17,7 +18,12 @@ import { LastMileService } from './last-mile.service';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([LastMile, LastMileContainerAllocation, LastMileVehicleAssignment]),
|
TypeOrmModule.forFeature([
|
||||||
|
LastMile,
|
||||||
|
LastMileContainerAllocation,
|
||||||
|
LastMileVehicleAssignment,
|
||||||
|
LastMileVehicleContainer,
|
||||||
|
]),
|
||||||
BillingModule,
|
BillingModule,
|
||||||
forwardRef(() => BookingsModule),
|
forwardRef(() => BookingsModule),
|
||||||
VehiclesModule,
|
VehiclesModule,
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { DataSource, FindOptionsWhere, In, IsNull, Not } from 'typeorm';
|
import { DataSource, FindOptionsWhere, In, IsNull, Not } from 'typeorm';
|
||||||
|
|
||||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||||
@@ -12,6 +18,7 @@ import { RecordProofOfDeliveryDto } from './dto/record-proof-of-delivery.dto';
|
|||||||
import { LastMile, LastMileStatus } from './entities/last-mile.entity';
|
import { LastMile, LastMileStatus } from './entities/last-mile.entity';
|
||||||
import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity';
|
import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity';
|
||||||
import { LastMileVehicleAssignment } from './entities/last-mile-vehicle-assignment.entity';
|
import { LastMileVehicleAssignment } from './entities/last-mile-vehicle-assignment.entity';
|
||||||
|
import { LastMileVehicleContainer } from './entities/last-mile-vehicle-container.entity';
|
||||||
import { LastMileRepository } from './last-mile.repository';
|
import { LastMileRepository } from './last-mile.repository';
|
||||||
import { FilesService } from '../files/files.service';
|
import { FilesService } from '../files/files.service';
|
||||||
import { BillingService, InvoiceEventPayload } from '../billing/billing.service';
|
import { BillingService, InvoiceEventPayload } from '../billing/billing.service';
|
||||||
@@ -175,7 +182,7 @@ export class LastMileService {
|
|||||||
relations: {
|
relations: {
|
||||||
booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true, bookingContainers: { containerType: true, units: true } },
|
booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true, bookingContainers: { containerType: true, units: true } },
|
||||||
vehicle: true,
|
vehicle: true,
|
||||||
vehicleAssignments: { vehicle: true },
|
vehicleAssignments: { vehicle: true, containers: true },
|
||||||
},
|
},
|
||||||
order: { [sortBy]: sortOrder },
|
order: { [sortBy]: sortOrder },
|
||||||
skip: (page - 1) * pageSize,
|
skip: (page - 1) * pageSize,
|
||||||
@@ -200,7 +207,7 @@ export class LastMileService {
|
|||||||
relations: {
|
relations: {
|
||||||
booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true, bookingContainers: { containerType: true, units: true } },
|
booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true, bookingContainers: { containerType: true, units: true } },
|
||||||
vehicle: true,
|
vehicle: true,
|
||||||
vehicleAssignments: { vehicle: true },
|
vehicleAssignments: { vehicle: true, containers: true },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -277,7 +284,7 @@ export class LastMileService {
|
|||||||
> {
|
> {
|
||||||
const [lm] = await this.lastMileRepository.findAll({
|
const [lm] = await this.lastMileRepository.findAll({
|
||||||
where: { bookingId },
|
where: { bookingId },
|
||||||
relations: { vehicle: true, vehicleAssignments: { vehicle: true } },
|
relations: { vehicle: true, vehicleAssignments: { vehicle: true, containers: true } },
|
||||||
take: 1,
|
take: 1,
|
||||||
});
|
});
|
||||||
if (!lm) return [];
|
if (!lm) return [];
|
||||||
@@ -552,22 +559,164 @@ export class LastMileService {
|
|||||||
* for each added/removed vehicle. The first vehicle is mirrored onto the legacy
|
* for each added/removed vehicle. The first vehicle is mirrored onto the legacy
|
||||||
* `vehicleId` column for back-compat with single-vehicle readers.
|
* `vehicleId` column for back-compat with single-vehicle readers.
|
||||||
*/
|
*/
|
||||||
|
/** Container numbers on the booking (upper-cased). */
|
||||||
|
private async bookingContainerNumbers(bookingId: string): Promise<string[]> {
|
||||||
|
const rows: Array<{ containerNumber: string }> = await this.dataSource.query(
|
||||||
|
`SELECT bcu.container_number AS "containerNumber"
|
||||||
|
FROM freight.booking_container_units bcu
|
||||||
|
JOIN freight.booking_container bc
|
||||||
|
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||||||
|
WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL`,
|
||||||
|
[bookingId],
|
||||||
|
);
|
||||||
|
return rows.map((r) => r.containerNumber.trim().toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Contract container sizes (e.g. "20ft" / "40ft") for the given numbers. */
|
||||||
|
private async containerSizes(bookingId: string, numbers: string[]): Promise<string[]> {
|
||||||
|
if (!numbers.length) return [];
|
||||||
|
const rows: Array<{ size: string | null }> = await this.dataSource.query(
|
||||||
|
`SELECT bc.container_size AS "size"
|
||||||
|
FROM freight.booking_container_units bcu
|
||||||
|
JOIN freight.booking_container bc
|
||||||
|
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||||||
|
WHERE bc.booking_id = $1
|
||||||
|
AND UPPER(bcu.container_number) = ANY($2)
|
||||||
|
AND bcu.deleted_at IS NULL`,
|
||||||
|
[bookingId, numbers],
|
||||||
|
);
|
||||||
|
return rows.map((r) => (r.size ?? '').trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk drawdown: how much of the booking's tonnage is still to be hauled —
|
||||||
|
* the booking VGM total minus the net weighed off every EDR truck that has
|
||||||
|
* already left. Both sides are tonnes, so no conversion.
|
||||||
|
*/
|
||||||
|
async remainingTonsForBooking(bookingId: string): Promise<{
|
||||||
|
totalTons: number;
|
||||||
|
hauledTons: number;
|
||||||
|
remainingTons: number;
|
||||||
|
complete: boolean;
|
||||||
|
}> {
|
||||||
|
const [row]: Array<{ totalTons: string | null; hauledTons: string | null }> =
|
||||||
|
await this.dataSource.query(
|
||||||
|
`SELECT COALESCE(b.cargo_total_weight_vgm, 0) AS "totalTons",
|
||||||
|
COALESCE((
|
||||||
|
SELECT SUM(va.net_weight_tons)
|
||||||
|
FROM freight.last_mile_vehicle_assignments va
|
||||||
|
JOIN freight.last_mile lm
|
||||||
|
ON lm.id = va.last_mile_id AND lm.deleted_at IS NULL
|
||||||
|
WHERE lm.booking_id = b.id
|
||||||
|
AND va.deleted_at IS NULL
|
||||||
|
AND va.departed_at IS NOT NULL
|
||||||
|
), 0) AS "hauledTons"
|
||||||
|
FROM freight.bookings b
|
||||||
|
WHERE b.id = $1 AND b.deleted_at IS NULL`,
|
||||||
|
[bookingId],
|
||||||
|
);
|
||||||
|
const totalTons = Number(row?.totalTons ?? 0);
|
||||||
|
const hauledTons = Number(row?.hauledTons ?? 0);
|
||||||
|
const remainingTons = Math.max(0, Math.round((totalTons - hauledTons) * 1000) / 1000);
|
||||||
|
return { totalTons, hauledTons, remainingTons, complete: totalTons > 0 && remainingTons <= 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Truck capacity rules for a last-mile delivery.
|
||||||
|
* - CONTAINER: a truck carries ONE 40ft or up to TWO 20ft; every container
|
||||||
|
* must belong to the booking and ride exactly one truck; never more trucks
|
||||||
|
* than containers.
|
||||||
|
* - BULK: no containers — trucks haul loose tonnage, so the only limit is
|
||||||
|
* that there is tonnage left to haul.
|
||||||
|
*/
|
||||||
|
private async assertVehicleLoads(
|
||||||
|
bookingId: string,
|
||||||
|
desired: string[],
|
||||||
|
loads: Map<string, string[]>,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!desired.length) return;
|
||||||
|
|
||||||
|
const [booking]: Array<{ freightType: string | null }> = await this.dataSource.query(
|
||||||
|
`SELECT freight_type AS "freightType"
|
||||||
|
FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
|
||||||
|
[bookingId],
|
||||||
|
);
|
||||||
|
if ((booking?.freightType ?? '').toUpperCase() === 'BULK') {
|
||||||
|
const { remainingTons, totalTons } = await this.remainingTonsForBooking(bookingId);
|
||||||
|
if (totalTons > 0 && remainingTons <= 0) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'This bulk booking is fully hauled — no tonnage left to assign trucks for',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
|
||||||
|
if (!bookingNumbers.length) return; // nothing to validate against
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const vehicleId of desired) {
|
||||||
|
const load = loads.get(vehicleId) ?? [];
|
||||||
|
if (load.length > 2) {
|
||||||
|
throw new BadRequestException('A truck carries at most 2 containers');
|
||||||
|
}
|
||||||
|
for (const n of load) {
|
||||||
|
if (!bookingNumbers.includes(n)) {
|
||||||
|
throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
|
||||||
|
}
|
||||||
|
if (seen.has(n)) {
|
||||||
|
throw new ConflictException(`Container ${n} is already assigned to another truck`);
|
||||||
|
}
|
||||||
|
seen.add(n);
|
||||||
|
}
|
||||||
|
// A 40ft container fills the truck; only two 20ft share one.
|
||||||
|
if (load.length > 1) {
|
||||||
|
const sizes = await this.containerSizes(bookingId, load);
|
||||||
|
if (sizes.some((s) => s.includes('40'))) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'A 40ft container fills the truck — assign only 1 container to this truck',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (desired.length > bookingNumbers.length) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Cannot assign more trucks than containers — this booking has ${bookingNumbers.length} container(s) and ${desired.length} truck(s) requested.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async setVehicles(
|
async setVehicles(
|
||||||
id: string,
|
id: string,
|
||||||
inputs: Array<{ vehicleId: string; containerNumber?: string | null }>,
|
inputs: Array<{
|
||||||
|
vehicleId: string;
|
||||||
|
containerNumbers?: string[] | null;
|
||||||
|
containerNumber?: string | null;
|
||||||
|
}>,
|
||||||
): Promise<LastMile> {
|
): Promise<LastMile> {
|
||||||
const existing = await this.findById(id);
|
const existing = await this.findById(id);
|
||||||
// Dedupe by vehicleId, keeping the container number; preserve order.
|
// Dedupe by vehicleId, keeping the container load; preserve order. Accepts
|
||||||
const desiredMap = new Map<string, string | null>();
|
// the legacy single `containerNumber` as a one-element load.
|
||||||
|
const desiredMap = new Map<string, string[]>();
|
||||||
for (const inp of inputs) {
|
for (const inp of inputs) {
|
||||||
if (inp.vehicleId) desiredMap.set(inp.vehicleId, inp.containerNumber ?? null);
|
if (!inp.vehicleId) continue;
|
||||||
|
const load = (inp.containerNumbers ?? (inp.containerNumber ? [inp.containerNumber] : []))
|
||||||
|
.map((n) => String(n).trim().toUpperCase())
|
||||||
|
.filter(Boolean);
|
||||||
|
desiredMap.set(inp.vehicleId, load);
|
||||||
}
|
}
|
||||||
const desired = [...desiredMap.keys()];
|
const desired = [...desiredMap.keys()];
|
||||||
const desiredSet = new Set(desired);
|
const desiredSet = new Set(desired);
|
||||||
|
|
||||||
|
// Capacity + membership rules (a truck holds one 40ft or two 20ft; bulk
|
||||||
|
// hauls tonnage until the booking is drawn down).
|
||||||
|
await this.assertVehicleLoads(existing.bookingId, desired, desiredMap);
|
||||||
|
|
||||||
const manager = this.dataSource.manager;
|
const manager = this.dataSource.manager;
|
||||||
const current = await manager.find(LastMileVehicleAssignment, {
|
const current = await manager.find(LastMileVehicleAssignment, {
|
||||||
where: { lastMileId: id },
|
where: { lastMileId: id },
|
||||||
|
relations: { containers: true },
|
||||||
});
|
});
|
||||||
const junctionSet = new Set(current.map((a) => a.vehicleId));
|
const junctionSet = new Set(current.map((a) => a.vehicleId));
|
||||||
// Fold the legacy vehicleId into the release set — a vehicle assigned via the
|
// Fold the legacy vehicleId into the release set — a vehicle assigned via the
|
||||||
@@ -588,33 +737,58 @@ export class LastMileService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Vehicles that stay but whose container number changed.
|
// Vehicles that stay but whose container load changed (order-insensitive).
|
||||||
|
const loadKey = (list: string[]) => [...list].sort().join('|');
|
||||||
|
const currentLoad = (a: LastMileVehicleAssignment) =>
|
||||||
|
(a.containers ?? []).map((c) => c.containerNumber.trim().toUpperCase());
|
||||||
const changed = current.filter(
|
const changed = current.filter(
|
||||||
(a) =>
|
(a) =>
|
||||||
desiredMap.has(a.vehicleId) &&
|
desiredMap.has(a.vehicleId) &&
|
||||||
(a.containerNumber ?? null) !== (desiredMap.get(a.vehicleId) ?? null),
|
loadKey(desiredMap.get(a.vehicleId) ?? []) !== loadKey(currentLoad(a)),
|
||||||
);
|
);
|
||||||
|
|
||||||
await this.dataSource.transaction(async (tx) => {
|
await this.dataSource.transaction(async (tx) => {
|
||||||
if (removed.length) {
|
if (removed.length) {
|
||||||
|
// Child containers cascade on delete.
|
||||||
await tx.delete(LastMileVehicleAssignment, {
|
await tx.delete(LastMileVehicleAssignment, {
|
||||||
lastMileId: id,
|
lastMileId: id,
|
||||||
vehicleId: In(removed),
|
vehicleId: In(removed),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
for (const vehicleId of added) {
|
for (const vehicleId of added) {
|
||||||
await tx.insert(LastMileVehicleAssignment, {
|
const load = desiredMap.get(vehicleId) ?? [];
|
||||||
|
const inserted = await tx.insert(LastMileVehicleAssignment, {
|
||||||
lastMileId: id,
|
lastMileId: id,
|
||||||
vehicleId,
|
vehicleId,
|
||||||
containerNumber: desiredMap.get(vehicleId) ?? null,
|
// Legacy scalar stays in sync with the first container.
|
||||||
|
containerNumber: load[0] ?? null,
|
||||||
});
|
});
|
||||||
|
const assignmentId = inserted.identifiers[0]?.id as string | undefined;
|
||||||
|
if (assignmentId && load.length) {
|
||||||
|
await tx.insert(
|
||||||
|
LastMileVehicleContainer,
|
||||||
|
load.map((containerNumber) => ({ assignmentId, lastMileId: id, containerNumber })),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for (const row of changed) {
|
for (const row of changed) {
|
||||||
|
const load = desiredMap.get(row.vehicleId) ?? [];
|
||||||
await tx.update(
|
await tx.update(
|
||||||
LastMileVehicleAssignment,
|
LastMileVehicleAssignment,
|
||||||
{ lastMileId: id, vehicleId: row.vehicleId },
|
{ lastMileId: id, vehicleId: row.vehicleId },
|
||||||
{ containerNumber: desiredMap.get(row.vehicleId) ?? null },
|
{ containerNumber: load[0] ?? null },
|
||||||
);
|
);
|
||||||
|
await tx.delete(LastMileVehicleContainer, { assignmentId: row.id });
|
||||||
|
if (load.length) {
|
||||||
|
await tx.insert(
|
||||||
|
LastMileVehicleContainer,
|
||||||
|
load.map((containerNumber) => ({
|
||||||
|
assignmentId: row.id,
|
||||||
|
lastMileId: id,
|
||||||
|
containerNumber,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
|
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
|
||||||
trainSchedulingService as never,
|
trainSchedulingService as never,
|
||||||
{
|
{
|
||||||
syncPayableDueDate: jest.fn().mockResolvedValue(undefined),
|
issuePayable: jest.fn().mockResolvedValue(null),
|
||||||
expirePayable: jest.fn().mockResolvedValue(undefined),
|
expirePayable: jest.fn().mockResolvedValue(undefined),
|
||||||
} as never,
|
} as never,
|
||||||
{ emitPhase: jest.fn() } as never,
|
{ emitPhase: jest.fn() } as never,
|
||||||
@@ -596,7 +596,7 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
notifier as never,
|
notifier as never,
|
||||||
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
|
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
|
||||||
trainSchedulingService as never,
|
trainSchedulingService as never,
|
||||||
{ syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never,
|
{ issuePayable: jest.fn(), expirePayable: jest.fn() } as never,
|
||||||
{ emitPhase: jest.fn() } as never,
|
{ emitPhase: jest.fn() } as never,
|
||||||
{ computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never,
|
{ computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never,
|
||||||
undefined,
|
undefined,
|
||||||
@@ -619,7 +619,7 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
notifier as never,
|
notifier as never,
|
||||||
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
|
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
|
||||||
trainSchedulingService as never,
|
trainSchedulingService as never,
|
||||||
{ syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never,
|
{ issuePayable: jest.fn(), expirePayable: jest.fn() } as never,
|
||||||
{ emitPhase: jest.fn() } as never,
|
{ emitPhase: jest.fn() } as never,
|
||||||
{ computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never,
|
{ computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never,
|
||||||
undefined,
|
undefined,
|
||||||
@@ -650,7 +650,7 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
notifier as never,
|
notifier as never,
|
||||||
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
|
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
|
||||||
trainSchedulingService as never,
|
trainSchedulingService as never,
|
||||||
{ syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never,
|
{ issuePayable: jest.fn(), expirePayable: jest.fn() } as never,
|
||||||
{ emitPhase: jest.fn() } as never,
|
{ emitPhase: jest.fn() } as never,
|
||||||
{ computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never,
|
{ computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never,
|
||||||
undefined,
|
undefined,
|
||||||
|
|||||||
@@ -2317,9 +2317,13 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
paymentDeadline: deadline,
|
paymentDeadline: deadline,
|
||||||
} as never);
|
} as never);
|
||||||
booking.trainScheduleId = scheduleId;
|
booking.trainScheduleId = scheduleId;
|
||||||
// The invoice was generated at booking creation/approval, before this pay
|
// The invoice was generated DRAFT at booking creation / operation-accept,
|
||||||
// window opened — refresh its printed due date to the real deadline.
|
// before this pay window existed. Reserving is the moment the booking becomes
|
||||||
await this.billing.syncPayableDueDate(
|
// payable (SELECTED_FOR_BATCH + a real deadline), so issue the draft here and
|
||||||
|
// print the deadline as its due date — never earlier, or the customer could
|
||||||
|
// settle an invoice for a slot they have not been offered yet. Idempotent: a
|
||||||
|
// re-reserve only refreshes `dueAt`.
|
||||||
|
await this.billing.issuePayable(
|
||||||
Freight.InvoiceSource.Booking,
|
Freight.InvoiceSource.Booking,
|
||||||
booking.id,
|
booking.id,
|
||||||
deadline,
|
deadline,
|
||||||
|
|||||||
@@ -421,6 +421,20 @@ export class WarehouseInventoryController {
|
|||||||
return res.send(buffer);
|
return res.send(buffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('edr-truck-exit-paper/:assignmentId')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||||
|
@ApiOperation({ summary: 'Per-truck exit paper PDF for an EDR last-mile truck' })
|
||||||
|
async edrTruckExitPaper(
|
||||||
|
@Param('assignmentId', ParseUUIDPipe) assignmentId: string,
|
||||||
|
@Res() res: Response,
|
||||||
|
) {
|
||||||
|
const { filename, buffer } = await this.inventoryService.edrTruckExitPaper(assignmentId);
|
||||||
|
res.setHeader('Content-Type', 'application/pdf');
|
||||||
|
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
|
||||||
|
res.setHeader('Content-Length', buffer.length);
|
||||||
|
return res.send(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id/grn-document')
|
@Get(':id/grn-document')
|
||||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||||
@ApiOperation({ summary: 'View goods received note PDF' })
|
@ApiOperation({ summary: 'View goods received note PDF' })
|
||||||
|
|||||||
@@ -2821,6 +2821,16 @@ export class WarehouseInventoryService {
|
|||||||
: dto;
|
: dto;
|
||||||
const exitInspectionNote = this.buildExitInspectionNote(exitInspectionDto);
|
const exitInspectionNote = this.buildExitInspectionNote(exitInspectionDto);
|
||||||
|
|
||||||
|
// The load actually leaving on this truck, in TONNES (the weighing UI is in
|
||||||
|
// t). Null when the operator skipped weighing — containers may skip, bulk
|
||||||
|
// never does.
|
||||||
|
const grossTons = exitInspectionDto.grossWeight ?? null;
|
||||||
|
const tareTons = exitInspectionDto.tareWeight ?? null;
|
||||||
|
const netTons =
|
||||||
|
grossTons != null && tareTons != null
|
||||||
|
? Math.round((grossTons - tareTons) * 1000) / 1000
|
||||||
|
: (exitInspectionDto.netWeight ?? null);
|
||||||
|
|
||||||
await this.dataSource.transaction(async (manager) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
await manager.getRepository(WarehouseInventory).update(id, {
|
await manager.getRepository(WarehouseInventory).update(id, {
|
||||||
releaseDate,
|
releaseDate,
|
||||||
@@ -2848,6 +2858,23 @@ export class WarehouseInventoryService {
|
|||||||
// an EXPORT concept (set when a truck delivers into the port). Import
|
// an EXPORT concept (set when a truck delivers into the port). Import
|
||||||
// load + weight are captured on truck departure, not arrival.
|
// load + weight are captured on truck departure, not arrival.
|
||||||
}
|
}
|
||||||
|
// EDR last-mile: stamp THIS truck's arrival. Matched by plate rather than
|
||||||
|
// container so it works for bulk too (bulk trucks carry no container).
|
||||||
|
if (dto.truckPlateNumber?.trim()) {
|
||||||
|
await manager.query(
|
||||||
|
`UPDATE freight.last_mile_vehicle_assignments va
|
||||||
|
SET arrived_at = COALESCE(va.arrived_at, NOW()), updated_at = NOW()
|
||||||
|
FROM freight.last_mile lm, freight.vehicles v
|
||||||
|
WHERE va.last_mile_id = lm.id
|
||||||
|
AND lm.booking_id = $1
|
||||||
|
AND lm.deleted_at IS NULL
|
||||||
|
AND v.id = va.vehicle_id
|
||||||
|
AND (UPPER(v.power_plate_no) = UPPER($2) OR UPPER(v.plate_number) = UPPER($2))
|
||||||
|
AND va.arrived_at IS NULL
|
||||||
|
AND va.deleted_at IS NULL`,
|
||||||
|
[item.bookingId, dto.truckPlateNumber.trim()],
|
||||||
|
);
|
||||||
|
}
|
||||||
// Booking-level flag stamped on the FIRST truck arrival. The import
|
// Booking-level flag stamped on the FIRST truck arrival. The import
|
||||||
// handover is signed ONCE (before the first truck leaves), even though
|
// handover is signed ONCE (before the first truck leaves), even though
|
||||||
// trucks pick up per-container — COALESCE keeps the first timestamp.
|
// trucks pick up per-container — COALESCE keeps the first timestamp.
|
||||||
@@ -2868,6 +2895,34 @@ export class WarehouseInventoryService {
|
|||||||
await this.handover.ensureForArrivedTruck(item.bookingId, {}, manager);
|
await this.handover.ensureForArrivedTruck(item.bookingId, {}, manager);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (isTruckLeaving && item.bookingId && dto.truckPlateNumber?.trim()) {
|
||||||
|
// EDR last-mile: this truck is leaving — record its exit and the load it
|
||||||
|
// actually took. net_weight_tons drives the bulk drawdown (booking VGM
|
||||||
|
// minus everything already hauled away).
|
||||||
|
await manager.query(
|
||||||
|
`UPDATE freight.last_mile_vehicle_assignments va
|
||||||
|
SET departed_at = COALESCE($3::timestamptz, NOW()),
|
||||||
|
arrived_at = COALESCE(va.arrived_at, NOW()),
|
||||||
|
gross_weight_tons = $4,
|
||||||
|
net_weight_tons = $5,
|
||||||
|
updated_at = NOW()
|
||||||
|
FROM freight.last_mile lm, freight.vehicles v
|
||||||
|
WHERE va.last_mile_id = lm.id
|
||||||
|
AND lm.booking_id = $1
|
||||||
|
AND lm.deleted_at IS NULL
|
||||||
|
AND v.id = va.vehicle_id
|
||||||
|
AND (UPPER(v.power_plate_no) = UPPER($2) OR UPPER(v.plate_number) = UPPER($2))
|
||||||
|
AND va.departed_at IS NULL
|
||||||
|
AND va.deleted_at IS NULL`,
|
||||||
|
[
|
||||||
|
item.bookingId,
|
||||||
|
dto.truckPlateNumber.trim(),
|
||||||
|
dto.gateOutTime ?? null,
|
||||||
|
grossTons,
|
||||||
|
netTons,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
await this.activityLog.record(
|
await this.activityLog.record(
|
||||||
{
|
{
|
||||||
activityType: 'INVENTORY_RELEASED',
|
activityType: 'INVENTORY_RELEASED',
|
||||||
@@ -2886,9 +2941,56 @@ export class WarehouseInventoryService {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Tell the customer their truck has left — one hook covers BOTH self-haul and
|
||||||
|
// EDR last-mile, since release() is the single exit path for either. Outside
|
||||||
|
// the transaction and fire-and-forget: notifying must never fail the exit.
|
||||||
|
if (isTruckLeaving && item.bookingId) {
|
||||||
|
void this.notifyTruckDeparture(item.bookingId, dto.truckPlateNumber?.trim() ?? null, netTons);
|
||||||
|
}
|
||||||
|
|
||||||
return this.findById(id);
|
return this.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort truck-departure notification to the booking's company across
|
||||||
|
* every channel: in-app (portal inbox) + SMS + email. Never throws — a missing
|
||||||
|
* provider or contact must not break the exit flow.
|
||||||
|
*/
|
||||||
|
private async notifyTruckDeparture(
|
||||||
|
bookingId: string,
|
||||||
|
plateNumber: string | null,
|
||||||
|
netTons: number | null,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const [booking]: Array<{ companyId: string | null; reference: string | null }> =
|
||||||
|
await this.dataSource.query(
|
||||||
|
`SELECT company_id AS "companyId", reference
|
||||||
|
FROM freight.bookings
|
||||||
|
WHERE id = $1 AND deleted_at IS NULL`,
|
||||||
|
[bookingId],
|
||||||
|
);
|
||||||
|
if (!booking?.companyId) return;
|
||||||
|
const ref = booking.reference ?? bookingId;
|
||||||
|
const truck = plateNumber ? `Truck ${plateNumber}` : 'A truck';
|
||||||
|
const load = netTons != null && netTons > 0 ? ` carrying ${netTons} t` : '';
|
||||||
|
const body = `${truck} has left the warehouse for booking ${ref}${load}.`;
|
||||||
|
await this.inbox.notify({
|
||||||
|
recipients: { companyId: booking.companyId },
|
||||||
|
audience: NotificationAudience.PORTAL,
|
||||||
|
type: NotificationType.BOOKING_STATUS,
|
||||||
|
title: 'Truck left the warehouse',
|
||||||
|
body,
|
||||||
|
link: `/bookings/${bookingId}`,
|
||||||
|
data: { bookingId, plateNumber, netTons, action: 'TRUCK_LEFT' },
|
||||||
|
});
|
||||||
|
await sendCompanyChannels(this.dataSource, this.notifications, booking.companyId, body);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Truck-departure notify failed for ${bookingId}: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async releaseDocument(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
async releaseDocument(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||||
const [row] = await this.dataSource.query(
|
const [row] = await this.dataSource.query(
|
||||||
`SELECT inv.id,
|
`SELECT inv.id,
|
||||||
@@ -3213,6 +3315,75 @@ export class WarehouseInventoryService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exit paper for an EDR last-mile truck (one per truck, keyed on the vehicle
|
||||||
|
* assignment). Deliberately NOT gated on the handover: EDR handovers are
|
||||||
|
* generated at delivery — i.e. after the truck has already left — so there is
|
||||||
|
* nothing to sign at exit time. Warehouse-fee clearance still applies.
|
||||||
|
*/
|
||||||
|
async edrTruckExitPaper(assignmentId: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||||
|
const [truck] = await this.dataSource.query(
|
||||||
|
`SELECT lm.booking_id AS "bookingId",
|
||||||
|
COALESCE(v.power_plate_no, v.plate_number) AS "plateNumber",
|
||||||
|
COALESCE(
|
||||||
|
v.assigned_driver_name,
|
||||||
|
NULLIF(TRIM(CONCAT(d.first_name, ' ', d.last_name)), '')
|
||||||
|
) AS "driverName",
|
||||||
|
v.vehicle_type AS "truckType",
|
||||||
|
va.gross_weight_tons AS "grossWeightKg",
|
||||||
|
va.departed_at AS "departedAt",
|
||||||
|
b.reference AS "bookingReference",
|
||||||
|
company.name AS "customerName"
|
||||||
|
FROM freight.last_mile_vehicle_assignments va
|
||||||
|
JOIN freight.last_mile lm ON lm.id = va.last_mile_id AND lm.deleted_at IS NULL
|
||||||
|
JOIN freight.bookings b ON b.id = lm.booking_id AND b.deleted_at IS NULL
|
||||||
|
JOIN freight.vehicles v ON v.id = va.vehicle_id
|
||||||
|
LEFT JOIN freight.drivers d ON d.id = v.assigned_driver_id AND d.deleted_at IS NULL
|
||||||
|
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||||
|
WHERE va.id = $1 AND va.deleted_at IS NULL`,
|
||||||
|
[assignmentId],
|
||||||
|
);
|
||||||
|
if (!truck) throw new NotFoundException(`EDR truck assignment ${assignmentId} not found`);
|
||||||
|
|
||||||
|
const [inv]: Array<{ id: string }> = await this.dataSource.query(
|
||||||
|
`SELECT id FROM freight.warehouse_inventory
|
||||||
|
WHERE booking_id = $1 AND deleted_at IS NULL ORDER BY created_at LIMIT 1`,
|
||||||
|
[truck.bookingId],
|
||||||
|
);
|
||||||
|
if (inv?.id) await this.invoices.assertClearanceAllowed(inv.id);
|
||||||
|
|
||||||
|
// Bulk trucks carry no containers — the table is then empty and the paper
|
||||||
|
// stands on the weighed gross alone.
|
||||||
|
const containers: Array<{ containerNumber: string; goods: string | null }> =
|
||||||
|
await this.dataSource.query(
|
||||||
|
`SELECT vc.container_number AS "containerNumber",
|
||||||
|
COALESCE(ct.cargo_type_name, b.cargo_free_text) AS goods
|
||||||
|
FROM freight.last_mile_vehicle_containers vc
|
||||||
|
JOIN freight.last_mile lm ON lm.id = vc.last_mile_id AND lm.deleted_at IS NULL
|
||||||
|
JOIN freight.bookings b ON b.id = lm.booking_id
|
||||||
|
LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id
|
||||||
|
WHERE vc.assignment_id = $1 AND vc.deleted_at IS NULL
|
||||||
|
ORDER BY vc.container_number`,
|
||||||
|
[assignmentId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const html = this.buildTruckExitPaperHtml({
|
||||||
|
reference: `REL-${String(truck.bookingReference).replace(/^BK-?/i, '')}-${truck.plateNumber}`,
|
||||||
|
bookingReference: truck.bookingReference,
|
||||||
|
customerName: truck.customerName,
|
||||||
|
plateNumber: truck.plateNumber,
|
||||||
|
driverName: truck.driverName ?? '-',
|
||||||
|
truckType: truck.truckType ?? '-',
|
||||||
|
grossWeightKg: Number(truck.grossWeightKg ?? 0),
|
||||||
|
gateOut: truck.departedAt,
|
||||||
|
containers,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
filename: `exit-${String(truck.plateNumber).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
|
||||||
|
buffer: await this.releaseDocuments.renderDocumentHtml(html, 'Warehouse exit paper'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private buildTruckExitPaperHtml(data: {
|
private buildTruckExitPaperHtml(data: {
|
||||||
reference: string;
|
reference: string;
|
||||||
bookingReference: string;
|
bookingReference: string;
|
||||||
@@ -3728,20 +3899,30 @@ export class WarehouseInventoryService {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// EDR last-mile: the handover is per delivering truck. Resolve the
|
// EDR last-mile: the handover is per delivering truck. Resolve the
|
||||||
// vehicle that carried this item's container so each truck gets its own
|
// vehicle from the truck's own container list (the earlier lookup went
|
||||||
// handover (falls back to a booking-level one when unresolvable).
|
// through last_mile_container_allocations, which nothing ever writes —
|
||||||
|
// so truckPlate was always null and every booking collapsed to a single
|
||||||
|
// booking-level handover). Bulk has no container, so fall back to the
|
||||||
|
// delivery's single truck; a booking-level handover when unresolvable.
|
||||||
let truckPlate: string | null = null;
|
let truckPlate: string | null = null;
|
||||||
if (item.containerId) {
|
const [veh]: Array<{ plate: string | null }> = await manager.query(
|
||||||
const [veh]: Array<{ plate: string | null }> = await manager.query(
|
`SELECT COALESCE(v.power_plate_no, v.plate_number) AS plate
|
||||||
`SELECT COALESCE(v.power_plate_no, v.plate_number) AS plate
|
FROM freight.last_mile_vehicle_assignments va
|
||||||
FROM freight.last_mile_container_allocations lca
|
JOIN freight.last_mile lm
|
||||||
JOIN freight.vehicles v ON v.id = lca.vehicle_id
|
ON lm.id = va.last_mile_id AND lm.deleted_at IS NULL
|
||||||
WHERE lca.container_id = $1 AND lca.vehicle_id IS NOT NULL
|
JOIN freight.vehicles v ON v.id = va.vehicle_id
|
||||||
LIMIT 1`,
|
LEFT JOIN freight.last_mile_vehicle_containers vc
|
||||||
[item.containerId],
|
ON vc.assignment_id = va.id AND vc.deleted_at IS NULL
|
||||||
);
|
LEFT JOIN freight.containers cont
|
||||||
truckPlate = veh?.plate ?? null;
|
ON cont.container_number = vc.container_number AND cont.deleted_at IS NULL
|
||||||
}
|
WHERE lm.booking_id = $1
|
||||||
|
AND va.deleted_at IS NULL
|
||||||
|
AND ($2::uuid IS NULL OR cont.id = $2::uuid)
|
||||||
|
ORDER BY (cont.id IS NOT NULL) DESC, va.created_at ASC
|
||||||
|
LIMIT 1`,
|
||||||
|
[item.bookingId, item.containerId ?? null],
|
||||||
|
);
|
||||||
|
truckPlate = veh?.plate ?? null;
|
||||||
await this.handover.ensureAtDelivery(item.bookingId, { truckPlate }, manager);
|
await this.handover.ensureAtDelivery(item.bookingId, { truckPlate }, manager);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { AppDataSource } from '../data-source';
|
import { AppDataSource } from '../data-source';
|
||||||
import { SeedEdRWagonFleet1750400000000 } from '../migrations/1750400000000-SeedEdRWagonFleet';
|
import { SeedEdrWagonFleetErNumbering2260000000000 } from '../migrations/2260000000000-SeedEdrWagonFleetErNumbering';
|
||||||
|
|
||||||
async function seedEdRWagons() {
|
async function seedEdRWagons() {
|
||||||
await AppDataSource.initialize();
|
await AppDataSource.initialize();
|
||||||
@@ -10,28 +10,32 @@ async function seedEdRWagons() {
|
|||||||
await queryRunner.connect();
|
await queryRunner.connect();
|
||||||
await queryRunner.startTransaction();
|
await queryRunner.startTransaction();
|
||||||
|
|
||||||
await new SeedEdRWagonFleet1750400000000().up(queryRunner);
|
await new SeedEdrWagonFleetErNumbering2260000000000().up(queryRunner);
|
||||||
|
|
||||||
const [summary] = await queryRunner.query(`
|
const summary = await queryRunner.query(`
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(*)::int AS total,
|
wt.code,
|
||||||
COUNT(*) FILTER (WHERE wt.code = 'PW2')::int AS pw2,
|
wt.name,
|
||||||
COUNT(*) FILTER (WHERE wt.code = 'CW4')::int AS cw4,
|
COUNT(*)::int AS wagons,
|
||||||
COUNT(*) FILTER (WHERE wt.code = 'CW3')::int AS cw3,
|
MIN(w.wagon_number) AS first_wagon,
|
||||||
COUNT(*) FILTER (WHERE wt.code = 'KW2')::int AS kw2,
|
MAX(w.wagon_number) AS last_wagon,
|
||||||
COUNT(*) FILTER (WHERE wt.code = 'KW3')::int AS kw3,
|
COUNT(*) FILTER (WHERE w.status = 'AVAILABLE')::int AS available,
|
||||||
COUNT(*) FILTER (WHERE wt.code = 'NW5')::int AS nw5,
|
COUNT(*) FILTER (WHERE w.current_yard_id IS NULL)::int AS unassigned_yard
|
||||||
COUNT(*) FILTER (WHERE wt.supported_load_types @> ARRAY['CONTAINER'])::int AS container_ready,
|
|
||||||
COUNT(*) FILTER (WHERE wt.supported_load_types @> ARRAY['BULK'])::int AS bulk_ready,
|
|
||||||
COUNT(*) FILTER (WHERE w.status = 'IMPORT_READY')::int AS import_ready
|
|
||||||
FROM freight.wagons w
|
FROM freight.wagons w
|
||||||
JOIN freight.wagon_types wt ON wt.id = w.wagon_type_id
|
JOIN freight.wagon_types wt ON wt.id = w.wagon_type_id
|
||||||
WHERE w.wagon_number BETWEEN 'ER0001' AND 'ER0940';
|
WHERE w.wagon_number BETWEEN 'ER0001' AND 'ER1100'
|
||||||
|
GROUP BY wt.code, wt.name
|
||||||
|
ORDER BY MIN(w.wagon_number);
|
||||||
|
`);
|
||||||
|
|
||||||
|
const [totals] = await queryRunner.query(`
|
||||||
|
SELECT COUNT(*)::int AS total FROM freight.wagons;
|
||||||
`);
|
`);
|
||||||
|
|
||||||
await queryRunner.commitTransaction();
|
await queryRunner.commitTransaction();
|
||||||
|
|
||||||
console.log('Seeded EDR wagon fleet:', summary);
|
console.table(summary);
|
||||||
|
console.log(`Seeded EDR wagon fleet — ${totals.total} wagons total (expected 1100).`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await queryRunner.rollbackTransaction();
|
await queryRunner.rollbackTransaction();
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -4,3 +4,8 @@ VITE_BASE_API_URL=http://localhost:3001
|
|||||||
# Proactive token refresh cadence (minutes). Must stay well under the 60-min
|
# Proactive token refresh cadence (minutes). Must stay well under the 60-min
|
||||||
# server session window. Default: 10.
|
# server session window. Default: 10.
|
||||||
VITE_TOKEN_REFRESH_INTERVAL_MINUTES=10
|
VITE_TOKEN_REFRESH_INTERVAL_MINUTES=10
|
||||||
|
|
||||||
|
# PostHog — session replay, error tracking, console logs. Both must be set or
|
||||||
|
# observability stays off (the app works either way). Self-hosted instance.
|
||||||
|
VITE_POSTHOG_KEY=phc_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
VITE_POSTHOG_HOST=https://posthog.example.com
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"@mantine/core": "^9.3.0",
|
"@mantine/core": "^9.3.0",
|
||||||
"@mantine/dates": "^9.3.0",
|
"@mantine/dates": "^9.3.0",
|
||||||
"@mantine/hooks": "^9.3.0",
|
"@mantine/hooks": "^9.3.0",
|
||||||
|
"@posthog/react": "^1.10.3",
|
||||||
"@radix-ui/react-accordion": "^1.2.13",
|
"@radix-ui/react-accordion": "^1.2.13",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.16",
|
"@radix-ui/react-alert-dialog": "^1.1.16",
|
||||||
"@radix-ui/react-avatar": "^1.1.12",
|
"@radix-ui/react-avatar": "^1.1.12",
|
||||||
@@ -76,6 +77,7 @@
|
|||||||
"lucide-react": "^1.14.0",
|
"lucide-react": "^1.14.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
|
"posthog-js": "^1.400.1",
|
||||||
"prop-types": "^15.8.1",
|
"prop-types": "^15.8.1",
|
||||||
"qs": "^6.15.2",
|
"qs": "^6.15.2",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
|
||||||
|
import { useIdentify } from "@/lib/posthog";
|
||||||
import { getMeRequest, loginRequest, verifyMfaRequest } from "./api";
|
import { getMeRequest, loginRequest, verifyMfaRequest } from "./api";
|
||||||
import {
|
import {
|
||||||
AUTH_TOKEN_COOKIE,
|
AUTH_TOKEN_COOKIE,
|
||||||
@@ -69,6 +70,9 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const mfaEmailRef = useRef<string | null>(null);
|
const mfaEmailRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
// Attribute replays and exceptions to the signed-in user (id/org only).
|
||||||
|
useIdentify(user);
|
||||||
|
|
||||||
const loadCurrentUser = async () => {
|
const loadCurrentUser = async () => {
|
||||||
const currentUser = await getMeRequest();
|
const currentUser = await getMeRequest();
|
||||||
setUser(currentUser);
|
setUser(currentUser);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
emitApiError,
|
emitApiError,
|
||||||
extractApiErrorPayload,
|
extractApiErrorPayload,
|
||||||
} from "@/components/errors/ApiErrorModal";
|
} from "@/components/errors/ApiErrorModal";
|
||||||
|
import { captureApiError } from "@/lib/posthog";
|
||||||
import {
|
import {
|
||||||
AUTH_TOKEN_COOKIE,
|
AUTH_TOKEN_COOKIE,
|
||||||
REFRESH_TOKEN_COOKIE,
|
REFRESH_TOKEN_COOKIE,
|
||||||
@@ -82,6 +83,14 @@ api.interceptors.response.use(
|
|||||||
async (error) => {
|
async (error) => {
|
||||||
const originalRequest = error.config as RetriableRequest | undefined;
|
const originalRequest = error.config as RetriableRequest | undefined;
|
||||||
|
|
||||||
|
// Report the failure to PostHog. Hooked here rather than inside
|
||||||
|
// `emitApiError`, which stays silent on suppressed paths (warehouse /
|
||||||
|
// mile / onboarding) — those failures still need reporting.
|
||||||
|
// 401s are skipped: an expired session is refreshed below, not a defect.
|
||||||
|
if (!error.response || error.response.status !== 401) {
|
||||||
|
captureApiError(error);
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
error.response?.status !== 401 ||
|
error.response?.status !== 401 ||
|
||||||
!originalRequest ||
|
!originalRequest ||
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||||
|
|
||||||
|
import { captureException } from "@/lib/posthog";
|
||||||
|
|
||||||
interface ErrorBoundaryProps {
|
interface ErrorBoundaryProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
@@ -21,6 +23,7 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
|
|||||||
}
|
}
|
||||||
|
|
||||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||||
|
captureException(error, { componentStack: info.componentStack });
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.error("[ErrorBoundary] Uncaught render error:", error, info.componentStack);
|
console.error("[ErrorBoundary] Uncaught render error:", error, info.componentStack);
|
||||||
}
|
}
|
||||||
|
|||||||
140
apps/edr-freight-web/backoffice/src/lib/posthog.ts
Normal file
140
apps/edr-freight-web/backoffice/src/lib/posthog.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* PostHog wiring — session replay, exception capture, console logs.
|
||||||
|
*
|
||||||
|
* NOTE: `portal/src/lib/posthog.ts` is the twin of this file. The init config
|
||||||
|
* below (masking rules) and the PII allowlist in `useIdentify` MUST be kept
|
||||||
|
* identical in both — a change made here alone silently leaks staff data into
|
||||||
|
* the other app's replays.
|
||||||
|
*
|
||||||
|
* This is instrumentation, not analytics: autocapture is off and no product
|
||||||
|
* events are sent.
|
||||||
|
*/
|
||||||
|
import posthog from "posthog-js";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
import type { AuthUser } from "@/auth/types";
|
||||||
|
|
||||||
|
const APP = "freight-backoffice";
|
||||||
|
|
||||||
|
const TOKEN = import.meta.env.VITE_POSTHOG_KEY;
|
||||||
|
const HOST = import.meta.env.VITE_POSTHOG_HOST;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether init actually ran. Without a token every export below is a no-op, so
|
||||||
|
* local dev and any environment whose env file lacks the vars keeps working —
|
||||||
|
* a missing observability token must never break the app.
|
||||||
|
*/
|
||||||
|
let enabled = false;
|
||||||
|
|
||||||
|
export function initPostHog(): void {
|
||||||
|
if (enabled || !TOKEN || !HOST) return;
|
||||||
|
|
||||||
|
posthog.init(TOKEN, {
|
||||||
|
api_host: HOST,
|
||||||
|
defaults: "2026-05-30",
|
||||||
|
|
||||||
|
// Debuggability, not analytics.
|
||||||
|
autocapture: false,
|
||||||
|
capture_pageview: true,
|
||||||
|
capture_pageleave: true,
|
||||||
|
disable_surveys: true,
|
||||||
|
person_profiles: "identified_only",
|
||||||
|
|
||||||
|
capture_exceptions: {
|
||||||
|
capture_unhandled_errors: true,
|
||||||
|
capture_unhandled_rejections: true,
|
||||||
|
capture_console_errors: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Off by default; this is half the point of the integration.
|
||||||
|
enable_recording_console_log: true,
|
||||||
|
|
||||||
|
// Inputs are masked; rendered text stays visible so replays are readable.
|
||||||
|
// Wrap sensitive elements in `ph-no-capture` to blank them individually.
|
||||||
|
session_recording: { maskAllInputs: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Portal and backoffice share one PostHog project — filter by this.
|
||||||
|
posthog.register({ app: APP });
|
||||||
|
|
||||||
|
enabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function captureException(
|
||||||
|
error: unknown,
|
||||||
|
properties?: Record<string, unknown>,
|
||||||
|
): void {
|
||||||
|
if (!enabled) return;
|
||||||
|
posthog.captureException(error, properties);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Report a failed API call.
|
||||||
|
*
|
||||||
|
* Called from the axios interceptor rather than from `emitApiError`, which
|
||||||
|
* early-returns on suppressed paths (warehouse / first-mile / onboarding /
|
||||||
|
* auth) — hooking there would drop errors on exactly those pages.
|
||||||
|
*
|
||||||
|
* 5xx and network failures are real defects and go to Error tracking. 4xx is
|
||||||
|
* usually the server correctly rejecting input, so it is recorded as a plain
|
||||||
|
* event to keep the issue list signal-heavy.
|
||||||
|
*/
|
||||||
|
export function captureApiError(error: unknown): void {
|
||||||
|
if (!enabled) return;
|
||||||
|
|
||||||
|
const err = error as {
|
||||||
|
message?: string;
|
||||||
|
config?: { method?: string; url?: string };
|
||||||
|
response?: { status?: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
const status = err.response?.status;
|
||||||
|
const properties = {
|
||||||
|
api_status: status ?? null,
|
||||||
|
api_method: err.config?.method?.toUpperCase() ?? null,
|
||||||
|
api_path: err.config?.url ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (status && status < 500) {
|
||||||
|
posthog.capture("api_error", properties);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
posthog.captureException(error, properties);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identify the current user to PostHog so replays and exceptions are
|
||||||
|
* attributable.
|
||||||
|
*
|
||||||
|
* Deliberately sends NO contact details. `AuthUser` carries email, phoneNumber,
|
||||||
|
* name and username; these are railway staff, and debugging a replay never
|
||||||
|
* requires knowing how to phone the person in it.
|
||||||
|
*/
|
||||||
|
export function useIdentify(user: AuthUser | null): void {
|
||||||
|
const employee = user?.employee?.[0];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
|
||||||
|
if (!user?.id) {
|
||||||
|
posthog.reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
posthog.identify(user.id, {
|
||||||
|
roles: user.roles?.map((role) => role.key ?? role.id),
|
||||||
|
status: user.status,
|
||||||
|
is_super_admin: user.isSuperAdmin,
|
||||||
|
organization_id: employee?.organizationId,
|
||||||
|
unit_id: employee?.unitId,
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
user?.id,
|
||||||
|
user?.roles,
|
||||||
|
user?.status,
|
||||||
|
user?.isSuperAdmin,
|
||||||
|
employee?.organizationId,
|
||||||
|
employee?.unitId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ import { StrictMode } from "react";
|
|||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter } from "react-router-dom";
|
||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
import posthog from "posthog-js";
|
||||||
|
import { PostHogProvider } from "@posthog/react";
|
||||||
import "@mantine/core/styles.css";
|
import "@mantine/core/styles.css";
|
||||||
import "@mantine/dates/styles.css";
|
import "@mantine/dates/styles.css";
|
||||||
import "@edr/ui-common/styles.css";
|
import "@edr/ui-common/styles.css";
|
||||||
@@ -19,6 +21,7 @@ import { ApiErrorModal } from "./components/errors/ApiErrorModal";
|
|||||||
import { ErrorBoundary } from "./components/ErrorBoundary";
|
import { ErrorBoundary } from "./components/ErrorBoundary";
|
||||||
import { AuthProvider } from "./auth/AuthProvider";
|
import { AuthProvider } from "./auth/AuthProvider";
|
||||||
import { queryClient } from "./lib/queryClient";
|
import { queryClient } from "./lib/queryClient";
|
||||||
|
import { initPostHog } from "./lib/posthog";
|
||||||
import { freightMantineTheme } from "./theme/freight-brand";
|
import { freightMantineTheme } from "./theme/freight-brand";
|
||||||
import { QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
|
||||||
@@ -43,6 +46,10 @@ const applyStoredTheme = () => {
|
|||||||
|
|
||||||
applyStoredTheme();
|
applyStoredTheme();
|
||||||
|
|
||||||
|
// Must run before render so replay and exception capture cover startup errors.
|
||||||
|
// No-ops when VITE_POSTHOG_KEY is unset.
|
||||||
|
initPostHog();
|
||||||
|
|
||||||
const rootElement = document.getElementById("root");
|
const rootElement = document.getElementById("root");
|
||||||
|
|
||||||
if (!rootElement) {
|
if (!rootElement) {
|
||||||
@@ -50,23 +57,25 @@ if (!rootElement) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
createRoot(rootElement).render(
|
createRoot(rootElement).render(
|
||||||
<QueryClientProvider client={queryClient}>
|
<PostHogProvider client={posthog}>
|
||||||
<MantineProvider theme={freightMantineTheme}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<StrictMode>
|
<MantineProvider theme={freightMantineTheme}>
|
||||||
<BrowserRouter>
|
<StrictMode>
|
||||||
<AuthProvider>
|
<BrowserRouter>
|
||||||
<ErrorBoundary>
|
<AuthProvider>
|
||||||
<App />
|
<ErrorBoundary>
|
||||||
</ErrorBoundary>
|
<App />
|
||||||
{/* Global API error modal — shows the server's actual error
|
</ErrorBoundary>
|
||||||
message (suppressed on warehouse / mile / onboarding pages). */}
|
{/* Global API error modal — shows the server's actual error
|
||||||
<ApiErrorModal />
|
message (suppressed on warehouse / mile / onboarding pages). */}
|
||||||
<Toaster position="top-right" />
|
<ApiErrorModal />
|
||||||
</AuthProvider>
|
<Toaster position="top-right" />
|
||||||
</BrowserRouter>
|
</AuthProvider>
|
||||||
</StrictMode>
|
</BrowserRouter>
|
||||||
</MantineProvider>
|
</StrictMode>
|
||||||
</QueryClientProvider>
|
</MantineProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</PostHogProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
// run
|
// run
|
||||||
@@ -110,6 +110,13 @@ export default function ContractClearanceDetailPage() {
|
|||||||
const { data: bookingMilestones, refetch: refetchBookingMilestones } =
|
const { data: bookingMilestones, refetch: refetchBookingMilestones } =
|
||||||
useBookingMilestones(linkedBookingId);
|
useBookingMilestones(linkedBookingId);
|
||||||
|
|
||||||
|
// react-query's imperative refetch() ignores `enabled`, so calling it while
|
||||||
|
// linkedBookingId is still undefined (pre-booking clearance) would fire
|
||||||
|
// GET /contracts/bookings/undefined/milestones → 400 (uuid expected). Guard it.
|
||||||
|
const refetchBookingMilestonesIfLinked = () => {
|
||||||
|
if (linkedBookingId) void refetchBookingMilestones();
|
||||||
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
@@ -261,7 +268,7 @@ export default function ContractClearanceDetailPage() {
|
|||||||
onChanged={() => {
|
onChanged={() => {
|
||||||
void refetch();
|
void refetch();
|
||||||
void refetchContract();
|
void refetchContract();
|
||||||
void refetchBookingMilestones();
|
refetchBookingMilestonesIfLinked();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
@@ -279,7 +286,7 @@ export default function ContractClearanceDetailPage() {
|
|||||||
roleMode="ET"
|
roleMode="ET"
|
||||||
onChanged={() => {
|
onChanged={() => {
|
||||||
void refetch();
|
void refetch();
|
||||||
void refetchBookingMilestones();
|
refetchBookingMilestonesIfLinked();
|
||||||
}}
|
}}
|
||||||
onViewFile={view}
|
onViewFile={view}
|
||||||
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
|
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
|
||||||
|
|||||||
@@ -105,6 +105,13 @@ export default function GlClearanceDetailPage() {
|
|||||||
const { data: bookingMilestones, refetch: refetchBookingMilestones } =
|
const { data: bookingMilestones, refetch: refetchBookingMilestones } =
|
||||||
useBookingMilestones(linkedBookingId);
|
useBookingMilestones(linkedBookingId);
|
||||||
|
|
||||||
|
// react-query's imperative refetch() ignores `enabled`, so calling it while
|
||||||
|
// linkedBookingId is still undefined (pre-booking clearance) would fire
|
||||||
|
// GET /contracts/bookings/undefined/milestones → 400 (uuid expected). Guard it.
|
||||||
|
const refetchBookingMilestonesIfLinked = () => {
|
||||||
|
if (linkedBookingId) void refetchBookingMilestones();
|
||||||
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
@@ -275,7 +282,7 @@ export default function GlClearanceDetailPage() {
|
|||||||
onUploadRoRequest={() => setUploadKind("ro")}
|
onUploadRoRequest={() => setUploadKind("ro")}
|
||||||
onChanged={() => {
|
onChanged={() => {
|
||||||
void refetch();
|
void refetch();
|
||||||
void refetchBookingMilestones();
|
refetchBookingMilestonesIfLinked();
|
||||||
}}
|
}}
|
||||||
onViewFile={view}
|
onViewFile={view}
|
||||||
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
|
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Alert, Badge, Button, Modal, Stack, Table, Text } from "@mantine/core";
|
||||||
|
import { FileText } from "lucide-react";
|
||||||
|
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { warehouseService } from "@/services/warehouse.service";
|
||||||
|
import type { LastMileRecord } from "@/services/last-mile.service";
|
||||||
|
import { extractDownloadErrorMessage } from "@/components/warehouses/options";
|
||||||
|
import { openPdfBlob } from "@/components/warehouses/pdf";
|
||||||
|
|
||||||
|
interface EdrTruckExitPapersModalProps {
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
record: LastMileRecord | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmt = (value?: string | null) =>
|
||||||
|
value ? new Date(value).toLocaleString() : "—";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-truck exit papers for an EDR last-mile delivery. Each assigned truck has
|
||||||
|
* its own arrival, exit and weighed load, so each gets its own paper.
|
||||||
|
*/
|
||||||
|
export function EdrTruckExitPapersModal({ opened, onClose, record }: EdrTruckExitPapersModalProps) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
|
const trucks = record?.vehicleAssignments ?? [];
|
||||||
|
|
||||||
|
const download = async (assignmentId: string, plate: string) => {
|
||||||
|
setBusyId(assignmentId);
|
||||||
|
try {
|
||||||
|
const res = await warehouseService.downloadEdrTruckExitPaper(assignmentId);
|
||||||
|
openPdfBlob(res.data, `exit-${plate || assignmentId}.pdf`);
|
||||||
|
} catch (e) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Exit paper not ready",
|
||||||
|
description: await extractDownloadErrorMessage(e),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={onClose}
|
||||||
|
centered
|
||||||
|
radius="lg"
|
||||||
|
size="lg"
|
||||||
|
title={
|
||||||
|
<Text fw={600}>
|
||||||
|
Truck exit papers {record?.booking?.reference ? `· ${record.booking.reference}` : ""}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{trucks.length === 0 ? (
|
||||||
|
<Alert variant="light" color="gray">
|
||||||
|
No trucks assigned to this delivery yet.
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Table verticalSpacing="xs" highlightOnHover>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Truck</Table.Th>
|
||||||
|
<Table.Th>Containers</Table.Th>
|
||||||
|
<Table.Th>Arrived</Table.Th>
|
||||||
|
<Table.Th>Left</Table.Th>
|
||||||
|
<Table.Th ta="right">Net</Table.Th>
|
||||||
|
<Table.Th ta="right">Exit paper</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{trucks.map((t) => {
|
||||||
|
const plate = t.vehicle?.powerPlateNo || t.vehicle?.plateNumber || "—";
|
||||||
|
const load = t.containers?.length
|
||||||
|
? t.containers.map((c) => c.containerNumber).join(", ")
|
||||||
|
: (t.containerNumber ?? "bulk");
|
||||||
|
return (
|
||||||
|
<Table.Tr key={t.id}>
|
||||||
|
<Table.Td>
|
||||||
|
<Text fw={600} size="sm">{plate}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm">{load}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm">{fmt(t.arrivedAt)}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
{t.departedAt ? (
|
||||||
|
<Text size="sm">{fmt(t.departedAt)}</Text>
|
||||||
|
) : (
|
||||||
|
<Badge size="sm" variant="light" color="gray">
|
||||||
|
Still on site
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="right">
|
||||||
|
<Text size="sm">
|
||||||
|
{t.netWeightTons != null ? `${t.netWeightTons} t` : "—"}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="right">
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
variant="light"
|
||||||
|
color="orange"
|
||||||
|
leftSection={<FileText size={13} />}
|
||||||
|
loading={busyId === t.id}
|
||||||
|
onClick={() => download(t.id, plate)}
|
||||||
|
>
|
||||||
|
Exit Paper
|
||||||
|
</Button>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
EDR handovers are generated at delivery, so an exit paper is not gated on a
|
||||||
|
signature — warehouse-fee clearance still applies.
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -208,6 +208,20 @@ const billingIssues = (r: FirstMileRecord) => {
|
|||||||
];
|
];
|
||||||
return { zeroPrice, mixedCurrency: currencies.length > 1, currencies };
|
return { zeroPrice, mixedCurrency: currencies.length > 1, currencies };
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* The mile bills as distance × pricePerKm in the vehicle's own currency, so a
|
||||||
|
* vehicle missing either field cannot produce an invoice line. Returns the
|
||||||
|
* human-readable gap, or null when the vehicle is billable.
|
||||||
|
*/
|
||||||
|
const pricingGap = (
|
||||||
|
v?: { pricePerKm?: number | string | null; currency?: string | null } | null,
|
||||||
|
): string | null => {
|
||||||
|
if (!v) return null;
|
||||||
|
const missing: string[] = [];
|
||||||
|
if (!(Number(v.pricePerKm) > 0)) missing.push("Price per KM");
|
||||||
|
if (!String(v.currency ?? "").trim()) missing.push("Currency");
|
||||||
|
return missing.length ? missing.join(" and ") : null;
|
||||||
|
};
|
||||||
const customerName = (r: FirstMileRecord) => r.booking?.company?.name ?? "—";
|
const customerName = (r: FirstMileRecord) => r.booking?.company?.name ?? "—";
|
||||||
const pickupLocation = (r: FirstMileRecord) => r.booking?.firstMilePickupAddress ?? "—";
|
const pickupLocation = (r: FirstMileRecord) => r.booking?.firstMilePickupAddress ?? "—";
|
||||||
const cargoDesc = (r: FirstMileRecord) => {
|
const cargoDesc = (r: FirstMileRecord) => {
|
||||||
@@ -681,6 +695,22 @@ const FirstMilePage = () => {
|
|||||||
return opts;
|
return opts;
|
||||||
}, [vehicleOptions, activeRecord]);
|
}, [vehicleOptions, activeRecord]);
|
||||||
|
|
||||||
|
// Pricing gap per vehicle id — an unpriced vehicle is blocked from assignment
|
||||||
|
// below rather than silently billing 0 once distances are entered.
|
||||||
|
const pricingGapById = useMemo(() => {
|
||||||
|
const map = new Map<string, string | null>();
|
||||||
|
const add = (v?: { id: string; pricePerKm?: number | string | null; currency?: string | null } | null) => {
|
||||||
|
if (v?.id) map.set(v.id, pricingGap(v));
|
||||||
|
};
|
||||||
|
for (const v of Array.isArray(vehiclesData) ? vehiclesData : []) add(v);
|
||||||
|
for (const a of activeRecord?.vehicleAssignments ?? []) add(a.vehicle);
|
||||||
|
add(activeRecord?.vehicle);
|
||||||
|
return map;
|
||||||
|
}, [vehiclesData, activeRecord]);
|
||||||
|
|
||||||
|
const vehicleLabelFor = (id: string) =>
|
||||||
|
assignVehicleOptions.find((o) => o.value === id)?.label ?? id;
|
||||||
|
|
||||||
// Full booking (with container units) for the assign modal's container dropdown.
|
// Full booking (with container units) for the assign modal's container dropdown.
|
||||||
// Fetched on open so container numbers show regardless of what the list embeds.
|
// Fetched on open so container numbers show regardless of what the list embeds.
|
||||||
const { data: assignBooking } = useQuery({
|
const { data: assignBooking } = useQuery({
|
||||||
@@ -932,6 +962,21 @@ const FirstMilePage = () => {
|
|||||||
|
|
||||||
if (!targetIds.length) return;
|
if (!targetIds.length) return;
|
||||||
|
|
||||||
|
// Backstop for rows the Select guard never saw (pre-filled reassignments).
|
||||||
|
const unpriced = vehicles
|
||||||
|
.map((v) => ({ label: vehicleLabelFor(v.vehicleId), gap: pricingGapById.get(v.vehicleId) }))
|
||||||
|
.filter((v): v is { label: string; gap: string } => Boolean(v.gap));
|
||||||
|
if (unpriced.length) {
|
||||||
|
toast({
|
||||||
|
title: "Vehicle is not priced",
|
||||||
|
description: `${unpriced
|
||||||
|
.map((v) => `${v.label} (${v.gap} not set)`)
|
||||||
|
.join("; ")} — set it on the vehicle before assigning.`,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Empty set = unassign all (setVehicles releases the removed vehicles).
|
// Empty set = unassign all (setVehicles releases the removed vehicles).
|
||||||
Promise.all(targetIds.map((id) => setVehiclesMutation.mutateAsync({ id, vehicles })))
|
Promise.all(targetIds.map((id) => setVehiclesMutation.mutateAsync({ id, vehicles })))
|
||||||
.then(() => {
|
.then(() => {
|
||||||
@@ -1365,9 +1410,18 @@ const FirstMilePage = () => {
|
|||||||
(o) => o.value === row.vehicleId || !vehicleRows.some((r) => r.vehicleId === o.value),
|
(o) => o.value === row.vehicleId || !vehicleRows.some((r) => r.vehicleId === o.value),
|
||||||
)}
|
)}
|
||||||
value={row.vehicleId}
|
value={row.vehicleId}
|
||||||
onChange={(v) =>
|
onChange={(v) => {
|
||||||
setVehicleRows((prev) => prev.map((x, idx) => (idx === i ? { ...x, vehicleId: v } : x)))
|
const gap = v ? pricingGapById.get(v) : null;
|
||||||
}
|
if (v && gap) {
|
||||||
|
toast({
|
||||||
|
title: "Vehicle is not priced",
|
||||||
|
description: `${vehicleLabelFor(v)} — ${gap} not set. Set it on the vehicle before assigning.`,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setVehicleRows((prev) => prev.map((x, idx) => (idx === i ? { ...x, vehicleId: v } : x)));
|
||||||
|
}}
|
||||||
searchable
|
searchable
|
||||||
clearable
|
clearable
|
||||||
disabled={assignVehicleOptions.length === 0}
|
disabled={assignVehicleOptions.length === 0}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
RefreshCw,
|
RefreshCw,
|
||||||
Ruler,
|
Ruler,
|
||||||
Trash,
|
Trash,
|
||||||
|
FileText,
|
||||||
Truck,
|
Truck,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -56,6 +57,7 @@ import {
|
|||||||
import { vehiclesService } from "@/services/vehicles.service";
|
import { vehiclesService } from "@/services/vehicles.service";
|
||||||
import { driversService, type Driver } from "@/services/drivers.service";
|
import { driversService, type Driver } from "@/services/drivers.service";
|
||||||
import { ReleaseOrderModal, type ReleaseOrderTruckPrefill } from "@/components/warehouses/ReleaseOrderModal";
|
import { ReleaseOrderModal, type ReleaseOrderTruckPrefill } from "@/components/warehouses/ReleaseOrderModal";
|
||||||
|
import { EdrTruckExitPapersModal } from "./EdrTruckExitPapersModal";
|
||||||
import { TruckDetentionModal } from "@/components/operations/TruckDetentionModal";
|
import { TruckDetentionModal } from "@/components/operations/TruckDetentionModal";
|
||||||
import { ProofOfDeliveryModal } from "@/components/operations/ProofOfDeliveryModal";
|
import { ProofOfDeliveryModal } from "@/components/operations/ProofOfDeliveryModal";
|
||||||
import { LastMileStepper, type LastMileStepState } from "@/components/operations/LastMileSteps";
|
import { LastMileStepper, type LastMileStepState } from "@/components/operations/LastMileSteps";
|
||||||
@@ -124,10 +126,22 @@ const containerCount = (record: LastMileRecord) =>
|
|||||||
(sum, c) => sum + (Number(c.quantity) || 0),
|
(sum, c) => sum + (Number(c.quantity) || 0),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
/** Trucks needed for a booking = ceil(containers / 2). 0 when no container data. */
|
/**
|
||||||
|
* Trucks needed for a booking, by container SIZE: a 40ft fills a truck (1 each),
|
||||||
|
* two 20ft share one. Falls back to ceil(n / 2) when no size is recorded.
|
||||||
|
* 0 when the booking has no container data (bulk).
|
||||||
|
*/
|
||||||
const requiredVehicles = (record: LastMileRecord) => {
|
const requiredVehicles = (record: LastMileRecord) => {
|
||||||
const n = containerCount(record);
|
const lines = record.booking?.bookingContainers ?? [];
|
||||||
return n > 0 ? Math.ceil(n / CONTAINERS_PER_VEHICLE) : 0;
|
if (!containerCount(record)) return 0;
|
||||||
|
let forty = 0;
|
||||||
|
let others = 0;
|
||||||
|
for (const c of lines) {
|
||||||
|
const qty = Number(c.quantity) || 0;
|
||||||
|
if ((c.containerSize ?? '').includes('40')) forty += qty;
|
||||||
|
else others += qty;
|
||||||
|
}
|
||||||
|
return forty + Math.ceil(others / CONTAINERS_PER_VEHICLE);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Real per-physical-container numbers on a booking, in order. Prefers each
|
/** Real per-physical-container numbers on a booking, in order. Prefers each
|
||||||
@@ -251,6 +265,20 @@ const billingIssues = (r: LastMileRecord) => {
|
|||||||
];
|
];
|
||||||
return { zeroPrice, mixedCurrency: currencies.length > 1, currencies };
|
return { zeroPrice, mixedCurrency: currencies.length > 1, currencies };
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* The mile bills as distance × pricePerKm in the vehicle's own currency, so a
|
||||||
|
* vehicle missing either field cannot produce an invoice line. Returns the
|
||||||
|
* human-readable gap, or null when the vehicle is billable.
|
||||||
|
*/
|
||||||
|
const pricingGap = (
|
||||||
|
v?: { pricePerKm?: number | string | null; currency?: string | null } | null,
|
||||||
|
): string | null => {
|
||||||
|
if (!v) return null;
|
||||||
|
const missing: string[] = [];
|
||||||
|
if (!(Number(v.pricePerKm) > 0)) missing.push("Price per KM");
|
||||||
|
if (!String(v.currency ?? "").trim()) missing.push("Currency");
|
||||||
|
return missing.length ? missing.join(" and ") : null;
|
||||||
|
};
|
||||||
const customerName = (r: LastMileRecord) => r.booking?.company?.name ?? "—";
|
const customerName = (r: LastMileRecord) => r.booking?.company?.name ?? "—";
|
||||||
const deliveryLocation = (r: LastMileRecord) => r.booking?.lastMileDeliveryAddress ?? "—";
|
const deliveryLocation = (r: LastMileRecord) => r.booking?.lastMileDeliveryAddress ?? "—";
|
||||||
const cargoDesc = (r: LastMileRecord) => {
|
const cargoDesc = (r: LastMileRecord) => {
|
||||||
@@ -567,9 +595,11 @@ const LastMilePage = () => {
|
|||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
const [detentionRecord, setDetentionRecord] = useState<LastMileRecord | null>(null);
|
const [detentionRecord, setDetentionRecord] = useState<LastMileRecord | null>(null);
|
||||||
// Multi-vehicle assign: one row per truck — vehicle + the container it carries.
|
// Multi-vehicle assign: one row per truck — vehicle + the container it carries.
|
||||||
|
// One row per truck. A truck carries one 40ft or up to two 20ft, so the load
|
||||||
|
// is a list, not a single container.
|
||||||
const [vehicleRows, setVehicleRows] = useState<
|
const [vehicleRows, setVehicleRows] = useState<
|
||||||
Array<{ vehicleId: string | null; containerNumber: string }>
|
Array<{ vehicleId: string | null; containerNumbers: string[] }>
|
||||||
>([{ vehicleId: null, containerNumber: "" }]);
|
>([{ vehicleId: null, containerNumbers: [] }]);
|
||||||
|
|
||||||
// 2-step "Assign Mile" accept modal (arrival queue → vehicle)
|
// 2-step "Assign Mile" accept modal (arrival queue → vehicle)
|
||||||
const [acceptOpen, setAcceptOpen] = useState(false);
|
const [acceptOpen, setAcceptOpen] = useState(false);
|
||||||
@@ -870,6 +900,22 @@ const LastMilePage = () => {
|
|||||||
return opts;
|
return opts;
|
||||||
}, [vehicleOptions, activeRecord]);
|
}, [vehicleOptions, activeRecord]);
|
||||||
|
|
||||||
|
// Pricing gap per vehicle id — an unpriced vehicle is blocked from assignment
|
||||||
|
// below rather than silently billing 0 once distances are entered.
|
||||||
|
const pricingGapById = useMemo(() => {
|
||||||
|
const map = new Map<string, string | null>();
|
||||||
|
const add = (v?: { id: string; pricePerKm?: number | string | null; currency?: string | null } | null) => {
|
||||||
|
if (v?.id) map.set(v.id, pricingGap(v));
|
||||||
|
};
|
||||||
|
for (const v of Array.isArray(vehiclesData) ? vehiclesData : []) add(v);
|
||||||
|
for (const a of activeRecord?.vehicleAssignments ?? []) add(a.vehicle);
|
||||||
|
add(activeRecord?.vehicle);
|
||||||
|
return map;
|
||||||
|
}, [vehiclesData, activeRecord]);
|
||||||
|
|
||||||
|
const vehicleLabelFor = (id: string) =>
|
||||||
|
assignVehicleOptions.find((o) => o.value === id)?.label ?? id;
|
||||||
|
|
||||||
// Full booking (with container units) for the assign modal's container dropdown.
|
// Full booking (with container units) for the assign modal's container dropdown.
|
||||||
// Fetched on open so container numbers show regardless of what the list embeds.
|
// Fetched on open so container numbers show regardless of what the list embeds.
|
||||||
const { data: assignBooking } = useQuery({
|
const { data: assignBooking } = useQuery({
|
||||||
@@ -970,31 +1016,59 @@ const LastMilePage = () => {
|
|||||||
return filteredRecords.slice(start, start + pagination.pageSize);
|
return filteredRecords.slice(start, start + pagination.pageSize);
|
||||||
}, [filteredRecords, pagination]);
|
}, [filteredRecords, pagination]);
|
||||||
|
|
||||||
|
// Per-truck exit papers for an EDR delivery (one paper per assigned truck).
|
||||||
|
const [exitPapersOpen, setExitPapersOpen] = useState(false);
|
||||||
|
const [exitPapersRecord, setExitPapersRecord] = useState<LastMileRecord | null>(null);
|
||||||
|
|
||||||
|
// Bulk drawdown: how much tonnage is still to be hauled on the booking being
|
||||||
|
// assigned. Bulk has no containers, so trucks keep going until this hits 0.
|
||||||
|
const assignBookingId = activeRecord?.booking?.id ?? null;
|
||||||
|
const { data: remainingTons } = useQuery({
|
||||||
|
queryKey: ["last-mile", "remaining-tons", assignBookingId],
|
||||||
|
queryFn: () => lastMileService.remainingTons(assignBookingId as string).then((r) => r.data),
|
||||||
|
enabled: assignOpen && !bulkMode && Boolean(assignBookingId),
|
||||||
|
});
|
||||||
|
|
||||||
const openAssign = (id: string | null) => {
|
const openAssign = (id: string | null) => {
|
||||||
const resolved = id ?? filteredRecords.find((r) => !isAssigned(r))?.id ?? null;
|
const resolved = id ?? filteredRecords.find((r) => !isAssigned(r))?.id ?? null;
|
||||||
const rec = records.find((r) => r.id === resolved);
|
const rec = records.find((r) => r.id === resolved);
|
||||||
// Prefill each row's container number from the booking's container numbers
|
// Prefill each row's container number from the booking's container numbers
|
||||||
// (by order) when the assignment doesn't already carry one.
|
// (by order) when the assignment doesn't already carry one.
|
||||||
const nums = rec ? bookingContainerNumbers(rec) : [];
|
const nums = rec ? bookingContainerNumbers(rec) : [];
|
||||||
|
// Prefer the truck's own container list; fall back to the legacy scalar, then
|
||||||
|
// to the booking's containers by order.
|
||||||
|
const loadOf = (
|
||||||
|
a: { containers?: Array<{ containerNumber: string }>; containerNumber?: string | null },
|
||||||
|
i: number,
|
||||||
|
) =>
|
||||||
|
a.containers?.length
|
||||||
|
? a.containers.map((c) => c.containerNumber)
|
||||||
|
: a.containerNumber
|
||||||
|
? [a.containerNumber]
|
||||||
|
: nums[i]
|
||||||
|
? [nums[i]]
|
||||||
|
: [];
|
||||||
const rows =
|
const rows =
|
||||||
rec?.vehicleAssignments?.length
|
rec?.vehicleAssignments?.length
|
||||||
? rec.vehicleAssignments.map((a, i) => ({
|
? rec.vehicleAssignments.map((a, i) => ({
|
||||||
vehicleId: a.vehicleId,
|
vehicleId: a.vehicleId,
|
||||||
containerNumber: a.containerNumber ?? nums[i] ?? "",
|
containerNumbers: loadOf(a, i),
|
||||||
}))
|
}))
|
||||||
: rec?.vehicleId
|
: rec?.vehicleId
|
||||||
? [{ vehicleId: rec.vehicleId, containerNumber: nums[0] ?? "" }]
|
? [{ vehicleId: rec.vehicleId, containerNumbers: nums[0] ? [nums[0]] : [] }]
|
||||||
: [{ vehicleId: null, containerNumber: nums[0] ?? "" }];
|
: [{ vehicleId: null, containerNumbers: nums[0] ? [nums[0]] : [] }];
|
||||||
setBulkMode(false);
|
setBulkMode(false);
|
||||||
setActiveId(resolved);
|
setActiveId(resolved);
|
||||||
setVehicleRows(rows.length ? rows : [{ vehicleId: null, containerNumber: nums[0] ?? "" }]);
|
setVehicleRows(
|
||||||
|
rows.length ? rows : [{ vehicleId: null, containerNumbers: nums[0] ? [nums[0]] : [] }],
|
||||||
|
);
|
||||||
setAssignOpen(true);
|
setAssignOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openBulkAssign = () => {
|
const openBulkAssign = () => {
|
||||||
setBulkMode(true);
|
setBulkMode(true);
|
||||||
setActiveId(null);
|
setActiveId(null);
|
||||||
setVehicleRows([{ vehicleId: null, containerNumber: "" }]);
|
setVehicleRows([{ vehicleId: null, containerNumbers: [] }]);
|
||||||
setAssignOpen(true);
|
setAssignOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1002,15 +1076,18 @@ const LastMilePage = () => {
|
|||||||
setAssignOpen(false);
|
setAssignOpen(false);
|
||||||
setBulkMode(false);
|
setBulkMode(false);
|
||||||
setActiveId(null);
|
setActiveId(null);
|
||||||
setVehicleRows([{ vehicleId: null, containerNumber: "" }]);
|
setVehicleRows([{ vehicleId: null, containerNumbers: [] }]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAssign = () => {
|
const handleAssign = () => {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const vehicles = vehicleRows
|
const vehicles = vehicleRows
|
||||||
.filter((r): r is { vehicleId: string; containerNumber: string } => Boolean(r.vehicleId))
|
.filter((r): r is { vehicleId: string; containerNumbers: string[] } => Boolean(r.vehicleId))
|
||||||
.filter((r) => (seen.has(r.vehicleId) ? false : seen.add(r.vehicleId)))
|
.filter((r) => (seen.has(r.vehicleId) ? false : seen.add(r.vehicleId)))
|
||||||
.map((r) => ({ vehicleId: r.vehicleId, containerNumber: r.containerNumber.trim() || null }));
|
.map((r) => ({
|
||||||
|
vehicleId: r.vehicleId,
|
||||||
|
containerNumbers: r.containerNumbers.map((n) => n.trim()).filter(Boolean),
|
||||||
|
}));
|
||||||
const count = vehicles.length;
|
const count = vehicles.length;
|
||||||
const targetIds = bulkMode
|
const targetIds = bulkMode
|
||||||
? selectedIds
|
? selectedIds
|
||||||
@@ -1018,6 +1095,21 @@ const LastMilePage = () => {
|
|||||||
|
|
||||||
if (!targetIds.length) return;
|
if (!targetIds.length) return;
|
||||||
|
|
||||||
|
// Backstop for rows the Select guard never saw (pre-filled reassignments).
|
||||||
|
const unpriced = vehicles
|
||||||
|
.map((v) => ({ label: vehicleLabelFor(v.vehicleId), gap: pricingGapById.get(v.vehicleId) }))
|
||||||
|
.filter((v): v is { label: string; gap: string } => Boolean(v.gap));
|
||||||
|
if (unpriced.length) {
|
||||||
|
toast({
|
||||||
|
title: "Vehicle is not priced",
|
||||||
|
description: `${unpriced
|
||||||
|
.map((v) => `${v.label} (${v.gap} not set)`)
|
||||||
|
.join("; ")} — set it on the vehicle before assigning.`,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Empty set = unassign all (setVehicles releases the removed vehicles).
|
// Empty set = unassign all (setVehicles releases the removed vehicles).
|
||||||
Promise.all(targetIds.map((id) => setVehiclesMutation.mutateAsync({ id, vehicles })))
|
Promise.all(targetIds.map((id) => setVehiclesMutation.mutateAsync({ id, vehicles })))
|
||||||
.then(() => {
|
.then(() => {
|
||||||
@@ -1374,6 +1466,16 @@ const LastMilePage = () => {
|
|||||||
>
|
>
|
||||||
Truck Leaving
|
Truck Leaving
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<FileText size={15} />}
|
||||||
|
disabled={!row.original.vehicleAssignments?.length}
|
||||||
|
onClick={() => {
|
||||||
|
setExitPapersRecord(row.original);
|
||||||
|
setExitPapersOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Truck exit papers
|
||||||
|
</Menu.Item>
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<Eye size={15} />}
|
leftSection={<Eye size={15} />}
|
||||||
@@ -1693,9 +1795,24 @@ const LastMilePage = () => {
|
|||||||
const needed = requiredVehicles(activeRecord);
|
const needed = requiredVehicles(activeRecord);
|
||||||
const picked = vehicleRows.filter((r) => r.vehicleId).length;
|
const picked = vehicleRows.filter((r) => r.vehicleId).length;
|
||||||
if (needed === 0) {
|
if (needed === 0) {
|
||||||
|
// Bulk: no containers — trucks haul loose tonnage until the
|
||||||
|
// booking's total is drawn down to zero by departing trucks.
|
||||||
|
const done = remainingTons?.complete;
|
||||||
return (
|
return (
|
||||||
<Alert variant="light" color="gray" title="One truck (with trailer) carries 2 containers">
|
<Alert
|
||||||
No container count on this booking — assign trucks as needed.
|
variant="light"
|
||||||
|
color={done ? "green" : remainingTons ? "blue" : "gray"}
|
||||||
|
title={
|
||||||
|
remainingTons
|
||||||
|
? `${remainingTons.remainingTons} t remaining of ${remainingTons.totalTons} t`
|
||||||
|
: "No container count on this booking"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{remainingTons
|
||||||
|
? done
|
||||||
|
? "Fully hauled — no tonnage left to assign trucks for."
|
||||||
|
: `Bulk booking: ${remainingTons.hauledTons} t hauled so far. Keep assigning trucks until the remaining tonnage reaches 0 — each truck's net weight is deducted when it leaves.`
|
||||||
|
: "Assign trucks as needed."}
|
||||||
</Alert>
|
</Alert>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1738,32 +1855,43 @@ const LastMilePage = () => {
|
|||||||
(o) => o.value === row.vehicleId || !vehicleRows.some((r) => r.vehicleId === o.value),
|
(o) => o.value === row.vehicleId || !vehicleRows.some((r) => r.vehicleId === o.value),
|
||||||
)}
|
)}
|
||||||
value={row.vehicleId}
|
value={row.vehicleId}
|
||||||
onChange={(v) =>
|
onChange={(v) => {
|
||||||
setVehicleRows((prev) => prev.map((x, idx) => (idx === i ? { ...x, vehicleId: v } : x)))
|
const gap = v ? pricingGapById.get(v) : null;
|
||||||
}
|
if (v && gap) {
|
||||||
|
toast({
|
||||||
|
title: "Vehicle is not priced",
|
||||||
|
description: `${vehicleLabelFor(v)} — ${gap} not set. Set it on the vehicle before assigning.`,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setVehicleRows((prev) => prev.map((x, idx) => (idx === i ? { ...x, vehicleId: v } : x)));
|
||||||
|
}}
|
||||||
searchable
|
searchable
|
||||||
clearable
|
clearable
|
||||||
disabled={assignVehicleOptions.length === 0}
|
disabled={assignVehicleOptions.length === 0}
|
||||||
/>
|
/>
|
||||||
<Select
|
<MultiSelect
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
label={i === 0 ? "Container no." : undefined}
|
label={i === 0 ? "Containers (1x40ft or 2x20ft)" : undefined}
|
||||||
placeholder={containerOptions.length ? "Select container" : "No container numbers"}
|
placeholder={containerOptions.length ? "Select containers" : "No container numbers"}
|
||||||
|
// A truck takes at most two containers; a 40ft fills it (the
|
||||||
|
// API rejects a 40ft paired with anything).
|
||||||
|
maxValues={2}
|
||||||
data={[
|
data={[
|
||||||
...containerOptions.filter(
|
...containerOptions.filter(
|
||||||
(n) =>
|
(n) =>
|
||||||
n === row.containerNumber ||
|
row.containerNumbers.includes(n) ||
|
||||||
!vehicleRows.some((r, idx) => idx !== i && r.containerNumber === n),
|
// a container rides exactly one truck
|
||||||
|
!vehicleRows.some((r, idx) => idx !== i && r.containerNumbers.includes(n)),
|
||||||
),
|
),
|
||||||
// keep a manual/legacy value selectable even if not in the booking
|
// keep manual/legacy values selectable even if not in the booking
|
||||||
...(row.containerNumber && !containerOptions.includes(row.containerNumber)
|
...row.containerNumbers.filter((n) => !containerOptions.includes(n)),
|
||||||
? [row.containerNumber]
|
|
||||||
: []),
|
|
||||||
]}
|
]}
|
||||||
value={row.containerNumber || null}
|
value={row.containerNumbers}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
setVehicleRows((prev) =>
|
setVehicleRows((prev) =>
|
||||||
prev.map((x, idx) => (idx === i ? { ...x, containerNumber: value ?? "" } : x)),
|
prev.map((x, idx) => (idx === i ? { ...x, containerNumbers: value } : x)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
searchable
|
searchable
|
||||||
@@ -1786,14 +1914,14 @@ const LastMilePage = () => {
|
|||||||
size="xs"
|
size="xs"
|
||||||
leftSection={<Plus size={14} />}
|
leftSection={<Plus size={14} />}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setVehicleRows((prev) => [
|
setVehicleRows((prev) => {
|
||||||
...prev,
|
// Suggest the next unassigned container for the new truck.
|
||||||
{
|
const taken = new Set(prev.flatMap((r) => r.containerNumbers));
|
||||||
vehicleId: null,
|
const next = (activeRecord ? bookingContainerNumbers(activeRecord) : []).find(
|
||||||
containerNumber:
|
(n) => !taken.has(n),
|
||||||
(activeRecord ? bookingContainerNumbers(activeRecord)[prev.length] : "") ?? "",
|
);
|
||||||
},
|
return [...prev, { vehicleId: null, containerNumbers: next ? [next] : [] }];
|
||||||
])
|
})
|
||||||
}
|
}
|
||||||
disabled={
|
disabled={
|
||||||
assignVehicleOptions.length === 0 ||
|
assignVehicleOptions.length === 0 ||
|
||||||
@@ -2087,6 +2215,12 @@ const LastMilePage = () => {
|
|||||||
truckPrefill={releaseTruckPrefill}
|
truckPrefill={releaseTruckPrefill}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<EdrTruckExitPapersModal
|
||||||
|
opened={exitPapersOpen}
|
||||||
|
onClose={() => setExitPapersOpen(false)}
|
||||||
|
record={exitPapersRecord}
|
||||||
|
/>
|
||||||
|
|
||||||
<TruckDetentionModal
|
<TruckDetentionModal
|
||||||
opened={Boolean(detentionRecord)}
|
opened={Boolean(detentionRecord)}
|
||||||
onClose={() => setDetentionRecord(null)}
|
onClose={() => setDetentionRecord(null)}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import React, { Component, ErrorInfo, ReactNode } from "react";
|
import React, { Component, ErrorInfo, ReactNode } from "react";
|
||||||
|
|
||||||
|
import { captureException } from "@/lib/posthog";
|
||||||
|
|
||||||
interface ErrorBoundaryProps {
|
interface ErrorBoundaryProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
@@ -20,7 +22,8 @@ export class ErrorBoundary extends Component<
|
|||||||
}
|
}
|
||||||
|
|
||||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||||
// Log to console in development; replace with a reporting service (e.g. Sentry) in production
|
captureException(error, { componentStack: info.componentStack });
|
||||||
|
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
console.error("ErrorBoundary caught:", error, info.componentStack);
|
console.error("ErrorBoundary caught:", error, info.componentStack);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,8 +67,16 @@ export interface LastMileRecord {
|
|||||||
vehicleAssignments?: Array<{
|
vehicleAssignments?: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
vehicleId: string;
|
vehicleId: string;
|
||||||
|
/** @deprecated Legacy single container — `containers` is authoritative. */
|
||||||
containerNumber?: string | null;
|
containerNumber?: string | null;
|
||||||
|
/** Containers riding this truck: one 40ft, or up to two 20ft. */
|
||||||
|
containers?: Array<{ id: string; containerNumber: string }>;
|
||||||
distanceKm?: number | null;
|
distanceKm?: number | null;
|
||||||
|
/** Per-truck arrival / exit, stamped by the warehouse weighing steps. */
|
||||||
|
arrivedAt?: string | null;
|
||||||
|
departedAt?: string | null;
|
||||||
|
grossWeightTons?: number | null;
|
||||||
|
netWeightTons?: number | null;
|
||||||
vehicle?: LastMileVehicle | null;
|
vehicle?: LastMileVehicle | null;
|
||||||
}>;
|
}>;
|
||||||
/** Present only when an invoice has actually been generated (not on distance). */
|
/** Present only when an invoice has actually been generated (not on distance). */
|
||||||
@@ -99,8 +107,13 @@ export const lastMileService = {
|
|||||||
api.delete<void>(LM.BY_ID(id)),
|
api.delete<void>(LM.BY_ID(id)),
|
||||||
setVehicles: (
|
setVehicles: (
|
||||||
id: string,
|
id: string,
|
||||||
vehicles: Array<{ vehicleId: string; containerNumber?: string | null }>,
|
vehicles: Array<{ vehicleId: string; containerNumbers?: string[] }>,
|
||||||
) => api.post<LastMileRecord>(`${LM.BASE}/${id}/vehicles`, { vehicles }),
|
) => api.post<LastMileRecord>(`${LM.BASE}/${id}/vehicles`, { vehicles }),
|
||||||
|
/** Bulk drawdown: tonnage still to be hauled on this booking. */
|
||||||
|
remainingTons: (bookingId: string) =>
|
||||||
|
api.get<{ totalTons: number; hauledTons: number; remainingTons: number; complete: boolean }>(
|
||||||
|
`${LM.BASE}/booking/${bookingId}/remaining-tons`,
|
||||||
|
),
|
||||||
setDistances: (
|
setDistances: (
|
||||||
id: string,
|
id: string,
|
||||||
distances: Array<{ vehicleId: string; distanceKm: number }>,
|
distances: Array<{ vehicleId: string; distanceKm: number }>,
|
||||||
|
|||||||
@@ -323,6 +323,11 @@ export const warehouseService = {
|
|||||||
apiClient.get<Blob>(`/warehouse-inventory/customer-truck-exit-paper/${assignmentId}`, {
|
apiClient.get<Blob>(`/warehouse-inventory/customer-truck-exit-paper/${assignmentId}`, {
|
||||||
responseType: 'blob',
|
responseType: 'blob',
|
||||||
}),
|
}),
|
||||||
|
/** Per-truck exit paper PDF for an EDR last-mile truck. */
|
||||||
|
downloadEdrTruckExitPaper: (assignmentId: string) =>
|
||||||
|
apiClient.get<Blob>(`/warehouse-inventory/edr-truck-exit-paper/${assignmentId}`, {
|
||||||
|
responseType: 'blob',
|
||||||
|
}),
|
||||||
deliver: (id: string, payload: DeliverInventoryPayload) =>
|
deliver: (id: string, payload: DeliverInventoryPayload) =>
|
||||||
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.DELIVER(id), payload),
|
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.DELIVER(id), payload),
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ interface Window {
|
|||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly VITE_API_URL: string;
|
readonly VITE_API_URL: string;
|
||||||
readonly VITE_BASE_API_URL: string;
|
readonly VITE_BASE_API_URL: string;
|
||||||
|
readonly VITE_TOKEN_REFRESH_INTERVAL_MINUTES?: string;
|
||||||
|
/** PostHog project token. Absent = observability disabled (see lib/posthog.ts). */
|
||||||
|
readonly VITE_POSTHOG_KEY?: string;
|
||||||
|
/** Self-hosted PostHog instance URL. */
|
||||||
|
readonly VITE_POSTHOG_HOST?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"@mantine/core": "^9.3.0",
|
"@mantine/core": "^9.3.0",
|
||||||
"@mantine/dates": "^9.3.0",
|
"@mantine/dates": "^9.3.0",
|
||||||
"@mantine/hooks": "^9.3.0",
|
"@mantine/hooks": "^9.3.0",
|
||||||
|
"@posthog/react": "^1.10.3",
|
||||||
"@tanstack/react-query": "^5.59.0",
|
"@tanstack/react-query": "^5.59.0",
|
||||||
"@tria-plc/iamui": "file:../../../local-packages/tria-plc-iamui-0.1.1.tgz",
|
"@tria-plc/iamui": "file:../../../local-packages/tria-plc-iamui-0.1.1.tgz",
|
||||||
"@vis.gl/react-google-maps": "^1.8.3",
|
"@vis.gl/react-google-maps": "^1.8.3",
|
||||||
@@ -26,6 +27,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"lucide-react": "^1.14.0",
|
"lucide-react": "^1.14.0",
|
||||||
|
"posthog-js": "^1.400.1",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "19.2.6",
|
"react": "19.2.6",
|
||||||
"react-dom": "19.2.6",
|
"react-dom": "19.2.6",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import OnboardingResumeBanner, {
|
|||||||
import OnboardingWizardDialog from "./components/onboarding/OnboardingWizardDialog";
|
import OnboardingWizardDialog from "./components/onboarding/OnboardingWizardDialog";
|
||||||
import { ApiErrorModal } from "./components/errors/ApiErrorModal";
|
import { ApiErrorModal } from "./components/errors/ApiErrorModal";
|
||||||
import useAuth from "./hooks/useAuth";
|
import useAuth from "./hooks/useAuth";
|
||||||
|
import { useIdentify } from "./lib/posthog";
|
||||||
import {
|
import {
|
||||||
startTokenRefreshScheduler,
|
startTokenRefreshScheduler,
|
||||||
stopTokenRefreshScheduler,
|
stopTokenRefreshScheduler,
|
||||||
@@ -221,6 +222,9 @@ const App = () => {
|
|||||||
const { user, company, companyType, createProfile, isAuthenticated } =
|
const { user, company, companyType, createProfile, isAuthenticated } =
|
||||||
useAuth();
|
useAuth();
|
||||||
|
|
||||||
|
// Attribute replays and exceptions to the signed-in user (id/org only).
|
||||||
|
useIdentify(user, company);
|
||||||
|
|
||||||
// Keep the server session alive while a user is logged in. Runs after
|
// Keep the server session alive while a user is logged in. Runs after
|
||||||
// login, signup, and page-reload bootstrap alike.
|
// login, signup, and page-reload bootstrap alike.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* Shown when a render error escapes to the app root.
|
||||||
|
*
|
||||||
|
* Before this existed, a render exception unmounted the tree and left the
|
||||||
|
* customer staring at a blank white page with no way forward. The matching
|
||||||
|
* `$exception` is reported by the surrounding PostHogErrorBoundary.
|
||||||
|
*/
|
||||||
|
export function AppErrorFallback() {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-slate-50 p-6">
|
||||||
|
<div className="w-full max-w-md rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||||
|
<h2 className="text-lg font-semibold text-slate-900">
|
||||||
|
Something went wrong
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-sm text-slate-600">
|
||||||
|
This page failed to load. The problem has been reported. Try reloading
|
||||||
|
— if it keeps happening, please contact support.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="mt-4 rounded-lg bg-teal-700 px-4 py-2 text-sm font-medium text-white hover:bg-teal-800"
|
||||||
|
>
|
||||||
|
Reload page
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AppErrorFallback;
|
||||||
151
apps/edr-freight-web/portal/src/lib/posthog.ts
Normal file
151
apps/edr-freight-web/portal/src/lib/posthog.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
/**
|
||||||
|
* PostHog wiring — session replay, exception capture, console logs.
|
||||||
|
*
|
||||||
|
* NOTE: `backoffice/src/lib/posthog.ts` is the twin of this file. The init
|
||||||
|
* config below (masking rules) and the PII allowlist in `useIdentify` MUST be
|
||||||
|
* kept identical in both — a change made here alone silently leaks customer
|
||||||
|
* data into the other app's replays.
|
||||||
|
*
|
||||||
|
* This is instrumentation, not analytics: autocapture is off and no product
|
||||||
|
* events are sent.
|
||||||
|
*/
|
||||||
|
import posthog from "posthog-js";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
import type { AuthUser } from "@/types/auth";
|
||||||
|
|
||||||
|
const APP = "freight-portal";
|
||||||
|
|
||||||
|
const TOKEN = import.meta.env.VITE_POSTHOG_KEY;
|
||||||
|
const HOST = import.meta.env.VITE_POSTHOG_HOST;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether init actually ran. Without a token every export below is a no-op, so
|
||||||
|
* local dev and any environment whose env file lacks the vars keeps working —
|
||||||
|
* a missing observability token must never break the app.
|
||||||
|
*/
|
||||||
|
let enabled = false;
|
||||||
|
|
||||||
|
export function initPostHog(): void {
|
||||||
|
if (enabled || !TOKEN || !HOST) return;
|
||||||
|
|
||||||
|
posthog.init(TOKEN, {
|
||||||
|
api_host: HOST,
|
||||||
|
defaults: "2026-05-30",
|
||||||
|
|
||||||
|
// Debuggability, not analytics.
|
||||||
|
autocapture: false,
|
||||||
|
capture_pageview: true,
|
||||||
|
capture_pageleave: true,
|
||||||
|
disable_surveys: true,
|
||||||
|
person_profiles: "identified_only",
|
||||||
|
|
||||||
|
capture_exceptions: {
|
||||||
|
capture_unhandled_errors: true,
|
||||||
|
capture_unhandled_rejections: true,
|
||||||
|
capture_console_errors: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Off by default; this is half the point of the integration.
|
||||||
|
enable_recording_console_log: true,
|
||||||
|
|
||||||
|
// Inputs are masked; rendered text stays visible so replays are readable.
|
||||||
|
// Wrap sensitive elements in `ph-no-capture` to blank them individually.
|
||||||
|
session_recording: { maskAllInputs: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Portal and backoffice share one PostHog project — filter by this.
|
||||||
|
posthog.register({ app: APP });
|
||||||
|
|
||||||
|
enabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function captureException(
|
||||||
|
error: unknown,
|
||||||
|
properties?: Record<string, unknown>,
|
||||||
|
): void {
|
||||||
|
if (!enabled) return;
|
||||||
|
posthog.captureException(error, properties);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Report a failed API call.
|
||||||
|
*
|
||||||
|
* Called from the axios interceptor rather than from `emitApiError`, which
|
||||||
|
* early-returns on suppressed paths (warehouse / first-mile / onboarding /
|
||||||
|
* auth) — hooking there would drop errors on exactly those pages.
|
||||||
|
*
|
||||||
|
* 5xx and network failures are real defects and go to Error tracking. 4xx is
|
||||||
|
* usually the server correctly rejecting input, so it is recorded as a plain
|
||||||
|
* event to keep the issue list signal-heavy.
|
||||||
|
*/
|
||||||
|
export function captureApiError(error: unknown): void {
|
||||||
|
if (!enabled) return;
|
||||||
|
|
||||||
|
const err = error as {
|
||||||
|
message?: string;
|
||||||
|
config?: { method?: string; url?: string };
|
||||||
|
response?: { status?: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
const status = err.response?.status;
|
||||||
|
const properties = {
|
||||||
|
api_status: status ?? null,
|
||||||
|
api_method: err.config?.method?.toUpperCase() ?? null,
|
||||||
|
api_path: err.config?.url ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (status && status < 500) {
|
||||||
|
posthog.capture("api_error", properties);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
posthog.captureException(error, properties);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Company context, as returned by `useAuth().company`. */
|
||||||
|
interface IdentifyCompany {
|
||||||
|
company?: { id?: string; type?: string | null; status?: string | null } | null;
|
||||||
|
profile?: { activeProfileType?: string | null } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identify the current user to PostHog so replays and exceptions are
|
||||||
|
* attributable.
|
||||||
|
*
|
||||||
|
* Deliberately sends NO contact details. `AuthUser` carries email, phoneNumber,
|
||||||
|
* name and username; these are real customers, and debugging a replay never
|
||||||
|
* requires knowing how to phone the person in it.
|
||||||
|
*/
|
||||||
|
export function useIdentify(
|
||||||
|
user: AuthUser | null,
|
||||||
|
company?: IdentifyCompany | null,
|
||||||
|
): void {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
|
||||||
|
if (!user?.id) {
|
||||||
|
posthog.reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
posthog.identify(user.id, {
|
||||||
|
roles: user.roles,
|
||||||
|
status: user.status,
|
||||||
|
user_type: user.userType,
|
||||||
|
company_id: company?.company?.id,
|
||||||
|
company_type: company?.company?.type,
|
||||||
|
company_status: company?.company?.status,
|
||||||
|
active_profile_type: company?.profile?.activeProfileType,
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
user?.id,
|
||||||
|
user?.roles,
|
||||||
|
user?.status,
|
||||||
|
user?.userType,
|
||||||
|
company?.company?.id,
|
||||||
|
company?.company?.type,
|
||||||
|
company?.company?.status,
|
||||||
|
company?.profile?.activeProfileType,
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ import { createRoot } from "react-dom/client";
|
|||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter } from "react-router-dom";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
import posthog from "posthog-js";
|
||||||
|
import { PostHogProvider, PostHogErrorBoundary } from "@posthog/react";
|
||||||
import "@mantine/core/styles.css";
|
import "@mantine/core/styles.css";
|
||||||
import "@mantine/dates/styles.css";
|
import "@mantine/dates/styles.css";
|
||||||
import "@edr/ui-common/styles.css";
|
import "@edr/ui-common/styles.css";
|
||||||
@@ -10,6 +12,8 @@ import "../index.css";
|
|||||||
import "@edr/ui-common/theme.css";
|
import "@edr/ui-common/theme.css";
|
||||||
import { Toaster } from "react-hot-toast";
|
import { Toaster } from "react-hot-toast";
|
||||||
import { mantineTheme } from "./theme/mantine";
|
import { mantineTheme } from "./theme/mantine";
|
||||||
|
import { initPostHog } from "./lib/posthog";
|
||||||
|
import { AppErrorFallback } from "./components/errors/AppErrorFallback";
|
||||||
|
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
|
|
||||||
@@ -26,6 +30,10 @@ import App from "./App";
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Must run before render so replay and exception capture cover startup errors.
|
||||||
|
// No-ops when VITE_POSTHOG_KEY is unset.
|
||||||
|
initPostHog();
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
const rootElement = document.getElementById("root");
|
const rootElement = document.getElementById("root");
|
||||||
@@ -36,13 +44,17 @@ if (!rootElement) {
|
|||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<MantineProvider theme={mantineTheme} defaultColorScheme="light">
|
<PostHogProvider client={posthog}>
|
||||||
<QueryClientProvider client={queryClient}>
|
<MantineProvider theme={mantineTheme} defaultColorScheme="light">
|
||||||
<BrowserRouter>
|
<QueryClientProvider client={queryClient}>
|
||||||
<App />
|
<BrowserRouter>
|
||||||
<Toaster position="top-right" />
|
<PostHogErrorBoundary fallback={<AppErrorFallback />}>
|
||||||
</BrowserRouter>
|
<App />
|
||||||
</QueryClientProvider>
|
</PostHogErrorBoundary>
|
||||||
</MantineProvider>
|
<Toaster position="top-right" />
|
||||||
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</MantineProvider>
|
||||||
|
</PostHogProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
emitApiError,
|
emitApiError,
|
||||||
extractApiErrorPayload,
|
extractApiErrorPayload,
|
||||||
} from "@/components/errors/ApiErrorModal";
|
} from "@/components/errors/ApiErrorModal";
|
||||||
|
import { captureApiError } from "@/lib/posthog";
|
||||||
|
|
||||||
const client = axios.create({
|
const client = axios.create({
|
||||||
baseURL: API_BASE_URL,
|
baseURL: API_BASE_URL,
|
||||||
@@ -91,6 +92,14 @@ client.interceptors.response.use(
|
|||||||
_retry?: boolean;
|
_retry?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Report the failure to PostHog. Hooked here rather than inside
|
||||||
|
// `emitApiError`, which stays silent on suppressed paths (warehouse /
|
||||||
|
// mile / onboarding) — those failures still need reporting.
|
||||||
|
// 401s are skipped: an expired session is refreshed below, not a defect.
|
||||||
|
if (!error.response || error.response.status !== 401) {
|
||||||
|
captureApiError(error);
|
||||||
|
}
|
||||||
|
|
||||||
// Don't intercept if:
|
// Don't intercept if:
|
||||||
// - no response (network error)
|
// - no response (network error)
|
||||||
// - status is not 401
|
// - status is not 401
|
||||||
|
|||||||
@@ -16,7 +16,13 @@ interface Window {
|
|||||||
|
|
||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly VITE_API_URL: string;
|
readonly VITE_API_URL: string;
|
||||||
|
readonly VITE_BASE_API_URL: string;
|
||||||
readonly VITE_GOOGLE_MAPS_API_KEY?: string;
|
readonly VITE_GOOGLE_MAPS_API_KEY?: string;
|
||||||
|
readonly VITE_TOKEN_REFRESH_INTERVAL_MINUTES?: string;
|
||||||
|
/** PostHog project token. Absent = observability disabled (see lib/posthog.ts). */
|
||||||
|
readonly VITE_POSTHOG_KEY?: string;
|
||||||
|
/** Self-hosted PostHog instance URL. */
|
||||||
|
readonly VITE_POSTHOG_HOST?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ services:
|
|||||||
VITE_BASE_API_URL: ${VITE_BASE_API_URL:-}
|
VITE_BASE_API_URL: ${VITE_BASE_API_URL:-}
|
||||||
VITE_USER_MANAGEMENT_BASE: ${VITE_USER_MANAGEMENT_BASE:-}
|
VITE_USER_MANAGEMENT_BASE: ${VITE_USER_MANAGEMENT_BASE:-}
|
||||||
VITE_GOOGLE_MAPS_API_KEY: ${VITE_GOOGLE_MAPS_API_KEY:-}
|
VITE_GOOGLE_MAPS_API_KEY: ${VITE_GOOGLE_MAPS_API_KEY:-}
|
||||||
|
VITE_POSTHOG_KEY: ${VITE_POSTHOG_KEY:-}
|
||||||
|
VITE_POSTHOG_HOST: ${VITE_POSTHOG_HOST:-}
|
||||||
secrets:
|
secrets:
|
||||||
- npmrc
|
- npmrc
|
||||||
ports:
|
ports:
|
||||||
@@ -72,6 +74,8 @@ services:
|
|||||||
VITE_BASE_API_URL: ${VITE_BASE_API_URL:-}
|
VITE_BASE_API_URL: ${VITE_BASE_API_URL:-}
|
||||||
VITE_USER_MANAGEMENT_BASE: ${VITE_USER_MANAGEMENT_BASE:-}
|
VITE_USER_MANAGEMENT_BASE: ${VITE_USER_MANAGEMENT_BASE:-}
|
||||||
VITE_GOOGLE_MAPS_API_KEY: ${VITE_GOOGLE_MAPS_API_KEY:-}
|
VITE_GOOGLE_MAPS_API_KEY: ${VITE_GOOGLE_MAPS_API_KEY:-}
|
||||||
|
VITE_POSTHOG_KEY: ${VITE_POSTHOG_KEY:-}
|
||||||
|
VITE_POSTHOG_HOST: ${VITE_POSTHOG_HOST:-}
|
||||||
secrets:
|
secrets:
|
||||||
- npmrc
|
- npmrc
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -28,11 +28,15 @@ ARG VITE_BASE_API_URL
|
|||||||
ARG VITE_USER_MANAGEMENT_BASE
|
ARG VITE_USER_MANAGEMENT_BASE
|
||||||
ARG NEXT_PUBLIC_API_URL
|
ARG NEXT_PUBLIC_API_URL
|
||||||
ARG VITE_GOOGLE_MAPS_API_KEY
|
ARG VITE_GOOGLE_MAPS_API_KEY
|
||||||
|
ARG VITE_POSTHOG_KEY
|
||||||
|
ARG VITE_POSTHOG_HOST
|
||||||
ENV VITE_API_URL=${VITE_API_URL}
|
ENV VITE_API_URL=${VITE_API_URL}
|
||||||
ENV VITE_BASE_API_URL=${VITE_BASE_API_URL}
|
ENV VITE_BASE_API_URL=${VITE_BASE_API_URL}
|
||||||
ENV VITE_USER_MANAGEMENT_BASE=${VITE_USER_MANAGEMENT_BASE}
|
ENV VITE_USER_MANAGEMENT_BASE=${VITE_USER_MANAGEMENT_BASE}
|
||||||
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
|
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
|
||||||
ENV VITE_GOOGLE_MAPS_API_KEY=${VITE_GOOGLE_MAPS_API_KEY}
|
ENV VITE_GOOGLE_MAPS_API_KEY=${VITE_GOOGLE_MAPS_API_KEY}
|
||||||
|
ENV VITE_POSTHOG_KEY=${VITE_POSTHOG_KEY}
|
||||||
|
ENV VITE_POSTHOG_HOST=${VITE_POSTHOG_HOST}
|
||||||
|
|
||||||
RUN if [ -z "$VITE_API_URL" ] || [ -z "$VITE_BASE_API_URL" ] || [ -z "$VITE_USER_MANAGEMENT_BASE" ]; then \
|
RUN if [ -z "$VITE_API_URL" ] || [ -z "$VITE_BASE_API_URL" ] || [ -z "$VITE_USER_MANAGEMENT_BASE" ]; then \
|
||||||
echo "ERROR: VITE_API_URL, VITE_BASE_API_URL, and VITE_USER_MANAGEMENT_BASE must all be set" && \
|
echo "ERROR: VITE_API_URL, VITE_BASE_API_URL, and VITE_USER_MANAGEMENT_BASE must all be set" && \
|
||||||
|
|||||||
248
pnpm-lock.yaml
generated
248
pnpm-lock.yaml
generated
@@ -241,6 +241,9 @@ importers:
|
|||||||
'@mantine/hooks':
|
'@mantine/hooks':
|
||||||
specifier: ^9.3.0
|
specifier: ^9.3.0
|
||||||
version: 9.3.0(react@19.2.6)
|
version: 9.3.0(react@19.2.6)
|
||||||
|
'@posthog/react':
|
||||||
|
specifier: ^1.10.3
|
||||||
|
version: 1.10.3(@types/react@18.3.31)(posthog-js@1.400.1)(react@19.2.6)
|
||||||
'@radix-ui/react-accordion':
|
'@radix-ui/react-accordion':
|
||||||
specifier: ^1.2.13
|
specifier: ^1.2.13
|
||||||
version: 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)
|
version: 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)
|
||||||
@@ -409,6 +412,9 @@ importers:
|
|||||||
pdf-lib:
|
pdf-lib:
|
||||||
specifier: ^1.17.1
|
specifier: ^1.17.1
|
||||||
version: 1.17.1
|
version: 1.17.1
|
||||||
|
posthog-js:
|
||||||
|
specifier: ^1.400.1
|
||||||
|
version: 1.400.1
|
||||||
prop-types:
|
prop-types:
|
||||||
specifier: ^15.8.1
|
specifier: ^15.8.1
|
||||||
version: 15.8.1
|
version: 15.8.1
|
||||||
@@ -566,12 +572,15 @@ importers:
|
|||||||
'@mantine/hooks':
|
'@mantine/hooks':
|
||||||
specifier: ^9.3.0
|
specifier: ^9.3.0
|
||||||
version: 9.3.0(react@19.2.6)
|
version: 9.3.0(react@19.2.6)
|
||||||
|
'@posthog/react':
|
||||||
|
specifier: ^1.10.3
|
||||||
|
version: 1.10.3(@types/react@18.3.31)(posthog-js@1.400.1)(react@19.2.6)
|
||||||
'@tanstack/react-query':
|
'@tanstack/react-query':
|
||||||
specifier: ^5.59.0
|
specifier: ^5.59.0
|
||||||
version: 5.101.0(react@19.2.6)
|
version: 5.101.0(react@19.2.6)
|
||||||
'@tria-plc/iamui':
|
'@tria-plc/iamui':
|
||||||
specifier: file:../../../local-packages/tria-plc-iamui-0.1.1.tgz
|
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':
|
'@vis.gl/react-google-maps':
|
||||||
specifier: ^1.8.3
|
specifier: ^1.8.3
|
||||||
version: 1.8.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
version: 1.8.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
@@ -590,6 +599,9 @@ importers:
|
|||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^1.14.0
|
specifier: ^1.14.0
|
||||||
version: 1.17.0(react@19.2.6)
|
version: 1.17.0(react@19.2.6)
|
||||||
|
posthog-js:
|
||||||
|
specifier: ^1.400.1
|
||||||
|
version: 1.400.1
|
||||||
radix-ui:
|
radix-ui:
|
||||||
specifier: ^1.4.3
|
specifier: ^1.4.3
|
||||||
version: 1.5.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)
|
version: 1.5.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)
|
||||||
@@ -3125,6 +3137,22 @@ packages:
|
|||||||
'@popperjs/core@2.11.8':
|
'@popperjs/core@2.11.8':
|
||||||
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
|
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
|
||||||
|
|
||||||
|
'@posthog/core@1.41.1':
|
||||||
|
resolution: {integrity: sha512-lKjPdeawDSvRhHnP14RwTSI5CofuyluhG3ISHRa+Kj6PyfSrEUyIkoVOpYWicFGgWikeaJCZzFQpo7ngHt9BcA==}
|
||||||
|
|
||||||
|
'@posthog/react@1.10.3':
|
||||||
|
resolution: {integrity: sha512-Qu//fGQmVlX0B9kTA3LLg67e7AYLEmeuA0Bf1qSyUM0uUILcRQGjQezhNQPLYSTakOqvXEnl6fM2iQBF6Toxrw==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '>=16.8.0'
|
||||||
|
posthog-js: '>=1.257.2'
|
||||||
|
react: '>=16.8.0'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@posthog/types@1.394.0':
|
||||||
|
resolution: {integrity: sha512-ifQ7p8o8hoHErlJmpzCFzHQcuRam0vXk8LBVhBu4BlPYP6S0tog4FSAFItnI/nwN6cHZI0WFQilr7sIqQa7Flg==}
|
||||||
|
|
||||||
'@prisma/client@6.19.3':
|
'@prisma/client@6.19.3':
|
||||||
resolution: {integrity: sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg==}
|
resolution: {integrity: sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg==}
|
||||||
engines: {node: '>=18.18'}
|
engines: {node: '>=18.18'}
|
||||||
@@ -7050,6 +7078,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==}
|
resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==}
|
||||||
engines: {node: ^12.20 || >= 14.13}
|
engines: {node: ^12.20 || >= 14.13}
|
||||||
|
|
||||||
|
fflate@0.4.8:
|
||||||
|
resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==}
|
||||||
|
|
||||||
fflate@0.8.3:
|
fflate@0.8.3:
|
||||||
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
|
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
|
||||||
|
|
||||||
@@ -9498,10 +9529,21 @@ packages:
|
|||||||
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
|
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
posthog-js@1.400.1:
|
||||||
|
resolution: {integrity: sha512-NGfzNwTu+VBw4FekgYs/aQbEkTFkvmpTFUKDGZw/9K6R/sG2WyuLsnnXySRcNH8RMki0Io8v9flNATrrTfmT+Q==}
|
||||||
|
|
||||||
powershell-utils@0.1.0:
|
powershell-utils@0.1.0:
|
||||||
resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==}
|
resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
|
preact@10.29.7:
|
||||||
|
resolution: {integrity: sha512-DCHYrK/B10yUD3ZjLfhZ3WIE/9Vf9VFUODcRE2dRomTYDpJk6z6L9wecSfhfE6M9ZTHUdyQkoC46arIDhEV84Q==}
|
||||||
|
peerDependencies:
|
||||||
|
preact-render-to-string: '>=5'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
preact-render-to-string:
|
||||||
|
optional: true
|
||||||
|
|
||||||
prelude-ls@1.2.1:
|
prelude-ls@1.2.1:
|
||||||
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@@ -9601,6 +9643,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==}
|
resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==}
|
||||||
engines: {node: '>=0.6'}
|
engines: {node: '>=0.6'}
|
||||||
|
|
||||||
|
query-selector-shadow-dom@1.0.1:
|
||||||
|
resolution: {integrity: sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==}
|
||||||
|
|
||||||
query-string@7.1.3:
|
query-string@7.1.3:
|
||||||
resolution: {integrity: sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==}
|
resolution: {integrity: sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -11478,6 +11523,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
|
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
||||||
|
web-vitals@5.3.0:
|
||||||
|
resolution: {integrity: sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g==}
|
||||||
|
|
||||||
webdriver-bidi-protocol@0.4.1:
|
webdriver-bidi-protocol@0.4.1:
|
||||||
resolution: {integrity: sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==}
|
resolution: {integrity: sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==}
|
||||||
|
|
||||||
@@ -13696,6 +13744,19 @@ snapshots:
|
|||||||
|
|
||||||
'@popperjs/core@2.11.8': {}
|
'@popperjs/core@2.11.8': {}
|
||||||
|
|
||||||
|
'@posthog/core@1.41.1':
|
||||||
|
dependencies:
|
||||||
|
'@posthog/types': 1.394.0
|
||||||
|
|
||||||
|
'@posthog/react@1.10.3(@types/react@18.3.31)(posthog-js@1.400.1)(react@19.2.6)':
|
||||||
|
dependencies:
|
||||||
|
posthog-js: 1.400.1
|
||||||
|
react: 19.2.6
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 18.3.31
|
||||||
|
|
||||||
|
'@posthog/types@1.394.0': {}
|
||||||
|
|
||||||
'@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3)':
|
'@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3)':
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
prisma: 6.19.3(typescript@5.9.3)
|
prisma: 6.19.3(typescript@5.9.3)
|
||||||
@@ -16092,130 +16153,6 @@ snapshots:
|
|||||||
- utf-8-validate
|
- utf-8-validate
|
||||||
- vite
|
- 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':
|
'@ts-morph/common@0.27.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
fast-glob: 3.3.3
|
fast-glob: 3.3.3
|
||||||
@@ -17402,16 +17339,6 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- 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:
|
babel-polyfill@6.26.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
babel-runtime: 6.26.0
|
babel-runtime: 6.26.0
|
||||||
@@ -18015,8 +17942,7 @@ snapshots:
|
|||||||
|
|
||||||
core-js@2.6.12: {}
|
core-js@2.6.12: {}
|
||||||
|
|
||||||
core-js@3.49.0:
|
core-js@3.49.0: {}
|
||||||
optional: true
|
|
||||||
|
|
||||||
core-util-is@1.0.3: {}
|
core-util-is@1.0.3: {}
|
||||||
|
|
||||||
@@ -19202,6 +19128,8 @@ snapshots:
|
|||||||
node-domexception: 1.0.0
|
node-domexception: 1.0.0
|
||||||
web-streams-polyfill: 3.3.3
|
web-streams-polyfill: 3.3.3
|
||||||
|
|
||||||
|
fflate@0.4.8: {}
|
||||||
|
|
||||||
fflate@0.8.3: {}
|
fflate@0.8.3: {}
|
||||||
|
|
||||||
figures@1.7.0:
|
figures@1.7.0:
|
||||||
@@ -21856,8 +21784,23 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
xtend: 4.0.2
|
xtend: 4.0.2
|
||||||
|
|
||||||
|
posthog-js@1.400.1:
|
||||||
|
dependencies:
|
||||||
|
'@posthog/core': 1.41.1
|
||||||
|
'@posthog/types': 1.394.0
|
||||||
|
core-js: 3.49.0
|
||||||
|
dompurify: 3.4.8
|
||||||
|
fflate: 0.4.8
|
||||||
|
preact: 10.29.7
|
||||||
|
query-selector-shadow-dom: 1.0.1
|
||||||
|
web-vitals: 5.3.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- preact-render-to-string
|
||||||
|
|
||||||
powershell-utils@0.1.0: {}
|
powershell-utils@0.1.0: {}
|
||||||
|
|
||||||
|
preact@10.29.7: {}
|
||||||
|
|
||||||
prelude-ls@1.2.1: {}
|
prelude-ls@1.2.1: {}
|
||||||
|
|
||||||
prettier@3.8.3: {}
|
prettier@3.8.3: {}
|
||||||
@@ -21981,6 +21924,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
side-channel: 1.1.0
|
side-channel: 1.1.0
|
||||||
|
|
||||||
|
query-selector-shadow-dom@1.0.1: {}
|
||||||
|
|
||||||
query-string@7.1.3:
|
query-string@7.1.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
decode-uri-component: 0.2.2
|
decode-uri-component: 0.2.2
|
||||||
@@ -22173,15 +22118,6 @@ snapshots:
|
|||||||
- '@babel/core'
|
- '@babel/core'
|
||||||
- react-is
|
- 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):
|
react-day-picker@8.10.2(date-fns@3.6.0)(react@19.2.6):
|
||||||
dependencies:
|
dependencies:
|
||||||
date-fns: 3.6.0
|
date-fns: 3.6.0
|
||||||
@@ -23371,24 +23307,6 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@babel/core'
|
- '@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):
|
styled-jsx@5.1.1(babel-plugin-macros@3.1.0)(react@18.3.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
client-only: 0.0.1
|
client-only: 0.0.1
|
||||||
@@ -24334,6 +24252,8 @@ snapshots:
|
|||||||
|
|
||||||
web-streams-polyfill@3.3.3: {}
|
web-streams-polyfill@3.3.3: {}
|
||||||
|
|
||||||
|
web-vitals@5.3.0: {}
|
||||||
|
|
||||||
webdriver-bidi-protocol@0.4.1: {}
|
webdriver-bidi-protocol@0.4.1: {}
|
||||||
|
|
||||||
webidl-conversions@7.0.0: {}
|
webidl-conversions@7.0.0: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user