mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +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,
|
||||
...(issuing
|
||||
? {
|
||||
status: Freight.InvoiceStatus.Pending,
|
||||
issuedAt: invoice.issuedAt ?? new Date(),
|
||||
}
|
||||
status: Freight.InvoiceStatus.Pending,
|
||||
issuedAt: invoice.issuedAt ?? new Date(),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
await mg.update(Invoice, { id: invoice.id }, patch);
|
||||
@@ -1032,20 +1032,20 @@ export class BillingService {
|
||||
.getRepository(Invoice)
|
||||
.update({ id: invoice.id }, { paymentId: result.intentId });
|
||||
|
||||
// // DEMO: manually fire the gateway `payment.succeeded` callback here, without
|
||||
// // waiting for real gateway settlement. Runs AFTER the paymentId link above so
|
||||
// // `handlePaymentEvent → settleByPaymentId` can correlate the invoice. TODO:
|
||||
// // remove — real settlement flips this via the `${source}.invoice.paid` handler.
|
||||
// if (!result.immediateSuccess) {
|
||||
// await this.payment.handlePaymentEvent({
|
||||
// eventType: "payment.succeeded",
|
||||
// eventId: `demo-${result.intentId}`,
|
||||
// referenceId: invoice.sourceId,
|
||||
// intentId: result.intentId,
|
||||
// providerTxnId: result.providerTxnId,
|
||||
// paidAt: (result.paidAt ?? new Date()).toISOString(),
|
||||
// });
|
||||
// }
|
||||
// DEMO: manually fire the gateway `payment.succeeded` callback here, without
|
||||
// waiting for real gateway settlement. Runs AFTER the paymentId link above so
|
||||
// `handlePaymentEvent → settleByPaymentId` can correlate the invoice. TODO:
|
||||
// remove — real settlement flips this via the `${source}.invoice.paid` handler.
|
||||
if (!result.immediateSuccess) {
|
||||
await this.payment.handlePaymentEvent({
|
||||
eventType: "payment.succeeded",
|
||||
eventId: `demo-${result.intentId}`,
|
||||
referenceId: invoice.sourceId,
|
||||
intentId: result.intentId,
|
||||
providerTxnId: result.providerTxnId,
|
||||
paidAt: (result.paidAt ?? new Date()).toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
if (result.immediateSuccess) {
|
||||
await this.settleByPaymentId(
|
||||
|
||||
@@ -204,17 +204,27 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
private async attachClearancePhases(contracts: Contract[]): Promise<void> {
|
||||
if (contracts.length === 0) return;
|
||||
const ids = contracts.map((c) => c.id);
|
||||
const rows: Array<{ contract_id: string; current_phase: string | null }> =
|
||||
await this.dataSource.query(
|
||||
`SELECT DISTINCT ON (contract_id) contract_id, current_phase
|
||||
FROM freight.contract_clearance_cycles
|
||||
WHERE contract_id = ANY($1)
|
||||
ORDER BY contract_id, cycle_number DESC`,
|
||||
[ids],
|
||||
);
|
||||
const byContract = new Map(rows.map((r) => [r.contract_id, r.current_phase]));
|
||||
const rows: Array<{
|
||||
contract_id: string;
|
||||
current_phase: string | null;
|
||||
booking_id: string | null;
|
||||
booking_status: string | null;
|
||||
}> = await this.dataSource.query(
|
||||
`SELECT DISTINCT ON (ccc.contract_id)
|
||||
ccc.contract_id, ccc.current_phase,
|
||||
b.id AS booking_id, b.status AS booking_status
|
||||
FROM freight.contract_clearance_cycles ccc
|
||||
LEFT JOIN freight.bookings b ON b.id = ccc.booking_id
|
||||
WHERE ccc.contract_id = ANY($1)
|
||||
ORDER BY ccc.contract_id, ccc.cycle_number DESC`,
|
||||
[ids],
|
||||
);
|
||||
const byContract = new Map(rows.map((r) => [r.contract_id, r]));
|
||||
for (const contract of contracts) {
|
||||
contract.clearancePhase = byContract.get(contract.id) ?? null;
|
||||
const row = byContract.get(contract.id);
|
||||
contract.clearancePhase = row?.current_phase ?? null;
|
||||
contract.latestCycleBookingId = row?.booking_id ?? null;
|
||||
contract.latestCycleBookingStatus = row?.booking_status ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -312,6 +312,14 @@ export class Contract extends BaseEntity {
|
||||
*/
|
||||
clearancePhase?: string | null;
|
||||
|
||||
/**
|
||||
* Latest clearance cycle's linked booking (id + status), attached alongside
|
||||
* clearancePhase. Lets the GL queue tell an expired (unpaid) booking apart
|
||||
* from a live one so it can offer a rebook. Not columns.
|
||||
*/
|
||||
latestCycleBookingId?: string | null;
|
||||
latestCycleBookingStatus?: string | null;
|
||||
|
||||
/**
|
||||
* Body of the most recent CHANGES_REQUESTED review note, attached by
|
||||
* ContractsService.findById so the portal can show the customer what staff
|
||||
|
||||
@@ -1,16 +1,36 @@
|
||||
import { IsArray, IsOptional, IsString, IsUUID, ValidateNested } from 'class-validator';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayUnique,
|
||||
IsArray,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class LastMileVehicleInput {
|
||||
@IsUUID()
|
||||
vehicleId!: string;
|
||||
|
||||
/**
|
||||
* Containers this truck carries: one 40ft, or up to two 20ft. Omit for bulk
|
||||
* (the truck hauls loose tonnage and is weighed out on exit).
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(2)
|
||||
@ArrayUnique()
|
||||
@IsString({ each: true })
|
||||
containerNumbers?: string[];
|
||||
|
||||
/** @deprecated Single-container form — use `containerNumbers`. Still accepted. */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
containerNumber?: string;
|
||||
}
|
||||
|
||||
/** Replace the full set of vehicles (with their container numbers) on a delivery. */
|
||||
/** Replace the full set of vehicles (with their containers) on a delivery. */
|
||||
export class SetVehiclesDto {
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne, Unique } from 'typeorm';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, Unique } from 'typeorm';
|
||||
|
||||
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
|
||||
import { LastMile } from './last-mile.entity';
|
||||
import { LastMileVehicleContainer } from './last-mile-vehicle-container.entity';
|
||||
|
||||
/**
|
||||
* One row per vehicle assigned to a last-mile delivery. A delivery can be
|
||||
@@ -28,12 +29,34 @@ export class LastMileVehicleAssignment extends BaseEntity {
|
||||
@JoinColumn({ name: 'vehicle_id' })
|
||||
vehicle?: Vehicle;
|
||||
|
||||
/** Container this truck carries — auto-filled from the booking's container
|
||||
* number when known, else entered manually at assignment time. */
|
||||
/** Legacy single container this truck carries. Kept in sync with the FIRST
|
||||
* entry of `containers` for backward compatibility — a truck can hold 1x40ft
|
||||
* or 2x20ft, so `containers` is the authoritative list. */
|
||||
@Column({ name: 'container_number', type: 'varchar', nullable: true })
|
||||
containerNumber?: string | null;
|
||||
|
||||
/** Containers riding this truck (1x40ft, or up to 2x20ft). */
|
||||
@OneToMany(() => LastMileVehicleContainer, (c) => c.assignment, { cascade: true })
|
||||
containers?: LastMileVehicleContainer[];
|
||||
|
||||
/** Actual distance driven by this truck (km), entered per vehicle. */
|
||||
@Column({ name: 'distance_km', type: 'numeric', precision: 10, scale: 2, nullable: true })
|
||||
distanceKm?: number | null;
|
||||
|
||||
/** This truck reached the warehouse (stamped by the arrival weighing step). */
|
||||
@Column({ name: 'arrived_at', type: 'timestamptz', nullable: true })
|
||||
arrivedAt?: Date | null;
|
||||
|
||||
/** This truck left the warehouse (stamped by the exit weighing step). */
|
||||
@Column({ name: 'departed_at', type: 'timestamptz', nullable: true })
|
||||
departedAt?: Date | null;
|
||||
|
||||
/** Weighed gross on exit, in TONNES (not kg — see the migration note). */
|
||||
@Column({ name: 'gross_weight_tons', type: 'numeric', precision: 14, scale: 3, nullable: true })
|
||||
grossWeightTons?: number | null;
|
||||
|
||||
/** Cargo actually taken by this truck (gross − tare), in TONNES. Drives the
|
||||
* bulk drawdown: remaining = booking VGM − SUM(net) over departed trucks. */
|
||||
@Column({ name: 'net_weight_tons', type: 'numeric', precision: 14, scale: 3, nullable: true })
|
||||
netWeightTons?: number | null;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@Get('booking/:bookingId/remaining-tons')
|
||||
@ApiOperation({ summary: 'Bulk drawdown: tonnage still to be hauled (total − departed trucks)' })
|
||||
remainingTons(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||
return this.lastMileService.remainingTonsForBooking(bookingId);
|
||||
}
|
||||
|
||||
@Post('accept/:reference')
|
||||
@BookingStaff(FREIGHT_PERMS.lastMile.accept)
|
||||
@ApiOperation({ summary: 'Accept a paid booking and create a last-mile leg' })
|
||||
|
||||
@@ -10,6 +10,7 @@ import { VehiclesModule } from '../vehicles/vehicles.module';
|
||||
import { LastMile } from './entities/last-mile.entity';
|
||||
import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity';
|
||||
import { LastMileVehicleAssignment } from './entities/last-mile-vehicle-assignment.entity';
|
||||
import { LastMileVehicleContainer } from './entities/last-mile-vehicle-container.entity';
|
||||
import { LastMileController } from './last-mile.controller';
|
||||
import { LastMileInvoiceService } from './last-mile-invoice.service';
|
||||
import { LastMileRepository } from './last-mile.repository';
|
||||
@@ -17,7 +18,12 @@ import { LastMileService } from './last-mile.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([LastMile, LastMileContainerAllocation, LastMileVehicleAssignment]),
|
||||
TypeOrmModule.forFeature([
|
||||
LastMile,
|
||||
LastMileContainerAllocation,
|
||||
LastMileVehicleAssignment,
|
||||
LastMileVehicleContainer,
|
||||
]),
|
||||
BillingModule,
|
||||
forwardRef(() => BookingsModule),
|
||||
VehiclesModule,
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { DataSource, FindOptionsWhere, In, IsNull, Not } from 'typeorm';
|
||||
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
@@ -12,6 +18,7 @@ import { RecordProofOfDeliveryDto } from './dto/record-proof-of-delivery.dto';
|
||||
import { LastMile, LastMileStatus } from './entities/last-mile.entity';
|
||||
import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity';
|
||||
import { LastMileVehicleAssignment } from './entities/last-mile-vehicle-assignment.entity';
|
||||
import { LastMileVehicleContainer } from './entities/last-mile-vehicle-container.entity';
|
||||
import { LastMileRepository } from './last-mile.repository';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { BillingService, InvoiceEventPayload } from '../billing/billing.service';
|
||||
@@ -175,7 +182,7 @@ export class LastMileService {
|
||||
relations: {
|
||||
booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true, bookingContainers: { containerType: true, units: true } },
|
||||
vehicle: true,
|
||||
vehicleAssignments: { vehicle: true },
|
||||
vehicleAssignments: { vehicle: true, containers: true },
|
||||
},
|
||||
order: { [sortBy]: sortOrder },
|
||||
skip: (page - 1) * pageSize,
|
||||
@@ -200,7 +207,7 @@ export class LastMileService {
|
||||
relations: {
|
||||
booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true, bookingContainers: { containerType: true, units: true } },
|
||||
vehicle: true,
|
||||
vehicleAssignments: { vehicle: true },
|
||||
vehicleAssignments: { vehicle: true, containers: true },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -277,7 +284,7 @@ export class LastMileService {
|
||||
> {
|
||||
const [lm] = await this.lastMileRepository.findAll({
|
||||
where: { bookingId },
|
||||
relations: { vehicle: true, vehicleAssignments: { vehicle: true } },
|
||||
relations: { vehicle: true, vehicleAssignments: { vehicle: true, containers: true } },
|
||||
take: 1,
|
||||
});
|
||||
if (!lm) return [];
|
||||
@@ -552,22 +559,164 @@ export class LastMileService {
|
||||
* for each added/removed vehicle. The first vehicle is mirrored onto the legacy
|
||||
* `vehicleId` column for back-compat with single-vehicle readers.
|
||||
*/
|
||||
/** Container numbers on the booking (upper-cased). */
|
||||
private async bookingContainerNumbers(bookingId: string): Promise<string[]> {
|
||||
const rows: Array<{ containerNumber: string }> = await this.dataSource.query(
|
||||
`SELECT bcu.container_number AS "containerNumber"
|
||||
FROM freight.booking_container_units bcu
|
||||
JOIN freight.booking_container bc
|
||||
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||||
WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL`,
|
||||
[bookingId],
|
||||
);
|
||||
return rows.map((r) => r.containerNumber.trim().toUpperCase());
|
||||
}
|
||||
|
||||
/** Contract container sizes (e.g. "20ft" / "40ft") for the given numbers. */
|
||||
private async containerSizes(bookingId: string, numbers: string[]): Promise<string[]> {
|
||||
if (!numbers.length) return [];
|
||||
const rows: Array<{ size: string | null }> = await this.dataSource.query(
|
||||
`SELECT bc.container_size AS "size"
|
||||
FROM freight.booking_container_units bcu
|
||||
JOIN freight.booking_container bc
|
||||
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||||
WHERE bc.booking_id = $1
|
||||
AND UPPER(bcu.container_number) = ANY($2)
|
||||
AND bcu.deleted_at IS NULL`,
|
||||
[bookingId, numbers],
|
||||
);
|
||||
return rows.map((r) => (r.size ?? '').trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk drawdown: how much of the booking's tonnage is still to be hauled —
|
||||
* the booking VGM total minus the net weighed off every EDR truck that has
|
||||
* already left. Both sides are tonnes, so no conversion.
|
||||
*/
|
||||
async remainingTonsForBooking(bookingId: string): Promise<{
|
||||
totalTons: number;
|
||||
hauledTons: number;
|
||||
remainingTons: number;
|
||||
complete: boolean;
|
||||
}> {
|
||||
const [row]: Array<{ totalTons: string | null; hauledTons: string | null }> =
|
||||
await this.dataSource.query(
|
||||
`SELECT COALESCE(b.cargo_total_weight_vgm, 0) AS "totalTons",
|
||||
COALESCE((
|
||||
SELECT SUM(va.net_weight_tons)
|
||||
FROM freight.last_mile_vehicle_assignments va
|
||||
JOIN freight.last_mile lm
|
||||
ON lm.id = va.last_mile_id AND lm.deleted_at IS NULL
|
||||
WHERE lm.booking_id = b.id
|
||||
AND va.deleted_at IS NULL
|
||||
AND va.departed_at IS NOT NULL
|
||||
), 0) AS "hauledTons"
|
||||
FROM freight.bookings b
|
||||
WHERE b.id = $1 AND b.deleted_at IS NULL`,
|
||||
[bookingId],
|
||||
);
|
||||
const totalTons = Number(row?.totalTons ?? 0);
|
||||
const hauledTons = Number(row?.hauledTons ?? 0);
|
||||
const remainingTons = Math.max(0, Math.round((totalTons - hauledTons) * 1000) / 1000);
|
||||
return { totalTons, hauledTons, remainingTons, complete: totalTons > 0 && remainingTons <= 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Truck capacity rules for a last-mile delivery.
|
||||
* - CONTAINER: a truck carries ONE 40ft or up to TWO 20ft; every container
|
||||
* must belong to the booking and ride exactly one truck; never more trucks
|
||||
* than containers.
|
||||
* - BULK: no containers — trucks haul loose tonnage, so the only limit is
|
||||
* that there is tonnage left to haul.
|
||||
*/
|
||||
private async assertVehicleLoads(
|
||||
bookingId: string,
|
||||
desired: string[],
|
||||
loads: Map<string, string[]>,
|
||||
): Promise<void> {
|
||||
if (!desired.length) return;
|
||||
|
||||
const [booking]: Array<{ freightType: string | null }> = await this.dataSource.query(
|
||||
`SELECT freight_type AS "freightType"
|
||||
FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[bookingId],
|
||||
);
|
||||
if ((booking?.freightType ?? '').toUpperCase() === 'BULK') {
|
||||
const { remainingTons, totalTons } = await this.remainingTonsForBooking(bookingId);
|
||||
if (totalTons > 0 && remainingTons <= 0) {
|
||||
throw new BadRequestException(
|
||||
'This bulk booking is fully hauled — no tonnage left to assign trucks for',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
|
||||
if (!bookingNumbers.length) return; // nothing to validate against
|
||||
|
||||
const seen = new Set<string>();
|
||||
for (const vehicleId of desired) {
|
||||
const load = loads.get(vehicleId) ?? [];
|
||||
if (load.length > 2) {
|
||||
throw new BadRequestException('A truck carries at most 2 containers');
|
||||
}
|
||||
for (const n of load) {
|
||||
if (!bookingNumbers.includes(n)) {
|
||||
throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
|
||||
}
|
||||
if (seen.has(n)) {
|
||||
throw new ConflictException(`Container ${n} is already assigned to another truck`);
|
||||
}
|
||||
seen.add(n);
|
||||
}
|
||||
// A 40ft container fills the truck; only two 20ft share one.
|
||||
if (load.length > 1) {
|
||||
const sizes = await this.containerSizes(bookingId, load);
|
||||
if (sizes.some((s) => s.includes('40'))) {
|
||||
throw new BadRequestException(
|
||||
'A 40ft container fills the truck — assign only 1 container to this truck',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (desired.length > bookingNumbers.length) {
|
||||
throw new BadRequestException(
|
||||
`Cannot assign more trucks than containers — this booking has ${bookingNumbers.length} container(s) and ${desired.length} truck(s) requested.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async setVehicles(
|
||||
id: string,
|
||||
inputs: Array<{ vehicleId: string; containerNumber?: string | null }>,
|
||||
inputs: Array<{
|
||||
vehicleId: string;
|
||||
containerNumbers?: string[] | null;
|
||||
containerNumber?: string | null;
|
||||
}>,
|
||||
): Promise<LastMile> {
|
||||
const existing = await this.findById(id);
|
||||
// Dedupe by vehicleId, keeping the container number; preserve order.
|
||||
const desiredMap = new Map<string, string | null>();
|
||||
// Dedupe by vehicleId, keeping the container load; preserve order. Accepts
|
||||
// the legacy single `containerNumber` as a one-element load.
|
||||
const desiredMap = new Map<string, string[]>();
|
||||
for (const inp of inputs) {
|
||||
if (inp.vehicleId) desiredMap.set(inp.vehicleId, inp.containerNumber ?? null);
|
||||
if (!inp.vehicleId) continue;
|
||||
const load = (inp.containerNumbers ?? (inp.containerNumber ? [inp.containerNumber] : []))
|
||||
.map((n) => String(n).trim().toUpperCase())
|
||||
.filter(Boolean);
|
||||
desiredMap.set(inp.vehicleId, load);
|
||||
}
|
||||
const desired = [...desiredMap.keys()];
|
||||
const desiredSet = new Set(desired);
|
||||
|
||||
// Capacity + membership rules (a truck holds one 40ft or two 20ft; bulk
|
||||
// hauls tonnage until the booking is drawn down).
|
||||
await this.assertVehicleLoads(existing.bookingId, desired, desiredMap);
|
||||
|
||||
const manager = this.dataSource.manager;
|
||||
const current = await manager.find(LastMileVehicleAssignment, {
|
||||
where: { lastMileId: id },
|
||||
relations: { containers: true },
|
||||
});
|
||||
const junctionSet = new Set(current.map((a) => a.vehicleId));
|
||||
// Fold the legacy vehicleId into the release set — a vehicle assigned via the
|
||||
@@ -588,33 +737,58 @@ export class LastMileService {
|
||||
);
|
||||
}
|
||||
}
|
||||
// Vehicles that stay but whose container number changed.
|
||||
// Vehicles that stay but whose container load changed (order-insensitive).
|
||||
const loadKey = (list: string[]) => [...list].sort().join('|');
|
||||
const currentLoad = (a: LastMileVehicleAssignment) =>
|
||||
(a.containers ?? []).map((c) => c.containerNumber.trim().toUpperCase());
|
||||
const changed = current.filter(
|
||||
(a) =>
|
||||
desiredMap.has(a.vehicleId) &&
|
||||
(a.containerNumber ?? null) !== (desiredMap.get(a.vehicleId) ?? null),
|
||||
loadKey(desiredMap.get(a.vehicleId) ?? []) !== loadKey(currentLoad(a)),
|
||||
);
|
||||
|
||||
await this.dataSource.transaction(async (tx) => {
|
||||
if (removed.length) {
|
||||
// Child containers cascade on delete.
|
||||
await tx.delete(LastMileVehicleAssignment, {
|
||||
lastMileId: id,
|
||||
vehicleId: In(removed),
|
||||
});
|
||||
}
|
||||
for (const vehicleId of added) {
|
||||
await tx.insert(LastMileVehicleAssignment, {
|
||||
const load = desiredMap.get(vehicleId) ?? [];
|
||||
const inserted = await tx.insert(LastMileVehicleAssignment, {
|
||||
lastMileId: id,
|
||||
vehicleId,
|
||||
containerNumber: desiredMap.get(vehicleId) ?? null,
|
||||
// Legacy scalar stays in sync with the first container.
|
||||
containerNumber: load[0] ?? null,
|
||||
});
|
||||
const assignmentId = inserted.identifiers[0]?.id as string | undefined;
|
||||
if (assignmentId && load.length) {
|
||||
await tx.insert(
|
||||
LastMileVehicleContainer,
|
||||
load.map((containerNumber) => ({ assignmentId, lastMileId: id, containerNumber })),
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const row of changed) {
|
||||
const load = desiredMap.get(row.vehicleId) ?? [];
|
||||
await tx.update(
|
||||
LastMileVehicleAssignment,
|
||||
{ lastMileId: id, vehicleId: row.vehicleId },
|
||||
{ containerNumber: desiredMap.get(row.vehicleId) ?? null },
|
||||
{ containerNumber: load[0] ?? null },
|
||||
);
|
||||
await tx.delete(LastMileVehicleContainer, { assignmentId: row.id });
|
||||
if (load.length) {
|
||||
await tx.insert(
|
||||
LastMileVehicleContainer,
|
||||
load.map((containerNumber) => ({
|
||||
assignmentId: row.id,
|
||||
lastMileId: id,
|
||||
containerNumber,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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 { UpdateScheduleWindowRuleDto } from "./dto/update-schedule-window-rule.dto";
|
||||
import { UpdateScheduleDateDto } from "./dto/update-schedule-date.dto";
|
||||
import { MaintenanceRescheduleDto } from "./dto/maintenance-reschedule.dto";
|
||||
import { TrainSchedulingService } from "./train-scheduling.service";
|
||||
import { BookingBatchService } from "./booking-batch.service";
|
||||
import { BookingJourneyService } from "./booking-journey.service";
|
||||
@@ -711,6 +712,20 @@ export class TrainSchedulingController {
|
||||
return this.trainSchedulingService.getContainerTrainScheduleById(id);
|
||||
}
|
||||
|
||||
@Post("schedules/:id/maintenance")
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Maintenance reschedule: move the train to a new departure with every allocated booking aboard — links, wagons and window settings unchanged",
|
||||
})
|
||||
async maintenanceReschedule(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: MaintenanceRescheduleDto,
|
||||
) {
|
||||
await this.trainSchedulingService.maintenanceReschedule(id, dto);
|
||||
return this.trainSchedulingService.getContainerTrainScheduleById(id);
|
||||
}
|
||||
|
||||
@Post("schedules/:id/doc-review-complete")
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({
|
||||
|
||||
@@ -91,6 +91,7 @@ import {
|
||||
import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduling-global-rules.dto';
|
||||
import { UpdateScheduleWindowRuleDto } from './dto/update-schedule-window-rule.dto';
|
||||
import { UpdateScheduleDateDto } from './dto/update-schedule-date.dto';
|
||||
import { MaintenanceRescheduleDto } from './dto/maintenance-reschedule.dto';
|
||||
import { type BookingWindowConfig } from './booking-window.config';
|
||||
import { BookingWindowGateway } from './booking-window.gateway';
|
||||
import { BookingNotifierService } from './booking-notifier.service';
|
||||
@@ -864,6 +865,121 @@ export class TrainSchedulingService {
|
||||
return fresh ?? schedule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maintenance reschedule: the admin moves a train (with everything aboard) to
|
||||
* a new departure. Unlike {@link updateScheduleDate} this runs at ANY window
|
||||
* phase and inside the booking lead window — a maintenance move is an
|
||||
* operational fact, not a planning choice. What moves and what stays:
|
||||
*
|
||||
* - MOVES: scheduledDepartureDate; scheduledArrivalDate (same delta); every
|
||||
* aboard/targeted booking's scheduledDate (the day-pool queries key on it,
|
||||
* so a booking left on the old day would fall out of its own train's pool).
|
||||
* - STAYS: train set, wagon assignments, schedule↔booking links, route,
|
||||
* maxWagons, and the window RULE snapshot. Stamped window times are only
|
||||
* re-derived for PRE_WINDOW schedules (their window hasn't run yet); a
|
||||
* schedule mid- or post-window keeps its timeline untouched.
|
||||
*
|
||||
* Customers of every moved booking are notified (maintenanceMoved).
|
||||
*/
|
||||
async maintenanceReschedule(
|
||||
id: string,
|
||||
dto: MaintenanceRescheduleDto,
|
||||
): Promise<TrainSchedule> {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id);
|
||||
if (!schedule) {
|
||||
throw new NotFoundException(`Train schedule ${id} not found`);
|
||||
}
|
||||
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
|
||||
throw new BadRequestException(
|
||||
`Cannot reschedule a ${schedule.status.toLowerCase()} train`,
|
||||
);
|
||||
}
|
||||
|
||||
const departure = new Date(dto.newDepartureDate);
|
||||
if (Number.isNaN(departure.getTime())) {
|
||||
throw new BadRequestException('Invalid departure date.');
|
||||
}
|
||||
if (departure.getTime() <= Date.now()) {
|
||||
throw new BadRequestException('New departure must be in the future.');
|
||||
}
|
||||
|
||||
const deltaMs =
|
||||
departure.getTime() - new Date(schedule.scheduledDepartureDate).getTime();
|
||||
const scheduledArrivalDate = schedule.scheduledArrivalDate
|
||||
? new Date(new Date(schedule.scheduledArrivalDate).getTime() + deltaMs)
|
||||
: undefined;
|
||||
|
||||
// PRE_WINDOW only: the stamped open/close were derived from the old
|
||||
// departure and the window hasn't opened yet, so re-derive them from the
|
||||
// schedule's own rule snapshot against the new date (joining the target
|
||||
// day's route group timeline when one exists, exactly like
|
||||
// updateScheduleDate). Mid/post-window schedules keep their timeline.
|
||||
const windowFields =
|
||||
schedule.windowPhase === 'PRE_WINDOW'
|
||||
? await (async () => {
|
||||
const merged = effectiveWindowConfig(
|
||||
schedule,
|
||||
await this.getWindowConfig(),
|
||||
);
|
||||
const times =
|
||||
schedule.direction === 'EXPORT'
|
||||
? computeExportWindowTimes(departure, merged)
|
||||
: computeImportWindowTimes(departure, merged, new Date());
|
||||
const anchor =
|
||||
schedule.direction === 'EXPORT'
|
||||
? null
|
||||
: await this.findGroupWindowAnchor(
|
||||
this.dataSource.manager,
|
||||
schedule.originStationId,
|
||||
schedule.destinationStationId,
|
||||
departure,
|
||||
);
|
||||
return anchor
|
||||
? this.groupWindowFieldsFrom(anchor, departure)
|
||||
: {
|
||||
windowOpensAt: times.windowOpensAt,
|
||||
windowClosesAt: times.windowClosesAt,
|
||||
};
|
||||
})()
|
||||
: {};
|
||||
|
||||
await this.dataSource.getRepository(TrainSchedule).update(id, {
|
||||
scheduledDepartureDate: departure,
|
||||
...(scheduledArrivalDate ? { scheduledArrivalDate } : {}),
|
||||
...windowFields,
|
||||
});
|
||||
|
||||
// Everything aboard or targeted rides along: bookings linked on the train
|
||||
// (schedule_bookings) plus reservations still pointing at it via
|
||||
// train_schedule_id (paid-but-unlinked, awaiting payment, …).
|
||||
const linkedIds = (schedule.scheduleBookings ?? []).map((sb) => sb.bookingId);
|
||||
const targeted = await this.dataSource.getRepository(Booking).find({
|
||||
where: [{ trainScheduleId: id }, ...(linkedIds.length ? [{ id: In(linkedIds) }] : [])],
|
||||
relations: { company: true },
|
||||
});
|
||||
const aboard = targeted.filter(
|
||||
(b) => !['CANCELLED', 'EXPIRED', 'REJECTED'].includes(b.status),
|
||||
);
|
||||
if (aboard.length) {
|
||||
await this.dataSource
|
||||
.getRepository(Booking)
|
||||
.update(aboard.map((b) => b.id), { scheduledDate: departure } as never);
|
||||
for (const booking of aboard) {
|
||||
this.bookingNotifier.maintenanceMoved(booking, departure);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`[MAINTENANCE] Schedule ${schedule.reference ?? id} moved to ${departure.toISOString()} ` +
|
||||
`(${dto.trigger ?? 'TRAIN_MAINTENANCE'}${dto.reason ? `: ${dto.reason}` : ''}); ` +
|
||||
`${aboard.length} booking(s) moved with the train.`,
|
||||
);
|
||||
void this.emitWindowState(id);
|
||||
|
||||
const fresh = await this.trainSchedulesRepository.findById(id);
|
||||
return fresh ?? schedule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-derive windowOpensAt/windowClosesAt for schedules whose booking window has
|
||||
* not opened yet (windowPhase === 'PRE_WINDOW', still Draft/Scheduled, departure
|
||||
@@ -4717,6 +4833,7 @@ export class TrainSchedulingService {
|
||||
createdAt: schedule.createdAt ?? null,
|
||||
scheduleDate: schedule.scheduledDepartureDate,
|
||||
trainNumber: schedule.trainNumber ?? null,
|
||||
direction: schedule.direction ?? null,
|
||||
routeName: schedule.route ? formatRouteLabel(schedule.route) : null,
|
||||
origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null,
|
||||
destination:
|
||||
@@ -5942,6 +6059,64 @@ export class TrainSchedulingService {
|
||||
(snapshot?.slots ?? []).map((slot) => [slot.trainSetWagonId, slot]),
|
||||
);
|
||||
|
||||
// The trainSet slots below are the PLANNED wagons (one per allocation). A
|
||||
// schedule tied to a built train hauls EVERY coupled wagon — empty ones
|
||||
// included (the pull-limit check already counts their tare) — so append the
|
||||
// train's remaining wagons as consist-only entries and the composition views
|
||||
// (scheduling-v2 finalize, batch-board composition tab) draw the train as it
|
||||
// really is: loaded slots first, then the empty consist. Skipped for frozen
|
||||
// (dispatched/arrived) schedules: their wagons are released and re-pinned to
|
||||
// later trains, so the live consist no longer describes THIS departure.
|
||||
const coveredPhysicalIds = new Set<string>();
|
||||
for (const slot of schedule.trainSet?.wagons ?? []) {
|
||||
const frozenSlot = isWagonAllocationFrozen
|
||||
? snapshotSlotByTrainSetWagonId.get(slot.id)
|
||||
: undefined;
|
||||
const physicalId = frozenSlot
|
||||
? frozenSlot.physicalWagonId
|
||||
: slot.physicalWagonId ?? null;
|
||||
if (physicalId) coveredPhysicalIds.add(physicalId);
|
||||
}
|
||||
const maxSlotSequenceNo = Math.max(
|
||||
0,
|
||||
...(schedule.trainSet?.wagons ?? []).map((w) => w.sequenceNo),
|
||||
);
|
||||
const emptyConsistWagons =
|
||||
schedule.trainSet?.trainId && !isWagonAllocationFrozen
|
||||
? (
|
||||
await this.dataSource.getRepository(Wagon).find({
|
||||
where: { trainId: schedule.trainSet.trainId },
|
||||
relations: { wagonType: true },
|
||||
order: { sequenceNumber: 'ASC' },
|
||||
})
|
||||
)
|
||||
.filter((wagon) => !coveredPhysicalIds.has(wagon.id))
|
||||
.map((wagon, index) => ({
|
||||
// Physical wagon id — there is no TrainSetWagon slot behind this
|
||||
// row, so remove/edit affordances must stay disabled (consistOnly).
|
||||
id: wagon.id,
|
||||
sequenceNo: maxSlotSequenceNo + index + 1,
|
||||
capacityTons: roundTons(Number(wagon.wagonType?.capacityTons ?? 0)),
|
||||
lengthMeters: roundTons(Number(wagon.wagonType?.lengthMeters ?? 0)),
|
||||
assignedWeightTons: 0,
|
||||
tareWeightTons: wagon.wagonType
|
||||
? roundTons(Number(wagon.wagonType.tareWeightTons))
|
||||
: null,
|
||||
status: 'EMPTY',
|
||||
physicalWagonId: wagon.id,
|
||||
physicalWagonNumber: wagon.wagonNumber ?? null,
|
||||
wagonType: wagon.wagonType
|
||||
? {
|
||||
id: wagon.wagonType.id,
|
||||
code: wagon.wagonType.code,
|
||||
name: wagon.wagonType.name,
|
||||
}
|
||||
: null,
|
||||
allocations: [],
|
||||
consistOnly: true,
|
||||
}))
|
||||
: [];
|
||||
|
||||
return {
|
||||
id: schedule.id,
|
||||
reference: schedule.reference ?? null,
|
||||
@@ -6109,7 +6284,8 @@ export class TrainSchedulingService {
|
||||
: null,
|
||||
})) ?? [],
|
||||
};
|
||||
}),
|
||||
})
|
||||
.concat(emptyConsistWagons),
|
||||
}
|
||||
: null,
|
||||
bookings:
|
||||
|
||||
@@ -421,6 +421,20 @@ export class WarehouseInventoryController {
|
||||
return res.send(buffer);
|
||||
}
|
||||
|
||||
@Get('edr-truck-exit-paper/:assignmentId')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'Per-truck exit paper PDF for an EDR last-mile truck' })
|
||||
async edrTruckExitPaper(
|
||||
@Param('assignmentId', ParseUUIDPipe) assignmentId: string,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const { filename, buffer } = await this.inventoryService.edrTruckExitPaper(assignmentId);
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
|
||||
res.setHeader('Content-Length', buffer.length);
|
||||
return res.send(buffer);
|
||||
}
|
||||
|
||||
@Get(':id/grn-document')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'View goods received note PDF' })
|
||||
|
||||
@@ -2827,6 +2827,16 @@ export class WarehouseInventoryService {
|
||||
: dto;
|
||||
const exitInspectionNote = this.buildExitInspectionNote(exitInspectionDto);
|
||||
|
||||
// The load actually leaving on this truck, in TONNES (the weighing UI is in
|
||||
// t). Null when the operator skipped weighing — containers may skip, bulk
|
||||
// never does.
|
||||
const grossTons = exitInspectionDto.grossWeight ?? null;
|
||||
const tareTons = exitInspectionDto.tareWeight ?? null;
|
||||
const netTons =
|
||||
grossTons != null && tareTons != null
|
||||
? Math.round((grossTons - tareTons) * 1000) / 1000
|
||||
: (exitInspectionDto.netWeight ?? null);
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(WarehouseInventory).update(id, {
|
||||
releaseDate,
|
||||
@@ -2854,6 +2864,23 @@ export class WarehouseInventoryService {
|
||||
// an EXPORT concept (set when a truck delivers into the port). Import
|
||||
// load + weight are captured on truck departure, not arrival.
|
||||
}
|
||||
// EDR last-mile: stamp THIS truck's arrival. Matched by plate rather than
|
||||
// container so it works for bulk too (bulk trucks carry no container).
|
||||
if (dto.truckPlateNumber?.trim()) {
|
||||
await manager.query(
|
||||
`UPDATE freight.last_mile_vehicle_assignments va
|
||||
SET arrived_at = COALESCE(va.arrived_at, NOW()), updated_at = NOW()
|
||||
FROM freight.last_mile lm, freight.vehicles v
|
||||
WHERE va.last_mile_id = lm.id
|
||||
AND lm.booking_id = $1
|
||||
AND lm.deleted_at IS NULL
|
||||
AND v.id = va.vehicle_id
|
||||
AND (UPPER(v.power_plate_no) = UPPER($2) OR UPPER(v.plate_number) = UPPER($2))
|
||||
AND va.arrived_at IS NULL
|
||||
AND va.deleted_at IS NULL`,
|
||||
[item.bookingId, dto.truckPlateNumber.trim()],
|
||||
);
|
||||
}
|
||||
// Booking-level flag stamped on the FIRST truck arrival. The import
|
||||
// handover is signed ONCE (before the first truck leaves), even though
|
||||
// trucks pick up per-container — COALESCE keeps the first timestamp.
|
||||
@@ -2874,6 +2901,34 @@ export class WarehouseInventoryService {
|
||||
await this.handover.ensureForArrivedTruck(item.bookingId, {}, manager);
|
||||
}
|
||||
}
|
||||
if (isTruckLeaving && item.bookingId && dto.truckPlateNumber?.trim()) {
|
||||
// EDR last-mile: this truck is leaving — record its exit and the load it
|
||||
// actually took. net_weight_tons drives the bulk drawdown (booking VGM
|
||||
// minus everything already hauled away).
|
||||
await manager.query(
|
||||
`UPDATE freight.last_mile_vehicle_assignments va
|
||||
SET departed_at = COALESCE($3::timestamptz, NOW()),
|
||||
arrived_at = COALESCE(va.arrived_at, NOW()),
|
||||
gross_weight_tons = $4,
|
||||
net_weight_tons = $5,
|
||||
updated_at = NOW()
|
||||
FROM freight.last_mile lm, freight.vehicles v
|
||||
WHERE va.last_mile_id = lm.id
|
||||
AND lm.booking_id = $1
|
||||
AND lm.deleted_at IS NULL
|
||||
AND v.id = va.vehicle_id
|
||||
AND (UPPER(v.power_plate_no) = UPPER($2) OR UPPER(v.plate_number) = UPPER($2))
|
||||
AND va.departed_at IS NULL
|
||||
AND va.deleted_at IS NULL`,
|
||||
[
|
||||
item.bookingId,
|
||||
dto.truckPlateNumber.trim(),
|
||||
dto.gateOutTime ?? null,
|
||||
grossTons,
|
||||
netTons,
|
||||
],
|
||||
);
|
||||
}
|
||||
await this.activityLog.record(
|
||||
{
|
||||
activityType: 'INVENTORY_RELEASED',
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort truck-departure notification to the booking's company across
|
||||
* every channel: in-app (portal inbox) + SMS + email. Never throws — a missing
|
||||
* provider or contact must not break the exit flow.
|
||||
*/
|
||||
private async notifyTruckDeparture(
|
||||
bookingId: string,
|
||||
plateNumber: string | null,
|
||||
netTons: number | null,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const [booking]: Array<{ companyId: string | null; reference: string | null }> =
|
||||
await this.dataSource.query(
|
||||
`SELECT company_id AS "companyId", reference
|
||||
FROM freight.bookings
|
||||
WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[bookingId],
|
||||
);
|
||||
if (!booking?.companyId) return;
|
||||
const ref = booking.reference ?? bookingId;
|
||||
const truck = plateNumber ? `Truck ${plateNumber}` : 'A truck';
|
||||
const load = netTons != null && netTons > 0 ? ` carrying ${netTons} t` : '';
|
||||
const body = `${truck} has left the warehouse for booking ${ref}${load}.`;
|
||||
await this.inbox.notify({
|
||||
recipients: { companyId: booking.companyId },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.BOOKING_STATUS,
|
||||
title: 'Truck left the warehouse',
|
||||
body,
|
||||
link: `/bookings/${bookingId}`,
|
||||
data: { bookingId, plateNumber, netTons, action: 'TRUCK_LEFT' },
|
||||
});
|
||||
await sendCompanyChannels(this.dataSource, this.notifications, booking.companyId, body);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Truck-departure notify failed for ${bookingId}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async releaseDocument(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const [row] = await this.dataSource.query(
|
||||
`SELECT inv.id,
|
||||
@@ -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: {
|
||||
reference: string;
|
||||
bookingReference: string;
|
||||
@@ -3734,20 +3905,30 @@ export class WarehouseInventoryService {
|
||||
);
|
||||
} else {
|
||||
// EDR last-mile: the handover is per delivering truck. Resolve the
|
||||
// vehicle that carried this item's container so each truck gets its own
|
||||
// handover (falls back to a booking-level one when unresolvable).
|
||||
// vehicle from the truck's own container list (the earlier lookup went
|
||||
// through last_mile_container_allocations, which nothing ever writes —
|
||||
// so truckPlate was always null and every booking collapsed to a single
|
||||
// booking-level handover). Bulk has no container, so fall back to the
|
||||
// delivery's single truck; a booking-level handover when unresolvable.
|
||||
let truckPlate: string | null = null;
|
||||
if (item.containerId) {
|
||||
const [veh]: Array<{ plate: string | null }> = await manager.query(
|
||||
`SELECT COALESCE(v.power_plate_no, v.plate_number) AS plate
|
||||
FROM freight.last_mile_container_allocations lca
|
||||
JOIN freight.vehicles v ON v.id = lca.vehicle_id
|
||||
WHERE lca.container_id = $1 AND lca.vehicle_id IS NOT NULL
|
||||
LIMIT 1`,
|
||||
[item.containerId],
|
||||
);
|
||||
truckPlate = veh?.plate ?? null;
|
||||
}
|
||||
const [veh]: Array<{ plate: string | null }> = await manager.query(
|
||||
`SELECT COALESCE(v.power_plate_no, v.plate_number) AS plate
|
||||
FROM freight.last_mile_vehicle_assignments va
|
||||
JOIN freight.last_mile lm
|
||||
ON lm.id = va.last_mile_id AND lm.deleted_at IS NULL
|
||||
JOIN freight.vehicles v ON v.id = va.vehicle_id
|
||||
LEFT JOIN freight.last_mile_vehicle_containers vc
|
||||
ON vc.assignment_id = va.id AND vc.deleted_at IS NULL
|
||||
LEFT JOIN freight.containers cont
|
||||
ON cont.container_number = vc.container_number AND cont.deleted_at IS NULL
|
||||
WHERE lm.booking_id = $1
|
||||
AND va.deleted_at IS NULL
|
||||
AND ($2::uuid IS NULL OR cont.id = $2::uuid)
|
||||
ORDER BY (cont.id IS NOT NULL) DESC, va.created_at ASC
|
||||
LIMIT 1`,
|
||||
[item.bookingId, item.containerId ?? null],
|
||||
);
|
||||
truckPlate = veh?.plate ?? null;
|
||||
await this.handover.ensureAtDelivery(item.bookingId, { truckPlate }, manager);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AppDataSource } from '../data-source';
|
||||
import { SeedEdRWagonFleet1750400000000 } from '../migrations/1750400000000-SeedEdRWagonFleet';
|
||||
import { SeedEdrWagonFleetErNumbering2260000000000 } from '../migrations/2260000000000-SeedEdrWagonFleetErNumbering';
|
||||
|
||||
async function seedEdRWagons() {
|
||||
await AppDataSource.initialize();
|
||||
@@ -10,28 +10,32 @@ async function seedEdRWagons() {
|
||||
await queryRunner.connect();
|
||||
await queryRunner.startTransaction();
|
||||
|
||||
await new SeedEdRWagonFleet1750400000000().up(queryRunner);
|
||||
await new SeedEdrWagonFleetErNumbering2260000000000().up(queryRunner);
|
||||
|
||||
const [summary] = await queryRunner.query(`
|
||||
const summary = await queryRunner.query(`
|
||||
SELECT
|
||||
COUNT(*)::int AS total,
|
||||
COUNT(*) FILTER (WHERE wt.code = 'PW2')::int AS pw2,
|
||||
COUNT(*) FILTER (WHERE wt.code = 'CW4')::int AS cw4,
|
||||
COUNT(*) FILTER (WHERE wt.code = 'CW3')::int AS cw3,
|
||||
COUNT(*) FILTER (WHERE wt.code = 'KW2')::int AS kw2,
|
||||
COUNT(*) FILTER (WHERE wt.code = 'KW3')::int AS kw3,
|
||||
COUNT(*) FILTER (WHERE wt.code = 'NW5')::int AS nw5,
|
||||
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
|
||||
wt.code,
|
||||
wt.name,
|
||||
COUNT(*)::int AS wagons,
|
||||
MIN(w.wagon_number) AS first_wagon,
|
||||
MAX(w.wagon_number) AS last_wagon,
|
||||
COUNT(*) FILTER (WHERE w.status = 'AVAILABLE')::int AS available,
|
||||
COUNT(*) FILTER (WHERE w.current_yard_id IS NULL)::int AS unassigned_yard
|
||||
FROM freight.wagons w
|
||||
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();
|
||||
|
||||
console.log('Seeded EDR wagon fleet:', summary);
|
||||
console.table(summary);
|
||||
console.log(`Seeded EDR wagon fleet — ${totals.total} wagons total (expected 1100).`);
|
||||
} catch (error) {
|
||||
await queryRunner.rollbackTransaction();
|
||||
throw error;
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
AlertCircle,
|
||||
@@ -642,6 +643,20 @@ export default function GlCreateBookingForm() {
|
||||
});
|
||||
}, [isContainer, contract, containerLines, contractWithReturn]);
|
||||
|
||||
// 20ft containers ride two per wagon, so an odd total leaves one unpaired and
|
||||
// the booking can never be planned. The server rejects it too (the price
|
||||
// modal's `pairingErrors`), but that only lands after GL has filled the whole
|
||||
// form — mirror the customer portal (new-booking-form/schema.ts `calcWagons`)
|
||||
// and block it inline instead. Size strings arrive as "20ft" from the contract
|
||||
// scope but as a bare "20" from the rebook seed, so match on the leading digits.
|
||||
const ft20Total = useMemo(() => {
|
||||
if (!isContainer) return 0;
|
||||
return containerLines
|
||||
.filter((l) => parseInt(l.containerSize, 10) === 20)
|
||||
.reduce((sum, l) => sum + Number(l.quantity || 0), 0);
|
||||
}, [isContainer, containerLines]);
|
||||
const hasOdd20ft = ft20Total % 2 === 1;
|
||||
|
||||
const bulkUom = contract ? bulkUnitOfMeasure(contract) : "PER_TON";
|
||||
|
||||
const bulkErrors = useMemo<BulkErrors>(() => {
|
||||
@@ -688,7 +703,7 @@ export default function GlCreateBookingForm() {
|
||||
)
|
||||
: !bulkErrors.quantity && !bulkErrors.hazardous && !bulkErrors.reefer;
|
||||
|
||||
const formValid = cargoValid && !dateError && !routeError;
|
||||
const formValid = cargoValid && !hasOdd20ft && !dateError && !routeError;
|
||||
|
||||
/** The create-booking DTO from the current form state — shared by the
|
||||
* authoritative price preview and the actual submit so what GL confirms is
|
||||
@@ -1286,6 +1301,21 @@ export default function GlCreateBookingForm() {
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
|
||||
{hasOdd20ft ? (
|
||||
<Alert
|
||||
color="red"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<AlertCircle size={16} />}
|
||||
title={`Odd number of 20ft containers (${ft20Total})`}
|
||||
>
|
||||
20ft containers travel two per wagon, so they must be booked in
|
||||
even numbers. Add one more 20ft container or remove one (e.g.
|
||||
book {ft20Total + 1} or {ft20Total - 1} instead of {ft20Total})
|
||||
— the booking cannot be created with an unpaired 20ft container.
|
||||
</Alert>
|
||||
) : null}
|
||||
</Stack>
|
||||
</StepCard>
|
||||
) : (
|
||||
@@ -1530,14 +1560,27 @@ export default function GlCreateBookingForm() {
|
||||
</Alert>
|
||||
) : null}
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Receipt size={16} />}
|
||||
onClick={openPriceModal}
|
||||
<Tooltip
|
||||
label={`Book an even number of 20ft containers — ${ft20Total} is odd and would leave one unpaired.`}
|
||||
withArrow
|
||||
disabled={!hasOdd20ft}
|
||||
>
|
||||
Review price & book
|
||||
</Button>
|
||||
{/* Mantine tooltips get no pointer events from a disabled button,
|
||||
so the wrapper carries the hover target. */}
|
||||
<Box>
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Receipt size={16} />}
|
||||
onClick={openPriceModal}
|
||||
// Same hard block the customer portal applies at review time —
|
||||
// an unpaired 20ft can never be planned onto a wagon.
|
||||
disabled={hasOdd20ft}
|
||||
>
|
||||
Review price & book
|
||||
</Button>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -36,7 +36,9 @@ export function PinWagonsForm({
|
||||
autoFillOnMount?: boolean;
|
||||
}) {
|
||||
const originYardId = schedule.originStation?.id;
|
||||
const slots = schedule.trainSet?.wagons ?? [];
|
||||
// Consist-only rows are the built train's coupled-but-empty wagons — display
|
||||
// entries with no TrainSetWagon slot behind them, so nothing can be pinned.
|
||||
const slots = (schedule.trainSet?.wagons ?? []).filter((w) => !w.consistOnly);
|
||||
const [assignments, setAssignments] = useState<Record<string, string>>({});
|
||||
|
||||
const wagonOptionsByType = useMemo(() => {
|
||||
|
||||
@@ -359,7 +359,9 @@ function WagonCar({
|
||||
|
||||
{isEmpty ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
Empty slot — available for allocation.
|
||||
{wagon.consistOnly
|
||||
? "Empty wagon — coupled on the train, no load planned."
|
||||
: "Empty slot — available for allocation."}
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap={6}>
|
||||
|
||||
@@ -167,9 +167,9 @@ export const WagonCard = ({
|
||||
<TrainFront size={18} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" c="dimmed">
|
||||
Empty slot
|
||||
{wagon.consistOnly ? "Empty wagon — coupled on the train" : "Empty slot"}
|
||||
</Text>
|
||||
{!isDispatched ? (
|
||||
{!isDispatched && !wagon.consistOnly ? (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
|
||||
@@ -13,3 +13,6 @@ export function fileViewUrl(fileId: string, download = false): string {
|
||||
const base = `${API_BASE_URL}/api/files/${fileId}`;
|
||||
return download ? `${base}?download=1` : base;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Grid,
|
||||
Group,
|
||||
Loader,
|
||||
@@ -21,10 +22,18 @@ import {
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
PackageCheck,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import {
|
||||
FREIGHT_PERMS,
|
||||
hasPermission,
|
||||
isDjiboutiGl,
|
||||
} from "@/lib/permissions";
|
||||
|
||||
import { ClearanceOpsTabs } from "@/components/contracts/ClearanceOpsTabs";
|
||||
import { PageContainer } from "@/components/page/PageContainer";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
@@ -44,6 +53,7 @@ import {
|
||||
export default function ContractClearanceDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { view, viewer } = useFileViewer();
|
||||
const { user } = useAuth();
|
||||
|
||||
const { data: contract, refetch: refetchContract } = useContractDetail(id);
|
||||
const {
|
||||
@@ -106,6 +116,16 @@ export default function ContractClearanceDetailPage() {
|
||||
const reviewReadOnly = shipmentLocked;
|
||||
const queriesLocked = Boolean(clearance?.preClearanceFinalized);
|
||||
const bookingHref = `/dashboard/contracts/${id}/create-booking`;
|
||||
// The GL-created booking expired unpaid — the slot is free again and GL
|
||||
// rebooks on the customer's behalf (customs bookings are never self-booked).
|
||||
const bookingExpired = clearance?.linkedBookingStatus === "EXPIRED";
|
||||
const canRebook =
|
||||
bookingExpired &&
|
||||
hasPermission(user, FREIGHT_PERMS.contracts.createBooking) &&
|
||||
!isDjiboutiGl(user);
|
||||
const rebookHref = linkedBookingId
|
||||
? `${bookingHref}?copyFrom=${linkedBookingId}`
|
||||
: bookingHref;
|
||||
|
||||
const { data: bookingMilestones, refetch: refetchBookingMilestones } =
|
||||
useBookingMilestones(linkedBookingId);
|
||||
@@ -165,7 +185,16 @@ export default function ContractClearanceDetailPage() {
|
||||
{ label: reference },
|
||||
]}
|
||||
meta={
|
||||
bookingAlreadyCreated ? (
|
||||
bookingExpired ? (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="orange"
|
||||
radius="sm"
|
||||
leftSection={<RefreshCw size={13} />}
|
||||
>
|
||||
Payment expired — rebook
|
||||
</Badge>
|
||||
) : bookingAlreadyCreated ? (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="blue"
|
||||
@@ -211,7 +240,49 @@ export default function ContractClearanceDetailPage() {
|
||||
it can actually create the booking without checking the schedule board. */}
|
||||
{id ? <GlUpcomingWindowsSection contractId={id} /> : null}
|
||||
|
||||
{bookingAlreadyCreated ? (
|
||||
{bookingExpired ? (
|
||||
<Alert
|
||||
color="orange"
|
||||
radius="md"
|
||||
icon={<RefreshCw size={16} />}
|
||||
title="Booking expired — payment not received"
|
||||
>
|
||||
<Stack gap="sm" align="flex-start">
|
||||
<Text size="sm">
|
||||
The customer did not pay before the deadline, so the booking
|
||||
expired and its train slot was released. The contract slot is
|
||||
free again — GL Ethiopia can rebook on the customer's
|
||||
behalf without re-running clearance.
|
||||
{linkedBookingId ? (
|
||||
<>
|
||||
{" "}
|
||||
<Text
|
||||
component={Link}
|
||||
to={`/dashboard/bookings/${linkedBookingId}/clearance`}
|
||||
inherit
|
||||
fw={600}
|
||||
c="orange.8"
|
||||
>
|
||||
View expired booking →
|
||||
</Text>
|
||||
</>
|
||||
) : null}
|
||||
</Text>
|
||||
{canRebook ? (
|
||||
<Button
|
||||
component={Link}
|
||||
to={rebookHref}
|
||||
color="grape"
|
||||
radius="md"
|
||||
size="sm"
|
||||
leftSection={<RefreshCw size={15} />}
|
||||
>
|
||||
Rebook for customer
|
||||
</Button>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Alert>
|
||||
) : bookingAlreadyCreated ? (
|
||||
<Alert
|
||||
color="blue"
|
||||
radius="md"
|
||||
|
||||
@@ -92,6 +92,10 @@ interface ClearanceRow {
|
||||
ready: boolean;
|
||||
/** true once GL Ethiopia created the shipment booking. */
|
||||
bookingCreated: boolean;
|
||||
/** true when the created booking EXPIRED unpaid — GL must rebook. */
|
||||
paymentExpired: boolean;
|
||||
/** The expired booking, so rebook can copy its cargo. */
|
||||
expiredBookingId: string | null;
|
||||
}
|
||||
|
||||
function yardLabel(
|
||||
@@ -145,6 +149,13 @@ function toClearanceRow(contract: Freight.IContract): ClearanceRow {
|
||||
status: contract.status,
|
||||
ready: contract.status === "CLEARANCE_READY_FOR_BOOKING",
|
||||
bookingCreated: contract.status === "ACTIVE_SHIPMENT_IN_PROGRESS",
|
||||
paymentExpired:
|
||||
contract.status === "ACTIVE_SHIPMENT_IN_PROGRESS" &&
|
||||
contract.latestCycleBookingStatus === "EXPIRED",
|
||||
expiredBookingId:
|
||||
contract.latestCycleBookingStatus === "EXPIRED"
|
||||
? (contract.latestCycleBookingId ?? null)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -186,6 +197,24 @@ function DirectionIcon({ direction }: { direction: string }) {
|
||||
}
|
||||
|
||||
function StatusBadge({ row }: { row: ClearanceRow }) {
|
||||
if (row.paymentExpired) {
|
||||
return (
|
||||
<Tooltip
|
||||
label="The customer did not pay in time — the booking expired. GL rebooks on the customer's behalf."
|
||||
withArrow
|
||||
>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="orange"
|
||||
radius="sm"
|
||||
leftSection={<RefreshCw size={12} />}
|
||||
>
|
||||
Payment expired
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
if (row.bookingCreated) {
|
||||
return (
|
||||
<Tooltip label="GL Ethiopia created the shipment booking" withArrow>
|
||||
@@ -519,6 +548,27 @@ export default function ContractClearanceListPage() {
|
||||
Create booking
|
||||
</Button>
|
||||
</Group>
|
||||
) : row.original.paymentExpired && canCreateBooking ? (
|
||||
<Group justify="flex-end" pr="xs">
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="grape"
|
||||
radius="md"
|
||||
leftSection={<RefreshCw size={14} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(
|
||||
`/dashboard/contracts/${row.original.id}/create-booking${
|
||||
row.original.expiredBookingId
|
||||
? `?copyFrom=${row.original.expiredBookingId}`
|
||||
: ""
|
||||
}`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
Rebook
|
||||
</Button>
|
||||
</Group>
|
||||
) : (
|
||||
<Group justify="flex-end" pr="xs">
|
||||
<ChevronRight size={16} className="text-muted-foreground" />
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
/**
|
||||
* 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 pickupLocation = (r: FirstMileRecord) => r.booking?.firstMilePickupAddress ?? "—";
|
||||
const cargoDesc = (r: FirstMileRecord) => {
|
||||
@@ -681,6 +695,22 @@ const FirstMilePage = () => {
|
||||
return opts;
|
||||
}, [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.
|
||||
// Fetched on open so container numbers show regardless of what the list embeds.
|
||||
const { data: assignBooking } = useQuery({
|
||||
@@ -932,6 +962,21 @@ const FirstMilePage = () => {
|
||||
|
||||
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).
|
||||
Promise.all(targetIds.map((id) => setVehiclesMutation.mutateAsync({ id, vehicles })))
|
||||
.then(() => {
|
||||
@@ -1365,9 +1410,18 @@ const FirstMilePage = () => {
|
||||
(o) => o.value === row.vehicleId || !vehicleRows.some((r) => r.vehicleId === o.value),
|
||||
)}
|
||||
value={row.vehicleId}
|
||||
onChange={(v) =>
|
||||
setVehicleRows((prev) => prev.map((x, idx) => (idx === i ? { ...x, vehicleId: v } : x)))
|
||||
}
|
||||
onChange={(v) => {
|
||||
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
|
||||
clearable
|
||||
disabled={assignVehicleOptions.length === 0}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
RefreshCw,
|
||||
Ruler,
|
||||
Trash,
|
||||
FileText,
|
||||
Truck,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
@@ -56,6 +57,7 @@ import {
|
||||
import { vehiclesService } from "@/services/vehicles.service";
|
||||
import { driversService, type Driver } from "@/services/drivers.service";
|
||||
import { ReleaseOrderModal, type ReleaseOrderTruckPrefill } from "@/components/warehouses/ReleaseOrderModal";
|
||||
import { EdrTruckExitPapersModal } from "./EdrTruckExitPapersModal";
|
||||
import { TruckDetentionModal } from "@/components/operations/TruckDetentionModal";
|
||||
import { ProofOfDeliveryModal } from "@/components/operations/ProofOfDeliveryModal";
|
||||
import { LastMileStepper, type LastMileStepState } from "@/components/operations/LastMileSteps";
|
||||
@@ -124,10 +126,22 @@ const containerCount = (record: LastMileRecord) =>
|
||||
(sum, c) => sum + (Number(c.quantity) || 0),
|
||||
0,
|
||||
);
|
||||
/** Trucks needed for a booking = ceil(containers / 2). 0 when no container data. */
|
||||
/**
|
||||
* Trucks needed for a booking, by container SIZE: a 40ft fills a truck (1 each),
|
||||
* two 20ft share one. Falls back to ceil(n / 2) when no size is recorded.
|
||||
* 0 when the booking has no container data (bulk).
|
||||
*/
|
||||
const requiredVehicles = (record: LastMileRecord) => {
|
||||
const n = containerCount(record);
|
||||
return n > 0 ? Math.ceil(n / CONTAINERS_PER_VEHICLE) : 0;
|
||||
const lines = record.booking?.bookingContainers ?? [];
|
||||
if (!containerCount(record)) return 0;
|
||||
let forty = 0;
|
||||
let others = 0;
|
||||
for (const c of lines) {
|
||||
const qty = Number(c.quantity) || 0;
|
||||
if ((c.containerSize ?? '').includes('40')) forty += qty;
|
||||
else others += qty;
|
||||
}
|
||||
return forty + Math.ceil(others / CONTAINERS_PER_VEHICLE);
|
||||
};
|
||||
|
||||
/** Real per-physical-container numbers on a booking, in order. Prefers each
|
||||
@@ -251,6 +265,20 @@ const billingIssues = (r: LastMileRecord) => {
|
||||
];
|
||||
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 deliveryLocation = (r: LastMileRecord) => r.booking?.lastMileDeliveryAddress ?? "—";
|
||||
const cargoDesc = (r: LastMileRecord) => {
|
||||
@@ -567,9 +595,11 @@ const LastMilePage = () => {
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [detentionRecord, setDetentionRecord] = useState<LastMileRecord | null>(null);
|
||||
// Multi-vehicle assign: one row per truck — vehicle + the container it carries.
|
||||
// One row per truck. A truck carries one 40ft or up to two 20ft, so the load
|
||||
// is a list, not a single container.
|
||||
const [vehicleRows, setVehicleRows] = useState<
|
||||
Array<{ vehicleId: string | null; containerNumber: string }>
|
||||
>([{ vehicleId: null, containerNumber: "" }]);
|
||||
Array<{ vehicleId: string | null; containerNumbers: string[] }>
|
||||
>([{ vehicleId: null, containerNumbers: [] }]);
|
||||
|
||||
// 2-step "Assign Mile" accept modal (arrival queue → vehicle)
|
||||
const [acceptOpen, setAcceptOpen] = useState(false);
|
||||
@@ -870,6 +900,22 @@ const LastMilePage = () => {
|
||||
return opts;
|
||||
}, [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.
|
||||
// Fetched on open so container numbers show regardless of what the list embeds.
|
||||
const { data: assignBooking } = useQuery({
|
||||
@@ -970,31 +1016,59 @@ const LastMilePage = () => {
|
||||
return filteredRecords.slice(start, start + pagination.pageSize);
|
||||
}, [filteredRecords, pagination]);
|
||||
|
||||
// Per-truck exit papers for an EDR delivery (one paper per assigned truck).
|
||||
const [exitPapersOpen, setExitPapersOpen] = useState(false);
|
||||
const [exitPapersRecord, setExitPapersRecord] = useState<LastMileRecord | null>(null);
|
||||
|
||||
// Bulk drawdown: how much tonnage is still to be hauled on the booking being
|
||||
// assigned. Bulk has no containers, so trucks keep going until this hits 0.
|
||||
const assignBookingId = activeRecord?.booking?.id ?? null;
|
||||
const { data: remainingTons } = useQuery({
|
||||
queryKey: ["last-mile", "remaining-tons", assignBookingId],
|
||||
queryFn: () => lastMileService.remainingTons(assignBookingId as string).then((r) => r.data),
|
||||
enabled: assignOpen && !bulkMode && Boolean(assignBookingId),
|
||||
});
|
||||
|
||||
const openAssign = (id: string | null) => {
|
||||
const resolved = id ?? filteredRecords.find((r) => !isAssigned(r))?.id ?? null;
|
||||
const rec = records.find((r) => r.id === resolved);
|
||||
// Prefill each row's container number from the booking's container numbers
|
||||
// (by order) when the assignment doesn't already carry one.
|
||||
const nums = rec ? bookingContainerNumbers(rec) : [];
|
||||
// Prefer the truck's own container list; fall back to the legacy scalar, then
|
||||
// to the booking's containers by order.
|
||||
const loadOf = (
|
||||
a: { containers?: Array<{ containerNumber: string }>; containerNumber?: string | null },
|
||||
i: number,
|
||||
) =>
|
||||
a.containers?.length
|
||||
? a.containers.map((c) => c.containerNumber)
|
||||
: a.containerNumber
|
||||
? [a.containerNumber]
|
||||
: nums[i]
|
||||
? [nums[i]]
|
||||
: [];
|
||||
const rows =
|
||||
rec?.vehicleAssignments?.length
|
||||
? rec.vehicleAssignments.map((a, i) => ({
|
||||
vehicleId: a.vehicleId,
|
||||
containerNumber: a.containerNumber ?? nums[i] ?? "",
|
||||
containerNumbers: loadOf(a, i),
|
||||
}))
|
||||
: rec?.vehicleId
|
||||
? [{ vehicleId: rec.vehicleId, containerNumber: nums[0] ?? "" }]
|
||||
: [{ vehicleId: null, containerNumber: nums[0] ?? "" }];
|
||||
? [{ vehicleId: rec.vehicleId, containerNumbers: nums[0] ? [nums[0]] : [] }]
|
||||
: [{ vehicleId: null, containerNumbers: nums[0] ? [nums[0]] : [] }];
|
||||
setBulkMode(false);
|
||||
setActiveId(resolved);
|
||||
setVehicleRows(rows.length ? rows : [{ vehicleId: null, containerNumber: nums[0] ?? "" }]);
|
||||
setVehicleRows(
|
||||
rows.length ? rows : [{ vehicleId: null, containerNumbers: nums[0] ? [nums[0]] : [] }],
|
||||
);
|
||||
setAssignOpen(true);
|
||||
};
|
||||
|
||||
const openBulkAssign = () => {
|
||||
setBulkMode(true);
|
||||
setActiveId(null);
|
||||
setVehicleRows([{ vehicleId: null, containerNumber: "" }]);
|
||||
setVehicleRows([{ vehicleId: null, containerNumbers: [] }]);
|
||||
setAssignOpen(true);
|
||||
};
|
||||
|
||||
@@ -1002,15 +1076,18 @@ const LastMilePage = () => {
|
||||
setAssignOpen(false);
|
||||
setBulkMode(false);
|
||||
setActiveId(null);
|
||||
setVehicleRows([{ vehicleId: null, containerNumber: "" }]);
|
||||
setVehicleRows([{ vehicleId: null, containerNumbers: [] }]);
|
||||
};
|
||||
|
||||
const handleAssign = () => {
|
||||
const seen = new Set<string>();
|
||||
const vehicles = vehicleRows
|
||||
.filter((r): r is { vehicleId: string; containerNumber: string } => Boolean(r.vehicleId))
|
||||
.filter((r): r is { vehicleId: string; containerNumbers: string[] } => Boolean(r.vehicleId))
|
||||
.filter((r) => (seen.has(r.vehicleId) ? false : seen.add(r.vehicleId)))
|
||||
.map((r) => ({ vehicleId: r.vehicleId, containerNumber: r.containerNumber.trim() || null }));
|
||||
.map((r) => ({
|
||||
vehicleId: r.vehicleId,
|
||||
containerNumbers: r.containerNumbers.map((n) => n.trim()).filter(Boolean),
|
||||
}));
|
||||
const count = vehicles.length;
|
||||
const targetIds = bulkMode
|
||||
? selectedIds
|
||||
@@ -1018,6 +1095,21 @@ const LastMilePage = () => {
|
||||
|
||||
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).
|
||||
Promise.all(targetIds.map((id) => setVehiclesMutation.mutateAsync({ id, vehicles })))
|
||||
.then(() => {
|
||||
@@ -1374,6 +1466,16 @@ const LastMilePage = () => {
|
||||
>
|
||||
Truck Leaving
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<FileText size={15} />}
|
||||
disabled={!row.original.vehicleAssignments?.length}
|
||||
onClick={() => {
|
||||
setExitPapersRecord(row.original);
|
||||
setExitPapersOpen(true);
|
||||
}}
|
||||
>
|
||||
Truck exit papers
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
leftSection={<Eye size={15} />}
|
||||
@@ -1693,9 +1795,24 @@ const LastMilePage = () => {
|
||||
const needed = requiredVehicles(activeRecord);
|
||||
const picked = vehicleRows.filter((r) => r.vehicleId).length;
|
||||
if (needed === 0) {
|
||||
// Bulk: no containers — trucks haul loose tonnage until the
|
||||
// booking's total is drawn down to zero by departing trucks.
|
||||
const done = remainingTons?.complete;
|
||||
return (
|
||||
<Alert variant="light" color="gray" title="One truck (with trailer) carries 2 containers">
|
||||
No container count on this booking — assign trucks as needed.
|
||||
<Alert
|
||||
variant="light"
|
||||
color={done ? "green" : remainingTons ? "blue" : "gray"}
|
||||
title={
|
||||
remainingTons
|
||||
? `${remainingTons.remainingTons} t remaining of ${remainingTons.totalTons} t`
|
||||
: "No container count on this booking"
|
||||
}
|
||||
>
|
||||
{remainingTons
|
||||
? done
|
||||
? "Fully hauled — no tonnage left to assign trucks for."
|
||||
: `Bulk booking: ${remainingTons.hauledTons} t hauled so far. Keep assigning trucks until the remaining tonnage reaches 0 — each truck's net weight is deducted when it leaves.`
|
||||
: "Assign trucks as needed."}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -1738,32 +1855,43 @@ const LastMilePage = () => {
|
||||
(o) => o.value === row.vehicleId || !vehicleRows.some((r) => r.vehicleId === o.value),
|
||||
)}
|
||||
value={row.vehicleId}
|
||||
onChange={(v) =>
|
||||
setVehicleRows((prev) => prev.map((x, idx) => (idx === i ? { ...x, vehicleId: v } : x)))
|
||||
}
|
||||
onChange={(v) => {
|
||||
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
|
||||
clearable
|
||||
disabled={assignVehicleOptions.length === 0}
|
||||
/>
|
||||
<Select
|
||||
<MultiSelect
|
||||
style={{ flex: 1 }}
|
||||
label={i === 0 ? "Container no." : undefined}
|
||||
placeholder={containerOptions.length ? "Select container" : "No container numbers"}
|
||||
label={i === 0 ? "Containers (1x40ft or 2x20ft)" : undefined}
|
||||
placeholder={containerOptions.length ? "Select containers" : "No container numbers"}
|
||||
// A truck takes at most two containers; a 40ft fills it (the
|
||||
// API rejects a 40ft paired with anything).
|
||||
maxValues={2}
|
||||
data={[
|
||||
...containerOptions.filter(
|
||||
(n) =>
|
||||
n === row.containerNumber ||
|
||||
!vehicleRows.some((r, idx) => idx !== i && r.containerNumber === n),
|
||||
row.containerNumbers.includes(n) ||
|
||||
// a container rides exactly one truck
|
||||
!vehicleRows.some((r, idx) => idx !== i && r.containerNumbers.includes(n)),
|
||||
),
|
||||
// keep a manual/legacy value selectable even if not in the booking
|
||||
...(row.containerNumber && !containerOptions.includes(row.containerNumber)
|
||||
? [row.containerNumber]
|
||||
: []),
|
||||
// keep manual/legacy values selectable even if not in the booking
|
||||
...row.containerNumbers.filter((n) => !containerOptions.includes(n)),
|
||||
]}
|
||||
value={row.containerNumber || null}
|
||||
value={row.containerNumbers}
|
||||
onChange={(value) =>
|
||||
setVehicleRows((prev) =>
|
||||
prev.map((x, idx) => (idx === i ? { ...x, containerNumber: value ?? "" } : x)),
|
||||
prev.map((x, idx) => (idx === i ? { ...x, containerNumbers: value } : x)),
|
||||
)
|
||||
}
|
||||
searchable
|
||||
@@ -1786,14 +1914,14 @@ const LastMilePage = () => {
|
||||
size="xs"
|
||||
leftSection={<Plus size={14} />}
|
||||
onClick={() =>
|
||||
setVehicleRows((prev) => [
|
||||
...prev,
|
||||
{
|
||||
vehicleId: null,
|
||||
containerNumber:
|
||||
(activeRecord ? bookingContainerNumbers(activeRecord)[prev.length] : "") ?? "",
|
||||
},
|
||||
])
|
||||
setVehicleRows((prev) => {
|
||||
// Suggest the next unassigned container for the new truck.
|
||||
const taken = new Set(prev.flatMap((r) => r.containerNumbers));
|
||||
const next = (activeRecord ? bookingContainerNumbers(activeRecord) : []).find(
|
||||
(n) => !taken.has(n),
|
||||
);
|
||||
return [...prev, { vehicleId: null, containerNumbers: next ? [next] : [] }];
|
||||
})
|
||||
}
|
||||
disabled={
|
||||
assignVehicleOptions.length === 0 ||
|
||||
@@ -2087,6 +2215,12 @@ const LastMilePage = () => {
|
||||
truckPrefill={releaseTruckPrefill}
|
||||
/>
|
||||
|
||||
<EdrTruckExitPapersModal
|
||||
opened={exitPapersOpen}
|
||||
onClose={() => setExitPapersOpen(false)}
|
||||
record={exitPapersRecord}
|
||||
/>
|
||||
|
||||
<TruckDetentionModal
|
||||
opened={Boolean(detentionRecord)}
|
||||
onClose={() => setDetentionRecord(null)}
|
||||
|
||||
@@ -31,8 +31,6 @@ import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
import BuildTrainModal from "@/components/trainBuilder/BuildTrainModal";
|
||||
import EditTrainDetailsModal from "@/components/trainBuilder/EditTrainDetailsModal";
|
||||
import {
|
||||
directionColor,
|
||||
directionRowStyle,
|
||||
trainStatusColor,
|
||||
trainStatusLabel,
|
||||
} from "@/components/trainBuilder/trainStatus";
|
||||
@@ -164,14 +162,9 @@ export default function TrainBuilderListPage() {
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
{active?.trainNumber ? (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm" fw={700} ff="monospace" lh={1.2}>
|
||||
{active.trainNumber}
|
||||
</Text>
|
||||
<Badge size="xs" variant="light" color={directionColor(active.direction)}>
|
||||
{active.direction ?? "—"}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text size="sm" fw={700} ff="monospace" lh={1.2}>
|
||||
{active.trainNumber}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text size="xs" c="dimmed" ff="monospace" lh={1.2}>
|
||||
IMP {row.original.importTrainNumber ?? "—"} · EXP{" "}
|
||||
@@ -348,7 +341,6 @@ export default function TrainBuilderListPage() {
|
||||
data={trains}
|
||||
status={tableStatus}
|
||||
onRowClick={(train) => navigate(`/dashboard/train-builder/${train.id}`)}
|
||||
rowStyle={(train) => directionRowStyle(train.activeSchedule?.direction)}
|
||||
error={
|
||||
trainsQuery.isError
|
||||
? {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ColumnDef } from "@edr/ui-common";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
@@ -38,6 +39,10 @@ import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
|
||||
import {
|
||||
directionColor,
|
||||
directionRowStyle,
|
||||
} from "@/components/trainBuilder/trainStatus";
|
||||
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
|
||||
import EditScheduleDateModal from "@/components/trainScheduling/EditScheduleDateModal";
|
||||
import { showScheduleWarnings } from "@/components/trainScheduling/locomotiveOptions";
|
||||
@@ -303,9 +308,20 @@ export default function TrainScheduleV2ListPage() {
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => (
|
||||
<Stack gap={4}>
|
||||
<Text size="sm" fw={600} lh={1.2}>
|
||||
{row.original.routeName ?? "—"}
|
||||
</Text>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm" fw={600} lh={1.2}>
|
||||
{row.original.routeName ?? "—"}
|
||||
</Text>
|
||||
{row.original.direction ? (
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color={directionColor(row.original.direction)}
|
||||
>
|
||||
{row.original.direction}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
<Box maw={220}>
|
||||
<RouteCorridor
|
||||
origin={row.original.origin}
|
||||
@@ -660,6 +676,7 @@ export default function TrainScheduleV2ListPage() {
|
||||
onRowClick={(schedule) =>
|
||||
navigate(`/dashboard/operations/train-scheduling-v2/${schedule.id}`)
|
||||
}
|
||||
rowStyle={(schedule) => directionRowStyle(schedule.direction)}
|
||||
error={
|
||||
schedulesQuery.isError
|
||||
? {
|
||||
@@ -909,7 +926,18 @@ function ScheduleCard({
|
||||
</Box>
|
||||
|
||||
<Group justify="space-between" align="center">
|
||||
<FreightTypeBadge freightType={schedule.freightType} />
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<FreightTypeBadge freightType={schedule.freightType} />
|
||||
{schedule.direction ? (
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color={directionColor(schedule.direction)}
|
||||
>
|
||||
{schedule.direction}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<MetricChip value={schedule.bookingsCount} label="bkg" />
|
||||
<MetricChip value={schedule.wagonCount} label="wgn" />
|
||||
|
||||
@@ -67,8 +67,16 @@ export interface LastMileRecord {
|
||||
vehicleAssignments?: Array<{
|
||||
id: string;
|
||||
vehicleId: string;
|
||||
/** @deprecated Legacy single container — `containers` is authoritative. */
|
||||
containerNumber?: string | null;
|
||||
/** Containers riding this truck: one 40ft, or up to two 20ft. */
|
||||
containers?: Array<{ id: string; containerNumber: string }>;
|
||||
distanceKm?: number | null;
|
||||
/** Per-truck arrival / exit, stamped by the warehouse weighing steps. */
|
||||
arrivedAt?: string | null;
|
||||
departedAt?: string | null;
|
||||
grossWeightTons?: number | null;
|
||||
netWeightTons?: number | null;
|
||||
vehicle?: LastMileVehicle | null;
|
||||
}>;
|
||||
/** Present only when an invoice has actually been generated (not on distance). */
|
||||
@@ -99,8 +107,13 @@ export const lastMileService = {
|
||||
api.delete<void>(LM.BY_ID(id)),
|
||||
setVehicles: (
|
||||
id: string,
|
||||
vehicles: Array<{ vehicleId: string; containerNumber?: string | null }>,
|
||||
vehicles: Array<{ vehicleId: string; containerNumbers?: string[] }>,
|
||||
) => api.post<LastMileRecord>(`${LM.BASE}/${id}/vehicles`, { vehicles }),
|
||||
/** Bulk drawdown: tonnage still to be hauled on this booking. */
|
||||
remainingTons: (bookingId: string) =>
|
||||
api.get<{ totalTons: number; hauledTons: number; remainingTons: number; complete: boolean }>(
|
||||
`${LM.BASE}/booking/${bookingId}/remaining-tons`,
|
||||
),
|
||||
setDistances: (
|
||||
id: string,
|
||||
distances: Array<{ vehicleId: string; distanceKm: number }>,
|
||||
|
||||
@@ -323,6 +323,11 @@ export const warehouseService = {
|
||||
apiClient.get<Blob>(`/warehouse-inventory/customer-truck-exit-paper/${assignmentId}`, {
|
||||
responseType: 'blob',
|
||||
}),
|
||||
/** Per-truck exit paper PDF for an EDR last-mile truck. */
|
||||
downloadEdrTruckExitPaper: (assignmentId: string) =>
|
||||
apiClient.get<Blob>(`/warehouse-inventory/edr-truck-exit-paper/${assignmentId}`, {
|
||||
responseType: 'blob',
|
||||
}),
|
||||
deliver: (id: string, payload: DeliverInventoryPayload) =>
|
||||
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.DELIVER(id), payload),
|
||||
|
||||
|
||||
@@ -168,6 +168,8 @@ export interface TrainScheduleListItem {
|
||||
createdAt?: string | null;
|
||||
scheduleDate: string;
|
||||
trainNumber?: string | null;
|
||||
/** Trade direction of this departure (IMPORT / EXPORT), when known. */
|
||||
direction?: string | null;
|
||||
routeName?: string | null;
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
@@ -608,6 +610,11 @@ export interface TrainScheduleDetail {
|
||||
name: string;
|
||||
} | null;
|
||||
allocations: TrainScheduleWagonAllocation[];
|
||||
/**
|
||||
* Coupled-but-empty wagon of the built train — no TrainSetWagon slot
|
||||
* behind it, so remove/edit actions do not apply.
|
||||
*/
|
||||
consistOnly?: boolean;
|
||||
}>;
|
||||
} | null;
|
||||
bookings: Array<{
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
Textarea,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
AlertCircle,
|
||||
@@ -269,6 +270,19 @@ function NewShipmentBookingForm({
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
// 20ft containers ride two per wagon, so an odd total leaves one unpaired and
|
||||
// the booking can never be planned. The server's shipment validation reports
|
||||
// it too, but only once the price modal opens — block it inline instead, the
|
||||
// same way the direct-booking wizard does (new-booking-form `calcWagons`).
|
||||
const watchedContainers = form.watch("containers");
|
||||
const ft20Total =
|
||||
contract.freightType === "CONTAINER"
|
||||
? (watchedContainers ?? [])
|
||||
.filter((l) => l.containerSize === "20ft")
|
||||
.reduce((sum, l) => sum + Number(l.quantity || 0), 0)
|
||||
: 0;
|
||||
const hasOdd20ft = ft20Total % 2 === 1;
|
||||
|
||||
const submitMutation = useMutation({
|
||||
mutationFn: (dto: Freight.CreateBookingUnderContractDto) =>
|
||||
completeBookingId
|
||||
@@ -367,6 +381,8 @@ function NewShipmentBookingForm({
|
||||
// run it for every freight type; container contracts additionally get
|
||||
// overweight warnings + 20ft pairing hard-blocks surfaced in the modal.
|
||||
const handleReview = form.handleSubmit((values) => {
|
||||
// An unpaired 20ft can never be planned onto a wagon — don't even price it.
|
||||
if (hasOdd20ft) return;
|
||||
setPendingValues(values);
|
||||
validateMutation.reset();
|
||||
validateMutation.mutate(buildDto(values));
|
||||
@@ -483,15 +499,26 @@ function NewShipmentBookingForm({
|
||||
}}
|
||||
>
|
||||
<Group justify="flex-end" className="mx-auto max-w-4xl">
|
||||
<Button
|
||||
type="button"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Receipt size={16} />}
|
||||
onClick={handleReview}
|
||||
<Tooltip
|
||||
label={`Book an even number of 20ft containers — ${ft20Total} is odd and would leave one unpaired.`}
|
||||
withArrow
|
||||
disabled={!hasOdd20ft}
|
||||
>
|
||||
Review price & book
|
||||
</Button>
|
||||
{/* Mantine tooltips get no pointer events from a disabled button,
|
||||
so the wrapper carries the hover target. */}
|
||||
<Box>
|
||||
<Button
|
||||
type="button"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Receipt size={16} />}
|
||||
onClick={handleReview}
|
||||
disabled={hasOdd20ft}
|
||||
>
|
||||
Review price & book
|
||||
</Button>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Box>
|
||||
</form>
|
||||
@@ -1265,6 +1292,28 @@ function CargoStep({
|
||||
This contract has no container sizes in scope.
|
||||
</Text>
|
||||
)}
|
||||
{(() => {
|
||||
const ft20 = lines
|
||||
.filter((l) => l.containerSize === "20ft")
|
||||
.reduce((sum, l) => sum + Number(l.quantity || 0), 0);
|
||||
if (ft20 % 2 !== 1) return null;
|
||||
return (
|
||||
<Alert
|
||||
color="red"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<AlertCircle size={16} />}
|
||||
title={`Odd number of 20ft containers (${ft20})`}
|
||||
>
|
||||
<Text fz={13}>
|
||||
20ft containers travel two per wagon, so they must be booked in
|
||||
even numbers. Please add one more 20ft container or remove one
|
||||
(e.g. book {ft20 + 1} or {ft20 - 1} instead of {ft20}) — the
|
||||
booking cannot be submitted with an unpaired 20ft container.
|
||||
</Text>
|
||||
</Alert>
|
||||
);
|
||||
})()}
|
||||
</Stack>
|
||||
</StepCard>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
@@ -13,7 +14,7 @@ import {
|
||||
Textarea,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { ArrowLeft, CalendarDays, Send } from "lucide-react";
|
||||
import { AlertCircle, ArrowLeft, CalendarDays, Send } from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
import type { Freight } from "@edr/types";
|
||||
import { DatePickerInput } from "@mantine/dates";
|
||||
@@ -98,7 +99,14 @@ export default function NewShipmentRequestPage() {
|
||||
contract.cargoScope?.[0];
|
||||
const isPerItem = bulkScope?.cargoType?.unitOfMeasure === "PER_ITEM";
|
||||
|
||||
// 20ft containers ride two per wagon, so an odd total can never be planned —
|
||||
// and GL's create-booking form blocks it too, so an odd request would only
|
||||
// dead-end there. Same even-number rule the booking forms apply.
|
||||
const ft20Requested = isContainer ? Number(qtyBySize["20ft"]) || 0 : 0;
|
||||
const hasOdd20ft = ft20Requested % 2 === 1;
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (hasOdd20ft) return;
|
||||
const dto: Freight.CreateBookingRequestDto = {
|
||||
contractRouteId: route?.id,
|
||||
scheduledDate: hasCustoms ? undefined : scheduledDate || undefined,
|
||||
@@ -202,6 +210,23 @@ export default function NewShipmentRequestPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasOdd20ft ? (
|
||||
<Alert
|
||||
color="red"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<AlertCircle size={16} />}
|
||||
title={`Odd number of 20ft containers (${ft20Requested})`}
|
||||
>
|
||||
<Text fz={13}>
|
||||
20ft containers travel two per wagon, so they must be requested
|
||||
in even numbers. Please add one more 20ft container or remove
|
||||
one (e.g. request {ft20Requested + 1} or {ft20Requested - 1}{" "}
|
||||
instead of {ft20Requested}).
|
||||
</Text>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{capacity?.length ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
Remaining capacity is shown on the contract — GL will validate your request.
|
||||
@@ -220,6 +245,7 @@ export default function NewShipmentRequestPage() {
|
||||
leftSection={<Send size={16} />}
|
||||
loading={submit.isPending}
|
||||
onClick={handleSubmit}
|
||||
disabled={hasOdd20ft}
|
||||
>
|
||||
Submit shipment request
|
||||
</Button>
|
||||
|
||||
@@ -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?
|
||||
baggage BaggageBooking[]
|
||||
excessBaggageCharges ExcessBaggageCharge[]
|
||||
supplementaryCharges SupplementaryCharge[]
|
||||
journey Journey?
|
||||
|
||||
@@index([passengerId, status])
|
||||
@@ -1234,6 +1235,28 @@ model BaggageBooking {
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
model SupplementaryCharge {
|
||||
id String @id @default(uuid())
|
||||
bookingId String
|
||||
reason String // e.g. "UNDERPAYMENT", "FARE_CORRECTION"
|
||||
amountMinor Int
|
||||
currency String @default("ETB")
|
||||
status String @default("PENDING") // PENDING | PAID | WAIVED | EXPIRED
|
||||
paymentToken String @unique @default(uuid())
|
||||
providerTxnId String?
|
||||
notes String?
|
||||
createdBy String
|
||||
paidAt DateTime?
|
||||
expiresAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
booking Booking @relation(fields: [bookingId], references: [id])
|
||||
|
||||
@@index([bookingId])
|
||||
@@index([paymentToken])
|
||||
@@index([status])
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
model ExcessBaggageCharge {
|
||||
id String @id @default(uuid())
|
||||
bookingId String
|
||||
|
||||
@@ -152,7 +152,7 @@ export class BookingsService {
|
||||
id: booking.id,
|
||||
bookingRef: booking.bookingRef,
|
||||
status: booking.status,
|
||||
totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
|
||||
totalMinor: booking.displayTotalMinor ?? resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
|
||||
currency: booking.displayCurrency,
|
||||
displayCurrency: booking.displayCurrency,
|
||||
displayTotalMinor: booking.displayTotalMinor,
|
||||
@@ -296,7 +296,7 @@ export class BookingsService {
|
||||
id: booking.id,
|
||||
bookingRef: booking.bookingRef,
|
||||
status: booking.status,
|
||||
totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
|
||||
totalMinor: booking.displayTotalMinor ?? resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
|
||||
currency: booking.displayCurrency,
|
||||
displayCurrency: booking.displayCurrency,
|
||||
displayTotalMinor: booking.displayTotalMinor,
|
||||
@@ -409,7 +409,7 @@ export class BookingsService {
|
||||
id: booking.id,
|
||||
bookingRef: booking.bookingRef,
|
||||
status: booking.status,
|
||||
totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
|
||||
totalMinor: booking.displayTotalMinor ?? resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
|
||||
currency: booking.displayCurrency,
|
||||
displayCurrency: booking.displayCurrency,
|
||||
displayTotalMinor: booking.displayTotalMinor,
|
||||
@@ -554,7 +554,7 @@ export class BookingsService {
|
||||
const uniquePassengers = Array.from(new Map(passengerDetails.map((p: any) => [p.name, p])).values());
|
||||
return {
|
||||
id: booking.id, bookingRef: booking.bookingRef, status: booking.status,
|
||||
totalMinor: resolvePackageRoundTripTotal(booking, booking.priceTier?.priceMinor, booking.adultCount, booking.childCount),
|
||||
totalMinor: booking.displayTotalMinor ?? resolvePackageRoundTripTotal(booking, booking.priceTier?.priceMinor, booking.adultCount, booking.childCount),
|
||||
currency: booking.displayCurrency,
|
||||
displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor,
|
||||
contactEmail: booking.contactEmail, contactPhone: booking.contactPhone,
|
||||
@@ -683,7 +683,7 @@ export class BookingsService {
|
||||
id: booking.id,
|
||||
bookingRef: booking.bookingRef,
|
||||
status: booking.status,
|
||||
totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
|
||||
totalMinor: booking.displayTotalMinor ?? resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
|
||||
currency: booking.displayCurrency,
|
||||
displayCurrency: booking.displayCurrency,
|
||||
displayTotalMinor: booking.displayTotalMinor,
|
||||
@@ -1883,7 +1883,7 @@ export class BookingsService {
|
||||
|
||||
return {
|
||||
id: booking.id, bookingRef: booking.bookingRef, status: booking.status,
|
||||
totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
|
||||
totalMinor: booking.displayTotalMinor ?? resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
|
||||
currency: booking.displayCurrency,
|
||||
adultCount: booking.adultCount, childCount: booking.childCount,
|
||||
displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor ?? undefined,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { Nack, RabbitSubscribe } from '@golevelup/nestjs-rabbitmq';
|
||||
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
||||
import {
|
||||
@@ -18,7 +19,15 @@ const PASSENGER_QUEUE = PAYMENT_QUEUES[PaymentService.PASSENGER];
|
||||
export class PaymentEventsConsumer {
|
||||
private readonly logger = new Logger(PaymentEventsConsumer.name);
|
||||
|
||||
constructor(private readonly paymentsService: PaymentsService) {}
|
||||
// IMPORTANT: do NOT constructor-inject PaymentsService here. It is a REQUEST/TRANSIENT-scoped
|
||||
// provider (its scope bubbles up from a scoped dependency), so it has no singleton instance at
|
||||
// bootstrap. Constructor-injecting it makes THIS consumer scoped too — and golevelup binds the
|
||||
// @RabbitSubscribe handler to the singleton instance it discovers at bootstrap. With no such
|
||||
// instance, the subscription still registers but delivered messages are never dispatched to
|
||||
// handle(): they pile up unacked and the booking never confirms. Injecting only the lightweight
|
||||
// (singleton) ModuleRef keeps this consumer a clean singleton; PaymentsService is resolved per
|
||||
// message via resolve() (get() throws for scoped providers).
|
||||
constructor(private readonly moduleRef: ModuleRef) {}
|
||||
|
||||
@IsPublic()
|
||||
@RabbitSubscribe({
|
||||
@@ -37,7 +46,13 @@ export class PaymentEventsConsumer {
|
||||
`RECEIVED ${event.eventType} (${event.eventId}) ref=${event.referenceId} via RabbitMQ`,
|
||||
);
|
||||
try {
|
||||
const result = await this.paymentsService.handlePaymentEvent(
|
||||
// resolve() (not get()) because PaymentsService is scoped — get() throws for scoped providers.
|
||||
const paymentsService = await this.moduleRef.resolve(
|
||||
PaymentsService,
|
||||
undefined,
|
||||
{ strict: false },
|
||||
);
|
||||
const result = await paymentsService.handlePaymentEvent(
|
||||
event as unknown as PaymentEventDto,
|
||||
);
|
||||
this.logger.log(
|
||||
|
||||
@@ -39,12 +39,34 @@ import {
|
||||
import { PassengerStaff } from "../../common/passenger-guards";
|
||||
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
|
||||
import { resolveAllowedOrigin } from "../../common/utils/redirect-origin.util";
|
||||
import { SupplementaryChargesService } from "./supplementary-charges.service";
|
||||
import { IsString, IsInt, IsOptional, Min, IsEnum, IsIn } from "class-validator";
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
|
||||
class CreateSupplementaryChargeDto {
|
||||
@ApiProperty({ example: 'EDR-20240001', description: 'Booking reference number' }) @IsString() bookingRef: string;
|
||||
@ApiProperty({ description: 'Amount owed in minor units (e.g. 5000 = 50 ETB)' }) @IsInt() @Min(1) amountMinor: number;
|
||||
@ApiProperty({ example: 'UNDERPAYMENT' }) @IsString() reason: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() notes?: string;
|
||||
}
|
||||
|
||||
class WaiveSupplementaryChargeDto {
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() notes?: string;
|
||||
}
|
||||
|
||||
class PaySupplementaryChargeDto {
|
||||
@ApiProperty({ enum: PaymentMethodTypeEnum, example: 'TELEBIRR' }) @IsEnum(PaymentMethodTypeEnum) method: PaymentMethodTypeEnum;
|
||||
@ApiPropertyOptional({ enum: ['web', 'mobile'], default: 'web' }) @IsOptional() @IsIn(['web', 'mobile']) platform?: 'web' | 'mobile';
|
||||
}
|
||||
|
||||
@ApiTags("Payment")
|
||||
@Controller("payments")
|
||||
// @Throttle({ strict: { limit: 20, ttl: 60_000 } })
|
||||
export class PaymentsController {
|
||||
constructor(private service: PaymentsService) {}
|
||||
constructor(
|
||||
private service: PaymentsService,
|
||||
private supplementaryService: SupplementaryChargesService,
|
||||
) {}
|
||||
|
||||
@Delete(":id")
|
||||
@PassengerStaff([PASSENGER_PERMS.admin])
|
||||
@@ -315,6 +337,92 @@ export class PaymentsController {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Supplementary Charges ──────────────────────────────────────────────────
|
||||
|
||||
@Post('supplementary')
|
||||
@PassengerStaff([PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.admin])
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Raise a supplementary charge for an underpayment (staff only)' })
|
||||
createSupplementaryCharge(
|
||||
@Body() dto: CreateSupplementaryChargeDto,
|
||||
@Headers('x-iam-user-id') iamUserId?: string,
|
||||
) {
|
||||
return this.supplementaryService.create({
|
||||
...dto,
|
||||
createdBy: iamUserId ?? 'staff',
|
||||
});
|
||||
}
|
||||
|
||||
@Get('supplementary')
|
||||
@PassengerStaff([PASSENGER_PERMS.payments.view, PASSENGER_PERMS.payments.viewAll, PASSENGER_PERMS.admin])
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'List supplementary charges (staff only)' })
|
||||
@ApiQuery({ name: 'bookingRef', required: false })
|
||||
@ApiQuery({ name: 'status', required: false })
|
||||
@ApiQuery({ name: 'page', required: false })
|
||||
@ApiQuery({ name: 'pageSize', required: false })
|
||||
listSupplementaryCharges(
|
||||
@Query('bookingRef') bookingRef?: string,
|
||||
@Query('status') status?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.supplementaryService.getAll({
|
||||
bookingRef,
|
||||
status,
|
||||
page: page ? parseInt(page) : 1,
|
||||
pageSize: pageSize ? parseInt(pageSize) : 20,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('supplementary/by-token/:token')
|
||||
@SetMetadata('isPublic', true)
|
||||
@ApiOperation({ summary: 'Get supplementary charge by payment token (public — for self-pay page)' })
|
||||
getSupplementaryByToken(@Param('token') token: string) {
|
||||
return this.supplementaryService.getByToken(token);
|
||||
}
|
||||
|
||||
@Post('supplementary/by-token/:token/pay')
|
||||
@SetMetadata('isPublic', true)
|
||||
@ApiOperation({ summary: 'Initiate payment for a supplementary charge (public — self-pay)' })
|
||||
paySupplementaryCharge(
|
||||
@Param('token') token: string,
|
||||
@Body() dto: PaySupplementaryChargeDto,
|
||||
) {
|
||||
return this.supplementaryService.pay(token, dto.method, dto.platform);
|
||||
}
|
||||
|
||||
@Post('supplementary/:id/mark-paid')
|
||||
@PassengerStaff([PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.admin])
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Manually mark a supplementary charge as paid (staff only)' })
|
||||
markSupplementaryPaid(
|
||||
@Param('id') id: string,
|
||||
@Body() body: { providerTxnId?: string },
|
||||
) {
|
||||
return this.supplementaryService.markPaid(id, body.providerTxnId);
|
||||
}
|
||||
|
||||
@Post('supplementary/:id/waive')
|
||||
@PassengerStaff([PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.admin])
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Waive a supplementary charge (staff only)' })
|
||||
waiveSupplementaryCharge(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: WaiveSupplementaryChargeDto,
|
||||
@Headers('x-iam-user-id') iamUserId?: string,
|
||||
) {
|
||||
return this.supplementaryService.waive(id, dto.notes ?? '', iamUserId ?? 'staff');
|
||||
}
|
||||
|
||||
@Post('supplementary/:id/resend')
|
||||
@PassengerStaff([PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.admin])
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Resend payment link for a supplementary charge (staff only)' })
|
||||
resendSupplementaryLink(@Param('id') id: string) {
|
||||
return this.supplementaryService.resendLink(id);
|
||||
}
|
||||
|
||||
private buildRedirectHtml(url: string): string {
|
||||
const escaped = url.replace(/\"/g, """);
|
||||
return `<!DOCTYPE html>
|
||||
|
||||
@@ -154,6 +154,13 @@ export class IntentStatusDto {
|
||||
@ApiPropertyOptional() paidAt?: string;
|
||||
@ApiPropertyOptional() failureCode?: string;
|
||||
@ApiPropertyOptional() failureMessage?: string;
|
||||
@ApiPropertyOptional({
|
||||
type: "object",
|
||||
additionalProperties: true,
|
||||
description:
|
||||
"Raw provider payload (initiation response merged with the latest status query) for inspection/debugging. Provider-specific shape; never trusted for state.",
|
||||
})
|
||||
providerResponse?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class BookingAmountResponseDto {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "@edr/types";
|
||||
import { PaymentsController } from "./payments.controller";
|
||||
import { PaymentsService } from "./payments.service";
|
||||
import { SupplementaryChargesService } from "./supplementary-charges.service";
|
||||
import { InternalPaymentsController } from "./internal-payments.controller";
|
||||
import { PaymentClientService } from "./payment-client.service";
|
||||
import { PaymentEventsConsumer } from "./payment-events.consumer";
|
||||
@@ -21,6 +22,8 @@ import { TicketsModule } from "../tickets/tickets.module";
|
||||
import { CurrencyModule } from "../currency/currency.module";
|
||||
import { AuditModule } from "../../common/audit.module";
|
||||
|
||||
import { NotificationsModule } from "../notifications/notifications.module";
|
||||
|
||||
const PASSENGER_QUEUE = PAYMENT_QUEUES[PaymentService.PASSENGER];
|
||||
|
||||
function rabbitMQImport(): DynamicModule[] {
|
||||
@@ -55,8 +58,7 @@ function rabbitMQImport(): DynamicModule[] {
|
||||
TicketsModule,
|
||||
CurrencyModule,
|
||||
AuditModule,
|
||||
// The payment service proxies slow provider calls (e.g. CAC Bank initiate, which SMSes an
|
||||
// OTP and can take tens of seconds). Keep this hop generous; overridable via env.
|
||||
NotificationsModule,
|
||||
HttpModule.register({
|
||||
timeout: Number(process.env.PAYMENT_API_HTTP_TIMEOUT_MS) || 60_000,
|
||||
}),
|
||||
@@ -65,10 +67,11 @@ function rabbitMQImport(): DynamicModule[] {
|
||||
controllers: [PaymentsController, InternalPaymentsController],
|
||||
providers: [
|
||||
PaymentsService,
|
||||
SupplementaryChargesService,
|
||||
PaymentClientService,
|
||||
PaymentEventsConsumer,
|
||||
ServiceAuthGuard,
|
||||
],
|
||||
exports: [PaymentClientService],
|
||||
exports: [PaymentClientService, PaymentsService],
|
||||
})
|
||||
export class PaymentsModule {}
|
||||
|
||||
@@ -432,6 +432,9 @@ export class PaymentsService {
|
||||
expiresAt: snapshot.expiresAt ? new Date(snapshot.expiresAt) : null,
|
||||
failureCode: snapshot.failureCode ?? null,
|
||||
failureMessage: snapshot.failureMessage ?? null,
|
||||
rawInitiation: snapshot.providerResponse
|
||||
? (snapshot.providerResponse as unknown as Prisma.InputJsonValue)
|
||||
: Prisma.DbNull,
|
||||
};
|
||||
return this.prisma.paymentIntent.upsert({
|
||||
where: { bookingId },
|
||||
@@ -589,6 +592,10 @@ export class PaymentsService {
|
||||
paidAt: intent.paidAt?.toISOString(),
|
||||
failureCode: intent.failureCode ?? undefined,
|
||||
failureMessage: intent.failureMessage ?? undefined,
|
||||
providerResponse:
|
||||
intent.rawInitiation && typeof intent.rawInitiation === "object"
|
||||
? (intent.rawInitiation as Record<string, unknown>)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -833,19 +840,46 @@ export class PaymentsService {
|
||||
return { alreadyFinalized: false };
|
||||
}
|
||||
|
||||
private async handleSupplementaryChargeEvent(event: PaymentEventDto): Promise<MarkPaidResponseDto> {
|
||||
if (event.eventType === 'payment.failed') {
|
||||
this.logger.warn(`supplementary charge ${event.referenceId} payment failed`);
|
||||
return { processed: true };
|
||||
}
|
||||
const charge = await this.prisma.supplementaryCharge.findUnique({ where: { id: event.referenceId } });
|
||||
if (!charge) {
|
||||
this.logger.error(`mark-paid: no supplementary charge for reference ${event.referenceId}`);
|
||||
return { processed: false, reason: 'charge-not-found' };
|
||||
}
|
||||
if (charge.status === 'PAID') return { processed: true, alreadyFinalized: true };
|
||||
await this.prisma.supplementaryCharge.update({
|
||||
where: { id: charge.id },
|
||||
data: { status: 'PAID', paidAt: new Date(), providerTxnId: event.providerTxnId ?? null },
|
||||
});
|
||||
await this.auditService.log({ action: 'UPDATE', entityType: 'SupplementaryCharge', entityId: charge.id, newData: { status: 'PAID', providerTxnId: event.providerTxnId } });
|
||||
return { processed: true };
|
||||
}
|
||||
|
||||
async handlePaymentEvent(
|
||||
event: PaymentEventDto,
|
||||
): Promise<MarkPaidResponseDto> {
|
||||
if (
|
||||
event.service !== PaymentServiceEnum.PASSENGER ||
|
||||
event.referenceType !== PaymentReferenceType.BOOKING
|
||||
) {
|
||||
if (event.service !== PaymentServiceEnum.PASSENGER) {
|
||||
this.logger.warn(
|
||||
`mark-paid: ignoring foreign reference ${event.service}/${event.referenceType}/${event.referenceId}`,
|
||||
);
|
||||
return { processed: false, reason: "foreign-reference" };
|
||||
}
|
||||
|
||||
if (event.referenceType === PaymentReferenceType.SUPPLEMENTARY_CHARGE) {
|
||||
return this.handleSupplementaryChargeEvent(event);
|
||||
}
|
||||
|
||||
if (event.referenceType !== PaymentReferenceType.BOOKING) {
|
||||
this.logger.warn(
|
||||
`mark-paid: ignoring unknown referenceType ${event.referenceType}`,
|
||||
);
|
||||
return { processed: false, reason: "foreign-reference" };
|
||||
}
|
||||
|
||||
if (event.eventType === "payment.failed") {
|
||||
const intent = await this.prisma.paymentIntent.findUnique({
|
||||
where: { bookingId: event.referenceId },
|
||||
|
||||
@@ -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 { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { CurrencyModule } from '../currency/currency.module';
|
||||
import { PaymentsModule } from '../payments/payments.module';
|
||||
import { TasksService } from './tasks.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, NotificationsModule, CurrencyModule],
|
||||
imports: [PrismaModule, NotificationsModule, CurrencyModule, PaymentsModule],
|
||||
providers: [TasksService],
|
||||
})
|
||||
export class TasksModule {}
|
||||
|
||||
@@ -3,6 +3,9 @@ import { Cron } from '@nestjs/schedule';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { SmsClientService } from '../notifications/sms-client.service';
|
||||
import { CurrencyService } from '../currency/currency.service';
|
||||
import { PaymentsService } from '../payments/payments.service';
|
||||
import { PaymentClientService } from '../payments/payment-client.service';
|
||||
import { PaymentReferenceType, ProviderPaymentStatus } from '@edr/types';
|
||||
import { MAX_PAYMENT_HOURS, CUTOFF_MINUTES, computePaymentDeadline } from '../../common/utils/payment-deadline.utils';
|
||||
|
||||
// Retention windows
|
||||
@@ -28,6 +31,8 @@ export class TasksService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly sms: SmsClientService,
|
||||
private readonly currencyService: CurrencyService,
|
||||
private readonly paymentsService: PaymentsService,
|
||||
private readonly paymentClient: PaymentClientService,
|
||||
) {}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@@ -253,6 +258,87 @@ export class TasksService {
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Every 1 min: poll the payment service for any PENDING_PAYMENT bookings
|
||||
// whose payment intent has moved to SUCCEEDED on the gateway but whose
|
||||
// confirmation event was never delivered (missed RabbitMQ message, network
|
||||
// blip, etc.). finalizePaymentSuccess() is fully idempotent so re-running
|
||||
// it for an already-confirmed booking is safe.
|
||||
//
|
||||
// Processes at most 50 bookings per cycle to avoid hammering the payment
|
||||
// service; the next tick picks up the remainder.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@Cron('*/1 * * * *')
|
||||
async syncPaymentStatuses() {
|
||||
const BATCH_SIZE = 50;
|
||||
|
||||
const bookings = await this.prisma.booking.findMany({
|
||||
where: {
|
||||
status: 'PENDING_PAYMENT',
|
||||
paymentIntent: { status: { in: ['REQUIRES_ACTION', 'PROCESSING'] } },
|
||||
},
|
||||
include: { paymentIntent: true },
|
||||
take: BATCH_SIZE,
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
if (bookings.length === 0) return;
|
||||
|
||||
let confirmed = 0;
|
||||
let failed = 0;
|
||||
let errored = 0;
|
||||
|
||||
for (const booking of bookings) {
|
||||
if (!booking.paymentIntent) continue;
|
||||
|
||||
try {
|
||||
const snapshot = await this.paymentClient.getIntentByReference(
|
||||
PaymentReferenceType.BOOKING,
|
||||
booking.id,
|
||||
);
|
||||
|
||||
if (!snapshot) continue;
|
||||
|
||||
if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) {
|
||||
const result = await this.paymentsService.finalizePaymentSuccess({
|
||||
intentId: booking.paymentIntent.id,
|
||||
providerTxnId: snapshot.providerTxnId,
|
||||
paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined,
|
||||
});
|
||||
if (!result.alreadyFinalized) {
|
||||
this.logger.log(`Payment sync confirmed: ${booking.bookingRef}`);
|
||||
confirmed++;
|
||||
}
|
||||
} else if (
|
||||
snapshot.status === ProviderPaymentStatus.FAILED ||
|
||||
snapshot.status === ProviderPaymentStatus.CANCELLED
|
||||
) {
|
||||
// The payment deadline enforcer will cancel the booking when its
|
||||
// window expires; log now so operations can see failed intents early.
|
||||
this.logger.warn(
|
||||
`Payment sync: ${booking.bookingRef} intent is ${snapshot.status} — ` +
|
||||
`booking will be auto-cancelled at payment deadline`,
|
||||
);
|
||||
failed++;
|
||||
}
|
||||
// REQUIRES_ACTION / PROCESSING → still pending, retry next cycle
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Payment sync error for ${booking.bookingRef}: ` +
|
||||
`${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
errored++;
|
||||
}
|
||||
}
|
||||
|
||||
if (confirmed > 0 || failed > 0 || errored > 0) {
|
||||
this.logger.log(
|
||||
`Payment sync run: ${bookings.length} checked, ` +
|
||||
`${confirmed} confirmed, ${failed} failed/cancelled, ${errored} errors`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Daily at 02:00 EAT: purge expired/stale records to enforce data retention.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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 { TicketsService } from './tickets.service';
|
||||
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')
|
||||
@Controller('tickets')
|
||||
@@ -10,7 +11,7 @@ export class TicketsController {
|
||||
constructor(private service: TicketsService) {}
|
||||
|
||||
@Post('smart-assign/:bookingId')
|
||||
@PassengerAdmin()
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Smart seat assignment + ticket generation',
|
||||
@@ -23,7 +24,7 @@ export class TicketsController {
|
||||
}
|
||||
|
||||
@Post('generate/:bookingId')
|
||||
@PassengerAdmin()
|
||||
@PassengerStaff(PASSENGER_PERMS.tickets.generate)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({
|
||||
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('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('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('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'),
|
||||
@@ -82,8 +83,9 @@ export const PASSENGER_PERMS = {
|
||||
manage: 'edr_passenger_app:passengers:manage',
|
||||
},
|
||||
tickets: {
|
||||
view: 'edr_passenger_app:tickets:view',
|
||||
manage: 'edr_passenger_app:tickets:manage',
|
||||
view: 'edr_passenger_app:tickets:view',
|
||||
manage: 'edr_passenger_app:tickets:manage',
|
||||
generate: 'edr_passenger_app:tickets:generate',
|
||||
},
|
||||
payments: {
|
||||
view: 'edr_passenger_app:payments:view',
|
||||
@@ -172,6 +174,7 @@ export const ROLE_PERMISSION_PRESETS = {
|
||||
PASSENGER_PERMS.bookings.manage,
|
||||
PASSENGER_PERMS.tickets.view,
|
||||
PASSENGER_PERMS.tickets.manage,
|
||||
PASSENGER_PERMS.tickets.generate,
|
||||
PASSENGER_PERMS.passengers.view,
|
||||
PASSENGER_PERMS.agents.view,
|
||||
PASSENGER_PERMS.audit.view,
|
||||
@@ -182,6 +185,7 @@ export const ROLE_PERMISSION_PRESETS = {
|
||||
ticketOfficer: [
|
||||
PASSENGER_PERMS.tickets.view,
|
||||
PASSENGER_PERMS.tickets.manage,
|
||||
PASSENGER_PERMS.tickets.generate,
|
||||
PASSENGER_PERMS.bookings.view,
|
||||
PASSENGER_PERMS.passengers.view,
|
||||
PASSENGER_PERMS.dashboard.view,
|
||||
@@ -194,6 +198,7 @@ export const ROLE_PERMISSION_PRESETS = {
|
||||
PASSENGER_PERMS.passengers.view,
|
||||
PASSENGER_PERMS.tickets.view,
|
||||
PASSENGER_PERMS.tickets.manage,
|
||||
PASSENGER_PERMS.tickets.generate,
|
||||
PASSENGER_PERMS.payments.refund,
|
||||
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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Download, Eye, Trash2 } from 'lucide-react';
|
||||
import { Download, Eye, Trash2, AlertCircle, Send, CheckCircle, XCircle, RotateCcw } from 'lucide-react';
|
||||
import DataTable from '@/components/ui/DataTable';
|
||||
import Badge from '@/components/ui/Badge';
|
||||
import ActionButton from '@/components/ui/ActionButton';
|
||||
@@ -10,6 +10,22 @@ import Modal from '@/components/ui/Modal';
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||
import { paymentsApi, apiClient } from '@/lib/api';
|
||||
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
||||
import SupplementaryChargesModal from './SupplementaryChargesModal';
|
||||
import {
|
||||
useSupplementaryCharges,
|
||||
useMarkSupplementaryPaid,
|
||||
useWaiveSupplementaryCharge,
|
||||
useResendSupplementaryLink,
|
||||
} from './useSupplementaryCharges';
|
||||
|
||||
type PageTab = 'payments' | 'supplementary';
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
PENDING: 'warning',
|
||||
PAID: 'success',
|
||||
WAIVED: 'info',
|
||||
EXPIRED: 'error',
|
||||
};
|
||||
|
||||
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
|
||||
<div className="bg-muted/40 rounded-lg p-3">
|
||||
@@ -25,6 +41,7 @@ const SectionHeader = ({ title }: { title: string }) => (
|
||||
);
|
||||
|
||||
export default function PaymentsPage() {
|
||||
const [pageTab, setPageTab] = useState<PageTab>('payments');
|
||||
const [filters, setFilters] = useState({ search: '', status: '', method: '' });
|
||||
const [selectedPayment, setSelectedPayment] = useState<any>(null);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
@@ -37,6 +54,34 @@ export default function PaymentsPage() {
|
||||
const [exportColumns, setExportColumns] = useState<Record<string, boolean>>({
|
||||
reference: true, booking: true, amount: true, method: true, status: true, createdAt: true,
|
||||
});
|
||||
const [supplementaryOpen, setSupplementaryOpen] = useState(false);
|
||||
|
||||
// Supplementary tab state
|
||||
const [suppFilters, setSuppFilters] = useState({ bookingRef: '', status: '' });
|
||||
const [suppActionError, setSuppActionError] = useState<string | null>(null);
|
||||
const [suppActionSuccess, setSuppActionSuccess] = useState<string | null>(null);
|
||||
const { data: chargesData, isLoading: loadingCharges } = useSupplementaryCharges(suppFilters);
|
||||
const charges: any[] = (chargesData as any)?.items ?? (Array.isArray(chargesData) ? chargesData : []);
|
||||
const markPaidMutation = useMarkSupplementaryPaid();
|
||||
const waiveMutation = useWaiveSupplementaryCharge();
|
||||
const resendMutation = useResendSupplementaryLink();
|
||||
|
||||
const flashSupp = (msg: string) => { setSuppActionSuccess(msg); setTimeout(() => setSuppActionSuccess(null), 3000); };
|
||||
const handleMarkPaid = async (id: string) => {
|
||||
setSuppActionError(null);
|
||||
try { await markPaidMutation.mutateAsync({ id }); flashSupp('Marked as paid'); }
|
||||
catch (e: any) { setSuppActionError(e?.response?.data?.message ?? e?.message ?? 'Failed'); }
|
||||
};
|
||||
const handleWaive = async (id: string) => {
|
||||
setSuppActionError(null);
|
||||
try { await waiveMutation.mutateAsync({ id }); flashSupp('Charge waived'); }
|
||||
catch (e: any) { setSuppActionError(e?.response?.data?.message ?? e?.message ?? 'Failed'); }
|
||||
};
|
||||
const handleResend = async (id: string) => {
|
||||
setSuppActionError(null);
|
||||
try { await resendMutation.mutateAsync(id); flashSupp('Payment link resent'); }
|
||||
catch (e: any) { setSuppActionError(e?.response?.data?.message ?? e?.message ?? 'Failed'); }
|
||||
};
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -134,9 +179,104 @@ export default function PaymentsPage() {
|
||||
<h1 className="text-2xl font-bold text-foreground">Payments</h1>
|
||||
<p className="text-muted-foreground">Manage payment transactions and refunds</p>
|
||||
</div>
|
||||
<ActionButton icon={Download} variant="export" onClick={() => setExportModalOpen(true)}>Export</ActionButton>
|
||||
<div className="flex items-center gap-2">
|
||||
{pageTab === 'supplementary' && (
|
||||
<ActionButton icon={AlertCircle} variant="secondary" onClick={() => setSupplementaryOpen(true)}>Raise Charge</ActionButton>
|
||||
)}
|
||||
{pageTab === 'payments' && (
|
||||
<ActionButton icon={Download} variant="export" onClick={() => setExportModalOpen(true)}>Export</ActionButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Page-level tabs */}
|
||||
<div className="flex gap-1 border-b border-muted">
|
||||
{(['payments', 'supplementary'] as PageTab[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setPageTab(t)}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
pageTab === t
|
||||
? 'border-emerald-500 text-emerald-600 dark:text-emerald-400'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{t === 'payments' ? 'Payments' : 'Supplementary Charges'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── SUPPLEMENTARY TAB ── */}
|
||||
{pageTab === 'supplementary' && (
|
||||
<div className="space-y-4">
|
||||
{suppActionSuccess && (
|
||||
<div className="rounded-lg bg-green-50 dark:bg-green-900/20 p-3 text-sm text-green-800 dark:text-green-200">✓ {suppActionSuccess}</div>
|
||||
)}
|
||||
{suppActionError && (
|
||||
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 p-3 text-sm text-red-700 dark:text-red-300">{suppActionError}</div>
|
||||
)}
|
||||
<div className="card grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label">Booking Ref</label>
|
||||
<input className="input" placeholder="Search booking ref…" value={suppFilters.bookingRef} onChange={(e) => setSuppFilters({ ...suppFilters, bookingRef: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Status</label>
|
||||
<select className="input" value={suppFilters.status} onChange={(e) => setSuppFilters({ ...suppFilters, status: e.target.value })}>
|
||||
<option value="">All</option>
|
||||
<option value="PENDING">Pending</option>
|
||||
<option value="PAID">Paid</option>
|
||||
<option value="WAIVED">Waived</option>
|
||||
<option value="EXPIRED">Expired</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{loadingCharges ? (
|
||||
<p className="text-sm text-muted-foreground py-6 text-center">Loading…</p>
|
||||
) : charges.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-6 text-center">No supplementary charges found.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border border-muted">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-muted/40 text-left text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<th className="px-3 py-2">Booking</th>
|
||||
<th className="px-3 py-2">Amount</th>
|
||||
<th className="px-3 py-2">Reason</th>
|
||||
<th className="px-3 py-2">Status</th>
|
||||
<th className="px-3 py-2">Created</th>
|
||||
<th className="px-3 py-2">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-muted">
|
||||
{charges.map((c: any) => (
|
||||
<tr key={c.id} className="hover:bg-muted/20 transition-colors">
|
||||
<td className="px-3 py-2 font-mono text-xs">{c.booking?.bookingRef ?? c.bookingId.substring(0, 8)}</td>
|
||||
<td className="px-3 py-2 font-semibold">{formatCurrency(c.amountMinor, c.currency ?? 'ETB')}</td>
|
||||
<td className="px-3 py-2 text-xs">{c.reason}</td>
|
||||
<td className="px-3 py-2"><Badge variant="status" status={STATUS_COLORS[c.status] ?? c.status}>{c.status}</Badge></td>
|
||||
<td className="px-3 py-2 text-xs text-muted-foreground">{formatDateTime(c.createdAt)}</td>
|
||||
<td className="px-3 py-2">
|
||||
{c.status === 'PENDING' && (
|
||||
<div className="flex gap-1">
|
||||
<button title="Mark paid" onClick={() => handleMarkPaid(c.id)} className="p-1 rounded hover:bg-green-100 dark:hover:bg-green-900/30 text-green-600"><CheckCircle size={15} /></button>
|
||||
<button title="Waive" onClick={() => handleWaive(c.id)} className="p-1 rounded hover:bg-red-100 dark:hover:bg-red-900/30 text-red-500"><XCircle size={15} /></button>
|
||||
<button title="Resend link" onClick={() => handleResend(c.id)} className="p-1 rounded hover:bg-blue-100 dark:hover:bg-blue-900/30 text-blue-500"><RotateCcw size={15} /></button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── PAYMENTS TAB ── */}
|
||||
{pageTab === 'payments' && (
|
||||
<>
|
||||
<div className="card">
|
||||
{successMessage && (
|
||||
<div className="mb-4 rounded-lg bg-green-50 dark:bg-green-900/20 p-4 text-sm text-green-800 dark:text-green-200">✓ {successMessage}</div>
|
||||
@@ -280,6 +420,11 @@ export default function PaymentsPage() {
|
||||
error={deleteError ?? undefined}
|
||||
/>
|
||||
|
||||
</>
|
||||
)}
|
||||
|
||||
<SupplementaryChargesModal isOpen={supplementaryOpen} onClose={() => setSupplementaryOpen(false)} />
|
||||
|
||||
{/* Export Modal */}
|
||||
<Modal isOpen={exportModalOpen} onClose={() => setExportModalOpen(false)} title="Export Payments" size="md">
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -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 { useQuery } from '@tanstack/react-query';
|
||||
import { Download, TrendingUp, Users, DollarSign, AlertCircle } from 'lucide-react';
|
||||
import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts';
|
||||
import { Download, TrendingUp, BookOpen, Banknote, Ticket } from 'lucide-react';
|
||||
import {
|
||||
LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid,
|
||||
Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell,
|
||||
} from 'recharts';
|
||||
import { bookingsApi } from '@/lib/api';
|
||||
import { dashboardApi } from '@/lib/api/dashboard';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import ActionButton from '@/components/ui/ActionButton';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
|
||||
const COLORS = ['#3b82f6', '#10b981', '#f59e0b'];
|
||||
const STATUS_COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444'];
|
||||
|
||||
function esc(s: string) {
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
export default function ReportsPage() {
|
||||
const [dateRange, setDateRange] = useState('30');
|
||||
@@ -21,23 +31,13 @@ export default function ReportsPage() {
|
||||
const end = new Date();
|
||||
end.setHours(23, 59, 59, 999);
|
||||
const start = new Date();
|
||||
|
||||
switch (dateRange) {
|
||||
case '7':
|
||||
start.setDate(end.getDate() - 7);
|
||||
break;
|
||||
case '30':
|
||||
start.setDate(end.getDate() - 30);
|
||||
break;
|
||||
case '90':
|
||||
start.setDate(end.getDate() - 90);
|
||||
break;
|
||||
case '7': start.setDate(end.getDate() - 7); break;
|
||||
case '30': start.setDate(end.getDate() - 30); break;
|
||||
case '90': start.setDate(end.getDate() - 90); break;
|
||||
default:
|
||||
if (startDate && endDate) {
|
||||
return { startDate, endDate };
|
||||
}
|
||||
if (startDate && endDate) return { startDate, endDate };
|
||||
}
|
||||
|
||||
return {
|
||||
startDate: start.toISOString().split('T')[0],
|
||||
endDate: end.toISOString().split('T')[0],
|
||||
@@ -46,37 +46,61 @@ export default function ReportsPage() {
|
||||
|
||||
const dates = getDateRange();
|
||||
|
||||
// Fetch all bookings
|
||||
const { data: bookingsData, isLoading } = useQuery({
|
||||
// Confirmed-ticket revenue — same source as dashboard
|
||||
const { data: stats, isLoading: statsLoading } = useQuery({
|
||||
queryKey: ['backoffice-stats'],
|
||||
queryFn: dashboardApi.getBackofficeStats,
|
||||
staleTime: 60000,
|
||||
});
|
||||
|
||||
const { data: exchangeRates = [] } = useQuery<any[]>({
|
||||
queryKey: ['currencies'],
|
||||
queryFn: () => apiClient.get('/currencies'),
|
||||
select: (d: any) => (Array.isArray(d) ? d : d?.data ?? d?.items ?? []),
|
||||
});
|
||||
|
||||
const toEtbRate = (currency: string): number | null => {
|
||||
if (currency === 'ETB') return 1;
|
||||
const r = exchangeRates.find((x: any) => x.fromCurrency === 'ETB' && x.toCurrency === currency);
|
||||
return r ? 1 / r.rate : null;
|
||||
};
|
||||
|
||||
const calcGrand = (rows: { currency: string; totalMinor: number }[]) =>
|
||||
rows.reduce((sum, { currency, totalMinor }) => {
|
||||
const rate = toEtbRate(currency);
|
||||
return rate !== null ? sum + Math.round(totalMinor * rate) : sum;
|
||||
}, 0);
|
||||
|
||||
const normalRows = stats?.revenueByCurrency ?? [];
|
||||
const packageRows = stats?.packageRevenueByCurrency ?? [];
|
||||
const normalGrand = calcGrand(normalRows);
|
||||
const packageGrand = calcGrand(packageRows);
|
||||
const overallGrand = normalGrand + packageGrand;
|
||||
|
||||
// Bookings for charts / status distribution
|
||||
const { data: bookingsData, isLoading: bookingsLoading } = useQuery({
|
||||
queryKey: ['all-bookings'],
|
||||
queryFn: () => bookingsApi.getAll({ pageSize: 1000 }),
|
||||
});
|
||||
|
||||
// Filter bookings by date range — exclude CANCELLED from revenue calculations
|
||||
const bookings = Array.isArray(bookingsData?.items)
|
||||
? bookingsData.items.filter((b: any) => {
|
||||
const bookingDate = new Date(b.createdAt).toISOString().split('T')[0];
|
||||
return bookingDate >= dates.startDate && bookingDate <= dates.endDate;
|
||||
})
|
||||
: [];
|
||||
const isLoading = statsLoading || bookingsLoading;
|
||||
|
||||
const revenueBookings = bookings.filter((b: any) => b.status !== 'CANCELLED' && b.status !== 'REFUNDED');
|
||||
const allBookings: any[] = Array.isArray(bookingsData?.items) ? bookingsData.items : [];
|
||||
|
||||
// Calculate metrics — revenue excludes cancelled/refunded bookings
|
||||
const totalRevenue = revenueBookings.reduce((sum: number, b: any) => sum + (b.totalMinor || 0), 0);
|
||||
const totalBookings = bookings.length;
|
||||
const avgTicketPrice = revenueBookings.length > 0 ? Math.round(totalRevenue / revenueBookings.length) : 0;
|
||||
const bookings = allBookings.filter((b: any) => {
|
||||
const d = new Date(b.createdAt).toISOString().split('T')[0];
|
||||
return d >= dates.startDate && d <= dates.endDate;
|
||||
});
|
||||
|
||||
// Group by date for revenue chart — exclude cancelled/refunded
|
||||
const byDate = revenueBookings.reduce((acc: Record<string, any>, b: any) => {
|
||||
const confirmedBookings = bookings.filter((b: any) => b.status !== 'CANCELLED' && b.status !== 'REFUNDED');
|
||||
|
||||
const byDate = confirmedBookings.reduce((acc: Record<string, any>, b: any) => {
|
||||
const date = new Date(b.createdAt).toISOString().split('T')[0];
|
||||
if (!acc[date]) {
|
||||
acc[date] = { totalMinor: 0, count: 0 };
|
||||
}
|
||||
if (!acc[date]) acc[date] = { totalMinor: 0, count: 0 };
|
||||
acc[date].totalMinor += b.totalMinor || 0;
|
||||
acc[date].count += 1;
|
||||
return acc;
|
||||
}, {} as Record<string, any>);
|
||||
}, {});
|
||||
|
||||
const chartData = Object.entries(byDate)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
@@ -86,33 +110,37 @@ export default function ReportsPage() {
|
||||
bookings: d.count || 0,
|
||||
}));
|
||||
|
||||
const REPORT_COLS = [
|
||||
{ key: 'date', label: 'Date' },
|
||||
{ key: 'revenue', label: 'Revenue (ETB)' },
|
||||
{ key: 'bookings', label: 'Bookings' },
|
||||
];
|
||||
const avgDailyRevenue = chartData.length > 0 ? Math.round(overallGrand / 100 / chartData.length) : 0;
|
||||
|
||||
const REPORT_COLS = ['Date', 'Revenue (ETB)', 'Confirmed Bookings'];
|
||||
|
||||
const doExport = () => {
|
||||
if (!chartData.length) { alert('No data to export'); return; }
|
||||
const headers = REPORT_COLS.map(c => c.label);
|
||||
const rows = chartData.map(r => [r.date, String(Math.round(r.revenue)), String(r.bookings)]);
|
||||
const dateStr = new Date().toISOString().split('T')[0];
|
||||
|
||||
if (exportFormat === 'pdf') {
|
||||
const w = window.open('', '_blank')!;
|
||||
w.document.write(`<!DOCTYPE html><html><head><title>Revenue Report</title><style>body{font-family:sans-serif;font-size:11px}table{border-collapse:collapse;width:100%}th,td{border:1px solid #ccc;padding:4px 8px}th{background:#10b981;color:#fff}</style></head><body>`);
|
||||
w.document.write(`<h2>Revenue Report — ${dates.startDate} to ${dates.endDate}</h2>`);
|
||||
w.document.write(`<p>Total Revenue: ETB ${Math.round(totalRevenue / 100).toLocaleString()} | Total Bookings: ${totalBookings} | Cancelled: ${bookings.filter((b: any) => b.status === 'CANCELLED').length}</p>`);
|
||||
w.document.write(`<table><thead><tr>${headers.map(h => `<th>${h}</th>`).join('')}</tr></thead><tbody>`);
|
||||
rows.forEach(r => { w.document.write(`<tr>${r.map(v => `<td>${v}</td>`).join('')}</tr>`); });
|
||||
w.document.write('</tbody></table></body></html>');
|
||||
const thead = REPORT_COLS.map(h => `<th>${esc(h)}</th>`).join('');
|
||||
const tbody = rows.map(r => `<tr>${r.map(v => `<td>${esc(v)}</td>`).join('')}</tr>`).join('');
|
||||
w.document.write(
|
||||
`<!DOCTYPE html><html><head><title>Revenue Report</title>` +
|
||||
`<style>body{font-family:sans-serif;font-size:11px}table{border-collapse:collapse;width:100%}` +
|
||||
`th,td{border:1px solid #ccc;padding:4px 8px}th{background:#10b981;color:#fff}</style></head><body>` +
|
||||
`<h2>Revenue Report — ${esc(dates.startDate)} to ${esc(dates.endDate)}</h2>` +
|
||||
`<p>Total Revenue: ${esc(formatCurrency(overallGrand, 'ETB'))} | ` +
|
||||
`Bookings: ${esc(String(stats?.totalBookings ?? 0))} | ` +
|
||||
`Tickets: ${esc(String(stats?.totalTickets ?? 0))}</p>` +
|
||||
`<table><thead><tr>${thead}</tr></thead><tbody>${tbody}</tbody></table></body></html>`
|
||||
);
|
||||
w.document.close(); w.print();
|
||||
} else if (exportFormat === 'excel') {
|
||||
const tsv = [headers.join('\t'), ...rows.map(r => r.join('\t'))].join('\n');
|
||||
const tsv = [REPORT_COLS.join('\t'), ...rows.map(r => r.join('\t'))].join('\n');
|
||||
const blob = new Blob([tsv], { type: 'application/vnd.ms-excel' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a'); a.href = url; a.download = `revenue-report-${dateStr}.xls`; a.click(); URL.revokeObjectURL(url);
|
||||
} else {
|
||||
const csv = [headers.map(h => `"${h}"`).join(','), ...rows.map(r => r.map(v => `"${v}"`).join(','))].join('\n');
|
||||
const csv = [REPORT_COLS.map(h => `"${h}"`).join(','), ...rows.map(r => r.map(v => `"${v}"`).join(','))].join('\n');
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a'); a.href = url; a.download = `revenue-report-${dateStr}.csv`; a.click(); URL.revokeObjectURL(url);
|
||||
@@ -120,11 +148,32 @@ export default function ReportsPage() {
|
||||
setExportModalOpen(false);
|
||||
};
|
||||
|
||||
const renderCurrencyRow = ({ currency, totalMinor }: { currency: string; totalMinor: number }) => {
|
||||
const rate = toEtbRate(currency);
|
||||
const etbMinor = rate !== null ? Math.round(totalMinor * rate) : null;
|
||||
return (
|
||||
<div key={currency} className="flex items-center justify-between rounded-md bg-muted/20 px-3 py-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Banknote className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">{currency}</span>
|
||||
</div>
|
||||
<span className="text-sm font-semibold tabular-nums">
|
||||
{formatCurrency(totalMinor, currency)}
|
||||
{currency !== 'ETB' && etbMinor !== null && (
|
||||
<span className="ml-1.5 text-xs font-normal text-muted-foreground">
|
||||
({formatCurrency(etbMinor, 'ETB')})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">Reports & Analytics</h1>
|
||||
<p className="text-muted-foreground mt-1">View detailed reports and performance metrics</p>
|
||||
<p className="text-muted-foreground mt-1">Revenue figures reflect confirmed tickets only</p>
|
||||
</div>
|
||||
|
||||
{/* Date Range Selector */}
|
||||
@@ -132,239 +181,327 @@ export default function ReportsPage() {
|
||||
<div className="flex items-end gap-4 flex-wrap">
|
||||
<div>
|
||||
<label className="label">Date Range</label>
|
||||
<select
|
||||
className="input"
|
||||
value={dateRange}
|
||||
onChange={(e) => setDateRange(e.target.value)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<select className="input" value={dateRange} onChange={(e) => setDateRange(e.target.value)} disabled={isLoading}>
|
||||
<option value="7">Last 7 Days</option>
|
||||
<option value="30">Last 30 Days</option>
|
||||
<option value="90">Last 90 Days</option>
|
||||
<option value="custom">Custom Range</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{dateRange === 'custom' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="label">Start Date</label>
|
||||
<input
|
||||
type="date"
|
||||
className="input"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<input type="date" className="input" value={startDate} onChange={(e) => setStartDate(e.target.value)} disabled={isLoading} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">End Date</label>
|
||||
<input
|
||||
type="date"
|
||||
className="input"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<input type="date" className="input" value={endDate} onChange={(e) => setEndDate(e.target.value)} disabled={isLoading} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ActionButton icon={Download} variant="secondary" disabled={isLoading} onClick={() => setExportModalOpen(true)}>
|
||||
Export
|
||||
</ActionButton>
|
||||
</div>
|
||||
{isLoading && (
|
||||
<p className="text-xs text-muted-foreground mt-2">Loading...</p>
|
||||
)}
|
||||
{isLoading && <p className="text-xs text-muted-foreground mt-2">Loading…</p>}
|
||||
</div>
|
||||
|
||||
{/* Key Metrics */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="card">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">Total Revenue</p>
|
||||
<p className="text-2xl font-bold mt-2">ETB {Math.round(totalRevenue / 100).toLocaleString()}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Excl. cancelled & refunded</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Total Revenue */}
|
||||
<div className="card flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Total Revenue</p>
|
||||
<div className="rounded-lg bg-amber-100 dark:bg-amber-900/30 p-1.5">
|
||||
<Banknote className="h-4 w-4 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-emerald-600 dark:text-emerald-400 tabular-nums mt-1">
|
||||
{statsLoading ? '—' : formatCurrency(overallGrand, 'ETB')}
|
||||
</p>
|
||||
<div className="flex flex-col gap-1 border-t border-border pt-2 mt-1">
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-muted-foreground">Regular</span>
|
||||
<span className="font-semibold tabular-nums">{statsLoading ? '—' : formatCurrency(normalGrand, 'ETB')}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-muted-foreground">Package</span>
|
||||
<span className="font-semibold tabular-nums">{statsLoading ? '—' : formatCurrency(packageGrand, 'ETB')}</span>
|
||||
</div>
|
||||
<DollarSign className="h-8 w-8 text-blue-500 opacity-20" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">Total Bookings</p>
|
||||
<p className="text-2xl font-bold mt-2">{totalBookings.toLocaleString()}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">All bookings</p>
|
||||
{/* Total Bookings */}
|
||||
<div className="card flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Total Bookings</p>
|
||||
<div className="rounded-lg bg-blue-100 dark:bg-blue-900/30 p-1.5">
|
||||
<BookOpen className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-2xl font-bold tabular-nums mt-1">
|
||||
{statsLoading ? '—' : (stats?.totalBookings ?? 0).toLocaleString()}
|
||||
</p>
|
||||
<div className="flex flex-col gap-1 border-t border-border pt-2 mt-1">
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-muted-foreground">Regular</span>
|
||||
<span className="font-semibold tabular-nums">{statsLoading ? '—' : (stats?.totalNormalBookings ?? 0).toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-muted-foreground">Package</span>
|
||||
<span className="font-semibold tabular-nums">{statsLoading ? '—' : (stats?.totalPackageBookings ?? 0).toLocaleString()}</span>
|
||||
</div>
|
||||
<Users className="h-8 w-8 text-green-500 opacity-20" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">Avg. Ticket Price</p>
|
||||
<p className="text-2xl font-bold mt-2">ETB {(avgTicketPrice / 100).toLocaleString()}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Non-cancelled bookings</p>
|
||||
{/* Total Tickets */}
|
||||
<div className="card flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Total Tickets</p>
|
||||
<div className="rounded-lg bg-emerald-100 dark:bg-emerald-900/30 p-1.5">
|
||||
<Ticket className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-2xl font-bold tabular-nums mt-1">
|
||||
{statsLoading ? '—' : (stats?.totalTickets ?? 0).toLocaleString()}
|
||||
</p>
|
||||
<div className="flex flex-col gap-1 border-t border-border pt-2 mt-1">
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-muted-foreground">Regular</span>
|
||||
<span className="font-semibold tabular-nums">{statsLoading ? '—' : (stats?.totalNormalTickets ?? 0).toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-muted-foreground">Package</span>
|
||||
<span className="font-semibold tabular-nums">{statsLoading ? '—' : (stats?.totalPackageTickets ?? 0).toLocaleString()}</span>
|
||||
</div>
|
||||
<TrendingUp className="h-8 w-8 text-purple-500 opacity-20" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">Avg. Daily Revenue</p>
|
||||
<p className="text-2xl font-bold mt-2">ETB {chartData.length > 0 ? Math.round((totalRevenue / 100) / chartData.length).toLocaleString() : '0'}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Daily average</p>
|
||||
{/* Avg Daily Revenue */}
|
||||
<div className="card flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Avg. Daily Revenue</p>
|
||||
<div className="rounded-lg bg-purple-100 dark:bg-purple-900/30 p-1.5">
|
||||
<TrendingUp className="h-4 w-4 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<AlertCircle className="h-8 w-8 text-orange-500 opacity-20" />
|
||||
</div>
|
||||
<p className="text-2xl font-bold tabular-nums mt-1">
|
||||
{isLoading ? '—' : formatCurrency(avgDailyRevenue * 100, 'ETB')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-auto pt-2 border-t border-border">
|
||||
Over {chartData.length} active day{chartData.length !== 1 ? 's' : ''} in range
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Revenue Breakdown by Currency */}
|
||||
<div className="card">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-widest text-muted-foreground mb-4">
|
||||
Revenue Breakdown — Confirmed Tickets
|
||||
</h2>
|
||||
{statsLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||
) : !normalRows.length && !packageRows.length ? (
|
||||
<p className="text-sm text-muted-foreground">No revenue data yet.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
{/* Regular */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Regular</span>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{(stats?.totalNormalBookings ?? 0).toLocaleString()} bookings · {(stats?.totalNormalTickets ?? 0).toLocaleString()} tickets
|
||||
</span>
|
||||
</div>
|
||||
{normalRows.length === 0
|
||||
? <p className="text-xs text-muted-foreground py-1">No revenue yet</p>
|
||||
: normalRows.map(renderCurrencyRow)}
|
||||
{normalRows.length > 0 && (
|
||||
<div className="flex items-center justify-between rounded-md bg-muted/40 px-3 py-2 mt-1">
|
||||
<span className="text-xs font-semibold text-muted-foreground">Subtotal</span>
|
||||
<span className="text-sm font-bold tabular-nums">{formatCurrency(normalGrand, 'ETB')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Package */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Package</span>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{(stats?.totalPackageBookings ?? 0).toLocaleString()} bookings · {(stats?.totalPackageTickets ?? 0).toLocaleString()} tickets
|
||||
</span>
|
||||
</div>
|
||||
{packageRows.length === 0
|
||||
? <p className="text-xs text-muted-foreground py-1">No revenue yet</p>
|
||||
: packageRows.map(renderCurrencyRow)}
|
||||
{packageRows.length > 0 && (
|
||||
<div className="flex items-center justify-between rounded-md bg-muted/40 px-3 py-2 mt-1">
|
||||
<span className="text-xs font-semibold text-muted-foreground">Subtotal</span>
|
||||
<span className="text-sm font-bold tabular-nums">{formatCurrency(packageGrand, 'ETB')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!statsLoading && (normalRows.length > 0 || packageRows.length > 0) && (
|
||||
<div className="flex items-center justify-between rounded-lg border border-border bg-muted/30 px-4 py-3 mt-4">
|
||||
<span className="text-sm font-semibold text-muted-foreground">Grand Total (ETB equivalent)</span>
|
||||
<span className="text-lg font-bold text-emerald-600 dark:text-emerald-400 tabular-nums">
|
||||
{formatCurrency(overallGrand, 'ETB')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Revenue Trend */}
|
||||
<div className="card">
|
||||
<h3 className="text-lg font-semibold mb-4">Revenue Trend</h3>
|
||||
<h3 className="text-base font-semibold mb-4">
|
||||
Revenue Trend{' '}
|
||||
<span className="text-xs font-normal text-muted-foreground">(confirmed, ETB)</span>
|
||||
</h3>
|
||||
{chartData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 12 }} />
|
||||
<YAxis tick={{ fontSize: 12 }} />
|
||||
<Tooltip formatter={(value: number) => `ETB ${Math.round(value).toLocaleString()}`} />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 11 }} />
|
||||
<YAxis tick={{ fontSize: 11 }} />
|
||||
<Tooltip formatter={(value: number) => [`ETB ${Math.round(value).toLocaleString()}`, 'Revenue']} />
|
||||
<Legend />
|
||||
<Line type="monotone" dataKey="revenue" stroke="#3b82f6" dot={{ r: 5 }} activeDot={{ r: 7 }} strokeWidth={2} />
|
||||
<Line type="monotone" dataKey="revenue" stroke="#10b981" dot={{ r: 4 }} activeDot={{ r: 6 }} strokeWidth={2} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
|
||||
No data available
|
||||
<div className="h-[280px] flex items-center justify-center text-muted-foreground text-sm">
|
||||
No data for selected range
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Daily Bookings */}
|
||||
{/* Daily Confirmed Bookings */}
|
||||
<div className="card">
|
||||
<h3 className="text-lg font-semibold mb-4">Daily Bookings</h3>
|
||||
<h3 className="text-base font-semibold mb-4">Daily Confirmed Bookings</h3>
|
||||
{chartData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 12 }} />
|
||||
<YAxis tick={{ fontSize: 12 }} />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 11 }} />
|
||||
<YAxis tick={{ fontSize: 11 }} />
|
||||
<Tooltip />
|
||||
<Bar dataKey="bookings" fill="#10b981" />
|
||||
<Bar dataKey="bookings" fill="#3b82f6" radius={[3, 3, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
|
||||
No data available
|
||||
<div className="h-[280px] flex items-center justify-center text-muted-foreground text-sm">
|
||||
No data for selected range
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Booking Status Distribution */}
|
||||
<div className="card">
|
||||
<h3 className="text-lg font-semibold mb-4">Booking Status</h3>
|
||||
<h3 className="text-base font-semibold mb-4">Booking Status Distribution</h3>
|
||||
{bookings.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={[
|
||||
{ name: 'Confirmed', value: bookings.filter((b: any) => b.status === 'CONFIRMED').length },
|
||||
{ name: 'Completed', value: bookings.filter((b: any) => b.status === 'BOARDED').length },
|
||||
{ name: 'Boarded', value: bookings.filter((b: any) => b.status === 'BOARDED').length },
|
||||
{ name: 'Cancelled', value: bookings.filter((b: any) => b.status === 'CANCELLED').length },
|
||||
{ name: 'Other', value: bookings.filter((b: any) => !['CONFIRMED', 'BOARDED', 'CANCELLED'].includes(b.status)).length },
|
||||
].filter(d => d.value > 0)}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
cx="50%" cy="50%"
|
||||
labelLine={false}
|
||||
label={({ name, value }) => `${name}: ${value}`}
|
||||
outerRadius={100}
|
||||
dataKey="value"
|
||||
>
|
||||
{COLORS.map((color, idx) => <Cell key={idx} fill={color} />)}
|
||||
{STATUS_COLORS.map((color, idx) => <Cell key={idx} fill={color} />)}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
|
||||
No data available
|
||||
<div className="h-[280px] flex items-center justify-center text-muted-foreground text-sm">
|
||||
No data for selected range
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Top Payment Methods */}
|
||||
{/* Payment Methods */}
|
||||
<div className="card">
|
||||
<h3 className="text-lg font-semibold mb-4">Payment Methods</h3>
|
||||
<h3 className="text-base font-semibold mb-4">Payment Methods</h3>
|
||||
{bookings.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-3 pt-1">
|
||||
{(Object.entries(
|
||||
bookings.reduce((acc: Record<string, number>, b: any) => {
|
||||
const method = b.paymentIntent?.method || 'Unknown';
|
||||
acc[method] = (acc[method] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>)
|
||||
) as [string, number][]
|
||||
)
|
||||
) as [string, number][])
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.slice(0, 5)
|
||||
.map(([method, count]) => (
|
||||
<div key={method} className="flex justify-between items-center p-2 bg-gray-50 dark:bg-gray-900 rounded">
|
||||
<span className="text-sm capitalize">{method.toLowerCase().replace(/_/g, ' ')}</span>
|
||||
<span className="font-semibold">{count}</span>
|
||||
</div>
|
||||
))}
|
||||
.slice(0, 6)
|
||||
.map(([method, count]) => {
|
||||
const pct = bookings.length > 0 ? Math.round((count / bookings.length) * 100) : 0;
|
||||
return (
|
||||
<div key={method} className="flex items-center gap-3">
|
||||
<span className="text-sm w-32 shrink-0 capitalize">{method.toLowerCase().replace(/_/g, ' ')}</span>
|
||||
<div className="flex-1 bg-muted rounded-full h-2">
|
||||
<div className="bg-primary h-2 rounded-full" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="text-sm font-semibold tabular-nums w-8 text-right">{count}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
|
||||
No data available
|
||||
<div className="h-[280px] flex items-center justify-center text-muted-foreground text-sm">
|
||||
No data for selected range
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Stats */}
|
||||
{/* Summary */}
|
||||
<div className="card">
|
||||
<h3 className="text-lg font-semibold mb-4">Summary</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<p className="text-sm text-muted-foreground">Total Days with Bookings</p>
|
||||
<p className="text-xl font-bold mt-2">{chartData.length}</p>
|
||||
</div>
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<p className="text-sm text-muted-foreground">Confirmed Bookings</p>
|
||||
<p className="text-xl font-bold mt-2">{bookings.filter((b: any) => b.status === 'CONFIRMED').length}</p>
|
||||
</div>
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<p className="text-sm text-muted-foreground">Completed Bookings</p>
|
||||
<p className="text-xl font-bold mt-2">{bookings.filter((b: any) => b.status === 'BOARDED').length}</p>
|
||||
</div>
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<p className="text-sm text-muted-foreground">Cancelled Bookings</p>
|
||||
<p className="text-xl font-bold mt-2">{bookings.filter((b: any) => b.status === 'CANCELLED').length}</p>
|
||||
</div>
|
||||
<h3 className="text-base font-semibold mb-4">Summary</h3>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
{[
|
||||
{ label: 'Active Days', value: chartData.length, fromStats: false },
|
||||
{ label: 'Confirmed', value: bookings.filter((b: any) => b.status === 'CONFIRMED').length, fromStats: false },
|
||||
{ label: 'Boarded', value: bookings.filter((b: any) => b.status === 'BOARDED').length, fromStats: false },
|
||||
{ label: 'Cancelled', value: bookings.filter((b: any) => b.status === 'CANCELLED').length, fromStats: false },
|
||||
{ label: 'Regular Bookings', value: stats?.totalNormalBookings ?? 0, fromStats: true },
|
||||
{ label: 'Package Bookings', value: stats?.totalPackageBookings ?? 0, fromStats: true },
|
||||
].map(({ label, value, fromStats }) => (
|
||||
<div key={label} className="border border-border rounded-lg p-3 text-center">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className="text-xl font-bold mt-1 tabular-nums">
|
||||
{fromStats && statsLoading ? '—' : value.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Export Modal */}
|
||||
<Modal isOpen={exportModalOpen} onClose={() => setExportModalOpen(false)} title="Export Revenue Report" size="sm">
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">Exports daily revenue and booking counts for the selected date range. Cancelled and refunded bookings are excluded from revenue figures.</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Exports daily confirmed-booking revenue for the selected date range. Cancelled and refunded bookings are excluded.
|
||||
</p>
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2">Export Format</p>
|
||||
<p className="text-sm font-medium mb-2">Format</p>
|
||||
<div className="flex gap-3">
|
||||
{(['csv', 'excel', 'pdf'] as const).map(fmt => (
|
||||
<label key={fmt} className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" name="reportExportFormat" value={fmt} checked={exportFormat === fmt} onChange={() => setExportFormat(fmt)} className="w-4 h-4" />
|
||||
<span className="text-sm font-medium capitalize">{fmt === 'excel' ? 'Excel (.xls)' : fmt === 'pdf' ? 'PDF (Print)' : 'CSV'}</span>
|
||||
<span className="text-sm font-medium">{fmt === 'excel' ? 'Excel (.xls)' : fmt === 'pdf' ? 'PDF (Print)' : 'CSV'}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
isOpen={deleteConfirm.isOpen}
|
||||
onClose={() => setDeleteConfirm({ isOpen: false, id: null })}
|
||||
onConfirm={() => remove.mutate(deleteConfirm.id!)}
|
||||
onConfirm={() => remove.mutate(deleteConfirm.id!, { onSuccess: () => setDeleteConfirm({ isOpen: false, id: null }) })}
|
||||
title="Delete Allowance Rule"
|
||||
message="Are you sure you want to delete this baggage allowance rule?"
|
||||
confirmText="Delete"
|
||||
|
||||
@@ -119,7 +119,8 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
|
||||
{
|
||||
title: 'Analytics & Reports',
|
||||
items: [
|
||||
{ name: 'Reports', href: '/reports', icon: BarChart3, permission: PERMS.reports.view },
|
||||
{ name: 'Overall', href: '/reports', icon: BarChart3, permission: PERMS.reports.view },
|
||||
{ name: 'Seats', href: '/reports/seats', icon: Armchair, permission: PERMS.reports.view },
|
||||
// { name: '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),
|
||||
updateMethod: (id: string, data: any) => apiClient.patch(`/payments/methods/${id}`, data),
|
||||
deleteMethod: (id: string) => apiClient.delete(`/payments/methods/${id}`),
|
||||
supplementary: {
|
||||
create: (data: { bookingRef: string; amountMinor: number; reason: string; notes?: string }) =>
|
||||
apiClient.post<any>('/payments/supplementary', data),
|
||||
getAll: async (params?: any) => {
|
||||
const cleanParams = Object.fromEntries(
|
||||
Object.entries(params || {}).filter(([, v]) => v !== '' && v !== undefined && v !== null)
|
||||
) as Record<string, string>;
|
||||
const query = new URLSearchParams(cleanParams).toString();
|
||||
const response = await apiClient.get<any>(`/payments/supplementary${query ? `?${query}` : ''}`);
|
||||
if ((response as any)?.data) return (response as any).data;
|
||||
return response;
|
||||
},
|
||||
markPaid: (id: string, providerTxnId?: string) =>
|
||||
apiClient.post<any>(`/payments/supplementary/${id}/mark-paid`, { providerTxnId }),
|
||||
waive: (id: string, notes?: string) =>
|
||||
apiClient.post<any>(`/payments/supplementary/${id}/waive`, { notes }),
|
||||
resend: (id: string) =>
|
||||
apiClient.post<any>(`/payments/supplementary/${id}/resend`, {}),
|
||||
},
|
||||
};
|
||||
|
||||
// Tickets API
|
||||
|
||||
@@ -12,6 +12,7 @@ export const PERMS = {
|
||||
tickets: {
|
||||
view: 'edr_passenger_app:tickets:view',
|
||||
manage: 'edr_passenger_app:tickets:manage',
|
||||
generate: 'edr_passenger_app:tickets:generate',
|
||||
},
|
||||
|
||||
// ── Master Data ────────────────────────────────────────────────
|
||||
|
||||
@@ -68,7 +68,11 @@ export default function ConfirmationPage() {
|
||||
import("@/lib/generate-voucher");
|
||||
}, []);
|
||||
|
||||
const { data: _booking } = useQuery<BookingWithTicket>({
|
||||
const {
|
||||
data: _booking,
|
||||
refetch: refetchBooking,
|
||||
isLoading: isBookingLoading,
|
||||
} = useQuery<BookingWithTicket>({
|
||||
queryKey: ["booking", bookingId],
|
||||
queryFn: async (): Promise<BookingWithTicket> => {
|
||||
try {
|
||||
@@ -88,6 +92,23 @@ export default function ConfirmationPage() {
|
||||
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 —
|
||||
// a gateway redirect back here does not mean payment succeeded (see payment return pages).
|
||||
// Ticket generation itself is never triggered from this page — the payment webhook
|
||||
@@ -254,6 +275,26 @@ export default function ConfirmationPage() {
|
||||
|
||||
if (!bookingId || !pnr) return null;
|
||||
|
||||
// Wait for the actual booking status before deciding pending vs. confirmed —
|
||||
// without this, _booking is briefly undefined on first load, isConfirmed reads
|
||||
// as false, and the page flashes "payment pending" before flipping to
|
||||
// "confirmed" once the fetch resolves (common, since the payment webhook has
|
||||
// often already completed by the time the user lands here).
|
||||
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 (
|
||||
<div className="booking-page">
|
||||
<div className="container mx-auto px-4">
|
||||
|
||||
@@ -125,6 +125,22 @@ function BookingDetailContent() {
|
||||
booking?.status === "PENDING_PAYMENT" || booking?.status === "DRAFT",
|
||||
});
|
||||
|
||||
// When the booking is PENDING_PAYMENT, poll the payment intent endpoint every 10 s.
|
||||
// The backend auto-confirms the booking when it finds a SUCCEEDED intent, so detecting
|
||||
// SUCCEEDED here means the booking is now CONFIRMED — refetch to update the UI.
|
||||
const { data: intentStatus } = useQuery<any>({
|
||||
queryKey: ["payment-intent-status", booking?.id],
|
||||
queryFn: () => apiClient.get(`/payments/intents/${booking!.id}`),
|
||||
enabled: booking?.status === "PENDING_PAYMENT" && !!booking?.id,
|
||||
refetchInterval: 10_000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (intentStatus?.status === "SUCCEEDED") {
|
||||
refetch();
|
||||
}
|
||||
}, [intentStatus?.status]);
|
||||
|
||||
const selectedPaymentMethod =
|
||||
(paymentMethods || []).find((m: any) => m.type === selectedMethod) || null;
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import { format } from "date-fns";
|
||||
import { formatTime, getTimePeriod, toZonedDate } from "@/utils/format";
|
||||
import { formatFare } from "@/utils/fare-utils";
|
||||
import { useState, useEffect } from "react";
|
||||
import AlternativeDatesCalendar from "@/components/AlternativeDatesCalendar";
|
||||
|
||||
// Shared by the compact schedule card's coach-type badges and the "Choose Your Coach"
|
||||
// modal, so both pick the same icon for a given coach type name.
|
||||
@@ -47,6 +48,11 @@ export default function ResultsPage() {
|
||||
);
|
||||
const [effectiveDepartureDate, setEffectiveDepartureDate] = useState<string>('');
|
||||
const [effectiveReturnDate, setEffectiveReturnDate] = useState<string>('');
|
||||
// Round-trip "both legs empty" dual-calendar: holds picks until both outbound
|
||||
// and return dates are chosen, then a single search fires for the pair — see
|
||||
// the effect below, right after searchData/pushResultsWithDates are defined.
|
||||
const [pendingOutboundDate, setPendingOutboundDate] = useState<Date | undefined>();
|
||||
const [pendingInboundDate, setPendingInboundDate] = useState<Date | undefined>();
|
||||
const [classModal, setClassModal] = useState<Schedule | null>(null);
|
||||
const [promoData, setPromoData] = useState<{
|
||||
code: string;
|
||||
@@ -96,11 +102,14 @@ export default function ResultsPage() {
|
||||
promoCode: searchParams.get("promoCode") || searchCriteria?.promoCode || "",
|
||||
};
|
||||
|
||||
// Initialise effective dates from URL/store once searchData is stable
|
||||
// Keep the displayed effective dates in sync with the URL-driven search
|
||||
// criteria — not just on first load, but every time searchData.date/returnDate
|
||||
// actually changes (e.g. picking a new date on the alternative-dates calendar
|
||||
// re-runs the search with a different date; the heading must follow it, not
|
||||
// stay frozen on whatever was first requested).
|
||||
useEffect(() => {
|
||||
if (searchData.date && !effectiveDepartureDate) setEffectiveDepartureDate(searchData.date);
|
||||
if (searchData.returnDate && !effectiveReturnDate) setEffectiveReturnDate(searchData.returnDate);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
if (searchData.date) setEffectiveDepartureDate(searchData.date);
|
||||
if (searchData.returnDate) setEffectiveReturnDate(searchData.returnDate);
|
||||
}, [searchData.date, searchData.returnDate]);
|
||||
|
||||
const nat = (searchData.nationality ?? '').toUpperCase();
|
||||
@@ -161,6 +170,46 @@ export default function ResultsPage() {
|
||||
return `/booking/search?${params}`;
|
||||
};
|
||||
|
||||
// Pushes a new date onto the CURRENT results route (not back to the search form,
|
||||
// unlike buildSearchUrl) — the query's queryKey is derived from these URL params
|
||||
// (see searchData/useQuery below), so this alone re-triggers a search with the
|
||||
// new date(s) while preserving route/passengers/nationality/promo unchanged.
|
||||
const pushResultsWithDates = (overrides: { date?: string; returnDate?: string }) => {
|
||||
const params = new URLSearchParams({
|
||||
tripType: searchData.journeyType,
|
||||
origin: searchData.originStationId,
|
||||
destination: searchData.destinationStationId,
|
||||
date: overrides.date ?? searchData.date,
|
||||
adults: searchData.adultCount.toString(),
|
||||
children: searchData.childCount.toString(),
|
||||
nationality: searchData.nationality,
|
||||
...(searchData.promoCode && { promoCode: searchData.promoCode }),
|
||||
});
|
||||
const returnDate = overrides.returnDate ?? searchData.returnDate;
|
||||
if (returnDate) params.set("returnDate", returnDate);
|
||||
router.push(`/booking/results?${params}`);
|
||||
};
|
||||
|
||||
// Round-trip dual calendar: fire the search only once both legs have a pick —
|
||||
// picking outbound alone must not trigger a search on its own.
|
||||
useEffect(() => {
|
||||
if (pendingOutboundDate && pendingInboundDate) {
|
||||
pushResultsWithDates({
|
||||
date: format(pendingOutboundDate, "yyyy-MM-dd"),
|
||||
returnDate: format(pendingInboundDate, "yyyy-MM-dd"),
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pendingOutboundDate, pendingInboundDate]);
|
||||
|
||||
// Clear stale picks once the URL-driven search criteria actually changes (i.e.
|
||||
// after a real navigation completes), so a previous failed attempt's picks don't
|
||||
// leak into the next "still no results" render.
|
||||
useEffect(() => {
|
||||
setPendingOutboundDate(undefined);
|
||||
setPendingInboundDate(undefined);
|
||||
}, [searchData.date, searchData.returnDate]);
|
||||
|
||||
const {
|
||||
data: results,
|
||||
isLoading,
|
||||
@@ -1064,6 +1113,90 @@ export default function ResultsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// Round trip, both legs empty, but at least one leg has alternative dates to
|
||||
// offer — show both date pickers together instead of forcing the user through
|
||||
// the outbound-then-return step wizard for a case we already know is doubly empty.
|
||||
const isRoundTripBothLegsEmpty =
|
||||
isRoundTrip && outboundSchedules.length === 0 && inboundSchedules.length === 0;
|
||||
if (isRoundTripBothLegsEmpty) {
|
||||
const bothCalendarsAvailable = alternativeOutbound.length > 0 && alternativeInbound.length > 0;
|
||||
const outboundValue = pendingOutboundDate ?? (searchData.date ? new Date(`${searchData.date}T00:00:00`) : undefined);
|
||||
const inboundValue = pendingInboundDate ?? (searchData.returnDate ? new Date(`${searchData.returnDate}T00:00:00`) : undefined);
|
||||
|
||||
return (
|
||||
<div className="booking-page">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="card max-w-lg mx-auto text-center py-10 px-6">
|
||||
<div className="w-16 h-16 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center mx-auto mb-5">
|
||||
<Calendar className="w-8 h-8 text-red-500 dark:text-red-400" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
No trains available
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
|
||||
No trains available on your selected dates. Please choose another
|
||||
date below.
|
||||
</p>
|
||||
<div className="space-y-4 text-left">
|
||||
{alternativeOutbound.length > 0 ? (
|
||||
<AlternativeDatesCalendar
|
||||
label="Outbound date"
|
||||
alternatives={alternativeOutbound}
|
||||
value={outboundValue}
|
||||
minDate={new Date()}
|
||||
onChange={(date) => {
|
||||
if (!bothCalendarsAvailable) {
|
||||
pushResultsWithDates({ date: format(date, "yyyy-MM-dd") });
|
||||
return;
|
||||
}
|
||||
setPendingOutboundDate(date);
|
||||
if (pendingInboundDate && pendingInboundDate < date) setPendingInboundDate(undefined);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
No alternative outbound dates found nearby.
|
||||
</p>
|
||||
)}
|
||||
{alternativeInbound.length > 0 ? (
|
||||
<AlternativeDatesCalendar
|
||||
label="Return date"
|
||||
alternatives={alternativeInbound}
|
||||
value={inboundValue}
|
||||
minDate={pendingOutboundDate ?? (searchData.date ? new Date(`${searchData.date}T00:00:00`) : new Date())}
|
||||
onChange={(date) => {
|
||||
if (!bothCalendarsAvailable) {
|
||||
pushResultsWithDates({ returnDate: format(date, "yyyy-MM-dd") });
|
||||
return;
|
||||
}
|
||||
setPendingInboundDate(date);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
No alternative return dates found nearby.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{bothCalendarsAvailable && pendingOutboundDate && !pendingInboundDate && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-4">
|
||||
Now choose a return date to search.
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
onClick={() => router.push(buildSearchUrl())}
|
||||
className="text-sm font-semibold text-primary hover:underline mt-6"
|
||||
>
|
||||
Modify search instead
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isOneWayNoOutbound) {
|
||||
return (
|
||||
<div className="booking-page">
|
||||
@@ -1078,42 +1211,26 @@ export default function ResultsPage() {
|
||||
No trains available
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
|
||||
There are no trains scheduled on{" "}
|
||||
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
||||
{searchData.date ? format(new Date(`${searchData.date}T00:00:00`), "EEEE, MMMM d, yyyy") : "your selected date"}
|
||||
</span>
|
||||
. Try a different date to see available trains.
|
||||
No trains available on your selected date. Please choose another
|
||||
date below.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.push(buildSearchUrl())}
|
||||
className="btn-primary inline-flex items-center gap-2"
|
||||
>
|
||||
<Calendar className="w-4 h-4" />
|
||||
Change Date
|
||||
</button>
|
||||
{alternativeOutbound.length > 0 ? (
|
||||
<AlternativeDatesCalendar
|
||||
alternatives={alternativeOutbound}
|
||||
value={searchData.date ? new Date(`${searchData.date}T00:00:00`) : undefined}
|
||||
minDate={new Date()}
|
||||
onChange={(date) => pushResultsWithDates({ date: format(date, "yyyy-MM-dd") })}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => router.push(buildSearchUrl())}
|
||||
className="btn-primary inline-flex items-center gap-2"
|
||||
>
|
||||
<Calendar className="w-4 h-4" />
|
||||
Change Date
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Alternative Travel Options — commented out for the time being;
|
||||
only the "No trains available" banner above is shown.
|
||||
{hasAlternatives && (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white">
|
||||
Alternative Travel Options
|
||||
</h3>
|
||||
<p className="text-sm text-amber-600 dark:text-amber-400 mt-1">
|
||||
These trains run on different dates than requested —
|
||||
adjust your travel date to book one of them.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{alternativeOutbound.map((schedule) =>
|
||||
renderScheduleCard(schedule, true, true),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
*/}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1249,28 +1366,23 @@ export default function ResultsPage() {
|
||||
</div>
|
||||
{outboundSchedules.length === 0 && (
|
||||
<div className="mt-6">
|
||||
<div className="flex items-center justify-between gap-4 px-4 py-3 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
|
||||
<div className="flex items-center gap-2.5 text-sm text-red-800 dark:text-red-300">
|
||||
<Calendar className="w-4 h-4 flex-shrink-0" />
|
||||
<span>No trains on <span className="font-semibold">{searchData.date ? format(new Date(`${searchData.date}T00:00:00`), "EEEE, MMMM d") : "your selected date"}</span>.</span>
|
||||
</div>
|
||||
<button onClick={() => router.push(buildSearchUrl())} className="text-sm font-semibold text-primary hover:underline flex-shrink-0">
|
||||
<div className="flex items-center gap-2.5 text-sm text-red-800 dark:text-red-300 px-4 py-3 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 mb-4">
|
||||
<Calendar className="w-4 h-4 flex-shrink-0" />
|
||||
<span>No trains on <span className="font-semibold">{searchData.date ? format(new Date(`${searchData.date}T00:00:00`), "EEEE, MMMM d") : "your selected date"}</span>.</span>
|
||||
</div>
|
||||
{alternativeOutbound.length > 0 ? (
|
||||
<AlternativeDatesCalendar
|
||||
alternatives={alternativeOutbound}
|
||||
value={searchData.date ? new Date(`${searchData.date}T00:00:00`) : undefined}
|
||||
minDate={new Date()}
|
||||
onChange={(date) => pushResultsWithDates({ date: format(date, "yyyy-MM-dd") })}
|
||||
/>
|
||||
) : (
|
||||
<button onClick={() => router.push(buildSearchUrl())} className="btn-primary inline-flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4" />
|
||||
Change dates
|
||||
</button>
|
||||
</div>
|
||||
{/* Alternative Outbound Options — commented out for the time being;
|
||||
only the "No trains" banner above is shown.
|
||||
<div className="mb-3">
|
||||
<h3 className="text-base font-bold text-gray-900 dark:text-white">
|
||||
Alternative Outbound Options
|
||||
</h3>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{alternativeOutbound.map((schedule: Schedule) =>
|
||||
renderScheduleCard(schedule, true, true),
|
||||
)}
|
||||
</div>
|
||||
*/}
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1335,28 +1447,23 @@ export default function ResultsPage() {
|
||||
</div>
|
||||
{inboundSchedules.length === 0 && (
|
||||
<div className="mt-6">
|
||||
<div className="flex items-center justify-between gap-4 px-4 py-3 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
|
||||
<div className="flex items-center gap-2.5 text-sm text-red-800 dark:text-red-300">
|
||||
<Calendar className="w-4 h-4 flex-shrink-0" />
|
||||
<span>No trains on <span className="font-semibold">{searchData.returnDate ? format(new Date(`${searchData.returnDate}T00:00:00`), "EEEE, MMMM d") : "your selected return date"}</span>.</span>
|
||||
</div>
|
||||
<button onClick={() => router.push(buildSearchUrl())} className="text-sm font-semibold text-primary hover:underline flex-shrink-0">
|
||||
<div className="flex items-center gap-2.5 text-sm text-red-800 dark:text-red-300 px-4 py-3 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 mb-4">
|
||||
<Calendar className="w-4 h-4 flex-shrink-0" />
|
||||
<span>No trains on <span className="font-semibold">{searchData.returnDate ? format(new Date(`${searchData.returnDate}T00:00:00`), "EEEE, MMMM d") : "your selected return date"}</span>.</span>
|
||||
</div>
|
||||
{alternativeInbound.length > 0 ? (
|
||||
<AlternativeDatesCalendar
|
||||
alternatives={alternativeInbound}
|
||||
value={searchData.returnDate ? new Date(`${searchData.returnDate}T00:00:00`) : undefined}
|
||||
minDate={new Date(`${searchData.date}T00:00:00`)}
|
||||
onChange={(date) => pushResultsWithDates({ returnDate: format(date, "yyyy-MM-dd") })}
|
||||
/>
|
||||
) : (
|
||||
<button onClick={() => router.push(buildSearchUrl())} className="btn-primary inline-flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4" />
|
||||
Change dates
|
||||
</button>
|
||||
</div>
|
||||
{/* Alternative Return Options — commented out for the time being;
|
||||
only the "No trains" banner above is shown.
|
||||
<div className="mb-3">
|
||||
<h3 className="text-base font-bold text-gray-900 dark:text-white">
|
||||
Alternative Return Options
|
||||
</h3>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{alternativeInbound.map((schedule: Schedule) =>
|
||||
renderScheduleCard(schedule, false, true),
|
||||
)}
|
||||
</div>
|
||||
*/}
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
failureCode?: string;
|
||||
failureMessage?: string;
|
||||
/** Raw provider status-query body, merged into the intent's audit payload when present. */
|
||||
rawResponse?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -357,6 +359,7 @@ export class IntentsService {
|
||||
providerTxnId: status.providerTxnId,
|
||||
failureCode: status.failureCode,
|
||||
failureMessage: status.failureMessage,
|
||||
rawResponse: status.rawResponse,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -388,6 +391,15 @@ export class IntentsService {
|
||||
return { alreadyTerminal: true };
|
||||
}
|
||||
|
||||
// Keep the audit payload current with the latest provider status body (surfaced as
|
||||
// `providerResponse` in the snapshot). Merged so the initiation keys are preserved.
|
||||
if (result.rawResponse) {
|
||||
intent.rawInitiation = {
|
||||
...(intent.rawInitiation ?? {}),
|
||||
statusResponse: result.rawResponse,
|
||||
};
|
||||
}
|
||||
|
||||
if (result.status === ProviderPaymentStatus.SUCCEEDED) {
|
||||
const paidAt = result.paidAt ?? new Date();
|
||||
intent.status = ProviderPaymentStatus.SUCCEEDED;
|
||||
@@ -482,6 +494,7 @@ export class IntentsService {
|
||||
failureCode: intent.failureCode ?? undefined,
|
||||
failureMessage: intent.failureMessage ?? undefined,
|
||||
expiresAt: intent.expiresAt?.toISOString(),
|
||||
providerResponse: intent.rawInitiation ?? undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,13 +129,17 @@ export class WaafiProvider implements PaymentProvider, OnModuleInit {
|
||||
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
|
||||
// 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),
|
||||
// never terminal — so the intent keeps waiting for the webhook / its expiry rather than being
|
||||
// wrongly resolved off a "no info" response.
|
||||
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`,
|
||||
);
|
||||
return {
|
||||
|
||||
@@ -100,6 +100,7 @@ export enum PaymentService {
|
||||
export enum PaymentReferenceType {
|
||||
BOOKING = "BOOKING",
|
||||
SHIPMENT = "SHIPMENT",
|
||||
SUPPLEMENTARY_CHARGE = "SUPPLEMENTARY_CHARGE",
|
||||
}
|
||||
|
||||
|
||||
@@ -153,6 +154,13 @@ export type PaymentIntentSnapshot ={
|
||||
failureCode?: string;
|
||||
failureMessage?: string;
|
||||
expiresAt?: string;
|
||||
/**
|
||||
* Raw provider payload for inspection/debugging — the audit copy of the provider
|
||||
* initiation response merged with the latest status-query response (secrets redacted
|
||||
* upstream). Not a contract with the provider; shape is provider-specific. Never trusted
|
||||
* for state decisions — the state machine drives `status`.
|
||||
*/
|
||||
providerResponse?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type PaymentEventType = "payment.succeeded" | "payment.failed";
|
||||
|
||||
@@ -620,6 +620,12 @@ export interface IContract extends BaseEntity {
|
||||
* clearance view per contract.
|
||||
*/
|
||||
clearancePhase?: ContractDocPhase | string | null;
|
||||
/**
|
||||
* Latest clearance cycle's linked booking id + status (list responses only).
|
||||
* An EXPIRED status means the customer never paid — GL may rebook.
|
||||
*/
|
||||
latestCycleBookingId?: string | null;
|
||||
latestCycleBookingStatus?: string | null;
|
||||
|
||||
pricingBreakdown?: ContractPricingBreakdown | null;
|
||||
pricingDisplayMode?: "UNIT_RATES";
|
||||
|
||||
Reference in New Issue
Block a user