Merge pull request #809 from Tria-plc/main

Main
This commit is contained in:
mulish77
2026-07-19 15:14:19 +03:00
committed by GitHub
100 changed files with 5249 additions and 728 deletions

View File

@@ -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
`);
}
}

View File

@@ -376,4 +376,97 @@ describe("BillingService.expirePayable — locked write runs in a transaction",
expect(result).toBeNull();
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();
});
});

View File

@@ -826,8 +826,15 @@ export class BillingService {
* Expire a source's currently-open invoice (its pay window closed before
* settlement), then emit `${source}.invoice.expired`. Resolves the open invoice
* 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
* (already paid/cancelled/expired).
* `OPEN_STATUSES`). No-op (returns null) when the source has no invoice left to
* 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
* the batch engine) to enlist in its DB transaction.
@@ -850,7 +857,7 @@ export class BillingService {
where: {
source,
sourceId,
status: In(OPEN_STATUSES),
status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]),
...(type ? { type } : {}),
},
order: { issuedAt: "DESC" },
@@ -867,30 +874,58 @@ export class BillingService {
}
/**
* Sync a source's open invoice `dueAt` to its real pay-window deadline. The
* booking invoice is generated before the pay window opens (at booking
* creation/approval), so its printed due date is refreshed when the batch engine
* sets `paymentDeadline`. No-op when the source has no open invoice.
* Issue a source's invoice and stamp its real pay-window deadline — the single
* transition that makes a source payable.
*
* 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,
sourceId: string,
dueAt: Date,
type?: string,
manager?: EntityManager,
): Promise<void> {
): Promise<Invoice | null> {
const mg = manager ?? this.dataSource.manager;
const invoice = await mg.findOne(Invoice, {
where: {
source,
sourceId,
status: In(OPEN_STATUSES),
status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]),
...(type ? { type } : {}),
},
order: { issuedAt: "DESC" },
});
if (!invoice) return;
await mg.update(Invoice, { id: invoice.id }, { dueAt });
if (!invoice) return null;
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;
}
/**
@@ -997,20 +1032,20 @@ export class BillingService {
.getRepository(Invoice)
.update({ id: invoice.id }, { paymentId: result.intentId });
// // DEMO: manually fire the gateway `payment.succeeded` callback here, without
// // waiting for real gateway settlement. Runs AFTER the paymentId link above so
// // `handlePaymentEvent → settleByPaymentId` can correlate the invoice. TODO:
// // remove — real settlement flips this via the `${source}.invoice.paid` handler.
// if (!result.immediateSuccess) {
// await this.payment.handlePaymentEvent({
// eventType: "payment.succeeded",
// eventId: `demo-${result.intentId}`,
// referenceId: invoice.sourceId,
// intentId: result.intentId,
// providerTxnId: result.providerTxnId,
// paidAt: (result.paidAt ?? new Date()).toISOString(),
// });
// }
// DEMO: manually fire the gateway `payment.succeeded` callback here, without
// waiting for real gateway settlement. Runs AFTER the paymentId link above so
// `handlePaymentEvent → settleByPaymentId` can correlate the invoice. TODO:
// remove — real settlement flips this via the `${source}.invoice.paid` handler.
if (!result.immediateSuccess) {
await this.payment.handlePaymentEvent({
eventType: "payment.succeeded",
eventId: `demo-${result.intentId}`,
referenceId: invoice.sourceId,
intentId: result.intentId,
providerTxnId: result.providerTxnId,
paidAt: (result.paidAt ?? new Date()).toISOString(),
});
}
if (result.immediateSuccess) {
await this.settleByPaymentId(

View File

@@ -33,8 +33,6 @@ import { ClearanceMilestoneService } from '../contracts/clearance-milestone.serv
import { ClearanceWorkflowService } from '../contracts/clearance-workflow.service';
import { ContractDocPhase } from '@edr/types';
import { Freight } from "@edr/types";
import { BookingInvoiceService } from "./booking-invoice.service";
@Injectable()
@@ -1112,14 +1110,25 @@ export class BookingTransitionService {
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);
this.logger.log(
`Generated invoice ${invoice.invoiceNumber} (${invoice.id}) for ${booking.reference}:${booking.id}`,
);
await this.invoiceService.updateStatus(
invoice.id,
Freight.InvoiceStatus.Pending,
`Generated draft invoice ${invoice.invoiceNumber} (${invoice.id}) for ${booking.reference}:${booking.id} — issued on batch selection`,
);
// 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)) {
await this.bookingsRepository.update(booking.id, {
status: "ROAD_DISPATCH_PENDING",

View File

@@ -204,17 +204,27 @@ export class ContractsRepository extends BaseRepository<Contract> {
private async attachClearancePhases(contracts: Contract[]): Promise<void> {
if (contracts.length === 0) return;
const ids = contracts.map((c) => c.id);
const rows: Array<{ contract_id: string; current_phase: string | null }> =
await this.dataSource.query(
`SELECT DISTINCT ON (contract_id) contract_id, current_phase
FROM freight.contract_clearance_cycles
WHERE contract_id = ANY($1)
ORDER BY contract_id, cycle_number DESC`,
[ids],
);
const byContract = new Map(rows.map((r) => [r.contract_id, r.current_phase]));
const rows: Array<{
contract_id: string;
current_phase: string | null;
booking_id: string | null;
booking_status: string | null;
}> = await this.dataSource.query(
`SELECT DISTINCT ON (ccc.contract_id)
ccc.contract_id, ccc.current_phase,
b.id AS booking_id, b.status AS booking_status
FROM freight.contract_clearance_cycles ccc
LEFT JOIN freight.bookings b ON b.id = ccc.booking_id
WHERE ccc.contract_id = ANY($1)
ORDER BY ccc.contract_id, ccc.cycle_number DESC`,
[ids],
);
const byContract = new Map(rows.map((r) => [r.contract_id, r]));
for (const contract of contracts) {
contract.clearancePhase = byContract.get(contract.id) ?? null;
const row = byContract.get(contract.id);
contract.clearancePhase = row?.current_phase ?? null;
contract.latestCycleBookingId = row?.booking_id ?? null;
contract.latestCycleBookingStatus = row?.booking_status ?? null;
}
}

View File

@@ -312,6 +312,14 @@ export class Contract extends BaseEntity {
*/
clearancePhase?: string | null;
/**
* Latest clearance cycle's linked booking (id + status), attached alongside
* clearancePhase. Lets the GL queue tell an expired (unpaid) booking apart
* from a live one so it can offer a rebook. Not columns.
*/
latestCycleBookingId?: string | null;
latestCycleBookingStatus?: string | null;
/**
* Body of the most recent CHANGES_REQUESTED review note, attached by
* ContractsService.findById so the portal can show the customer what staff

View File

@@ -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';
export class LastMileVehicleInput {
@IsUUID()
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()
@IsString()
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 {
@IsArray()
@ValidateNested({ each: true })

View File

@@ -1,8 +1,9 @@
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 { 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
@@ -28,12 +29,34 @@ export class LastMileVehicleAssignment extends BaseEntity {
@JoinColumn({ name: 'vehicle_id' })
vehicle?: Vehicle;
/** Container this truck carries — auto-filled from the booking's container
* number when known, else entered manually at assignment time. */
/** Legacy single container this truck carries. Kept in sync with the FIRST
* 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 })
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. */
@Column({ name: 'distance_km', type: 'numeric', precision: 10, scale: 2, nullable: true })
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;
}

View File

@@ -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;
}

View File

@@ -73,6 +73,12 @@ export class LastMileController {
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')
@BookingStaff(FREIGHT_PERMS.lastMile.accept)
@ApiOperation({ summary: 'Accept a paid booking and create a last-mile leg' })

View File

@@ -10,6 +10,7 @@ import { VehiclesModule } from '../vehicles/vehicles.module';
import { LastMile } from './entities/last-mile.entity';
import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.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 { LastMileInvoiceService } from './last-mile-invoice.service';
import { LastMileRepository } from './last-mile.repository';
@@ -17,7 +18,12 @@ import { LastMileService } from './last-mile.service';
@Module({
imports: [
TypeOrmModule.forFeature([LastMile, LastMileContainerAllocation, LastMileVehicleAssignment]),
TypeOrmModule.forFeature([
LastMile,
LastMileContainerAllocation,
LastMileVehicleAssignment,
LastMileVehicleContainer,
]),
BillingModule,
forwardRef(() => BookingsModule),
VehiclesModule,

View File

@@ -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 { 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 { LastMileContainerAllocation } from './entities/last-mile-container-allocation.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 { FilesService } from '../files/files.service';
import { BillingService, InvoiceEventPayload } from '../billing/billing.service';
@@ -175,7 +182,7 @@ export class LastMileService {
relations: {
booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true, bookingContainers: { containerType: true, units: true } },
vehicle: true,
vehicleAssignments: { vehicle: true },
vehicleAssignments: { vehicle: true, containers: true },
},
order: { [sortBy]: sortOrder },
skip: (page - 1) * pageSize,
@@ -200,7 +207,7 @@ export class LastMileService {
relations: {
booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true, bookingContainers: { containerType: true, units: true } },
vehicle: true,
vehicleAssignments: { vehicle: true },
vehicleAssignments: { vehicle: true, containers: true },
},
});
@@ -277,7 +284,7 @@ export class LastMileService {
> {
const [lm] = await this.lastMileRepository.findAll({
where: { bookingId },
relations: { vehicle: true, vehicleAssignments: { vehicle: true } },
relations: { vehicle: true, vehicleAssignments: { vehicle: true, containers: true } },
take: 1,
});
if (!lm) return [];
@@ -552,22 +559,164 @@ export class LastMileService {
* for each added/removed vehicle. The first vehicle is mirrored onto the legacy
* `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(
id: string,
inputs: Array<{ vehicleId: string; containerNumber?: string | null }>,
inputs: Array<{
vehicleId: string;
containerNumbers?: string[] | null;
containerNumber?: string | null;
}>,
): Promise<LastMile> {
const existing = await this.findById(id);
// Dedupe by vehicleId, keeping the container number; preserve order.
const desiredMap = new Map<string, string | null>();
// Dedupe by vehicleId, keeping the container load; preserve order. Accepts
// the legacy single `containerNumber` as a one-element load.
const desiredMap = new Map<string, string[]>();
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 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 current = await manager.find(LastMileVehicleAssignment, {
where: { lastMileId: id },
relations: { containers: true },
});
const junctionSet = new Set(current.map((a) => a.vehicleId));
// 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(
(a) =>
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) => {
if (removed.length) {
// Child containers cascade on delete.
await tx.delete(LastMileVehicleAssignment, {
lastMileId: id,
vehicleId: In(removed),
});
}
for (const vehicleId of added) {
await tx.insert(LastMileVehicleAssignment, {
const load = desiredMap.get(vehicleId) ?? [];
const inserted = await tx.insert(LastMileVehicleAssignment, {
lastMileId: id,
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) {
const load = desiredMap.get(row.vehicleId) ?? [];
await tx.update(
LastMileVehicleAssignment,
{ 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,
})),
);
}
}
});

View File

