mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 09:58:12 +00:00
Merge branch 'dev' into freight/feat/fixes-v1
This commit is contained in:
@@ -0,0 +1,64 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EDR last-mile is multi-truck: a booking can be served by as many trucks as it
|
||||||
|
* has containers (bulk hauls until the tonnage is drawn down). Arrival/delivery
|
||||||
|
* were stamped once per `last_mile` record, so every truck shared one timestamp.
|
||||||
|
* These per-vehicle columns give each EDR truck its own arrival, leaving and
|
||||||
|
* weighed load — the same granularity self-haul trucks already have.
|
||||||
|
*
|
||||||
|
* Weights are TONNES (matching bookings.cargo_total_weight_vgm and the exit
|
||||||
|
* weighing UI). Named `*_tons` deliberately: the older
|
||||||
|
* customer_truck_assignments.gross_weight_kg is named kg but stores tonnes.
|
||||||
|
* All nullable — legacy rows predate per-truck tracking.
|
||||||
|
*/
|
||||||
|
export class AddLastMileTruckArrivalDeparture2260000000000 implements MigrationInterface {
|
||||||
|
name = 'AddLastMileTruckArrivalDeparture2260000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.last_mile_vehicle_assignments
|
||||||
|
ADD COLUMN IF NOT EXISTS arrived_at timestamptz NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS departed_at timestamptz NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS gross_weight_tons numeric(14, 3) NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS net_weight_tons numeric(14, 3) NULL
|
||||||
|
`);
|
||||||
|
|
||||||
|
// A truck carries 1x40ft OR 2x20ft, so an EDR truck needs MORE than the one
|
||||||
|
// container the legacy scalar `container_number` can hold. Mirrors the
|
||||||
|
// self-haul customer_truck_containers child table. The scalar stays in place
|
||||||
|
// (synced to the first container) for backward compatibility.
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.last_mile_vehicle_containers (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
assignment_id uuid NOT NULL REFERENCES freight.last_mile_vehicle_assignments(id) ON DELETE CASCADE,
|
||||||
|
last_mile_id uuid NOT NULL,
|
||||||
|
container_number varchar(32) NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS "IDX_last_mile_vehicle_containers_assignment"
|
||||||
|
ON freight.last_mile_vehicle_containers (assignment_id)
|
||||||
|
`);
|
||||||
|
// A container rides exactly one truck per delivery.
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_last_mile_vehicle_container"
|
||||||
|
ON freight.last_mile_vehicle_containers (last_mile_id, container_number)
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.last_mile_vehicle_containers`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.last_mile_vehicle_assignments
|
||||||
|
DROP COLUMN IF EXISTS arrived_at,
|
||||||
|
DROP COLUMN IF EXISTS departed_at,
|
||||||
|
DROP COLUMN IF EXISTS gross_weight_tons,
|
||||||
|
DROP COLUMN IF EXISTS net_weight_tons
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-seed the EDR wagon fleet onto the official ER numbering.
|
||||||
|
*
|
||||||
|
* Supersedes SeedWagonsWithYardAssignment1784000000001, which seeded 500 wagons
|
||||||
|
* on a `<CODE>-NNNN` scheme and wrote the status as 'Available' — mixed case
|
||||||
|
* that never matches WagonStatus.Available ('AVAILABLE'), so status filters
|
||||||
|
* silently returned nothing. This seed uses the enum value.
|
||||||
|
*
|
||||||
|
* Every wagon lands unassigned: current_yard_id NULL, status AVAILABLE. Wagon
|
||||||
|
* specs (capacity/length/tare) stay owned by wagon_types and are not touched —
|
||||||
|
* the types already exist and only the wagon↔type link is (re)established here.
|
||||||
|
*/
|
||||||
|
type FleetRow = {
|
||||||
|
code: string;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Official fleet: 1100 wagons, ER0001–ER1100, contiguous across 10 types. */
|
||||||
|
const FLEET: FleetRow[] = [
|
||||||
|
{ code: 'PW2', start: 1, end: 220, count: 220 },
|
||||||
|
{ code: 'CW4', start: 221, end: 330, count: 110 },
|
||||||
|
{ code: 'CW3', start: 331, end: 350, count: 20 },
|
||||||
|
{ code: 'KW2', start: 351, end: 370, count: 20 },
|
||||||
|
{ code: 'KW3', start: 371, end: 390, count: 20 },
|
||||||
|
{ code: 'NW5', start: 391, end: 940, count: 550 },
|
||||||
|
{ code: 'BW1', start: 941, end: 950, count: 10 },
|
||||||
|
{ code: 'GW2', start: 951, end: 1060, count: 110 },
|
||||||
|
{ code: 'NW6', start: 1061, end: 1080, count: 20 },
|
||||||
|
{ code: 'NW7', start: 1081, end: 1100, count: 20 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const wagonNumber = (sequence: number) => `ER${String(sequence).padStart(4, '0')}`;
|
||||||
|
|
||||||
|
export class SeedEdrWagonFleetErNumbering2260000000000 implements MigrationInterface {
|
||||||
|
name = 'SeedEdrWagonFleetErNumbering2260000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// Full replacement: the ER range is the fleet of record, so any wagon
|
||||||
|
// outside it is stale seed data. Safe to hard-delete — containers and
|
||||||
|
// train_set_wagons null their link, wagon_movements cascade.
|
||||||
|
await queryRunner.query(`DELETE FROM freight.wagons;`);
|
||||||
|
|
||||||
|
// Wagon.wagonNumber declares `unique: true`, but some environments never got
|
||||||
|
// the constraint. Repair it here — the table is empty at this point, so the
|
||||||
|
// index build cannot fail on pre-existing duplicates.
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS wagons_wagon_number_key
|
||||||
|
ON freight.wagons (wagon_number);
|
||||||
|
`);
|
||||||
|
|
||||||
|
for (const row of FLEET) {
|
||||||
|
if (row.end - row.start + 1 !== row.count) {
|
||||||
|
throw new Error(`wagon_range_mismatch:${row.code}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [typeRecord] = await queryRunner.query(
|
||||||
|
`SELECT id FROM freight.wagon_types WHERE code = $1 AND deleted_at IS NULL LIMIT 1;`,
|
||||||
|
[row.code],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!typeRecord?.id) {
|
||||||
|
throw new Error(`wagon_type_missing:${row.code}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// generate_series builds the range server-side — one round trip per type
|
||||||
|
// instead of 1100 individual INSERTs. No ON CONFLICT clause: every wagon
|
||||||
|
// was deleted above, so a plain INSERT cannot collide, and the clause would
|
||||||
|
// otherwise hard-require a unique index this table lacks on some envs.
|
||||||
|
await queryRunner.query(
|
||||||
|
`
|
||||||
|
INSERT INTO freight.wagons (
|
||||||
|
wagon_number,
|
||||||
|
wagon_type_id,
|
||||||
|
status,
|
||||||
|
current_yard_id,
|
||||||
|
train_id,
|
||||||
|
sequence_number,
|
||||||
|
notes,
|
||||||
|
train_set_wagon_id,
|
||||||
|
current_train_schedule_id
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'ER' || LPAD(seq::text, 4, '0'),
|
||||||
|
$1::uuid,
|
||||||
|
'AVAILABLE',
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
NULL
|
||||||
|
FROM generate_series($2::int, $3::int) AS seq;
|
||||||
|
`,
|
||||||
|
[typeRecord.id, row.start, row.end],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`DELETE FROM freight.wagons WHERE wagon_number BETWEEN $1 AND $2;`,
|
||||||
|
[wagonNumber(FLEET[0].start), wagonNumber(FLEET[FLEET.length - 1].end)],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -913,9 +913,9 @@ export class BillingService {
|
|||||||
dueAt,
|
dueAt,
|
||||||
...(issuing
|
...(issuing
|
||||||
? {
|
? {
|
||||||
status: Freight.InvoiceStatus.Pending,
|
status: Freight.InvoiceStatus.Pending,
|
||||||
issuedAt: invoice.issuedAt ?? new Date(),
|
issuedAt: invoice.issuedAt ?? new Date(),
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
};
|
};
|
||||||
await mg.update(Invoice, { id: invoice.id }, patch);
|
await mg.update(Invoice, { id: invoice.id }, patch);
|
||||||
@@ -1032,20 +1032,20 @@ export class BillingService {
|
|||||||
.getRepository(Invoice)
|
.getRepository(Invoice)
|
||||||
.update({ id: invoice.id }, { paymentId: result.intentId });
|
.update({ id: invoice.id }, { paymentId: result.intentId });
|
||||||
|
|
||||||
// // DEMO: manually fire the gateway `payment.succeeded` callback here, without
|
// DEMO: manually fire the gateway `payment.succeeded` callback here, without
|
||||||
// // waiting for real gateway settlement. Runs AFTER the paymentId link above so
|
// waiting for real gateway settlement. Runs AFTER the paymentId link above so
|
||||||
// // `handlePaymentEvent → settleByPaymentId` can correlate the invoice. TODO:
|
// `handlePaymentEvent → settleByPaymentId` can correlate the invoice. TODO:
|
||||||
// // remove — real settlement flips this via the `${source}.invoice.paid` handler.
|
// remove — real settlement flips this via the `${source}.invoice.paid` handler.
|
||||||
// if (!result.immediateSuccess) {
|
if (!result.immediateSuccess) {
|
||||||
// await this.payment.handlePaymentEvent({
|
await this.payment.handlePaymentEvent({
|
||||||
// eventType: "payment.succeeded",
|
eventType: "payment.succeeded",
|
||||||
// eventId: `demo-${result.intentId}`,
|
eventId: `demo-${result.intentId}`,
|
||||||
// referenceId: invoice.sourceId,
|
referenceId: invoice.sourceId,
|
||||||
// intentId: result.intentId,
|
intentId: result.intentId,
|
||||||
// providerTxnId: result.providerTxnId,
|
providerTxnId: result.providerTxnId,
|
||||||
// paidAt: (result.paidAt ?? new Date()).toISOString(),
|
paidAt: (result.paidAt ?? new Date()).toISOString(),
|
||||||
// });
|
});
|
||||||
// }
|
}
|
||||||
|
|
||||||
if (result.immediateSuccess) {
|
if (result.immediateSuccess) {
|
||||||
await this.settleByPaymentId(
|
await this.settleByPaymentId(
|
||||||
|
|||||||
@@ -204,17 +204,27 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
|||||||
private async attachClearancePhases(contracts: Contract[]): Promise<void> {
|
private async attachClearancePhases(contracts: Contract[]): Promise<void> {
|
||||||
if (contracts.length === 0) return;
|
if (contracts.length === 0) return;
|
||||||
const ids = contracts.map((c) => c.id);
|
const ids = contracts.map((c) => c.id);
|
||||||
const rows: Array<{ contract_id: string; current_phase: string | null }> =
|
const rows: Array<{
|
||||||
await this.dataSource.query(
|
contract_id: string;
|
||||||
`SELECT DISTINCT ON (contract_id) contract_id, current_phase
|
current_phase: string | null;
|
||||||
FROM freight.contract_clearance_cycles
|
booking_id: string | null;
|
||||||
WHERE contract_id = ANY($1)
|
booking_status: string | null;
|
||||||
ORDER BY contract_id, cycle_number DESC`,
|
}> = await this.dataSource.query(
|
||||||
[ids],
|
`SELECT DISTINCT ON (ccc.contract_id)
|
||||||
);
|
ccc.contract_id, ccc.current_phase,
|
||||||
const byContract = new Map(rows.map((r) => [r.contract_id, r.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) {
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -312,6 +312,14 @@ export class Contract extends BaseEntity {
|
|||||||
*/
|
*/
|
||||||
clearancePhase?: string | null;
|
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
|
* Body of the most recent CHANGES_REQUESTED review note, attached by
|
||||||
* ContractsService.findById so the portal can show the customer what staff
|
* ContractsService.findById so the portal can show the customer what staff
|
||||||
|
|||||||
@@ -1,16 +1,36 @@
|
|||||||
import { IsArray, IsOptional, IsString, IsUUID, ValidateNested } from 'class-validator';
|
import {
|
||||||
|
ArrayMaxSize,
|
||||||
|
ArrayUnique,
|
||||||
|
IsArray,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
ValidateNested,
|
||||||
|
} from 'class-validator';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
export class LastMileVehicleInput {
|
export class LastMileVehicleInput {
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
vehicleId!: string;
|
vehicleId!: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Containers this truck carries: one 40ft, or up to two 20ft. Omit for bulk
|
||||||
|
* (the truck hauls loose tonnage and is weighed out on exit).
|
||||||
|
*/
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMaxSize(2)
|
||||||
|
@ArrayUnique()
|
||||||
|
@IsString({ each: true })
|
||||||
|
containerNumbers?: string[];
|
||||||
|
|
||||||
|
/** @deprecated Single-container form — use `containerNumbers`. Still accepted. */
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
containerNumber?: string;
|
containerNumber?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Replace the full set of vehicles (with their container numbers) on a delivery. */
|
/** Replace the full set of vehicles (with their containers) on a delivery. */
|
||||||
export class SetVehiclesDto {
|
export class SetVehiclesDto {
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@ValidateNested({ each: true })
|
@ValidateNested({ each: true })
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { BaseEntity } from '@edr/api-common';
|
import { BaseEntity } from '@edr/api-common';
|
||||||
import { Column, Entity, Index, JoinColumn, ManyToOne, Unique } from 'typeorm';
|
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, Unique } from 'typeorm';
|
||||||
|
|
||||||
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
|
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
|
||||||
import { LastMile } from './last-mile.entity';
|
import { LastMile } from './last-mile.entity';
|
||||||
|
import { LastMileVehicleContainer } from './last-mile-vehicle-container.entity';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One row per vehicle assigned to a last-mile delivery. A delivery can be
|
* One row per vehicle assigned to a last-mile delivery. A delivery can be
|
||||||
@@ -28,12 +29,34 @@ export class LastMileVehicleAssignment extends BaseEntity {
|
|||||||
@JoinColumn({ name: 'vehicle_id' })
|
@JoinColumn({ name: 'vehicle_id' })
|
||||||
vehicle?: Vehicle;
|
vehicle?: Vehicle;
|
||||||
|
|
||||||
/** Container this truck carries — auto-filled from the booking's container
|
/** Legacy single container this truck carries. Kept in sync with the FIRST
|
||||||
* number when known, else entered manually at assignment time. */
|
* entry of `containers` for backward compatibility — a truck can hold 1x40ft
|
||||||
|
* or 2x20ft, so `containers` is the authoritative list. */
|
||||||
@Column({ name: 'container_number', type: 'varchar', nullable: true })
|
@Column({ name: 'container_number', type: 'varchar', nullable: true })
|
||||||
containerNumber?: string | null;
|
containerNumber?: string | null;
|
||||||
|
|
||||||
|
/** Containers riding this truck (1x40ft, or up to 2x20ft). */
|
||||||
|
@OneToMany(() => LastMileVehicleContainer, (c) => c.assignment, { cascade: true })
|
||||||
|
containers?: LastMileVehicleContainer[];
|
||||||
|
|
||||||
/** Actual distance driven by this truck (km), entered per vehicle. */
|
/** Actual distance driven by this truck (km), entered per vehicle. */
|
||||||
@Column({ name: 'distance_km', type: 'numeric', precision: 10, scale: 2, nullable: true })
|
@Column({ name: 'distance_km', type: 'numeric', precision: 10, scale: 2, nullable: true })
|
||||||
distanceKm?: number | null;
|
distanceKm?: number | null;
|
||||||
|
|
||||||
|
/** This truck reached the warehouse (stamped by the arrival weighing step). */
|
||||||
|
@Column({ name: 'arrived_at', type: 'timestamptz', nullable: true })
|
||||||
|
arrivedAt?: Date | null;
|
||||||
|
|
||||||
|
/** This truck left the warehouse (stamped by the exit weighing step). */
|
||||||
|
@Column({ name: 'departed_at', type: 'timestamptz', nullable: true })
|
||||||
|
departedAt?: Date | null;
|
||||||
|
|
||||||
|
/** Weighed gross on exit, in TONNES (not kg — see the migration note). */
|
||||||
|
@Column({ name: 'gross_weight_tons', type: 'numeric', precision: 14, scale: 3, nullable: true })
|
||||||
|
grossWeightTons?: number | null;
|
||||||
|
|
||||||
|
/** Cargo actually taken by this truck (gross − tare), in TONNES. Drives the
|
||||||
|
* bulk drawdown: remaining = booking VGM − SUM(net) over departed trucks. */
|
||||||
|
@Column({ name: 'net_weight_tons', type: 'numeric', precision: 14, scale: 3, nullable: true })
|
||||||
|
netWeightTons?: number | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||||
|
|
||||||
|
import { LastMileVehicleAssignment } from './last-mile-vehicle-assignment.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A container riding a specific EDR last-mile truck. A truck carries 1x40ft OR
|
||||||
|
* 2x20ft, so the assignment needs more than the single legacy `container_number`
|
||||||
|
* scalar. Mirrors the self-haul `customer_truck_containers` child table.
|
||||||
|
*/
|
||||||
|
@Entity({ schema: 'freight', name: 'last_mile_vehicle_containers' })
|
||||||
|
@Index(['assignmentId'])
|
||||||
|
export class LastMileVehicleContainer extends BaseEntity {
|
||||||
|
@Column({ name: 'assignment_id', type: 'uuid' })
|
||||||
|
assignmentId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => LastMileVehicleAssignment, (a) => a.containers, {
|
||||||
|
nullable: false,
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
})
|
||||||
|
@JoinColumn({ name: 'assignment_id' })
|
||||||
|
assignment?: LastMileVehicleAssignment;
|
||||||
|
|
||||||
|
/** Denormalised for the "one container, one truck per delivery" unique index. */
|
||||||
|
@Column({ name: 'last_mile_id', type: 'uuid' })
|
||||||
|
lastMileId!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'container_number', type: 'varchar', length: 32 })
|
||||||
|
containerNumber!: string;
|
||||||
|
}
|
||||||
@@ -73,6 +73,12 @@ export class LastMileController {
|
|||||||
return this.lastMileService.arrivalTrucksForBooking(bookingId);
|
return this.lastMileService.arrivalTrucksForBooking(bookingId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('booking/:bookingId/remaining-tons')
|
||||||
|
@ApiOperation({ summary: 'Bulk drawdown: tonnage still to be hauled (total − departed trucks)' })
|
||||||
|
remainingTons(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||||
|
return this.lastMileService.remainingTonsForBooking(bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('accept/:reference')
|
@Post('accept/:reference')
|
||||||
@BookingStaff(FREIGHT_PERMS.lastMile.accept)
|
@BookingStaff(FREIGHT_PERMS.lastMile.accept)
|
||||||
@ApiOperation({ summary: 'Accept a paid booking and create a last-mile leg' })
|
@ApiOperation({ summary: 'Accept a paid booking and create a last-mile leg' })
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { VehiclesModule } from '../vehicles/vehicles.module';
|
|||||||
import { LastMile } from './entities/last-mile.entity';
|
import { LastMile } from './entities/last-mile.entity';
|
||||||
import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity';
|
import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity';
|
||||||
import { LastMileVehicleAssignment } from './entities/last-mile-vehicle-assignment.entity';
|
import { LastMileVehicleAssignment } from './entities/last-mile-vehicle-assignment.entity';
|
||||||
|
import { LastMileVehicleContainer } from './entities/last-mile-vehicle-container.entity';
|
||||||
import { LastMileController } from './last-mile.controller';
|
import { LastMileController } from './last-mile.controller';
|
||||||
import { LastMileInvoiceService } from './last-mile-invoice.service';
|
import { LastMileInvoiceService } from './last-mile-invoice.service';
|
||||||
import { LastMileRepository } from './last-mile.repository';
|
import { LastMileRepository } from './last-mile.repository';
|
||||||
@@ -17,7 +18,12 @@ import { LastMileService } from './last-mile.service';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([LastMile, LastMileContainerAllocation, LastMileVehicleAssignment]),
|
TypeOrmModule.forFeature([
|
||||||
|
LastMile,
|
||||||
|
LastMileContainerAllocation,
|
||||||
|
LastMileVehicleAssignment,
|
||||||
|
LastMileVehicleContainer,
|
||||||
|
]),
|
||||||
BillingModule,
|
BillingModule,
|
||||||
forwardRef(() => BookingsModule),
|
forwardRef(() => BookingsModule),
|
||||||
VehiclesModule,
|
VehiclesModule,
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { DataSource, FindOptionsWhere, In, IsNull, Not } from 'typeorm';
|
import { DataSource, FindOptionsWhere, In, IsNull, Not } from 'typeorm';
|
||||||
|
|
||||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||||
@@ -12,6 +18,7 @@ import { RecordProofOfDeliveryDto } from './dto/record-proof-of-delivery.dto';
|
|||||||
import { LastMile, LastMileStatus } from './entities/last-mile.entity';
|
import { LastMile, LastMileStatus } from './entities/last-mile.entity';
|
||||||
import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity';
|
import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity';
|
||||||
import { LastMileVehicleAssignment } from './entities/last-mile-vehicle-assignment.entity';
|
import { LastMileVehicleAssignment } from './entities/last-mile-vehicle-assignment.entity';
|
||||||
|
import { LastMileVehicleContainer } from './entities/last-mile-vehicle-container.entity';
|
||||||
import { LastMileRepository } from './last-mile.repository';
|
import { LastMileRepository } from './last-mile.repository';
|
||||||
import { FilesService } from '../files/files.service';
|
import { FilesService } from '../files/files.service';
|
||||||
import { BillingService, InvoiceEventPayload } from '../billing/billing.service';
|
import { BillingService, InvoiceEventPayload } from '../billing/billing.service';
|
||||||
@@ -175,7 +182,7 @@ export class LastMileService {
|
|||||||
relations: {
|
relations: {
|
||||||
booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true, bookingContainers: { containerType: true, units: true } },
|
booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true, bookingContainers: { containerType: true, units: true } },
|
||||||
vehicle: true,
|
vehicle: true,
|
||||||
vehicleAssignments: { vehicle: true },
|
vehicleAssignments: { vehicle: true, containers: true },
|
||||||
},
|
},
|
||||||
order: { [sortBy]: sortOrder },
|
order: { [sortBy]: sortOrder },
|
||||||
skip: (page - 1) * pageSize,
|
skip: (page - 1) * pageSize,
|
||||||
@@ -200,7 +207,7 @@ export class LastMileService {
|
|||||||
relations: {
|
relations: {
|
||||||
booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true, bookingContainers: { containerType: true, units: true } },
|
booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true, bookingContainers: { containerType: true, units: true } },
|
||||||
vehicle: true,
|
vehicle: true,
|
||||||
vehicleAssignments: { vehicle: true },
|
vehicleAssignments: { vehicle: true, containers: true },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -277,7 +284,7 @@ export class LastMileService {
|
|||||||
> {
|
> {
|
||||||
const [lm] = await this.lastMileRepository.findAll({
|
const [lm] = await this.lastMileRepository.findAll({
|
||||||
where: { bookingId },
|
where: { bookingId },
|
||||||
relations: { vehicle: true, vehicleAssignments: { vehicle: true } },
|
relations: { vehicle: true, vehicleAssignments: { vehicle: true, containers: true } },
|
||||||
take: 1,
|
take: 1,
|
||||||
});
|
});
|
||||||
if (!lm) return [];
|
if (!lm) return [];
|
||||||
@@ -552,22 +559,164 @@ export class LastMileService {
|
|||||||
* for each added/removed vehicle. The first vehicle is mirrored onto the legacy
|
* for each added/removed vehicle. The first vehicle is mirrored onto the legacy
|
||||||
* `vehicleId` column for back-compat with single-vehicle readers.
|
* `vehicleId` column for back-compat with single-vehicle readers.
|
||||||
*/
|
*/
|
||||||
|
/** Container numbers on the booking (upper-cased). */
|
||||||
|
private async bookingContainerNumbers(bookingId: string): Promise<string[]> {
|
||||||
|
const rows: Array<{ containerNumber: string }> = await this.dataSource.query(
|
||||||
|
`SELECT bcu.container_number AS "containerNumber"
|
||||||
|
FROM freight.booking_container_units bcu
|
||||||
|
JOIN freight.booking_container bc
|
||||||
|
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||||||
|
WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL`,
|
||||||
|
[bookingId],
|
||||||
|
);
|
||||||
|
return rows.map((r) => r.containerNumber.trim().toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Contract container sizes (e.g. "20ft" / "40ft") for the given numbers. */
|
||||||
|
private async containerSizes(bookingId: string, numbers: string[]): Promise<string[]> {
|
||||||
|
if (!numbers.length) return [];
|
||||||
|
const rows: Array<{ size: string | null }> = await this.dataSource.query(
|
||||||
|
`SELECT bc.container_size AS "size"
|
||||||
|
FROM freight.booking_container_units bcu
|
||||||
|
JOIN freight.booking_container bc
|
||||||
|
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||||||
|
WHERE bc.booking_id = $1
|
||||||
|
AND UPPER(bcu.container_number) = ANY($2)
|
||||||
|
AND bcu.deleted_at IS NULL`,
|
||||||
|
[bookingId, numbers],
|
||||||
|
);
|
||||||
|
return rows.map((r) => (r.size ?? '').trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk drawdown: how much of the booking's tonnage is still to be hauled —
|
||||||
|
* the booking VGM total minus the net weighed off every EDR truck that has
|
||||||
|
* already left. Both sides are tonnes, so no conversion.
|
||||||
|
*/
|
||||||
|
async remainingTonsForBooking(bookingId: string): Promise<{
|
||||||
|
totalTons: number;
|
||||||
|
hauledTons: number;
|
||||||
|
remainingTons: number;
|
||||||
|
complete: boolean;
|
||||||
|
}> {
|
||||||
|
const [row]: Array<{ totalTons: string | null; hauledTons: string | null }> =
|
||||||
|
await this.dataSource.query(
|
||||||
|
`SELECT COALESCE(b.cargo_total_weight_vgm, 0) AS "totalTons",
|
||||||
|
COALESCE((
|
||||||
|
SELECT SUM(va.net_weight_tons)
|
||||||
|
FROM freight.last_mile_vehicle_assignments va
|
||||||
|
JOIN freight.last_mile lm
|
||||||
|
ON lm.id = va.last_mile_id AND lm.deleted_at IS NULL
|
||||||
|
WHERE lm.booking_id = b.id
|
||||||
|
AND va.deleted_at IS NULL
|
||||||
|
AND va.departed_at IS NOT NULL
|
||||||
|
), 0) AS "hauledTons"
|
||||||
|
FROM freight.bookings b
|
||||||
|
WHERE b.id = $1 AND b.deleted_at IS NULL`,
|
||||||
|
[bookingId],
|
||||||
|
);
|
||||||
|
const totalTons = Number(row?.totalTons ?? 0);
|
||||||
|
const hauledTons = Number(row?.hauledTons ?? 0);
|
||||||
|
const remainingTons = Math.max(0, Math.round((totalTons - hauledTons) * 1000) / 1000);
|
||||||
|
return { totalTons, hauledTons, remainingTons, complete: totalTons > 0 && remainingTons <= 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Truck capacity rules for a last-mile delivery.
|
||||||
|
* - CONTAINER: a truck carries ONE 40ft or up to TWO 20ft; every container
|
||||||
|
* must belong to the booking and ride exactly one truck; never more trucks
|
||||||
|
* than containers.
|
||||||
|
* - BULK: no containers — trucks haul loose tonnage, so the only limit is
|
||||||
|
* that there is tonnage left to haul.
|
||||||
|
*/
|
||||||
|
private async assertVehicleLoads(
|
||||||
|
bookingId: string,
|
||||||
|
desired: string[],
|
||||||
|
loads: Map<string, string[]>,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!desired.length) return;
|
||||||
|
|
||||||
|
const [booking]: Array<{ freightType: string | null }> = await this.dataSource.query(
|
||||||
|
`SELECT freight_type AS "freightType"
|
||||||
|
FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
|
||||||
|
[bookingId],
|
||||||
|
);
|
||||||
|
if ((booking?.freightType ?? '').toUpperCase() === 'BULK') {
|
||||||
|
const { remainingTons, totalTons } = await this.remainingTonsForBooking(bookingId);
|
||||||
|
if (totalTons > 0 && remainingTons <= 0) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'This bulk booking is fully hauled — no tonnage left to assign trucks for',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
|
||||||
|
if (!bookingNumbers.length) return; // nothing to validate against
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const vehicleId of desired) {
|
||||||
|
const load = loads.get(vehicleId) ?? [];
|
||||||
|
if (load.length > 2) {
|
||||||
|
throw new BadRequestException('A truck carries at most 2 containers');
|
||||||
|
}
|
||||||
|
for (const n of load) {
|
||||||
|
if (!bookingNumbers.includes(n)) {
|
||||||
|
throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
|
||||||
|
}
|
||||||
|
if (seen.has(n)) {
|
||||||
|
throw new ConflictException(`Container ${n} is already assigned to another truck`);
|
||||||
|
}
|
||||||
|
seen.add(n);
|
||||||
|
}
|
||||||
|
// A 40ft container fills the truck; only two 20ft share one.
|
||||||
|
if (load.length > 1) {
|
||||||
|
const sizes = await this.containerSizes(bookingId, load);
|
||||||
|
if (sizes.some((s) => s.includes('40'))) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'A 40ft container fills the truck — assign only 1 container to this truck',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (desired.length > bookingNumbers.length) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Cannot assign more trucks than containers — this booking has ${bookingNumbers.length} container(s) and ${desired.length} truck(s) requested.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async setVehicles(
|
async setVehicles(
|
||||||
id: string,
|
id: string,
|
||||||
inputs: Array<{ vehicleId: string; containerNumber?: string | null }>,
|
inputs: Array<{
|
||||||
|
vehicleId: string;
|
||||||
|
containerNumbers?: string[] | null;
|
||||||
|
containerNumber?: string | null;
|
||||||
|
}>,
|
||||||
): Promise<LastMile> {
|
): Promise<LastMile> {
|
||||||
const existing = await this.findById(id);
|
const existing = await this.findById(id);
|
||||||
// Dedupe by vehicleId, keeping the container number; preserve order.
|
// Dedupe by vehicleId, keeping the container load; preserve order. Accepts
|
||||||
const desiredMap = new Map<string, string | null>();
|
// the legacy single `containerNumber` as a one-element load.
|
||||||
|
const desiredMap = new Map<string, string[]>();
|
||||||
for (const inp of inputs) {
|
for (const inp of inputs) {
|
||||||
if (inp.vehicleId) desiredMap.set(inp.vehicleId, inp.containerNumber ?? null);
|
if (!inp.vehicleId) continue;
|
||||||
|
const load = (inp.containerNumbers ?? (inp.containerNumber ? [inp.containerNumber] : []))
|
||||||
|
.map((n) => String(n).trim().toUpperCase())
|
||||||
|
.filter(Boolean);
|
||||||
|
desiredMap.set(inp.vehicleId, load);
|
||||||
}
|
}
|
||||||
const desired = [...desiredMap.keys()];
|
const desired = [...desiredMap.keys()];
|
||||||
const desiredSet = new Set(desired);
|
const desiredSet = new Set(desired);
|
||||||
|
|
||||||
|
// Capacity + membership rules (a truck holds one 40ft or two 20ft; bulk
|
||||||
|
// hauls tonnage until the booking is drawn down).
|
||||||
|
await this.assertVehicleLoads(existing.bookingId, desired, desiredMap);
|
||||||
|
|
||||||
const manager = this.dataSource.manager;
|
const manager = this.dataSource.manager;
|
||||||
const current = await manager.find(LastMileVehicleAssignment, {
|
const current = await manager.find(LastMileVehicleAssignment, {
|
||||||
where: { lastMileId: id },
|
where: { lastMileId: id },
|
||||||
|
relations: { containers: true },
|
||||||
});
|
});
|
||||||
const junctionSet = new Set(current.map((a) => a.vehicleId));
|
const junctionSet = new Set(current.map((a) => a.vehicleId));
|
||||||
// Fold the legacy vehicleId into the release set — a vehicle assigned via the
|
// Fold the legacy vehicleId into the release set — a vehicle assigned via the
|
||||||
@@ -588,33 +737,58 @@ export class LastMileService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Vehicles that stay but whose container number changed.
|
// Vehicles that stay but whose container load changed (order-insensitive).
|
||||||
|
const loadKey = (list: string[]) => [...list].sort().join('|');
|
||||||
|
const currentLoad = (a: LastMileVehicleAssignment) =>
|
||||||
|
(a.containers ?? []).map((c) => c.containerNumber.trim().toUpperCase());
|
||||||
const changed = current.filter(
|
const changed = current.filter(
|
||||||
(a) =>
|
(a) =>
|
||||||
desiredMap.has(a.vehicleId) &&
|
desiredMap.has(a.vehicleId) &&
|
||||||
(a.containerNumber ?? null) !== (desiredMap.get(a.vehicleId) ?? null),
|
loadKey(desiredMap.get(a.vehicleId) ?? []) !== loadKey(currentLoad(a)),
|
||||||
);
|
);
|
||||||
|
|
||||||
await this.dataSource.transaction(async (tx) => {
|
await this.dataSource.transaction(async (tx) => {
|
||||||
if (removed.length) {
|
if (removed.length) {
|
||||||
|
// Child containers cascade on delete.
|
||||||
await tx.delete(LastMileVehicleAssignment, {
|
await tx.delete(LastMileVehicleAssignment, {
|
||||||
lastMileId: id,
|
lastMileId: id,
|
||||||
vehicleId: In(removed),
|
vehicleId: In(removed),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
for (const vehicleId of added) {
|
for (const vehicleId of added) {
|
||||||
await tx.insert(LastMileVehicleAssignment, {
|
const load = desiredMap.get(vehicleId) ?? [];
|
||||||
|
const inserted = await tx.insert(LastMileVehicleAssignment, {
|
||||||
lastMileId: id,
|
lastMileId: id,
|
||||||
vehicleId,
|
vehicleId,
|
||||||
containerNumber: desiredMap.get(vehicleId) ?? null,
|
// Legacy scalar stays in sync with the first container.
|
||||||
|
containerNumber: load[0] ?? null,
|
||||||
});
|
});
|
||||||
|
const assignmentId = inserted.identifiers[0]?.id as string | undefined;
|
||||||
|
if (assignmentId && load.length) {
|
||||||
|
await tx.insert(
|
||||||
|
LastMileVehicleContainer,
|
||||||
|
load.map((containerNumber) => ({ assignmentId, lastMileId: id, containerNumber })),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for (const row of changed) {
|
for (const row of changed) {
|
||||||
|
const load = desiredMap.get(row.vehicleId) ?? [];
|
||||||
await tx.update(
|
await tx.update(
|
||||||
LastMileVehicleAssignment,
|
LastMileVehicleAssignment,
|
||||||
{ lastMileId: id, vehicleId: row.vehicleId },
|
{ lastMileId: id, vehicleId: row.vehicleId },
|
||||||
{ containerNumber: desiredMap.get(row.vehicleId) ?? null },
|
{ containerNumber: load[0] ?? null },
|
||||||
);
|
);
|
||||||
|
await tx.delete(LastMileVehicleContainer, { assignmentId: row.id });
|
||||||
|
if (load.length) {
|
||||||
|
await tx.insert(
|
||||||
|
LastMileVehicleContainer,
|
||||||
|
load.map((containerNumber) => ({
|
||||||
|
assignmentId: row.id,
|
||||||
|
lastMileId: id,
|
||||||
|
containerNumber,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -49,6 +49,7 @@ import { AvailableDaysForCargoQueryDto } from "./dto/available-days-for-cargo-qu
|
|||||||
import { UpdateTrainSchedulingGlobalRulesDto } from "./dto/update-train-scheduling-global-rules.dto";
|
import { UpdateTrainSchedulingGlobalRulesDto } from "./dto/update-train-scheduling-global-rules.dto";
|
||||||
import { UpdateScheduleWindowRuleDto } from "./dto/update-schedule-window-rule.dto";
|
import { UpdateScheduleWindowRuleDto } from "./dto/update-schedule-window-rule.dto";
|
||||||
import { UpdateScheduleDateDto } from "./dto/update-schedule-date.dto";
|
import { UpdateScheduleDateDto } from "./dto/update-schedule-date.dto";
|
||||||
|
import { MaintenanceRescheduleDto } from "./dto/maintenance-reschedule.dto";
|
||||||
import { TrainSchedulingService } from "./train-scheduling.service";
|
import { TrainSchedulingService } from "./train-scheduling.service";
|
||||||
import { BookingBatchService } from "./booking-batch.service";
|
import { BookingBatchService } from "./booking-batch.service";
|
||||||
import { BookingJourneyService } from "./booking-journey.service";
|
import { BookingJourneyService } from "./booking-journey.service";
|
||||||
@@ -711,6 +712,20 @@ export class TrainSchedulingController {
|
|||||||
return this.trainSchedulingService.getContainerTrainScheduleById(id);
|
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")
|
@Post("schedules/:id/doc-review-complete")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingManage()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ import {
|
|||||||
import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduling-global-rules.dto';
|
import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduling-global-rules.dto';
|
||||||
import { UpdateScheduleWindowRuleDto } from './dto/update-schedule-window-rule.dto';
|
import { UpdateScheduleWindowRuleDto } from './dto/update-schedule-window-rule.dto';
|
||||||
import { UpdateScheduleDateDto } from './dto/update-schedule-date.dto';
|
import { UpdateScheduleDateDto } from './dto/update-schedule-date.dto';
|
||||||
|
import { MaintenanceRescheduleDto } from './dto/maintenance-reschedule.dto';
|
||||||
import { type BookingWindowConfig } from './booking-window.config';
|
import { type BookingWindowConfig } from './booking-window.config';
|
||||||
import { BookingWindowGateway } from './booking-window.gateway';
|
import { BookingWindowGateway } from './booking-window.gateway';
|
||||||
import { BookingNotifierService } from './booking-notifier.service';
|
import { BookingNotifierService } from './booking-notifier.service';
|
||||||
@@ -864,6 +865,121 @@ export class TrainSchedulingService {
|
|||||||
return fresh ?? schedule;
|
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
|
* Re-derive windowOpensAt/windowClosesAt for schedules whose booking window has
|
||||||
* not opened yet (windowPhase === 'PRE_WINDOW', still Draft/Scheduled, departure
|
* not opened yet (windowPhase === 'PRE_WINDOW', still Draft/Scheduled, departure
|
||||||
@@ -4717,6 +4833,7 @@ export class TrainSchedulingService {
|
|||||||
createdAt: schedule.createdAt ?? null,
|
createdAt: schedule.createdAt ?? null,
|
||||||
scheduleDate: schedule.scheduledDepartureDate,
|
scheduleDate: schedule.scheduledDepartureDate,
|
||||||
trainNumber: schedule.trainNumber ?? null,
|
trainNumber: schedule.trainNumber ?? null,
|
||||||
|
direction: schedule.direction ?? null,
|
||||||
routeName: schedule.route ? formatRouteLabel(schedule.route) : null,
|
routeName: schedule.route ? formatRouteLabel(schedule.route) : null,
|
||||||
origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null,
|
origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null,
|
||||||
destination:
|
destination:
|
||||||
@@ -5942,6 +6059,64 @@ export class TrainSchedulingService {
|
|||||||
(snapshot?.slots ?? []).map((slot) => [slot.trainSetWagonId, slot]),
|
(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 {
|
return {
|
||||||
id: schedule.id,
|
id: schedule.id,
|
||||||
reference: schedule.reference ?? null,
|
reference: schedule.reference ?? null,
|
||||||
@@ -6109,7 +6284,8 @@ export class TrainSchedulingService {
|
|||||||
: null,
|
: null,
|
||||||
})) ?? [],
|
})) ?? [],
|
||||||
};
|
};
|
||||||
}),
|
})
|
||||||
|
.concat(emptyConsistWagons),
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
bookings:
|
bookings:
|
||||||
|
|||||||
@@ -421,6 +421,20 @@ export class WarehouseInventoryController {
|
|||||||
return res.send(buffer);
|
return res.send(buffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('edr-truck-exit-paper/:assignmentId')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||||
|
@ApiOperation({ summary: 'Per-truck exit paper PDF for an EDR last-mile truck' })
|
||||||
|
async edrTruckExitPaper(
|
||||||
|
@Param('assignmentId', ParseUUIDPipe) assignmentId: string,
|
||||||
|
@Res() res: Response,
|
||||||
|
) {
|
||||||
|
const { filename, buffer } = await this.inventoryService.edrTruckExitPaper(assignmentId);
|
||||||
|
res.setHeader('Content-Type', 'application/pdf');
|
||||||
|
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
|
||||||
|
res.setHeader('Content-Length', buffer.length);
|
||||||
|
return res.send(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id/grn-document')
|
@Get(':id/grn-document')
|
||||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||||
@ApiOperation({ summary: 'View goods received note PDF' })
|
@ApiOperation({ summary: 'View goods received note PDF' })
|
||||||
|
|||||||
@@ -2827,6 +2827,16 @@ export class WarehouseInventoryService {
|
|||||||
: dto;
|
: dto;
|
||||||
const exitInspectionNote = this.buildExitInspectionNote(exitInspectionDto);
|
const exitInspectionNote = this.buildExitInspectionNote(exitInspectionDto);
|
||||||
|
|
||||||
|
// The load actually leaving on this truck, in TONNES (the weighing UI is in
|
||||||
|
// t). Null when the operator skipped weighing — containers may skip, bulk
|
||||||
|
// never does.
|
||||||
|
const grossTons = exitInspectionDto.grossWeight ?? null;
|
||||||
|
const tareTons = exitInspectionDto.tareWeight ?? null;
|
||||||
|
const netTons =
|
||||||
|
grossTons != null && tareTons != null
|
||||||
|
? Math.round((grossTons - tareTons) * 1000) / 1000
|
||||||
|
: (exitInspectionDto.netWeight ?? null);
|
||||||
|
|
||||||
await this.dataSource.transaction(async (manager) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
await manager.getRepository(WarehouseInventory).update(id, {
|
await manager.getRepository(WarehouseInventory).update(id, {
|
||||||
releaseDate,
|
releaseDate,
|
||||||
@@ -2854,6 +2864,23 @@ export class WarehouseInventoryService {
|
|||||||
// an EXPORT concept (set when a truck delivers into the port). Import
|
// an EXPORT concept (set when a truck delivers into the port). Import
|
||||||
// load + weight are captured on truck departure, not arrival.
|
// load + weight are captured on truck departure, not arrival.
|
||||||
}
|
}
|
||||||
|
// EDR last-mile: stamp THIS truck's arrival. Matched by plate rather than
|
||||||
|
// container so it works for bulk too (bulk trucks carry no container).
|
||||||
|
if (dto.truckPlateNumber?.trim()) {
|
||||||
|
await manager.query(
|
||||||
|
`UPDATE freight.last_mile_vehicle_assignments va
|
||||||
|
SET arrived_at = COALESCE(va.arrived_at, NOW()), updated_at = NOW()
|
||||||
|
FROM freight.last_mile lm, freight.vehicles v
|
||||||
|
WHERE va.last_mile_id = lm.id
|
||||||
|
AND lm.booking_id = $1
|
||||||
|
AND lm.deleted_at IS NULL
|
||||||
|
AND v.id = va.vehicle_id
|
||||||
|
AND (UPPER(v.power_plate_no) = UPPER($2) OR UPPER(v.plate_number) = UPPER($2))
|
||||||
|
AND va.arrived_at IS NULL
|
||||||
|
AND va.deleted_at IS NULL`,
|
||||||
|
[item.bookingId, dto.truckPlateNumber.trim()],
|
||||||
|
);
|
||||||
|
}
|
||||||
// Booking-level flag stamped on the FIRST truck arrival. The import
|
// Booking-level flag stamped on the FIRST truck arrival. The import
|
||||||
// handover is signed ONCE (before the first truck leaves), even though
|
// handover is signed ONCE (before the first truck leaves), even though
|
||||||
// trucks pick up per-container — COALESCE keeps the first timestamp.
|
// trucks pick up per-container — COALESCE keeps the first timestamp.
|
||||||
@@ -2874,6 +2901,34 @@ export class WarehouseInventoryService {
|
|||||||
await this.handover.ensureForArrivedTruck(item.bookingId, {}, manager);
|
await this.handover.ensureForArrivedTruck(item.bookingId, {}, manager);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (isTruckLeaving && item.bookingId && dto.truckPlateNumber?.trim()) {
|
||||||
|
// EDR last-mile: this truck is leaving — record its exit and the load it
|
||||||
|
// actually took. net_weight_tons drives the bulk drawdown (booking VGM
|
||||||
|
// minus everything already hauled away).
|
||||||
|
await manager.query(
|
||||||
|
`UPDATE freight.last_mile_vehicle_assignments va
|
||||||
|
SET departed_at = COALESCE($3::timestamptz, NOW()),
|
||||||
|
arrived_at = COALESCE(va.arrived_at, NOW()),
|
||||||
|
gross_weight_tons = $4,
|
||||||
|
net_weight_tons = $5,
|
||||||
|
updated_at = NOW()
|
||||||
|
FROM freight.last_mile lm, freight.vehicles v
|
||||||
|
WHERE va.last_mile_id = lm.id
|
||||||
|
AND lm.booking_id = $1
|
||||||
|
AND lm.deleted_at IS NULL
|
||||||
|
AND v.id = va.vehicle_id
|
||||||
|
AND (UPPER(v.power_plate_no) = UPPER($2) OR UPPER(v.plate_number) = UPPER($2))
|
||||||
|
AND va.departed_at IS NULL
|
||||||
|
AND va.deleted_at IS NULL`,
|
||||||
|
[
|
||||||
|
item.bookingId,
|
||||||
|
dto.truckPlateNumber.trim(),
|
||||||
|
dto.gateOutTime ?? null,
|
||||||
|
grossTons,
|
||||||
|
netTons,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
await this.activityLog.record(
|
await this.activityLog.record(
|
||||||
{
|
{
|
||||||
activityType: 'INVENTORY_RELEASED',
|
activityType: 'INVENTORY_RELEASED',
|
||||||
@@ -2892,9 +2947,56 @@ export class WarehouseInventoryService {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Tell the customer their truck has left — one hook covers BOTH self-haul and
|
||||||
|
// EDR last-mile, since release() is the single exit path for either. Outside
|
||||||
|
// the transaction and fire-and-forget: notifying must never fail the exit.
|
||||||
|
if (isTruckLeaving && item.bookingId) {
|
||||||
|
void this.notifyTruckDeparture(item.bookingId, dto.truckPlateNumber?.trim() ?? null, netTons);
|
||||||
|
}
|
||||||
|
|
||||||
return this.findById(id);
|
return this.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort truck-departure notification to the booking's company across
|
||||||
|
* every channel: in-app (portal inbox) + SMS + email. Never throws — a missing
|
||||||
|
* provider or contact must not break the exit flow.
|
||||||
|
*/
|
||||||
|
private async notifyTruckDeparture(
|
||||||
|
bookingId: string,
|
||||||
|
plateNumber: string | null,
|
||||||
|
netTons: number | null,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const [booking]: Array<{ companyId: string | null; reference: string | null }> =
|
||||||
|
await this.dataSource.query(
|
||||||
|
`SELECT company_id AS "companyId", reference
|
||||||
|
FROM freight.bookings
|
||||||
|
WHERE id = $1 AND deleted_at IS NULL`,
|
||||||
|
[bookingId],
|
||||||
|
);
|
||||||
|
if (!booking?.companyId) return;
|
||||||
|
const ref = booking.reference ?? bookingId;
|
||||||
|
const truck = plateNumber ? `Truck ${plateNumber}` : 'A truck';
|
||||||
|
const load = netTons != null && netTons > 0 ? ` carrying ${netTons} t` : '';
|
||||||
|
const body = `${truck} has left the warehouse for booking ${ref}${load}.`;
|
||||||
|
await this.inbox.notify({
|
||||||
|
recipients: { companyId: booking.companyId },
|
||||||
|
audience: NotificationAudience.PORTAL,
|
||||||
|
type: NotificationType.BOOKING_STATUS,
|
||||||
|
title: 'Truck left the warehouse',
|
||||||
|
body,
|
||||||
|
link: `/bookings/${bookingId}`,
|
||||||
|
data: { bookingId, plateNumber, netTons, action: 'TRUCK_LEFT' },
|
||||||
|
});
|
||||||
|
await sendCompanyChannels(this.dataSource, this.notifications, booking.companyId, body);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Truck-departure notify failed for ${bookingId}: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async releaseDocument(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
async releaseDocument(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||||
const [row] = await this.dataSource.query(
|
const [row] = await this.dataSource.query(
|
||||||
`SELECT inv.id,
|
`SELECT inv.id,
|
||||||
@@ -3219,6 +3321,75 @@ export class WarehouseInventoryService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exit paper for an EDR last-mile truck (one per truck, keyed on the vehicle
|
||||||
|
* assignment). Deliberately NOT gated on the handover: EDR handovers are
|
||||||
|
* generated at delivery — i.e. after the truck has already left — so there is
|
||||||
|
* nothing to sign at exit time. Warehouse-fee clearance still applies.
|
||||||
|
*/
|
||||||
|
async edrTruckExitPaper(assignmentId: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||||
|
const [truck] = await this.dataSource.query(
|
||||||
|
`SELECT lm.booking_id AS "bookingId",
|
||||||
|
COALESCE(v.power_plate_no, v.plate_number) AS "plateNumber",
|
||||||
|
COALESCE(
|
||||||
|
v.assigned_driver_name,
|
||||||
|
NULLIF(TRIM(CONCAT(d.first_name, ' ', d.last_name)), '')
|
||||||
|
) AS "driverName",
|
||||||
|
v.vehicle_type AS "truckType",
|
||||||
|
va.gross_weight_tons AS "grossWeightKg",
|
||||||
|
va.departed_at AS "departedAt",
|
||||||
|
b.reference AS "bookingReference",
|
||||||
|
company.name AS "customerName"
|
||||||
|
FROM freight.last_mile_vehicle_assignments va
|
||||||
|
JOIN freight.last_mile lm ON lm.id = va.last_mile_id AND lm.deleted_at IS NULL
|
||||||
|
JOIN freight.bookings b ON b.id = lm.booking_id AND b.deleted_at IS NULL
|
||||||
|
JOIN freight.vehicles v ON v.id = va.vehicle_id
|
||||||
|
LEFT JOIN freight.drivers d ON d.id = v.assigned_driver_id AND d.deleted_at IS NULL
|
||||||
|
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||||
|
WHERE va.id = $1 AND va.deleted_at IS NULL`,
|
||||||
|
[assignmentId],
|
||||||
|
);
|
||||||
|
if (!truck) throw new NotFoundException(`EDR truck assignment ${assignmentId} not found`);
|
||||||
|
|
||||||
|
const [inv]: Array<{ id: string }> = await this.dataSource.query(
|
||||||
|
`SELECT id FROM freight.warehouse_inventory
|
||||||
|
WHERE booking_id = $1 AND deleted_at IS NULL ORDER BY created_at LIMIT 1`,
|
||||||
|
[truck.bookingId],
|
||||||
|
);
|
||||||
|
if (inv?.id) await this.invoices.assertClearanceAllowed(inv.id);
|
||||||
|
|
||||||
|
// Bulk trucks carry no containers — the table is then empty and the paper
|
||||||
|
// stands on the weighed gross alone.
|
||||||
|
const containers: Array<{ containerNumber: string; goods: string | null }> =
|
||||||
|
await this.dataSource.query(
|
||||||
|
`SELECT vc.container_number AS "containerNumber",
|
||||||
|
COALESCE(ct.cargo_type_name, b.cargo_free_text) AS goods
|
||||||
|
FROM freight.last_mile_vehicle_containers vc
|
||||||
|
JOIN freight.last_mile lm ON lm.id = vc.last_mile_id AND lm.deleted_at IS NULL
|
||||||
|
JOIN freight.bookings b ON b.id = lm.booking_id
|
||||||
|
LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id
|
||||||
|
WHERE vc.assignment_id = $1 AND vc.deleted_at IS NULL
|
||||||
|
ORDER BY vc.container_number`,
|
||||||
|
[assignmentId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const html = this.buildTruckExitPaperHtml({
|
||||||
|
reference: `REL-${String(truck.bookingReference).replace(/^BK-?/i, '')}-${truck.plateNumber}`,
|
||||||
|
bookingReference: truck.bookingReference,
|
||||||
|
customerName: truck.customerName,
|
||||||
|
plateNumber: truck.plateNumber,
|
||||||
|
driverName: truck.driverName ?? '-',
|
||||||
|
truckType: truck.truckType ?? '-',
|
||||||
|
grossWeightKg: Number(truck.grossWeightKg ?? 0),
|
||||||
|
gateOut: truck.departedAt,
|
||||||
|
containers,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
filename: `exit-${String(truck.plateNumber).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
|
||||||
|
buffer: await this.releaseDocuments.renderDocumentHtml(html, 'Warehouse exit paper'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private buildTruckExitPaperHtml(data: {
|
private buildTruckExitPaperHtml(data: {
|
||||||
reference: string;
|
reference: string;
|
||||||
bookingReference: string;
|
bookingReference: string;
|
||||||
@@ -3734,20 +3905,30 @@ export class WarehouseInventoryService {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// EDR last-mile: the handover is per delivering truck. Resolve the
|
// EDR last-mile: the handover is per delivering truck. Resolve the
|
||||||
// vehicle that carried this item's container so each truck gets its own
|
// vehicle from the truck's own container list (the earlier lookup went
|
||||||
// handover (falls back to a booking-level one when unresolvable).
|
// through last_mile_container_allocations, which nothing ever writes —
|
||||||
|
// so truckPlate was always null and every booking collapsed to a single
|
||||||
|
// booking-level handover). Bulk has no container, so fall back to the
|
||||||
|
// delivery's single truck; a booking-level handover when unresolvable.
|
||||||
let truckPlate: string | null = null;
|
let truckPlate: string | null = null;
|
||||||
if (item.containerId) {
|
const [veh]: Array<{ plate: string | null }> = await manager.query(
|
||||||
const [veh]: Array<{ plate: string | null }> = await manager.query(
|
`SELECT COALESCE(v.power_plate_no, v.plate_number) AS plate
|
||||||
`SELECT COALESCE(v.power_plate_no, v.plate_number) AS plate
|
FROM freight.last_mile_vehicle_assignments va
|
||||||
FROM freight.last_mile_container_allocations lca
|
JOIN freight.last_mile lm
|
||||||
JOIN freight.vehicles v ON v.id = lca.vehicle_id
|
ON lm.id = va.last_mile_id AND lm.deleted_at IS NULL
|
||||||
WHERE lca.container_id = $1 AND lca.vehicle_id IS NOT NULL
|
JOIN freight.vehicles v ON v.id = va.vehicle_id
|
||||||
LIMIT 1`,
|
LEFT JOIN freight.last_mile_vehicle_containers vc
|
||||||
[item.containerId],
|
ON vc.assignment_id = va.id AND vc.deleted_at IS NULL
|
||||||
);
|
LEFT JOIN freight.containers cont
|
||||||
truckPlate = veh?.plate ?? null;
|
ON cont.container_number = vc.container_number AND cont.deleted_at IS NULL
|
||||||
}
|
WHERE lm.booking_id = $1
|
||||||
|
AND va.deleted_at IS NULL
|
||||||
|
AND ($2::uuid IS NULL OR cont.id = $2::uuid)
|
||||||
|
ORDER BY (cont.id IS NOT NULL) DESC, va.created_at ASC
|
||||||
|
LIMIT 1`,
|
||||||
|
[item.bookingId, item.containerId ?? null],
|
||||||
|
);
|
||||||
|
truckPlate = veh?.plate ?? null;
|
||||||
await this.handover.ensureAtDelivery(item.bookingId, { truckPlate }, manager);
|
await this.handover.ensureAtDelivery(item.bookingId, { truckPlate }, manager);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { AppDataSource } from '../data-source';
|
import { AppDataSource } from '../data-source';
|
||||||
import { SeedEdRWagonFleet1750400000000 } from '../migrations/1750400000000-SeedEdRWagonFleet';
|
import { SeedEdrWagonFleetErNumbering2260000000000 } from '../migrations/2260000000000-SeedEdrWagonFleetErNumbering';
|
||||||
|
|
||||||
async function seedEdRWagons() {
|
async function seedEdRWagons() {
|
||||||
await AppDataSource.initialize();
|
await AppDataSource.initialize();
|
||||||
@@ -10,28 +10,32 @@ async function seedEdRWagons() {
|
|||||||
await queryRunner.connect();
|
await queryRunner.connect();
|
||||||
await queryRunner.startTransaction();
|
await queryRunner.startTransaction();
|
||||||
|
|
||||||
await new SeedEdRWagonFleet1750400000000().up(queryRunner);
|
await new SeedEdrWagonFleetErNumbering2260000000000().up(queryRunner);
|
||||||
|
|
||||||
const [summary] = await queryRunner.query(`
|
const summary = await queryRunner.query(`
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(*)::int AS total,
|
wt.code,
|
||||||
COUNT(*) FILTER (WHERE wt.code = 'PW2')::int AS pw2,
|
wt.name,
|
||||||
COUNT(*) FILTER (WHERE wt.code = 'CW4')::int AS cw4,
|
COUNT(*)::int AS wagons,
|
||||||
COUNT(*) FILTER (WHERE wt.code = 'CW3')::int AS cw3,
|
MIN(w.wagon_number) AS first_wagon,
|
||||||
COUNT(*) FILTER (WHERE wt.code = 'KW2')::int AS kw2,
|
MAX(w.wagon_number) AS last_wagon,
|
||||||
COUNT(*) FILTER (WHERE wt.code = 'KW3')::int AS kw3,
|
COUNT(*) FILTER (WHERE w.status = 'AVAILABLE')::int AS available,
|
||||||
COUNT(*) FILTER (WHERE wt.code = 'NW5')::int AS nw5,
|
COUNT(*) FILTER (WHERE w.current_yard_id IS NULL)::int AS unassigned_yard
|
||||||
COUNT(*) FILTER (WHERE wt.supported_load_types @> ARRAY['CONTAINER'])::int AS container_ready,
|
|
||||||
COUNT(*) FILTER (WHERE wt.supported_load_types @> ARRAY['BULK'])::int AS bulk_ready,
|
|
||||||
COUNT(*) FILTER (WHERE w.status = 'IMPORT_READY')::int AS import_ready
|
|
||||||
FROM freight.wagons w
|
FROM freight.wagons w
|
||||||
JOIN freight.wagon_types wt ON wt.id = w.wagon_type_id
|
JOIN freight.wagon_types wt ON wt.id = w.wagon_type_id
|
||||||
WHERE w.wagon_number BETWEEN 'ER0001' AND 'ER0940';
|
WHERE w.wagon_number BETWEEN 'ER0001' AND 'ER1100'
|
||||||
|
GROUP BY wt.code, wt.name
|
||||||
|
ORDER BY MIN(w.wagon_number);
|
||||||
|
`);
|
||||||
|
|
||||||
|
const [totals] = await queryRunner.query(`
|
||||||
|
SELECT COUNT(*)::int AS total FROM freight.wagons;
|
||||||
`);
|
`);
|
||||||
|
|
||||||
await queryRunner.commitTransaction();
|
await queryRunner.commitTransaction();
|
||||||
|
|
||||||
console.log('Seeded EDR wagon fleet:', summary);
|
console.table(summary);
|
||||||
|
console.log(`Seeded EDR wagon fleet — ${totals.total} wagons total (expected 1100).`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await queryRunner.rollbackTransaction();
|
await queryRunner.rollbackTransaction();
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
TextInput,
|
TextInput,
|
||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
Title,
|
Title,
|
||||||
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
@@ -642,6 +643,20 @@ export default function GlCreateBookingForm() {
|
|||||||
});
|
});
|
||||||
}, [isContainer, contract, containerLines, contractWithReturn]);
|
}, [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 bulkUom = contract ? bulkUnitOfMeasure(contract) : "PER_TON";
|
||||||
|
|
||||||
const bulkErrors = useMemo<BulkErrors>(() => {
|
const bulkErrors = useMemo<BulkErrors>(() => {
|
||||||
@@ -688,7 +703,7 @@ export default function GlCreateBookingForm() {
|
|||||||
)
|
)
|
||||||
: !bulkErrors.quantity && !bulkErrors.hazardous && !bulkErrors.reefer;
|
: !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
|
/** The create-booking DTO from the current form state — shared by the
|
||||||
* authoritative price preview and the actual submit so what GL confirms is
|
* authoritative price preview and the actual submit so what GL confirms is
|
||||||
@@ -1286,6 +1301,21 @@ export default function GlCreateBookingForm() {
|
|||||||
</Box>
|
</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>
|
</Stack>
|
||||||
</StepCard>
|
</StepCard>
|
||||||
) : (
|
) : (
|
||||||
@@ -1530,14 +1560,27 @@ export default function GlCreateBookingForm() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
<Group justify="flex-end">
|
<Group justify="flex-end">
|
||||||
<Button
|
<Tooltip
|
||||||
color="edr-green"
|
label={`Book an even number of 20ft containers — ${ft20Total} is odd and would leave one unpaired.`}
|
||||||
radius="md"
|
withArrow
|
||||||
leftSection={<Receipt size={16} />}
|
disabled={!hasOdd20ft}
|
||||||
onClick={openPriceModal}
|
|
||||||
>
|
>
|
||||||
Review price & book
|
{/* Mantine tooltips get no pointer events from a disabled button,
|
||||||
</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 & book
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
</Group>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -36,7 +36,9 @@ export function PinWagonsForm({
|
|||||||
autoFillOnMount?: boolean;
|
autoFillOnMount?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const originYardId = schedule.originStation?.id;
|
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 [assignments, setAssignments] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
const wagonOptionsByType = useMemo(() => {
|
const wagonOptionsByType = useMemo(() => {
|
||||||
|
|||||||
@@ -359,7 +359,9 @@ function WagonCar({
|
|||||||
|
|
||||||
{isEmpty ? (
|
{isEmpty ? (
|
||||||
<Text size="xs" c="dimmed">
|
<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>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<Stack gap={6}>
|
<Stack gap={6}>
|
||||||
|
|||||||
@@ -167,9 +167,9 @@ export const WagonCard = ({
|
|||||||
<TrainFront size={18} />
|
<TrainFront size={18} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
Empty slot
|
{wagon.consistOnly ? "Empty wagon — coupled on the train" : "Empty slot"}
|
||||||
</Text>
|
</Text>
|
||||||
{!isDispatched ? (
|
{!isDispatched && !wagon.consistOnly ? (
|
||||||
<Button
|
<Button
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="gray"
|
color="gray"
|
||||||
|
|||||||
@@ -13,3 +13,6 @@ export function fileViewUrl(fileId: string, download = false): string {
|
|||||||
const base = `${API_BASE_URL}/api/files/${fileId}`;
|
const base = `${API_BASE_URL}/api/files/${fileId}`;
|
||||||
return download ? `${base}?download=1` : base;
|
return download ? `${base}?download=1` : base;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Alert,
|
Alert,
|
||||||
Badge,
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
|
Button,
|
||||||
Grid,
|
Grid,
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
@@ -21,10 +22,18 @@ import {
|
|||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Clock,
|
Clock,
|
||||||
PackageCheck,
|
PackageCheck,
|
||||||
|
RefreshCw,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Link } from "react-router-dom";
|
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 { ClearanceOpsTabs } from "@/components/contracts/ClearanceOpsTabs";
|
||||||
import { PageContainer } from "@/components/page/PageContainer";
|
import { PageContainer } from "@/components/page/PageContainer";
|
||||||
import { PageHeader } from "@/components/page/PageHeader";
|
import { PageHeader } from "@/components/page/PageHeader";
|
||||||
@@ -44,6 +53,7 @@ import {
|
|||||||
export default function ContractClearanceDetailPage() {
|
export default function ContractClearanceDetailPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const { view, viewer } = useFileViewer();
|
const { view, viewer } = useFileViewer();
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
const { data: contract, refetch: refetchContract } = useContractDetail(id);
|
const { data: contract, refetch: refetchContract } = useContractDetail(id);
|
||||||
const {
|
const {
|
||||||
@@ -106,6 +116,16 @@ export default function ContractClearanceDetailPage() {
|
|||||||
const reviewReadOnly = shipmentLocked;
|
const reviewReadOnly = shipmentLocked;
|
||||||
const queriesLocked = Boolean(clearance?.preClearanceFinalized);
|
const queriesLocked = Boolean(clearance?.preClearanceFinalized);
|
||||||
const bookingHref = `/dashboard/contracts/${id}/create-booking`;
|
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 } =
|
const { data: bookingMilestones, refetch: refetchBookingMilestones } =
|
||||||
useBookingMilestones(linkedBookingId);
|
useBookingMilestones(linkedBookingId);
|
||||||
@@ -165,7 +185,16 @@ export default function ContractClearanceDetailPage() {
|
|||||||
{ label: reference },
|
{ label: reference },
|
||||||
]}
|
]}
|
||||||
meta={
|
meta={
|
||||||
bookingAlreadyCreated ? (
|
bookingExpired ? (
|
||||||
|
<Badge
|
||||||
|
variant="light"
|
||||||
|
color="orange"
|
||||||
|
radius="sm"
|
||||||
|
leftSection={<RefreshCw size={13} />}
|
||||||
|
>
|
||||||
|
Payment expired — rebook
|
||||||
|
</Badge>
|
||||||
|
) : bookingAlreadyCreated ? (
|
||||||
<Badge
|
<Badge
|
||||||
variant="light"
|
variant="light"
|
||||||
color="blue"
|
color="blue"
|
||||||
@@ -211,7 +240,49 @@ export default function ContractClearanceDetailPage() {
|
|||||||
it can actually create the booking without checking the schedule board. */}
|
it can actually create the booking without checking the schedule board. */}
|
||||||
{id ? <GlUpcomingWindowsSection contractId={id} /> : null}
|
{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'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
|
<Alert
|
||||||
color="blue"
|
color="blue"
|
||||||
radius="md"
|
radius="md"
|
||||||
|
|||||||
@@ -92,6 +92,10 @@ interface ClearanceRow {
|
|||||||
ready: boolean;
|
ready: boolean;
|
||||||
/** true once GL Ethiopia created the shipment booking. */
|
/** true once GL Ethiopia created the shipment booking. */
|
||||||
bookingCreated: boolean;
|
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(
|
function yardLabel(
|
||||||
@@ -145,6 +149,13 @@ function toClearanceRow(contract: Freight.IContract): ClearanceRow {
|
|||||||
status: contract.status,
|
status: contract.status,
|
||||||
ready: contract.status === "CLEARANCE_READY_FOR_BOOKING",
|
ready: contract.status === "CLEARANCE_READY_FOR_BOOKING",
|
||||||
bookingCreated: contract.status === "ACTIVE_SHIPMENT_IN_PROGRESS",
|
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 }) {
|
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) {
|
if (row.bookingCreated) {
|
||||||
return (
|
return (
|
||||||
<Tooltip label="GL Ethiopia created the shipment booking" withArrow>
|
<Tooltip label="GL Ethiopia created the shipment booking" withArrow>
|
||||||
@@ -519,6 +548,27 @@ export default function ContractClearanceListPage() {
|
|||||||
Create booking
|
Create booking
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</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">
|
<Group justify="flex-end" pr="xs">
|
||||||
<ChevronRight size={16} className="text-muted-foreground" />
|
<ChevronRight size={16} className="text-muted-foreground" />
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Alert, Badge, Button, Modal, Stack, Table, Text } from "@mantine/core";
|
||||||
|
import { FileText } from "lucide-react";
|
||||||
|
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { warehouseService } from "@/services/warehouse.service";
|
||||||
|
import type { LastMileRecord } from "@/services/last-mile.service";
|
||||||
|
import { extractDownloadErrorMessage } from "@/components/warehouses/options";
|
||||||
|
import { openPdfBlob } from "@/components/warehouses/pdf";
|
||||||
|
|
||||||
|
interface EdrTruckExitPapersModalProps {
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
record: LastMileRecord | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmt = (value?: string | null) =>
|
||||||
|
value ? new Date(value).toLocaleString() : "—";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-truck exit papers for an EDR last-mile delivery. Each assigned truck has
|
||||||
|
* its own arrival, exit and weighed load, so each gets its own paper.
|
||||||
|
*/
|
||||||
|
export function EdrTruckExitPapersModal({ opened, onClose, record }: EdrTruckExitPapersModalProps) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
|
const trucks = record?.vehicleAssignments ?? [];
|
||||||
|
|
||||||
|
const download = async (assignmentId: string, plate: string) => {
|
||||||
|
setBusyId(assignmentId);
|
||||||
|
try {
|
||||||
|
const res = await warehouseService.downloadEdrTruckExitPaper(assignmentId);
|
||||||
|
openPdfBlob(res.data, `exit-${plate || assignmentId}.pdf`);
|
||||||
|
} catch (e) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Exit paper not ready",
|
||||||
|
description: await extractDownloadErrorMessage(e),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={onClose}
|
||||||
|
centered
|
||||||
|
radius="lg"
|
||||||
|
size="lg"
|
||||||
|
title={
|
||||||
|
<Text fw={600}>
|
||||||
|
Truck exit papers {record?.booking?.reference ? `· ${record.booking.reference}` : ""}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{trucks.length === 0 ? (
|
||||||
|
<Alert variant="light" color="gray">
|
||||||
|
No trucks assigned to this delivery yet.
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Table verticalSpacing="xs" highlightOnHover>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Truck</Table.Th>
|
||||||
|
<Table.Th>Containers</Table.Th>
|
||||||
|
<Table.Th>Arrived</Table.Th>
|
||||||
|
<Table.Th>Left</Table.Th>
|
||||||
|
<Table.Th ta="right">Net</Table.Th>
|
||||||
|
<Table.Th ta="right">Exit paper</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{trucks.map((t) => {
|
||||||
|
const plate = t.vehicle?.powerPlateNo || t.vehicle?.plateNumber || "—";
|
||||||
|
const load = t.containers?.length
|
||||||
|
? t.containers.map((c) => c.containerNumber).join(", ")
|
||||||
|
: (t.containerNumber ?? "bulk");
|
||||||
|
return (
|
||||||
|
<Table.Tr key={t.id}>
|
||||||
|
<Table.Td>
|
||||||
|
<Text fw={600} size="sm">{plate}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm">{load}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm">{fmt(t.arrivedAt)}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
{t.departedAt ? (
|
||||||
|
<Text size="sm">{fmt(t.departedAt)}</Text>
|
||||||
|
) : (
|
||||||
|
<Badge size="sm" variant="light" color="gray">
|
||||||
|
Still on site
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="right">
|
||||||
|
<Text size="sm">
|
||||||
|
{t.netWeightTons != null ? `${t.netWeightTons} t` : "—"}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="right">
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
variant="light"
|
||||||
|
color="orange"
|
||||||
|
leftSection={<FileText size={13} />}
|
||||||
|
loading={busyId === t.id}
|
||||||
|
onClick={() => download(t.id, plate)}
|
||||||
|
>
|
||||||
|
Exit Paper
|
||||||
|
</Button>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
EDR handovers are generated at delivery, so an exit paper is not gated on a
|
||||||
|
signature — warehouse-fee clearance still applies.
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -208,6 +208,20 @@ const billingIssues = (r: FirstMileRecord) => {
|
|||||||
];
|
];
|
||||||
return { zeroPrice, mixedCurrency: currencies.length > 1, currencies };
|
return { zeroPrice, mixedCurrency: currencies.length > 1, currencies };
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* The mile bills as distance × pricePerKm in the vehicle's own currency, so a
|
||||||
|
* vehicle missing either field cannot produce an invoice line. Returns the
|
||||||
|
* human-readable gap, or null when the vehicle is billable.
|
||||||
|
*/
|
||||||
|
const pricingGap = (
|
||||||
|
v?: { pricePerKm?: number | string | null; currency?: string | null } | null,
|
||||||
|
): string | null => {
|
||||||
|
if (!v) return null;
|
||||||
|
const missing: string[] = [];
|
||||||
|
if (!(Number(v.pricePerKm) > 0)) missing.push("Price per KM");
|
||||||
|
if (!String(v.currency ?? "").trim()) missing.push("Currency");
|
||||||
|
return missing.length ? missing.join(" and ") : null;
|
||||||
|
};
|
||||||
const customerName = (r: FirstMileRecord) => r.booking?.company?.name ?? "—";
|
const customerName = (r: FirstMileRecord) => r.booking?.company?.name ?? "—";
|
||||||
const pickupLocation = (r: FirstMileRecord) => r.booking?.firstMilePickupAddress ?? "—";
|
const pickupLocation = (r: FirstMileRecord) => r.booking?.firstMilePickupAddress ?? "—";
|
||||||
const cargoDesc = (r: FirstMileRecord) => {
|
const cargoDesc = (r: FirstMileRecord) => {
|
||||||
@@ -681,6 +695,22 @@ const FirstMilePage = () => {
|
|||||||
return opts;
|
return opts;
|
||||||
}, [vehicleOptions, activeRecord]);
|
}, [vehicleOptions, activeRecord]);
|
||||||
|
|
||||||
|
// Pricing gap per vehicle id — an unpriced vehicle is blocked from assignment
|
||||||
|
// below rather than silently billing 0 once distances are entered.
|
||||||
|
const pricingGapById = useMemo(() => {
|
||||||
|
const map = new Map<string, string | null>();
|
||||||
|
const add = (v?: { id: string; pricePerKm?: number | string | null; currency?: string | null } | null) => {
|
||||||
|
if (v?.id) map.set(v.id, pricingGap(v));
|
||||||
|
};
|
||||||
|
for (const v of Array.isArray(vehiclesData) ? vehiclesData : []) add(v);
|
||||||
|
for (const a of activeRecord?.vehicleAssignments ?? []) add(a.vehicle);
|
||||||
|
add(activeRecord?.vehicle);
|
||||||
|
return map;
|
||||||
|
}, [vehiclesData, activeRecord]);
|
||||||
|
|
||||||
|
const vehicleLabelFor = (id: string) =>
|
||||||
|
assignVehicleOptions.find((o) => o.value === id)?.label ?? id;
|
||||||
|
|
||||||
// Full booking (with container units) for the assign modal's container dropdown.
|
// Full booking (with container units) for the assign modal's container dropdown.
|
||||||
// Fetched on open so container numbers show regardless of what the list embeds.
|
// Fetched on open so container numbers show regardless of what the list embeds.
|
||||||
const { data: assignBooking } = useQuery({
|
const { data: assignBooking } = useQuery({
|
||||||
@@ -932,6 +962,21 @@ const FirstMilePage = () => {
|
|||||||
|
|
||||||
if (!targetIds.length) return;
|
if (!targetIds.length) return;
|
||||||
|
|
||||||
|
// Backstop for rows the Select guard never saw (pre-filled reassignments).
|
||||||
|
const unpriced = vehicles
|
||||||
|
.map((v) => ({ label: vehicleLabelFor(v.vehicleId), gap: pricingGapById.get(v.vehicleId) }))
|
||||||
|
.filter((v): v is { label: string; gap: string } => Boolean(v.gap));
|
||||||
|
if (unpriced.length) {
|
||||||
|
toast({
|
||||||
|
title: "Vehicle is not priced",
|
||||||
|
description: `${unpriced
|
||||||
|
.map((v) => `${v.label} (${v.gap} not set)`)
|
||||||
|
.join("; ")} — set it on the vehicle before assigning.`,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Empty set = unassign all (setVehicles releases the removed vehicles).
|
// Empty set = unassign all (setVehicles releases the removed vehicles).
|
||||||
Promise.all(targetIds.map((id) => setVehiclesMutation.mutateAsync({ id, vehicles })))
|
Promise.all(targetIds.map((id) => setVehiclesMutation.mutateAsync({ id, vehicles })))
|
||||||
.then(() => {
|
.then(() => {
|
||||||
@@ -1365,9 +1410,18 @@ const FirstMilePage = () => {
|
|||||||
(o) => o.value === row.vehicleId || !vehicleRows.some((r) => r.vehicleId === o.value),
|
(o) => o.value === row.vehicleId || !vehicleRows.some((r) => r.vehicleId === o.value),
|
||||||
)}
|
)}
|
||||||
value={row.vehicleId}
|
value={row.vehicleId}
|
||||||
onChange={(v) =>
|
onChange={(v) => {
|
||||||
setVehicleRows((prev) => prev.map((x, idx) => (idx === i ? { ...x, vehicleId: v } : x)))
|
const gap = v ? pricingGapById.get(v) : null;
|
||||||
}
|
if (v && gap) {
|
||||||
|
toast({
|
||||||
|
title: "Vehicle is not priced",
|
||||||
|
description: `${vehicleLabelFor(v)} — ${gap} not set. Set it on the vehicle before assigning.`,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setVehicleRows((prev) => prev.map((x, idx) => (idx === i ? { ...x, vehicleId: v } : x)));
|
||||||
|
}}
|
||||||
searchable
|
searchable
|
||||||
clearable
|
clearable
|
||||||
disabled={assignVehicleOptions.length === 0}
|
disabled={assignVehicleOptions.length === 0}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
RefreshCw,
|
RefreshCw,
|
||||||
Ruler,
|
Ruler,
|
||||||
Trash,
|
Trash,
|
||||||
|
FileText,
|
||||||
Truck,
|
Truck,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -56,6 +57,7 @@ import {
|
|||||||
import { vehiclesService } from "@/services/vehicles.service";
|
import { vehiclesService } from "@/services/vehicles.service";
|
||||||
import { driversService, type Driver } from "@/services/drivers.service";
|
import { driversService, type Driver } from "@/services/drivers.service";
|
||||||
import { ReleaseOrderModal, type ReleaseOrderTruckPrefill } from "@/components/warehouses/ReleaseOrderModal";
|
import { ReleaseOrderModal, type ReleaseOrderTruckPrefill } from "@/components/warehouses/ReleaseOrderModal";
|
||||||
|
import { EdrTruckExitPapersModal } from "./EdrTruckExitPapersModal";
|
||||||
import { TruckDetentionModal } from "@/components/operations/TruckDetentionModal";
|
import { TruckDetentionModal } from "@/components/operations/TruckDetentionModal";
|
||||||
import { ProofOfDeliveryModal } from "@/components/operations/ProofOfDeliveryModal";
|
import { ProofOfDeliveryModal } from "@/components/operations/ProofOfDeliveryModal";
|
||||||
import { LastMileStepper, type LastMileStepState } from "@/components/operations/LastMileSteps";
|
import { LastMileStepper, type LastMileStepState } from "@/components/operations/LastMileSteps";
|
||||||
@@ -124,10 +126,22 @@ const containerCount = (record: LastMileRecord) =>
|
|||||||
(sum, c) => sum + (Number(c.quantity) || 0),
|
(sum, c) => sum + (Number(c.quantity) || 0),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
/** Trucks needed for a booking = ceil(containers / 2). 0 when no container data. */
|
/**
|
||||||
|
* Trucks needed for a booking, by container SIZE: a 40ft fills a truck (1 each),
|
||||||
|
* two 20ft share one. Falls back to ceil(n / 2) when no size is recorded.
|
||||||
|
* 0 when the booking has no container data (bulk).
|
||||||
|
*/
|
||||||
const requiredVehicles = (record: LastMileRecord) => {
|
const requiredVehicles = (record: LastMileRecord) => {
|
||||||
const n = containerCount(record);
|
const lines = record.booking?.bookingContainers ?? [];
|
||||||
return n > 0 ? Math.ceil(n / CONTAINERS_PER_VEHICLE) : 0;
|
if (!containerCount(record)) return 0;
|
||||||
|
let forty = 0;
|
||||||
|
let others = 0;
|
||||||
|
for (const c of lines) {
|
||||||
|
const qty = Number(c.quantity) || 0;
|
||||||
|
if ((c.containerSize ?? '').includes('40')) forty += qty;
|
||||||
|
else others += qty;
|
||||||
|
}
|
||||||
|
return forty + Math.ceil(others / CONTAINERS_PER_VEHICLE);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Real per-physical-container numbers on a booking, in order. Prefers each
|
/** Real per-physical-container numbers on a booking, in order. Prefers each
|
||||||
@@ -251,6 +265,20 @@ const billingIssues = (r: LastMileRecord) => {
|
|||||||
];
|
];
|
||||||
return { zeroPrice, mixedCurrency: currencies.length > 1, currencies };
|
return { zeroPrice, mixedCurrency: currencies.length > 1, currencies };
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* The mile bills as distance × pricePerKm in the vehicle's own currency, so a
|
||||||
|
* vehicle missing either field cannot produce an invoice line. Returns the
|
||||||
|
* human-readable gap, or null when the vehicle is billable.
|
||||||
|
*/
|
||||||
|
const pricingGap = (
|
||||||
|
v?: { pricePerKm?: number | string | null; currency?: string | null } | null,
|
||||||
|
): string | null => {
|
||||||
|
if (!v) return null;
|
||||||
|
const missing: string[] = [];
|
||||||
|
if (!(Number(v.pricePerKm) > 0)) missing.push("Price per KM");
|
||||||
|
if (!String(v.currency ?? "").trim()) missing.push("Currency");
|
||||||
|
return missing.length ? missing.join(" and ") : null;
|
||||||
|
};
|
||||||
const customerName = (r: LastMileRecord) => r.booking?.company?.name ?? "—";
|
const customerName = (r: LastMileRecord) => r.booking?.company?.name ?? "—";
|
||||||
const deliveryLocation = (r: LastMileRecord) => r.booking?.lastMileDeliveryAddress ?? "—";
|
const deliveryLocation = (r: LastMileRecord) => r.booking?.lastMileDeliveryAddress ?? "—";
|
||||||
const cargoDesc = (r: LastMileRecord) => {
|
const cargoDesc = (r: LastMileRecord) => {
|
||||||
@@ -567,9 +595,11 @@ const LastMilePage = () => {
|
|||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
const [detentionRecord, setDetentionRecord] = useState<LastMileRecord | null>(null);
|
const [detentionRecord, setDetentionRecord] = useState<LastMileRecord | null>(null);
|
||||||
// Multi-vehicle assign: one row per truck — vehicle + the container it carries.
|
// Multi-vehicle assign: one row per truck — vehicle + the container it carries.
|
||||||
|
// One row per truck. A truck carries one 40ft or up to two 20ft, so the load
|
||||||
|
// is a list, not a single container.
|
||||||
const [vehicleRows, setVehicleRows] = useState<
|
const [vehicleRows, setVehicleRows] = useState<
|
||||||
Array<{ vehicleId: string | null; containerNumber: string }>
|
Array<{ vehicleId: string | null; containerNumbers: string[] }>
|
||||||
>([{ vehicleId: null, containerNumber: "" }]);
|
>([{ vehicleId: null, containerNumbers: [] }]);
|
||||||
|
|
||||||
// 2-step "Assign Mile" accept modal (arrival queue → vehicle)
|
// 2-step "Assign Mile" accept modal (arrival queue → vehicle)
|
||||||
const [acceptOpen, setAcceptOpen] = useState(false);
|
const [acceptOpen, setAcceptOpen] = useState(false);
|
||||||
@@ -870,6 +900,22 @@ const LastMilePage = () => {
|
|||||||
return opts;
|
return opts;
|
||||||
}, [vehicleOptions, activeRecord]);
|
}, [vehicleOptions, activeRecord]);
|
||||||
|
|
||||||
|
// Pricing gap per vehicle id — an unpriced vehicle is blocked from assignment
|
||||||
|
// below rather than silently billing 0 once distances are entered.
|
||||||
|
const pricingGapById = useMemo(() => {
|
||||||
|
const map = new Map<string, string | null>();
|
||||||
|
const add = (v?: { id: string; pricePerKm?: number | string | null; currency?: string | null } | null) => {
|
||||||
|
if (v?.id) map.set(v.id, pricingGap(v));
|
||||||
|
};
|
||||||
|
for (const v of Array.isArray(vehiclesData) ? vehiclesData : []) add(v);
|
||||||
|
for (const a of activeRecord?.vehicleAssignments ?? []) add(a.vehicle);
|
||||||
|
add(activeRecord?.vehicle);
|
||||||
|
return map;
|
||||||
|
}, [vehiclesData, activeRecord]);
|
||||||
|
|
||||||
|
const vehicleLabelFor = (id: string) =>
|
||||||
|
assignVehicleOptions.find((o) => o.value === id)?.label ?? id;
|
||||||
|
|
||||||
// Full booking (with container units) for the assign modal's container dropdown.
|
// Full booking (with container units) for the assign modal's container dropdown.
|
||||||
// Fetched on open so container numbers show regardless of what the list embeds.
|
// Fetched on open so container numbers show regardless of what the list embeds.
|
||||||
const { data: assignBooking } = useQuery({
|
const { data: assignBooking } = useQuery({
|
||||||
@@ -970,31 +1016,59 @@ const LastMilePage = () => {
|
|||||||
return filteredRecords.slice(start, start + pagination.pageSize);
|
return filteredRecords.slice(start, start + pagination.pageSize);
|
||||||
}, [filteredRecords, pagination]);
|
}, [filteredRecords, pagination]);
|
||||||
|
|
||||||
|
// Per-truck exit papers for an EDR delivery (one paper per assigned truck).
|
||||||
|
const [exitPapersOpen, setExitPapersOpen] = useState(false);
|
||||||
|
const [exitPapersRecord, setExitPapersRecord] = useState<LastMileRecord | null>(null);
|
||||||
|
|
||||||
|
// Bulk drawdown: how much tonnage is still to be hauled on the booking being
|
||||||
|
// assigned. Bulk has no containers, so trucks keep going until this hits 0.
|
||||||
|
const assignBookingId = activeRecord?.booking?.id ?? null;
|
||||||
|
const { data: remainingTons } = useQuery({
|
||||||
|
queryKey: ["last-mile", "remaining-tons", assignBookingId],
|
||||||
|
queryFn: () => lastMileService.remainingTons(assignBookingId as string).then((r) => r.data),
|
||||||
|
enabled: assignOpen && !bulkMode && Boolean(assignBookingId),
|
||||||
|
});
|
||||||
|
|
||||||
const openAssign = (id: string | null) => {
|
const openAssign = (id: string | null) => {
|
||||||
const resolved = id ?? filteredRecords.find((r) => !isAssigned(r))?.id ?? null;
|
const resolved = id ?? filteredRecords.find((r) => !isAssigned(r))?.id ?? null;
|
||||||
const rec = records.find((r) => r.id === resolved);
|
const rec = records.find((r) => r.id === resolved);
|
||||||
// Prefill each row's container number from the booking's container numbers
|
// Prefill each row's container number from the booking's container numbers
|
||||||
// (by order) when the assignment doesn't already carry one.
|
// (by order) when the assignment doesn't already carry one.
|
||||||
const nums = rec ? bookingContainerNumbers(rec) : [];
|
const nums = rec ? bookingContainerNumbers(rec) : [];
|
||||||
|
// Prefer the truck's own container list; fall back to the legacy scalar, then
|
||||||
|
// to the booking's containers by order.
|
||||||
|
const loadOf = (
|
||||||
|
a: { containers?: Array<{ containerNumber: string }>; containerNumber?: string | null },
|
||||||
|
i: number,
|
||||||
|
) =>
|
||||||
|
a.containers?.length
|
||||||
|
? a.containers.map((c) => c.containerNumber)
|
||||||
|
: a.containerNumber
|
||||||
|
? [a.containerNumber]
|
||||||
|
: nums[i]
|
||||||
|
? [nums[i]]
|
||||||
|
: [];
|
||||||
const rows =
|
const rows =
|
||||||
rec?.vehicleAssignments?.length
|
rec?.vehicleAssignments?.length
|
||||||
? rec.vehicleAssignments.map((a, i) => ({
|
? rec.vehicleAssignments.map((a, i) => ({
|
||||||
vehicleId: a.vehicleId,
|
vehicleId: a.vehicleId,
|
||||||
containerNumber: a.containerNumber ?? nums[i] ?? "",
|
containerNumbers: loadOf(a, i),
|
||||||
}))
|
}))
|
||||||
: rec?.vehicleId
|
: rec?.vehicleId
|
||||||
? [{ vehicleId: rec.vehicleId, containerNumber: nums[0] ?? "" }]
|
? [{ vehicleId: rec.vehicleId, containerNumbers: nums[0] ? [nums[0]] : [] }]
|
||||||
: [{ vehicleId: null, containerNumber: nums[0] ?? "" }];
|
: [{ vehicleId: null, containerNumbers: nums[0] ? [nums[0]] : [] }];
|
||||||
setBulkMode(false);
|
setBulkMode(false);
|
||||||
setActiveId(resolved);
|
setActiveId(resolved);
|
||||||
setVehicleRows(rows.length ? rows : [{ vehicleId: null, containerNumber: nums[0] ?? "" }]);
|
setVehicleRows(
|
||||||
|
rows.length ? rows : [{ vehicleId: null, containerNumbers: nums[0] ? [nums[0]] : [] }],
|
||||||
|
);
|
||||||
setAssignOpen(true);
|
setAssignOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openBulkAssign = () => {
|
const openBulkAssign = () => {
|
||||||
setBulkMode(true);
|
setBulkMode(true);
|
||||||
setActiveId(null);
|
setActiveId(null);
|
||||||
setVehicleRows([{ vehicleId: null, containerNumber: "" }]);
|
setVehicleRows([{ vehicleId: null, containerNumbers: [] }]);
|
||||||
setAssignOpen(true);
|
setAssignOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1002,15 +1076,18 @@ const LastMilePage = () => {
|
|||||||
setAssignOpen(false);
|
setAssignOpen(false);
|
||||||
setBulkMode(false);
|
setBulkMode(false);
|
||||||
setActiveId(null);
|
setActiveId(null);
|
||||||
setVehicleRows([{ vehicleId: null, containerNumber: "" }]);
|
setVehicleRows([{ vehicleId: null, containerNumbers: [] }]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAssign = () => {
|
const handleAssign = () => {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const vehicles = vehicleRows
|
const vehicles = vehicleRows
|
||||||
.filter((r): r is { vehicleId: string; containerNumber: string } => Boolean(r.vehicleId))
|
.filter((r): r is { vehicleId: string; containerNumbers: string[] } => Boolean(r.vehicleId))
|
||||||
.filter((r) => (seen.has(r.vehicleId) ? false : seen.add(r.vehicleId)))
|
.filter((r) => (seen.has(r.vehicleId) ? false : seen.add(r.vehicleId)))
|
||||||
.map((r) => ({ vehicleId: r.vehicleId, containerNumber: r.containerNumber.trim() || null }));
|
.map((r) => ({
|
||||||
|
vehicleId: r.vehicleId,
|
||||||
|
containerNumbers: r.containerNumbers.map((n) => n.trim()).filter(Boolean),
|
||||||
|
}));
|
||||||
const count = vehicles.length;
|
const count = vehicles.length;
|
||||||
const targetIds = bulkMode
|
const targetIds = bulkMode
|
||||||
? selectedIds
|
? selectedIds
|
||||||
@@ -1018,6 +1095,21 @@ const LastMilePage = () => {
|
|||||||
|
|
||||||
if (!targetIds.length) return;
|
if (!targetIds.length) return;
|
||||||
|
|
||||||
|
// Backstop for rows the Select guard never saw (pre-filled reassignments).
|
||||||
|
const unpriced = vehicles
|
||||||
|
.map((v) => ({ label: vehicleLabelFor(v.vehicleId), gap: pricingGapById.get(v.vehicleId) }))
|
||||||
|
.filter((v): v is { label: string; gap: string } => Boolean(v.gap));
|
||||||
|
if (unpriced.length) {
|
||||||
|
toast({
|
||||||
|
title: "Vehicle is not priced",
|
||||||
|
description: `${unpriced
|
||||||
|
.map((v) => `${v.label} (${v.gap} not set)`)
|
||||||
|
.join("; ")} — set it on the vehicle before assigning.`,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Empty set = unassign all (setVehicles releases the removed vehicles).
|
// Empty set = unassign all (setVehicles releases the removed vehicles).
|
||||||
Promise.all(targetIds.map((id) => setVehiclesMutation.mutateAsync({ id, vehicles })))
|
Promise.all(targetIds.map((id) => setVehiclesMutation.mutateAsync({ id, vehicles })))
|
||||||
.then(() => {
|
.then(() => {
|
||||||
@@ -1374,6 +1466,16 @@ const LastMilePage = () => {
|
|||||||
>
|
>
|
||||||
Truck Leaving
|
Truck Leaving
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<FileText size={15} />}
|
||||||
|
disabled={!row.original.vehicleAssignments?.length}
|
||||||
|
onClick={() => {
|
||||||
|
setExitPapersRecord(row.original);
|
||||||
|
setExitPapersOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Truck exit papers
|
||||||
|
</Menu.Item>
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<Eye size={15} />}
|
leftSection={<Eye size={15} />}
|
||||||
@@ -1693,9 +1795,24 @@ const LastMilePage = () => {
|
|||||||
const needed = requiredVehicles(activeRecord);
|
const needed = requiredVehicles(activeRecord);
|
||||||
const picked = vehicleRows.filter((r) => r.vehicleId).length;
|
const picked = vehicleRows.filter((r) => r.vehicleId).length;
|
||||||
if (needed === 0) {
|
if (needed === 0) {
|
||||||
|
// Bulk: no containers — trucks haul loose tonnage until the
|
||||||
|
// booking's total is drawn down to zero by departing trucks.
|
||||||
|
const done = remainingTons?.complete;
|
||||||
return (
|
return (
|
||||||
<Alert variant="light" color="gray" title="One truck (with trailer) carries 2 containers">
|
<Alert
|
||||||
No container count on this booking — assign trucks as needed.
|
variant="light"
|
||||||
|
color={done ? "green" : remainingTons ? "blue" : "gray"}
|
||||||
|
title={
|
||||||
|
remainingTons
|
||||||
|
? `${remainingTons.remainingTons} t remaining of ${remainingTons.totalTons} t`
|
||||||
|
: "No container count on this booking"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{remainingTons
|
||||||
|
? done
|
||||||
|
? "Fully hauled — no tonnage left to assign trucks for."
|
||||||
|
: `Bulk booking: ${remainingTons.hauledTons} t hauled so far. Keep assigning trucks until the remaining tonnage reaches 0 — each truck's net weight is deducted when it leaves.`
|
||||||
|
: "Assign trucks as needed."}
|
||||||
</Alert>
|
</Alert>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1738,32 +1855,43 @@ const LastMilePage = () => {
|
|||||||
(o) => o.value === row.vehicleId || !vehicleRows.some((r) => r.vehicleId === o.value),
|
(o) => o.value === row.vehicleId || !vehicleRows.some((r) => r.vehicleId === o.value),
|
||||||
)}
|
)}
|
||||||
value={row.vehicleId}
|
value={row.vehicleId}
|
||||||
onChange={(v) =>
|
onChange={(v) => {
|
||||||
setVehicleRows((prev) => prev.map((x, idx) => (idx === i ? { ...x, vehicleId: v } : x)))
|
const gap = v ? pricingGapById.get(v) : null;
|
||||||
}
|
if (v && gap) {
|
||||||
|
toast({
|
||||||
|
title: "Vehicle is not priced",
|
||||||
|
description: `${vehicleLabelFor(v)} — ${gap} not set. Set it on the vehicle before assigning.`,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setVehicleRows((prev) => prev.map((x, idx) => (idx === i ? { ...x, vehicleId: v } : x)));
|
||||||
|
}}
|
||||||
searchable
|
searchable
|
||||||
clearable
|
clearable
|
||||||
disabled={assignVehicleOptions.length === 0}
|
disabled={assignVehicleOptions.length === 0}
|
||||||
/>
|
/>
|
||||||
<Select
|
<MultiSelect
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
label={i === 0 ? "Container no." : undefined}
|
label={i === 0 ? "Containers (1x40ft or 2x20ft)" : undefined}
|
||||||
placeholder={containerOptions.length ? "Select container" : "No container numbers"}
|
placeholder={containerOptions.length ? "Select containers" : "No container numbers"}
|
||||||
|
// A truck takes at most two containers; a 40ft fills it (the
|
||||||
|
// API rejects a 40ft paired with anything).
|
||||||
|
maxValues={2}
|
||||||
data={[
|
data={[
|
||||||
...containerOptions.filter(
|
...containerOptions.filter(
|
||||||
(n) =>
|
(n) =>
|
||||||
n === row.containerNumber ||
|
row.containerNumbers.includes(n) ||
|
||||||
!vehicleRows.some((r, idx) => idx !== i && r.containerNumber === n),
|
// a container rides exactly one truck
|
||||||
|
!vehicleRows.some((r, idx) => idx !== i && r.containerNumbers.includes(n)),
|
||||||
),
|
),
|
||||||
// keep a manual/legacy value selectable even if not in the booking
|
// keep manual/legacy values selectable even if not in the booking
|
||||||
...(row.containerNumber && !containerOptions.includes(row.containerNumber)
|
...row.containerNumbers.filter((n) => !containerOptions.includes(n)),
|
||||||
? [row.containerNumber]
|
|
||||||
: []),
|
|
||||||
]}
|
]}
|
||||||
value={row.containerNumber || null}
|
value={row.containerNumbers}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
setVehicleRows((prev) =>
|
setVehicleRows((prev) =>
|
||||||
prev.map((x, idx) => (idx === i ? { ...x, containerNumber: value ?? "" } : x)),
|
prev.map((x, idx) => (idx === i ? { ...x, containerNumbers: value } : x)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
searchable
|
searchable
|
||||||
@@ -1786,14 +1914,14 @@ const LastMilePage = () => {
|
|||||||
size="xs"
|
size="xs"
|
||||||
leftSection={<Plus size={14} />}
|
leftSection={<Plus size={14} />}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setVehicleRows((prev) => [
|
setVehicleRows((prev) => {
|
||||||
...prev,
|
// Suggest the next unassigned container for the new truck.
|
||||||
{
|
const taken = new Set(prev.flatMap((r) => r.containerNumbers));
|
||||||
vehicleId: null,
|
const next = (activeRecord ? bookingContainerNumbers(activeRecord) : []).find(
|
||||||
containerNumber:
|
(n) => !taken.has(n),
|
||||||
(activeRecord ? bookingContainerNumbers(activeRecord)[prev.length] : "") ?? "",
|
);
|
||||||
},
|
return [...prev, { vehicleId: null, containerNumbers: next ? [next] : [] }];
|
||||||
])
|
})
|
||||||
}
|
}
|
||||||
disabled={
|
disabled={
|
||||||
assignVehicleOptions.length === 0 ||
|
assignVehicleOptions.length === 0 ||
|
||||||
@@ -2087,6 +2215,12 @@ const LastMilePage = () => {
|
|||||||
truckPrefill={releaseTruckPrefill}
|
truckPrefill={releaseTruckPrefill}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<EdrTruckExitPapersModal
|
||||||
|
opened={exitPapersOpen}
|
||||||
|
onClose={() => setExitPapersOpen(false)}
|
||||||
|
record={exitPapersRecord}
|
||||||
|
/>
|
||||||
|
|
||||||
<TruckDetentionModal
|
<TruckDetentionModal
|
||||||
opened={Boolean(detentionRecord)}
|
opened={Boolean(detentionRecord)}
|
||||||
onClose={() => setDetentionRecord(null)}
|
onClose={() => setDetentionRecord(null)}
|
||||||
|
|||||||
@@ -31,8 +31,6 @@ import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
|||||||
import BuildTrainModal from "@/components/trainBuilder/BuildTrainModal";
|
import BuildTrainModal from "@/components/trainBuilder/BuildTrainModal";
|
||||||
import EditTrainDetailsModal from "@/components/trainBuilder/EditTrainDetailsModal";
|
import EditTrainDetailsModal from "@/components/trainBuilder/EditTrainDetailsModal";
|
||||||
import {
|
import {
|
||||||
directionColor,
|
|
||||||
directionRowStyle,
|
|
||||||
trainStatusColor,
|
trainStatusColor,
|
||||||
trainStatusLabel,
|
trainStatusLabel,
|
||||||
} from "@/components/trainBuilder/trainStatus";
|
} from "@/components/trainBuilder/trainStatus";
|
||||||
@@ -164,14 +162,9 @@ export default function TrainBuilderListPage() {
|
|||||||
return (
|
return (
|
||||||
<Stack gap={2}>
|
<Stack gap={2}>
|
||||||
{active?.trainNumber ? (
|
{active?.trainNumber ? (
|
||||||
<Group gap={6} wrap="nowrap">
|
<Text size="sm" fw={700} ff="monospace" lh={1.2}>
|
||||||
<Text size="sm" fw={700} ff="monospace" lh={1.2}>
|
{active.trainNumber}
|
||||||
{active.trainNumber}
|
</Text>
|
||||||
</Text>
|
|
||||||
<Badge size="xs" variant="light" color={directionColor(active.direction)}>
|
|
||||||
{active.direction ?? "—"}
|
|
||||||
</Badge>
|
|
||||||
</Group>
|
|
||||||
) : null}
|
) : null}
|
||||||
<Text size="xs" c="dimmed" ff="monospace" lh={1.2}>
|
<Text size="xs" c="dimmed" ff="monospace" lh={1.2}>
|
||||||
IMP {row.original.importTrainNumber ?? "—"} · EXP{" "}
|
IMP {row.original.importTrainNumber ?? "—"} · EXP{" "}
|
||||||
@@ -348,7 +341,6 @@ export default function TrainBuilderListPage() {
|
|||||||
data={trains}
|
data={trains}
|
||||||
status={tableStatus}
|
status={tableStatus}
|
||||||
onRowClick={(train) => navigate(`/dashboard/train-builder/${train.id}`)}
|
onRowClick={(train) => navigate(`/dashboard/train-builder/${train.id}`)}
|
||||||
rowStyle={(train) => directionRowStyle(train.activeSchedule?.direction)}
|
|
||||||
error={
|
error={
|
||||||
trainsQuery.isError
|
trainsQuery.isError
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { ColumnDef } from "@edr/ui-common";
|
import type { ColumnDef } from "@edr/ui-common";
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
@@ -38,6 +39,10 @@ import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
|||||||
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
||||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||||
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
|
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
|
||||||
|
import {
|
||||||
|
directionColor,
|
||||||
|
directionRowStyle,
|
||||||
|
} from "@/components/trainBuilder/trainStatus";
|
||||||
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
|
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
|
||||||
import EditScheduleDateModal from "@/components/trainScheduling/EditScheduleDateModal";
|
import EditScheduleDateModal from "@/components/trainScheduling/EditScheduleDateModal";
|
||||||
import { showScheduleWarnings } from "@/components/trainScheduling/locomotiveOptions";
|
import { showScheduleWarnings } from "@/components/trainScheduling/locomotiveOptions";
|
||||||
@@ -303,9 +308,20 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
meta: { headerClassName, cellClassName },
|
meta: { headerClassName, cellClassName },
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Stack gap={4}>
|
<Stack gap={4}>
|
||||||
<Text size="sm" fw={600} lh={1.2}>
|
<Group gap={6} wrap="nowrap">
|
||||||
{row.original.routeName ?? "—"}
|
<Text size="sm" fw={600} lh={1.2}>
|
||||||
</Text>
|
{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}>
|
<Box maw={220}>
|
||||||
<RouteCorridor
|
<RouteCorridor
|
||||||
origin={row.original.origin}
|
origin={row.original.origin}
|
||||||
@@ -660,6 +676,7 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
onRowClick={(schedule) =>
|
onRowClick={(schedule) =>
|
||||||
navigate(`/dashboard/operations/train-scheduling-v2/${schedule.id}`)
|
navigate(`/dashboard/operations/train-scheduling-v2/${schedule.id}`)
|
||||||
}
|
}
|
||||||
|
rowStyle={(schedule) => directionRowStyle(schedule.direction)}
|
||||||
error={
|
error={
|
||||||
schedulesQuery.isError
|
schedulesQuery.isError
|
||||||
? {
|
? {
|
||||||
@@ -909,7 +926,18 @@ function ScheduleCard({
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Group justify="space-between" align="center">
|
<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">
|
<Group gap={6} wrap="nowrap">
|
||||||
<MetricChip value={schedule.bookingsCount} label="bkg" />
|
<MetricChip value={schedule.bookingsCount} label="bkg" />
|
||||||
<MetricChip value={schedule.wagonCount} label="wgn" />
|
<MetricChip value={schedule.wagonCount} label="wgn" />
|
||||||
|
|||||||
@@ -67,8 +67,16 @@ export interface LastMileRecord {
|
|||||||
vehicleAssignments?: Array<{
|
vehicleAssignments?: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
vehicleId: string;
|
vehicleId: string;
|
||||||
|
/** @deprecated Legacy single container — `containers` is authoritative. */
|
||||||
containerNumber?: string | null;
|
containerNumber?: string | null;
|
||||||
|
/** Containers riding this truck: one 40ft, or up to two 20ft. */
|
||||||
|
containers?: Array<{ id: string; containerNumber: string }>;
|
||||||
distanceKm?: number | null;
|
distanceKm?: number | null;
|
||||||
|
/** Per-truck arrival / exit, stamped by the warehouse weighing steps. */
|
||||||
|
arrivedAt?: string | null;
|
||||||
|
departedAt?: string | null;
|
||||||
|
grossWeightTons?: number | null;
|
||||||
|
netWeightTons?: number | null;
|
||||||
vehicle?: LastMileVehicle | null;
|
vehicle?: LastMileVehicle | null;
|
||||||
}>;
|
}>;
|
||||||
/** Present only when an invoice has actually been generated (not on distance). */
|
/** Present only when an invoice has actually been generated (not on distance). */
|
||||||
@@ -99,8 +107,13 @@ export const lastMileService = {
|
|||||||
api.delete<void>(LM.BY_ID(id)),
|
api.delete<void>(LM.BY_ID(id)),
|
||||||
setVehicles: (
|
setVehicles: (
|
||||||
id: string,
|
id: string,
|
||||||
vehicles: Array<{ vehicleId: string; containerNumber?: string | null }>,
|
vehicles: Array<{ vehicleId: string; containerNumbers?: string[] }>,
|
||||||
) => api.post<LastMileRecord>(`${LM.BASE}/${id}/vehicles`, { vehicles }),
|
) => api.post<LastMileRecord>(`${LM.BASE}/${id}/vehicles`, { vehicles }),
|
||||||
|
/** Bulk drawdown: tonnage still to be hauled on this booking. */
|
||||||
|
remainingTons: (bookingId: string) =>
|
||||||
|
api.get<{ totalTons: number; hauledTons: number; remainingTons: number; complete: boolean }>(
|
||||||
|
`${LM.BASE}/booking/${bookingId}/remaining-tons`,
|
||||||
|
),
|
||||||
setDistances: (
|
setDistances: (
|
||||||
id: string,
|
id: string,
|
||||||
distances: Array<{ vehicleId: string; distanceKm: number }>,
|
distances: Array<{ vehicleId: string; distanceKm: number }>,
|
||||||
|
|||||||
@@ -323,6 +323,11 @@ export const warehouseService = {
|
|||||||
apiClient.get<Blob>(`/warehouse-inventory/customer-truck-exit-paper/${assignmentId}`, {
|
apiClient.get<Blob>(`/warehouse-inventory/customer-truck-exit-paper/${assignmentId}`, {
|
||||||
responseType: 'blob',
|
responseType: 'blob',
|
||||||
}),
|
}),
|
||||||
|
/** Per-truck exit paper PDF for an EDR last-mile truck. */
|
||||||
|
downloadEdrTruckExitPaper: (assignmentId: string) =>
|
||||||
|
apiClient.get<Blob>(`/warehouse-inventory/edr-truck-exit-paper/${assignmentId}`, {
|
||||||
|
responseType: 'blob',
|
||||||
|
}),
|
||||||
deliver: (id: string, payload: DeliverInventoryPayload) =>
|
deliver: (id: string, payload: DeliverInventoryPayload) =>
|
||||||
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.DELIVER(id), payload),
|
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.DELIVER(id), payload),
|
||||||
|
|
||||||
|
|||||||
@@ -168,6 +168,8 @@ export interface TrainScheduleListItem {
|
|||||||
createdAt?: string | null;
|
createdAt?: string | null;
|
||||||
scheduleDate: string;
|
scheduleDate: string;
|
||||||
trainNumber?: string | null;
|
trainNumber?: string | null;
|
||||||
|
/** Trade direction of this departure (IMPORT / EXPORT), when known. */
|
||||||
|
direction?: string | null;
|
||||||
routeName?: string | null;
|
routeName?: string | null;
|
||||||
origin: string | null;
|
origin: string | null;
|
||||||
destination: string | null;
|
destination: string | null;
|
||||||
@@ -608,6 +610,11 @@ export interface TrainScheduleDetail {
|
|||||||
name: string;
|
name: string;
|
||||||
} | null;
|
} | null;
|
||||||
allocations: TrainScheduleWagonAllocation[];
|
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;
|
} | null;
|
||||||
bookings: Array<{
|
bookings: Array<{
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
Textarea,
|
Textarea,
|
||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
Title,
|
Title,
|
||||||
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
@@ -269,6 +270,19 @@ function NewShipmentBookingForm({
|
|||||||
mode: "onChange",
|
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({
|
const submitMutation = useMutation({
|
||||||
mutationFn: (dto: Freight.CreateBookingUnderContractDto) =>
|
mutationFn: (dto: Freight.CreateBookingUnderContractDto) =>
|
||||||
completeBookingId
|
completeBookingId
|
||||||
@@ -367,6 +381,8 @@ function NewShipmentBookingForm({
|
|||||||
// run it for every freight type; container contracts additionally get
|
// run it for every freight type; container contracts additionally get
|
||||||
// overweight warnings + 20ft pairing hard-blocks surfaced in the modal.
|
// overweight warnings + 20ft pairing hard-blocks surfaced in the modal.
|
||||||
const handleReview = form.handleSubmit((values) => {
|
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);
|
setPendingValues(values);
|
||||||
validateMutation.reset();
|
validateMutation.reset();
|
||||||
validateMutation.mutate(buildDto(values));
|
validateMutation.mutate(buildDto(values));
|
||||||
@@ -483,15 +499,26 @@ function NewShipmentBookingForm({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Group justify="flex-end" className="mx-auto max-w-4xl">
|
<Group justify="flex-end" className="mx-auto max-w-4xl">
|
||||||
<Button
|
<Tooltip
|
||||||
type="button"
|
label={`Book an even number of 20ft containers — ${ft20Total} is odd and would leave one unpaired.`}
|
||||||
color="edr-green"
|
withArrow
|
||||||
radius="md"
|
disabled={!hasOdd20ft}
|
||||||
leftSection={<Receipt size={16} />}
|
|
||||||
onClick={handleReview}
|
|
||||||
>
|
>
|
||||||
Review price & book
|
{/* Mantine tooltips get no pointer events from a disabled button,
|
||||||
</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 & book
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
</Group>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
</form>
|
</form>
|
||||||
@@ -1265,6 +1292,28 @@ function CargoStep({
|
|||||||
This contract has no container sizes in scope.
|
This contract has no container sizes in scope.
|
||||||
</Text>
|
</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>
|
</Stack>
|
||||||
</StepCard>
|
</StepCard>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState } from "react";
|
|||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Group,
|
Group,
|
||||||
@@ -13,7 +14,7 @@ import {
|
|||||||
Textarea,
|
Textarea,
|
||||||
Title,
|
Title,
|
||||||
} from "@mantine/core";
|
} 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 toast from "react-hot-toast";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
import { DatePickerInput } from "@mantine/dates";
|
import { DatePickerInput } from "@mantine/dates";
|
||||||
@@ -98,7 +99,14 @@ export default function NewShipmentRequestPage() {
|
|||||||
contract.cargoScope?.[0];
|
contract.cargoScope?.[0];
|
||||||
const isPerItem = bulkScope?.cargoType?.unitOfMeasure === "PER_ITEM";
|
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 = () => {
|
const handleSubmit = () => {
|
||||||
|
if (hasOdd20ft) return;
|
||||||
const dto: Freight.CreateBookingRequestDto = {
|
const dto: Freight.CreateBookingRequestDto = {
|
||||||
contractRouteId: route?.id,
|
contractRouteId: route?.id,
|
||||||
scheduledDate: hasCustoms ? undefined : scheduledDate || undefined,
|
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 ? (
|
{capacity?.length ? (
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
Remaining capacity is shown on the contract — GL will validate your request.
|
Remaining capacity is shown on the contract — GL will validate your request.
|
||||||
@@ -220,6 +245,7 @@ export default function NewShipmentRequestPage() {
|
|||||||
leftSection={<Send size={16} />}
|
leftSection={<Send size={16} />}
|
||||||
loading={submit.isPending}
|
loading={submit.isPending}
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
|
disabled={hasOdd20ft}
|
||||||
>
|
>
|
||||||
Submit shipment request
|
Submit shipment request
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -567,6 +567,7 @@ model Booking {
|
|||||||
cancellation BookingCancellation?
|
cancellation BookingCancellation?
|
||||||
baggage BaggageBooking[]
|
baggage BaggageBooking[]
|
||||||
excessBaggageCharges ExcessBaggageCharge[]
|
excessBaggageCharges ExcessBaggageCharge[]
|
||||||
|
supplementaryCharges SupplementaryCharge[]
|
||||||
journey Journey?
|
journey Journey?
|
||||||
|
|
||||||
@@index([passengerId, status])
|
@@index([passengerId, status])
|
||||||
@@ -1234,6 +1235,28 @@ model BaggageBooking {
|
|||||||
@@schema("passenger")
|
@@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 {
|
model ExcessBaggageCharge {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
bookingId String
|
bookingId String
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ export class BookingsService {
|
|||||||
id: booking.id,
|
id: booking.id,
|
||||||
bookingRef: booking.bookingRef,
|
bookingRef: booking.bookingRef,
|
||||||
status: booking.status,
|
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,
|
currency: booking.displayCurrency,
|
||||||
displayCurrency: booking.displayCurrency,
|
displayCurrency: booking.displayCurrency,
|
||||||
displayTotalMinor: booking.displayTotalMinor,
|
displayTotalMinor: booking.displayTotalMinor,
|
||||||
@@ -296,7 +296,7 @@ export class BookingsService {
|
|||||||
id: booking.id,
|
id: booking.id,
|
||||||
bookingRef: booking.bookingRef,
|
bookingRef: booking.bookingRef,
|
||||||
status: booking.status,
|
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,
|
currency: booking.displayCurrency,
|
||||||
displayCurrency: booking.displayCurrency,
|
displayCurrency: booking.displayCurrency,
|
||||||
displayTotalMinor: booking.displayTotalMinor,
|
displayTotalMinor: booking.displayTotalMinor,
|
||||||
@@ -409,7 +409,7 @@ export class BookingsService {
|
|||||||
id: booking.id,
|
id: booking.id,
|
||||||
bookingRef: booking.bookingRef,
|
bookingRef: booking.bookingRef,
|
||||||
status: booking.status,
|
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,
|
currency: booking.displayCurrency,
|
||||||
displayCurrency: booking.displayCurrency,
|
displayCurrency: booking.displayCurrency,
|
||||||
displayTotalMinor: booking.displayTotalMinor,
|
displayTotalMinor: booking.displayTotalMinor,
|
||||||
@@ -554,7 +554,7 @@ export class BookingsService {
|
|||||||
const uniquePassengers = Array.from(new Map(passengerDetails.map((p: any) => [p.name, p])).values());
|
const uniquePassengers = Array.from(new Map(passengerDetails.map((p: any) => [p.name, p])).values());
|
||||||
return {
|
return {
|
||||||
id: booking.id, bookingRef: booking.bookingRef, status: booking.status,
|
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,
|
currency: booking.displayCurrency,
|
||||||
displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor,
|
displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor,
|
||||||
contactEmail: booking.contactEmail, contactPhone: booking.contactPhone,
|
contactEmail: booking.contactEmail, contactPhone: booking.contactPhone,
|
||||||
@@ -683,7 +683,7 @@ export class BookingsService {
|
|||||||
id: booking.id,
|
id: booking.id,
|
||||||
bookingRef: booking.bookingRef,
|
bookingRef: booking.bookingRef,
|
||||||
status: booking.status,
|
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,
|
currency: booking.displayCurrency,
|
||||||
displayCurrency: booking.displayCurrency,
|
displayCurrency: booking.displayCurrency,
|
||||||
displayTotalMinor: booking.displayTotalMinor,
|
displayTotalMinor: booking.displayTotalMinor,
|
||||||
@@ -1883,7 +1883,7 @@ export class BookingsService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: booking.id, bookingRef: booking.bookingRef, status: booking.status,
|
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,
|
currency: booking.displayCurrency,
|
||||||
adultCount: booking.adultCount, childCount: booking.childCount,
|
adultCount: booking.adultCount, childCount: booking.childCount,
|
||||||
displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor ?? undefined,
|
displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor ?? undefined,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ModuleRef } from '@nestjs/core';
|
||||||
import { Nack, RabbitSubscribe } from '@golevelup/nestjs-rabbitmq';
|
import { Nack, RabbitSubscribe } from '@golevelup/nestjs-rabbitmq';
|
||||||
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
||||||
import {
|
import {
|
||||||
@@ -18,7 +19,15 @@ const PASSENGER_QUEUE = PAYMENT_QUEUES[PaymentService.PASSENGER];
|
|||||||
export class PaymentEventsConsumer {
|
export class PaymentEventsConsumer {
|
||||||
private readonly logger = new Logger(PaymentEventsConsumer.name);
|
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()
|
@IsPublic()
|
||||||
@RabbitSubscribe({
|
@RabbitSubscribe({
|
||||||
@@ -37,7 +46,13 @@ export class PaymentEventsConsumer {
|
|||||||
`RECEIVED ${event.eventType} (${event.eventId}) ref=${event.referenceId} via RabbitMQ`,
|
`RECEIVED ${event.eventType} (${event.eventId}) ref=${event.referenceId} via RabbitMQ`,
|
||||||
);
|
);
|
||||||
try {
|
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,
|
event as unknown as PaymentEventDto,
|
||||||
);
|
);
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
|
|||||||
@@ -39,12 +39,34 @@ import {
|
|||||||
import { PassengerStaff } from "../../common/passenger-guards";
|
import { PassengerStaff } from "../../common/passenger-guards";
|
||||||
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
|
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
|
||||||
import { resolveAllowedOrigin } from "../../common/utils/redirect-origin.util";
|
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")
|
@ApiTags("Payment")
|
||||||
@Controller("payments")
|
@Controller("payments")
|
||||||
// @Throttle({ strict: { limit: 20, ttl: 60_000 } })
|
// @Throttle({ strict: { limit: 20, ttl: 60_000 } })
|
||||||
export class PaymentsController {
|
export class PaymentsController {
|
||||||
constructor(private service: PaymentsService) {}
|
constructor(
|
||||||
|
private service: PaymentsService,
|
||||||
|
private supplementaryService: SupplementaryChargesService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Delete(":id")
|
@Delete(":id")
|
||||||
@PassengerStaff([PASSENGER_PERMS.admin])
|
@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 {
|
private buildRedirectHtml(url: string): string {
|
||||||
const escaped = url.replace(/\"/g, """);
|
const escaped = url.replace(/\"/g, """);
|
||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
|
|||||||
@@ -154,6 +154,13 @@ export class IntentStatusDto {
|
|||||||
@ApiPropertyOptional() paidAt?: string;
|
@ApiPropertyOptional() paidAt?: string;
|
||||||
@ApiPropertyOptional() failureCode?: string;
|
@ApiPropertyOptional() failureCode?: string;
|
||||||
@ApiPropertyOptional() failureMessage?: 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 {
|
export class BookingAmountResponseDto {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from "@edr/types";
|
} from "@edr/types";
|
||||||
import { PaymentsController } from "./payments.controller";
|
import { PaymentsController } from "./payments.controller";
|
||||||
import { PaymentsService } from "./payments.service";
|
import { PaymentsService } from "./payments.service";
|
||||||
|
import { SupplementaryChargesService } from "./supplementary-charges.service";
|
||||||
import { InternalPaymentsController } from "./internal-payments.controller";
|
import { InternalPaymentsController } from "./internal-payments.controller";
|
||||||
import { PaymentClientService } from "./payment-client.service";
|
import { PaymentClientService } from "./payment-client.service";
|
||||||
import { PaymentEventsConsumer } from "./payment-events.consumer";
|
import { PaymentEventsConsumer } from "./payment-events.consumer";
|
||||||
@@ -21,6 +22,8 @@ import { TicketsModule } from "../tickets/tickets.module";
|
|||||||
import { CurrencyModule } from "../currency/currency.module";
|
import { CurrencyModule } from "../currency/currency.module";
|
||||||
import { AuditModule } from "../../common/audit.module";
|
import { AuditModule } from "../../common/audit.module";
|
||||||
|
|
||||||
|
import { NotificationsModule } from "../notifications/notifications.module";
|
||||||
|
|
||||||
const PASSENGER_QUEUE = PAYMENT_QUEUES[PaymentService.PASSENGER];
|
const PASSENGER_QUEUE = PAYMENT_QUEUES[PaymentService.PASSENGER];
|
||||||
|
|
||||||
function rabbitMQImport(): DynamicModule[] {
|
function rabbitMQImport(): DynamicModule[] {
|
||||||
@@ -55,8 +58,7 @@ function rabbitMQImport(): DynamicModule[] {
|
|||||||
TicketsModule,
|
TicketsModule,
|
||||||
CurrencyModule,
|
CurrencyModule,
|
||||||
AuditModule,
|
AuditModule,
|
||||||
// The payment service proxies slow provider calls (e.g. CAC Bank initiate, which SMSes an
|
NotificationsModule,
|
||||||
// OTP and can take tens of seconds). Keep this hop generous; overridable via env.
|
|
||||||
HttpModule.register({
|
HttpModule.register({
|
||||||
timeout: Number(process.env.PAYMENT_API_HTTP_TIMEOUT_MS) || 60_000,
|
timeout: Number(process.env.PAYMENT_API_HTTP_TIMEOUT_MS) || 60_000,
|
||||||
}),
|
}),
|
||||||
@@ -65,10 +67,11 @@ function rabbitMQImport(): DynamicModule[] {
|
|||||||
controllers: [PaymentsController, InternalPaymentsController],
|
controllers: [PaymentsController, InternalPaymentsController],
|
||||||
providers: [
|
providers: [
|
||||||
PaymentsService,
|
PaymentsService,
|
||||||
|
SupplementaryChargesService,
|
||||||
PaymentClientService,
|
PaymentClientService,
|
||||||
PaymentEventsConsumer,
|
PaymentEventsConsumer,
|
||||||
ServiceAuthGuard,
|
ServiceAuthGuard,
|
||||||
],
|
],
|
||||||
exports: [PaymentClientService],
|
exports: [PaymentClientService, PaymentsService],
|
||||||
})
|
})
|
||||||
export class PaymentsModule {}
|
export class PaymentsModule {}
|
||||||
|
|||||||
@@ -432,6 +432,9 @@ export class PaymentsService {
|
|||||||
expiresAt: snapshot.expiresAt ? new Date(snapshot.expiresAt) : null,
|
expiresAt: snapshot.expiresAt ? new Date(snapshot.expiresAt) : null,
|
||||||
failureCode: snapshot.failureCode ?? null,
|
failureCode: snapshot.failureCode ?? null,
|
||||||
failureMessage: snapshot.failureMessage ?? null,
|
failureMessage: snapshot.failureMessage ?? null,
|
||||||
|
rawInitiation: snapshot.providerResponse
|
||||||
|
? (snapshot.providerResponse as unknown as Prisma.InputJsonValue)
|
||||||
|
: Prisma.DbNull,
|
||||||
};
|
};
|
||||||
return this.prisma.paymentIntent.upsert({
|
return this.prisma.paymentIntent.upsert({
|
||||||
where: { bookingId },
|
where: { bookingId },
|
||||||
@@ -589,6 +592,10 @@ export class PaymentsService {
|
|||||||
paidAt: intent.paidAt?.toISOString(),
|
paidAt: intent.paidAt?.toISOString(),
|
||||||
failureCode: intent.failureCode ?? undefined,
|
failureCode: intent.failureCode ?? undefined,
|
||||||
failureMessage: intent.failureMessage ?? 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 };
|
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(
|
async handlePaymentEvent(
|
||||||
event: PaymentEventDto,
|
event: PaymentEventDto,
|
||||||
): Promise<MarkPaidResponseDto> {
|
): Promise<MarkPaidResponseDto> {
|
||||||
if (
|
if (event.service !== PaymentServiceEnum.PASSENGER) {
|
||||||
event.service !== PaymentServiceEnum.PASSENGER ||
|
|
||||||
event.referenceType !== PaymentReferenceType.BOOKING
|
|
||||||
) {
|
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`mark-paid: ignoring foreign reference ${event.service}/${event.referenceType}/${event.referenceId}`,
|
`mark-paid: ignoring foreign reference ${event.service}/${event.referenceType}/${event.referenceId}`,
|
||||||
);
|
);
|
||||||
return { processed: false, reason: "foreign-reference" };
|
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") {
|
if (event.eventType === "payment.failed") {
|
||||||
const intent = await this.prisma.paymentIntent.findUnique({
|
const intent = await this.prisma.paymentIntent.findUnique({
|
||||||
where: { bookingId: event.referenceId },
|
where: { bookingId: event.referenceId },
|
||||||
|
|||||||
@@ -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,
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,10 +2,11 @@ import { Module } from '@nestjs/common';
|
|||||||
import { PrismaModule } from '../../common/prisma.module';
|
import { PrismaModule } from '../../common/prisma.module';
|
||||||
import { NotificationsModule } from '../notifications/notifications.module';
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
import { CurrencyModule } from '../currency/currency.module';
|
import { CurrencyModule } from '../currency/currency.module';
|
||||||
|
import { PaymentsModule } from '../payments/payments.module';
|
||||||
import { TasksService } from './tasks.service';
|
import { TasksService } from './tasks.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, NotificationsModule, CurrencyModule],
|
imports: [PrismaModule, NotificationsModule, CurrencyModule, PaymentsModule],
|
||||||
providers: [TasksService],
|
providers: [TasksService],
|
||||||
})
|
})
|
||||||
export class TasksModule {}
|
export class TasksModule {}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { Cron } from '@nestjs/schedule';
|
|||||||
import { PrismaService } from '../../common/prisma.service';
|
import { PrismaService } from '../../common/prisma.service';
|
||||||
import { SmsClientService } from '../notifications/sms-client.service';
|
import { SmsClientService } from '../notifications/sms-client.service';
|
||||||
import { CurrencyService } from '../currency/currency.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';
|
import { MAX_PAYMENT_HOURS, CUTOFF_MINUTES, computePaymentDeadline } from '../../common/utils/payment-deadline.utils';
|
||||||
|
|
||||||
// Retention windows
|
// Retention windows
|
||||||
@@ -28,6 +31,8 @@ export class TasksService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly sms: SmsClientService,
|
private readonly sms: SmsClientService,
|
||||||
private readonly currencyService: CurrencyService,
|
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.
|
// Daily at 02:00 EAT: purge expired/stale records to enforce data retention.
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { Body, Controller, Get, Param, Post, Query, UseGuards, Delete, Patch, Se
|
|||||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody, ApiQuery } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody, ApiQuery } from '@nestjs/swagger';
|
||||||
import { TicketsService } from './tickets.service';
|
import { TicketsService } from './tickets.service';
|
||||||
import { JwtGuard } from '../../common/jwt.guard';
|
import { JwtGuard } from '../../common/jwt.guard';
|
||||||
import { PassengerAdmin } from '../../common/passenger-guards';
|
import { PassengerStaff } from '../../common/passenger-guards';
|
||||||
|
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
||||||
|
|
||||||
@ApiTags('Tickets')
|
@ApiTags('Tickets')
|
||||||
@Controller('tickets')
|
@Controller('tickets')
|
||||||
@@ -10,7 +11,7 @@ export class TicketsController {
|
|||||||
constructor(private service: TicketsService) {}
|
constructor(private service: TicketsService) {}
|
||||||
|
|
||||||
@Post('smart-assign/:bookingId')
|
@Post('smart-assign/:bookingId')
|
||||||
@PassengerAdmin()
|
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
|
||||||
@ApiBearerAuth('IAM-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Smart seat assignment + ticket generation',
|
summary: 'Smart seat assignment + ticket generation',
|
||||||
@@ -23,7 +24,7 @@ export class TicketsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('generate/:bookingId')
|
@Post('generate/:bookingId')
|
||||||
@PassengerAdmin()
|
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
|
||||||
@ApiBearerAuth('IAM-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Generate ticket for booking (confirmation page)',
|
summary: 'Generate ticket for booking (confirmation page)',
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export const PASSENGER_PERMISSIONS: PassengerPermissionSeed[] = [
|
|||||||
perm('ff5d33a0-0fe7-427f-a065-46dd14ac1da0', 'edr_passenger_app:passengers:manage', 'Manage passengers'),
|
perm('ff5d33a0-0fe7-427f-a065-46dd14ac1da0', 'edr_passenger_app:passengers:manage', 'Manage passengers'),
|
||||||
perm('326ec767-1da8-4c7e-b557-d4d2f9dd6d2c', 'edr_passenger_app:tickets:view', 'View tickets'),
|
perm('326ec767-1da8-4c7e-b557-d4d2f9dd6d2c', 'edr_passenger_app:tickets:view', 'View tickets'),
|
||||||
perm('8ec5697f-d2d4-40a2-a365-ad624991a2ab', 'edr_passenger_app:tickets:manage', 'Manage tickets'),
|
perm('8ec5697f-d2d4-40a2-a365-ad624991a2ab', 'edr_passenger_app:tickets:manage', 'Manage tickets'),
|
||||||
|
perm('7f3a1e9c-2b4d-4c8a-9e6f-1a2b3c4d5e6f', 'edr_passenger_app:tickets:generate', 'Generate tickets'),
|
||||||
perm('736aca18-6660-4865-9773-81a636f51fa0', 'edr_passenger_app:payments:view_all', 'View all payments'),
|
perm('736aca18-6660-4865-9773-81a636f51fa0', 'edr_passenger_app:payments:view_all', 'View all payments'),
|
||||||
perm('44065042-b4af-4af2-b213-34a823f78be1', 'edr_passenger_app:payments:refund', 'Refund payments'),
|
perm('44065042-b4af-4af2-b213-34a823f78be1', 'edr_passenger_app:payments:refund', 'Refund payments'),
|
||||||
perm('558f0172-ab9f-4d13-9477-4ca247d94f3c', 'edr_passenger_app:payments:manage_methods', 'Manage payment methods'),
|
perm('558f0172-ab9f-4d13-9477-4ca247d94f3c', 'edr_passenger_app:payments:manage_methods', 'Manage payment methods'),
|
||||||
@@ -82,8 +83,9 @@ export const PASSENGER_PERMS = {
|
|||||||
manage: 'edr_passenger_app:passengers:manage',
|
manage: 'edr_passenger_app:passengers:manage',
|
||||||
},
|
},
|
||||||
tickets: {
|
tickets: {
|
||||||
view: 'edr_passenger_app:tickets:view',
|
view: 'edr_passenger_app:tickets:view',
|
||||||
manage: 'edr_passenger_app:tickets:manage',
|
manage: 'edr_passenger_app:tickets:manage',
|
||||||
|
generate: 'edr_passenger_app:tickets:generate',
|
||||||
},
|
},
|
||||||
payments: {
|
payments: {
|
||||||
view: 'edr_passenger_app:payments:view',
|
view: 'edr_passenger_app:payments:view',
|
||||||
@@ -172,6 +174,7 @@ export const ROLE_PERMISSION_PRESETS = {
|
|||||||
PASSENGER_PERMS.bookings.manage,
|
PASSENGER_PERMS.bookings.manage,
|
||||||
PASSENGER_PERMS.tickets.view,
|
PASSENGER_PERMS.tickets.view,
|
||||||
PASSENGER_PERMS.tickets.manage,
|
PASSENGER_PERMS.tickets.manage,
|
||||||
|
PASSENGER_PERMS.tickets.generate,
|
||||||
PASSENGER_PERMS.passengers.view,
|
PASSENGER_PERMS.passengers.view,
|
||||||
PASSENGER_PERMS.agents.view,
|
PASSENGER_PERMS.agents.view,
|
||||||
PASSENGER_PERMS.audit.view,
|
PASSENGER_PERMS.audit.view,
|
||||||
@@ -182,6 +185,7 @@ export const ROLE_PERMISSION_PRESETS = {
|
|||||||
ticketOfficer: [
|
ticketOfficer: [
|
||||||
PASSENGER_PERMS.tickets.view,
|
PASSENGER_PERMS.tickets.view,
|
||||||
PASSENGER_PERMS.tickets.manage,
|
PASSENGER_PERMS.tickets.manage,
|
||||||
|
PASSENGER_PERMS.tickets.generate,
|
||||||
PASSENGER_PERMS.bookings.view,
|
PASSENGER_PERMS.bookings.view,
|
||||||
PASSENGER_PERMS.passengers.view,
|
PASSENGER_PERMS.passengers.view,
|
||||||
PASSENGER_PERMS.dashboard.view,
|
PASSENGER_PERMS.dashboard.view,
|
||||||
@@ -194,6 +198,7 @@ export const ROLE_PERMISSION_PRESETS = {
|
|||||||
PASSENGER_PERMS.passengers.view,
|
PASSENGER_PERMS.passengers.view,
|
||||||
PASSENGER_PERMS.tickets.view,
|
PASSENGER_PERMS.tickets.view,
|
||||||
PASSENGER_PERMS.tickets.manage,
|
PASSENGER_PERMS.tickets.manage,
|
||||||
|
PASSENGER_PERMS.tickets.generate,
|
||||||
PASSENGER_PERMS.payments.refund,
|
PASSENGER_PERMS.payments.refund,
|
||||||
PASSENGER_PERMS.dashboard.view,
|
PASSENGER_PERMS.dashboard.view,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
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 DataTable from '@/components/ui/DataTable';
|
||||||
import Badge from '@/components/ui/Badge';
|
import Badge from '@/components/ui/Badge';
|
||||||
import ActionButton from '@/components/ui/ActionButton';
|
import ActionButton from '@/components/ui/ActionButton';
|
||||||
@@ -10,6 +10,22 @@ import Modal from '@/components/ui/Modal';
|
|||||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||||
import { paymentsApi, apiClient } from '@/lib/api';
|
import { paymentsApi, apiClient } from '@/lib/api';
|
||||||
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
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 }) => (
|
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">
|
<div className="bg-muted/40 rounded-lg p-3">
|
||||||
@@ -25,6 +41,7 @@ const SectionHeader = ({ title }: { title: string }) => (
|
|||||||
);
|
);
|
||||||
|
|
||||||
export default function PaymentsPage() {
|
export default function PaymentsPage() {
|
||||||
|
const [pageTab, setPageTab] = useState<PageTab>('payments');
|
||||||
const [filters, setFilters] = useState({ search: '', status: '', method: '' });
|
const [filters, setFilters] = useState({ search: '', status: '', method: '' });
|
||||||
const [selectedPayment, setSelectedPayment] = useState<any>(null);
|
const [selectedPayment, setSelectedPayment] = useState<any>(null);
|
||||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||||
@@ -37,6 +54,34 @@ export default function PaymentsPage() {
|
|||||||
const [exportColumns, setExportColumns] = useState<Record<string, boolean>>({
|
const [exportColumns, setExportColumns] = useState<Record<string, boolean>>({
|
||||||
reference: true, booking: true, amount: true, method: true, status: true, createdAt: true,
|
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();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
@@ -134,9 +179,104 @@ export default function PaymentsPage() {
|
|||||||
<h1 className="text-2xl font-bold text-foreground">Payments</h1>
|
<h1 className="text-2xl font-bold text-foreground">Payments</h1>
|
||||||
<p className="text-muted-foreground">Manage payment transactions and refunds</p>
|
<p className="text-muted-foreground">Manage payment transactions and refunds</p>
|
||||||
</div>
|
</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>
|
</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">
|
<div className="card">
|
||||||
{successMessage && (
|
{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>
|
<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}
|
error={deleteError ?? undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SupplementaryChargesModal isOpen={supplementaryOpen} onClose={() => setSupplementaryOpen(false)} />
|
||||||
|
|
||||||
{/* Export Modal */}
|
{/* Export Modal */}
|
||||||
<Modal isOpen={exportModalOpen} onClose={() => setExportModalOpen(false)} title="Export Payments" size="md">
|
<Modal isOpen={exportModalOpen} onClose={() => setExportModalOpen(false)} title="Export Payments" size="md">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|||||||
@@ -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),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2,13 +2,23 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { Download, TrendingUp, Users, DollarSign, AlertCircle } from 'lucide-react';
|
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 {
|
||||||
|
LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid,
|
||||||
|
Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell,
|
||||||
|
} from 'recharts';
|
||||||
import { bookingsApi } from '@/lib/api';
|
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 ActionButton from '@/components/ui/ActionButton';
|
||||||
import Modal from '@/components/ui/Modal';
|
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
export default function ReportsPage() {
|
export default function ReportsPage() {
|
||||||
const [dateRange, setDateRange] = useState('30');
|
const [dateRange, setDateRange] = useState('30');
|
||||||
@@ -21,23 +31,13 @@ export default function ReportsPage() {
|
|||||||
const end = new Date();
|
const end = new Date();
|
||||||
end.setHours(23, 59, 59, 999);
|
end.setHours(23, 59, 59, 999);
|
||||||
const start = new Date();
|
const start = new Date();
|
||||||
|
|
||||||
switch (dateRange) {
|
switch (dateRange) {
|
||||||
case '7':
|
case '7': start.setDate(end.getDate() - 7); break;
|
||||||
start.setDate(end.getDate() - 7);
|
case '30': start.setDate(end.getDate() - 30); break;
|
||||||
break;
|
case '90': start.setDate(end.getDate() - 90); break;
|
||||||
case '30':
|
|
||||||
start.setDate(end.getDate() - 30);
|
|
||||||
break;
|
|
||||||
case '90':
|
|
||||||
start.setDate(end.getDate() - 90);
|
|
||||||
break;
|
|
||||||
default:
|
default:
|
||||||
if (startDate && endDate) {
|
if (startDate && endDate) return { startDate, endDate };
|
||||||
return { startDate, endDate };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
startDate: start.toISOString().split('T')[0],
|
startDate: start.toISOString().split('T')[0],
|
||||||
endDate: end.toISOString().split('T')[0],
|
endDate: end.toISOString().split('T')[0],
|
||||||
@@ -46,37 +46,61 @@ export default function ReportsPage() {
|
|||||||
|
|
||||||
const dates = getDateRange();
|
const dates = getDateRange();
|
||||||
|
|
||||||
// Fetch all bookings
|
// Confirmed-ticket revenue — same source as dashboard
|
||||||
const { data: bookingsData, isLoading } = useQuery({
|
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'],
|
queryKey: ['all-bookings'],
|
||||||
queryFn: () => bookingsApi.getAll({ pageSize: 1000 }),
|
queryFn: () => bookingsApi.getAll({ pageSize: 1000 }),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Filter bookings by date range — exclude CANCELLED from revenue calculations
|
const isLoading = statsLoading || bookingsLoading;
|
||||||
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 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 bookings = allBookings.filter((b: any) => {
|
||||||
const totalRevenue = revenueBookings.reduce((sum: number, b: any) => sum + (b.totalMinor || 0), 0);
|
const d = new Date(b.createdAt).toISOString().split('T')[0];
|
||||||
const totalBookings = bookings.length;
|
return d >= dates.startDate && d <= dates.endDate;
|
||||||
const avgTicketPrice = revenueBookings.length > 0 ? Math.round(totalRevenue / revenueBookings.length) : 0;
|
});
|
||||||
|
|
||||||
// Group by date for revenue chart — exclude cancelled/refunded
|
const confirmedBookings = bookings.filter((b: any) => b.status !== 'CANCELLED' && b.status !== 'REFUNDED');
|
||||||
const byDate = revenueBookings.reduce((acc: Record<string, any>, b: any) => {
|
|
||||||
|
const byDate = confirmedBookings.reduce((acc: Record<string, any>, b: any) => {
|
||||||
const date = new Date(b.createdAt).toISOString().split('T')[0];
|
const date = new Date(b.createdAt).toISOString().split('T')[0];
|
||||||
if (!acc[date]) {
|
if (!acc[date]) acc[date] = { totalMinor: 0, count: 0 };
|
||||||
acc[date] = { totalMinor: 0, count: 0 };
|
|
||||||
}
|
|
||||||
acc[date].totalMinor += b.totalMinor || 0;
|
acc[date].totalMinor += b.totalMinor || 0;
|
||||||
acc[date].count += 1;
|
acc[date].count += 1;
|
||||||
return acc;
|
return acc;
|
||||||
}, {} as Record<string, any>);
|
}, {});
|
||||||
|
|
||||||
const chartData = Object.entries(byDate)
|
const chartData = Object.entries(byDate)
|
||||||
.sort(([a], [b]) => a.localeCompare(b))
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
@@ -86,33 +110,37 @@ export default function ReportsPage() {
|
|||||||
bookings: d.count || 0,
|
bookings: d.count || 0,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const REPORT_COLS = [
|
const avgDailyRevenue = chartData.length > 0 ? Math.round(overallGrand / 100 / chartData.length) : 0;
|
||||||
{ key: 'date', label: 'Date' },
|
|
||||||
{ key: 'revenue', label: 'Revenue (ETB)' },
|
const REPORT_COLS = ['Date', 'Revenue (ETB)', 'Confirmed Bookings'];
|
||||||
{ key: 'bookings', label: 'Bookings' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const doExport = () => {
|
const doExport = () => {
|
||||||
if (!chartData.length) { alert('No data to export'); return; }
|
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 rows = chartData.map(r => [r.date, String(Math.round(r.revenue)), String(r.bookings)]);
|
||||||
const dateStr = new Date().toISOString().split('T')[0];
|
const dateStr = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
if (exportFormat === 'pdf') {
|
if (exportFormat === 'pdf') {
|
||||||
const w = window.open('', '_blank')!;
|
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>`);
|
const thead = REPORT_COLS.map(h => `<th>${esc(h)}</th>`).join('');
|
||||||
w.document.write(`<h2>Revenue Report — ${dates.startDate} to ${dates.endDate}</h2>`);
|
const tbody = rows.map(r => `<tr>${r.map(v => `<td>${esc(v)}</td>`).join('')}</tr>`).join('');
|
||||||
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(
|
||||||
w.document.write(`<table><thead><tr>${headers.map(h => `<th>${h}</th>`).join('')}</tr></thead><tbody>`);
|
`<!DOCTYPE html><html><head><title>Revenue Report</title>` +
|
||||||
rows.forEach(r => { w.document.write(`<tr>${r.map(v => `<td>${v}</td>`).join('')}</tr>`); });
|
`<style>body{font-family:sans-serif;font-size:11px}table{border-collapse:collapse;width:100%}` +
|
||||||
w.document.write('</tbody></table></body></html>');
|
`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();
|
w.document.close(); w.print();
|
||||||
} else if (exportFormat === 'excel') {
|
} 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 blob = new Blob([tsv], { type: 'application/vnd.ms-excel' });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a'); a.href = url; a.download = `revenue-report-${dateStr}.xls`; a.click(); URL.revokeObjectURL(url);
|
const a = document.createElement('a'); a.href = url; a.download = `revenue-report-${dateStr}.xls`; a.click(); URL.revokeObjectURL(url);
|
||||||
} else {
|
} 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 blob = new Blob([csv], { type: 'text/csv' });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a'); a.href = url; a.download = `revenue-report-${dateStr}.csv`; a.click(); URL.revokeObjectURL(url);
|
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);
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-foreground">Reports & Analytics</h1>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Date Range Selector */}
|
{/* Date Range Selector */}
|
||||||
@@ -132,239 +181,327 @@ export default function ReportsPage() {
|
|||||||
<div className="flex items-end gap-4 flex-wrap">
|
<div className="flex items-end gap-4 flex-wrap">
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Date Range</label>
|
<label className="label">Date Range</label>
|
||||||
<select
|
<select className="input" value={dateRange} onChange={(e) => setDateRange(e.target.value)} disabled={isLoading}>
|
||||||
className="input"
|
|
||||||
value={dateRange}
|
|
||||||
onChange={(e) => setDateRange(e.target.value)}
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
<option value="7">Last 7 Days</option>
|
<option value="7">Last 7 Days</option>
|
||||||
<option value="30">Last 30 Days</option>
|
<option value="30">Last 30 Days</option>
|
||||||
<option value="90">Last 90 Days</option>
|
<option value="90">Last 90 Days</option>
|
||||||
<option value="custom">Custom Range</option>
|
<option value="custom">Custom Range</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{dateRange === 'custom' && (
|
{dateRange === 'custom' && (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Start Date</label>
|
<label className="label">Start Date</label>
|
||||||
<input
|
<input type="date" className="input" value={startDate} onChange={(e) => setStartDate(e.target.value)} disabled={isLoading} />
|
||||||
type="date"
|
|
||||||
className="input"
|
|
||||||
value={startDate}
|
|
||||||
onChange={(e) => setStartDate(e.target.value)}
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="label">End Date</label>
|
<label className="label">End Date</label>
|
||||||
<input
|
<input type="date" className="input" value={endDate} onChange={(e) => setEndDate(e.target.value)} disabled={isLoading} />
|
||||||
type="date"
|
|
||||||
className="input"
|
|
||||||
value={endDate}
|
|
||||||
onChange={(e) => setEndDate(e.target.value)}
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ActionButton icon={Download} variant="secondary" disabled={isLoading} onClick={() => setExportModalOpen(true)}>
|
<ActionButton icon={Download} variant="secondary" disabled={isLoading} onClick={() => setExportModalOpen(true)}>
|
||||||
Export
|
Export
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
</div>
|
</div>
|
||||||
{isLoading && (
|
{isLoading && <p className="text-xs text-muted-foreground mt-2">Loading…</p>}
|
||||||
<p className="text-xs text-muted-foreground mt-2">Loading...</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Key Metrics */}
|
{/* Key Metrics */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
<div className="card">
|
{/* Total Revenue */}
|
||||||
<div className="flex items-start justify-between">
|
<div className="card flex flex-col gap-1">
|
||||||
<div>
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-muted-foreground text-sm font-medium">Total Revenue</p>
|
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Total Revenue</p>
|
||||||
<p className="text-2xl font-bold mt-2">ETB {Math.round(totalRevenue / 100).toLocaleString()}</p>
|
<div className="rounded-lg bg-amber-100 dark:bg-amber-900/30 p-1.5">
|
||||||
<p className="text-xs text-muted-foreground mt-1">Excl. cancelled & refunded</p>
|
<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>
|
</div>
|
||||||
<DollarSign className="h-8 w-8 text-blue-500 opacity-20" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card">
|
{/* Total Bookings */}
|
||||||
<div className="flex items-start justify-between">
|
<div className="card flex flex-col gap-1">
|
||||||
<div>
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-muted-foreground text-sm font-medium">Total Bookings</p>
|
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Total Bookings</p>
|
||||||
<p className="text-2xl font-bold mt-2">{totalBookings.toLocaleString()}</p>
|
<div className="rounded-lg bg-blue-100 dark:bg-blue-900/30 p-1.5">
|
||||||
<p className="text-xs text-muted-foreground mt-1">All bookings</p>
|
<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>
|
</div>
|
||||||
<Users className="h-8 w-8 text-green-500 opacity-20" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card">
|
{/* Total Tickets */}
|
||||||
<div className="flex items-start justify-between">
|
<div className="card flex flex-col gap-1">
|
||||||
<div>
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-muted-foreground text-sm font-medium">Avg. Ticket Price</p>
|
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Total Tickets</p>
|
||||||
<p className="text-2xl font-bold mt-2">ETB {(avgTicketPrice / 100).toLocaleString()}</p>
|
<div className="rounded-lg bg-emerald-100 dark:bg-emerald-900/30 p-1.5">
|
||||||
<p className="text-xs text-muted-foreground mt-1">Non-cancelled bookings</p>
|
<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>
|
</div>
|
||||||
<TrendingUp className="h-8 w-8 text-purple-500 opacity-20" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card">
|
{/* Avg Daily Revenue */}
|
||||||
<div className="flex items-start justify-between">
|
<div className="card flex flex-col gap-1">
|
||||||
<div>
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-muted-foreground text-sm font-medium">Avg. Daily Revenue</p>
|
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">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>
|
<div className="rounded-lg bg-purple-100 dark:bg-purple-900/30 p-1.5">
|
||||||
<p className="text-xs text-muted-foreground mt-1">Daily average</p>
|
<TrendingUp className="h-4 w-4 text-purple-600 dark:text-purple-400" />
|
||||||
</div>
|
</div>
|
||||||
<AlertCircle className="h-8 w-8 text-orange-500 opacity-20" />
|
|
||||||
</div>
|
</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>
|
||||||
</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 */}
|
{/* Charts */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
{/* Revenue Trend */}
|
{/* Revenue Trend */}
|
||||||
<div className="card">
|
<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 ? (
|
{chartData.length > 0 ? (
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={280}>
|
||||||
<LineChart data={chartData}>
|
<LineChart data={chartData}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
||||||
<XAxis dataKey="date" tick={{ fontSize: 12 }} />
|
<XAxis dataKey="date" tick={{ fontSize: 11 }} />
|
||||||
<YAxis tick={{ fontSize: 12 }} />
|
<YAxis tick={{ fontSize: 11 }} />
|
||||||
<Tooltip formatter={(value: number) => `ETB ${Math.round(value).toLocaleString()}`} />
|
<Tooltip formatter={(value: number) => [`ETB ${Math.round(value).toLocaleString()}`, 'Revenue']} />
|
||||||
<Legend />
|
<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>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
|
<div className="h-[280px] flex items-center justify-center text-muted-foreground text-sm">
|
||||||
No data available
|
No data for selected range
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Daily Bookings */}
|
{/* Daily Confirmed Bookings */}
|
||||||
<div className="card">
|
<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 ? (
|
{chartData.length > 0 ? (
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={280}>
|
||||||
<BarChart data={chartData}>
|
<BarChart data={chartData}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
||||||
<XAxis dataKey="date" tick={{ fontSize: 12 }} />
|
<XAxis dataKey="date" tick={{ fontSize: 11 }} />
|
||||||
<YAxis tick={{ fontSize: 12 }} />
|
<YAxis tick={{ fontSize: 11 }} />
|
||||||
<Tooltip />
|
<Tooltip />
|
||||||
<Bar dataKey="bookings" fill="#10b981" />
|
<Bar dataKey="bookings" fill="#3b82f6" radius={[3, 3, 0, 0]} />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
|
<div className="h-[280px] flex items-center justify-center text-muted-foreground text-sm">
|
||||||
No data available
|
No data for selected range
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Booking Status Distribution */}
|
{/* Booking Status Distribution */}
|
||||||
<div className="card">
|
<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 ? (
|
{bookings.length > 0 ? (
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={280}>
|
||||||
<PieChart>
|
<PieChart>
|
||||||
<Pie
|
<Pie
|
||||||
data={[
|
data={[
|
||||||
{ name: 'Confirmed', value: bookings.filter((b: any) => b.status === 'CONFIRMED').length },
|
{ 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: 'Cancelled', value: bookings.filter((b: any) => b.status === 'CANCELLED').length },
|
||||||
{ name: 'Other', value: bookings.filter((b: any) => !['CONFIRMED', 'BOARDED', 'CANCELLED'].includes(b.status)).length },
|
{ name: 'Other', value: bookings.filter((b: any) => !['CONFIRMED', 'BOARDED', 'CANCELLED'].includes(b.status)).length },
|
||||||
].filter(d => d.value > 0)}
|
].filter(d => d.value > 0)}
|
||||||
cx="50%"
|
cx="50%" cy="50%"
|
||||||
cy="50%"
|
|
||||||
labelLine={false}
|
labelLine={false}
|
||||||
label={({ name, value }) => `${name}: ${value}`}
|
label={({ name, value }) => `${name}: ${value}`}
|
||||||
outerRadius={100}
|
outerRadius={100}
|
||||||
dataKey="value"
|
dataKey="value"
|
||||||
>
|
>
|
||||||
{COLORS.map((color, idx) => <Cell key={idx} fill={color} />)}
|
{STATUS_COLORS.map((color, idx) => <Cell key={idx} fill={color} />)}
|
||||||
</Pie>
|
</Pie>
|
||||||
<Tooltip />
|
<Tooltip />
|
||||||
</PieChart>
|
</PieChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
|
<div className="h-[280px] flex items-center justify-center text-muted-foreground text-sm">
|
||||||
No data available
|
No data for selected range
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Top Payment Methods */}
|
{/* Payment Methods */}
|
||||||
<div className="card">
|
<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 ? (
|
{bookings.length > 0 ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3 pt-1">
|
||||||
{(Object.entries(
|
{(Object.entries(
|
||||||
bookings.reduce((acc: Record<string, number>, b: any) => {
|
bookings.reduce((acc: Record<string, number>, b: any) => {
|
||||||
const method = b.paymentIntent?.method || 'Unknown';
|
const method = b.paymentIntent?.method || 'Unknown';
|
||||||
acc[method] = (acc[method] || 0) + 1;
|
acc[method] = (acc[method] || 0) + 1;
|
||||||
return acc;
|
return acc;
|
||||||
}, {} as Record<string, number>)
|
}, {} as Record<string, number>)
|
||||||
) as [string, number][]
|
) as [string, number][])
|
||||||
)
|
|
||||||
.sort(([, a], [, b]) => b - a)
|
.sort(([, a], [, b]) => b - a)
|
||||||
.slice(0, 5)
|
.slice(0, 6)
|
||||||
.map(([method, count]) => (
|
.map(([method, count]) => {
|
||||||
<div key={method} className="flex justify-between items-center p-2 bg-gray-50 dark:bg-gray-900 rounded">
|
const pct = bookings.length > 0 ? Math.round((count / bookings.length) * 100) : 0;
|
||||||
<span className="text-sm capitalize">{method.toLowerCase().replace(/_/g, ' ')}</span>
|
return (
|
||||||
<span className="font-semibold">{count}</span>
|
<div key={method} className="flex items-center gap-3">
|
||||||
</div>
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
|
<div className="h-[280px] flex items-center justify-center text-muted-foreground text-sm">
|
||||||
No data available
|
No data for selected range
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Summary Stats */}
|
{/* Summary */}
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h3 className="text-lg font-semibold mb-4">Summary</h3>
|
<h3 className="text-base font-semibold mb-4">Summary</h3>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||||
<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>
|
{ label: 'Active Days', value: chartData.length, fromStats: false },
|
||||||
<p className="text-xl font-bold mt-2">{chartData.length}</p>
|
{ label: 'Confirmed', value: bookings.filter((b: any) => b.status === 'CONFIRMED').length, fromStats: false },
|
||||||
</div>
|
{ label: 'Boarded', value: bookings.filter((b: any) => b.status === 'BOARDED').length, fromStats: false },
|
||||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
{ label: 'Cancelled', value: bookings.filter((b: any) => b.status === 'CANCELLED').length, fromStats: false },
|
||||||
<p className="text-sm text-muted-foreground">Confirmed Bookings</p>
|
{ label: 'Regular Bookings', value: stats?.totalNormalBookings ?? 0, fromStats: true },
|
||||||
<p className="text-xl font-bold mt-2">{bookings.filter((b: any) => b.status === 'CONFIRMED').length}</p>
|
{ label: 'Package Bookings', value: stats?.totalPackageBookings ?? 0, fromStats: true },
|
||||||
</div>
|
].map(({ label, value, fromStats }) => (
|
||||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
<div key={label} className="border border-border rounded-lg p-3 text-center">
|
||||||
<p className="text-sm text-muted-foreground">Completed Bookings</p>
|
<p className="text-xs text-muted-foreground">{label}</p>
|
||||||
<p className="text-xl font-bold mt-2">{bookings.filter((b: any) => b.status === 'BOARDED').length}</p>
|
<p className="text-xl font-bold mt-1 tabular-nums">
|
||||||
</div>
|
{fromStats && statsLoading ? '—' : value.toLocaleString()}
|
||||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
</p>
|
||||||
<p className="text-sm text-muted-foreground">Cancelled Bookings</p>
|
</div>
|
||||||
<p className="text-xl font-bold mt-2">{bookings.filter((b: any) => b.status === 'CANCELLED').length}</p>
|
))}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Export Modal */}
|
{/* Export Modal */}
|
||||||
<Modal isOpen={exportModalOpen} onClose={() => setExportModalOpen(false)} title="Export Revenue Report" size="sm">
|
<Modal isOpen={exportModalOpen} onClose={() => setExportModalOpen(false)} title="Export Revenue Report" size="sm">
|
||||||
<div className="space-y-4">
|
<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>
|
<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">
|
<div className="flex gap-3">
|
||||||
{(['csv', 'excel', 'pdf'] as const).map(fmt => (
|
{(['csv', 'excel', 'pdf'] as const).map(fmt => (
|
||||||
<label key={fmt} className="flex items-center gap-2 cursor-pointer">
|
<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" />
|
<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>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
324
apps/edr-passenger-web/backoffice/src/app/reports/seats/page.tsx
Normal file
324
apps/edr-passenger-web/backoffice/src/app/reports/seats/page.tsx
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Download, Armchair, CheckCircle, Clock, AlertCircle } from 'lucide-react';
|
||||||
|
import { bookingsApi } 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: 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-3 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>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -100,7 +100,7 @@ export default function BaggageTab({ allClasses, isOpen, onClose }: Props) {
|
|||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
isOpen={deleteConfirm.isOpen}
|
isOpen={deleteConfirm.isOpen}
|
||||||
onClose={() => setDeleteConfirm({ isOpen: false, id: null })}
|
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"
|
title="Delete Allowance Rule"
|
||||||
message="Are you sure you want to delete this baggage allowance rule?"
|
message="Are you sure you want to delete this baggage allowance rule?"
|
||||||
confirmText="Delete"
|
confirmText="Delete"
|
||||||
|
|||||||
@@ -119,7 +119,8 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
|
|||||||
{
|
{
|
||||||
title: 'Analytics & Reports',
|
title: 'Analytics & Reports',
|
||||||
items: [
|
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: 'Operational Reports', href: '/operational-reports', icon: FileText, permission: PERMS.reports.view },
|
// { name: 'Operational Reports', href: '/operational-reports', icon: FileText, permission: PERMS.reports.view },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -184,6 +184,25 @@ export const paymentsApi = {
|
|||||||
addMethod: (data: any) => apiClient.post('/payments/methods', data),
|
addMethod: (data: any) => apiClient.post('/payments/methods', data),
|
||||||
updateMethod: (id: string, data: any) => apiClient.patch(`/payments/methods/${id}`, data),
|
updateMethod: (id: string, data: any) => apiClient.patch(`/payments/methods/${id}`, data),
|
||||||
deleteMethod: (id: string) => apiClient.delete(`/payments/methods/${id}`),
|
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
|
// Tickets API
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export const PERMS = {
|
|||||||
tickets: {
|
tickets: {
|
||||||
view: 'edr_passenger_app:tickets:view',
|
view: 'edr_passenger_app:tickets:view',
|
||||||
manage: 'edr_passenger_app:tickets:manage',
|
manage: 'edr_passenger_app:tickets:manage',
|
||||||
|
generate: 'edr_passenger_app:tickets:generate',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Master Data ────────────────────────────────────────────────
|
// ── Master Data ────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -68,7 +68,11 @@ export default function ConfirmationPage() {
|
|||||||
import("@/lib/generate-voucher");
|
import("@/lib/generate-voucher");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const { data: _booking } = useQuery<BookingWithTicket>({
|
const {
|
||||||
|
data: _booking,
|
||||||
|
refetch: refetchBooking,
|
||||||
|
isLoading: isBookingLoading,
|
||||||
|
} = useQuery<BookingWithTicket>({
|
||||||
queryKey: ["booking", bookingId],
|
queryKey: ["booking", bookingId],
|
||||||
queryFn: async (): Promise<BookingWithTicket> => {
|
queryFn: async (): Promise<BookingWithTicket> => {
|
||||||
try {
|
try {
|
||||||
@@ -88,6 +92,23 @@ export default function ConfirmationPage() {
|
|||||||
enabled: !!bookingId,
|
enabled: !!bookingId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Poll the payment intent every 10 s while the booking is PENDING_PAYMENT.
|
||||||
|
// 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,
|
||||||
|
refetchInterval: 10_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (intentStatus?.status === "SUCCEEDED") {
|
||||||
|
refetchBooking();
|
||||||
|
}
|
||||||
|
}, [intentStatus?.status]);
|
||||||
|
|
||||||
// Only trust an actually-confirmed booking to show ticket numbers / a "CONFIRMED" badge —
|
// 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).
|
// 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
|
// Ticket generation itself is never triggered from this page — the payment webhook
|
||||||
@@ -254,6 +275,26 @@ export default function ConfirmationPage() {
|
|||||||
|
|
||||||
if (!bookingId || !pnr) return null;
|
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).
|
||||||
|
if (isBookingLoading) {
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="booking-page">
|
<div className="booking-page">
|
||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-4">
|
||||||
|
|||||||
@@ -125,6 +125,22 @@ function BookingDetailContent() {
|
|||||||
booking?.status === "PENDING_PAYMENT" || booking?.status === "DRAFT",
|
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 =
|
const selectedPaymentMethod =
|
||||||
(paymentMethods || []).find((m: any) => m.type === selectedMethod) || null;
|
(paymentMethods || []).find((m: any) => m.type === selectedMethod) || null;
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { format } from "date-fns";
|
|||||||
import { formatTime, getTimePeriod, toZonedDate } from "@/utils/format";
|
import { formatTime, getTimePeriod, toZonedDate } from "@/utils/format";
|
||||||
import { formatFare } from "@/utils/fare-utils";
|
import { formatFare } from "@/utils/fare-utils";
|
||||||
import { useState, useEffect } from "react";
|
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"
|
// 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.
|
// 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 [effectiveDepartureDate, setEffectiveDepartureDate] = useState<string>('');
|
||||||
const [effectiveReturnDate, setEffectiveReturnDate] = 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 [classModal, setClassModal] = useState<Schedule | null>(null);
|
||||||
const [promoData, setPromoData] = useState<{
|
const [promoData, setPromoData] = useState<{
|
||||||
code: string;
|
code: string;
|
||||||
@@ -96,11 +102,14 @@ export default function ResultsPage() {
|
|||||||
promoCode: searchParams.get("promoCode") || searchCriteria?.promoCode || "",
|
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(() => {
|
useEffect(() => {
|
||||||
if (searchData.date && !effectiveDepartureDate) setEffectiveDepartureDate(searchData.date);
|
if (searchData.date) setEffectiveDepartureDate(searchData.date);
|
||||||
if (searchData.returnDate && !effectiveReturnDate) setEffectiveReturnDate(searchData.returnDate);
|
if (searchData.returnDate) setEffectiveReturnDate(searchData.returnDate);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [searchData.date, searchData.returnDate]);
|
}, [searchData.date, searchData.returnDate]);
|
||||||
|
|
||||||
const nat = (searchData.nationality ?? '').toUpperCase();
|
const nat = (searchData.nationality ?? '').toUpperCase();
|
||||||
@@ -161,6 +170,46 @@ export default function ResultsPage() {
|
|||||||
return `/booking/search?${params}`;
|
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 {
|
const {
|
||||||
data: results,
|
data: results,
|
||||||
isLoading,
|
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) {
|
if (isOneWayNoOutbound) {
|
||||||
return (
|
return (
|
||||||
<div className="booking-page">
|
<div className="booking-page">
|
||||||
@@ -1078,42 +1211,26 @@ export default function ResultsPage() {
|
|||||||
No trains available
|
No trains available
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
|
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
|
||||||
There are no trains scheduled on{" "}
|
No trains available on your selected date. Please choose another
|
||||||
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
date below.
|
||||||
{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.
|
|
||||||
</p>
|
</p>
|
||||||
<button
|
{alternativeOutbound.length > 0 ? (
|
||||||
onClick={() => router.push(buildSearchUrl())}
|
<AlternativeDatesCalendar
|
||||||
className="btn-primary inline-flex items-center gap-2"
|
alternatives={alternativeOutbound}
|
||||||
>
|
value={searchData.date ? new Date(`${searchData.date}T00:00:00`) : undefined}
|
||||||
<Calendar className="w-4 h-4" />
|
minDate={new Date()}
|
||||||
Change Date
|
onChange={(date) => pushResultsWithDates({ date: format(date, "yyyy-MM-dd") })}
|
||||||
</button>
|
/>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => router.push(buildSearchUrl())}
|
||||||
|
className="btn-primary inline-flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Calendar className="w-4 h-4" />
|
||||||
|
Change Date
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1249,28 +1366,23 @@ export default function ResultsPage() {
|
|||||||
</div>
|
</div>
|
||||||
{outboundSchedules.length === 0 && (
|
{outboundSchedules.length === 0 && (
|
||||||
<div className="mt-6">
|
<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 px-4 py-3 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 mb-4">
|
||||||
<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" />
|
||||||
<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>
|
||||||
<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>
|
||||||
</div>
|
{alternativeOutbound.length > 0 ? (
|
||||||
<button onClick={() => router.push(buildSearchUrl())} className="text-sm font-semibold text-primary hover:underline flex-shrink-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
|
Change dates
|
||||||
</button>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1335,28 +1447,23 @@ export default function ResultsPage() {
|
|||||||
</div>
|
</div>
|
||||||
{inboundSchedules.length === 0 && (
|
{inboundSchedules.length === 0 && (
|
||||||
<div className="mt-6">
|
<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 px-4 py-3 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 mb-4">
|
||||||
<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" />
|
||||||
<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>
|
||||||
<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>
|
||||||
</div>
|
{alternativeInbound.length > 0 ? (
|
||||||
<button onClick={() => router.push(buildSearchUrl())} className="text-sm font-semibold text-primary hover:underline flex-shrink-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
|
Change dates
|
||||||
</button>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -41,6 +41,8 @@ export interface ProviderResultInput {
|
|||||||
confirmedAmountMinor?: number;
|
confirmedAmountMinor?: number;
|
||||||
failureCode?: string;
|
failureCode?: string;
|
||||||
failureMessage?: string;
|
failureMessage?: string;
|
||||||
|
/** Raw provider status-query body, merged into the intent's audit payload when present. */
|
||||||
|
rawResponse?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -357,6 +359,7 @@ export class IntentsService {
|
|||||||
providerTxnId: status.providerTxnId,
|
providerTxnId: status.providerTxnId,
|
||||||
failureCode: status.failureCode,
|
failureCode: status.failureCode,
|
||||||
failureMessage: status.failureMessage,
|
failureMessage: status.failureMessage,
|
||||||
|
rawResponse: status.rawResponse,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,6 +391,15 @@ export class IntentsService {
|
|||||||
return { alreadyTerminal: true };
|
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) {
|
if (result.status === ProviderPaymentStatus.SUCCEEDED) {
|
||||||
const paidAt = result.paidAt ?? new Date();
|
const paidAt = result.paidAt ?? new Date();
|
||||||
intent.status = ProviderPaymentStatus.SUCCEEDED;
|
intent.status = ProviderPaymentStatus.SUCCEEDED;
|
||||||
@@ -482,6 +494,7 @@ export class IntentsService {
|
|||||||
failureCode: intent.failureCode ?? undefined,
|
failureCode: intent.failureCode ?? undefined,
|
||||||
failureMessage: intent.failureMessage ?? undefined,
|
failureMessage: intent.failureMessage ?? undefined,
|
||||||
expiresAt: intent.expiresAt?.toISOString(),
|
expiresAt: intent.expiresAt?.toISOString(),
|
||||||
|
providerResponse: intent.rawInitiation ?? undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,13 +129,17 @@ export class WaafiProvider implements PaymentProvider, OnModuleInit {
|
|||||||
requestBody,
|
requestBody,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Waafi HPP_GETTRANINFO ref=${merchantOrderId} response: ${JSON.stringify(response)}`,
|
||||||
|
);
|
||||||
|
|
||||||
// Waafi returns transaction info (params.status) ONLY when responseCode is 2001. For an
|
// Waafi returns transaction info (params.status) ONLY when responseCode is 2001. For an
|
||||||
// unpaid or not-yet-existing transaction it returns an error envelope (e.g. 5001 / E10206
|
// unpaid or not-yet-existing transaction it returns an error envelope (e.g. 5001 / E10206
|
||||||
// "Failed to get transaction info") with no status. Treat that as still-pending (PROCESSING),
|
// "Failed to get transaction info") with no status. Treat that as still-pending (PROCESSING),
|
||||||
// never terminal — so the intent keeps waiting for the webhook / its expiry rather than being
|
// never terminal — so the intent keeps waiting for the webhook / its expiry rather than being
|
||||||
// wrongly resolved off a "no info" response.
|
// wrongly resolved off a "no info" response.
|
||||||
if (response.responseCode !== WAAFI_SUCCESS_CODE) {
|
if (response.responseCode !== WAAFI_SUCCESS_CODE) {
|
||||||
this.logger.debug(
|
this.logger.warn(
|
||||||
`Waafi HPP_GETTRANINFO ${merchantOrderId}: ${response.responseCode}/${response.errorCode} ${response.responseMsg} — treating as pending`,
|
`Waafi HPP_GETTRANINFO ${merchantOrderId}: ${response.responseCode}/${response.errorCode} ${response.responseMsg} — treating as pending`,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ export enum PaymentService {
|
|||||||
export enum PaymentReferenceType {
|
export enum PaymentReferenceType {
|
||||||
BOOKING = "BOOKING",
|
BOOKING = "BOOKING",
|
||||||
SHIPMENT = "SHIPMENT",
|
SHIPMENT = "SHIPMENT",
|
||||||
|
SUPPLEMENTARY_CHARGE = "SUPPLEMENTARY_CHARGE",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -153,6 +154,13 @@ export type PaymentIntentSnapshot ={
|
|||||||
failureCode?: string;
|
failureCode?: string;
|
||||||
failureMessage?: string;
|
failureMessage?: string;
|
||||||
expiresAt?: 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";
|
export type PaymentEventType = "payment.succeeded" | "payment.failed";
|
||||||
|
|||||||
@@ -620,6 +620,12 @@ export interface IContract extends BaseEntity {
|
|||||||
* clearance view per contract.
|
* clearance view per contract.
|
||||||
*/
|
*/
|
||||||
clearancePhase?: ContractDocPhase | string | null;
|
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;
|
pricingBreakdown?: ContractPricingBreakdown | null;
|
||||||
pricingDisplayMode?: "UNIT_RATES";
|
pricingDisplayMode?: "UNIT_RATES";
|
||||||
|
|||||||
Reference in New Issue
Block a user