@@ -134,7 +134,7 @@ describe('BookingBatchService — PAID reconcile', () => {
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
trainSchedulingService as never,
{
syncPayableDueDate: jest.fn().mockResolvedValue(undefined),
issuePayable: jest.fn().mockResolvedValue(null),
expirePayable: jest.fn().mockResolvedValue(undefined),
} as never,
{ emitPhase: jest.fn() } as never,
@@ -596,7 +596,7 @@ describe('BookingBatchService — PAID reconcile', () => {
notifier as never,
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } 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,
{ computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never,
undefined,
@@ -619,7 +619,7 @@ describe('BookingBatchService — PAID reconcile', () => {
notifier as never,
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } 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,
{ computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never,
undefined,
@@ -650,7 +650,7 @@ describe('BookingBatchService — PAID reconcile', () => {
notifier as never,
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } 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,
{ computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never,
undefined,

View File

@@ -2317,9 +2317,13 @@ export class BookingBatchService implements OnModuleInit {
paymentDeadline: deadline,
} as never);
booking.trainScheduleId = scheduleId;
// The invoice was generated at booking creation/approval, before this pay
// window opened — refresh its printed due date to the real deadline.
await this.billing.syncPayableDueDate(
// The invoice was generated DRAFT at booking creation / operation-accept,
// before this pay window existed. Reserving is the moment the booking becomes
// 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,
booking.id,
deadline,

View File

@@ -0,0 +1,39 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsArray, IsISO8601, IsOptional, IsString, IsUUID } from 'class-validator';
/**
* Admin maintenance reschedule: move a train's departure to a new date/time.
* Every allocated booking rides along (links and wagon assignments untouched);
* only the dates move — the schedule's train set, route, and window rule
* snapshot all stay exactly as they were.
*/
export class MaintenanceRescheduleDto {
@ApiProperty({
example: '2026-07-20T05:00:00.000Z',
description: 'New scheduled departure date/time (ISO 8601)',
})
@IsISO8601()
newDepartureDate!: string;
@ApiPropertyOptional({ description: 'Why the train is being moved (logged)' })
@IsOptional()
@IsString()
reason?: string;
@ApiPropertyOptional({
description: 'Client-side trigger tag (e.g. TRAIN_MAINTENANCE) — logged only',
})
@IsOptional()
@IsString()
trigger?: string;
@ApiPropertyOptional({
description:
"The bookings the client believes are aboard — informational; the server moves the schedule's actual bookings",
type: [String],
})
@IsOptional()
@IsArray()
@IsUUID('4', { each: true })
incomingBookingIds?: string[];
}

View File

@@ -49,6 +49,7 @@ import { AvailableDaysForCargoQueryDto } from "./dto/available-days-for-cargo-qu
import { UpdateTrainSchedulingGlobalRulesDto } from "./dto/update-train-scheduling-global-rules.dto";
import { UpdateScheduleWindowRuleDto } from "./dto/update-schedule-window-rule.dto";
import { UpdateScheduleDateDto } from "./dto/update-schedule-date.dto";
import { MaintenanceRescheduleDto } from "./dto/maintenance-reschedule.dto";
import { TrainSchedulingService } from "./train-scheduling.service";
import { BookingBatchService } from "./booking-batch.service";
import { BookingJourneyService } from "./booking-journey.service";
@@ -711,6 +712,20 @@ export class TrainSchedulingController {
return this.trainSchedulingService.getContainerTrainScheduleById(id);
}
@Post("schedules/:id/maintenance")
@TrainSchedulingManage()
@ApiOperation({
summary:
"Maintenance reschedule: move the train to a new departure with every allocated booking aboard — links, wagons and window settings unchanged",
})
async maintenanceReschedule(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: MaintenanceRescheduleDto,
) {
await this.trainSchedulingService.maintenanceReschedule(id, dto);
return this.trainSchedulingService.getContainerTrainScheduleById(id);
}
@Post("schedules/:id/doc-review-complete")
@TrainSchedulingManage()
@ApiOperation({

View File

@@ -91,6 +91,7 @@ import {
import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduling-global-rules.dto';
import { UpdateScheduleWindowRuleDto } from './dto/update-schedule-window-rule.dto';
import { UpdateScheduleDateDto } from './dto/update-schedule-date.dto';
import { MaintenanceRescheduleDto } from './dto/maintenance-reschedule.dto';
import { type BookingWindowConfig } from './booking-window.config';
import { BookingWindowGateway } from './booking-window.gateway';
import { BookingNotifierService } from './booking-notifier.service';
@@ -864,6 +865,121 @@ export class TrainSchedulingService {
return fresh ?? schedule;
}
/**
* Maintenance reschedule: the admin moves a train (with everything aboard) to
* a new departure. Unlike {@link updateScheduleDate} this runs at ANY window
* phase and inside the booking lead window — a maintenance move is an
* operational fact, not a planning choice. What moves and what stays:
*
* - MOVES: scheduledDepartureDate; scheduledArrivalDate (same delta); every
* aboard/targeted booking's scheduledDate (the day-pool queries key on it,
* so a booking left on the old day would fall out of its own train's pool).
* - STAYS: train set, wagon assignments, schedule↔booking links, route,
* maxWagons, and the window RULE snapshot. Stamped window times are only
* re-derived for PRE_WINDOW schedules (their window hasn't run yet); a
* schedule mid- or post-window keeps its timeline untouched.
*
* Customers of every moved booking are notified (maintenanceMoved).
*/
async maintenanceReschedule(
id: string,
dto: MaintenanceRescheduleDto,
): Promise<TrainSchedule> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id);
if (!schedule) {
throw new NotFoundException(`Train schedule ${id} not found`);
}
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
throw new BadRequestException(
`Cannot reschedule a ${schedule.status.toLowerCase()} train`,
);
}
const departure = new Date(dto.newDepartureDate);
if (Number.isNaN(departure.getTime())) {
throw new BadRequestException('Invalid departure date.');
}
if (departure.getTime() <= Date.now()) {
throw new BadRequestException('New departure must be in the future.');
}
const deltaMs =
departure.getTime() - new Date(schedule.scheduledDepartureDate).getTime();
const scheduledArrivalDate = schedule.scheduledArrivalDate
? new Date(new Date(schedule.scheduledArrivalDate).getTime() + deltaMs)
: undefined;
// PRE_WINDOW only: the stamped open/close were derived from the old
// departure and the window hasn't opened yet, so re-derive them from the
// schedule's own rule snapshot against the new date (joining the target
// day's route group timeline when one exists, exactly like
// updateScheduleDate). Mid/post-window schedules keep their timeline.
const windowFields =
schedule.windowPhase === 'PRE_WINDOW'
? await (async () => {
const merged = effectiveWindowConfig(
schedule,
await this.getWindowConfig(),
);
const times =
schedule.direction === 'EXPORT'
? computeExportWindowTimes(departure, merged)
: computeImportWindowTimes(departure, merged, new Date());
const anchor =
schedule.direction === 'EXPORT'
? null
: await this.findGroupWindowAnchor(
this.dataSource.manager,
schedule.originStationId,
schedule.destinationStationId,
departure,
);
return anchor
? this.groupWindowFieldsFrom(anchor, departure)
: {
windowOpensAt: times.windowOpensAt,
windowClosesAt: times.windowClosesAt,
};
})()
: {};
await this.dataSource.getRepository(TrainSchedule).update(id, {
scheduledDepartureDate: departure,
...(scheduledArrivalDate ? { scheduledArrivalDate } : {}),
...windowFields,
});
// Everything aboard or targeted rides along: bookings linked on the train
// (schedule_bookings) plus reservations still pointing at it via
// train_schedule_id (paid-but-unlinked, awaiting payment, …).
const linkedIds = (schedule.scheduleBookings ?? []).map((sb) => sb.bookingId);
const targeted = await this.dataSource.getRepository(Booking).find({
where: [{ trainScheduleId: id }, ...(linkedIds.length ? [{ id: In(linkedIds) }] : [])],
relations: { company: true },
});
const aboard = targeted.filter(
(b) => !['CANCELLED', 'EXPIRED', 'REJECTED'].includes(b.status),
);
if (aboard.length) {
await this.dataSource
.getRepository(Booking)
.update(aboard.map((b) => b.id), { scheduledDate: departure } as never);
for (const booking of aboard) {
this.bookingNotifier.maintenanceMoved(booking, departure);
}
}
this.logger.log(
`[MAINTENANCE] Schedule ${schedule.reference ?? id} moved to ${departure.toISOString()} ` +
`(${dto.trigger ?? 'TRAIN_MAINTENANCE'}${dto.reason ? `: ${dto.reason}` : ''}); ` +
`${aboard.length} booking(s) moved with the train.`,
);
void this.emitWindowState(id);
const fresh = await this.trainSchedulesRepository.findById(id);
return fresh ?? schedule;
}
/**
* Re-derive windowOpensAt/windowClosesAt for schedules whose booking window has
* not opened yet (windowPhase === 'PRE_WINDOW', still Draft/Scheduled, departure
@@ -4717,6 +4833,7 @@ export class TrainSchedulingService {
createdAt: schedule.createdAt ?? null,
scheduleDate: schedule.scheduledDepartureDate,
trainNumber: schedule.trainNumber ?? null,
direction: schedule.direction ?? null,
routeName: schedule.route ? formatRouteLabel(schedule.route) : null,
origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null,
destination:
@@ -5942,6 +6059,64 @@ export class TrainSchedulingService {
(snapshot?.slots ?? []).map((slot) => [slot.trainSetWagonId, slot]),
);
// The trainSet slots below are the PLANNED wagons (one per allocation). A
// schedule tied to a built train hauls EVERY coupled wagon — empty ones
// included (the pull-limit check already counts their tare) — so append the
// train's remaining wagons as consist-only entries and the composition views
// (scheduling-v2 finalize, batch-board composition tab) draw the train as it
// really is: loaded slots first, then the empty consist. Skipped for frozen
// (dispatched/arrived) schedules: their wagons are released and re-pinned to
// later trains, so the live consist no longer describes THIS departure.
const coveredPhysicalIds = new Set<string>();
for (const slot of schedule.trainSet?.wagons ?? []) {
const frozenSlot = isWagonAllocationFrozen
? snapshotSlotByTrainSetWagonId.get(slot.id)
: undefined;
const physicalId = frozenSlot
? frozenSlot.physicalWagonId
: slot.physicalWagonId ?? null;
if (physicalId) coveredPhysicalIds.add(physicalId);
}
const maxSlotSequenceNo = Math.max(
0,
...(schedule.trainSet?.wagons ?? []).map((w) => w.sequenceNo),
);
const emptyConsistWagons =
schedule.trainSet?.trainId && !isWagonAllocationFrozen
? (
await this.dataSource.getRepository(Wagon).find({
where: { trainId: schedule.trainSet.trainId },
relations: { wagonType: true },
order: { sequenceNumber: 'ASC' },
})
)
.filter((wagon) => !coveredPhysicalIds.has(wagon.id))
.map((wagon, index) => ({
// Physical wagon id — there is no TrainSetWagon slot behind this
// row, so remove/edit affordances must stay disabled (consistOnly).
id: wagon.id,
sequenceNo: maxSlotSequenceNo + index + 1,
capacityTons: roundTons(Number(wagon.wagonType?.capacityTons ?? 0)),
lengthMeters: roundTons(Number(wagon.wagonType?.lengthMeters ?? 0)),
assignedWeightTons: 0,
tareWeightTons: wagon.wagonType
? roundTons(Number(wagon.wagonType.tareWeightTons))
: null,
status: 'EMPTY',
physicalWagonId: wagon.id,
physicalWagonNumber: wagon.wagonNumber ?? null,
wagonType: wagon.wagonType
? {
id: wagon.wagonType.id,
code: wagon.wagonType.code,
name: wagon.wagonType.name,
}
: null,
allocations: [],
consistOnly: true,
}))
: [];
return {
id: schedule.id,
reference: schedule.reference ?? null,
@@ -6109,7 +6284,8 @@ export class TrainSchedulingService {
: null,
})) ?? [],
};
}),
})
.concat(emptyConsistWagons),
}
: null,
bookings:

View File

@@ -421,6 +421,20 @@ export class WarehouseInventoryController {
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')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'View goods received note PDF' })

View File

@@ -2821,6 +2821,16 @@ export class WarehouseInventoryService {
: dto;
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 manager.getRepository(WarehouseInventory).update(id, {
releaseDate,
@@ -2848,6 +2858,23 @@ export class WarehouseInventoryService {
// an EXPORT concept (set when a truck delivers into the port). Import
// 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
// handover is signed ONCE (before the first truck leaves), even though
// trucks pick up per-container — COALESCE keeps the first timestamp.
@@ -2868,6 +2895,34 @@ export class WarehouseInventoryService {
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(
{
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);
}
/**
* 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 }> {
const [row] = await this.dataSource.query(
`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: {
reference: string;
bookingReference: string;
@@ -3728,20 +3899,30 @@ export class WarehouseInventoryService {
);
} else {
// EDR last-mile: the handover is per delivering truck. Resolve the
// vehicle that carried this item's container so each truck gets its own
// handover (falls back to a booking-level one when unresolvable).
// vehicle from the truck's own container list (the earlier lookup went
// 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;
if (item.containerId) {
const [veh]: Array<{ plate: string | null }> = await manager.query(
`SELECT COALESCE(v.power_plate_no, v.plate_number) AS plate
FROM freight.last_mile_container_allocations lca
JOIN freight.vehicles v ON v.id = lca.vehicle_id
WHERE lca.container_id = $1 AND lca.vehicle_id IS NOT NULL
LIMIT 1`,
[item.containerId],
);
truckPlate = veh?.plate ?? null;
}
const [veh]: Array<{ plate: string | null }> = await manager.query(
`SELECT COALESCE(v.power_plate_no, v.plate_number) AS plate
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.vehicles v ON v.id = va.vehicle_id
LEFT JOIN freight.last_mile_vehicle_containers vc
ON vc.assignment_id = va.id AND vc.deleted_at IS NULL
LEFT JOIN freight.containers cont
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);
}
}

View File

@@ -4,3 +4,8 @@ VITE_BASE_API_URL=http://localhost:3001
# Proactive token refresh cadence (minutes). Must stay well under the 60-min
# server session window. Default: 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

View File

@@ -20,6 +20,7 @@
"@mantine/core": "^9.3.0",
"@mantine/dates": "^9.3.0",
"@mantine/hooks": "^9.3.0",
"@posthog/react": "^1.10.3",
"@radix-ui/react-accordion": "^1.2.13",
"@radix-ui/react-alert-dialog": "^1.1.16",
"@radix-ui/react-avatar": "^1.1.12",
@@ -76,6 +77,7 @@
"lucide-react": "^1.14.0",
"next-themes": "^0.4.6",
"pdf-lib": "^1.17.1",
"posthog-js": "^1.400.1",
"prop-types": "^15.8.1",
"qs": "^6.15.2",
"radix-ui": "^1.4.3",

View File

@@ -7,6 +7,7 @@ import {
type ReactNode,
} from "react";
import { useIdentify } from "@/lib/posthog";
import { getMeRequest, loginRequest, verifyMfaRequest } from "./api";
import {
AUTH_TOKEN_COOKIE,
@@ -69,6 +70,9 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
const [loading, setLoading] = useState(true);
const mfaEmailRef = useRef<string | null>(null);
// Attribute replays and exceptions to the signed-in user (id/org only).
useIdentify(user);
const loadCurrentUser = async () => {
const currentUser = await getMeRequest();
setUser(currentUser);

View File

@@ -5,6 +5,7 @@ import {
emitApiError,
extractApiErrorPayload,
} from "@/components/errors/ApiErrorModal";
import { captureApiError } from "@/lib/posthog";
import {
AUTH_TOKEN_COOKIE,
REFRESH_TOKEN_COOKIE,
@@ -82,6 +83,14 @@ api.interceptors.response.use(
async (error) => {
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 (
error.response?.status !== 401 ||
!originalRequest ||

View File

@@ -1,5 +1,7 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
import { captureException } from "@/lib/posthog";
interface ErrorBoundaryProps {
children: ReactNode;
}
@@ -21,6 +23,7 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
}
componentDidCatch(error: Error, info: ErrorInfo) {
captureException(error, { componentStack: info.componentStack });
// eslint-disable-next-line no-console
console.error("[ErrorBoundary] Uncaught render error:", error, info.componentStack);
}

View File

@@ -26,6 +26,7 @@ import {
TextInput,
ThemeIcon,
Title,
Tooltip,
} from "@mantine/core";
import {
AlertCircle,
@@ -642,6 +643,20 @@ export default function GlCreateBookingForm() {
});
}, [isContainer, contract, containerLines, contractWithReturn]);
// 20ft containers ride two per wagon, so an odd total leaves one unpaired and
// the booking can never be planned. The server rejects it too (the price
// modal's `pairingErrors`), but that only lands after GL has filled the whole
// form — mirror the customer portal (new-booking-form/schema.ts `calcWagons`)
// and block it inline instead. Size strings arrive as "20ft" from the contract
// scope but as a bare "20" from the rebook seed, so match on the leading digits.
const ft20Total = useMemo(() => {
if (!isContainer) return 0;
return containerLines
.filter((l) => parseInt(l.containerSize, 10) === 20)
.reduce((sum, l) => sum + Number(l.quantity || 0), 0);
}, [isContainer, containerLines]);
const hasOdd20ft = ft20Total % 2 === 1;
const bulkUom = contract ? bulkUnitOfMeasure(contract) : "PER_TON";
const bulkErrors = useMemo<BulkErrors>(() => {
@@ -688,7 +703,7 @@ export default function GlCreateBookingForm() {
)
: !bulkErrors.quantity && !bulkErrors.hazardous && !bulkErrors.reefer;
const formValid = cargoValid && !dateError && !routeError;
const formValid = cargoValid && !hasOdd20ft && !dateError && !routeError;
/** The create-booking DTO from the current form state — shared by the
* authoritative price preview and the actual submit so what GL confirms is
@@ -1286,6 +1301,21 @@ export default function GlCreateBookingForm() {
</Box>
))
)}
{hasOdd20ft ? (
<Alert
color="red"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
title={`Odd number of 20ft containers (${ft20Total})`}
>
20ft containers travel two per wagon, so they must be booked in
even numbers. Add one more 20ft container or remove one (e.g.
book {ft20Total + 1} or {ft20Total - 1} instead of {ft20Total})
the booking cannot be created with an unpaired 20ft container.
</Alert>
) : null}
</Stack>
</StepCard>
) : (
@@ -1530,14 +1560,27 @@ export default function GlCreateBookingForm() {
</Alert>
) : null}
<Group justify="flex-end">
<Button
color="edr-green"
radius="md"
leftSection={<Receipt size={16} />}
onClick={openPriceModal}
<Tooltip
label={`Book an even number of 20ft containers — ${ft20Total} is odd and would leave one unpaired.`}
withArrow
disabled={!hasOdd20ft}
>
Review price &amp; book
</Button>
{/* Mantine tooltips get no pointer events from a disabled button,
so the wrapper carries the hover target. */}
<Box>
<Button
color="edr-green"
radius="md"
leftSection={<Receipt size={16} />}
onClick={openPriceModal}
// Same hard block the customer portal applies at review time —
// an unpaired 20ft can never be planned onto a wagon.
disabled={hasOdd20ft}
>
Review price &amp; book
</Button>
</Box>
</Tooltip>
</Group>
</Box>
</Box>

View File

@@ -36,7 +36,9 @@ export function PinWagonsForm({
autoFillOnMount?: boolean;
}) {
const originYardId = schedule.originStation?.id;
const slots = schedule.trainSet?.wagons ?? [];
// Consist-only rows are the built train's coupled-but-empty wagons — display
// entries with no TrainSetWagon slot behind them, so nothing can be pinned.
const slots = (schedule.trainSet?.wagons ?? []).filter((w) => !w.consistOnly);
const [assignments, setAssignments] = useState<Record<string, string>>({});
const wagonOptionsByType = useMemo(() => {

View File

@@ -359,7 +359,9 @@ function WagonCar({
{isEmpty ? (
<Text size="xs" c="dimmed">
Empty slot available for allocation.
{wagon.consistOnly
? "Empty wagon — coupled on the train, no load planned."
: "Empty slot — available for allocation."}
</Text>
) : (
<Stack gap={6}>

View File

@@ -167,9 +167,9 @@ export const WagonCard = ({
<TrainFront size={18} />
</ThemeIcon>
<Text size="sm" c="dimmed">
Empty slot
{wagon.consistOnly ? "Empty wagon — coupled on the train" : "Empty slot"}
</Text>
{!isDispatched ? (
{!isDispatched && !wagon.consistOnly ? (
<Button
variant="subtle"
color="gray"

View File

@@ -13,3 +13,6 @@ export function fileViewUrl(fileId: string, download = false): string {
const base = `${API_BASE_URL}/api/files/${fileId}`;
return download ? `${base}?download=1` : base;
}

View 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,
]);
}

View File

@@ -2,6 +2,8 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { MantineProvider } from "@mantine/core";
import posthog from "posthog-js";
import { PostHogProvider } from "@posthog/react";
import "@mantine/core/styles.css";
import "@mantine/dates/styles.css";
import "@edr/ui-common/styles.css";
@@ -19,6 +21,7 @@ import { ApiErrorModal } from "./components/errors/ApiErrorModal";
import { ErrorBoundary } from "./components/ErrorBoundary";
import { AuthProvider } from "./auth/AuthProvider";
import { queryClient } from "./lib/queryClient";
import { initPostHog } from "./lib/posthog";
import { freightMantineTheme } from "./theme/freight-brand";
import { QueryClientProvider } from "@tanstack/react-query";
@@ -43,6 +46,10 @@ const 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");
if (!rootElement) {
@@ -50,23 +57,25 @@ if (!rootElement) {
}
createRoot(rootElement).render(
<QueryClientProvider client={queryClient}>
<MantineProvider theme={freightMantineTheme}>
<StrictMode>
<BrowserRouter>
<AuthProvider>
<ErrorBoundary>
<App />
</ErrorBoundary>
{/* Global API error modal — shows the server's actual error
message (suppressed on warehouse / mile / onboarding pages). */}
<ApiErrorModal />
<Toaster position="top-right" />
</AuthProvider>
</BrowserRouter>
</StrictMode>
</MantineProvider>
</QueryClientProvider>
<PostHogProvider client={posthog}>
<QueryClientProvider client={queryClient}>
<MantineProvider theme={freightMantineTheme}>
<StrictMode>
<BrowserRouter>
<AuthProvider>
<ErrorBoundary>
<App />
</ErrorBoundary>
{/* Global API error modal — shows the server's actual error
message (suppressed on warehouse / mile / onboarding pages). */}
<ApiErrorModal />
<Toaster position="top-right" />
</AuthProvider>
</BrowserRouter>
</StrictMode>
</MantineProvider>
</QueryClientProvider>
</PostHogProvider>
);
// run

View File

@@ -5,6 +5,7 @@ import {
Alert,
Badge,
Box,
Button,
Grid,
Group,
Loader,
@@ -21,10 +22,18 @@ import {
CheckCircle2,
Clock,
PackageCheck,
RefreshCw,
ShieldCheck,
} from "lucide-react";
import { Link } from "react-router-dom";
import { useAuth } from "@/auth/useAuth";
import {
FREIGHT_PERMS,
hasPermission,
isDjiboutiGl,
} from "@/lib/permissions";
import { ClearanceOpsTabs } from "@/components/contracts/ClearanceOpsTabs";
import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
@@ -44,6 +53,7 @@ import {
export default function ContractClearanceDetailPage() {
const { id } = useParams<{ id: string }>();
const { view, viewer } = useFileViewer();
const { user } = useAuth();
const { data: contract, refetch: refetchContract } = useContractDetail(id);
const {
@@ -106,6 +116,16 @@ export default function ContractClearanceDetailPage() {
const reviewReadOnly = shipmentLocked;
const queriesLocked = Boolean(clearance?.preClearanceFinalized);
const bookingHref = `/dashboard/contracts/${id}/create-booking`;
// The GL-created booking expired unpaid — the slot is free again and GL
// rebooks on the customer's behalf (customs bookings are never self-booked).
const bookingExpired = clearance?.linkedBookingStatus === "EXPIRED";
const canRebook =
bookingExpired &&
hasPermission(user, FREIGHT_PERMS.contracts.createBooking) &&
!isDjiboutiGl(user);
const rebookHref = linkedBookingId
? `${bookingHref}?copyFrom=${linkedBookingId}`
: bookingHref;
const { data: bookingMilestones, refetch: refetchBookingMilestones } =
useBookingMilestones(linkedBookingId);
@@ -165,7 +185,16 @@ export default function ContractClearanceDetailPage() {
{ label: reference },
]}
meta={
bookingAlreadyCreated ? (
bookingExpired ? (
<Badge
variant="light"
color="orange"
radius="sm"
leftSection={<RefreshCw size={13} />}
>
Payment expired rebook
</Badge>
) : bookingAlreadyCreated ? (
<Badge
variant="light"
color="blue"
@@ -211,7 +240,49 @@ export default function ContractClearanceDetailPage() {
it can actually create the booking without checking the schedule board. */}
{id ? <GlUpcomingWindowsSection contractId={id} /> : null}
{bookingAlreadyCreated ? (
{bookingExpired ? (
<Alert
color="orange"
radius="md"
icon={<RefreshCw size={16} />}
title="Booking expired — payment not received"
>
<Stack gap="sm" align="flex-start">
<Text size="sm">
The customer did not pay before the deadline, so the booking
expired and its train slot was released. The contract slot is
free again GL Ethiopia can rebook on the customer&apos;s
behalf without re-running clearance.
{linkedBookingId ? (
<>
{" "}
<Text
component={Link}
to={`/dashboard/bookings/${linkedBookingId}/clearance`}
inherit
fw={600}
c="orange.8"
>
View expired booking
</Text>
</>
) : null}
</Text>
{canRebook ? (
<Button
component={Link}
to={rebookHref}
color="grape"
radius="md"
size="sm"
leftSection={<RefreshCw size={15} />}
>
Rebook for customer
</Button>
) : null}
</Stack>
</Alert>
) : bookingAlreadyCreated ? (
<Alert
color="blue"
radius="md"

View File

@@ -92,6 +92,10 @@ interface ClearanceRow {
ready: boolean;
/** true once GL Ethiopia created the shipment booking. */
bookingCreated: boolean;
/** true when the created booking EXPIRED unpaid — GL must rebook. */
paymentExpired: boolean;
/** The expired booking, so rebook can copy its cargo. */
expiredBookingId: string | null;
}
function yardLabel(
@@ -145,6 +149,13 @@ function toClearanceRow(contract: Freight.IContract): ClearanceRow {
status: contract.status,
ready: contract.status === "CLEARANCE_READY_FOR_BOOKING",
bookingCreated: contract.status === "ACTIVE_SHIPMENT_IN_PROGRESS",
paymentExpired:
contract.status === "ACTIVE_SHIPMENT_IN_PROGRESS" &&
contract.latestCycleBookingStatus === "EXPIRED",
expiredBookingId:
contract.latestCycleBookingStatus === "EXPIRED"
? (contract.latestCycleBookingId ?? null)
: null,
};
}
@@ -186,6 +197,24 @@ function DirectionIcon({ direction }: { direction: string }) {
}
function StatusBadge({ row }: { row: ClearanceRow }) {
if (row.paymentExpired) {
return (
<Tooltip
label="The customer did not pay in time — the booking expired. GL rebooks on the customer's behalf."
withArrow
>
<Badge
size="sm"
variant="light"
color="orange"
radius="sm"
leftSection={<RefreshCw size={12} />}
>
Payment expired
</Badge>
</Tooltip>
);
}
if (row.bookingCreated) {
return (
<Tooltip label="GL Ethiopia created the shipment booking" withArrow>
@@ -519,6 +548,27 @@ export default function ContractClearanceListPage() {
Create booking
</Button>
</Group>
) : row.original.paymentExpired && canCreateBooking ? (
<Group justify="flex-end" pr="xs">
<Button
size="compact-sm"
color="grape"
radius="md"
leftSection={<RefreshCw size={14} />}
onClick={(e) => {
e.stopPropagation();
navigate(
`/dashboard/contracts/${row.original.id}/create-booking${
row.original.expiredBookingId
? `?copyFrom=${row.original.expiredBookingId}`
: ""
}`,
);
}}
>
Rebook
</Button>
</Group>
) : (
<Group justify="flex-end" pr="xs">
<ChevronRight size={16} className="text-muted-foreground" />

View File

@@ -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>
);
}

View File

@@ -9,6 +9,7 @@ import {
RefreshCw,
Ruler,
Trash,
FileText,
Truck,
X,
} from "lucide-react";
@@ -56,6 +57,7 @@ import {
import { vehiclesService } from "@/services/vehicles.service";
import { driversService, type Driver } from "@/services/drivers.service";
import { ReleaseOrderModal, type ReleaseOrderTruckPrefill } from "@/components/warehouses/ReleaseOrderModal";
import { EdrTruckExitPapersModal } from "./EdrTruckExitPapersModal";
import { TruckDetentionModal } from "@/components/operations/TruckDetentionModal";
import { ProofOfDeliveryModal } from "@/components/operations/ProofOfDeliveryModal";
import { LastMileStepper, type LastMileStepState } from "@/components/operations/LastMileSteps";
@@ -124,10 +126,22 @@ const containerCount = (record: LastMileRecord) =>
(sum, c) => sum + (Number(c.quantity) || 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 n = containerCount(record);
return n > 0 ? Math.ceil(n / CONTAINERS_PER_VEHICLE) : 0;
const lines = record.booking?.bookingContainers ?? [];
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
@@ -581,9 +595,11 @@ const LastMilePage = () => {
const [activeId, setActiveId] = useState<string | null>(null);
const [detentionRecord, setDetentionRecord] = useState<LastMileRecord | null>(null);
// 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<
Array<{ vehicleId: string | null; containerNumber: string }>
>([{ vehicleId: null, containerNumber: "" }]);
Array<{ vehicleId: string | null; containerNumbers: string[] }>
>([{ vehicleId: null, containerNumbers: [] }]);
// 2-step "Assign Mile" accept modal (arrival queue → vehicle)
const [acceptOpen, setAcceptOpen] = useState(false);
@@ -1000,31 +1016,59 @@ const LastMilePage = () => {
return filteredRecords.slice(start, start + pagination.pageSize);
}, [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 resolved = id ?? filteredRecords.find((r) => !isAssigned(r))?.id ?? null;
const rec = records.find((r) => r.id === resolved);
// Prefill each row's container number from the booking's container numbers
// (by order) when the assignment doesn't already carry one.
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 =
rec?.vehicleAssignments?.length
? rec.vehicleAssignments.map((a, i) => ({
vehicleId: a.vehicleId,
containerNumber: a.containerNumber ?? nums[i] ?? "",
containerNumbers: loadOf(a, i),
}))
: rec?.vehicleId
? [{ vehicleId: rec.vehicleId, containerNumber: nums[0] ?? "" }]
: [{ vehicleId: null, containerNumber: nums[0] ?? "" }];
? [{ vehicleId: rec.vehicleId, containerNumbers: nums[0] ? [nums[0]] : [] }]
: [{ vehicleId: null, containerNumbers: nums[0] ? [nums[0]] : [] }];
setBulkMode(false);
setActiveId(resolved);
setVehicleRows(rows.length ? rows : [{ vehicleId: null, containerNumber: nums[0] ?? "" }]);
setVehicleRows(
rows.length ? rows : [{ vehicleId: null, containerNumbers: nums[0] ? [nums[0]] : [] }],
);
setAssignOpen(true);
};
const openBulkAssign = () => {
setBulkMode(true);
setActiveId(null);
setVehicleRows([{ vehicleId: null, containerNumber: "" }]);
setVehicleRows([{ vehicleId: null, containerNumbers: [] }]);
setAssignOpen(true);
};
@@ -1032,15 +1076,18 @@ const LastMilePage = () => {
setAssignOpen(false);
setBulkMode(false);
setActiveId(null);
setVehicleRows([{ vehicleId: null, containerNumber: "" }]);
setVehicleRows([{ vehicleId: null, containerNumbers: [] }]);
};
const handleAssign = () => {
const seen = new Set<string>();
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)))
.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 targetIds = bulkMode
? selectedIds
@@ -1419,6 +1466,16 @@ const LastMilePage = () => {
>
Truck Leaving
</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.Item
leftSection={<Eye size={15} />}
@@ -1738,9 +1795,24 @@ const LastMilePage = () => {
const needed = requiredVehicles(activeRecord);
const picked = vehicleRows.filter((r) => r.vehicleId).length;
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 (
<Alert variant="light" color="gray" title="One truck (with trailer) carries 2 containers">
No container count on this booking assign trucks as needed.
<Alert
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>
);
}
@@ -1799,25 +1871,27 @@ const LastMilePage = () => {
clearable
disabled={assignVehicleOptions.length === 0}
/>
<Select
<MultiSelect
style={{ flex: 1 }}
label={i === 0 ? "Container no." : undefined}
placeholder={containerOptions.length ? "Select container" : "No container numbers"}
label={i === 0 ? "Containers (1x40ft or 2x20ft)" : undefined}
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={[
...containerOptions.filter(
(n) =>
n === row.containerNumber ||
!vehicleRows.some((r, idx) => idx !== i && r.containerNumber === n),
row.containerNumbers.includes(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
...(row.containerNumber && !containerOptions.includes(row.containerNumber)
? [row.containerNumber]
: []),
// keep manual/legacy values selectable even if not in the booking
...row.containerNumbers.filter((n) => !containerOptions.includes(n)),
]}
value={row.containerNumber || null}
value={row.containerNumbers}
onChange={(value) =>
setVehicleRows((prev) =>
prev.map((x, idx) => (idx === i ? { ...x, containerNumber: value ?? "" } : x)),
prev.map((x, idx) => (idx === i ? { ...x, containerNumbers: value } : x)),
)
}
searchable
@@ -1840,14 +1914,14 @@ const LastMilePage = () => {
size="xs"
leftSection={<Plus size={14} />}
onClick={() =>
setVehicleRows((prev) => [
...prev,
{
vehicleId: null,
containerNumber:
(activeRecord ? bookingContainerNumbers(activeRecord)[prev.length] : "") ?? "",
},
])
setVehicleRows((prev) => {
// Suggest the next unassigned container for the new truck.
const taken = new Set(prev.flatMap((r) => r.containerNumbers));
const next = (activeRecord ? bookingContainerNumbers(activeRecord) : []).find(
(n) => !taken.has(n),
);
return [...prev, { vehicleId: null, containerNumbers: next ? [next] : [] }];
})
}
disabled={
assignVehicleOptions.length === 0 ||
@@ -2141,6 +2215,12 @@ const LastMilePage = () => {
truckPrefill={releaseTruckPrefill}
/>
<EdrTruckExitPapersModal
opened={exitPapersOpen}
onClose={() => setExitPapersOpen(false)}
record={exitPapersRecord}
/>
<TruckDetentionModal
opened={Boolean(detentionRecord)}
onClose={() => setDetentionRecord(null)}

View File

@@ -31,8 +31,6 @@ import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import BuildTrainModal from "@/components/trainBuilder/BuildTrainModal";
import EditTrainDetailsModal from "@/components/trainBuilder/EditTrainDetailsModal";
import {
directionColor,
directionRowStyle,
trainStatusColor,
trainStatusLabel,
} from "@/components/trainBuilder/trainStatus";
@@ -164,14 +162,9 @@ export default function TrainBuilderListPage() {
return (
<Stack gap={2}>
{active?.trainNumber ? (
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={700} ff="monospace" lh={1.2}>
{active.trainNumber}
</Text>
<Badge size="xs" variant="light" color={directionColor(active.direction)}>
{active.direction ?? "—"}
</Badge>
</Group>
<Text size="sm" fw={700} ff="monospace" lh={1.2}>
{active.trainNumber}
</Text>
) : null}
<Text size="xs" c="dimmed" ff="monospace" lh={1.2}>
IMP {row.original.importTrainNumber ?? "—"} · EXP{" "}
@@ -348,7 +341,6 @@ export default function TrainBuilderListPage() {
data={trains}
status={tableStatus}
onRowClick={(train) => navigate(`/dashboard/train-builder/${train.id}`)}
rowStyle={(train) => directionRowStyle(train.activeSchedule?.direction)}
error={
trainsQuery.isError
? {

View File

@@ -1,6 +1,7 @@
import type { ColumnDef } from "@edr/ui-common";
import {
ActionIcon,
Badge,
Box,
Button,
Card,
@@ -38,6 +39,10 @@ import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
import {
directionColor,
directionRowStyle,
} from "@/components/trainBuilder/trainStatus";
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
import EditScheduleDateModal from "@/components/trainScheduling/EditScheduleDateModal";
import { showScheduleWarnings } from "@/components/trainScheduling/locomotiveOptions";
@@ -303,9 +308,20 @@ export default function TrainScheduleV2ListPage() {
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Stack gap={4}>
<Text size="sm" fw={600} lh={1.2}>
{row.original.routeName ?? "—"}
</Text>
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={600} lh={1.2}>
{row.original.routeName ?? "—"}
</Text>
{row.original.direction ? (
<Badge
size="xs"
variant="light"
color={directionColor(row.original.direction)}
>
{row.original.direction}
</Badge>
) : null}
</Group>
<Box maw={220}>
<RouteCorridor
origin={row.original.origin}
@@ -660,6 +676,7 @@ export default function TrainScheduleV2ListPage() {
onRowClick={(schedule) =>
navigate(`/dashboard/operations/train-scheduling-v2/${schedule.id}`)
}
rowStyle={(schedule) => directionRowStyle(schedule.direction)}
error={
schedulesQuery.isError
? {
@@ -909,7 +926,18 @@ function ScheduleCard({
</Box>
<Group justify="space-between" align="center">
<FreightTypeBadge freightType={schedule.freightType} />
<Group gap={6} wrap="nowrap">
<FreightTypeBadge freightType={schedule.freightType} />
{schedule.direction ? (
<Badge
size="xs"
variant="light"
color={directionColor(schedule.direction)}
>
{schedule.direction}
</Badge>
) : null}
</Group>
<Group gap={6} wrap="nowrap">
<MetricChip value={schedule.bookingsCount} label="bkg" />
<MetricChip value={schedule.wagonCount} label="wgn" />

View File

@@ -1,5 +1,7 @@
import React, { Component, ErrorInfo, ReactNode } from "react";
import { captureException } from "@/lib/posthog";
interface ErrorBoundaryProps {
children: ReactNode;
}
@@ -20,7 +22,8 @@ export class ErrorBoundary extends Component<
}
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) {
console.error("ErrorBoundary caught:", error, info.componentStack);
}

View File

@@ -67,8 +67,16 @@ export interface LastMileRecord {
vehicleAssignments?: Array<{
id: string;
vehicleId: string;
/** @deprecated Legacy single container — `containers` is authoritative. */
containerNumber?: string | null;
/** Containers riding this truck: one 40ft, or up to two 20ft. */
containers?: Array<{ id: string; containerNumber: string }>;
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;
}>;
/** 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)),
setVehicles: (
id: string,
vehicles: Array<{ vehicleId: string; containerNumber?: string | null }>,
vehicles: Array<{ vehicleId: string; containerNumbers?: string[] }>,
) => 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: (
id: string,
distances: Array<{ vehicleId: string; distanceKm: number }>,

View File

@@ -323,6 +323,11 @@ export const warehouseService = {
apiClient.get<Blob>(`/warehouse-inventory/customer-truck-exit-paper/${assignmentId}`, {
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) =>
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.DELIVER(id), payload),

View File

@@ -168,6 +168,8 @@ export interface TrainScheduleListItem {
createdAt?: string | null;
scheduleDate: string;
trainNumber?: string | null;
/** Trade direction of this departure (IMPORT / EXPORT), when known. */
direction?: string | null;
routeName?: string | null;
origin: string | null;
destination: string | null;
@@ -608,6 +610,11 @@ export interface TrainScheduleDetail {
name: string;
} | null;
allocations: TrainScheduleWagonAllocation[];
/**
* Coupled-but-empty wagon of the built train — no TrainSetWagon slot
* behind it, so remove/edit actions do not apply.
*/
consistOnly?: boolean;
}>;
} | null;
bookings: Array<{

View File

@@ -17,6 +17,11 @@ interface Window {
interface ImportMetaEnv {
readonly VITE_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 {

View File

@@ -18,6 +18,7 @@
"@mantine/core": "^9.3.0",
"@mantine/dates": "^9.3.0",
"@mantine/hooks": "^9.3.0",
"@posthog/react": "^1.10.3",
"@tanstack/react-query": "^5.59.0",
"@tria-plc/iamui": "file:../../../local-packages/tria-plc-iamui-0.1.1.tgz",
"@vis.gl/react-google-maps": "^1.8.3",
@@ -26,6 +27,7 @@
"clsx": "^2.1.1",
"date-fns": "^3.6.0",
"lucide-react": "^1.14.0",
"posthog-js": "^1.400.1",
"radix-ui": "^1.4.3",
"react": "19.2.6",
"react-dom": "19.2.6",

View File

@@ -25,6 +25,7 @@ import OnboardingResumeBanner, {
import OnboardingWizardDialog from "./components/onboarding/OnboardingWizardDialog";
import { ApiErrorModal } from "./components/errors/ApiErrorModal";
import useAuth from "./hooks/useAuth";
import { useIdentify } from "./lib/posthog";
import {
startTokenRefreshScheduler,
stopTokenRefreshScheduler,
@@ -221,6 +222,9 @@ const App = () => {
const { user, company, companyType, createProfile, isAuthenticated } =
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
// login, signup, and page-reload bootstrap alike.
useEffect(() => {

View File

@@ -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;

View 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,
]);
}

View File

@@ -3,6 +3,8 @@ import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MantineProvider } from "@mantine/core";
import posthog from "posthog-js";
import { PostHogProvider, PostHogErrorBoundary } from "@posthog/react";
import "@mantine/core/styles.css";
import "@mantine/dates/styles.css";
import "@edr/ui-common/styles.css";
@@ -10,6 +12,8 @@ import "../index.css";
import "@edr/ui-common/theme.css";
import { Toaster } from "react-hot-toast";
import { mantineTheme } from "./theme/mantine";
import { initPostHog } from "./lib/posthog";
import { AppErrorFallback } from "./components/errors/AppErrorFallback";
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 rootElement = document.getElementById("root");
@@ -36,13 +44,17 @@ if (!rootElement) {
createRoot(document.getElementById("root")!).render(
<StrictMode>
<MantineProvider theme={mantineTheme} defaultColorScheme="light">
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
<Toaster position="top-right" />
</BrowserRouter>
</QueryClientProvider>
</MantineProvider>
<PostHogProvider client={posthog}>
<MantineProvider theme={mantineTheme} defaultColorScheme="light">
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<PostHogErrorBoundary fallback={<AppErrorFallback />}>
<App />
</PostHogErrorBoundary>
<Toaster position="top-right" />
</BrowserRouter>
</QueryClientProvider>
</MantineProvider>
</PostHogProvider>
</StrictMode>,
);

View File

@@ -21,6 +21,7 @@ import {
Textarea,
ThemeIcon,
Title,
Tooltip,
} from "@mantine/core";
import {
AlertCircle,
@@ -269,6 +270,19 @@ function NewShipmentBookingForm({
mode: "onChange",
});
// 20ft containers ride two per wagon, so an odd total leaves one unpaired and
// the booking can never be planned. The server's shipment validation reports
// it too, but only once the price modal opens — block it inline instead, the
// same way the direct-booking wizard does (new-booking-form `calcWagons`).
const watchedContainers = form.watch("containers");
const ft20Total =
contract.freightType === "CONTAINER"
? (watchedContainers ?? [])
.filter((l) => l.containerSize === "20ft")
.reduce((sum, l) => sum + Number(l.quantity || 0), 0)
: 0;
const hasOdd20ft = ft20Total % 2 === 1;
const submitMutation = useMutation({
mutationFn: (dto: Freight.CreateBookingUnderContractDto) =>
completeBookingId
@@ -367,6 +381,8 @@ function NewShipmentBookingForm({
// run it for every freight type; container contracts additionally get
// overweight warnings + 20ft pairing hard-blocks surfaced in the modal.
const handleReview = form.handleSubmit((values) => {
// An unpaired 20ft can never be planned onto a wagon — don't even price it.
if (hasOdd20ft) return;
setPendingValues(values);
validateMutation.reset();
validateMutation.mutate(buildDto(values));
@@ -483,15 +499,26 @@ function NewShipmentBookingForm({
}}
>
<Group justify="flex-end" className="mx-auto max-w-4xl">
<Button
type="button"
color="edr-green"
radius="md"
leftSection={<Receipt size={16} />}
onClick={handleReview}
<Tooltip
label={`Book an even number of 20ft containers — ${ft20Total} is odd and would leave one unpaired.`}
withArrow
disabled={!hasOdd20ft}
>
Review price &amp; book
</Button>
{/* Mantine tooltips get no pointer events from a disabled button,
so the wrapper carries the hover target. */}
<Box>
<Button
type="button"
color="edr-green"
radius="md"
leftSection={<Receipt size={16} />}
onClick={handleReview}
disabled={hasOdd20ft}
>
Review price &amp; book
</Button>
</Box>
</Tooltip>
</Group>
</Box>
</form>
@@ -1265,6 +1292,28 @@ function CargoStep({
This contract has no container sizes in scope.
</Text>
)}
{(() => {
const ft20 = lines
.filter((l) => l.containerSize === "20ft")
.reduce((sum, l) => sum + Number(l.quantity || 0), 0);
if (ft20 % 2 !== 1) return null;
return (
<Alert
color="red"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
title={`Odd number of 20ft containers (${ft20})`}
>
<Text fz={13}>
20ft containers travel two per wagon, so they must be booked in
even numbers. Please add one more 20ft container or remove one
(e.g. book {ft20 + 1} or {ft20 - 1} instead of {ft20}) the
booking cannot be submitted with an unpaired 20ft container.
</Text>
</Alert>
);
})()}
</Stack>
</StepCard>
);

View File

@@ -2,6 +2,7 @@ import { useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
Alert,
Box,
Button,
Group,
@@ -13,7 +14,7 @@ import {
Textarea,
Title,
} from "@mantine/core";
import { ArrowLeft, CalendarDays, Send } from "lucide-react";
import { AlertCircle, ArrowLeft, CalendarDays, Send } from "lucide-react";
import toast from "react-hot-toast";
import type { Freight } from "@edr/types";
import { DatePickerInput } from "@mantine/dates";
@@ -98,7 +99,14 @@ export default function NewShipmentRequestPage() {
contract.cargoScope?.[0];
const isPerItem = bulkScope?.cargoType?.unitOfMeasure === "PER_ITEM";
// 20ft containers ride two per wagon, so an odd total can never be planned —
// and GL's create-booking form blocks it too, so an odd request would only
// dead-end there. Same even-number rule the booking forms apply.
const ft20Requested = isContainer ? Number(qtyBySize["20ft"]) || 0 : 0;
const hasOdd20ft = ft20Requested % 2 === 1;
const handleSubmit = () => {
if (hasOdd20ft) return;
const dto: Freight.CreateBookingRequestDto = {
contractRouteId: route?.id,
scheduledDate: hasCustoms ? undefined : scheduledDate || undefined,
@@ -202,6 +210,23 @@ export default function NewShipmentRequestPage() {
/>
)}
{hasOdd20ft ? (
<Alert
color="red"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
title={`Odd number of 20ft containers (${ft20Requested})`}
>
<Text fz={13}>
20ft containers travel two per wagon, so they must be requested
in even numbers. Please add one more 20ft container or remove
one (e.g. request {ft20Requested + 1} or {ft20Requested - 1}{" "}
instead of {ft20Requested}).
</Text>
</Alert>
) : null}
{capacity?.length ? (
<Text size="xs" c="dimmed">
Remaining capacity is shown on the contract GL will validate your request.
@@ -220,6 +245,7 @@ export default function NewShipmentRequestPage() {
leftSection={<Send size={16} />}
loading={submit.isPending}
onClick={handleSubmit}
disabled={hasOdd20ft}
>
Submit shipment request
</Button>

View File

@@ -6,6 +6,7 @@ import {
emitApiError,
extractApiErrorPayload,
} from "@/components/errors/ApiErrorModal";
import { captureApiError } from "@/lib/posthog";
const client = axios.create({
baseURL: API_BASE_URL,
@@ -91,6 +92,14 @@ client.interceptors.response.use(
_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:
// - no response (network error)
// - status is not 401

View File

@@ -16,7 +16,13 @@ interface Window {
interface ImportMetaEnv {
readonly VITE_API_URL: string;
readonly VITE_BASE_API_URL: 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 {

View File

@@ -0,0 +1,33 @@
-- CreateTable
CREATE TABLE "SupplementaryCharge" (
"id" TEXT NOT NULL,
"bookingId" TEXT NOT NULL,
"reason" TEXT NOT NULL,
"amountMinor" INTEGER NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'ETB',
"status" TEXT NOT NULL DEFAULT 'PENDING',
"paymentToken" TEXT NOT NULL,
"providerTxnId" TEXT,
"notes" TEXT,
"createdBy" TEXT NOT NULL,
"paidAt" TIMESTAMP(3),
"expiresAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SupplementaryCharge_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "SupplementaryCharge_paymentToken_key" ON "SupplementaryCharge"("paymentToken");
-- CreateIndex
CREATE INDEX "SupplementaryCharge_bookingId_idx" ON "SupplementaryCharge"("bookingId");
-- CreateIndex
CREATE INDEX "SupplementaryCharge_paymentToken_idx" ON "SupplementaryCharge"("paymentToken");
-- CreateIndex
CREATE INDEX "SupplementaryCharge_status_idx" ON "SupplementaryCharge"("status");
-- AddForeignKey
ALTER TABLE "SupplementaryCharge" ADD CONSTRAINT "SupplementaryCharge_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "SeatBlock" ADD COLUMN "scheduleId" TEXT;
-- CreateIndex
CREATE INDEX "SeatBlock_scheduleId_idx" ON "SeatBlock"("scheduleId");

View File

@@ -567,6 +567,7 @@ model Booking {
cancellation BookingCancellation?
baggage BaggageBooking[]
excessBaggageCharges ExcessBaggageCharge[]
supplementaryCharges SupplementaryCharge[]
journey Journey?
@@index([passengerId, status])
@@ -1234,6 +1235,28 @@ model BaggageBooking {
@@schema("passenger")
}
model SupplementaryCharge {
id String @id @default(uuid())
bookingId String
reason String // e.g. "UNDERPAYMENT", "FARE_CORRECTION"
amountMinor Int
currency String @default("ETB")
status String @default("PENDING") // PENDING | PAID | WAIVED | EXPIRED
paymentToken String @unique @default(uuid())
providerTxnId String?
notes String?
createdBy String
paidAt DateTime?
expiresAt DateTime?
createdAt DateTime @default(now())
booking Booking @relation(fields: [bookingId], references: [id])
@@index([bookingId])
@@index([paymentToken])
@@index([status])
@@schema("passenger")
}
model ExcessBaggageCharge {
id String @id @default(uuid())
bookingId String
@@ -1289,6 +1312,7 @@ model NotificationTemplate {
model SeatBlock {
id String @id @default(uuid())
seatId String
scheduleId String?
reason String
blockedBy String
approvedBy String?
@@ -1297,6 +1321,7 @@ model SeatBlock {
seat Seat @relation(fields: [seatId], references: [id])
@@index([seatId])
@@index([scheduleId])
@@schema("passenger")
}

View File

@@ -152,7 +152,7 @@ export class BookingsService {
id: booking.id,
bookingRef: booking.bookingRef,
status: booking.status,
totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
totalMinor: booking.displayTotalMinor ?? resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
currency: booking.displayCurrency,
displayCurrency: booking.displayCurrency,
displayTotalMinor: booking.displayTotalMinor,
@@ -296,7 +296,7 @@ export class BookingsService {
id: booking.id,
bookingRef: booking.bookingRef,
status: booking.status,
totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
totalMinor: booking.displayTotalMinor ?? resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
currency: booking.displayCurrency,
displayCurrency: booking.displayCurrency,
displayTotalMinor: booking.displayTotalMinor,
@@ -409,7 +409,7 @@ export class BookingsService {
id: booking.id,
bookingRef: booking.bookingRef,
status: booking.status,
totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
totalMinor: booking.displayTotalMinor ?? resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
currency: booking.displayCurrency,
displayCurrency: booking.displayCurrency,
displayTotalMinor: booking.displayTotalMinor,
@@ -554,7 +554,7 @@ export class BookingsService {
const uniquePassengers = Array.from(new Map(passengerDetails.map((p: any) => [p.name, p])).values());
return {
id: booking.id, bookingRef: booking.bookingRef, status: booking.status,
totalMinor: resolvePackageRoundTripTotal(booking, booking.priceTier?.priceMinor, booking.adultCount, booking.childCount),
totalMinor: booking.displayTotalMinor ?? resolvePackageRoundTripTotal(booking, booking.priceTier?.priceMinor, booking.adultCount, booking.childCount),
currency: booking.displayCurrency,
displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor,
contactEmail: booking.contactEmail, contactPhone: booking.contactPhone,
@@ -683,7 +683,7 @@ export class BookingsService {
id: booking.id,
bookingRef: booking.bookingRef,
status: booking.status,
totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
totalMinor: booking.displayTotalMinor ?? resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
currency: booking.displayCurrency,
displayCurrency: booking.displayCurrency,
displayTotalMinor: booking.displayTotalMinor,
@@ -1883,7 +1883,7 @@ export class BookingsService {
return {
id: booking.id, bookingRef: booking.bookingRef, status: booking.status,
totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
totalMinor: booking.displayTotalMinor ?? resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
currency: booking.displayCurrency,
adultCount: booking.adultCount, childCount: booking.childCount,
displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor ?? undefined,

View File

@@ -13,8 +13,8 @@ export class DashboardService {
async getBackofficeStats() {
const [totalBookings, totalPackageBookings, totalTickets, totalPassengers, revenueRows, packageRevenueRows] =
await Promise.all([
this.prisma.booking.count(),
this.prisma.booking.count({ where: { packageId: { not: null } } }),
this.prisma.booking.count({ where: { status: { in: ['CONFIRMED', 'BOARDED'] } } }),
this.prisma.booking.count({ where: { status: { in: ['CONFIRMED', 'BOARDED'] }, packageId: { not: null } } }),
this.prisma.ticket.count(),
this.prisma.passenger.count(),
this.prisma.$queryRaw<{ currency: string; total: bigint }[]>`

View File

@@ -1,4 +1,5 @@
import { Injectable, Logger } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { Nack, RabbitSubscribe } from '@golevelup/nestjs-rabbitmq';
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
import {
@@ -18,7 +19,15 @@ const PASSENGER_QUEUE = PAYMENT_QUEUES[PaymentService.PASSENGER];
export class PaymentEventsConsumer {
private readonly logger = new Logger(PaymentEventsConsumer.name);
constructor(private readonly paymentsService: PaymentsService) {}
// IMPORTANT: do NOT constructor-inject PaymentsService here. It is a REQUEST/TRANSIENT-scoped
// provider (its scope bubbles up from a scoped dependency), so it has no singleton instance at
// bootstrap. Constructor-injecting it makes THIS consumer scoped too — and golevelup binds the
// @RabbitSubscribe handler to the singleton instance it discovers at bootstrap. With no such
// instance, the subscription still registers but delivered messages are never dispatched to
// handle(): they pile up unacked and the booking never confirms. Injecting only the lightweight
// (singleton) ModuleRef keeps this consumer a clean singleton; PaymentsService is resolved per
// message via resolve() (get() throws for scoped providers).
constructor(private readonly moduleRef: ModuleRef) {}
@IsPublic()
@RabbitSubscribe({
@@ -37,7 +46,13 @@ export class PaymentEventsConsumer {
`RECEIVED ${event.eventType} (${event.eventId}) ref=${event.referenceId} via RabbitMQ`,
);
try {
const result = await this.paymentsService.handlePaymentEvent(
// resolve() (not get()) because PaymentsService is scoped — get() throws for scoped providers.
const paymentsService = await this.moduleRef.resolve(
PaymentsService,
undefined,
{ strict: false },
);
const result = await paymentsService.handlePaymentEvent(
event as unknown as PaymentEventDto,
);
this.logger.log(

View File

@@ -39,12 +39,34 @@ import {
import { PassengerStaff } from "../../common/passenger-guards";
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
import { resolveAllowedOrigin } from "../../common/utils/redirect-origin.util";
import { SupplementaryChargesService } from "./supplementary-charges.service";
import { IsString, IsInt, IsOptional, Min, IsEnum, IsIn } from "class-validator";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
class CreateSupplementaryChargeDto {
@ApiProperty({ example: 'EDR-20240001', description: 'Booking reference number' }) @IsString() bookingRef: string;
@ApiProperty({ description: 'Amount owed in minor units (e.g. 5000 = 50 ETB)' }) @IsInt() @Min(1) amountMinor: number;
@ApiProperty({ example: 'UNDERPAYMENT' }) @IsString() reason: string;
@ApiPropertyOptional() @IsOptional() @IsString() notes?: string;
}
class WaiveSupplementaryChargeDto {
@ApiPropertyOptional() @IsOptional() @IsString() notes?: string;
}
class PaySupplementaryChargeDto {
@ApiProperty({ enum: PaymentMethodTypeEnum, example: 'TELEBIRR' }) @IsEnum(PaymentMethodTypeEnum) method: PaymentMethodTypeEnum;
@ApiPropertyOptional({ enum: ['web', 'mobile'], default: 'web' }) @IsOptional() @IsIn(['web', 'mobile']) platform?: 'web' | 'mobile';
}
@ApiTags("Payment")
@Controller("payments")
// @Throttle({ strict: { limit: 20, ttl: 60_000 } })
export class PaymentsController {
constructor(private service: PaymentsService) {}
constructor(
private service: PaymentsService,
private supplementaryService: SupplementaryChargesService,
) {}
@Delete(":id")
@PassengerStaff([PASSENGER_PERMS.admin])
@@ -315,6 +337,92 @@ export class PaymentsController {
}
}
// ── Supplementary Charges ──────────────────────────────────────────────────
@Post('supplementary')
@PassengerStaff([PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Raise a supplementary charge for an underpayment (staff only)' })
createSupplementaryCharge(
@Body() dto: CreateSupplementaryChargeDto,
@Headers('x-iam-user-id') iamUserId?: string,
) {
return this.supplementaryService.create({
...dto,
createdBy: iamUserId ?? 'staff',
});
}
@Get('supplementary')
@PassengerStaff([PASSENGER_PERMS.payments.view, PASSENGER_PERMS.payments.viewAll, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'List supplementary charges (staff only)' })
@ApiQuery({ name: 'bookingRef', required: false })
@ApiQuery({ name: 'status', required: false })
@ApiQuery({ name: 'page', required: false })
@ApiQuery({ name: 'pageSize', required: false })
listSupplementaryCharges(
@Query('bookingRef') bookingRef?: string,
@Query('status') status?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.supplementaryService.getAll({
bookingRef,
status,
page: page ? parseInt(page) : 1,
pageSize: pageSize ? parseInt(pageSize) : 20,
});
}
@Get('supplementary/by-token/:token')
@SetMetadata('isPublic', true)
@ApiOperation({ summary: 'Get supplementary charge by payment token (public — for self-pay page)' })
getSupplementaryByToken(@Param('token') token: string) {
return this.supplementaryService.getByToken(token);
}
@Post('supplementary/by-token/:token/pay')
@SetMetadata('isPublic', true)
@ApiOperation({ summary: 'Initiate payment for a supplementary charge (public — self-pay)' })
paySupplementaryCharge(
@Param('token') token: string,
@Body() dto: PaySupplementaryChargeDto,
) {
return this.supplementaryService.pay(token, dto.method, dto.platform);
}
@Post('supplementary/:id/mark-paid')
@PassengerStaff([PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Manually mark a supplementary charge as paid (staff only)' })
markSupplementaryPaid(
@Param('id') id: string,
@Body() body: { providerTxnId?: string },
) {
return this.supplementaryService.markPaid(id, body.providerTxnId);
}
@Post('supplementary/:id/waive')
@PassengerStaff([PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Waive a supplementary charge (staff only)' })
waiveSupplementaryCharge(
@Param('id') id: string,
@Body() dto: WaiveSupplementaryChargeDto,
@Headers('x-iam-user-id') iamUserId?: string,
) {
return this.supplementaryService.waive(id, dto.notes ?? '', iamUserId ?? 'staff');
}
@Post('supplementary/:id/resend')
@PassengerStaff([PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Resend payment link for a supplementary charge (staff only)' })
resendSupplementaryLink(@Param('id') id: string) {
return this.supplementaryService.resendLink(id);
}
private buildRedirectHtml(url: string): string {
const escaped = url.replace(/\"/g, "&quot;");
return `<!DOCTYPE html>

View File

@@ -154,6 +154,13 @@ export class IntentStatusDto {
@ApiPropertyOptional() paidAt?: string;
@ApiPropertyOptional() failureCode?: string;
@ApiPropertyOptional() failureMessage?: string;
@ApiPropertyOptional({
type: "object",
additionalProperties: true,
description:
"Raw provider payload (initiation response merged with the latest status query) for inspection/debugging. Provider-specific shape; never trusted for state.",
})
providerResponse?: Record<string, unknown>;
}
export class BookingAmountResponseDto {

View File

@@ -12,6 +12,7 @@ import {
} from "@edr/types";
import { PaymentsController } from "./payments.controller";
import { PaymentsService } from "./payments.service";
import { SupplementaryChargesService } from "./supplementary-charges.service";
import { InternalPaymentsController } from "./internal-payments.controller";
import { PaymentClientService } from "./payment-client.service";
import { PaymentEventsConsumer } from "./payment-events.consumer";
@@ -21,6 +22,8 @@ import { TicketsModule } from "../tickets/tickets.module";
import { CurrencyModule } from "../currency/currency.module";
import { AuditModule } from "../../common/audit.module";
import { NotificationsModule } from "../notifications/notifications.module";
const PASSENGER_QUEUE = PAYMENT_QUEUES[PaymentService.PASSENGER];
function rabbitMQImport(): DynamicModule[] {
@@ -55,8 +58,7 @@ function rabbitMQImport(): DynamicModule[] {
TicketsModule,
CurrencyModule,
AuditModule,
// The payment service proxies slow provider calls (e.g. CAC Bank initiate, which SMSes an
// OTP and can take tens of seconds). Keep this hop generous; overridable via env.
NotificationsModule,
HttpModule.register({
timeout: Number(process.env.PAYMENT_API_HTTP_TIMEOUT_MS) || 60_000,
}),
@@ -65,10 +67,11 @@ function rabbitMQImport(): DynamicModule[] {
controllers: [PaymentsController, InternalPaymentsController],
providers: [
PaymentsService,
SupplementaryChargesService,
PaymentClientService,
PaymentEventsConsumer,
ServiceAuthGuard,
],
exports: [PaymentClientService],
exports: [PaymentClientService, PaymentsService],
})
export class PaymentsModule {}

View File

@@ -432,6 +432,9 @@ export class PaymentsService {
expiresAt: snapshot.expiresAt ? new Date(snapshot.expiresAt) : null,
failureCode: snapshot.failureCode ?? null,
failureMessage: snapshot.failureMessage ?? null,
rawInitiation: (snapshot as any).providerResponse
? ((snapshot as any).providerResponse as unknown as Prisma.InputJsonValue)
: Prisma.DbNull,
};
return this.prisma.paymentIntent.upsert({
where: { bookingId },
@@ -589,6 +592,10 @@ export class PaymentsService {
paidAt: intent.paidAt?.toISOString(),
failureCode: intent.failureCode ?? undefined,
failureMessage: intent.failureMessage ?? undefined,
providerResponse:
intent.rawInitiation && typeof intent.rawInitiation === "object"
? (intent.rawInitiation as Record<string, unknown>)
: undefined,
};
}
@@ -833,19 +840,46 @@ export class PaymentsService {
return { alreadyFinalized: false };
}
private async handleSupplementaryChargeEvent(event: PaymentEventDto): Promise<MarkPaidResponseDto> {
if (event.eventType === 'payment.failed') {
this.logger.warn(`supplementary charge ${event.referenceId} payment failed`);
return { processed: true };
}
const charge = await this.prisma.supplementaryCharge.findUnique({ where: { id: event.referenceId } });
if (!charge) {
this.logger.error(`mark-paid: no supplementary charge for reference ${event.referenceId}`);
return { processed: false, reason: 'charge-not-found' };
}
if (charge.status === 'PAID') return { processed: true, alreadyFinalized: true };
await this.prisma.supplementaryCharge.update({
where: { id: charge.id },
data: { status: 'PAID', paidAt: new Date(), providerTxnId: event.providerTxnId ?? null },
});
await this.auditService.log({ action: 'UPDATE', entityType: 'SupplementaryCharge', entityId: charge.id, newData: { status: 'PAID', providerTxnId: event.providerTxnId } });
return { processed: true };
}
async handlePaymentEvent(
event: PaymentEventDto,
): Promise<MarkPaidResponseDto> {
if (
event.service !== PaymentServiceEnum.PASSENGER ||
event.referenceType !== PaymentReferenceType.BOOKING
) {
if (event.service !== PaymentServiceEnum.PASSENGER) {
this.logger.warn(
`mark-paid: ignoring foreign reference ${event.service}/${event.referenceType}/${event.referenceId}`,
);
return { processed: false, reason: "foreign-reference" };
}
if (event.referenceType === PaymentReferenceType.SUPPLEMENTARY_CHARGE) {
return this.handleSupplementaryChargeEvent(event);
}
if (event.referenceType !== PaymentReferenceType.BOOKING) {
this.logger.warn(
`mark-paid: ignoring unknown referenceType ${event.referenceType}`,
);
return { processed: false, reason: "foreign-reference" };
}
if (event.eventType === "payment.failed") {
const intent = await this.prisma.paymentIntent.findUnique({
where: { bookingId: event.referenceId },

View File

@@ -0,0 +1,193 @@
import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { AuditService } from '../../common/audit.service';
import { SmsClientService } from '../notifications/sms-client.service';
import { EmailClientService } from '../notifications/email-client.service';
import { PaymentClientService } from './payment-client.service';
import { PaymentReferenceType, PaymentService as PaymentServiceEnum, ProviderMethod } from '@edr/types';
const CHARGE_TTL_MS = 72 * 60 * 60 * 1000; // 72 hours
@Injectable()
export class SupplementaryChargesService {
private readonly logger = new Logger(SupplementaryChargesService.name);
constructor(
private prisma: PrismaService,
private auditService: AuditService,
private smsClient: SmsClientService,
private emailClient: EmailClientService,
private paymentClient: PaymentClientService,
) {}
async create(dto: {
bookingRef: string;
amountMinor: number;
reason: string;
notes?: string;
createdBy: string;
}) {
const booking = await this.prisma.booking.findUnique({
where: { bookingRef: dto.bookingRef },
include: { passenger: { include: { user: true } } },
});
if (!booking) throw new NotFoundException('Booking not found');
if (!['CONFIRMED', 'BOARDED'].includes(booking.status)) {
throw new BadRequestException('Booking must be CONFIRMED or BOARDED to raise a supplementary charge');
}
if (dto.amountMinor <= 0) throw new BadRequestException('Amount must be positive');
const expiresAt = new Date(Date.now() + CHARGE_TTL_MS);
const charge = await this.prisma.supplementaryCharge.create({
data: {
bookingId: booking.id,
reason: dto.reason,
amountMinor: dto.amountMinor,
notes: dto.notes ?? null,
createdBy: dto.createdBy,
expiresAt,
},
});
const phone = booking.contactPhone ?? booking.passenger?.user?.phone ?? null;
const email = booking.contactEmail ?? booking.passenger?.user?.email ?? null;
await this.sendLink(charge, booking.bookingRef, phone, email);
await this.auditService.log({
action: 'CREATE',
entityType: 'SupplementaryCharge',
entityId: charge.id,
newData: { bookingRef: dto.bookingRef, amountMinor: dto.amountMinor, reason: dto.reason },
});
return charge;
}
async getAll(filters: { bookingRef?: string; status?: string; page?: number; pageSize?: number }) {
const { bookingRef, status, page = 1, pageSize = 20 } = filters;
const skip = (page - 1) * pageSize;
const where: any = {};
if (status) where.status = status;
if (bookingRef) where.booking = { bookingRef: { contains: bookingRef, mode: 'insensitive' } };
await this.prisma.supplementaryCharge.updateMany({
where: { status: 'PENDING', expiresAt: { lt: new Date() } },
data: { status: 'EXPIRED' },
});
const [items, total] = await Promise.all([
this.prisma.supplementaryCharge.findMany({
where,
include: { booking: { select: { bookingRef: true, status: true, contactPhone: true, contactEmail: true } } },
orderBy: { createdAt: 'desc' },
skip,
take: pageSize,
}),
this.prisma.supplementaryCharge.count({ where }),
]);
return { items, total, page, pageSize };
}
async getByToken(token: string) {
const charge = await this.prisma.supplementaryCharge.findUnique({
where: { paymentToken: token },
include: { booking: { select: { bookingRef: true } } },
});
if (!charge) throw new NotFoundException('Payment link not found');
if (charge.status === 'PAID') throw new BadRequestException('This charge has already been paid');
if (charge.status === 'WAIVED') throw new BadRequestException('This charge has been waived');
if (charge.status === 'EXPIRED' || (charge.expiresAt && new Date() > charge.expiresAt)) {
if (charge.status === 'PENDING') {
await this.prisma.supplementaryCharge.update({ where: { id: charge.id }, data: { status: 'EXPIRED' } });
}
throw new BadRequestException('This payment link has expired');
}
return charge;
}
async markPaid(id: string, providerTxnId?: string) {
const charge = await this.prisma.supplementaryCharge.findUnique({ where: { id } });
if (!charge) throw new NotFoundException('Charge not found');
if (charge.status === 'PAID') return charge;
const updated = await this.prisma.supplementaryCharge.update({
where: { id },
data: { status: 'PAID', paidAt: new Date(), providerTxnId: providerTxnId ?? null },
});
await this.auditService.log({ action: 'UPDATE', entityType: 'SupplementaryCharge', entityId: id, newData: { status: 'PAID' } });
return updated;
}
async pay(token: string, method: string, platform?: 'web' | 'mobile') {
const charge = await this.getByToken(token); // validates status/expiry
const paymentMethod = method as ProviderMethod;
const portalUrl = process.env.PORTAL_URL ?? 'http://localhost:5174';
const returnUrl = `${portalUrl}/pay-balance/${token}/success`;
const failureUrl = `${portalUrl}/pay-balance/${token}/failed`;
const snapshot = await this.paymentClient.initiate({
service: PaymentServiceEnum.PASSENGER,
referenceType: PaymentReferenceType.SUPPLEMENTARY_CHARGE,
referenceId: charge.id,
orderRef: `SC-${charge.id.substring(0, 8)}`,
amountMinor: charge.amountMinor / 100,
currency: charge.currency,
provider: paymentMethod,
platform,
returnUrl,
failureUrl,
});
return snapshot;
}
async waive(id: string, notes: string, waivedBy: string) {
const charge = await this.prisma.supplementaryCharge.findUnique({ where: { id } });
if (!charge) throw new NotFoundException('Charge not found');
if (charge.status === 'PAID') throw new BadRequestException('Cannot waive a paid charge');
const updated = await this.prisma.supplementaryCharge.update({
where: { id },
data: { status: 'WAIVED', notes },
});
await this.auditService.log({ action: 'UPDATE', entityType: 'SupplementaryCharge', entityId: id, newData: { status: 'WAIVED', waivedBy, notes } });
return updated;
}
async resendLink(id: string) {
const charge = await this.prisma.supplementaryCharge.findUnique({
where: { id },
include: { booking: { select: { bookingRef: true, contactPhone: true, contactEmail: true } } },
});
if (!charge) throw new NotFoundException('Charge not found');
if (charge.status !== 'PENDING') throw new BadRequestException('Can only resend link for PENDING charges');
const updated = await this.prisma.supplementaryCharge.update({
where: { id },
data: { expiresAt: new Date(Date.now() + CHARGE_TTL_MS) },
});
await this.sendLink(updated, charge.booking.bookingRef, charge.booking.contactPhone, charge.booking.contactEmail);
return { sent: true };
}
private async sendLink(charge: any, bookingRef: string, phone: string | null, email: string | null) {
const portalUrl = process.env.PORTAL_URL ?? 'http://localhost:5174';
const payUrl = `${portalUrl}/pay-balance/${charge.paymentToken}`;
const amount = (charge.amountMinor / 100).toFixed(2);
const msg = `EDR: A balance of ${amount} ETB is outstanding for booking ${bookingRef}. Pay here: ${payUrl}`;
if (phone) {
try { await this.smsClient.sendSms({ to: phone, message: msg }); }
catch (err) { this.logger.warn(`SMS failed for supplementary charge ${charge.id}: ${err}`); }
}
if (email) {
try {
await this.emailClient.sendEmail({
to: email,
subject: `EDR — Outstanding balance for booking ${bookingRef}`,
text: msg,
});
} catch (err) { this.logger.warn(`Email failed for supplementary charge ${charge.id}: ${err}`); }
}
if (!phone && !email) {
this.logger.warn(`No contact info for supplementary charge ${charge.id}`);
}
}
}

View File

@@ -18,6 +18,24 @@ export class ReportsController {
return this.service.generateReport(dto);
}
@Get('schedules')
@ApiOperation({ summary: 'List schedules for the passengers report picker' })
listSchedulesForPicker() {
return this.service.listSchedulesForPicker();
}
@Get('passengers/list')
@ApiOperation({ summary: 'Flat passenger list for a specific schedule' })
getPassengerList(@Query('scheduleId') scheduleId: string) {
return this.service.getPassengerList(scheduleId);
}
@Get('passengers')
@ApiOperation({ summary: 'Passengers report for a specific schedule' })
getOccupancyReport(@Query('scheduleId') scheduleId: string) {
return this.service.getOccupancyBySchedule(scheduleId);
}
@Get(':reportId')
@ApiOperation({ summary: 'Get report by ID' })
getReport(@Param('reportId') reportId: string) {

View File

@@ -95,13 +95,18 @@ export class ReportsService {
where: { departureAt: { gte: dateFrom, lte: dateTo } },
include: {
coachAssignments: { include: { coach: { include: { seats: true } } } },
bookings: { include: { seats: true } },
bookings: {
where: { status: { in: ['CONFIRMED', 'BOARDED'] } },
include: { seats: true },
},
},
});
const tripData = schedules.map(schedule => {
const totalSeats = schedule.coachAssignments.reduce((sum, a) => sum + a.coach.seats.length, 0);
const bookedSeats = schedule.bookings.reduce((sum, b) => sum + b.seats.length, 0);
const bookedSeats = schedule.bookings.reduce(
(sum, b) => sum + b.seats.filter((s: any) => s.leg === 1).length, 0,
);
const occupancyRate = totalSeats > 0 ? (bookedSeats / totalSeats) * 100 : 0;
return { scheduleId: schedule.id, departureAt: schedule.departureAt, totalSeats, bookedSeats, occupancyRate: +occupancyRate.toFixed(2) };
});
@@ -191,6 +196,166 @@ export class ReportsService {
};
}
async getOccupancyBySchedule(scheduleId: string) {
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: scheduleId },
include: {
originStation: true,
destinationStation: true,
train: true,
coachAssignments: {
include: {
coach: {
include: {
coachType: true,
seats: { select: { id: true } },
},
},
},
},
bookings: {
where: { status: { in: ['CONFIRMED', 'BOARDED'] } },
include: {
seats: {
where: { leg: 1 },
include: {
seat: { include: { coach: { include: { coachType: true } } } },
},
},
},
},
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
},
});
if (!schedule) return null;
const totalSeats = (schedule as any).coachAssignments.reduce((s: number, a: any) => s + a.coach.seats.length, 0);
const allBookingSeats = (schedule as any).bookings.flatMap((b: any) => b.seats);
const totalPassengers = allBookingSeats.length;
const occupancyRate = totalSeats > 0 ? +((totalPassengers / totalSeats) * 100).toFixed(1) : 0;
const coachMap = new Map<string, { coachNumber: string; coachType: string; totalSeats: number; booked: number }>();
for (const assignment of (schedule as any).coachAssignments) {
const c = assignment.coach;
coachMap.set(c.id, { coachNumber: c.number, coachType: (c as any).coachType?.name ?? 'Unknown', totalSeats: c.seats.length, booked: 0 });
}
for (const bs of allBookingSeats) {
const coachId = bs.seat?.coachId;
if (coachId && coachMap.has(coachId)) coachMap.get(coachId)!.booked++;
}
const byCoach = [...coachMap.values()].map(c => ({ ...c, occupancyRate: c.totalSeats > 0 ? +((c.booked / c.totalSeats) * 100).toFixed(1) : 0 }));
const originMap = new Map<string, { stationName: string; passengers: number }>();
for (const booking of (schedule as any).bookings) {
const stationId = booking.originStationId ?? schedule.originStationId;
const stationName = (schedule as any).stopTimes.find((st: any) => st.stationId === stationId)?.station?.name ?? (schedule as any).originStation?.name ?? stationId;
if (!originMap.has(stationId)) originMap.set(stationId, { stationName, passengers: 0 });
originMap.get(stationId)!.passengers += booking.seats.length;
}
const destMap = new Map<string, { stationName: string; passengers: number }>();
for (const booking of (schedule as any).bookings) {
const stationId = booking.destinationStationId ?? schedule.destinationStationId;
const stationName = (schedule as any).stopTimes.find((st: any) => st.stationId === stationId)?.station?.name ?? (schedule as any).destinationStation?.name ?? stationId;
if (!destMap.has(stationId)) destMap.set(stationId, { stationName, passengers: 0 });
destMap.get(stationId)!.passengers += booking.seats.length;
}
const classMap = new Map<string, { className: string; totalSeats: number; booked: number }>();
for (const assignment of (schedule as any).coachAssignments) {
const typeName = (assignment.coach as any).coachType?.name ?? 'Unknown';
if (!classMap.has(typeName)) classMap.set(typeName, { className: typeName, totalSeats: 0, booked: 0 });
classMap.get(typeName)!.totalSeats += assignment.coach.seats.length;
}
for (const bs of allBookingSeats) {
const typeName = bs.seat?.coach?.coachType?.name ?? 'Unknown';
if (!classMap.has(typeName)) classMap.set(typeName, { className: typeName, totalSeats: 0, booked: 0 });
classMap.get(typeName)!.booked++;
}
const byClass = [...classMap.values()].map(c => ({ ...c, occupancyRate: c.totalSeats > 0 ? +((c.booked / c.totalSeats) * 100).toFixed(1) : 0 }));
return {
schedule: {
id: schedule.id,
trainName: (schedule as any).train?.name ?? (schedule as any).train?.number,
origin: (schedule as any).originStation?.name,
destination: (schedule as any).destinationStation?.name,
departureAt: schedule.departureAt,
arrivalAt: schedule.arrivalAt,
},
summary: { totalSeats, totalPassengers, occupancyRate },
byCoach,
byClass,
byOrigin: [...originMap.values()].sort((a, b) => b.passengers - a.passengers),
byDestination: [...destMap.values()].sort((a, b) => b.passengers - a.passengers),
};
}
async listSchedulesForPicker() {
const schedules = await this.prisma.trainSchedule.findMany({
select: {
id: true,
departureAt: true,
train: { select: { number: true } },
originStation: { select: { name: true } },
destinationStation: { select: { name: true } },
},
orderBy: { departureAt: 'desc' },
take: 200,
});
return schedules.map(s => ({
id: s.id,
label: `${s.train.number} · ${s.originStation.name}${s.destinationStation.name} · ${new Date(s.departureAt).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' })}`,
}));
}
async getPassengerList(scheduleId: string) {
const seats = await this.prisma.bookingSeat.findMany({
where: {
leg: 1,
booking: { scheduleId, status: { in: ['CONFIRMED', 'BOARDED'] } },
},
include: {
booking: {
select: {
bookingRef: true,
status: true,
originStationId: true,
destinationStationId: true,
},
},
seat: { include: { coach: { select: { number: true } } } },
},
orderBy: [{ seat: { coach: { number: 'asc' } } }, { seat: { seatNumber: 'asc' } }],
});
// Resolve station names in one query
const stationIds = [...new Set(
seats.flatMap(bs => [bs.booking.originStationId, bs.booking.destinationStationId]).filter(Boolean) as string[],
)];
const stations = stationIds.length > 0
? await this.prisma.station.findMany({ where: { id: { in: stationIds } }, select: { id: true, name: true } })
: [];
const stationName = new Map(stations.map(s => [s.id, s.name]));
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: scheduleId },
select: { departureAt: true },
});
return seats.map(bs => ({
bookingRef: bs.booking.bookingRef,
passengerName: bs.passengerName,
coachSeat: bs.seat?.coach?.number && bs.seatLabelSnapshot
? `${bs.seat.coach.number}·${bs.seatLabelSnapshot}`
: (bs.seatLabelSnapshot ?? '—'),
origin: bs.booking.originStationId ? (stationName.get(bs.booking.originStationId) ?? '—') : '—',
destination: bs.booking.destinationStationId ? (stationName.get(bs.booking.destinationStationId) ?? '—') : '—',
departureAt: schedule?.departureAt ?? null,
}));
}
async getReport(reportId: string) {
return this.prisma.operationalReport.findUnique({ where: { id: reportId } });
}

View File

@@ -29,6 +29,16 @@ import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
export class SeatsController {
constructor(private service: SeatsService) {}
// ── Blocked Seats ─────────────────────────────────────────────────────────
@Get('blocks')
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'List all blocked seats with reason and coach info' })
@ApiResponse({ status: 200, description: 'Blocked seat records' })
getBlockedSeats() {
return this.service.getBlockedSeats();
}
// ── Coach Availability ────────────────────────────────────────────────────
@Get('coaches/:scheduleId')
@SetMetadata('isPublic', true)
@@ -211,8 +221,8 @@ This makes it clear which segment of the route each seat is held for, enabling s
@ApiOperation({ summary: "Block a seat (e.g., maintenance, damage)" })
@ApiParam({ name: "seatId", description: "Seat UUID" })
@ApiResponse({ status: 200, description: "Seat blocked" })
blockSeat(@Param("seatId") seatId: string, @Body() body: { reason: string }) {
return this.service.blockSeat(seatId, body.reason);
blockSeat(@Param("seatId") seatId: string, @Body() body: { reason: string; scheduleId?: string }) {
return this.service.blockSeat(seatId, body.reason, body.scheduleId);
}
@Delete(":seatId/block")
@@ -221,8 +231,8 @@ This makes it clear which segment of the route each seat is held for, enabling s
@ApiOperation({ summary: "Unblock a seat" })
@ApiParam({ name: "seatId", description: "Seat UUID" })
@ApiResponse({ status: 200, description: "Seat unblocked" })
unblockSeat(@Param("seatId") seatId: string) {
return this.service.unblockSeat(seatId);
unblockSeat(@Param("seatId") seatId: string, @Query("scheduleId") scheduleId?: string) {
return this.service.unblockSeat(seatId, scheduleId);
}
// ── Maintenance ───────────────────────────────────────────────────────────

View File

@@ -224,7 +224,7 @@ export class SeatsService {
}
}
const [availability, persistedSeats] = await Promise.all([
const [availability, persistedSeats, scheduleBlocks] = await Promise.all([
this.segmentsService.getSeatAvailabilityMap(
scheduleId, seatIds, stopTimes, reqFrom, reqTo, journeyDirection || JourneyDirection.ONE_WAY,
),
@@ -232,16 +232,23 @@ export class SeatsService {
where: { id: { in: seatIds } },
select: { id: true, status: true },
}),
this.prisma.seatBlock.findMany({
where: { seatId: { in: seatIds }, scheduleId },
select: { seatId: true },
}),
]);
const persistedStatus = new Map(persistedSeats.map(s => [s.id, s.status]));
const scheduleBlockedIds = new Set(scheduleBlocks.map(b => b.seatId));
for (const seatId of seatIds) {
const persisted = persistedStatus.get(seatId);
// BLOCKED and UNDER_MAINTENANCE are cross-schedule flags set by admins —
// always honour them regardless of hold/booking state.
// Global BLOCKED/UNDER_MAINTENANCE (no scheduleId) — always honour
if ((persisted as string) === 'BLOCKED' || (persisted as string) === 'UNDER_MAINTENANCE') {
statusMap.set(seatId, persisted!);
} else if (scheduleBlockedIds.has(seatId)) {
// Schedule-scoped block — only blocked for this schedule
statusMap.set(seatId, 'BLOCKED');
} else {
statusMap.set(seatId, availability.get(seatId) ?? 'AVAILABLE');
}
@@ -291,15 +298,12 @@ export class SeatsService {
throw new NotFoundException(`Seat(s) not found: ${missing.join(', ')}`);
}
// Only the raw BLOCKED status (seat pulled out of service — a genuine
// cross-schedule flag) is trusted here. BOOKED is intentionally NOT checked
// against this raw column: the same physical Seat row is reused across every
// recurring date a coach runs, and Seat.status only resets to AVAILABLE via a
// trip-completion event that isn't guaranteed to fire, so a stale BOOKED value
// here would wrongly block a seat that's actually free for this schedule/leg.
// The schedule- and leg-scoped SeatHold/JourneySegment checks below are the
// authoritative source for whether a seat is actually taken.
const blocked = seats.filter(s => s.status === 'BLOCKED');
// Only the raw BLOCKED/UNDER_MAINTENANCE status (seat pulled out of service —
// a genuine cross-schedule flag) is checked here. Seat.status is never written
// for holds/bookings because coaches are reused across schedules; the
// schedule-scoped SeatHold/JourneySegment checks below are the authoritative
// source for whether a seat is taken on this specific schedule/leg.
const blocked = seats.filter(s => s.status === 'BLOCKED' || (s.status as string) === 'UNDER_MAINTENANCE');
if (blocked.length > 0)
throw new ConflictException(`Seat(s) ${blocked.map(s => s.seatNumber).join(', ')} are already taken`);
@@ -400,11 +404,6 @@ export class SeatsService {
passengers: dto.passengers.map(p => ({ passengerId: p.passengerId, seatId: p.seatId })),
};
await tx.seat.updateMany({
where: { id: { in: seatIds } },
data: { status: 'HELD' },
});
return tx.seatHold.create({
data: {
scheduleId: dto.scheduleId,
@@ -545,13 +544,7 @@ export class SeatsService {
async releaseHold(holdId: string) {
const hold = await this.prisma.seatHold.findUnique({ where: { id: holdId } });
if (!hold) throw new NotFoundException('Hold not found');
await this.prisma.$transaction([
this.prisma.seat.updateMany({
where: { id: { in: hold.seatIds as string[] }, status: 'HELD' },
data: { status: 'AVAILABLE' },
}),
this.prisma.seatHold.delete({ where: { id: holdId } }),
]);
await this.prisma.seatHold.delete({ where: { id: holdId } });
return { released: true, holdId };
}
@@ -605,6 +598,29 @@ export class SeatsService {
await this.prisma.journey.deleteMany({ where: { bookingId } as any });
}
async getBlockedSeats() {
const blocks = await this.prisma.seatBlock.findMany({
where: {
NOT: { reason: { startsWith: 'MAINTENANCE:' } },
},
include: {
seat: { include: { coach: { select: { number: true } } } },
},
orderBy: { blockedAt: 'desc' },
});
return blocks.map(b => ({
id: b.id,
seatId: b.seatId,
seatNumber: b.seat.seatNumber,
coachNumber: b.seat.coach.number,
scheduleId: b.scheduleId,
reason: b.reason,
blockedBy: b.blockedBy,
blockedAt: b.blockedAt,
unblockAt: b.unblockAt,
}));
}
async getCoachesWithAvailability(scheduleId: string, originStationId?: string, destinationStationId?: string) {
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: scheduleId },
@@ -657,21 +673,41 @@ export class SeatsService {
}
async autoAssignSeats(scheduleId: string, count: number, seatClassName: string): Promise<string[]> {
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: scheduleId },
select: { originStationId: true, destinationStationId: true },
});
if (!schedule) throw new NotFoundException('Schedule not found');
const seats = await this.prisma.seat.findMany({
where: {
coach: { assignments: { some: { scheduleId } } },
status: 'AVAILABLE',
seatNumber: { not: '' },
NOT: { seatNumber: { startsWith: '-' } },
NOT: [{ seatNumber: { startsWith: '-' } }, { status: 'BLOCKED' }, { status: 'UNDER_MAINTENANCE' as any }],
},
orderBy: [{ coach: { number: 'asc' } }, { row: 'asc' }, { col: 'asc' }],
});
if (seats.length < count) {
throw new ConflictException(`Only ${seats.length} seats available, requested ${count}`);
const allSeatIds = seats.map(s => s.id);
const stopTimes = await this.prisma.tripStopTime.findMany({
where: { scheduleId },
select: { stationId: true, sequence: true },
});
const seqOf = (id: string) => stopTimes.find(s => s.stationId === id)?.sequence;
const reqFrom = seqOf(schedule.originStationId) ?? 0;
const reqTo = seqOf(schedule.destinationStationId) ?? stopTimes.length;
const unavailable = await this.segmentsService.getSeatAvailabilityMap(
scheduleId, allSeatIds, stopTimes, reqFrom, reqTo,
);
const availableSeats = seats.filter(s => !unavailable.has(s.id));
if (availableSeats.length < count) {
throw new ConflictException(`Only ${availableSeats.length} seats available, requested ${count}`);
}
const assigned = this.findContiguousSeats(seats, count);
const assigned = this.findContiguousSeats(availableSeats, count);
return assigned.map((s) => s.id);
}
@@ -774,24 +810,34 @@ export class SeatsService {
return { imported, errors: errors.slice(0, 10) };
}
async blockSeat(seatId: string, reason: string) {
async blockSeat(seatId: string, reason: string, scheduleId?: string) {
const seat = await this.prisma.seat.findUnique({ where: { id: seatId } });
if (!seat) throw new NotFoundException('Seat not found');
await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'BLOCKED' } });
await this.prisma.seatBlock.create({ data: { seatId, reason, blockedBy: 'system' } });
await this.auditService.log({ action: 'UPDATE', entityType: 'Seat', entityId: seatId, newData: { status: 'BLOCKED', reason } });
return { blocked: true, seatId, reason };
// Schedule-scoped block: only affects this schedule, not all schedules
// Global block (no scheduleId): sets Seat.status = BLOCKED for all schedules
if (scheduleId) {
await this.prisma.seatBlock.create({ data: { seatId, scheduleId, reason, blockedBy: 'system' } });
} else {
await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'BLOCKED' } });
await this.prisma.seatBlock.create({ data: { seatId, reason, blockedBy: 'system' } });
}
await this.auditService.log({ action: 'UPDATE', entityType: 'Seat', entityId: seatId, newData: { status: 'BLOCKED', reason, scheduleId } });
return { blocked: true, seatId, reason, scheduleId };
}
async unblockSeat(seatId: string) {
async unblockSeat(seatId: string, scheduleId?: string) {
const seat = await this.prisma.seat.findUnique({ where: { id: seatId } });
if (!seat) throw new NotFoundException('Seat not found');
await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'AVAILABLE' } });
await this.prisma.seatBlock.deleteMany({ where: { seatId } });
await this.auditService.log({ action: 'UPDATE', entityType: 'Seat', entityId: seatId, newData: { status: 'AVAILABLE' } });
return { unblocked: true, seatId };
if (scheduleId) {
await this.prisma.seatBlock.deleteMany({ where: { seatId, scheduleId } });
} else {
await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'AVAILABLE' } });
await this.prisma.seatBlock.deleteMany({ where: { seatId, scheduleId: null } });
}
await this.auditService.log({ action: 'UPDATE', entityType: 'Seat', entityId: seatId, newData: { status: 'AVAILABLE', scheduleId } });
return { unblocked: true, seatId, scheduleId };
}
async setMaintenance(seatId: string, reason: string) {
@@ -929,22 +975,12 @@ export class SeatsService {
}
}
if (releasedSeatIds.size > 0) {
await this.prisma.seat.updateMany({
where: { id: { in: Array.from(releasedSeatIds) }, status: 'HELD' },
// heldUntil is cleared alongside status — leaving a stale (past) heldUntil on an
// AVAILABLE seat is stale data that any future code reading heldUntil directly
// (instead of re-deriving availability live) would misinterpret.
data: { status: 'AVAILABLE', heldUntil: null },
});
}
await this.prisma.seatHold.deleteMany({ where: { expiresAt: { lt: now } } });
return {
expiredHolds: expired.length,
releasedSeatIds: Array.from(releasedSeatIds),
skippedSeatIds: Array.from(skippedSeatIds),
skippedSeatIds: Array.from(skippedSeatIds), // kept for logging/API compat; no DB writes needed
};
}
}

View File

@@ -2,10 +2,11 @@ import { Module } from '@nestjs/common';
import { PrismaModule } from '../../common/prisma.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { CurrencyModule } from '../currency/currency.module';
import { PaymentsModule } from '../payments/payments.module';
import { TasksService } from './tasks.service';
@Module({
imports: [PrismaModule, NotificationsModule, CurrencyModule],
imports: [PrismaModule, NotificationsModule, CurrencyModule, PaymentsModule],
providers: [TasksService],
})
export class TasksModule {}

View File

@@ -3,6 +3,9 @@ import { Cron } from '@nestjs/schedule';
import { PrismaService } from '../../common/prisma.service';
import { SmsClientService } from '../notifications/sms-client.service';
import { CurrencyService } from '../currency/currency.service';
import { PaymentsService } from '../payments/payments.service';
import { PaymentClientService } from '../payments/payment-client.service';
import { PaymentReferenceType, ProviderPaymentStatus } from '@edr/types';
import { MAX_PAYMENT_HOURS, CUTOFF_MINUTES, computePaymentDeadline } from '../../common/utils/payment-deadline.utils';
// Retention windows
@@ -28,6 +31,8 @@ export class TasksService {
private readonly prisma: PrismaService,
private readonly sms: SmsClientService,
private readonly currencyService: CurrencyService,
private readonly paymentsService: PaymentsService,
private readonly paymentClient: PaymentClientService,
) {}
// ─────────────────────────────────────────────────────────────────────────
@@ -253,6 +258,87 @@ export class TasksService {
}
}
// ─────────────────────────────────────────────────────────────────────────
// Every 1 min: poll the payment service for any PENDING_PAYMENT bookings
// whose payment intent has moved to SUCCEEDED on the gateway but whose
// confirmation event was never delivered (missed RabbitMQ message, network
// blip, etc.). finalizePaymentSuccess() is fully idempotent so re-running
// it for an already-confirmed booking is safe.
//
// Processes at most 50 bookings per cycle to avoid hammering the payment
// service; the next tick picks up the remainder.
// ─────────────────────────────────────────────────────────────────────────
@Cron('*/1 * * * *')
async syncPaymentStatuses() {
const BATCH_SIZE = 50;
const bookings = await this.prisma.booking.findMany({
where: {
status: 'PENDING_PAYMENT',
paymentIntent: { status: { in: ['REQUIRES_ACTION', 'PROCESSING'] } },
},
include: { paymentIntent: true },
take: BATCH_SIZE,
orderBy: { createdAt: 'asc' },
});
if (bookings.length === 0) return;
let confirmed = 0;
let failed = 0;
let errored = 0;
for (const booking of bookings) {
if (!booking.paymentIntent) continue;
try {
const snapshot = await this.paymentClient.getIntentByReference(
PaymentReferenceType.BOOKING,
booking.id,
);
if (!snapshot) continue;
if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) {
const result = await this.paymentsService.finalizePaymentSuccess({
intentId: booking.paymentIntent.id,
providerTxnId: snapshot.providerTxnId,
paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined,
});
if (!result.alreadyFinalized) {
this.logger.log(`Payment sync confirmed: ${booking.bookingRef}`);
confirmed++;
}
} else if (
snapshot.status === ProviderPaymentStatus.FAILED ||
snapshot.status === ProviderPaymentStatus.CANCELLED
) {
// The payment deadline enforcer will cancel the booking when its
// window expires; log now so operations can see failed intents early.
this.logger.warn(
`Payment sync: ${booking.bookingRef} intent is ${snapshot.status}` +
`booking will be auto-cancelled at payment deadline`,
);
failed++;
}
// REQUIRES_ACTION / PROCESSING → still pending, retry next cycle
} catch (err) {
this.logger.error(
`Payment sync error for ${booking.bookingRef}: ` +
`${err instanceof Error ? err.message : String(err)}`,
);
errored++;
}
}
if (confirmed > 0 || failed > 0 || errored > 0) {
this.logger.log(
`Payment sync run: ${bookings.length} checked, ` +
`${confirmed} confirmed, ${failed} failed/cancelled, ${errored} errors`,
);
}
}
// ─────────────────────────────────────────────────────────────────────────
// Daily at 02:00 EAT: purge expired/stale records to enforce data retention.
// ─────────────────────────────────────────────────────────────────────────

View File

@@ -3,7 +3,7 @@
import { useQuery } from '@tanstack/react-query';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
import { Ticket, AlertCircle, BookOpen, Banknote, ArrowRight } from 'lucide-react';
import { Ticket, AlertCircle, BookOpen, Banknote, ArrowRight, ScanLine } from 'lucide-react';
import { dashboardApi } from '@/lib/api/dashboard';
import { apiClient } from '@/lib/api-client';
import { formatCurrency } from '@/lib/utils';
@@ -143,9 +143,18 @@ function DashboardPageContent() {
return (
<div className="space-y-6 p-6">
<div>
<h1 className="text-3xl font-bold text-foreground">Dashboard</h1>
<p className="text-muted-foreground mt-1">Welcome back! Here&apos;s your operational summary.</p>
<div className="flex items-start justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground">Dashboard</h1>
<p className="text-muted-foreground mt-1">Welcome back! Here&apos;s your operational summary.</p>
</div>
<Link
href="/boarding"
className="flex items-center gap-2 rounded-lg bg-emerald-600 hover:bg-emerald-700 text-white px-4 py-2 text-sm font-medium transition-colors"
>
<ScanLine className="h-4 w-4" />
Boarding
</Link>
</div>
{statsError && (
@@ -161,7 +170,7 @@ function DashboardPageContent() {
)}
{/* Stat cards */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<StatCard
icon={<BookOpen className="h-4 w-4 text-blue-600 dark:text-blue-400" />}
iconBg="bg-blue-100 dark:bg-blue-900/30"
@@ -174,18 +183,7 @@ function DashboardPageContent() {
{ label: 'Package', value: stats?.totalPackageBookings ?? 0, href: '/package-bookings' },
]}
/>
<StatCard
icon={<Ticket className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />}
iconBg="bg-emerald-100 dark:bg-emerald-900/30"
label="Tickets"
total={stats?.totalTickets ?? 0}
loading={statsLoading}
href="/tickets"
rows={[
{ label: 'Regular', value: stats?.totalNormalTickets ?? 0, href: '/tickets' },
{ label: 'Package', value: stats?.totalPackageTickets ?? 0, href: '/tickets' },
]}
/>
{/* Tickets card hidden temporarily */}
{/* Revenue card */}
<div className="card flex flex-col gap-3">

View File

@@ -0,0 +1,82 @@
'use client';
import { useState } from 'react';
import { PlusCircle } from 'lucide-react';
import Modal from '@/components/ui/Modal';
import ActionButton from '@/components/ui/ActionButton';
import { useCreateSupplementaryCharge } from './useSupplementaryCharges';
const REASONS = ['UNDERPAYMENT', 'FARE_CORRECTION', 'CURRENCY_ADJUSTMENT', 'OTHER'];
interface Props {
isOpen: boolean;
onClose: () => void;
}
export default function SupplementaryChargesModal({ isOpen, onClose }: Props) {
const [form, setForm] = useState({ bookingRef: '', amountEtb: '', reason: 'UNDERPAYMENT', notes: '' });
const [formError, setFormError] = useState<string | null>(null);
const [createSuccess, setCreateSuccess] = useState<string | null>(null);
const createMutation = useCreateSupplementaryCharge(() => {
setCreateSuccess('Charge created and payment link sent.');
setForm({ bookingRef: '', amountEtb: '', reason: 'UNDERPAYMENT', notes: '' });
setFormError(null);
setTimeout(() => { setCreateSuccess(null); onClose(); }, 2000);
});
const handleCreate = async () => {
setFormError(null);
const amountMinor = Math.round(parseFloat(form.amountEtb) * 100);
if (!form.bookingRef.trim()) return setFormError('Booking reference is required');
if (!form.amountEtb || isNaN(amountMinor) || amountMinor <= 0) return setFormError('Enter a valid amount');
try {
await createMutation.mutateAsync({ bookingRef: form.bookingRef.trim(), amountMinor, reason: form.reason, notes: form.notes || undefined });
} catch (e: any) {
setFormError(e?.response?.data?.message ?? e?.message ?? 'Failed to create charge');
}
};
return (
<Modal isOpen={isOpen} onClose={onClose} title="Raise Supplementary Charge" size="lg">
<div className="space-y-4">
{createSuccess && (
<div className="rounded-lg bg-green-50 dark:bg-green-900/20 p-3 text-sm text-green-800 dark:text-green-200"> {createSuccess}</div>
)}
{formError && (
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 p-3 text-sm text-red-700 dark:text-red-300">{formError}</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2">
<label className="label">Booking Reference <span className="text-red-500">*</span></label>
<input className="input" placeholder="e.g. EDR-20240001" value={form.bookingRef} onChange={(e) => setForm({ ...form, bookingRef: e.target.value })} />
</div>
<div>
<label className="label">Amount Owed (ETB) <span className="text-red-500">*</span></label>
<input className="input" type="number" min="0.01" step="0.01" placeholder="e.g. 50.00" value={form.amountEtb} onChange={(e) => setForm({ ...form, amountEtb: e.target.value })} />
</div>
<div>
<label className="label">Reason <span className="text-red-500">*</span></label>
<select className="input" value={form.reason} onChange={(e) => setForm({ ...form, reason: e.target.value })}>
{REASONS.map((r) => <option key={r} value={r}>{r.replace('_', ' ')}</option>)}
</select>
</div>
<div className="md:col-span-2">
<label className="label">Notes (optional)</label>
<textarea className="input resize-none" rows={2} placeholder="e.g. Passenger paid 350 ETB, correct fare is 400 ETB" value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} />
</div>
</div>
<p className="text-xs text-muted-foreground">
A payment link will be sent to the passenger's registered phone/email. The link expires in 72 hours.
</p>
<div className="flex justify-end gap-2 pt-2 border-t border-muted">
<ActionButton variant="secondary" onClick={onClose}>Cancel</ActionButton>
<ActionButton icon={PlusCircle} onClick={handleCreate} loading={createMutation.isPending}>Raise Charge</ActionButton>
</div>
</div>
</Modal>
);
}

View File

@@ -2,7 +2,7 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Download, Eye, Trash2 } from 'lucide-react';
import { Download, Eye, Trash2, AlertCircle, Send, CheckCircle, XCircle, RotateCcw } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
@@ -10,6 +10,22 @@ import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { paymentsApi, apiClient } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils';
import SupplementaryChargesModal from './SupplementaryChargesModal';
import {
useSupplementaryCharges,
useMarkSupplementaryPaid,
useWaiveSupplementaryCharge,
useResendSupplementaryLink,
} from './useSupplementaryCharges';
type PageTab = 'payments' | 'supplementary';
const STATUS_COLORS: Record<string, string> = {
PENDING: 'warning',
PAID: 'success',
WAIVED: 'info',
EXPIRED: 'error',
};
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
<div className="bg-muted/40 rounded-lg p-3">
@@ -25,6 +41,7 @@ const SectionHeader = ({ title }: { title: string }) => (
);
export default function PaymentsPage() {
const [pageTab, setPageTab] = useState<PageTab>('payments');
const [filters, setFilters] = useState({ search: '', status: '', method: '' });
const [selectedPayment, setSelectedPayment] = useState<any>(null);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
@@ -37,6 +54,34 @@ export default function PaymentsPage() {
const [exportColumns, setExportColumns] = useState<Record<string, boolean>>({
reference: true, booking: true, amount: true, method: true, status: true, createdAt: true,
});
const [supplementaryOpen, setSupplementaryOpen] = useState(false);
// Supplementary tab state
const [suppFilters, setSuppFilters] = useState({ bookingRef: '', status: '' });
const [suppActionError, setSuppActionError] = useState<string | null>(null);
const [suppActionSuccess, setSuppActionSuccess] = useState<string | null>(null);
const { data: chargesData, isLoading: loadingCharges } = useSupplementaryCharges(suppFilters);
const charges: any[] = (chargesData as any)?.items ?? (Array.isArray(chargesData) ? chargesData : []);
const markPaidMutation = useMarkSupplementaryPaid();
const waiveMutation = useWaiveSupplementaryCharge();
const resendMutation = useResendSupplementaryLink();
const flashSupp = (msg: string) => { setSuppActionSuccess(msg); setTimeout(() => setSuppActionSuccess(null), 3000); };
const handleMarkPaid = async (id: string) => {
setSuppActionError(null);
try { await markPaidMutation.mutateAsync({ id }); flashSupp('Marked as paid'); }
catch (e: any) { setSuppActionError(e?.response?.data?.message ?? e?.message ?? 'Failed'); }
};
const handleWaive = async (id: string) => {
setSuppActionError(null);
try { await waiveMutation.mutateAsync({ id }); flashSupp('Charge waived'); }
catch (e: any) { setSuppActionError(e?.response?.data?.message ?? e?.message ?? 'Failed'); }
};
const handleResend = async (id: string) => {
setSuppActionError(null);
try { await resendMutation.mutateAsync(id); flashSupp('Payment link resent'); }
catch (e: any) { setSuppActionError(e?.response?.data?.message ?? e?.message ?? 'Failed'); }
};
const queryClient = useQueryClient();
@@ -134,9 +179,104 @@ export default function PaymentsPage() {
<h1 className="text-2xl font-bold text-foreground">Payments</h1>
<p className="text-muted-foreground">Manage payment transactions and refunds</p>
</div>
<ActionButton icon={Download} variant="export" onClick={() => setExportModalOpen(true)}>Export</ActionButton>
<div className="flex items-center gap-2">
{pageTab === 'supplementary' && (
<ActionButton icon={AlertCircle} variant="secondary" onClick={() => setSupplementaryOpen(true)}>Raise Charge</ActionButton>
)}
{pageTab === 'payments' && (
<ActionButton icon={Download} variant="export" onClick={() => setExportModalOpen(true)}>Export</ActionButton>
)}
</div>
</div>
{/* Page-level tabs */}
<div className="flex gap-1 border-b border-muted">
{(['payments', 'supplementary'] as PageTab[]).map((t) => (
<button
key={t}
onClick={() => setPageTab(t)}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
pageTab === t
? 'border-emerald-500 text-emerald-600 dark:text-emerald-400'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
{t === 'payments' ? 'Payments' : 'Supplementary Charges'}
</button>
))}
</div>
{/* ── SUPPLEMENTARY TAB ── */}
{pageTab === 'supplementary' && (
<div className="space-y-4">
{suppActionSuccess && (
<div className="rounded-lg bg-green-50 dark:bg-green-900/20 p-3 text-sm text-green-800 dark:text-green-200"> {suppActionSuccess}</div>
)}
{suppActionError && (
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 p-3 text-sm text-red-700 dark:text-red-300">{suppActionError}</div>
)}
<div className="card grid grid-cols-2 gap-3">
<div>
<label className="label">Booking Ref</label>
<input className="input" placeholder="Search booking ref…" value={suppFilters.bookingRef} onChange={(e) => setSuppFilters({ ...suppFilters, bookingRef: e.target.value })} />
</div>
<div>
<label className="label">Status</label>
<select className="input" value={suppFilters.status} onChange={(e) => setSuppFilters({ ...suppFilters, status: e.target.value })}>
<option value="">All</option>
<option value="PENDING">Pending</option>
<option value="PAID">Paid</option>
<option value="WAIVED">Waived</option>
<option value="EXPIRED">Expired</option>
</select>
</div>
</div>
{loadingCharges ? (
<p className="text-sm text-muted-foreground py-6 text-center">Loading</p>
) : charges.length === 0 ? (
<p className="text-sm text-muted-foreground py-6 text-center">No supplementary charges found.</p>
) : (
<div className="overflow-x-auto rounded-lg border border-muted">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/40 text-left text-xs font-semibold uppercase tracking-wider text-muted-foreground">
<th className="px-3 py-2">Booking</th>
<th className="px-3 py-2">Amount</th>
<th className="px-3 py-2">Reason</th>
<th className="px-3 py-2">Status</th>
<th className="px-3 py-2">Created</th>
<th className="px-3 py-2">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-muted">
{charges.map((c: any) => (
<tr key={c.id} className="hover:bg-muted/20 transition-colors">
<td className="px-3 py-2 font-mono text-xs">{c.booking?.bookingRef ?? c.bookingId.substring(0, 8)}</td>
<td className="px-3 py-2 font-semibold">{formatCurrency(c.amountMinor, c.currency ?? 'ETB')}</td>
<td className="px-3 py-2 text-xs">{c.reason}</td>
<td className="px-3 py-2"><Badge variant="status" status={STATUS_COLORS[c.status] ?? c.status}>{c.status}</Badge></td>
<td className="px-3 py-2 text-xs text-muted-foreground">{formatDateTime(c.createdAt)}</td>
<td className="px-3 py-2">
{c.status === 'PENDING' && (
<div className="flex gap-1">
<button title="Mark paid" onClick={() => handleMarkPaid(c.id)} className="p-1 rounded hover:bg-green-100 dark:hover:bg-green-900/30 text-green-600"><CheckCircle size={15} /></button>
<button title="Waive" onClick={() => handleWaive(c.id)} className="p-1 rounded hover:bg-red-100 dark:hover:bg-red-900/30 text-red-500"><XCircle size={15} /></button>
<button title="Resend link" onClick={() => handleResend(c.id)} className="p-1 rounded hover:bg-blue-100 dark:hover:bg-blue-900/30 text-blue-500"><RotateCcw size={15} /></button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{/* ── PAYMENTS TAB ── */}
{pageTab === 'payments' && (
<>
<div className="card">
{successMessage && (
<div className="mb-4 rounded-lg bg-green-50 dark:bg-green-900/20 p-4 text-sm text-green-800 dark:text-green-200"> {successMessage}</div>
@@ -280,6 +420,11 @@ export default function PaymentsPage() {
error={deleteError ?? undefined}
/>
</>
)}
<SupplementaryChargesModal isOpen={supplementaryOpen} onClose={() => setSupplementaryOpen(false)} />
{/* Export Modal */}
<Modal isOpen={exportModalOpen} onClose={() => setExportModalOpen(false)} title="Export Payments" size="md">
<div className="space-y-4">

View File

@@ -0,0 +1,47 @@
'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { paymentsApi } from '@/lib/api';
export function useSupplementaryCharges(filters: { bookingRef?: string; status?: string }) {
return useQuery({
queryKey: ['supplementary-charges', filters],
queryFn: () => paymentsApi.supplementary.getAll(filters),
});
}
export function useCreateSupplementaryCharge(onSuccess: () => void) {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { bookingRef: string; amountMinor: number; reason: string; notes?: string }) =>
paymentsApi.supplementary.create(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['supplementary-charges'] });
onSuccess();
},
});
}
export function useMarkSupplementaryPaid() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, providerTxnId }: { id: string; providerTxnId?: string }) =>
paymentsApi.supplementary.markPaid(id, providerTxnId),
onSuccess: () => qc.invalidateQueries({ queryKey: ['supplementary-charges'] }),
});
}
export function useWaiveSupplementaryCharge() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, notes }: { id: string; notes?: string }) =>
paymentsApi.supplementary.waive(id, notes),
onSuccess: () => qc.invalidateQueries({ queryKey: ['supplementary-charges'] }),
});
}
export function useResendSupplementaryLink() {
return useMutation({
mutationFn: (id: string) => paymentsApi.supplementary.resend(id),
});
}

View File

@@ -2,13 +2,23 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Download, TrendingUp, Users, DollarSign, AlertCircle } from 'lucide-react';
import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts';
import { Download, TrendingUp, BookOpen, Banknote, Ticket } from 'lucide-react';
import {
LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid,
Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell,
} from 'recharts';
import { bookingsApi } from '@/lib/api';
import { dashboardApi } from '@/lib/api/dashboard';
import { apiClient } from '@/lib/api-client';
import { formatCurrency } from '@/lib/utils';
import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
const COLORS = ['#3b82f6', '#10b981', '#f59e0b'];
const STATUS_COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444'];
function esc(s: string) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
export default function ReportsPage() {
const [dateRange, setDateRange] = useState('30');
@@ -21,23 +31,13 @@ export default function ReportsPage() {
const end = new Date();
end.setHours(23, 59, 59, 999);
const start = new Date();
switch (dateRange) {
case '7':
start.setDate(end.getDate() - 7);
break;
case '30':
start.setDate(end.getDate() - 30);
break;
case '90':
start.setDate(end.getDate() - 90);
break;
case '7': start.setDate(end.getDate() - 7); break;
case '30': start.setDate(end.getDate() - 30); break;
case '90': start.setDate(end.getDate() - 90); break;
default:
if (startDate && endDate) {
return { startDate, endDate };
}
if (startDate && endDate) return { startDate, endDate };
}
return {
startDate: start.toISOString().split('T')[0],
endDate: end.toISOString().split('T')[0],
@@ -46,37 +46,61 @@ export default function ReportsPage() {
const dates = getDateRange();
// Fetch all bookings
const { data: bookingsData, isLoading } = useQuery({
// Confirmed-ticket revenue — same source as dashboard
const { data: stats, isLoading: statsLoading } = useQuery({
queryKey: ['backoffice-stats'],
queryFn: dashboardApi.getBackofficeStats,
staleTime: 60000,
});
const { data: exchangeRates = [] } = useQuery<any[]>({
queryKey: ['currencies'],
queryFn: () => apiClient.get('/currencies'),
select: (d: any) => (Array.isArray(d) ? d : d?.data ?? d?.items ?? []),
});
const toEtbRate = (currency: string): number | null => {
if (currency === 'ETB') return 1;
const r = exchangeRates.find((x: any) => x.fromCurrency === 'ETB' && x.toCurrency === currency);
return r ? 1 / r.rate : null;
};
const calcGrand = (rows: { currency: string; totalMinor: number }[]) =>
rows.reduce((sum, { currency, totalMinor }) => {
const rate = toEtbRate(currency);
return rate !== null ? sum + Math.round(totalMinor * rate) : sum;
}, 0);
const normalRows = stats?.revenueByCurrency ?? [];
const packageRows = stats?.packageRevenueByCurrency ?? [];
const normalGrand = calcGrand(normalRows);
const packageGrand = calcGrand(packageRows);
const overallGrand = normalGrand + packageGrand;
// Bookings for charts / status distribution
const { data: bookingsData, isLoading: bookingsLoading } = useQuery({
queryKey: ['all-bookings'],
queryFn: () => bookingsApi.getAll({ pageSize: 1000 }),
});
// Filter bookings by date range — exclude CANCELLED from revenue calculations
const bookings = Array.isArray(bookingsData?.items)
? bookingsData.items.filter((b: any) => {
const bookingDate = new Date(b.createdAt).toISOString().split('T')[0];
return bookingDate >= dates.startDate && bookingDate <= dates.endDate;
})
: [];
const isLoading = statsLoading || bookingsLoading;
const revenueBookings = bookings.filter((b: any) => b.status !== 'CANCELLED' && b.status !== 'REFUNDED');
const allBookings: any[] = Array.isArray(bookingsData?.items) ? bookingsData.items : [];
// Calculate metrics — revenue excludes cancelled/refunded bookings
const totalRevenue = revenueBookings.reduce((sum: number, b: any) => sum + (b.totalMinor || 0), 0);
const totalBookings = bookings.length;
const avgTicketPrice = revenueBookings.length > 0 ? Math.round(totalRevenue / revenueBookings.length) : 0;
const bookings = allBookings.filter((b: any) => {
const d = new Date(b.createdAt).toISOString().split('T')[0];
return d >= dates.startDate && d <= dates.endDate;
});
// Group by date for revenue chart — exclude cancelled/refunded
const byDate = revenueBookings.reduce((acc: Record<string, any>, b: any) => {
const confirmedBookings = bookings.filter((b: any) => b.status !== 'CANCELLED' && b.status !== 'REFUNDED');
const byDate = confirmedBookings.reduce((acc: Record<string, any>, b: any) => {
const date = new Date(b.createdAt).toISOString().split('T')[0];
if (!acc[date]) {
acc[date] = { totalMinor: 0, count: 0 };
}
if (!acc[date]) acc[date] = { totalMinor: 0, count: 0 };
acc[date].totalMinor += b.totalMinor || 0;
acc[date].count += 1;
return acc;
}, {} as Record<string, any>);
}, {});
const chartData = Object.entries(byDate)
.sort(([a], [b]) => a.localeCompare(b))
@@ -86,33 +110,37 @@ export default function ReportsPage() {
bookings: d.count || 0,
}));
const REPORT_COLS = [
{ key: 'date', label: 'Date' },
{ key: 'revenue', label: 'Revenue (ETB)' },
{ key: 'bookings', label: 'Bookings' },
];
const avgDailyRevenue = chartData.length > 0 ? Math.round(overallGrand / 100 / chartData.length) : 0;
const REPORT_COLS = ['Date', 'Revenue (ETB)', 'Confirmed Bookings'];
const doExport = () => {
if (!chartData.length) { alert('No data to export'); return; }
const headers = REPORT_COLS.map(c => c.label);
const rows = chartData.map(r => [r.date, String(Math.round(r.revenue)), String(r.bookings)]);
const dateStr = new Date().toISOString().split('T')[0];
if (exportFormat === 'pdf') {
const w = window.open('', '_blank')!;
w.document.write(`<!DOCTYPE html><html><head><title>Revenue Report</title><style>body{font-family:sans-serif;font-size:11px}table{border-collapse:collapse;width:100%}th,td{border:1px solid #ccc;padding:4px 8px}th{background:#10b981;color:#fff}</style></head><body>`);
w.document.write(`<h2>Revenue Report — ${dates.startDate} to ${dates.endDate}</h2>`);
w.document.write(`<p>Total Revenue: ETB ${Math.round(totalRevenue / 100).toLocaleString()} | Total Bookings: ${totalBookings} | Cancelled: ${bookings.filter((b: any) => b.status === 'CANCELLED').length}</p>`);
w.document.write(`<table><thead><tr>${headers.map(h => `<th>${h}</th>`).join('')}</tr></thead><tbody>`);
rows.forEach(r => { w.document.write(`<tr>${r.map(v => `<td>${v}</td>`).join('')}</tr>`); });
w.document.write('</tbody></table></body></html>');
const thead = REPORT_COLS.map(h => `<th>${esc(h)}</th>`).join('');
const tbody = rows.map(r => `<tr>${r.map(v => `<td>${esc(v)}</td>`).join('')}</tr>`).join('');
w.document.write(
`<!DOCTYPE html><html><head><title>Revenue Report</title>` +
`<style>body{font-family:sans-serif;font-size:11px}table{border-collapse:collapse;width:100%}` +
`th,td{border:1px solid #ccc;padding:4px 8px}th{background:#10b981;color:#fff}</style></head><body>` +
`<h2>Revenue Report — ${esc(dates.startDate)} to ${esc(dates.endDate)}</h2>` +
`<p>Total Revenue: ${esc(formatCurrency(overallGrand, 'ETB'))} | ` +
`Bookings: ${esc(String(stats?.totalBookings ?? 0))} | ` +
`Tickets: ${esc(String(stats?.totalTickets ?? 0))}</p>` +
`<table><thead><tr>${thead}</tr></thead><tbody>${tbody}</tbody></table></body></html>`
);
w.document.close(); w.print();
} else if (exportFormat === 'excel') {
const tsv = [headers.join('\t'), ...rows.map(r => r.join('\t'))].join('\n');
const tsv = [REPORT_COLS.join('\t'), ...rows.map(r => r.join('\t'))].join('\n');
const blob = new Blob([tsv], { type: 'application/vnd.ms-excel' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = `revenue-report-${dateStr}.xls`; a.click(); URL.revokeObjectURL(url);
} else {
const csv = [headers.map(h => `"${h}"`).join(','), ...rows.map(r => r.map(v => `"${v}"`).join(','))].join('\n');
const csv = [REPORT_COLS.map(h => `"${h}"`).join(','), ...rows.map(r => r.map(v => `"${v}"`).join(','))].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = `revenue-report-${dateStr}.csv`; a.click(); URL.revokeObjectURL(url);
@@ -120,11 +148,32 @@ export default function ReportsPage() {
setExportModalOpen(false);
};
const renderCurrencyRow = ({ currency, totalMinor }: { currency: string; totalMinor: number }) => {
const rate = toEtbRate(currency);
const etbMinor = rate !== null ? Math.round(totalMinor * rate) : null;
return (
<div key={currency} className="flex items-center justify-between rounded-md bg-muted/20 px-3 py-2">
<div className="flex items-center gap-1.5">
<Banknote className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-sm font-medium">{currency}</span>
</div>
<span className="text-sm font-semibold tabular-nums">
{formatCurrency(totalMinor, currency)}
{currency !== 'ETB' && etbMinor !== null && (
<span className="ml-1.5 text-xs font-normal text-muted-foreground">
({formatCurrency(etbMinor, 'ETB')})
</span>
)}
</span>
</div>
);
};
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-foreground">Reports & Analytics</h1>
<p className="text-muted-foreground mt-1">View detailed reports and performance metrics</p>
<p className="text-muted-foreground mt-1">Revenue figures reflect confirmed tickets only</p>
</div>
{/* Date Range Selector */}
@@ -132,239 +181,327 @@ export default function ReportsPage() {
<div className="flex items-end gap-4 flex-wrap">
<div>
<label className="label">Date Range</label>
<select
className="input"
value={dateRange}
onChange={(e) => setDateRange(e.target.value)}
disabled={isLoading}
>
<select className="input" value={dateRange} onChange={(e) => setDateRange(e.target.value)} disabled={isLoading}>
<option value="7">Last 7 Days</option>
<option value="30">Last 30 Days</option>
<option value="90">Last 90 Days</option>
<option value="custom">Custom Range</option>
</select>
</div>
{dateRange === 'custom' && (
<>
<div>
<label className="label">Start Date</label>
<input
type="date"
className="input"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
disabled={isLoading}
/>
<input type="date" className="input" value={startDate} onChange={(e) => setStartDate(e.target.value)} disabled={isLoading} />
</div>
<div>
<label className="label">End Date</label>
<input
type="date"
className="input"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
disabled={isLoading}
/>
<input type="date" className="input" value={endDate} onChange={(e) => setEndDate(e.target.value)} disabled={isLoading} />
</div>
</>
)}
<ActionButton icon={Download} variant="secondary" disabled={isLoading} onClick={() => setExportModalOpen(true)}>
Export
</ActionButton>
</div>
{isLoading && (
<p className="text-xs text-muted-foreground mt-2">Loading...</p>
)}
{isLoading && <p className="text-xs text-muted-foreground mt-2">Loading</p>}
</div>
{/* Key Metrics */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="card">
<div className="flex items-start justify-between">
<div>
<p className="text-muted-foreground text-sm font-medium">Total Revenue</p>
<p className="text-2xl font-bold mt-2">ETB {Math.round(totalRevenue / 100).toLocaleString()}</p>
<p className="text-xs text-muted-foreground mt-1">Excl. cancelled &amp; refunded</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{/* Total Revenue */}
<div className="card flex flex-col gap-1">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Total Revenue</p>
<div className="rounded-lg bg-amber-100 dark:bg-amber-900/30 p-1.5">
<Banknote className="h-4 w-4 text-amber-600 dark:text-amber-400" />
</div>
</div>
<p className="text-2xl font-bold text-emerald-600 dark:text-emerald-400 tabular-nums mt-1">
{statsLoading ? '—' : formatCurrency(overallGrand, 'ETB')}
</p>
<div className="flex flex-col gap-1 border-t border-border pt-2 mt-1">
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">Regular</span>
<span className="font-semibold tabular-nums">{statsLoading ? '—' : formatCurrency(normalGrand, 'ETB')}</span>
</div>
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">Package</span>
<span className="font-semibold tabular-nums">{statsLoading ? '—' : formatCurrency(packageGrand, 'ETB')}</span>
</div>
<DollarSign className="h-8 w-8 text-blue-500 opacity-20" />
</div>
</div>
<div className="card">
<div className="flex items-start justify-between">
<div>
<p className="text-muted-foreground text-sm font-medium">Total Bookings</p>
<p className="text-2xl font-bold mt-2">{totalBookings.toLocaleString()}</p>
<p className="text-xs text-muted-foreground mt-1">All bookings</p>
{/* Total Bookings */}
<div className="card flex flex-col gap-1">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Total Bookings</p>
<div className="rounded-lg bg-blue-100 dark:bg-blue-900/30 p-1.5">
<BookOpen className="h-4 w-4 text-blue-600 dark:text-blue-400" />
</div>
</div>
<p className="text-2xl font-bold tabular-nums mt-1">
{statsLoading ? '—' : (stats?.totalBookings ?? 0).toLocaleString()}
</p>
<div className="flex flex-col gap-1 border-t border-border pt-2 mt-1">
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">Regular</span>
<span className="font-semibold tabular-nums">{statsLoading ? '—' : (stats?.totalNormalBookings ?? 0).toLocaleString()}</span>
</div>
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">Package</span>
<span className="font-semibold tabular-nums">{statsLoading ? '—' : (stats?.totalPackageBookings ?? 0).toLocaleString()}</span>
</div>
<Users className="h-8 w-8 text-green-500 opacity-20" />
</div>
</div>
<div className="card">
<div className="flex items-start justify-between">
<div>
<p className="text-muted-foreground text-sm font-medium">Avg. Ticket Price</p>
<p className="text-2xl font-bold mt-2">ETB {(avgTicketPrice / 100).toLocaleString()}</p>
<p className="text-xs text-muted-foreground mt-1">Non-cancelled bookings</p>
{/* Total Tickets */}
<div className="card flex flex-col gap-1">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Total Tickets</p>
<div className="rounded-lg bg-emerald-100 dark:bg-emerald-900/30 p-1.5">
<Ticket className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
</div>
</div>
<p className="text-2xl font-bold tabular-nums mt-1">
{statsLoading ? '—' : (stats?.totalTickets ?? 0).toLocaleString()}
</p>
<div className="flex flex-col gap-1 border-t border-border pt-2 mt-1">
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">Regular</span>
<span className="font-semibold tabular-nums">{statsLoading ? '—' : (stats?.totalNormalTickets ?? 0).toLocaleString()}</span>
</div>
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">Package</span>
<span className="font-semibold tabular-nums">{statsLoading ? '—' : (stats?.totalPackageTickets ?? 0).toLocaleString()}</span>
</div>
<TrendingUp className="h-8 w-8 text-purple-500 opacity-20" />
</div>
</div>
<div className="card">
<div className="flex items-start justify-between">
<div>
<p className="text-muted-foreground text-sm font-medium">Avg. Daily Revenue</p>
<p className="text-2xl font-bold mt-2">ETB {chartData.length > 0 ? Math.round((totalRevenue / 100) / chartData.length).toLocaleString() : '0'}</p>
<p className="text-xs text-muted-foreground mt-1">Daily average</p>
{/* Avg Daily Revenue */}
<div className="card flex flex-col gap-1">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Avg. Daily Revenue</p>
<div className="rounded-lg bg-purple-100 dark:bg-purple-900/30 p-1.5">
<TrendingUp className="h-4 w-4 text-purple-600 dark:text-purple-400" />
</div>
<AlertCircle className="h-8 w-8 text-orange-500 opacity-20" />
</div>
<p className="text-2xl font-bold tabular-nums mt-1">
{isLoading ? '—' : formatCurrency(avgDailyRevenue * 100, 'ETB')}
</p>
<p className="text-xs text-muted-foreground mt-auto pt-2 border-t border-border">
Over {chartData.length} active day{chartData.length !== 1 ? 's' : ''} in range
</p>
</div>
</div>
{/* Revenue Breakdown by Currency */}
<div className="card">
<h2 className="text-sm font-semibold uppercase tracking-widest text-muted-foreground mb-4">
Revenue Breakdown Confirmed Tickets
</h2>
{statsLoading ? (
<p className="text-sm text-muted-foreground">Loading</p>
) : !normalRows.length && !packageRows.length ? (
<p className="text-sm text-muted-foreground">No revenue data yet.</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
{/* Regular */}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between mb-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Regular</span>
<span className="text-xs text-muted-foreground tabular-nums">
{(stats?.totalNormalBookings ?? 0).toLocaleString()} bookings · {(stats?.totalNormalTickets ?? 0).toLocaleString()} tickets
</span>
</div>
{normalRows.length === 0
? <p className="text-xs text-muted-foreground py-1">No revenue yet</p>
: normalRows.map(renderCurrencyRow)}
{normalRows.length > 0 && (
<div className="flex items-center justify-between rounded-md bg-muted/40 px-3 py-2 mt-1">
<span className="text-xs font-semibold text-muted-foreground">Subtotal</span>
<span className="text-sm font-bold tabular-nums">{formatCurrency(normalGrand, 'ETB')}</span>
</div>
)}
</div>
{/* Package */}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between mb-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Package</span>
<span className="text-xs text-muted-foreground tabular-nums">
{(stats?.totalPackageBookings ?? 0).toLocaleString()} bookings · {(stats?.totalPackageTickets ?? 0).toLocaleString()} tickets
</span>
</div>
{packageRows.length === 0
? <p className="text-xs text-muted-foreground py-1">No revenue yet</p>
: packageRows.map(renderCurrencyRow)}
{packageRows.length > 0 && (
<div className="flex items-center justify-between rounded-md bg-muted/40 px-3 py-2 mt-1">
<span className="text-xs font-semibold text-muted-foreground">Subtotal</span>
<span className="text-sm font-bold tabular-nums">{formatCurrency(packageGrand, 'ETB')}</span>
</div>
)}
</div>
</div>
)}
{!statsLoading && (normalRows.length > 0 || packageRows.length > 0) && (
<div className="flex items-center justify-between rounded-lg border border-border bg-muted/30 px-4 py-3 mt-4">
<span className="text-sm font-semibold text-muted-foreground">Grand Total (ETB equivalent)</span>
<span className="text-lg font-bold text-emerald-600 dark:text-emerald-400 tabular-nums">
{formatCurrency(overallGrand, 'ETB')}
</span>
</div>
)}
</div>
{/* Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Revenue Trend */}
<div className="card">
<h3 className="text-lg font-semibold mb-4">Revenue Trend</h3>
<h3 className="text-base font-semibold mb-4">
Revenue Trend{' '}
<span className="text-xs font-normal text-muted-foreground">(confirmed, ETB)</span>
</h3>
{chartData.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<ResponsiveContainer width="100%" height={280}>
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="date" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} />
<Tooltip formatter={(value: number) => `ETB ${Math.round(value).toLocaleString()}`} />
<XAxis dataKey="date" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 11 }} />
<Tooltip formatter={(value: number) => [`ETB ${Math.round(value).toLocaleString()}`, 'Revenue']} />
<Legend />
<Line type="monotone" dataKey="revenue" stroke="#3b82f6" dot={{ r: 5 }} activeDot={{ r: 7 }} strokeWidth={2} />
<Line type="monotone" dataKey="revenue" stroke="#10b981" dot={{ r: 4 }} activeDot={{ r: 6 }} strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
) : (
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
No data available
<div className="h-[280px] flex items-center justify-center text-muted-foreground text-sm">
No data for selected range
</div>
)}
</div>
{/* Daily Bookings */}
{/* Daily Confirmed Bookings */}
<div className="card">
<h3 className="text-lg font-semibold mb-4">Daily Bookings</h3>
<h3 className="text-base font-semibold mb-4">Daily Confirmed Bookings</h3>
{chartData.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<ResponsiveContainer width="100%" height={280}>
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="date" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} />
<XAxis dataKey="date" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 11 }} />
<Tooltip />
<Bar dataKey="bookings" fill="#10b981" />
<Bar dataKey="bookings" fill="#3b82f6" radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
) : (
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
No data available
<div className="h-[280px] flex items-center justify-center text-muted-foreground text-sm">
No data for selected range
</div>
)}
</div>
{/* Booking Status Distribution */}
<div className="card">
<h3 className="text-lg font-semibold mb-4">Booking Status</h3>
<h3 className="text-base font-semibold mb-4">Booking Status Distribution</h3>
{bookings.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<ResponsiveContainer width="100%" height={280}>
<PieChart>
<Pie
data={[
{ name: 'Confirmed', value: bookings.filter((b: any) => b.status === 'CONFIRMED').length },
{ name: 'Completed', value: bookings.filter((b: any) => b.status === 'BOARDED').length },
{ name: 'Boarded', value: bookings.filter((b: any) => b.status === 'BOARDED').length },
{ name: 'Cancelled', value: bookings.filter((b: any) => b.status === 'CANCELLED').length },
{ name: 'Other', value: bookings.filter((b: any) => !['CONFIRMED', 'BOARDED', 'CANCELLED'].includes(b.status)).length },
].filter(d => d.value > 0)}
cx="50%"
cy="50%"
cx="50%" cy="50%"
labelLine={false}
label={({ name, value }) => `${name}: ${value}`}
outerRadius={100}
dataKey="value"
>
{COLORS.map((color, idx) => <Cell key={idx} fill={color} />)}
{STATUS_COLORS.map((color, idx) => <Cell key={idx} fill={color} />)}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
) : (
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
No data available
<div className="h-[280px] flex items-center justify-center text-muted-foreground text-sm">
No data for selected range
</div>
)}
</div>
{/* Top Payment Methods */}
{/* Payment Methods */}
<div className="card">
<h3 className="text-lg font-semibold mb-4">Payment Methods</h3>
<h3 className="text-base font-semibold mb-4">Payment Methods</h3>
{bookings.length > 0 ? (
<div className="space-y-3">
<div className="space-y-3 pt-1">
{(Object.entries(
bookings.reduce((acc: Record<string, number>, b: any) => {
const method = b.paymentIntent?.method || 'Unknown';
acc[method] = (acc[method] || 0) + 1;
return acc;
}, {} as Record<string, number>)
) as [string, number][]
)
) as [string, number][])
.sort(([, a], [, b]) => b - a)
.slice(0, 5)
.map(([method, count]) => (
<div key={method} className="flex justify-between items-center p-2 bg-gray-50 dark:bg-gray-900 rounded">
<span className="text-sm capitalize">{method.toLowerCase().replace(/_/g, ' ')}</span>
<span className="font-semibold">{count}</span>
</div>
))}
.slice(0, 6)
.map(([method, count]) => {
const pct = bookings.length > 0 ? Math.round((count / bookings.length) * 100) : 0;
return (
<div key={method} className="flex items-center gap-3">
<span className="text-sm w-32 shrink-0 capitalize">{method.toLowerCase().replace(/_/g, ' ')}</span>
<div className="flex-1 bg-muted rounded-full h-2">
<div className="bg-primary h-2 rounded-full" style={{ width: `${pct}%` }} />
</div>
<span className="text-sm font-semibold tabular-nums w-8 text-right">{count}</span>
</div>
);
})}
</div>
) : (
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
No data available
<div className="h-[280px] flex items-center justify-center text-muted-foreground text-sm">
No data for selected range
</div>
)}
</div>
</div>
{/* Summary Stats */}
{/* Summary */}
<div className="card">
<h3 className="text-lg font-semibold mb-4">Summary</h3>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<p className="text-sm text-muted-foreground">Total Days with Bookings</p>
<p className="text-xl font-bold mt-2">{chartData.length}</p>
</div>
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<p className="text-sm text-muted-foreground">Confirmed Bookings</p>
<p className="text-xl font-bold mt-2">{bookings.filter((b: any) => b.status === 'CONFIRMED').length}</p>
</div>
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<p className="text-sm text-muted-foreground">Completed Bookings</p>
<p className="text-xl font-bold mt-2">{bookings.filter((b: any) => b.status === 'BOARDED').length}</p>
</div>
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<p className="text-sm text-muted-foreground">Cancelled Bookings</p>
<p className="text-xl font-bold mt-2">{bookings.filter((b: any) => b.status === 'CANCELLED').length}</p>
</div>
<h3 className="text-base font-semibold mb-4">Summary</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
{[
{ label: 'Active Days', value: chartData.length, fromStats: false },
{ label: 'Confirmed', value: bookings.filter((b: any) => b.status === 'CONFIRMED').length, fromStats: false },
{ label: 'Boarded', value: bookings.filter((b: any) => b.status === 'BOARDED').length, fromStats: false },
{ label: 'Cancelled', value: bookings.filter((b: any) => b.status === 'CANCELLED').length, fromStats: false },
{ label: 'Regular Bookings', value: stats?.totalNormalBookings ?? 0, fromStats: true },
{ label: 'Package Bookings', value: stats?.totalPackageBookings ?? 0, fromStats: true },
].map(({ label, value, fromStats }) => (
<div key={label} className="border border-border rounded-lg p-3 text-center">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="text-xl font-bold mt-1 tabular-nums">
{fromStats && statsLoading ? '—' : value.toLocaleString()}
</p>
</div>
))}
</div>
</div>
{/* Export Modal */}
<Modal isOpen={exportModalOpen} onClose={() => setExportModalOpen(false)} title="Export Revenue Report" size="sm">
<div className="space-y-4">
<p className="text-sm text-muted-foreground">Exports daily revenue and booking counts for the selected date range. Cancelled and refunded bookings are excluded from revenue figures.</p>
<p className="text-sm text-muted-foreground">
Exports daily confirmed-booking revenue for the selected date range. Cancelled and refunded bookings are excluded.
</p>
<div>
<p className="text-sm font-medium mb-2">Export Format</p>
<p className="text-sm font-medium mb-2">Format</p>
<div className="flex gap-3">
{(['csv', 'excel', 'pdf'] as const).map(fmt => (
<label key={fmt} className="flex items-center gap-2 cursor-pointer">
<input type="radio" name="reportExportFormat" value={fmt} checked={exportFormat === fmt} onChange={() => setExportFormat(fmt)} className="w-4 h-4" />
<span className="text-sm font-medium capitalize">{fmt === 'excel' ? 'Excel (.xls)' : fmt === 'pdf' ? 'PDF (Print)' : 'CSV'}</span>
<span className="text-sm font-medium">{fmt === 'excel' ? 'Excel (.xls)' : fmt === 'pdf' ? 'PDF (Print)' : 'CSV'}</span>
</label>
))}
</div>

View File

@@ -0,0 +1,3 @@
export default function Layout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}

View File

@@ -0,0 +1,312 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Users, Armchair, BarChart3, Download } from 'lucide-react';
import { apiClient } from '@/lib/api-client';
import ActionButton from '@/components/ui/ActionButton';
import { formatDateTime } from '@/lib/utils';
interface ScheduleOption { id: string; label: string; }
interface PassengersReport {
schedule: { id: string; trainName: string; origin: string; destination: string; departureAt: string; arrivalAt: string; };
summary: { totalSeats: number; totalPassengers: number; occupancyRate: number };
byCoach: { coachNumber: string; coachType: string; totalSeats: number; booked: number; occupancyRate: number }[];
byClass: { className: string; totalSeats: number; booked: number; occupancyRate: number }[];
byOrigin: { stationName: string; passengers: number }[];
byDestination: { stationName: string; passengers: number }[];
}
interface PassengerRow {
bookingRef: string;
passengerName: string;
coachSeat: string;
origin: string;
destination: string;
departureAt: string | null;
}
type Tab = 'occupancy' | 'list';
export default function PassengersReportPage() {
const [scheduleId, setScheduleId] = useState('');
const [tab, setTab] = useState<Tab>('occupancy');
const [listSearch, setListSearch] = useState('');
const { data: schedulesRaw, isLoading: loadingSchedules } = useQuery<ScheduleOption[]>({
queryKey: ['report-schedules'],
queryFn: () => apiClient.get('/reports/schedules'),
});
const schedules = schedulesRaw ?? [];
const { data, isLoading, isError } = useQuery<PassengersReport>({
queryKey: ['passengers-report', scheduleId],
queryFn: () => apiClient.get(`/reports/passengers?scheduleId=${scheduleId}`),
enabled: !!scheduleId,
});
const { data: passengerList = [], isLoading: listLoading } = useQuery<PassengerRow[]>({
queryKey: ['passengers-list', scheduleId],
queryFn: () => apiClient.get(`/reports/passengers/list?scheduleId=${scheduleId}`),
enabled: !!scheduleId,
});
const filteredList = listSearch.trim()
? passengerList.filter(p =>
p.passengerName.toLowerCase().includes(listSearch.toLowerCase()) ||
p.bookingRef.toLowerCase().includes(listSearch.toLowerCase()),
)
: passengerList;
const downloadCsv = (csv: string, filename: string) => {
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename; a.click();
URL.revokeObjectURL(url);
};
const doExportOccupancy = () => {
if (!data) return;
const rows = data.byCoach.map(c => [c.coachNumber, c.coachType, String(c.totalSeats), String(c.booked), `${c.occupancyRate}%`]);
downloadCsv([['Coach', 'Type', 'Total Seats', 'Booked', 'Occupancy'].join(','), ...rows.map(r => r.join(','))].join('\n'), `occupancy-${scheduleId}.csv`);
};
const doExportList = () => {
if (!passengerList.length) return;
const headers = ['#', 'Name', 'Coach·Seat', 'Origin', 'Destination', 'Date', 'Booking Ref'];
const rows = passengerList.map((p, i) => [
String(i + 1), p.passengerName, p.coachSeat, p.origin, p.destination,
p.departureAt ? formatDateTime(p.departureAt) : '—', p.bookingRef,
].map(v => `"${String(v).replace(/"/g, '""')}"`));
downloadCsv([headers.join(','), ...rows.map(r => r.join(','))].join('\n'), `passengers-${scheduleId}.csv`);
};
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-foreground">Passengers Report</h1>
<p className="text-muted-foreground mt-1">Occupancy and passenger breakdown for a schedule</p>
</div>
{/* Schedule selector */}
<div className="card">
<div className="flex items-end gap-4 flex-wrap">
<div className="flex-1 min-w-72">
<label className="label">Schedule</label>
<select
className="input"
value={scheduleId}
onChange={e => { setScheduleId(e.target.value); setTab('occupancy'); setListSearch(''); }}
disabled={loadingSchedules}
>
<option value="">{loadingSchedules ? 'Loading schedules…' : 'Select a schedule…'}</option>
{schedules.map(s => (
<option key={s.id} value={s.id}>{s.label}</option>
))}
</select>
</div>
{data && tab === 'occupancy' && (
<ActionButton icon={Download} variant="secondary" onClick={doExportOccupancy}>Export CSV</ActionButton>
)}
</div>
{(isLoading || listLoading) && <p className="text-xs text-muted-foreground mt-2">Loading</p>}
{isError && <p className="text-xs text-red-500 mt-2">Failed to load report.</p>}
</div>
{data && (
<>
{/* Schedule info */}
<div className="card">
<p className="text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-3">Schedule</p>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 text-sm">
<div><span className="text-muted-foreground">Train</span><p className="font-semibold">{data.schedule.trainName ?? '—'}</p></div>
<div><span className="text-muted-foreground">Route</span><p className="font-semibold">{data.schedule.origin} {data.schedule.destination}</p></div>
<div><span className="text-muted-foreground">Departure</span><p className="font-semibold">{formatDateTime(data.schedule.departureAt)}</p></div>
</div>
</div>
{/* Tabs */}
<div className="border-b border-border flex">
<button
onClick={() => setTab('occupancy')}
className={`px-5 py-2.5 text-sm font-medium border-b-2 transition-colors ${tab === 'occupancy' ? 'border-emerald-500 text-emerald-600 dark:text-emerald-400' : 'border-transparent text-muted-foreground hover:text-foreground'}`}
>
Occupancy
</button>
<button
onClick={() => setTab('list')}
className={`px-5 py-2.5 text-sm font-medium border-b-2 transition-colors ${tab === 'list' ? 'border-emerald-500 text-emerald-600 dark:text-emerald-400' : 'border-transparent text-muted-foreground hover:text-foreground'}`}
>
Passenger List{passengerList.length > 0 ? ` (${passengerList.length})` : ''}
</button>
</div>
{/* Occupancy tab */}
{tab === 'occupancy' && (
<div className="space-y-6">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="card flex flex-col gap-1">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Total Seats</p>
<div className="rounded-lg bg-blue-100 dark:bg-blue-900/30 p-1.5"><Armchair className="h-4 w-4 text-blue-600 dark:text-blue-400" /></div>
</div>
<p className="text-2xl font-bold tabular-nums mt-1">{data.summary.totalSeats}</p>
</div>
<div className="card flex flex-col gap-1">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Passengers</p>
<div className="rounded-lg bg-emerald-100 dark:bg-emerald-900/30 p-1.5"><Users className="h-4 w-4 text-emerald-600 dark:text-emerald-400" /></div>
</div>
<p className="text-2xl font-bold tabular-nums mt-1">{data.summary.totalPassengers}</p>
</div>
<div className="card flex flex-col gap-1">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Occupancy Rate</p>
<div className="rounded-lg bg-purple-100 dark:bg-purple-900/30 p-1.5"><BarChart3 className="h-4 w-4 text-purple-600 dark:text-purple-400" /></div>
</div>
<p className="text-2xl font-bold tabular-nums mt-1">{data.summary.occupancyRate}%</p>
<div className="w-full bg-muted rounded-full h-1.5 mt-1">
<div className="bg-purple-500 h-1.5 rounded-full" style={{ width: `${data.summary.occupancyRate}%` }} />
</div>
</div>
</div>
<div className="card">
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-4">By Coach</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left text-xs text-muted-foreground uppercase tracking-wider">
<th className="pb-2 pr-4">Coach</th>
<th className="pb-2 pr-4">Type</th>
<th className="pb-2 pr-4 text-right">Seats</th>
<th className="pb-2 pr-4 text-right">Booked</th>
<th className="pb-2">Occupancy</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{data.byCoach.map(c => (
<tr key={c.coachNumber} className="hover:bg-muted/30">
<td className="py-2 pr-4 font-semibold">{c.coachNumber}</td>
<td className="py-2 pr-4 text-muted-foreground">{c.coachType}</td>
<td className="py-2 pr-4 text-right tabular-nums">{c.totalSeats}</td>
<td className="py-2 pr-4 text-right tabular-nums">{c.booked}</td>
<td className="py-2">
<div className="flex items-center gap-2">
<div className="flex-1 bg-muted rounded-full h-1.5">
<div className="bg-emerald-500 h-1.5 rounded-full" style={{ width: `${c.occupancyRate}%` }} />
</div>
<span className="tabular-nums text-xs w-10 text-right">{c.occupancyRate}%</span>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="card">
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-4">By Class</h3>
<div className="space-y-3">
{data.byClass.map(c => (
<div key={c.className}>
<div className="flex justify-between text-sm mb-1">
<span className="font-medium">{c.className}</span>
<span className="tabular-nums text-muted-foreground">{c.booked}/{c.totalSeats}</span>
</div>
<div className="flex items-center gap-2">
<div className="flex-1 bg-muted rounded-full h-1.5">
<div className="bg-blue-500 h-1.5 rounded-full" style={{ width: `${c.occupancyRate}%` }} />
</div>
<span className="text-xs tabular-nums w-10 text-right">{c.occupancyRate}%</span>
</div>
</div>
))}
</div>
</div>
<div className="card">
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-4">By Boarding Station</h3>
<div className="space-y-2">
{data.byOrigin.map(o => (
<div key={o.stationName} className="flex justify-between text-sm">
<span className="text-muted-foreground truncate">{o.stationName}</span>
<span className="font-semibold tabular-nums ml-2">{o.passengers}</span>
</div>
))}
{data.byOrigin.length === 0 && <p className="text-xs text-muted-foreground">No data</p>}
</div>
</div>
<div className="card">
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-4">By Alighting Station</h3>
<div className="space-y-2">
{data.byDestination.map(d => (
<div key={d.stationName} className="flex justify-between text-sm">
<span className="text-muted-foreground truncate">{d.stationName}</span>
<span className="font-semibold tabular-nums ml-2">{d.passengers}</span>
</div>
))}
{data.byDestination.length === 0 && <p className="text-xs text-muted-foreground">No data</p>}
</div>
</div>
</div>
</div>
)}
{tab === 'list' && (
<div className="space-y-4">
<div className="flex items-center gap-3 flex-wrap">
<input
type="text"
className="input max-w-sm flex-1"
placeholder="Search by name or booking ref…"
value={listSearch}
onChange={e => setListSearch(e.target.value)}
/>
{passengerList.length > 0 && (
<ActionButton icon={Download} variant="secondary" onClick={doExportList}>Export CSV</ActionButton>
)}
</div>
<div className="card p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left text-xs text-muted-foreground uppercase tracking-wider">
<th className="px-4 py-3">#</th>
<th className="px-4 py-3">Name</th>
<th className="px-4 py-3">Coach · Seat</th>
<th className="px-4 py-3">Origin</th>
<th className="px-4 py-3">Destination</th>
<th className="px-4 py-3">Date</th>
<th className="px-4 py-3">Booking Ref</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{filteredList.map((p, i) => (
<tr key={`${p.bookingRef}-${i}`} className="hover:bg-muted/30">
<td className="px-4 py-3 text-muted-foreground tabular-nums">{i + 1}</td>
<td className="px-4 py-3 font-medium">{p.passengerName}</td>
<td className="px-4 py-3 font-mono text-xs">{p.coachSeat}</td>
<td className="px-4 py-3 text-muted-foreground">{p.origin}</td>
<td className="px-4 py-3 text-muted-foreground">{p.destination}</td>
<td className="px-4 py-3 text-xs text-muted-foreground whitespace-nowrap">{p.departureAt ? formatDateTime(p.departureAt) : '—'}</td>
<td className="px-4 py-3 font-mono text-xs">{p.bookingRef}</td>
</tr>
))}
{filteredList.length === 0 && (
<tr><td colSpan={7} className="py-8 text-center text-sm text-muted-foreground">No passengers found</td></tr>
)}
</tbody>
</table>
</div>
</div>
</div>
)}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,3 @@
export default function Layout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}

View File

@@ -0,0 +1,342 @@
'use client';
import { useState, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Download, Armchair, CheckCircle, Clock, AlertCircle, Ban } from 'lucide-react';
import { bookingsApi, seatsApi } from '@/lib/api';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import { formatDateTime, formatCurrency } from '@/lib/utils';
interface SeatRow {
bookingRef: string;
passengerName: string;
seatNumber: string;
coachNumber: string;
fareMinor: number;
currency: string;
paymentStatus: string;
bookingStatus: string;
bookedAt: string;
releaseAt: string | null;
scheduleOrigin: string;
scheduleDestination: string;
scheduleDeparture: string;
}
const HOLD_DURATION_MS = 5 * 60 * 1000;
function getReleaseAt(booking: any, seat: any): string | null {
const paymentStatus = booking.paymentIntent?.status || 'PENDING';
if (paymentStatus === 'SUCCEEDED' || paymentStatus === 'COMPLETED') return null;
if (booking.status === 'CONFIRMED') return null;
if (seat?.holdExpiresAt) return seat.holdExpiresAt;
if (booking.createdAt) {
return new Date(new Date(booking.createdAt).getTime() + HOLD_DURATION_MS).toISOString();
}
return null;
}
function isExpired(releaseAt: string | null): boolean {
if (!releaseAt) return false;
return new Date(releaseAt) < new Date();
}
export default function SeatStatusReportPage() {
const [statusFilter, setStatusFilter] = useState<'ALL' | 'PAID' | 'UNPAID'>('ALL');
const [search, setSearch] = useState('');
const { data: blockedSeats = [] } = useQuery({
queryKey: ['blocked-seats'],
queryFn: () => seatsApi.getBlocked().then((r: any) => Array.isArray(r) ? r : r?.data ?? []),
});
const { data: bookingsData, isLoading } = useQuery({
queryKey: ['seat-report-bookings'],
queryFn: () => bookingsApi.getAll({ pageSize: 1000 }),
});
const rows: SeatRow[] = useMemo(() => {
const bookings: any[] = bookingsData?.items || [];
const result: SeatRow[] = [];
for (const booking of bookings) {
if (booking.status === 'CANCELLED') continue;
const seats: any[] = booking.seats || [];
const paymentStatus = booking.paymentIntent?.status || 'PENDING';
for (const seat of seats) {
result.push({
bookingRef: booking.bookingRef || '—',
passengerName: seat.passengerName || seat.name || booking.passengerNames?.[0] || '—',
seatNumber: seat.seat?.seatNumber || seat.seatNumber || '—',
coachNumber: seat.seat?.coach?.number || seat.coach || '—',
fareMinor: seat.fareMinor ?? 0,
currency: booking.currency || 'ETB',
paymentStatus,
bookingStatus: booking.status,
bookedAt: booking.createdAt,
releaseAt: getReleaseAt(booking, seat),
scheduleOrigin: booking.schedule?.originStation?.name || '—',
scheduleDestination: booking.schedule?.destinationStation?.name || '—',
scheduleDeparture: booking.schedule?.departureAt || '',
});
}
}
return result;
}, [bookingsData]);
const filtered = useMemo(() => {
return rows.filter((r) => {
const isPaid = r.paymentStatus === 'SUCCEEDED' || r.paymentStatus === 'COMPLETED';
if (statusFilter === 'PAID' && !isPaid) return false;
if (statusFilter === 'UNPAID' && isPaid) return false;
if (search) {
const q = search.toLowerCase();
return (
r.bookingRef.toLowerCase().includes(q) ||
r.passengerName.toLowerCase().includes(q) ||
r.seatNumber.toLowerCase().includes(q) ||
r.coachNumber.toLowerCase().includes(q)
);
}
return true;
});
}, [rows, statusFilter, search]);
const paidCount = rows.filter(
(r) => r.paymentStatus === 'SUCCEEDED' || r.paymentStatus === 'COMPLETED'
).length;
const unpaidCount = rows.length - paidCount;
const expiredCount = rows.filter((r) => isExpired(r.releaseAt)).length;
const doExport = () => {
if (!filtered.length) { alert('No data to export'); return; }
const headers = [
'Booking Ref', 'Passenger', 'Seat', 'Coach', 'Fare',
'Payment Status', 'Booking Status', 'Booked At', 'Release At',
'Origin', 'Destination', 'Departure',
];
const csvRows = filtered.map((r) => [
r.bookingRef,
r.passengerName,
r.seatNumber,
r.coachNumber,
formatCurrency(r.fareMinor, r.currency),
r.paymentStatus,
r.bookingStatus,
r.bookedAt ? formatDateTime(r.bookedAt) : '—',
r.releaseAt ? formatDateTime(r.releaseAt) : '—',
r.scheduleOrigin,
r.scheduleDestination,
r.scheduleDeparture ? formatDateTime(r.scheduleDeparture) : '—',
]);
const csv = [
headers.map((h) => `"${h}"`).join(','),
...csvRows.map((row) => row.map((v) => `"${v}"`).join(',')),
].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `seat-status-report-${new Date().toISOString().split('T')[0]}.csv`;
a.click();
URL.revokeObjectURL(url);
};
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-foreground">Seat Status Report</h1>
<p className="text-muted-foreground mt-1">
Track booked seats paid vs unpaid, booking times, and hold release times
</p>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="card">
<div className="flex items-start justify-between">
<div>
<p className="text-muted-foreground text-sm font-medium">Paid Seats</p>
<p className="text-2xl font-bold mt-2 text-green-600 dark:text-green-400">
{paidCount}
</p>
<p className="text-xs text-muted-foreground mt-1">Payment confirmed</p>
</div>
<CheckCircle className="h-8 w-8 text-green-500 opacity-30" />
</div>
</div>
<div className="card">
<div className="flex items-start justify-between">
<div>
<p className="text-muted-foreground text-sm font-medium">Unpaid Seats</p>
<p className="text-2xl font-bold mt-2 text-amber-600 dark:text-amber-400">
{unpaidCount}
</p>
<p className="text-xs text-muted-foreground mt-1">Awaiting payment</p>
</div>
<Clock className="h-8 w-8 text-amber-500 opacity-30" />
</div>
</div>
<div className="card">
<div className="flex items-start justify-between">
<div>
<p className="text-muted-foreground text-sm font-medium">Expired Holds</p>
<p className="text-2xl font-bold mt-2 text-red-600 dark:text-red-400">
{expiredCount}
</p>
<p className="text-xs text-muted-foreground mt-1">Hold time passed, not paid</p>
</div>
<AlertCircle className="h-8 w-8 text-red-500 opacity-30" />
</div>
</div>
<div className="card">
<div className="flex items-start justify-between">
<div>
<p className="text-muted-foreground text-sm font-medium">Blocked Seats</p>
<p className="text-2xl font-bold mt-2 text-slate-600 dark:text-slate-400">
{blockedSeats.length}
</p>
<p className="text-xs text-muted-foreground mt-1">Manually blocked</p>
</div>
<Ban className="h-8 w-8 text-slate-500 opacity-30" />
</div>
</div>
</div>
{/* Filters */}
<div className="card">
<div className="flex flex-wrap items-end gap-4">
<div className="flex-1 min-w-48">
<label className="label">Search</label>
<input
type="text"
className="input"
placeholder="Booking ref, passenger, seat, coach..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div>
<label className="label">Payment Status</label>
<select
className="input"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as 'ALL' | 'PAID' | 'UNPAID')}
>
<option value="ALL">All Seats</option>
<option value="PAID">Paid Only</option>
<option value="UNPAID">Unpaid Only</option>
</select>
</div>
<ActionButton icon={Download} variant="secondary" onClick={doExport} disabled={isLoading}>
Export CSV
</ActionButton>
</div>
{isLoading && <p className="text-xs text-muted-foreground mt-2">Loading...</p>}
</div>
{/* Table */}
<div className="card p-0">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-800">
<tr>
{[
'Booking Ref',
'Passenger',
'Seat / Coach',
'Fare',
'Payment',
'Booked At',
'Release At',
'Route',
].map((h) => (
<th
key={h}
className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400"
>
{h}
</th>
))}
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
{filtered.map((row, i) => {
const isPaid =
row.paymentStatus === 'SUCCEEDED' || row.paymentStatus === 'COMPLETED';
const expired = isExpired(row.releaseAt);
return (
<tr
key={i}
className="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
<td className="px-4 py-3 text-sm font-mono font-semibold whitespace-nowrap">
{row.bookingRef}
</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">{row.passengerName}</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">
<span className="font-semibold">{row.seatNumber}</span>
{row.coachNumber !== '—' && (
<span className="text-muted-foreground"> · Coach {row.coachNumber}</span>
)}
</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">
{formatCurrency(row.fareMinor, row.currency)}
</td>
<td className="px-4 py-3 whitespace-nowrap">
<Badge variant="status" status={isPaid ? 'PAID' : row.paymentStatus}>
{isPaid ? 'PAID' : row.paymentStatus}
</Badge>
</td>
<td className="px-4 py-3 text-sm whitespace-nowrap text-muted-foreground">
{row.bookedAt ? formatDateTime(row.bookedAt) : '—'}
</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">
{isPaid ? (
<span className="text-green-600 dark:text-green-400 text-xs font-medium">
Paid
</span>
) : row.releaseAt ? (
<span
className={
expired
? 'text-red-600 dark:text-red-400 text-xs font-semibold'
: 'text-amber-600 dark:text-amber-400 text-xs font-medium'
}
>
{expired ? '⚠ ' : '⏱ '}
{formatDateTime(row.releaseAt)}
{expired && ' (expired)'}
</span>
) : (
<span className="text-muted-foreground text-xs"></span>
)}
</td>
<td className="px-4 py-3 text-sm whitespace-nowrap text-muted-foreground">
{row.scheduleOrigin} {row.scheduleDestination}
{row.scheduleDeparture && (
<div className="text-xs">{formatDateTime(row.scheduleDeparture)}</div>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{!isLoading && filtered.length === 0 && (
<div className="py-12 text-center text-muted-foreground">
<Armchair className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p>No seats found</p>
</div>
)}
</div>
</div>
);
}

View File

@@ -87,7 +87,8 @@ export default function SeatsPage() {
};
const blockMutation = useMutation({
mutationFn: ({ seatId, reason }: any) => seatsApi.block(seatId, { reason }),
mutationFn: ({ seatId, reason }: any) =>
seatsApi.block(seatId, { reason, ...(activeTab === 'schedule' && selectedSchedule ? { scheduleId: selectedSchedule } : {}) }),
onSuccess: () => {
invalidateSeatData();
setShowBlockModal(false);
@@ -97,7 +98,8 @@ export default function SeatsPage() {
});
const unblockMutation = useMutation({
mutationFn: (seatId: string) => seatsApi.unblock(seatId),
mutationFn: (seatId: string) =>
seatsApi.unblock(seatId, activeTab === 'schedule' ? selectedSchedule : undefined),
onSuccess: () => {
invalidateSeatData();
},
@@ -143,7 +145,8 @@ export default function SeatsPage() {
mutationFn: async ({ coachId, reason }: any) => {
const coachSeats = coaches.find((c: any) => c.id === coachId)?.seats || [];
const seatIds = coachSeats.map((s: any) => s.id).filter((id: any) => id);
return Promise.all(seatIds.map((seatId: string) => seatsApi.block(seatId, { reason })));
const scheduleId = activeTab === 'schedule' ? selectedSchedule : undefined;
return Promise.all(seatIds.map((seatId: string) => seatsApi.block(seatId, { reason, ...(scheduleId ? { scheduleId } : {}) })));
},
onSuccess: () => {
invalidateSeatData();
@@ -157,7 +160,8 @@ export default function SeatsPage() {
mutationFn: async ({ coachId }: any) => {
const coachSeats = coaches.find((c: any) => c.id === coachId)?.seats || [];
const seatIds = coachSeats.map((s: any) => s.id).filter((id: any) => id);
return Promise.all(seatIds.map((seatId: string) => seatsApi.unblock(seatId)));
const scheduleId = activeTab === 'schedule' ? selectedSchedule : undefined;
return Promise.all(seatIds.map((seatId: string) => seatsApi.unblock(seatId, scheduleId)));
},
onSuccess: () => {
invalidateSeatData();

View File

@@ -100,7 +100,7 @@ export default function BaggageTab({ allClasses, isOpen, onClose }: Props) {
<ConfirmDialog
isOpen={deleteConfirm.isOpen}
onClose={() => setDeleteConfirm({ isOpen: false, id: null })}
onConfirm={() => remove.mutate(deleteConfirm.id!)}
onConfirm={() => remove.mutate(deleteConfirm.id!, { onSuccess: () => setDeleteConfirm({ isOpen: false, id: null }) })}
title="Delete Allowance Rule"
message="Are you sure you want to delete this baggage allowance rule?"
confirmText="Delete"

View File

@@ -119,7 +119,9 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
{
title: 'Analytics & Reports',
items: [
{ name: 'Reports', href: '/reports', icon: BarChart3, permission: PERMS.reports.view },
{ name: 'Overall', href: '/reports', icon: BarChart3, permission: PERMS.reports.view },
{ name: 'Seats', href: '/reports/seats', icon: Armchair, permission: PERMS.reports.view },
{ name: 'Passengers', href: '/reports/passengers', icon: Users, permission: PERMS.reports.view },
// { name: 'Operational Reports', href: '/operational-reports', icon: FileText, permission: PERMS.reports.view },
]
},

View File

@@ -151,10 +151,11 @@ export const seatsApi = {
return apiClient.get<any>(`/seats/seatmap/${scheduleId}${params}`);
},
getBySchedule: (scheduleId: string) => apiClient.get<any>(`/seats/schedule/${scheduleId}`),
getBlocked: () => apiClient.get<any[]>('/seats/blocks'),
hold: (data: any) => apiClient.post<any>('/seats/hold', data),
release: (holdId: string) => apiClient.delete(`/seats/hold/${holdId}`),
block: (seatId: string, data: any) => apiClient.post<any>(`/seats/${seatId}/block`, data),
unblock: (seatId: string) => apiClient.delete(`/seats/${seatId}/block`),
unblock: (seatId: string, scheduleId?: string) => apiClient.delete(`/seats/${seatId}/block${scheduleId ? `?scheduleId=${scheduleId}` : ''}`),
removeSeat: (seatId: string) => apiClient.patch<any>(`/seats/${seatId}/remove`, {}),
undoRemove: (seatId: string) => apiClient.patch<any>(`/seats/${seatId}/undo-remove`, {}),
setMaintenance: (seatId: string, reason: string) => apiClient.post<any>(`/seats/${seatId}/maintenance`, { reason }),
@@ -184,6 +185,25 @@ export const paymentsApi = {
addMethod: (data: any) => apiClient.post('/payments/methods', data),
updateMethod: (id: string, data: any) => apiClient.patch(`/payments/methods/${id}`, data),
deleteMethod: (id: string) => apiClient.delete(`/payments/methods/${id}`),
supplementary: {
create: (data: { bookingRef: string; amountMinor: number; reason: string; notes?: string }) =>
apiClient.post<any>('/payments/supplementary', data),
getAll: async (params?: any) => {
const cleanParams = Object.fromEntries(
Object.entries(params || {}).filter(([, v]) => v !== '' && v !== undefined && v !== null)
) as Record<string, string>;
const query = new URLSearchParams(cleanParams).toString();
const response = await apiClient.get<any>(`/payments/supplementary${query ? `?${query}` : ''}`);
if ((response as any)?.data) return (response as any).data;
return response;
},
markPaid: (id: string, providerTxnId?: string) =>
apiClient.post<any>(`/payments/supplementary/${id}/mark-paid`, { providerTxnId }),
waive: (id: string, notes?: string) =>
apiClient.post<any>(`/payments/supplementary/${id}/waive`, { notes }),
resend: (id: string) =>
apiClient.post<any>(`/payments/supplementary/${id}/resend`, {}),
},
};
// Tickets API

View File

@@ -7,7 +7,7 @@ import { useBookingStore } from "@/lib/booking-store";
import { usePaymentStore } from "@/lib/payment-store";
import { useQuery } from "@tanstack/react-query";
import { apiClient } from "@/lib/api-client";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { CheckCircle, Clock, Copy, Train, FileText } from "lucide-react";
import { format } from "date-fns";
import { isChild, isFirstChild } from "@/utils/fare-utils";
@@ -60,6 +60,27 @@ export default function ConfirmationPage() {
const [copied, setCopied] = useState(false);
const [isGeneratingVoucher, setIsGeneratingVoucher] = useState(false);
// Grace period after landing on this page: keep showing the generic "processing"
// spinner instead of the "payment pending" screen, and poll the payment intent
// frequently — booking.status and paymentIntent.status flip to CONFIRMED/SUCCEEDED
// together (see finalizePaymentSuccess in payments.service.ts), so a payment that
// already succeeded at the provider often just needs a few more seconds for its
// webhook to reach us. Once the grace period elapses, fall back to the normal
// pending screen with slower background polling.
const CONFIRMATION_GRACE_PERIOD_MS = 10_000;
const FAST_POLL_INTERVAL_MS = 2_500;
const SLOW_POLL_INTERVAL_MS = 10_000;
const mountTimeRef = useRef(Date.now());
const [withinGracePeriod, setWithinGracePeriod] = useState(true);
useEffect(() => {
const timer = setTimeout(
() => setWithinGracePeriod(false),
CONFIRMATION_GRACE_PERIOD_MS,
);
return () => clearTimeout(timer);
}, []);
// Warms the code-split voucher module ahead of the click so the handler's own
// `await import(...)` resolves near-instantly — on iOS Safari, a file save triggered
// too long after the originating click's synchronous execution window is silently
@@ -68,26 +89,54 @@ export default function ConfirmationPage() {
import("@/lib/generate-voucher");
}, []);
const { data: _booking } = useQuery<BookingWithTicket>({
const {
data: _booking,
refetch: refetchBooking,
isLoading: isBookingLoading,
isError: isBookingError,
} = useQuery<BookingWithTicket>({
queryKey: ["booking", bookingId],
queryFn: async (): Promise<BookingWithTicket> => {
try {
return await apiClient.get(`/bookings/${bookingId}`);
} catch (error) {
return {
id: bookingId || "",
pnr: pnr || undefined,
status: "PENDING_PAYMENT",
totalMinor: passengers.reduce(
(sum) => sum + (selectedSchedule?.baseFareAdult || 0),
0,
),
};
}
},
// Let a real fetch failure surface as a real error (React Query's global retry:1
// default then retries once automatically) instead of silently returning a
// fabricated "PENDING_PAYMENT" object — that used to mask genuine failures (a
// transient blip right after a cross-domain redirect from the payment gateway is
// common) as normal pending state forever, since a caught error that returns data
// looks like a success to React Query and never gets retried.
queryFn: (): Promise<BookingWithTicket> => apiClient.get(`/bookings/${bookingId}`),
// Payment status must never be served from a stale cache — the app-wide default
// (providers.tsx) is a 60s staleTime, which would otherwise block React Query's
// own refetch-on-window-focus from firing (it only refetches stale data). Without
// this override, a tab left open past a payment completing can sit showing
// "pending" long after it's actually confirmed, even after being refocused,
// until the interval below happens to tick — which browsers throttle heavily in
// backgrounded tabs, so that can take a very long time.
staleTime: 0,
enabled: !!bookingId,
});
// Poll the payment intent while the booking is PENDING_PAYMENT — fast during the
// grace period (catches a webhook that's just a few seconds behind), then slower
// in the background afterward. The backend auto-confirms (and generates tickets)
// when the payment-api reports SUCCEEDED, so detecting that here means the
// booking is now CONFIRMED — refetch to update the UI without requiring the user
// to refresh.
const { data: intentStatus } = useQuery<any>({
queryKey: ["payment-intent-status", bookingId],
queryFn: () => apiClient.get(`/payments/intents/${bookingId}`),
enabled: _booking?.status === "PENDING_PAYMENT" && !!bookingId,
staleTime: 0,
refetchInterval: () =>
Date.now() - mountTimeRef.current < CONFIRMATION_GRACE_PERIOD_MS
? FAST_POLL_INTERVAL_MS
: SLOW_POLL_INTERVAL_MS,
});
useEffect(() => {
if (intentStatus?.status === "SUCCEEDED") {
refetchBooking();
}
}, [intentStatus?.status]);
// Only trust an actually-confirmed booking to show ticket numbers / a "CONFIRMED" badge —
// a gateway redirect back here does not mean payment succeeded (see payment return pages).
// Ticket generation itself is never triggered from this page — the payment webhook
@@ -254,6 +303,53 @@ export default function ConfirmationPage() {
if (!bookingId || !pnr) return null;
// Wait for the actual booking status before deciding pending vs. confirmed —
// without this, _booking is briefly undefined on first load, isConfirmed reads
// as false, and the page flashes "payment pending" before flipping to
// "confirmed" once the fetch resolves (common, since the payment webhook has
// often already completed by the time the user lands here). Also keep showing
// this same spinner through the grace period above if the booking is still
// PENDING_PAYMENT — most of the time the webhook lands within that window, so
// the user goes straight to "confirmed" without ever seeing "pending" at all.
if (isBookingLoading || (withinGracePeriod && _booking?.status === "PENDING_PAYMENT")) {
return (
<div className="booking-page">
<div className="container mx-auto px-4">
<div className="max-w-6xl mx-auto flex flex-col items-center justify-center py-24 text-center">
<div className="w-12 h-12 rounded-full border-4 border-primary/20 border-t-primary animate-spin mb-4" />
<p className="text-gray-600 dark:text-gray-400">
Please wait, we are processing your booking
</p>
</div>
</div>
</div>
);
}
// A real fetch failure (not just "still pending") — surface it honestly instead of
// silently pretending the booking is pending, and let the user retry the check
// without needing a full page refresh.
if (isBookingError) {
return (
<div className="booking-page">
<div className="container mx-auto px-4">
<div className="max-w-6xl mx-auto flex flex-col items-center justify-center py-24 text-center">
<p className="text-gray-600 dark:text-gray-400 mb-4">
We&apos;re having trouble loading your booking status right
now. This is usually temporary tap below to try again.
</p>
<button
onClick={() => refetchBooking()}
className="btn-primary"
>
Check again
</button>
</div>
</div>
</div>
);
}
return (
<div className="booking-page">
<div className="container mx-auto px-4">

View File

@@ -125,6 +125,22 @@ function BookingDetailContent() {
booking?.status === "PENDING_PAYMENT" || booking?.status === "DRAFT",
});
// When the booking is PENDING_PAYMENT, poll the payment intent endpoint every 10 s.
// The backend auto-confirms the booking when it finds a SUCCEEDED intent, so detecting
// SUCCEEDED here means the booking is now CONFIRMED — refetch to update the UI.
const { data: intentStatus } = useQuery<any>({
queryKey: ["payment-intent-status", booking?.id],
queryFn: () => apiClient.get(`/payments/intents/${booking!.id}`),
enabled: booking?.status === "PENDING_PAYMENT" && !!booking?.id,
refetchInterval: 10_000,
});
useEffect(() => {
if (intentStatus?.status === "SUCCEEDED") {
refetch();
}
}, [intentStatus?.status]);
const selectedPaymentMethod =
(paymentMethods || []).find((m: any) => m.type === selectedMethod) || null;

View File

@@ -55,13 +55,20 @@ function DmoneySuccessContent() {
// Manage Booking sessions don't carry a bookingId in the client store — the detail page
// it lands on re-fetches the booking's real status itself, so there's nothing to verify
// client-side here; just hand off without claiming an outcome we can't confirm.
if (!bookingId) {
if (!cancelled) {
router.push(target);
}
if (manageBookingRef) {
router.push(target);
return;
}
// Normal booking flow: bookingId comes from a Zustand store persisted to localStorage.
// This page is always reached via a real cross-domain redirect from the payment gateway
// (a full page load, not an in-app navigation), so that store has to rehydrate from
// localStorage asynchronously — bookingId can read as empty on the first render or two.
// Wait for it instead of treating an empty first-render value as "nothing to verify",
// which would silently skip this page's whole verification step and hand off to
// /booking/confirmation without ever having checked payment status here.
if (!bookingId) return;
verifyBookingPaid(bookingId).then((result) => {
if (cancelled) return;
if (result === 'SUCCEEDED') {
@@ -82,8 +89,7 @@ function DmoneySuccessContent() {
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [bookingId, router, updateStatus]);
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center px-4">

View File

@@ -55,13 +55,20 @@ function TelebirrSuccessContent() {
// Manage Booking sessions don't carry a bookingId in the client store — the detail page
// it lands on re-fetches the booking's real status itself, so there's nothing to verify
// client-side here; just hand off without claiming an outcome we can't confirm.
if (!bookingId) {
if (!cancelled) {
router.push(target);
}
if (manageBookingRef) {
router.push(target);
return;
}
// Normal booking flow: bookingId comes from a Zustand store persisted to localStorage.
// This page is always reached via a real cross-domain redirect from the payment gateway
// (a full page load, not an in-app navigation), so that store has to rehydrate from
// localStorage asynchronously — bookingId can read as empty on the first render or two.
// Wait for it instead of treating an empty first-render value as "nothing to verify",
// which would silently skip this page's whole verification step and hand off to
// /booking/confirmation without ever having checked payment status here.
if (!bookingId) return;
verifyBookingPaid(bookingId).then((result) => {
if (cancelled) return;
if (result === 'SUCCEEDED') {
@@ -82,8 +89,7 @@ function TelebirrSuccessContent() {
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [bookingId, router, updateStatus]);
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center px-4">

View File

@@ -55,13 +55,20 @@ function WaafiSuccessContent() {
// Manage Booking sessions don't carry a bookingId in the client store — the detail page
// it lands on re-fetches the booking's real status itself, so there's nothing to verify
// client-side here; just hand off without claiming an outcome we can't confirm.
if (!bookingId) {
if (!cancelled) {
router.push(target);
}
if (manageBookingRef) {
router.push(target);
return;
}
// Normal booking flow: bookingId comes from a Zustand store persisted to localStorage.
// This page is always reached via a real cross-domain redirect from the payment gateway
// (a full page load, not an in-app navigation), so that store has to rehydrate from
// localStorage asynchronously — bookingId can read as empty on the first render or two.
// Wait for it instead of treating an empty first-render value as "nothing to verify",
// which would silently skip this page's whole verification step and hand off to
// /booking/confirmation without ever having checked payment status here.
if (!bookingId) return;
verifyBookingPaid(bookingId).then((result) => {
if (cancelled) return;
if (result === 'SUCCEEDED') {
@@ -82,8 +89,7 @@ function WaafiSuccessContent() {
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [bookingId, router, updateStatus]);
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center px-4">

View File

@@ -24,6 +24,7 @@ import { format } from "date-fns";
import { formatTime, getTimePeriod, toZonedDate } from "@/utils/format";
import { formatFare } from "@/utils/fare-utils";
import { useState, useEffect } from "react";
import AlternativeDatesCalendar from "@/components/AlternativeDatesCalendar";
// Shared by the compact schedule card's coach-type badges and the "Choose Your Coach"
// modal, so both pick the same icon for a given coach type name.
@@ -47,6 +48,11 @@ export default function ResultsPage() {
);
const [effectiveDepartureDate, setEffectiveDepartureDate] = useState<string>('');
const [effectiveReturnDate, setEffectiveReturnDate] = useState<string>('');
// Round-trip "both legs empty" dual-calendar: holds picks until both outbound
// and return dates are chosen, then a single search fires for the pair — see
// the effect below, right after searchData/pushResultsWithDates are defined.
const [pendingOutboundDate, setPendingOutboundDate] = useState<Date | undefined>();
const [pendingInboundDate, setPendingInboundDate] = useState<Date | undefined>();
const [classModal, setClassModal] = useState<Schedule | null>(null);
const [promoData, setPromoData] = useState<{
code: string;
@@ -96,11 +102,14 @@ export default function ResultsPage() {
promoCode: searchParams.get("promoCode") || searchCriteria?.promoCode || "",
};
// Initialise effective dates from URL/store once searchData is stable
// Keep the displayed effective dates in sync with the URL-driven search
// criteria — not just on first load, but every time searchData.date/returnDate
// actually changes (e.g. picking a new date on the alternative-dates calendar
// re-runs the search with a different date; the heading must follow it, not
// stay frozen on whatever was first requested).
useEffect(() => {
if (searchData.date && !effectiveDepartureDate) setEffectiveDepartureDate(searchData.date);
if (searchData.returnDate && !effectiveReturnDate) setEffectiveReturnDate(searchData.returnDate);
// eslint-disable-next-line react-hooks/exhaustive-deps
if (searchData.date) setEffectiveDepartureDate(searchData.date);
if (searchData.returnDate) setEffectiveReturnDate(searchData.returnDate);
}, [searchData.date, searchData.returnDate]);
const nat = (searchData.nationality ?? '').toUpperCase();
@@ -161,6 +170,46 @@ export default function ResultsPage() {
return `/booking/search?${params}`;
};
// Pushes a new date onto the CURRENT results route (not back to the search form,
// unlike buildSearchUrl) — the query's queryKey is derived from these URL params
// (see searchData/useQuery below), so this alone re-triggers a search with the
// new date(s) while preserving route/passengers/nationality/promo unchanged.
const pushResultsWithDates = (overrides: { date?: string; returnDate?: string }) => {
const params = new URLSearchParams({
tripType: searchData.journeyType,
origin: searchData.originStationId,
destination: searchData.destinationStationId,
date: overrides.date ?? searchData.date,
adults: searchData.adultCount.toString(),
children: searchData.childCount.toString(),
nationality: searchData.nationality,
...(searchData.promoCode && { promoCode: searchData.promoCode }),
});
const returnDate = overrides.returnDate ?? searchData.returnDate;
if (returnDate) params.set("returnDate", returnDate);
router.push(`/booking/results?${params}`);
};
// Round-trip dual calendar: fire the search only once both legs have a pick —
// picking outbound alone must not trigger a search on its own.
useEffect(() => {
if (pendingOutboundDate && pendingInboundDate) {
pushResultsWithDates({
date: format(pendingOutboundDate, "yyyy-MM-dd"),
returnDate: format(pendingInboundDate, "yyyy-MM-dd"),
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pendingOutboundDate, pendingInboundDate]);
// Clear stale picks once the URL-driven search criteria actually changes (i.e.
// after a real navigation completes), so a previous failed attempt's picks don't
// leak into the next "still no results" render.
useEffect(() => {
setPendingOutboundDate(undefined);
setPendingInboundDate(undefined);
}, [searchData.date, searchData.returnDate]);
const {
data: results,
isLoading,
@@ -1064,6 +1113,90 @@ export default function ResultsPage() {
);
}
// Round trip, both legs empty, but at least one leg has alternative dates to
// offer — show both date pickers together instead of forcing the user through
// the outbound-then-return step wizard for a case we already know is doubly empty.
const isRoundTripBothLegsEmpty =
isRoundTrip && outboundSchedules.length === 0 && inboundSchedules.length === 0;
if (isRoundTripBothLegsEmpty) {
const bothCalendarsAvailable = alternativeOutbound.length > 0 && alternativeInbound.length > 0;
const outboundValue = pendingOutboundDate ?? (searchData.date ? new Date(`${searchData.date}T00:00:00`) : undefined);
const inboundValue = pendingInboundDate ?? (searchData.returnDate ? new Date(`${searchData.returnDate}T00:00:00`) : undefined);
return (
<div className="booking-page">
<div className="container mx-auto px-4">
<div className="max-w-6xl mx-auto">
<div className="card max-w-lg mx-auto text-center py-10 px-6">
<div className="w-16 h-16 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center mx-auto mb-5">
<Calendar className="w-8 h-8 text-red-500 dark:text-red-400" />
</div>
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-2">
No trains available
</h2>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
No trains available on your selected dates. Please choose another
date below.
</p>
<div className="space-y-4 text-left">
{alternativeOutbound.length > 0 ? (
<AlternativeDatesCalendar
label="Outbound date"
alternatives={alternativeOutbound}
value={outboundValue}
minDate={new Date()}
onChange={(date) => {
if (!bothCalendarsAvailable) {
pushResultsWithDates({ date: format(date, "yyyy-MM-dd") });
return;
}
setPendingOutboundDate(date);
if (pendingInboundDate && pendingInboundDate < date) setPendingInboundDate(undefined);
}}
/>
) : (
<p className="text-xs text-gray-500 dark:text-gray-400">
No alternative outbound dates found nearby.
</p>
)}
{alternativeInbound.length > 0 ? (
<AlternativeDatesCalendar
label="Return date"
alternatives={alternativeInbound}
value={inboundValue}
minDate={pendingOutboundDate ?? (searchData.date ? new Date(`${searchData.date}T00:00:00`) : new Date())}
onChange={(date) => {
if (!bothCalendarsAvailable) {
pushResultsWithDates({ returnDate: format(date, "yyyy-MM-dd") });
return;
}
setPendingInboundDate(date);
}}
/>
) : (
<p className="text-xs text-gray-500 dark:text-gray-400">
No alternative return dates found nearby.
</p>
)}
</div>
{bothCalendarsAvailable && pendingOutboundDate && !pendingInboundDate && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-4">
Now choose a return date to search.
</p>
)}
<button
onClick={() => router.push(buildSearchUrl())}
className="text-sm font-semibold text-primary hover:underline mt-6"
>
Modify search instead
</button>
</div>
</div>
</div>
</div>
);
}
if (isOneWayNoOutbound) {
return (
<div className="booking-page">
@@ -1078,42 +1211,26 @@ export default function ResultsPage() {
No trains available
</h2>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
There are no trains scheduled on{" "}
<span className="font-semibold text-gray-900 dark:text-gray-100">
{searchData.date ? format(new Date(`${searchData.date}T00:00:00`), "EEEE, MMMM d, yyyy") : "your selected date"}
</span>
. Try a different date to see available trains.
No trains available on your selected date. Please choose another
date below.
</p>
<button
onClick={() => router.push(buildSearchUrl())}
className="btn-primary inline-flex items-center gap-2"
>
<Calendar className="w-4 h-4" />
Change Date
</button>
{alternativeOutbound.length > 0 ? (
<AlternativeDatesCalendar
alternatives={alternativeOutbound}
value={searchData.date ? new Date(`${searchData.date}T00:00:00`) : undefined}
minDate={new Date()}
onChange={(date) => pushResultsWithDates({ date: format(date, "yyyy-MM-dd") })}
/>
) : (
<button
onClick={() => router.push(buildSearchUrl())}
className="btn-primary inline-flex items-center gap-2"
>
<Calendar className="w-4 h-4" />
Change Date
</button>
)}
</div>
{/* Alternative Travel Options — commented out for the time being;
only the "No trains available" banner above is shown.
{hasAlternatives && (
<div>
<div className="mb-4">
<h3 className="text-lg font-bold text-gray-900 dark:text-white">
Alternative Travel Options
</h3>
<p className="text-sm text-amber-600 dark:text-amber-400 mt-1">
These trains run on different dates than requested —
adjust your travel date to book one of them.
</p>
</div>
<div className="space-y-4">
{alternativeOutbound.map((schedule) =>
renderScheduleCard(schedule, true, true),
)}
</div>
</div>
)}
*/}
</div>
</div>
</div>
@@ -1249,28 +1366,23 @@ export default function ResultsPage() {
</div>
{outboundSchedules.length === 0 && (
<div className="mt-6">
<div className="flex items-center justify-between gap-4 px-4 py-3 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
<div className="flex items-center gap-2.5 text-sm text-red-800 dark:text-red-300">
<Calendar className="w-4 h-4 flex-shrink-0" />
<span>No trains on <span className="font-semibold">{searchData.date ? format(new Date(`${searchData.date}T00:00:00`), "EEEE, MMMM d") : "your selected date"}</span>.</span>
</div>
<button onClick={() => router.push(buildSearchUrl())} className="text-sm font-semibold text-primary hover:underline flex-shrink-0">
<div className="flex items-center gap-2.5 text-sm text-red-800 dark:text-red-300 px-4 py-3 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 mb-4">
<Calendar className="w-4 h-4 flex-shrink-0" />
<span>No trains on <span className="font-semibold">{searchData.date ? format(new Date(`${searchData.date}T00:00:00`), "EEEE, MMMM d") : "your selected date"}</span>.</span>
</div>
{alternativeOutbound.length > 0 ? (
<AlternativeDatesCalendar
alternatives={alternativeOutbound}
value={searchData.date ? new Date(`${searchData.date}T00:00:00`) : undefined}
minDate={new Date()}
onChange={(date) => pushResultsWithDates({ date: format(date, "yyyy-MM-dd") })}
/>
) : (
<button onClick={() => router.push(buildSearchUrl())} className="btn-primary inline-flex items-center gap-2">
<Calendar className="w-4 h-4" />
Change dates
</button>
</div>
{/* Alternative Outbound Options — commented out for the time being;
only the "No trains" banner above is shown.
<div className="mb-3">
<h3 className="text-base font-bold text-gray-900 dark:text-white">
Alternative Outbound Options
</h3>
</div>
<div className="space-y-4">
{alternativeOutbound.map((schedule: Schedule) =>
renderScheduleCard(schedule, true, true),
)}
</div>
*/}
)}
</div>
)}
</div>
@@ -1335,28 +1447,23 @@ export default function ResultsPage() {
</div>
{inboundSchedules.length === 0 && (
<div className="mt-6">
<div className="flex items-center justify-between gap-4 px-4 py-3 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
<div className="flex items-center gap-2.5 text-sm text-red-800 dark:text-red-300">
<Calendar className="w-4 h-4 flex-shrink-0" />
<span>No trains on <span className="font-semibold">{searchData.returnDate ? format(new Date(`${searchData.returnDate}T00:00:00`), "EEEE, MMMM d") : "your selected return date"}</span>.</span>
</div>
<button onClick={() => router.push(buildSearchUrl())} className="text-sm font-semibold text-primary hover:underline flex-shrink-0">
<div className="flex items-center gap-2.5 text-sm text-red-800 dark:text-red-300 px-4 py-3 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 mb-4">
<Calendar className="w-4 h-4 flex-shrink-0" />
<span>No trains on <span className="font-semibold">{searchData.returnDate ? format(new Date(`${searchData.returnDate}T00:00:00`), "EEEE, MMMM d") : "your selected return date"}</span>.</span>
</div>
{alternativeInbound.length > 0 ? (
<AlternativeDatesCalendar
alternatives={alternativeInbound}
value={searchData.returnDate ? new Date(`${searchData.returnDate}T00:00:00`) : undefined}
minDate={new Date(`${searchData.date}T00:00:00`)}
onChange={(date) => pushResultsWithDates({ returnDate: format(date, "yyyy-MM-dd") })}
/>
) : (
<button onClick={() => router.push(buildSearchUrl())} className="btn-primary inline-flex items-center gap-2">
<Calendar className="w-4 h-4" />
Change dates
</button>
</div>
{/* Alternative Return Options — commented out for the time being;
only the "No trains" banner above is shown.
<div className="mb-3">
<h3 className="text-base font-bold text-gray-900 dark:text-white">
Alternative Return Options
</h3>
</div>
<div className="space-y-4">
{alternativeInbound.map((schedule: Schedule) =>
renderScheduleCard(schedule, false, true),
)}
</div>
*/}
)}
</div>
)}
</div>

View File

@@ -0,0 +1,24 @@
"use client";
import { useParams } from "next/navigation";
import { XCircle } from "lucide-react";
import Link from "next/link";
export default function PayBalanceFailedPage() {
const { token } = useParams<{ token: string }>();
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950 flex items-center justify-center px-4">
<div className="max-w-sm w-full text-center space-y-4">
<XCircle className="w-16 h-16 text-red-500 mx-auto" />
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Payment failed</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">
Your payment could not be completed. Please try again.
</p>
<Link href={`/pay-balance/${token}`} className="btn-primary inline-block px-6 py-2.5 font-semibold">
Try again
</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,191 @@
"use client";
import { useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useQuery, useMutation } from "@tanstack/react-query";
import { apiClient } from "@/lib/api-client";
import { PaymentMethod } from "@/types";
import {
Loader2,
CreditCard,
Smartphone,
Wallet,
Landmark,
CheckCircle,
AlertCircle,
} from "lucide-react";
const getIconForMethod = (methodId: string) => {
if (methodId.includes("CARD")) return CreditCard;
if (methodId.includes("WALLET")) return Wallet;
if (methodId.includes("CAC")) return Landmark;
return Smartphone;
};
export default function PayBalancePage() {
const { token } = useParams<{ token: string }>();
const router = useRouter();
const [selectedMethod, setSelectedMethod] = useState<string | null>(null);
const [isProcessing, setIsProcessing] = useState(false);
const [paymentError, setPaymentError] = useState<string | null>(null);
const { data: charge, isLoading: loadingCharge, error: chargeError } = useQuery({
queryKey: ["supplementary-charge", token],
queryFn: () => apiClient.get<any>(`/payments/supplementary/by-token/${token}`),
retry: false,
});
const { data: paymentMethods = [], isLoading: loadingMethods } = useQuery<PaymentMethod[]>({
queryKey: ["paymentMethods"],
queryFn: async () => {
const res = await apiClient.get<PaymentMethod[]>("/payments/methods");
return Array.isArray(res) ? res : [];
},
enabled: !!charge,
});
const payMutation = useMutation({
mutationFn: (method: string) =>
apiClient.post<any>(`/payments/supplementary/by-token/${token}/pay`, {
method,
platform: "web",
}),
onSuccess: (data: any) => {
if (data?.clientAction?.type === "REDIRECT") {
window.location.href = data.clientAction.url;
return;
}
// Immediate success (e.g. wallet)
router.push(`/pay-balance/${token}/success`);
},
onError: (err: any) => {
setPaymentError(
err?.response?.data?.message ?? err?.message ?? "Payment failed. Please try again."
);
setIsProcessing(false);
},
});
const handlePay = () => {
if (!selectedMethod) return;
setIsProcessing(true);
setPaymentError(null);
payMutation.mutate(selectedMethod);
};
if (loadingCharge) {
return (
<div className="min-h-screen flex items-center justify-center">
<Loader2 className="w-10 h-10 text-primary animate-spin" />
</div>
);
}
if (chargeError || !charge) {
const msg = (chargeError as any)?.response?.data?.message ?? "This payment link is invalid or has expired.";
return (
<div className="min-h-screen flex items-center justify-center px-4">
<div className="max-w-sm w-full text-center space-y-4">
<AlertCircle className="w-14 h-14 text-red-500 mx-auto" />
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">Link unavailable</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">{msg}</p>
</div>
</div>
);
}
const amountDisplay = (charge.amountMinor / 100).toFixed(2);
const currency = charge.currency ?? "ETB";
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950 flex items-start justify-center px-4 py-10">
<div className="w-full max-w-md space-y-4">
{/* Header */}
<div className="text-center space-y-1">
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Outstanding balance</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">
Booking <span className="font-semibold text-gray-700 dark:text-gray-300">{charge.booking?.bookingRef}</span>
</p>
</div>
{/* Charge summary */}
<div className="card space-y-3">
<div className="flex justify-between items-center">
<span className="text-sm text-gray-500 dark:text-gray-400">Reason</span>
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">{charge.reason}</span>
</div>
{charge.notes && (
<div className="flex justify-between items-start gap-4">
<span className="text-sm text-gray-500 dark:text-gray-400 shrink-0">Notes</span>
<span className="text-sm text-gray-700 dark:text-gray-300 text-right">{charge.notes}</span>
</div>
)}
<div className="border-t border-gray-100 dark:border-gray-800 pt-3 flex justify-between items-center">
<span className="font-bold text-gray-900 dark:text-gray-100">Amount due</span>
<span className="text-2xl font-bold text-primary">{currency} {amountDisplay}</span>
</div>
</div>
{/* Payment methods */}
<div className="card space-y-3">
<h2 className="text-base font-bold text-gray-900 dark:text-gray-100">Select payment method</h2>
{loadingMethods ? (
<div className="flex items-center justify-center py-6 gap-2">
<Loader2 className="w-5 h-5 text-primary animate-spin" />
<span className="text-sm text-gray-500 dark:text-gray-400">Loading...</span>
</div>
) : (
<div className="space-y-2">
{paymentMethods.filter((m) => m.enabled).map((method) => {
const Icon = getIconForMethod(method.type);
const isSelected = selectedMethod === method.type;
return (
<button
key={method.id}
onClick={() => setSelectedMethod(method.type)}
disabled={isProcessing}
className={`w-full p-4 rounded-xl border-2 transition-all text-left ${
isSelected
? "border-primary bg-primary/8 dark:bg-primary/15 shadow-md"
: "border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:border-primary/50"
} ${isProcessing ? "opacity-50 cursor-not-allowed" : ""}`}
>
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 ${isSelected ? "bg-primary" : "bg-gray-100 dark:bg-gray-700"}`}>
<Icon className={`w-5 h-5 ${isSelected ? "text-white" : "text-primary"}`} />
</div>
<div className="flex-1 min-w-0">
<p className="font-semibold text-gray-900 dark:text-gray-100">{method.displayName}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{method.region} · {method.currency}</p>
</div>
{isSelected && <CheckCircle className="w-5 h-5 text-primary flex-shrink-0" />}
</div>
</button>
);
})}
</div>
)}
</div>
{paymentError && (
<p className="text-red-600 dark:text-red-400 text-sm text-center"> {paymentError}</p>
)}
<button
onClick={handlePay}
disabled={!selectedMethod || isProcessing}
className="btn-primary w-full py-3 font-semibold disabled:opacity-50 disabled:cursor-not-allowed"
>
{isProcessing ? (
<span className="flex items-center justify-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" /> Processing...
</span>
) : (
`Pay ${currency} ${amountDisplay}`
)}
</button>
<p className="text-xs text-gray-400 dark:text-gray-500 text-center">🔒 Secure & encrypted payment</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,21 @@
"use client";
import { CheckCircle } from "lucide-react";
import Link from "next/link";
export default function PayBalanceSuccessPage() {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950 flex items-center justify-center px-4">
<div className="max-w-sm w-full text-center space-y-4">
<CheckCircle className="w-16 h-16 text-green-500 mx-auto" />
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Payment successful</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">
Your outstanding balance has been settled. Thank you.
</p>
<Link href="/" className="btn-primary inline-block px-6 py-2.5 font-semibold">
Back to home
</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,426 @@
'use client';
import { useState, useMemo, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { ChevronLeft, ChevronRight, Calendar as CalendarIcon, Globe, X, Star } from 'lucide-react';
import {
gregorianToEthiopian,
ethiopianToGregorian,
formatEthiopianDate,
getDaysInEthiopianMonth,
ETHIOPIAN_MONTHS,
type EthiopianDate,
} from '@/lib/ethiopian-calendar';
import { format } from 'date-fns';
import { formatFare } from '@/utils/fare-utils';
import { Schedule } from '@/types';
interface AlternativeDatesCalendarProps {
// alternativeOutbound / alternativeInbound, as returned by /search — no fetching
// of its own, this component is purely presentational over data the results
// page already has in hand.
alternatives: Schedule[];
value?: Date;
minDate?: Date;
onChange: (date: Date) => void;
label?: string;
}
interface DayInfo {
hasAvailability: boolean;
lowestFareMinor: number | null;
displayCurrency: string | null;
}
const toDateKey = (date: Date) => {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
};
export default function AlternativeDatesCalendar({
value,
minDate,
onChange,
label,
alternatives,
}: AlternativeDatesCalendarProps) {
const [isOpen, setIsOpen] = useState(false);
const [isMobileView, setIsMobileView] = useState(false);
const [calendarType, setCalendarType] = useState<'gregorian' | 'ethiopian'>('gregorian');
const containerRef = useRef<HTMLDivElement>(null);
// Day-level availability + cheapest fare, derived once from the alternatives
// list — same "cheapest across faresByClass" logic used by the schedule cards
// on the results page.
const dayMap = useMemo(() => {
const map = new Map<string, DayInfo>();
for (const schedule of alternatives) {
if (!schedule.departureAt) continue;
const key = toDateKey(new Date(schedule.departureAt));
const fares = (schedule.faresByClass ?? [])
.map((f) => f.displayAmountMinor ?? f.baseFareMinor)
.filter((n): n is number => !!n && n > 0);
const lowestFareMinor = fares.length ? Math.min(...fares) : null;
const displayCurrency = schedule.faresByClass?.[0]?.displayCurrency ?? null;
const hasAvailability = !!schedule.hasAvailability;
const existing = map.get(key);
if (!existing) {
map.set(key, { hasAvailability, lowestFareMinor: hasAvailability ? lowestFareMinor : null, displayCurrency: hasAvailability ? displayCurrency : null });
} else {
existing.hasAvailability = existing.hasAvailability || hasAvailability;
if (hasAvailability && lowestFareMinor !== null && (existing.lowestFareMinor === null || lowestFareMinor < existing.lowestFareMinor)) {
existing.lowestFareMinor = lowestFareMinor;
existing.displayCurrency = displayCurrency;
}
}
}
return map;
}, [alternatives]);
const availableDateKeys = useMemo(
() => Array.from(dayMap.entries()).filter(([, info]) => info.hasAvailability).map(([key]) => key),
[dayMap],
);
const bestPriceDateKeys = useMemo(() => {
const fares = availableDateKeys
.map((key) => dayMap.get(key)!.lowestFareMinor)
.filter((n): n is number => n !== null);
if (fares.length === 0) return new Set<string>();
const min = Math.min(...fares);
return new Set(availableDateKeys.filter((key) => dayMap.get(key)!.lowestFareMinor === min));
}, [availableDateKeys, dayMap]);
// Pick the initial month to show: the requested date's month if it actually has
// data, otherwise the month of whichever available date is closest to it — the
// alternatives list has no day-window bound, so it can easily land in a
// different month than the one originally searched.
const initialFocusDate = useMemo(() => {
const base = value ?? new Date();
const baseMonthKey = `${base.getFullYear()}-${String(base.getMonth() + 1).padStart(2, '0')}`;
if (availableDateKeys.some((key) => key.startsWith(baseMonthKey))) return base;
let nearest: string | null = null;
let nearestDiff = Infinity;
for (const key of availableDateKeys) {
const diff = Math.abs(new Date(`${key}T00:00:00`).getTime() - base.getTime());
if (diff < nearestDiff) { nearestDiff = diff; nearest = key; }
}
return nearest ? new Date(`${nearest}T00:00:00`) : base;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const [viewMonth, setViewMonth] = useState(initialFocusDate.getMonth());
const [viewYear, setViewYear] = useState(initialFocusDate.getFullYear());
const initialEthDate = gregorianToEthiopian(initialFocusDate);
const [ethViewMonth, setEthViewMonth] = useState(initialEthDate.month);
const [ethViewYear, setEthViewYear] = useState(initialEthDate.year);
useEffect(() => {
const check = () => setIsMobileView(window.innerWidth < 768);
check();
window.addEventListener('resize', check);
return () => window.removeEventListener('resize', check);
}, []);
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => { document.body.style.overflow = ''; };
}, [isOpen]);
const toggleCalendarType = () => {
const newType = calendarType === 'gregorian' ? 'ethiopian' : 'gregorian';
const ref = value || new Date();
if (newType === 'ethiopian') {
const ethDate = gregorianToEthiopian(ref);
setEthViewMonth(ethDate.month);
setEthViewYear(ethDate.year);
} else {
setViewMonth(ref.getMonth());
setViewYear(ref.getFullYear());
}
setCalendarType(newType);
};
const handleDateSelect = (date: Date) => {
onChange(date);
setIsOpen(false);
};
const handleEthiopianDateSelect = (ethDate: EthiopianDate) => {
handleDateSelect(ethiopianToGregorian(ethDate));
};
const isBeforeMin = (date: Date) =>
!!minDate && date < new Date(minDate.getFullYear(), minDate.getMonth(), minDate.getDate());
const renderGregorianCalendar = () => {
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
const firstDay = new Date(viewYear, viewMonth, 1).getDay();
const days: (number | null)[] = Array(firstDay).fill(null);
for (let d = 1; d <= daysInMonth; d++) days.push(d);
const monthNames = ['January','February','March','April','May','June','July','August','September','October','November','December'];
return (
<div className="p-4">
<div className="flex items-center justify-between mb-4">
<button type="button" onClick={() => { if (viewMonth === 0) { setViewMonth(11); setViewYear(y => y - 1); } else setViewMonth(m => m - 1); }} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors">
<ChevronLeft className="w-5 h-5 text-gray-600 dark:text-gray-400" />
</button>
<span className="font-semibold text-base text-gray-900 dark:text-gray-100">{monthNames[viewMonth]} {viewYear}</span>
<button type="button" onClick={() => { if (viewMonth === 11) { setViewMonth(0); setViewYear(y => y + 1); } else setViewMonth(m => m + 1); }} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors">
<ChevronRight className="w-5 h-5 text-gray-600 dark:text-gray-400" />
</button>
</div>
<div className="grid grid-cols-7 gap-1 mb-2">
{['Su','Mo','Tu','We','Th','Fr','Sa'].map(d => (
<div key={d} className="text-center text-xs font-semibold text-gray-400 py-2">{d}</div>
))}
</div>
<div className="grid grid-cols-7 gap-1">
{days.map((day, i) => {
if (day === null) return <div key={`e-${i}`} />;
const date = new Date(viewYear, viewMonth, day);
const key = toDateKey(date);
const dayInfo = dayMap.get(key);
const isSelected = value && date.getDate() === value.getDate() && date.getMonth() === value.getMonth() && date.getFullYear() === value.getFullYear();
const isToday = date.toDateString() === new Date().toDateString();
const isDisabled = isBeforeMin(date) || !dayInfo?.hasAvailability;
const isBestPrice = bestPriceDateKeys.has(key);
return (
<button key={day} type="button" onClick={() => !isDisabled && handleDateSelect(date)} disabled={isDisabled}
className={`relative aspect-square flex flex-col items-center justify-center text-sm rounded-lg transition-all
${isSelected ? 'bg-primary text-white font-semibold shadow-md scale-105' : ''}
${isToday && !isSelected ? 'border-2 border-primary text-primary font-semibold' : ''}
${!isSelected && !isToday && !isDisabled ? 'hover:bg-green-50 dark:hover:bg-green-900/20 text-gray-700 dark:text-gray-300' : ''}
${isDisabled ? 'text-gray-300 dark:text-gray-600 cursor-not-allowed' : ''}`}>
{isBestPrice && !isSelected && (
<Star className="w-2.5 h-2.5 absolute top-0.5 right-0.5 text-amber-500 fill-amber-500" />
)}
<span>{day}</span>
{dayInfo?.hasAvailability && dayInfo.lowestFareMinor != null && (
<span className={`text-[8px] leading-none mt-0.5 ${isSelected ? 'text-white/90' : 'text-green-600 dark:text-green-400'}`}>
{formatFare(dayInfo.lowestFareMinor, dayInfo.displayCurrency ?? 'ETB').replace(/\.00$/, '')}
</span>
)}
</button>
);
})}
</div>
</div>
);
};
const renderEthiopianCalendar = () => {
const daysInMonth = getDaysInEthiopianMonth(ethViewYear, ethViewMonth);
const firstDate = ethiopianToGregorian({ year: ethViewYear, month: ethViewMonth, day: 1 });
const firstDayOfWeek = firstDate.getDay();
const daysWithOffset: (number | null)[] = Array(firstDayOfWeek).fill(null);
for (let d = 1; d <= daysInMonth; d++) daysWithOffset.push(d);
const monthName = ETHIOPIAN_MONTHS[ethViewMonth - 1] || `Month ${ethViewMonth}`;
return (
<div className="p-4">
<div className="flex items-center justify-between mb-4">
<button type="button" onClick={() => { if (ethViewMonth === 1) { setEthViewMonth(13); setEthViewYear(y => y - 1); } else setEthViewMonth(m => m - 1); }} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors">
<ChevronLeft className="w-5 h-5 text-gray-600 dark:text-gray-400" />
</button>
<span className="font-semibold text-base text-gray-900 dark:text-gray-100">{monthName} {ethViewYear}</span>
<button type="button" onClick={() => { if (ethViewMonth === 13) { setEthViewMonth(1); setEthViewYear(y => y + 1); } else setEthViewMonth(m => m + 1); }} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors">
<ChevronRight className="w-5 h-5 text-gray-600 dark:text-gray-400" />
</button>
</div>
<div className="grid grid-cols-7 gap-1 mb-2">
{['Su','Mo','Tu','We','Th','Fr','Sa'].map(d => (
<div key={d} className="text-center text-xs font-semibold text-gray-400 py-2">{d}</div>
))}
</div>
<div className="grid grid-cols-7 gap-1">
{daysWithOffset.map((day, i) => {
if (day === null) return <div key={`e-${i}`} />;
const ethDate: EthiopianDate = { year: ethViewYear, month: ethViewMonth, day };
const gregDate = ethiopianToGregorian(ethDate);
const key = toDateKey(gregDate);
const dayInfo = dayMap.get(key);
const isSelected = value && gregDate.getDate() === value.getDate() && gregDate.getMonth() === value.getMonth() && gregDate.getFullYear() === value.getFullYear();
const todayEth = gregorianToEthiopian(new Date());
const isToday = ethDate.day === todayEth.day && ethDate.month === todayEth.month && ethDate.year === todayEth.year;
const isDisabled = isBeforeMin(gregDate) || !dayInfo?.hasAvailability;
const isBestPrice = bestPriceDateKeys.has(key);
return (
<button key={day} type="button" onClick={() => !isDisabled && handleEthiopianDateSelect(ethDate)} disabled={isDisabled}
className={`relative aspect-square flex flex-col items-center justify-center text-sm rounded-lg transition-all
${isSelected ? 'bg-primary text-white font-semibold shadow-md scale-105' : ''}
${isToday && !isSelected ? 'border-2 border-primary text-primary font-semibold' : ''}
${!isSelected && !isToday && !isDisabled ? 'hover:bg-green-50 dark:hover:bg-green-900/20 text-gray-700 dark:text-gray-300' : ''}
${isDisabled ? 'text-gray-300 dark:text-gray-600 cursor-not-allowed' : ''}`}>
{isBestPrice && !isSelected && (
<Star className="w-2.5 h-2.5 absolute top-0.5 right-0.5 text-amber-500 fill-amber-500" />
)}
<span>{day}</span>
{dayInfo?.hasAvailability && dayInfo.lowestFareMinor != null && (
<span className={`text-[8px] leading-none mt-0.5 ${isSelected ? 'text-white/90' : 'text-green-600 dark:text-green-400'}`}>
{formatFare(dayInfo.lowestFareMinor, dayInfo.displayCurrency ?? 'ETB').replace(/\.00$/, '')}
</span>
)}
</button>
);
})}
</div>
</div>
);
};
const legend = (
<div className="flex items-center gap-4 px-4 pb-3 text-xs text-gray-500 dark:text-gray-400">
<span className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full bg-green-500" /> Available
</span>
<span className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full bg-gray-300 dark:bg-gray-600" /> Unavailable
</span>
<span className="flex items-center gap-1.5">
<Star className="w-3 h-3 text-amber-500 fill-amber-500" /> Best price
</span>
</div>
);
const calendarFooter = value && (
<div className="border-t border-gray-200 dark:border-gray-700 p-3 bg-gray-50 dark:bg-gray-800/60 space-y-1.5">
<div className="flex items-center justify-between text-xs">
<span className="text-gray-500 dark:text-gray-400">Gregorian:</span>
<span className="font-medium text-gray-700 dark:text-gray-300">{format(value, 'MMMM d, yyyy')}</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-gray-500 dark:text-gray-400">Ethiopian:</span>
<span className="font-medium text-gray-700 dark:text-gray-300">{formatEthiopianDate(gregorianToEthiopian(value))}</span>
</div>
</div>
);
const modalContent = (
<div className="flex flex-col h-full">
<div className="flex items-center justify-between px-4 py-4 border-b border-gray-100 dark:border-gray-800 flex-shrink-0">
<div className="flex items-center gap-2">
<CalendarIcon className="w-4 h-4 text-primary" />
<h2 className="text-base font-bold text-gray-900 dark:text-white">{label ? `Select ${label}` : 'Select a date'}</h2>
</div>
<button
type="button"
onClick={() => setIsOpen(false)}
className="w-9 h-9 flex items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
<X className="w-5 h-5 text-gray-500 dark:text-gray-400" />
</button>
</div>
{legend}
<div className="px-4 py-2.5 border-b border-gray-100 dark:border-gray-800 flex-shrink-0">
<div className="flex rounded-lg overflow-hidden border-2 border-gray-200 dark:border-gray-700">
<button
type="button"
onClick={() => calendarType !== 'gregorian' && toggleCalendarType()}
className={`flex-1 flex items-center justify-center gap-1.5 py-2 text-xs font-semibold transition-colors ${
calendarType === 'gregorian'
? 'bg-primary text-white'
: 'bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
>
<Globe className="w-3.5 h-3.5" />
Gregorian
</button>
<button
type="button"
onClick={() => calendarType !== 'ethiopian' && toggleCalendarType()}
className={`flex-1 flex items-center justify-center gap-1.5 py-2 text-xs font-semibold transition-colors ${
calendarType === 'ethiopian'
? 'bg-primary text-white'
: 'bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
>
<Globe className="w-3.5 h-3.5" />
Ethiopian
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto">
{calendarType === 'gregorian' ? renderGregorianCalendar() : renderEthiopianCalendar()}
{calendarFooter}
</div>
{isMobileView && (
<div className="flex-shrink-0 border-t border-gray-200 dark:border-gray-700 p-3 bg-white dark:bg-gray-900">
<button
type="button"
onClick={() => setIsOpen(false)}
className="w-full py-3 rounded-xl border-2 border-gray-200 dark:border-gray-700 text-sm font-semibold text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
Cancel
</button>
</div>
)}
</div>
);
return (
<div className="relative" ref={containerRef}>
<button
type="button"
onClick={() => setIsOpen(true)}
className="w-full min-w-0 px-4 py-3.5 border-2 border-dashed border-primary/40 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary text-center flex items-center justify-center gap-2 bg-primary/5 hover:bg-primary/10 dark:bg-primary/10 dark:hover:bg-primary/15 transition-all group"
>
<CalendarIcon className="w-4 h-4 text-primary flex-shrink-0" />
<span className="text-sm font-semibold text-primary">
{`Click here to see available ${label ? label.toLowerCase() + " " : ""}dates`}
</span>
</button>
{isOpen && createPortal(
<>
{isMobileView ? (
<div
className="fixed inset-0 z-[100] bg-white dark:bg-gray-900 flex flex-col"
style={{ animation: 'mdp-slide-up 0.25s cubic-bezier(0.32,0.72,0,1)' }}
>
{modalContent}
</div>
) : (
<>
<div className="fixed inset-0 z-[99] bg-black/40 backdrop-blur-sm" onClick={() => setIsOpen(false)} />
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 pointer-events-none">
<div
className="bg-white dark:bg-gray-900 rounded-2xl shadow-2xl border border-gray-200 dark:border-gray-700 w-full max-w-sm pointer-events-auto"
style={{ animation: 'mdp-scale-in 0.2s cubic-bezier(0.34,1.56,0.64,1)' }}
>
{modalContent}
</div>
</div>
</>
)}
<style>{`
@keyframes mdp-slide-up {
from { transform: translateY(100%); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes mdp-scale-in {
from { transform: scale(0.92); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
`}</style>
</>,
document.body
)}
</div>
);
}

View File

@@ -0,0 +1,29 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* `reference_type` was varchar(16) but `SUPPLEMENTARY_CHARGE` is 20 characters, causing
* a value-too-long error whenever the passenger API initiates a supplementary-charge payment.
* Widen to varchar(32) to accommodate all current and near-future PaymentReferenceType values.
* Same fix applied to notification_outbox.reference_type for consistency.
*/
export class WideReferenceType1782100000000 implements MigrationInterface {
name = "WideReferenceType1782100000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "edr_payment"."payment_intent" ALTER COLUMN "reference_type" TYPE varchar(32)`,
);
await queryRunner.query(
`ALTER TABLE "edr_payment"."notification_outbox" ALTER COLUMN "reference_type" TYPE varchar(32)`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "edr_payment"."notification_outbox" ALTER COLUMN "reference_type" TYPE varchar(16)`,
);
await queryRunner.query(
`ALTER TABLE "edr_payment"."payment_intent" ALTER COLUMN "reference_type" TYPE varchar(16)`,
);
}
}

View File

@@ -33,7 +33,7 @@ export class PaymentIntent extends BaseEntity {
@Column({ name: "service", type: "varchar", length: 16 })
service!: PaymentService;
@Column({ name: "reference_type", type: "varchar", length: 16 })
@Column({ name: "reference_type", type: "varchar", length: 32 })
referenceType!: PaymentReferenceType;
/** Domain order id (booking/shipment). Soft reference — no cross-schema FK. */

View File

@@ -41,6 +41,8 @@ export interface ProviderResultInput {
confirmedAmountMinor?: number;
failureCode?: string;
failureMessage?: string;
/** Raw provider status-query body, merged into the intent's audit payload when present. */
rawResponse?: Record<string, unknown>;
}
@Injectable()
@@ -357,6 +359,7 @@ export class IntentsService {
providerTxnId: status.providerTxnId,
failureCode: status.failureCode,
failureMessage: status.failureMessage,
rawResponse: status.rawResponse,
};
}
@@ -388,6 +391,15 @@ export class IntentsService {
return { alreadyTerminal: true };
}
// Keep the audit payload current with the latest provider status body (surfaced as
// `providerResponse` in the snapshot). Merged so the initiation keys are preserved.
if (result.rawResponse) {
intent.rawInitiation = {
...(intent.rawInitiation ?? {}),
statusResponse: result.rawResponse,
};
}
if (result.status === ProviderPaymentStatus.SUCCEEDED) {
const paidAt = result.paidAt ?? new Date();
intent.status = ProviderPaymentStatus.SUCCEEDED;
@@ -482,6 +494,7 @@ export class IntentsService {
failureCode: intent.failureCode ?? undefined,
failureMessage: intent.failureMessage ?? undefined,
expiresAt: intent.expiresAt?.toISOString(),
providerResponse: intent.rawInitiation ?? undefined,
};
}
}

View File

@@ -28,7 +28,7 @@ export class NotificationOutbox extends BaseEntity {
@Column({ name: "intent_id", type: "uuid" })
intentId!: string;
@Column({ name: "reference_type", type: "varchar", length: 16 })
@Column({ name: "reference_type", type: "varchar", length: 32 })
referenceType!: PaymentReferenceType;
@Column({ name: "reference_id", type: "varchar", length: 64 })

View File

@@ -56,6 +56,8 @@ services:
VITE_BASE_API_URL: ${VITE_BASE_API_URL:-}
VITE_USER_MANAGEMENT_BASE: ${VITE_USER_MANAGEMENT_BASE:-}
VITE_GOOGLE_MAPS_API_KEY: ${VITE_GOOGLE_MAPS_API_KEY:-}
VITE_POSTHOG_KEY: ${VITE_POSTHOG_KEY:-}
VITE_POSTHOG_HOST: ${VITE_POSTHOG_HOST:-}
secrets:
- npmrc
ports:
@@ -72,6 +74,8 @@ services:
VITE_BASE_API_URL: ${VITE_BASE_API_URL:-}
VITE_USER_MANAGEMENT_BASE: ${VITE_USER_MANAGEMENT_BASE:-}
VITE_GOOGLE_MAPS_API_KEY: ${VITE_GOOGLE_MAPS_API_KEY:-}
VITE_POSTHOG_KEY: ${VITE_POSTHOG_KEY:-}
VITE_POSTHOG_HOST: ${VITE_POSTHOG_HOST:-}
secrets:
- npmrc
ports:

View File

@@ -28,11 +28,15 @@ ARG VITE_BASE_API_URL
ARG VITE_USER_MANAGEMENT_BASE
ARG NEXT_PUBLIC_API_URL
ARG VITE_GOOGLE_MAPS_API_KEY
ARG VITE_POSTHOG_KEY
ARG VITE_POSTHOG_HOST
ENV VITE_API_URL=${VITE_API_URL}
ENV VITE_BASE_API_URL=${VITE_BASE_API_URL}
ENV VITE_USER_MANAGEMENT_BASE=${VITE_USER_MANAGEMENT_BASE}
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
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 \
echo "ERROR: VITE_API_URL, VITE_BASE_API_URL, and VITE_USER_MANAGEMENT_BASE must all be set" && \

View File

@@ -100,6 +100,7 @@ export enum PaymentService {
export enum PaymentReferenceType {
BOOKING = "BOOKING",
SHIPMENT = "SHIPMENT",
SUPPLEMENTARY_CHARGE = "SUPPLEMENTARY_CHARGE",
}
@@ -153,6 +154,13 @@ export type PaymentIntentSnapshot ={
failureCode?: string;
failureMessage?: string;
expiresAt?: string;
/**
* Raw provider payload for inspection/debugging — the audit copy of the provider
* initiation response merged with the latest status-query response (secrets redacted
* upstream). Not a contract with the provider; shape is provider-specific. Never trusted
* for state decisions — the state machine drives `status`.
*/
providerResponse?: Record<string, unknown>;
}
export type PaymentEventType = "payment.succeeded" | "payment.failed";

View File

@@ -620,6 +620,12 @@ export interface IContract extends BaseEntity {
* clearance view per contract.
*/
clearancePhase?: ContractDocPhase | string | null;
/**
* Latest clearance cycle's linked booking id + status (list responses only).
* An EXPIRED status means the customer never paid — GL may rebook.
*/
latestCycleBookingId?: string | null;
latestCycleBookingStatus?: string | null;
pricingBreakdown?: ContractPricingBreakdown | null;
pricingDisplayMode?: "UNIT_RATES";

248
pnpm-lock.yaml generated
View File

@@ -241,6 +241,9 @@ importers:
'@mantine/hooks':
specifier: ^9.3.0
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':
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)
@@ -409,6 +412,9 @@ importers:
pdf-lib:
specifier: ^1.17.1
version: 1.17.1
posthog-js:
specifier: ^1.400.1
version: 1.400.1
prop-types:
specifier: ^15.8.1
version: 15.8.1
@@ -566,12 +572,15 @@ importers:
'@mantine/hooks':
specifier: ^9.3.0
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':
specifier: ^5.59.0
version: 5.101.0(react@19.2.6)
'@tria-plc/iamui':
specifier: file:../../../local-packages/tria-plc-iamui-0.1.1.tgz
version: file:local-packages/tria-plc-iamui-0.1.1.tgz(a5d0bdee55164ae56064fb273b530e00)
version: file:local-packages/tria-plc-iamui-0.1.1.tgz(0ce39b7e349029277dcd938d06eeb0f7)
'@vis.gl/react-google-maps':
specifier: ^1.8.3
version: 1.8.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@@ -590,6 +599,9 @@ importers:
lucide-react:
specifier: ^1.14.0
version: 1.17.0(react@19.2.6)
posthog-js:
specifier: ^1.400.1
version: 1.400.1
radix-ui:
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)
@@ -3125,6 +3137,22 @@ packages:
'@popperjs/core@2.11.8':
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':
resolution: {integrity: sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg==}
engines: {node: '>=18.18'}
@@ -7050,6 +7078,9 @@ packages:
resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==}
engines: {node: ^12.20 || >= 14.13}
fflate@0.4.8:
resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==}
fflate@0.8.3:
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
@@ -9498,10 +9529,21 @@ packages:
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
engines: {node: '>=0.10.0'}
posthog-js@1.400.1:
resolution: {integrity: sha512-NGfzNwTu+VBw4FekgYs/aQbEkTFkvmpTFUKDGZw/9K6R/sG2WyuLsnnXySRcNH8RMki0Io8v9flNATrrTfmT+Q==}
powershell-utils@0.1.0:
resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==}
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:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
@@ -9601,6 +9643,9 @@ packages:
resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==}
engines: {node: '>=0.6'}
query-selector-shadow-dom@1.0.1:
resolution: {integrity: sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==}
query-string@7.1.3:
resolution: {integrity: sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==}
engines: {node: '>=6'}
@@ -11478,6 +11523,9 @@ packages:
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
engines: {node: '>= 8'}
web-vitals@5.3.0:
resolution: {integrity: sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g==}
webdriver-bidi-protocol@0.4.1:
resolution: {integrity: sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==}
@@ -13696,6 +13744,19 @@ snapshots:
'@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)':
optionalDependencies:
prisma: 6.19.3(typescript@5.9.3)
@@ -16092,130 +16153,6 @@ snapshots:
- utf-8-validate
- vite
'@tria-plc/iamui@file:local-packages/tria-plc-iamui-0.1.1.tgz(a5d0bdee55164ae56064fb273b530e00)':
dependencies:
'@emotion/react': 11.14.0(@types/react@18.3.31)(react@19.2.6)
'@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react@19.2.6)
'@hookform/resolvers': 5.4.0(react-hook-form@7.77.0(react@19.2.6))
'@lottiefiles/react-lottie-player': 3.6.0(react@19.2.6)
'@mantine/charts': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(recharts@3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1))
'@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@mantine/dates': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@mantine/hooks': 7.17.8(react@19.2.6)
'@mantine/notifications': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-accordion': 1.2.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-alert-dialog': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-avatar': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-checkbox': 1.3.4(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-collapsible': 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-context-menu': 2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-dialog': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-dropdown-menu': 2.1.17(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-hover-card': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-label': 2.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-navigation-menu': 1.2.15(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-popover': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-progress': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-radio-group': 1.4.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-scroll-area': 1.2.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-select': 2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-separator': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-slot': 1.2.5(@types/react@18.3.31)(react@19.2.6)
'@radix-ui/react-switch': 1.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-tabs': 1.1.14(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-toast': 1.2.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-tooltip': 1.2.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@react-pdf-viewer/core': 3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@react-pdf-viewer/default-layout': 3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@react-pdf/renderer': 4.5.1(react@19.2.6)
'@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@18.3.31)(react@19.2.6)(redux@5.0.1))(react@19.2.6)
'@tabler/icons-react': 3.44.0(react@19.2.6)
'@tailwindcss/vite': 4.3.0(vite@5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0))
'@tanstack/react-query': 5.101.0(react@19.2.6)
'@tanstack/react-query-devtools': 5.101.0(@tanstack/react-query@5.101.0(react@19.2.6))(react@19.2.6)
'@tanstack/react-table': 8.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@tinymce/tinymce-react': 6.3.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tinymce@7.9.3)
'@types/dompurify': 3.2.0
'@types/node': 24.13.1
'@types/tinymce': 4.6.9
axios: 1.17.0
class-variance-authority: 0.7.1
clsx: 2.1.1
cmdk: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
date-fns: 3.6.0
dayjs: 1.11.21
dompurify: 3.4.8
ethiopian-calendar-date-converter: 2.1.6
ethiopian-calendar-new: 1.1.0
file-type: 18.7.0
framer-motion: 12.40.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
html2canvas: 1.4.1
i18next: 25.10.10(typescript@5.9.3)
i18next-browser-languagedetector: 8.2.1
jquery: 3.7.1
js-cookie: 3.0.8
jspdf: 3.0.4
lodash: 4.18.1
lucide-react: 0.513.0(react@19.2.6)
mantine-react-table: 2.0.0-beta.9(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/dates@7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(@tabler/icons-react@3.44.0(react@19.2.6))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
mui-ethiopian-datepicker: 0.3.2(4b3af212eafdf0059f009b005d7e343d)
next-themes: 0.4.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
path: 0.12.7
pdf-lib: 1.17.1
qs: 6.15.2
react: 19.2.6
react-cookie: 8.1.2(@types/react@18.3.31)(react@19.2.6)
react-css-nocode-editor: 1.0.13(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)
react-day-picker: 8.10.2(date-fns@3.6.0)(react@19.2.6)
react-dom: 19.2.6(react@19.2.6)
react-dropzone: 14.4.1(react@19.2.6)
react-hook-form: 7.77.0(react@19.2.6)
react-i18next: 15.7.4(i18next@25.10.10(typescript@5.9.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
react-icons: 5.6.0(react@19.2.6)
react-image-crop: 11.0.10(react@19.2.6)
react-intersection-observer: 9.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react-pdf: 10.4.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react-pdf-html: 2.1.5(@react-pdf/renderer@4.5.1(react@19.2.6))(react@19.2.6)
react-redux: 9.3.0(@types/react@18.3.31)(react@19.2.6)(redux@5.0.1)
react-resizable-panels: 3.0.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react-router-dom: 7.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react-signature-canvas: 1.1.0-alpha.2(@types/prop-types@15.7.15)(@types/react@18.3.31)(prop-types@15.8.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
recharts: 3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1)
rollup-plugin-visualizer: 7.0.1(rollup@4.61.1)
socket.io-client: 4.8.3
sonner: 2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
tailwind-merge: 3.6.0
tailwind-scrollbar-hide: 4.0.0(tailwindcss@4.3.0)
tailwindcss: 4.3.0
tailwindcss-animate: 1.0.7(tailwindcss@4.3.0)
tinymce: 7.9.3
url: 0.11.4
vaul: 1.1.2(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
xlsx: 0.18.5
zod: 3.25.76
transitivePeerDependencies:
- '@babel/core'
- '@emotion/is-prop-valid'
- '@mui/icons-material'
- '@mui/material'
- '@mui/x-date-pickers'
- '@types/prop-types'
- '@types/react'
- '@types/react-dom'
- bufferutil
- debug
- pdfjs-dist
- prop-types
- react-is
- react-native
- redux
- rolldown
- rollup
- supports-color
- typescript
- utf-8-validate
- vite
'@ts-morph/common@0.27.0':
dependencies:
fast-glob: 3.3.3
@@ -17402,16 +17339,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
babel-plugin-styled-components@2.3.0(styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6))(supports-color@5.5.0):
dependencies:
'@babel/helper-annotate-as-pure': 7.29.7
'@babel/helper-module-imports': 7.29.7(supports-color@5.5.0)
'@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7)
picomatch: 4.0.4
styled-components: 5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)
transitivePeerDependencies:
- supports-color
babel-polyfill@6.26.0:
dependencies:
babel-runtime: 6.26.0
@@ -18015,8 +17942,7 @@ snapshots:
core-js@2.6.12: {}
core-js@3.49.0:
optional: true
core-js@3.49.0: {}
core-util-is@1.0.3: {}
@@ -19202,6 +19128,8 @@ snapshots:
node-domexception: 1.0.0
web-streams-polyfill: 3.3.3
fflate@0.4.8: {}
fflate@0.8.3: {}
figures@1.7.0:
@@ -21856,8 +21784,23 @@ snapshots:
dependencies:
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: {}
preact@10.29.7: {}
prelude-ls@1.2.1: {}
prettier@3.8.3: {}
@@ -21981,6 +21924,8 @@ snapshots:
dependencies:
side-channel: 1.1.0
query-selector-shadow-dom@1.0.1: {}
query-string@7.1.3:
dependencies:
decode-uri-component: 0.2.2
@@ -22173,15 +22118,6 @@ snapshots:
- '@babel/core'
- react-is
react-css-nocode-editor@1.0.13(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6):
dependencies:
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
styled-components: 5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)
transitivePeerDependencies:
- '@babel/core'
- react-is
react-day-picker@8.10.2(date-fns@3.6.0)(react@19.2.6):
dependencies:
date-fns: 3.6.0
@@ -23371,24 +23307,6 @@ snapshots:
transitivePeerDependencies:
- '@babel/core'
styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6):
dependencies:
'@babel/helper-module-imports': 7.29.7(supports-color@5.5.0)
'@babel/traverse': 7.29.7(supports-color@5.5.0)
'@emotion/is-prop-valid': 1.4.0
'@emotion/stylis': 0.8.5
'@emotion/unitless': 0.7.5
babel-plugin-styled-components: 2.3.0(styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6))(supports-color@5.5.0)
css-to-react-native: 3.2.0
hoist-non-react-statics: 3.3.2
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
react-is: 19.2.7
shallowequal: 1.1.0
supports-color: 5.5.0
transitivePeerDependencies:
- '@babel/core'
styled-jsx@5.1.1(babel-plugin-macros@3.1.0)(react@18.3.1):
dependencies:
client-only: 0.0.1
@@ -24334,6 +24252,8 @@ snapshots:
web-streams-polyfill@3.3.3: {}
web-vitals@5.3.0: {}
webdriver-bidi-protocol@0.4.1: {}
webidl-conversions@7.0.0: {}