mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 18:20:57 +00:00
Merge branch 'dev' into freight/nati-2
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Per-wagon loading. Staff may confirm loading wagon-by-wagon instead of the
|
||||
* whole booking at once:
|
||||
* - bookings.loading_started_at — first wagon loaded; the booking stays PAID
|
||||
* until every remaining wagon is LOADED (remaining = allocated − cancelled).
|
||||
* Also shields a mid-load booking from the dispatch "left behind" unassign.
|
||||
* - wagon_booking_allocations.loaded_at / loaded_by_user_id — per-wagon
|
||||
* confirmation audit.
|
||||
* - booking_wagon_cancellations.fault — who caused an at-loading cancel of
|
||||
* the never-loaded remainder: CUSTOMER (fee applies) or EDR (no fee, credit
|
||||
* rebookable in full).
|
||||
*/
|
||||
export class PerWagonLoading3760000000000 implements MigrationInterface {
|
||||
name = 'PerWagonLoading3760000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS loading_started_at timestamptz`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.wagon_booking_allocations
|
||||
ADD COLUMN IF NOT EXISTS loaded_at timestamptz`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.wagon_booking_allocations
|
||||
ADD COLUMN IF NOT EXISTS loaded_by_user_id uuid`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.wagon_booking_allocations
|
||||
ADD COLUMN IF NOT EXISTS unloaded_at timestamptz`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.wagon_booking_allocations
|
||||
ADD COLUMN IF NOT EXISTS unloaded_by_user_id uuid`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.booking_wagon_cancellations
|
||||
ADD COLUMN IF NOT EXISTS fault varchar(16)`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.booking_wagon_cancellations DROP COLUMN IF EXISTS fault`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.wagon_booking_allocations DROP COLUMN IF EXISTS loaded_by_user_id`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.wagon_booking_allocations DROP COLUMN IF EXISTS unloaded_by_user_id`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.wagon_booking_allocations DROP COLUMN IF EXISTS unloaded_at`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.wagon_booking_allocations DROP COLUMN IF EXISTS loaded_at`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS loading_started_at`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Why a wagon left (or joined) the consist, on the adjustment log itself.
|
||||
*
|
||||
* SCHEDULED-run detach / send-to-maintenance now requires a reason instead of
|
||||
* a second staffer's approval, so the reason has to read back where the change
|
||||
* reads back: the train-builder History tab. Nullable — every other writer
|
||||
* (trip cuts, couples, arrival returns) keeps logging without one.
|
||||
*/
|
||||
export class WagonAdjustmentReason3770000000000 implements MigrationInterface {
|
||||
name = 'WagonAdjustmentReason3770000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.schedule_wagon_adjustment_logs
|
||||
ADD COLUMN IF NOT EXISTS reason varchar(500)`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.schedule_wagon_adjustment_logs
|
||||
DROP COLUMN IF EXISTS reason`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
import { BookingWagonCancellationService } from './booking-wagon-cancellation.service';
|
||||
import {
|
||||
bulkTonWagonsRequired,
|
||||
bulkTonsPerWagonFor,
|
||||
} from '../train-scheduling/train-capacity.util';
|
||||
|
||||
/**
|
||||
* Sizing of a bulk quantity cut (no DB touched on this branch): a whole-booking
|
||||
@@ -107,3 +111,91 @@ describe('BookingWagonCancellationService.rebook (odd-20ft consolidation)', () =
|
||||
).rejects.toThrow(/already shares a wagon/i);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A NUMBER_OF_WAGONS booking pins its count in `bulkRequestedWagons`, and
|
||||
* bulkTonWagonsRequired honours that verbatim. Partial cancel must shrink it
|
||||
* alongside wagonsRequired/cargoTotalWeightVgm — left stale, the booking
|
||||
* re-inflates to its pre-cancel count on the next allocation and each wagon
|
||||
* carries tons / stale-count instead of the real even share.
|
||||
*/
|
||||
describe('partial cancel of a NUMBER_OF_WAGONS bulk booking', () => {
|
||||
// 980T over 14 wagons (70T each), 2 wagons cancelled.
|
||||
const before = { freightType: 'BULK', cargoTotalWeightVgm: 980, bulkRequestedWagons: 14 };
|
||||
const droppedWeight = 140;
|
||||
const wagonsCancelled = 2;
|
||||
|
||||
// The decrement applied in applyPaidCut's booking update.
|
||||
const after = {
|
||||
...before,
|
||||
cargoTotalWeightVgm: before.cargoTotalWeightVgm - droppedWeight,
|
||||
bulkRequestedWagons: Math.max(
|
||||
0,
|
||||
Math.floor(before.bulkRequestedWagons - wagonsCancelled),
|
||||
),
|
||||
};
|
||||
|
||||
it('reallocates at the reduced count, not the pre-cancel one', () => {
|
||||
expect(bulkTonWagonsRequired(before, undefined, 'nw5', 70)).toBe(14);
|
||||
expect(bulkTonWagonsRequired(after, undefined, 'nw5', 70)).toBe(12);
|
||||
});
|
||||
|
||||
it('keeps tons-per-wagon at the real even share', () => {
|
||||
// Stale count would spread 840T over 14 wagons → 60T each.
|
||||
expect(bulkTonsPerWagonFor(after, undefined, 'nw5', 70)).toBe(70);
|
||||
});
|
||||
|
||||
it('cancelling every wagon leaves no requested count behind', () => {
|
||||
const all = Math.max(0, Math.floor(before.bulkRequestedWagons - 14));
|
||||
expect(all).toBe(0);
|
||||
expect(bulkTonWagonsRequired(
|
||||
{ ...before, cargoTotalWeightVgm: 0, bulkRequestedWagons: all },
|
||||
undefined,
|
||||
'nw5',
|
||||
70,
|
||||
)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Rebooking a NUMBER_OF_WAGONS bulk credit: the create path rejects the rebook
|
||||
* unless the DTO carries a wagon count ("<cargo> is booked by wagons — enter
|
||||
* the number of wagons needed"), and the quantities snapshot holds tons only.
|
||||
* The count therefore has to come off the cancellation row itself.
|
||||
*/
|
||||
describe('BookingWagonCancellationService.buildRebookDto (bulk wagon count)', () => {
|
||||
const svc = Object.create(BookingWagonCancellationService.prototype) as {
|
||||
buildRebookDto(
|
||||
row: unknown,
|
||||
scheduledDate: string,
|
||||
overrides?: unknown,
|
||||
): { bulkLines?: { cargoWeightTons: number }[]; requestedWagons?: number };
|
||||
};
|
||||
|
||||
it('carries the cancelled wagon count onto the rebook', () => {
|
||||
const dto = svc.buildRebookDto(
|
||||
{ wagonsCancelled: 2, weightTons: 140, cancelledQuantities: { bulkTons: 140 } },
|
||||
'2026-09-10',
|
||||
);
|
||||
expect(dto.bulkLines).toEqual([{ cargoWeightTons: 140 }]);
|
||||
// Without this the create path throws before the booking is ever made.
|
||||
expect(dto.requestedWagons).toBe(2);
|
||||
});
|
||||
|
||||
it('rounds a fractional cut up to a whole wagon', () => {
|
||||
const dto = svc.buildRebookDto(
|
||||
{ wagonsCancelled: 0.5, weightTons: 35, cancelledQuantities: { bulkTons: 35 } },
|
||||
'2026-09-10',
|
||||
);
|
||||
// Flooring would send 0 into a check that demands >= 1.
|
||||
expect(dto.requestedWagons).toBe(1);
|
||||
});
|
||||
|
||||
it('leaves the count off when nothing was cancelled', () => {
|
||||
const dto = svc.buildRebookDto(
|
||||
{ wagonsCancelled: 0, weightTons: 0, cancelledQuantities: { bulkTons: 12 } },
|
||||
'2026-09-10',
|
||||
);
|
||||
expect(dto.requestedWagons).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,6 +25,8 @@ import { Rate } from '../rule-engine/entities/rate.entity';
|
||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||
import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service';
|
||||
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
||||
import { WagonAllocationBulkLoad } from '../train-schedules/entities/wagon-allocation-bulk-load.entity';
|
||||
import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity';
|
||||
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
|
||||
@@ -35,6 +37,7 @@ import {
|
||||
} from './booking-wagon-cancellations.repository';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import {
|
||||
CancelRemainingWagonsDto,
|
||||
RebookCancelledWagonsDto,
|
||||
RebookContainerLineDto,
|
||||
RequestWagonCancellationDto,
|
||||
@@ -625,7 +628,14 @@ export class BookingWagonCancellationService {
|
||||
this.logger.warn(`No wagon cancellation for paid fee invoice ${feeInvoiceId}.`);
|
||||
return;
|
||||
}
|
||||
if (row.status !== 'FEE_PENDING') return;
|
||||
if (row.status !== 'FEE_PENDING') {
|
||||
// At-loading cancels apply the cut immediately and leave the invoice
|
||||
// open — settle only the payment stamp when the customer pays later.
|
||||
if (!row.feePaidAt) {
|
||||
await this.repo.update(row.id, { feePaidAt: new Date() });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// The fee can settle after loading started (slow payment). Never cut
|
||||
// loaded cargo: leave the row FEE_PENDING and alert staff to resolve
|
||||
@@ -653,6 +663,25 @@ export class BookingWagonCancellationService {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.applyCut(row, releasedEarly, { feeSettled: true });
|
||||
this.logger.log(
|
||||
`Wagon cancellation ${row.id}: fee paid, booking ${row.bookingId} reduced by ${row.wagonsCancelled} wagon(s).`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the cut to the booking: reduce quantities/wagons/amount, release the
|
||||
* cancelled allocations, flip the row to CREDIT_AVAILABLE. Runs at fee
|
||||
* settlement for the customer-requested flow (feeSettled: true) and
|
||||
* immediately for at-loading cancels (feeSettled only when no fee is owed —
|
||||
* EDR fault; a customer-fault cut leaves feePaidAt null until the open
|
||||
* invoice settles via onFeePaid).
|
||||
*/
|
||||
private async applyCut(
|
||||
row: BookingWagonCancellation,
|
||||
releasedEarly: boolean,
|
||||
opts: { feeSettled: boolean },
|
||||
): Promise<void> {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const booking = await manager.getRepository(Booking).findOne({
|
||||
where: { id: row.bookingId },
|
||||
@@ -704,8 +733,21 @@ export class BookingWagonCancellationService {
|
||||
Number(booking.wagonsRequired ?? 0) - Number(row.wagonsCancelled),
|
||||
);
|
||||
const isFull = wagonsLeft <= 0;
|
||||
// NUMBER_OF_WAGONS bookings pin their count in bulkRequestedWagons, which
|
||||
// bulkTonWagonsRequired honours verbatim. Left stale it re-inflates the
|
||||
// booking to its pre-cancel count on the next allocation (and shrinks
|
||||
// tons-per-wagon to tons / stale-count), so shrink it with the cut.
|
||||
const requestedWagonsLeft = booking.bulkRequestedWagons
|
||||
? Math.max(
|
||||
0,
|
||||
Math.floor(Number(booking.bulkRequestedWagons) - Number(row.wagonsCancelled)),
|
||||
)
|
||||
: null;
|
||||
await manager.getRepository(Booking).update(booking.id, {
|
||||
wagonsRequired: Math.max(0, wagonsLeft),
|
||||
...(requestedWagonsLeft !== null
|
||||
? { bulkRequestedWagons: requestedWagonsLeft }
|
||||
: {}),
|
||||
cargoTotalWeightVgm: Math.max(
|
||||
0,
|
||||
round3(Number(booking.cargoTotalWeightVgm) - droppedWeight),
|
||||
@@ -723,7 +765,7 @@ export class BookingWagonCancellationService {
|
||||
|
||||
await manager.getRepository(BookingWagonCancellation).update(row.id, {
|
||||
status: 'CREDIT_AVAILABLE',
|
||||
feePaidAt: new Date(),
|
||||
...(opts.feeSettled ? { feePaidAt: new Date() } : {}),
|
||||
weightTons: droppedWeight,
|
||||
cancelledQuantities: quantities,
|
||||
});
|
||||
@@ -741,9 +783,145 @@ export class BookingWagonCancellationService {
|
||||
: `${row.wagonsCancelled} wagon(s) of ${booking.reference} are cancelled. Your paid freight is kept as credit — rebook it on any coming train day.`,
|
||||
);
|
||||
}
|
||||
this.logger.log(
|
||||
`Wagon cancellation ${row.id}: fee paid, booking ${row.bookingId} reduced by ${row.wagonsCancelled} wagon(s).`,
|
||||
}
|
||||
|
||||
/**
|
||||
* Staff cancel of the never-loaded remainder mid-load: the operator loaded
|
||||
* what physically rides and cuts the rest, so the booking shrinks to its
|
||||
* loaded wagons, dispatch unblocks, and the warehouse only ever sees the
|
||||
* final (smaller) booking. Unlike the customer flow the cut applies
|
||||
* IMMEDIATELY — the train cannot wait for a fee payment:
|
||||
* - CUSTOMER fault: cancellation fee invoiced, payable after; the credit
|
||||
* row opens right away (feePaidAt stamps when the invoice settles).
|
||||
* - EDR fault: no fee at all; the credit is rebookable in full.
|
||||
*/
|
||||
async cancelRemainingAtLoading(
|
||||
bookingId: string,
|
||||
dto: CancelRemainingWagonsDto,
|
||||
userId?: string,
|
||||
): Promise<BookingWagonCancellation> {
|
||||
const booking = await this.bookingsRepository.findById(bookingId);
|
||||
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found.`);
|
||||
if (booking.paymentStatus !== 'PAID' || booking.status !== 'PAID') {
|
||||
throw new BadRequestException(
|
||||
'Only a paid booking still loading can cancel its remaining wagons.',
|
||||
);
|
||||
}
|
||||
if (booking.loadedAt) {
|
||||
throw new BadRequestException(
|
||||
'This booking is already fully loaded — there is nothing left to cancel.',
|
||||
);
|
||||
}
|
||||
if (!booking.contractId) {
|
||||
throw new BadRequestException(
|
||||
'Wagon cancellation needs a contract booking (the credit is rebooked under the contract).',
|
||||
);
|
||||
}
|
||||
const open = await this.repo.findOpenForBooking(bookingId);
|
||||
if (open) {
|
||||
throw new ConflictException(
|
||||
'This booking already has a cancellation awaiting its fee. Pay or withdraw it first.',
|
||||
);
|
||||
}
|
||||
|
||||
const allocations = await this.dataSource
|
||||
.getRepository(WagonBookingAllocation)
|
||||
.createQueryBuilder('alloc')
|
||||
.innerJoin(TrainSetWagon, 'slot', 'slot.id = alloc.train_set_wagon_id')
|
||||
.innerJoin(
|
||||
TrainSchedule,
|
||||
'schedule',
|
||||
'schedule.train_set_id = slot.train_set_id AND schedule.id = :scheduleId',
|
||||
{ scheduleId: dto.scheduleId },
|
||||
)
|
||||
.where('alloc.booking_id = :bookingId', { bookingId })
|
||||
.getMany();
|
||||
const loaded = allocations.filter(
|
||||
(a) => a.status === 'LOADED' || a.status === 'DEPARTED',
|
||||
);
|
||||
const remaining = allocations.filter(
|
||||
(a) => a.status !== 'LOADED' && a.status !== 'DEPARTED',
|
||||
);
|
||||
if (!loaded.length) {
|
||||
throw new BadRequestException(
|
||||
'Loading has not started for this booking — use the normal wagon cancellation flow.',
|
||||
);
|
||||
}
|
||||
if (!remaining.length) {
|
||||
throw new BadRequestException(
|
||||
'Every wagon of this booking is loaded — there is nothing to cancel.',
|
||||
);
|
||||
}
|
||||
|
||||
const cut = await this.resolveRequestedCut(booking, {
|
||||
wagonAllocationIds: remaining.map((r) => r.id),
|
||||
} as RequestWagonCancellationDto);
|
||||
if (booking.consolidationPartnerId) this.assertCutSparesSharedWagon(cut);
|
||||
|
||||
const edrFault = !!dto.edrFault;
|
||||
const fee = edrFault ? null : await this.priceFee(booking, cut);
|
||||
const creditAmount = this.creditFor(booking, cut.wagons);
|
||||
|
||||
const row = await this.repo.create({
|
||||
bookingId,
|
||||
wagonsCancelled: cut.wagons,
|
||||
weightTons: cut.weightTons,
|
||||
cancelledQuantities: cut.quantities,
|
||||
creditAmount,
|
||||
feeRateId: fee?.rates[0]?.id ?? null,
|
||||
feeAmount: fee?.amount ?? 0,
|
||||
feeCurrency: fee?.currency ?? booking.paymentCurrency ?? 'ETB',
|
||||
status: 'FEE_PENDING',
|
||||
reason: dto.reason,
|
||||
fault: edrFault ? 'EDR' : 'CUSTOMER',
|
||||
requestedByUserId: userId ?? null,
|
||||
});
|
||||
|
||||
let current = row;
|
||||
if (fee && fee.amount > 0) {
|
||||
const invoice = await this.billing.generateInvoice({
|
||||
source: Freight.InvoiceSource.Booking,
|
||||
sourceId: bookingId,
|
||||
type: WAGON_CANCEL_FEE_INVOICE_TYPE,
|
||||
companyId: booking.companyId,
|
||||
companyProfileId: booking.companyProfileId,
|
||||
currency: fee.currency,
|
||||
lines: [
|
||||
{
|
||||
chargeType: 'CANCELLATION_FEE',
|
||||
description: `Wagon cancellation fee — ${cut.wagons} wagon(s) of booking ${booking.reference} cancelled at loading`,
|
||||
quantity: cut.wagons,
|
||||
unitRate: fee.perWagon,
|
||||
amount: fee.amount,
|
||||
currency: fee.currency,
|
||||
metadata: { wagonCancellationId: row.id },
|
||||
},
|
||||
],
|
||||
totalAmount: fee.amount,
|
||||
status: Freight.InvoiceStatus.Issued,
|
||||
});
|
||||
current = (await this.repo.update(row.id, { feeInvoiceId: invoice.id })) ?? row;
|
||||
}
|
||||
|
||||
// The cut applies NOW — booking shrinks, allocations release, credit opens.
|
||||
// EDR fault (or a zero fee) settles the fee side immediately; a customer-
|
||||
// fault fee stays owed and stamps feePaidAt via onFeePaid when it settles.
|
||||
await this.applyCut(current, false, { feeSettled: edrFault || !fee || fee.amount <= 0 });
|
||||
|
||||
// The booking now holds only loaded wagons — let the journey complete the
|
||||
// load (PAID → IN_TRANSIT, warehouse inventory, milestones).
|
||||
this.events.emit('booking.wagonsCancelledAtLoading', {
|
||||
bookingId,
|
||||
scheduleId: dto.scheduleId,
|
||||
userId: userId ?? null,
|
||||
});
|
||||
|
||||
this.notifyStaff(
|
||||
booking,
|
||||
'Wagons cancelled at loading',
|
||||
`${booking.reference}: ${cut.wagons} unloaded wagon(s) cancelled (${edrFault ? 'EDR fault — no fee' : `customer fault — fee invoiced`}). Reason: ${dto.reason}`,
|
||||
);
|
||||
return this.mustFind(row.id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1712,6 +1890,14 @@ export class BookingWagonCancellationService {
|
||||
}
|
||||
|
||||
dto.bulkLines = [{ cargoWeightTons: Number(q.bulkTons ?? row.weightTons) }];
|
||||
// NUMBER_OF_WAGONS cargo is booked by wagon count, not by tons: the create
|
||||
// path rejects the rebook outright without it. The count is not in the
|
||||
// quantities snapshot (which only carries tons) — it is the cancellation's
|
||||
// own wagonsCancelled, so every existing credit rebooks without a backfill.
|
||||
// Rounded UP: a fractional cut still needs a whole wagon to ride on, and
|
||||
// flooring 0.5 would send 0 into a check that demands >= 1.
|
||||
const cancelledWagons = Math.ceil(Number(row.wagonsCancelled ?? 0));
|
||||
if (cancelledWagons >= 1) dto.requestedWagons = cancelledWagons;
|
||||
return dto;
|
||||
}
|
||||
|
||||
|
||||
@@ -100,6 +100,7 @@ import { BookingWagonCancellationService } from "./booking-wagon-cancellation.se
|
||||
import {
|
||||
FilterWagonCancellationsDto,
|
||||
RebookCancelledWagonsDto,
|
||||
CancelRemainingWagonsDto,
|
||||
RequestWagonCancellationDto,
|
||||
} from "./dto/wagon-cancellation.dto";
|
||||
import {
|
||||
@@ -640,6 +641,20 @@ export class BookingsController {
|
||||
return this.wagonCancellationService.requestCancellation(id, dto, user?.id);
|
||||
}
|
||||
|
||||
@Post(":id/wagon-cancellations/at-loading")
|
||||
@BookingStaff(FREIGHT_PERMS.trainScheduling.load)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Staff: cancel the never-loaded remainder of a booking mid-load. The cut applies immediately (the train cannot wait); CUSTOMER fault invoices the fee to pay after, EDR fault charges nothing.",
|
||||
})
|
||||
async cancelRemainingWagonsAtLoading(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: CancelRemainingWagonsDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.wagonCancellationService.cancelRemainingAtLoading(id, dto, user?.id);
|
||||
}
|
||||
|
||||
@Get(":id/wagon-cancellations")
|
||||
@ApiOperation({
|
||||
summary: "Wagon-cancellation history of one booking (owner or staff)",
|
||||
|
||||
@@ -3,9 +3,11 @@ import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayNotEmpty,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
@@ -169,3 +171,28 @@ export class FilterWagonCancellationsDto {
|
||||
@Min(1)
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Staff cancel of the never-loaded remainder of a booking mid-load: everything
|
||||
* not yet LOADED on the schedule is cut, the booking shrinks to its loaded
|
||||
* wagons, and the freed credit is rebookable. Fault decides the fee: CUSTOMER
|
||||
* — cancellation fee invoiced (payable after the cut); EDR — no fee.
|
||||
*/
|
||||
export class CancelRemainingWagonsDto {
|
||||
@ApiProperty({ description: 'Schedule the booking is being loaded on' })
|
||||
@IsUUID('4')
|
||||
scheduleId!: string;
|
||||
|
||||
@ApiProperty({ description: 'Why the remaining wagons are not riding' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(2000)
|
||||
reason!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'The shortfall is EDR\'s fault (wagon shortage, yard problem) — no fee charged',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
edrFault?: boolean;
|
||||
}
|
||||
|
||||
@@ -132,6 +132,15 @@ export class BookingWagonCancellation extends BaseEntity {
|
||||
@Column({ name: 'reason', type: 'text', nullable: true })
|
||||
reason?: string | null;
|
||||
|
||||
/**
|
||||
* At-loading cancels of the never-loaded remainder: who caused it.
|
||||
* CUSTOMER — cancellation fee applies (invoice payable after the cut);
|
||||
* EDR — no fee, the full credit is rebookable. Null for customer-requested
|
||||
* cancellations (the pre-loading flow).
|
||||
*/
|
||||
@Column({ name: 'fault', type: 'varchar', length: 16, nullable: true })
|
||||
fault?: 'CUSTOMER' | 'EDR' | null;
|
||||
|
||||
@Column({ name: 'requested_by_user_id', type: 'uuid', nullable: true })
|
||||
requestedByUserId?: string | null;
|
||||
|
||||
|
||||
@@ -588,6 +588,15 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'loaded_at', type: 'timestamptz', nullable: true })
|
||||
loadedAt?: Date | null;
|
||||
|
||||
/**
|
||||
* First wagon of this booking confirmed loaded (per-wagon loading). The
|
||||
* booking stays PAID until every remaining wagon is LOADED — loadedAt then
|
||||
* stamps the completion. Also shields the booking from the dispatch
|
||||
* "left behind" unassign while mid-load.
|
||||
*/
|
||||
@Column({ name: 'loading_started_at', type: 'timestamptz', nullable: true })
|
||||
loadingStartedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'loaded_by_user_id', type: 'uuid', nullable: true })
|
||||
loadedByUserId?: string | null;
|
||||
|
||||
|
||||
@@ -201,6 +201,9 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
.leftJoinAndSelect('routes.destinationYard', 'routeDestination')
|
||||
.leftJoinAndSelect('contract.cargoScope', 'cargoScope')
|
||||
.leftJoinAndSelect('cargoScope.cargoType', 'cargoType')
|
||||
// Wagon types carry the rated capacity the booking forms need to reject
|
||||
// a wagon count whose even share overloads a wagon (see maxTonsPerWagon).
|
||||
.leftJoinAndSelect('cargoType.wagonTypes', 'cargoTypeWagonTypes')
|
||||
.leftJoinAndSelect('contract.rateSnapshots', 'rateSnapshots')
|
||||
.leftJoinAndSelect('contract.signatures', 'signatures')
|
||||
.leftJoinAndSelect('signatures.signatureFile', 'signatureFile')
|
||||
|
||||
@@ -13,7 +13,9 @@ import { YardCountry } from '@edr/types';
|
||||
//
|
||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||
import { CompaniesService } from '../companies/companies.service';
|
||||
import { CargoType } from '../rule-engine/entities/cargo-type.entity';
|
||||
import { ServiceType } from '../rule-engine/entities/service-type.entity';
|
||||
import { bulkTonsPerWagon } from '../train-scheduling/train-capacity.util';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { MinioService } from '../minio/minio.service';
|
||||
@@ -858,6 +860,29 @@ export class ContractsService {
|
||||
);
|
||||
}
|
||||
|
||||
// NUMBER_OF_WAGONS booking forms need the heaviest load one wagon may take
|
||||
// so they can reject a wagon count whose even share overloads a wagon —
|
||||
// the client-side twin of ContractBookingService.assertWagonShareFits.
|
||||
// The raw wagonTypes join rows are dropped: only the derived cap ships.
|
||||
for (const scope of contract.cargoScope ?? []) {
|
||||
const cargoType = scope.cargoType as
|
||||
| (CargoType & { maxTonsPerWagon?: number | null })
|
||||
| null
|
||||
| undefined;
|
||||
if (!cargoType) continue;
|
||||
const allowed = (cargoType.wagonTypes ?? []).filter(
|
||||
(wt) => Number(wt.capacityTons) > 0,
|
||||
);
|
||||
cargoType.maxTonsPerWagon = allowed.length
|
||||
? Math.max(
|
||||
...allowed.map((wt) =>
|
||||
bulkTonsPerWagon(cargoType, wt.id, Number(wt.capacityTons)),
|
||||
),
|
||||
)
|
||||
: null;
|
||||
delete cargoType.wagonTypes;
|
||||
}
|
||||
|
||||
// Surface the staff "request changes" note so the portal can show the
|
||||
// customer what to fix. Degrade to null on lookup failure — a missing note
|
||||
// must never 500 a contract fetch.
|
||||
|
||||
@@ -39,6 +39,14 @@ export class ScheduleWagonAdjustmentLog extends BaseEntity {
|
||||
@Column({ name: 'yard_id', type: 'uuid', nullable: true })
|
||||
yardId!: string | null;
|
||||
|
||||
/**
|
||||
* Why the wagon left/joined the consist. Required by the builder for a
|
||||
* detach or maintenance move on a SCHEDULED run (that reason replaced the
|
||||
* old second-staff approval); null for every other adjustment.
|
||||
*/
|
||||
@Column({ name: 'reason', type: 'varchar', length: 500, nullable: true })
|
||||
reason?: string | null;
|
||||
|
||||
@Column({ name: 'occurred_at', type: 'timestamptz', default: () => 'now()' })
|
||||
occurredAt!: Date;
|
||||
}
|
||||
|
||||
@@ -50,6 +50,20 @@ export class WagonBookingAllocation extends BaseEntity {
|
||||
@Column({ name: 'confirmed_by_user_id', type: 'uuid', nullable: true })
|
||||
confirmedByUserId?: string | null;
|
||||
|
||||
/** Per-wagon loading confirmation (status LOADED) — when and by whom. */
|
||||
@Column({ name: 'loaded_at', type: 'timestamptz', nullable: true })
|
||||
loadedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'loaded_by_user_id', type: 'uuid', nullable: true })
|
||||
loadedByUserId?: string | null;
|
||||
|
||||
/** Per-wagon unloading confirmation (status DEPARTED) — when and by whom. */
|
||||
@Column({ name: 'unloaded_at', type: 'timestamptz', nullable: true })
|
||||
unloadedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'unloaded_by_user_id', type: 'uuid', nullable: true })
|
||||
unloadedByUserId?: string | null;
|
||||
|
||||
@OneToMany(() => WagonAllocationContainerItem, (item) => item.allocation)
|
||||
containerItems?: WagonAllocationContainerItem[];
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
NotFoundException,
|
||||
Optional,
|
||||
} from '@nestjs/common';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource, EntityManager, In } from 'typeorm';
|
||||
import { Freight } from '@edr/types';
|
||||
@@ -72,6 +72,117 @@ export class BookingJourneyService {
|
||||
|
||||
async loadBooking(scheduleId: string, bookingId: string, userId?: string | null) {
|
||||
const { schedule, booking } = await this.getScheduleBooking(scheduleId, bookingId);
|
||||
await this.assertBookingLoadable(schedule, booking);
|
||||
return this.completeLoad(schedule, booking, userId ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm ONE wagon of the booking loaded (per-wagon loading). The booking
|
||||
* stays PAID while wagons remain; loading the last remaining wagon runs the
|
||||
* whole-booking completion (IN_TRANSIT, warehouse inventory, GRN,
|
||||
* milestones) exactly as the one-shot load does. Wagons that will NOT ride
|
||||
* must be cancelled via the at-loading cancellation before the booking can
|
||||
* complete (and before the train may dispatch).
|
||||
*/
|
||||
async loadWagon(
|
||||
scheduleId: string,
|
||||
bookingId: string,
|
||||
allocationId: string,
|
||||
userId?: string | null,
|
||||
) {
|
||||
const { schedule, booking } = await this.getScheduleBooking(scheduleId, bookingId);
|
||||
await this.assertBookingLoadable(schedule, booking);
|
||||
const allocations = await this.allocationsForBooking(
|
||||
this.dataSource.manager,
|
||||
scheduleId,
|
||||
bookingId,
|
||||
);
|
||||
if (!allocations.length) {
|
||||
throw new BadRequestException(
|
||||
'This booking has no wagon allocations on the schedule — use the whole-booking load.',
|
||||
);
|
||||
}
|
||||
const target = allocations.find((a) => a.id === allocationId);
|
||||
if (!target) {
|
||||
throw new NotFoundException('Wagon allocation not found on this booking/schedule');
|
||||
}
|
||||
if (target.status === 'LOADED' || target.status === 'DEPARTED') {
|
||||
throw new BadRequestException('This wagon is already loaded');
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(WagonBookingAllocation).update(target.id, {
|
||||
status: 'LOADED',
|
||||
loadedAt: now,
|
||||
loadedByUserId: userId ?? null,
|
||||
});
|
||||
if (!booking.loadingStartedAt) {
|
||||
await manager
|
||||
.getRepository(Booking)
|
||||
.update(bookingId, { loadingStartedAt: now } as never);
|
||||
}
|
||||
// PARTIAL keeps the dispatch-readiness badge honest; completion below
|
||||
// flips it to LOADED.
|
||||
await manager
|
||||
.getRepository(TrainScheduleBooking)
|
||||
.update({ trainScheduleId: scheduleId, bookingId }, { loadingStatus: 'PARTIAL' });
|
||||
});
|
||||
|
||||
const remaining = allocations.filter(
|
||||
(a) => a.id !== target.id && a.status !== 'LOADED' && a.status !== 'DEPARTED',
|
||||
).length;
|
||||
if (remaining === 0) {
|
||||
const done = await this.completeLoad(schedule, booking, userId ?? null);
|
||||
return {
|
||||
...done,
|
||||
allocationId,
|
||||
loadedWagons: allocations.length,
|
||||
totalWagons: allocations.length,
|
||||
completed: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
bookingId,
|
||||
allocationId,
|
||||
status: booking.status,
|
||||
loadedWagons: allocations.length - remaining,
|
||||
totalWagons: allocations.length,
|
||||
completed: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The at-loading cancel shrank the booking to its loaded wagons — if every
|
||||
* wagon left on it is LOADED, the load is complete: run the whole-booking
|
||||
* completion. Fired by BookingWagonCancellationService.cancelRemainingAtLoading.
|
||||
*/
|
||||
@OnEvent('booking.wagonsCancelledAtLoading')
|
||||
async onWagonsCancelledAtLoading(payload: {
|
||||
bookingId: string;
|
||||
scheduleId: string;
|
||||
userId?: string | null;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const allocations = await this.allocationsForBooking(
|
||||
this.dataSource.manager,
|
||||
payload.scheduleId,
|
||||
payload.bookingId,
|
||||
);
|
||||
const loaded = allocations.filter(
|
||||
(a) => a.status === 'LOADED' || a.status === 'DEPARTED',
|
||||
).length;
|
||||
if (!allocations.length || loaded < allocations.length) return;
|
||||
await this.loadBooking(payload.scheduleId, payload.bookingId, payload.userId);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Post-cancel load completion failed for booking ${payload.bookingId}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** The pre-load gates shared by whole-booking and per-wagon loading. */
|
||||
private async assertBookingLoadable(schedule: TrainSchedule, booking: Booking): Promise<void> {
|
||||
if (booking.loadedAt || booking.status === 'IN_TRANSIT') {
|
||||
throw new BadRequestException('Booking is already loaded');
|
||||
}
|
||||
@@ -86,6 +197,16 @@ export class BookingJourneyService {
|
||||
// Export cargo must be in the warehouse with a GRN before it can be loaded,
|
||||
// however it arrived and whatever it is allocated to.
|
||||
await assertExportReceivedWithGrn(this.dataSource, booking);
|
||||
}
|
||||
|
||||
/** The whole-booking load side effects — gates already passed. */
|
||||
private async completeLoad(
|
||||
schedule: TrainSchedule,
|
||||
booking: Booking,
|
||||
userId: string | null,
|
||||
) {
|
||||
const scheduleId = schedule.id;
|
||||
const bookingId = booking.id;
|
||||
// Direct truck-to-train cargo never sees the warehouse, so loading IS its
|
||||
// handover moment — the carriage acceptance sheet must go out to the
|
||||
// customer right here, not on a receive event that will never fire.
|
||||
@@ -165,6 +286,77 @@ export class BookingJourneyService {
|
||||
|
||||
async unloadBooking(scheduleId: string, bookingId: string, userId?: string | null) {
|
||||
const { schedule, booking } = await this.getScheduleBooking(scheduleId, bookingId);
|
||||
await this.assertBookingUnloadable(schedule, booking);
|
||||
return this.completeUnload(schedule, booking, userId ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm ONE wagon of the booking unloaded (per-wagon unloading). Tracking
|
||||
* only while wagons remain on the train — the booking stays IN_TRANSIT;
|
||||
* unloading the last wagon runs the whole-booking completion (ARRIVED/
|
||||
* COMPLETED, wagon settlement, events) exactly as the one-shot unload does.
|
||||
*/
|
||||
async unloadWagon(
|
||||
scheduleId: string,
|
||||
bookingId: string,
|
||||
allocationId: string,
|
||||
userId?: string | null,
|
||||
) {
|
||||
const { schedule, booking } = await this.getScheduleBooking(scheduleId, bookingId);
|
||||
await this.assertBookingUnloadable(schedule, booking);
|
||||
const allocations = await this.allocationsForBooking(
|
||||
this.dataSource.manager,
|
||||
scheduleId,
|
||||
bookingId,
|
||||
);
|
||||
if (!allocations.length) {
|
||||
throw new BadRequestException(
|
||||
'This booking has no wagon allocations on the schedule — use the whole-booking unload.',
|
||||
);
|
||||
}
|
||||
const target = allocations.find((a) => a.id === allocationId);
|
||||
if (!target) {
|
||||
throw new NotFoundException('Wagon allocation not found on this booking/schedule');
|
||||
}
|
||||
if (target.status === 'DEPARTED') {
|
||||
throw new BadRequestException('This wagon is already unloaded');
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
await this.dataSource.getRepository(WagonBookingAllocation).update(target.id, {
|
||||
status: 'DEPARTED',
|
||||
unloadedAt: now,
|
||||
unloadedByUserId: userId ?? null,
|
||||
});
|
||||
|
||||
const remaining = allocations.filter(
|
||||
(a) => a.id !== target.id && a.status !== 'DEPARTED',
|
||||
).length;
|
||||
if (remaining === 0) {
|
||||
const done = await this.completeUnload(schedule, booking, userId ?? null);
|
||||
return {
|
||||
...done,
|
||||
allocationId,
|
||||
unloadedWagons: allocations.length,
|
||||
totalWagons: allocations.length,
|
||||
completed: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
bookingId,
|
||||
allocationId,
|
||||
status: booking.status,
|
||||
unloadedWagons: allocations.length - remaining,
|
||||
totalWagons: allocations.length,
|
||||
completed: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** The pre-unload gates shared by whole-booking and per-wagon unloading. */
|
||||
private async assertBookingUnloadable(
|
||||
schedule: TrainSchedule,
|
||||
booking: Booking,
|
||||
): Promise<void> {
|
||||
if (booking.status !== 'IN_TRANSIT') {
|
||||
throw new BadRequestException(
|
||||
`Booking must be loaded/in transit before unloading (currently ${booking.status})`,
|
||||
@@ -173,7 +365,16 @@ export class BookingJourneyService {
|
||||
await this.assertTrainAtYard(schedule, booking.destinationYardId, 'destination');
|
||||
this.assertStationWorkStarted(schedule, booking.destinationYardId, 'unloading');
|
||||
await this.assertYardCanHandleCargo(booking, booking.destinationYardId, 'destination');
|
||||
}
|
||||
|
||||
/** The whole-booking unload side effects — gates already passed. */
|
||||
private async completeUnload(
|
||||
schedule: TrainSchedule,
|
||||
booking: Booking,
|
||||
userId: string | null,
|
||||
) {
|
||||
const scheduleId = schedule.id;
|
||||
const bookingId = booking.id;
|
||||
// Intercity has no clearance/delivery tail — unloading completes it. Import/
|
||||
// export continue into clearance, keyed on the booking's own arrival.
|
||||
const nextStatus = booking.tradeDirection === 'DOMESTIC' ? 'COMPLETED' : 'ARRIVED';
|
||||
@@ -498,7 +699,17 @@ export class BookingJourneyService {
|
||||
.findOne({ where: { id: bookingId } });
|
||||
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
if (booking.trainScheduleId !== scheduleId) {
|
||||
throw new BadRequestException('Booking is not assigned to this schedule');
|
||||
// The schedule↔booking LINK is the same authority the workspace list
|
||||
// (listYardWork) renders from — some flows (export train pick) create it
|
||||
// with wagon allocations before bookings.train_schedule_id is stamped.
|
||||
// Trusting only the column made those rows show a Load button that
|
||||
// always 400'd.
|
||||
const linked = await this.dataSource.getRepository(TrainScheduleBooking).findOne({
|
||||
where: { trainScheduleId: scheduleId, bookingId },
|
||||
});
|
||||
if (!linked) {
|
||||
throw new BadRequestException('Booking is not assigned to this schedule');
|
||||
}
|
||||
}
|
||||
return { schedule, booking };
|
||||
}
|
||||
|
||||
@@ -707,6 +707,46 @@ export class TrainSchedulingController {
|
||||
return this.bookingJourneyService.unloadBooking(id, bookingId);
|
||||
}
|
||||
|
||||
@Post("schedules/:id/bookings/:bookingId/wagons/:allocationId/load")
|
||||
@TrainSchedulingLoad()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Confirm ONE wagon of the booking loaded (per-wagon loading). The booking stays PAID until every remaining wagon is LOADED; the last wagon runs the whole-booking load completion.",
|
||||
})
|
||||
loadScheduleBookingWagon(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("bookingId", ParseUUIDPipe) bookingId: string,
|
||||
@Param("allocationId", ParseUUIDPipe) allocationId: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.bookingJourneyService.loadWagon(
|
||||
id,
|
||||
bookingId,
|
||||
allocationId,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Post("schedules/:id/bookings/:bookingId/wagons/:allocationId/unload")
|
||||
@TrainSchedulingUnload()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Confirm ONE wagon of the booking unloaded (per-wagon unloading). The booking stays IN_TRANSIT until the last wagon, which runs the whole-booking unload completion.",
|
||||
})
|
||||
unloadScheduleBookingWagon(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("bookingId", ParseUUIDPipe) bookingId: string,
|
||||
@Param("allocationId", ParseUUIDPipe) allocationId: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.bookingJourneyService.unloadWagon(
|
||||
id,
|
||||
bookingId,
|
||||
allocationId,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Post("schedules/:id/intercity/:bookingId/load")
|
||||
@TrainSchedulingLoad()
|
||||
@ApiOperation({
|
||||
|
||||
@@ -2902,27 +2902,17 @@ export class TrainSchedulingService {
|
||||
schedule = reloaded;
|
||||
}
|
||||
}
|
||||
// Loading is tracked per station: dispatching with cargo still to board at
|
||||
// the origin marks it loaded (checklist + auto-load below), so the origin's
|
||||
// loading time window must have been started first — same gate the
|
||||
// per-booking load endpoint enforces.
|
||||
const originBoarders = await this.unloadedOriginBoarderIds(
|
||||
scheduleId,
|
||||
schedule.originStationId,
|
||||
);
|
||||
const boardersToLoad = dto.loadedBookingIds
|
||||
? originBoarders.filter((id) => new Set(dto.loadedBookingIds).has(id))
|
||||
: originBoarders;
|
||||
// Dispatch requires the origin's loading window to be COMPLETE: started
|
||||
// and ended. Not started or still open both block — a train departs only
|
||||
// after loading was formally opened and closed.
|
||||
const originLoadingLog =
|
||||
schedule.stationWorkLogs?.[schedule.originStationId]?.loading;
|
||||
if (boardersToLoad.length && !originLoadingLog?.startedAt) {
|
||||
if (!originLoadingLog?.startedAt) {
|
||||
throw new BadRequestException(
|
||||
'Start loading at the origin station before dispatching with cargo to load',
|
||||
'Start (and end) the loading window at the origin station before dispatching',
|
||||
);
|
||||
}
|
||||
// A train never departs mid-loading: once the origin's loading window was
|
||||
// opened (or there is cargo to load), it must be ENDED before dispatch.
|
||||
if ((boardersToLoad.length || originLoadingLog?.startedAt) && !originLoadingLog?.endedAt) {
|
||||
if (!originLoadingLog?.endedAt) {
|
||||
throw new BadRequestException(
|
||||
'End the loading window at the origin station before dispatching',
|
||||
);
|
||||
@@ -2956,6 +2946,10 @@ export class TrainSchedulingService {
|
||||
// The yard plan this departure was SOLD against must match where the steel
|
||||
// actually stands: a wagon sold from Dire but still in Mojo cannot board.
|
||||
await this.assertPlannedYardsAligned(schedule);
|
||||
// Per-wagon loading: a booking mid-load is neither ridable nor removable —
|
||||
// every wagon must be LOADED, or the never-loaded remainder cancelled
|
||||
// (at-loading cancellation), before the train departs.
|
||||
await this.assertNoPartiallyLoadedBookings(schedule);
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const trainNumber = await this.assignTrainNumber(manager, schedule);
|
||||
@@ -3154,6 +3148,42 @@ export class TrainSchedulingService {
|
||||
* PAID — or FULLY_EXECUTED for shipping-line bookings, which never prepay
|
||||
* (their charge sits on the credit ledger) yet ride from accept.
|
||||
*/
|
||||
/**
|
||||
* Per-wagon loading dispatch gate: a booking with SOME wagons LOADED and
|
||||
* SOME still PLANNED/RESERVED must resolve before departure — load the rest
|
||||
* or cancel it (which shrinks the booking to its loaded wagons). Blocking
|
||||
* here beats silently unassigning: unassign would delete LOADED allocations
|
||||
* and strand cargo that is physically on the train.
|
||||
*/
|
||||
private async assertNoPartiallyLoadedBookings(schedule: TrainSchedule): Promise<void> {
|
||||
if (!schedule.trainSetId) return;
|
||||
const rows: Array<{ reference: string; loaded: string; total: string }> =
|
||||
await this.dataSource.query(
|
||||
`SELECT b.reference,
|
||||
COUNT(*) FILTER (WHERE a.status IN ('LOADED', 'DEPARTED')) AS loaded,
|
||||
COUNT(*) AS total
|
||||
FROM freight.wagon_booking_allocations a
|
||||
JOIN freight.train_set_wagons tsw ON tsw.id = a.train_set_wagon_id
|
||||
JOIN freight.bookings b ON b.id = a.booking_id
|
||||
WHERE tsw.train_set_id = $1
|
||||
AND a.deleted_at IS NULL
|
||||
AND tsw.deleted_at IS NULL
|
||||
AND b.deleted_at IS NULL
|
||||
GROUP BY b.id, b.reference
|
||||
HAVING COUNT(*) FILTER (WHERE a.status IN ('LOADED', 'DEPARTED')) > 0
|
||||
AND COUNT(*) FILTER (WHERE a.status NOT IN ('LOADED', 'DEPARTED')) > 0`,
|
||||
[schedule.trainSetId],
|
||||
);
|
||||
if (rows.length) {
|
||||
const detail = rows
|
||||
.map((r) => `${r.reference} (${r.loaded}/${r.total} wagons loaded)`)
|
||||
.join(', ');
|
||||
throw new BadRequestException(
|
||||
`Cannot dispatch: booking(s) partially loaded — load every wagon or cancel the remainder first: ${detail}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async unloadedOriginBoarderIds(
|
||||
scheduleId: string,
|
||||
originYardId: string,
|
||||
@@ -3167,6 +3197,7 @@ export class TrainSchedulingService {
|
||||
AND b.deleted_at IS NULL
|
||||
AND b.origin_yard_id = $2
|
||||
AND b.loaded_at IS NULL
|
||||
AND b.loading_started_at IS NULL
|
||||
AND COALESCE(tsb.loading_status, 'UNLOADED') <> 'LOADED'
|
||||
AND b.is_government = false
|
||||
AND (b.status = 'PAID'
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
export class SendWagonToMaintenanceDto {
|
||||
@ApiPropertyOptional({
|
||||
@ApiProperty({
|
||||
description:
|
||||
"Why the wagon is going to maintenance. Stored on the wagon's status-history " +
|
||||
'log alongside the train it was detached from, matching the fleet desk flow.',
|
||||
"Why the wagon is going to maintenance — required. Stored on the wagon's " +
|
||||
'status-history log alongside the train it was detached from, and on the ' +
|
||||
"train's wagon-adjustment history.",
|
||||
maxLength: 500,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(500)
|
||||
note?: string;
|
||||
note!: string;
|
||||
}
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
import { WagonDetachRequestAction } from '../entities/wagon-detach-request.entity';
|
||||
|
||||
export class CreateWagonDetachRequestDto {
|
||||
/**
|
||||
* Detach a wagon from the consist. The reason is always required — it is
|
||||
* recorded both as an auto-approved wagon_detach_requests audit row and on the
|
||||
* train's wagon-adjustment history (the History tab).
|
||||
*/
|
||||
export class DetachWagonDto {
|
||||
@ApiProperty({
|
||||
enum: WagonDetachRequestAction,
|
||||
description: 'What approval is being asked for: a plain detach, or detach + MAINTENANCE.',
|
||||
})
|
||||
@IsEnum(WagonDetachRequestAction)
|
||||
action!: WagonDetachRequestAction;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Why the wagon must leave the scheduled consist. Shown to the approver.',
|
||||
description: 'Why the wagon leaves the consist — required.',
|
||||
maxLength: 500,
|
||||
})
|
||||
@IsString()
|
||||
@@ -20,14 +16,3 @@ export class CreateWagonDetachRequestDto {
|
||||
@MaxLength(500)
|
||||
reason!: string;
|
||||
}
|
||||
|
||||
export class DecideWagonDetachRequestDto {
|
||||
@ApiPropertyOptional({
|
||||
description: 'Decision note — required when rejecting, optional when approving.',
|
||||
maxLength: 500,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
note?: string;
|
||||
}
|
||||
|
||||
@@ -25,10 +25,7 @@ import { BuildTrainDto } from './dto/build-train.dto';
|
||||
import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto';
|
||||
import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto';
|
||||
import { SendWagonToMaintenanceDto } from './dto/send-wagon-to-maintenance.dto';
|
||||
import {
|
||||
CreateWagonDetachRequestDto,
|
||||
DecideWagonDetachRequestDto,
|
||||
} from './dto/wagon-detach-request.dto';
|
||||
import { DetachWagonDto } from './dto/wagon-detach-request.dto';
|
||||
import { UpdateTrainDetailsDto } from './dto/update-train-details.dto';
|
||||
import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto';
|
||||
import { UpdateTrainYardDto } from './dto/update-train-yard.dto';
|
||||
@@ -51,7 +48,6 @@ import { TrainBuilderService } from './train-builder.service';
|
||||
FREIGHT_PERMS.trains.changeWagonYard,
|
||||
FREIGHT_PERMS.trains.toggleActive,
|
||||
FREIGHT_PERMS.trains.disband,
|
||||
FREIGHT_PERMS.trains.approveWagonDetach,
|
||||
])
|
||||
export class TrainBuilderController {
|
||||
constructor(private readonly trainBuilderService: TrainBuilderService) {}
|
||||
@@ -185,29 +181,38 @@ export class TrainBuilderController {
|
||||
|
||||
@Delete(':id/wagons/:wagonId')
|
||||
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
|
||||
@ApiOperation({ summary: 'Detach one wagon from the consist' })
|
||||
@ApiOperation({ summary: 'Detach one wagon from the consist — a reason is required' })
|
||||
removeWagon(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('wagonId', ParseUUIDPipe) wagonId: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@Body() dto: DetachWagonDto,
|
||||
) {
|
||||
return this.trainBuilderService.removeWagon(id, wagonId, resolveAuthUserId(user));
|
||||
return this.trainBuilderService.removeWagon(
|
||||
id,
|
||||
wagonId,
|
||||
resolveAuthUserId(user),
|
||||
dto.reason,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/wagons/:wagonId/maintenance')
|
||||
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
|
||||
@ApiOperation({ summary: 'Detach one wagon and move it to MAINTENANCE status' })
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Detach one wagon and move it to MAINTENANCE status — a reason (note) is required',
|
||||
})
|
||||
sendWagonToMaintenance(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('wagonId', ParseUUIDPipe) wagonId: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@Body() dto?: SendWagonToMaintenanceDto,
|
||||
@Body() dto: SendWagonToMaintenanceDto,
|
||||
) {
|
||||
return this.trainBuilderService.sendWagonToMaintenance(
|
||||
id,
|
||||
wagonId,
|
||||
resolveAuthUserId(user),
|
||||
dto?.note,
|
||||
dto.note,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -220,65 +225,6 @@ export class TrainBuilderController {
|
||||
return this.trainBuilderService.listDetachRequests(id);
|
||||
}
|
||||
|
||||
@Post(':id/wagons/:wagonId/detach-requests')
|
||||
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Request approval to detach a wagon (or send it to maintenance) while the train is on a SCHEDULED run',
|
||||
})
|
||||
createDetachRequest(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('wagonId', ParseUUIDPipe) wagonId: string,
|
||||
@Body() dto: CreateWagonDetachRequestDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.trainBuilderService.createDetachRequest(
|
||||
id,
|
||||
wagonId,
|
||||
dto,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/detach-requests/:requestId/approve')
|
||||
@FleetManage(FREIGHT_PERMS.trains.approveWagonDetach)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Approve a detach/maintenance request — the detach executes immediately; the approver must not be the requester',
|
||||
})
|
||||
approveDetachRequest(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('requestId', ParseUUIDPipe) requestId: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@Body() dto?: DecideWagonDetachRequestDto,
|
||||
) {
|
||||
return this.trainBuilderService.decideDetachRequest(
|
||||
id,
|
||||
requestId,
|
||||
'APPROVE',
|
||||
resolveAuthUserId(user),
|
||||
dto?.note,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/detach-requests/:requestId/reject')
|
||||
@FleetManage(FREIGHT_PERMS.trains.approveWagonDetach)
|
||||
@ApiOperation({ summary: 'Reject a detach/maintenance request — a note explaining why is required' })
|
||||
rejectDetachRequest(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('requestId', ParseUUIDPipe) requestId: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@Body() dto: DecideWagonDetachRequestDto,
|
||||
) {
|
||||
return this.trainBuilderService.decideDetachRequest(
|
||||
id,
|
||||
requestId,
|
||||
'REJECT',
|
||||
resolveAuthUserId(user),
|
||||
dto.note,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/reorder-wagons')
|
||||
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
|
||||
@ApiOperation({ summary: 'Persist a drag-reorder of the full consist' })
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
import { TrainBuilderService } from './train-builder.service';
|
||||
import { WagonDetachRequestAction } from './entities/wagon-detach-request.entity';
|
||||
|
||||
/**
|
||||
* Every detach / send-to-maintenance carries a reason — scheduled run or not
|
||||
* (the reason replaced the old second-staff approval). The recorder rejects a
|
||||
* missing/blank one before anything is written, and stores a trimmed, capped
|
||||
* copy on the audit row otherwise.
|
||||
*/
|
||||
describe('TrainBuilderService — detach reason is always required', () => {
|
||||
const svc = Object.create(TrainBuilderService.prototype) as {
|
||||
recordDetachReason(
|
||||
manager: unknown,
|
||||
trainId: string,
|
||||
wagonId: string,
|
||||
action: WagonDetachRequestAction,
|
||||
reason: string | null | undefined,
|
||||
userId?: string | null,
|
||||
): Promise<void>;
|
||||
};
|
||||
|
||||
/** Minimal EntityManager: records what the recorder would persist. */
|
||||
const managerSpy = () => {
|
||||
const saved: Array<Record<string, unknown>> = [];
|
||||
return {
|
||||
saved,
|
||||
getRepository: (entity: { name: string }) =>
|
||||
entity.name === 'Wagon'
|
||||
? { findOne: async () => ({ wagonNumber: 'NW5-0412' }) }
|
||||
: {
|
||||
create: (row: Record<string, unknown>) => row,
|
||||
save: async (row: Record<string, unknown>) => {
|
||||
saved.push(row);
|
||||
return row;
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
it.each([undefined, null, '', ' '])('refuses a blank reason (%p)', async (reason) => {
|
||||
const manager = managerSpy();
|
||||
await expect(
|
||||
svc.recordDetachReason(
|
||||
manager,
|
||||
'train-1',
|
||||
'wagon-1',
|
||||
WagonDetachRequestAction.Detach,
|
||||
reason,
|
||||
'user-1',
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
// Nothing is written when the reason is missing.
|
||||
expect(manager.saved).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('records the reason as an auto-approved audit row', async () => {
|
||||
const manager = managerSpy();
|
||||
await svc.recordDetachReason(
|
||||
manager,
|
||||
'train-1',
|
||||
'wagon-1',
|
||||
WagonDetachRequestAction.Maintenance,
|
||||
' Brake shoe worn through ',
|
||||
'user-1',
|
||||
);
|
||||
expect(manager.saved).toHaveLength(1);
|
||||
const row = manager.saved[0];
|
||||
expect(row).toMatchObject({
|
||||
trainId: 'train-1',
|
||||
wagonId: 'wagon-1',
|
||||
wagonNumber: 'NW5-0412',
|
||||
action: WagonDetachRequestAction.Maintenance,
|
||||
reason: 'Brake shoe worn through',
|
||||
// No second person: the actor is both requester and decider.
|
||||
status: 'APPROVED',
|
||||
requestedBy: 'user-1',
|
||||
decidedBy: 'user-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('caps an over-long reason at the column width', async () => {
|
||||
const manager = managerSpy();
|
||||
await svc.recordDetachReason(
|
||||
manager,
|
||||
'train-1',
|
||||
'wagon-1',
|
||||
WagonDetachRequestAction.Detach,
|
||||
'x'.repeat(900),
|
||||
null,
|
||||
);
|
||||
expect(String(manager.saved[0].reason)).toHaveLength(500);
|
||||
});
|
||||
});
|
||||
@@ -30,7 +30,6 @@ import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto';
|
||||
import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto';
|
||||
import { UpdateTrainDetailsDto } from './dto/update-train-details.dto';
|
||||
import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto';
|
||||
import { CreateWagonDetachRequestDto } from './dto/wagon-detach-request.dto';
|
||||
import { TrainLocomotive } from './entities/train-locomotive.entity';
|
||||
import { Train } from './entities/train.entity';
|
||||
import {
|
||||
@@ -253,6 +252,7 @@ export class TrainBuilderService {
|
||||
yardLabel: string | null;
|
||||
actor: string | null;
|
||||
scheduleReference: string | null;
|
||||
reason: string | null;
|
||||
occurredAt: Date;
|
||||
}>,
|
||||
] = await Promise.all([
|
||||
@@ -270,6 +270,7 @@ export class TrainBuilderService {
|
||||
COALESCE(y.label, y.code) AS "yardLabel",
|
||||
COALESCE(u.username, u.email) AS "actor",
|
||||
ts.reference AS "scheduleReference",
|
||||
l.reason,
|
||||
l.occurred_at AS "occurredAt"
|
||||
FROM freight.schedule_wagon_adjustment_logs l
|
||||
LEFT JOIN freight.yards y ON y.id = l.yard_id
|
||||
@@ -491,8 +492,9 @@ export class TrainBuilderService {
|
||||
: null,
|
||||
},
|
||||
activeSchedules: schedules,
|
||||
// Composition is frozen while the train is out on a dispatched run.
|
||||
editable: !schedules.some((s) => s.status === 'DISPATCHED'),
|
||||
// The built train is always editable — dispatched/arrived runs render from
|
||||
// their frozen snapshot, so consist edits reach only DRAFT/SCHEDULED runs.
|
||||
editable: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -754,22 +756,40 @@ export class TrainBuilderService {
|
||||
return this.getComposition(id);
|
||||
}
|
||||
|
||||
/** Detach one wagon and close the sequence gap it leaves. */
|
||||
async removeWagon(id: string, wagonId: string, userId?: string | null) {
|
||||
/**
|
||||
* Detach one wagon and close the sequence gap it leaves. On a SCHEDULED run
|
||||
* the detach still executes directly, but a reason is required and recorded
|
||||
* in wagon_detach_requests (auto-approved) — the audit trail without the
|
||||
* former second-staff approval step.
|
||||
*/
|
||||
async removeWagon(
|
||||
id: string,
|
||||
wagonId: string,
|
||||
userId?: string | null,
|
||||
reason?: string | null,
|
||||
) {
|
||||
const pending = await this.dataSource.transaction(async (manager) => {
|
||||
await this.assertDetachNeedsNoApproval(manager, id);
|
||||
return this.removeWagonCore(manager, id, wagonId, userId);
|
||||
await this.recordDetachReason(
|
||||
manager,
|
||||
id,
|
||||
wagonId,
|
||||
WagonDetachRequestAction.Detach,
|
||||
reason,
|
||||
userId,
|
||||
);
|
||||
return this.removeWagonCore(manager, id, wagonId, userId, reason);
|
||||
});
|
||||
await this.reconcileWindowAfterConsistChange(pending);
|
||||
return this.getComposition(id);
|
||||
}
|
||||
|
||||
/** Transactional body of removeWagon — also runs under an approved detach request. */
|
||||
/** Transactional body of removeWagon — `reason` rides into the history log. */
|
||||
private async removeWagonCore(
|
||||
manager: EntityManager,
|
||||
id: string,
|
||||
wagonId: string,
|
||||
userId?: string | null,
|
||||
reason?: string | null,
|
||||
): Promise<PendingWindowCheck | null> {
|
||||
const train = await this.getEditableTrain(manager, id);
|
||||
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
|
||||
@@ -791,6 +811,7 @@ export class TrainBuilderService {
|
||||
[{ action: 'REMOVE', wagonId: wagon.id, wagonNumber: wagon.wagonNumber }],
|
||||
userId ?? null,
|
||||
wagon.currentYardId ?? train.currentYardId ?? null,
|
||||
reason,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -805,8 +826,16 @@ export class TrainBuilderService {
|
||||
userId?: string | null,
|
||||
note?: string | null,
|
||||
) {
|
||||
// `note` is the required reason — recordDetachReason rejects it empty.
|
||||
const pending = await this.dataSource.transaction(async (manager) => {
|
||||
await this.assertDetachNeedsNoApproval(manager, id);
|
||||
await this.recordDetachReason(
|
||||
manager,
|
||||
id,
|
||||
wagonId,
|
||||
WagonDetachRequestAction.Maintenance,
|
||||
note,
|
||||
userId,
|
||||
);
|
||||
return this.sendWagonToMaintenanceCore(manager, id, wagonId, userId, note);
|
||||
});
|
||||
await this.reconcileWindowAfterConsistChange(pending);
|
||||
@@ -883,96 +912,54 @@ export class TrainBuilderService {
|
||||
[{ action: 'REMOVE', wagonId: wagon.id, wagonNumber: wagon.wagonNumber }],
|
||||
userId ?? null,
|
||||
yardId,
|
||||
note,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct-detach guard: while this train carries a live SCHEDULED run,
|
||||
* removing a wagon changes a departure customers already booked against, so
|
||||
* it is a two-person action — refuse here and point at the request flow.
|
||||
* DRAFT stays freely editable; DISPATCHED is already frozen by
|
||||
* getEditableTrain (the train is IN_SERVICE).
|
||||
* Every detach / send-to-maintenance carries a REASON — scheduled run or
|
||||
* not — and an auto-approved wagon_detach_requests row records who did it
|
||||
* and why (the audit trail that replaced the former second-staff approval).
|
||||
* DISPATCHED trains never reach here: getEditableTrain freezes them.
|
||||
*/
|
||||
private async assertDetachNeedsNoApproval(
|
||||
private async recordDetachReason(
|
||||
manager: EntityManager,
|
||||
trainId: string,
|
||||
): Promise<void> {
|
||||
const scheduled = await this.findScheduledRun(manager, trainId);
|
||||
if (scheduled) {
|
||||
throw new ConflictException(
|
||||
`Train is on scheduled run ${scheduled.reference ?? scheduled.id} — detaching a wagon needs an approved detach request`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async findScheduledRun(
|
||||
manager: EntityManager,
|
||||
trainId: string,
|
||||
): Promise<{ id: string; reference: string | null } | null> {
|
||||
const rows: { id: string; reference: string | null }[] = await manager.query(
|
||||
`SELECT ts.id, ts.reference
|
||||
FROM freight.train_schedules ts
|
||||
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
|
||||
WHERE tset.train_id = $1
|
||||
AND ts.status = 'SCHEDULED'
|
||||
AND ts.deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[trainId],
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* File a detach/maintenance approval request for a wagon on a SCHEDULED
|
||||
* train. The request carries the reason; a different staffer with
|
||||
* trains:approve_wagon_detach decides it (approval executes the detach).
|
||||
*/
|
||||
async createDetachRequest(
|
||||
id: string,
|
||||
wagonId: string,
|
||||
dto: CreateWagonDetachRequestDto,
|
||||
action: WagonDetachRequestAction,
|
||||
reason: string | null | undefined,
|
||||
userId?: string | null,
|
||||
) {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const train = await this.getEditableTrain(manager, id);
|
||||
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
|
||||
if (!wagon || wagon.trainId !== train.id) {
|
||||
throw new NotFoundException(`Wagon ${wagonId} is not part of this train`);
|
||||
}
|
||||
const scheduled = await this.findScheduledRun(manager, train.id);
|
||||
if (!scheduled) {
|
||||
throw new ConflictException(
|
||||
'This train has no SCHEDULED run — detach the wagon directly, no approval needed',
|
||||
);
|
||||
}
|
||||
// Refuse up front what an approval could never execute (booked
|
||||
// allocations pin the wagon) — but release nothing yet: slots are only
|
||||
// touched when the approved detach actually runs.
|
||||
await this.assertDetachableAndReleaseStaleSlots(manager, wagon, { checkOnly: true });
|
||||
const repo = manager.getRepository(WagonDetachRequest);
|
||||
const open = await repo.findOne({
|
||||
where: { trainId: train.id, wagonId: wagon.id, status: WagonDetachRequestStatus.Pending },
|
||||
});
|
||||
if (open) {
|
||||
throw new ConflictException(
|
||||
`Wagon ${wagon.wagonNumber} already has a pending detach request`,
|
||||
);
|
||||
}
|
||||
return repo.save(
|
||||
repo.create({
|
||||
trainId: train.id,
|
||||
wagonId: wagon.id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
action: dto.action,
|
||||
reason: dto.reason.trim(),
|
||||
requestedBy: userId ?? null,
|
||||
}),
|
||||
): Promise<void> {
|
||||
const trimmed = reason?.trim();
|
||||
if (!trimmed) {
|
||||
throw new BadRequestException(
|
||||
`Give a reason for ${
|
||||
action === WagonDetachRequestAction.Maintenance
|
||||
? 'sending this wagon to maintenance'
|
||||
: 'detaching this wagon'
|
||||
}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
|
||||
const repo = manager.getRepository(WagonDetachRequest);
|
||||
const now = new Date();
|
||||
await repo.save(
|
||||
repo.create({
|
||||
trainId,
|
||||
wagonId,
|
||||
wagonNumber: wagon?.wagonNumber ?? wagonId,
|
||||
action,
|
||||
reason: trimmed.slice(0, 500),
|
||||
status: WagonDetachRequestStatus.Approved,
|
||||
requestedBy: userId ?? null,
|
||||
decidedBy: userId ?? null,
|
||||
decidedAt: now,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** All detach/maintenance requests of this train, newest first — the approval audit trail. */
|
||||
/** All detach/maintenance records of this train, newest first — the audit trail. */
|
||||
async listDetachRequests(trainId: string) {
|
||||
const rows: Array<{
|
||||
id: string;
|
||||
@@ -1012,68 +999,6 @@ export class TrainBuilderService {
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide a pending request. Approve executes the detach (or maintenance
|
||||
* move) in the same transaction that stamps the decision, so an approved row
|
||||
* can never exist without its detach having happened. The requester cannot
|
||||
* approve their own request; a rejection must carry a note.
|
||||
*/
|
||||
async decideDetachRequest(
|
||||
id: string,
|
||||
requestId: string,
|
||||
decision: 'APPROVE' | 'REJECT',
|
||||
userId?: string | null,
|
||||
note?: string | null,
|
||||
) {
|
||||
const pending = await this.dataSource.transaction(async (manager) => {
|
||||
const repo = manager.getRepository(WagonDetachRequest);
|
||||
const request = await repo.findOne({
|
||||
where: { id: requestId, trainId: id },
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
});
|
||||
if (!request) {
|
||||
throw new NotFoundException(`Detach request ${requestId} not found on this train`);
|
||||
}
|
||||
if (request.status !== WagonDetachRequestStatus.Pending) {
|
||||
throw new ConflictException(
|
||||
`This request was already ${request.status.toLowerCase()}`,
|
||||
);
|
||||
}
|
||||
const decisionNote = note?.trim() || null;
|
||||
if (decision === 'REJECT') {
|
||||
if (!decisionNote) {
|
||||
throw new BadRequestException('A note explaining the rejection is required');
|
||||
}
|
||||
await repo.update(request.id, {
|
||||
status: WagonDetachRequestStatus.Rejected,
|
||||
decidedBy: userId ?? null,
|
||||
decidedAt: new Date(),
|
||||
decisionNote,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
// The 4-eyes point of the gate: requester and approver are different people.
|
||||
if (request.requestedBy && userId && request.requestedBy === userId) {
|
||||
throw new ConflictException(
|
||||
'You filed this request — a different staff member must approve it',
|
||||
);
|
||||
}
|
||||
const pendingCheck =
|
||||
request.action === WagonDetachRequestAction.Maintenance
|
||||
? await this.sendWagonToMaintenanceCore(manager, id, request.wagonId, userId, request.reason)
|
||||
: await this.removeWagonCore(manager, id, request.wagonId, userId);
|
||||
await repo.update(request.id, {
|
||||
status: WagonDetachRequestStatus.Approved,
|
||||
decidedBy: userId ?? null,
|
||||
decidedAt: new Date(),
|
||||
decisionNote,
|
||||
});
|
||||
return pendingCheck;
|
||||
});
|
||||
await this.reconcileWindowAfterConsistChange(pending);
|
||||
return this.getComposition(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule occupancy lives on TrainSetWagon slots (per-schedule snapshot),
|
||||
* not on the Wagon entity — a wagon is busy when any live (DRAFT/SCHEDULED/
|
||||
@@ -1113,6 +1038,9 @@ export class TrainBuilderService {
|
||||
wagon: Wagon,
|
||||
opts: { checkOnly?: boolean } = {},
|
||||
): Promise<void> {
|
||||
// Only DRAFT/SCHEDULED runs still follow the live consist, so only they can
|
||||
// pin a wagon. A DISPATCHED/ARRIVED run reads its frozen snapshot and is
|
||||
// unaffected by what happens to the physical train behind it.
|
||||
const rows: { id: string; train_set_id: string; status: string; allocs: string }[] =
|
||||
await manager.query(
|
||||
`SELECT tsw.id, tsw.train_set_id, ts.status,
|
||||
@@ -1123,15 +1051,18 @@ export class TrainBuilderService {
|
||||
FROM freight.train_set_wagons tsw
|
||||
JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id
|
||||
WHERE tsw.physical_wagon_id = $1
|
||||
AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')
|
||||
AND ts.status IN ('DRAFT', 'SCHEDULED')
|
||||
AND ts.deleted_at IS NULL
|
||||
AND tsw.deleted_at IS NULL`,
|
||||
[wagon.id],
|
||||
);
|
||||
if (!rows.length) return;
|
||||
if (rows.some((r) => Number(r.allocs) > 0 || r.status === 'DISPATCHED')) {
|
||||
// Cargo already allocated to a live run keeps its wagon: the booking must be
|
||||
// unassigned from the slot first, so a customer's shipment can never lose its
|
||||
// wagon as a side effect of editing the train.
|
||||
if (rows.some((r) => Number(r.allocs) > 0)) {
|
||||
throw new ConflictException(
|
||||
`Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be removed`,
|
||||
`Wagon ${wagon.wagonNumber} is carrying cargo on a live schedule — unassign its bookings before removing it`,
|
||||
);
|
||||
}
|
||||
if (opts.checkOnly) return;
|
||||
@@ -1162,25 +1093,9 @@ export class TrainBuilderService {
|
||||
if (current.size !== incoming.size || [...current].some((wid) => !incoming.has(wid))) {
|
||||
throw new BadRequestException('Reorder must include every wagon of the train exactly once');
|
||||
}
|
||||
// Only a rolling train is frozen. Pre-dispatch (DRAFT/SCHEDULED) reorder
|
||||
// is allowed — the pinned schedules' consists are resequenced below so
|
||||
// they can never desync from the built train's real order.
|
||||
const dispatched: { exists: boolean }[] = await manager.query(
|
||||
`SELECT TRUE AS exists
|
||||
FROM freight.train_set_wagons tsw
|
||||
JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id
|
||||
WHERE tsw.physical_wagon_id = ANY($1::uuid[])
|
||||
AND ts.status = 'DISPATCHED'
|
||||
AND ts.deleted_at IS NULL
|
||||
AND tsw.deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[[...current]],
|
||||
);
|
||||
if (dispatched.length > 0) {
|
||||
throw new ConflictException(
|
||||
'This train is dispatched — wagons cannot be reordered while it is rolling.',
|
||||
);
|
||||
}
|
||||
// Reorder is allowed at any time, dispatched runs included: a DISPATCHED
|
||||
// schedule renders the order frozen in its snapshot, and only the
|
||||
// DRAFT/SCHEDULED consists resequenced below follow the built train.
|
||||
for (let i = 0; i < dto.wagonIds.length; i++) {
|
||||
await manager.getRepository(Wagon).update(dto.wagonIds[i], { sequenceNumber: i + 1 });
|
||||
}
|
||||
@@ -1402,6 +1317,7 @@ export class TrainBuilderService {
|
||||
changes: Array<{ action: 'ADD' | 'REMOVE'; wagonId: string; wagonNumber: string }>,
|
||||
userId: string | null,
|
||||
yardId: string | null,
|
||||
reason?: string | null,
|
||||
): Promise<PendingWindowCheck | null> {
|
||||
if (!changes.length) return null;
|
||||
const trainSet = await manager
|
||||
@@ -1443,6 +1359,7 @@ export class TrainBuilderService {
|
||||
wagonNumber: c.wagonNumber,
|
||||
adjustedByUserId: userId,
|
||||
yardId,
|
||||
reason: reason?.trim() || null,
|
||||
occurredAt: now,
|
||||
}),
|
||||
),
|
||||
@@ -1481,17 +1398,21 @@ export class TrainBuilderService {
|
||||
}
|
||||
|
||||
/** Load + freeze the train row for edit; block edits while it is out on a run. */
|
||||
/**
|
||||
* The built train is editable at ANY time, including while it is out on a
|
||||
* dispatched run. A dispatched/arrived schedule froze its own wagon plan into
|
||||
* `wagonAllocationSnapshot` at the transition and renders from that, so it can
|
||||
* never be disturbed by later consist edits; only DRAFT/SCHEDULED runs follow
|
||||
* the live train (see syncLiveScheduleAfterConsistChange). Per-wagon safety
|
||||
* still applies — assertDetachableAndReleaseStaleSlots refuses to pull a wagon
|
||||
* whose cargo is allocated to a live run.
|
||||
*/
|
||||
private async getEditableTrain(manager: EntityManager, id: string): Promise<Train> {
|
||||
const train = await manager.getRepository(Train).findOne({
|
||||
where: { id },
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
});
|
||||
if (!train) throw new NotFoundException(`Train ${id} not found`);
|
||||
if (train.status === Freight.TrainStatus.InService) {
|
||||
throw new ConflictException(
|
||||
`Train ${train.code} is out on a dispatched run; its composition is frozen until arrival`,
|
||||
);
|
||||
}
|
||||
return train;
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,13 @@ export function DoCollectionDateFields({
|
||||
const outOfOrder =
|
||||
Boolean(value.vesselArrival && value.doCollected) && !doDatesComplete(value);
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const doMin =
|
||||
value.vesselArrival && value.vesselArrival > today
|
||||
? value.vesselArrival
|
||||
: today;
|
||||
|
||||
return (
|
||||
<Group grow align="flex-start" gap="sm" wrap="wrap">
|
||||
<DateInput
|
||||
@@ -45,7 +52,7 @@ export function DoCollectionDateFields({
|
||||
onChange={(v) =>
|
||||
onChange({ ...value, vesselArrival: v ? new Date(v) : null })
|
||||
}
|
||||
maxDate={new Date()}
|
||||
minDate={today}
|
||||
size="sm"
|
||||
required
|
||||
withAsterisk
|
||||
@@ -57,8 +64,7 @@ export function DoCollectionDateFields({
|
||||
onChange={(v) =>
|
||||
onChange({ ...value, doCollected: v ? new Date(v) : null })
|
||||
}
|
||||
minDate={value.vesselArrival ?? undefined}
|
||||
maxDate={new Date()}
|
||||
minDate={doMin}
|
||||
size="sm"
|
||||
required
|
||||
withAsterisk
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
Flame,
|
||||
Link2,
|
||||
MapPin,
|
||||
MoveRight,
|
||||
Package,
|
||||
Receipt,
|
||||
Repeat,
|
||||
@@ -204,6 +205,19 @@ function emptyLine(size: string): ContainerLineDraft {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Heaviest load one wagon of this contract's bulk cargo may take, as computed
|
||||
* by the API from the cargo type's allowed wagon types. Null/undefined when no
|
||||
* wagon type is configured — the wagon-count check then falls away.
|
||||
*/
|
||||
function bulkMaxTonsPerWagon(
|
||||
contract: Freight.IContract,
|
||||
): number | null | undefined {
|
||||
return contract.cargoScope?.find(
|
||||
(scope) => scope.cargoType?.maxTonsPerWagon != null,
|
||||
)?.cargoType?.maxTonsPerWagon;
|
||||
}
|
||||
|
||||
function bulkUnitOfMeasure(
|
||||
contract: Freight.IContract,
|
||||
): "PER_TON" | "PER_ITEM" | "NUMBER_OF_WAGONS" {
|
||||
@@ -1000,8 +1014,20 @@ export default function GlCreateBookingForm() {
|
||||
}
|
||||
if (bulkUom === "NUMBER_OF_WAGONS") {
|
||||
const wagons = Number(bulk.requestedWagons || 0);
|
||||
const maxPerWagon = Number(
|
||||
(contract && bulkMaxTonsPerWagon(contract)) || 0,
|
||||
);
|
||||
if (!Number.isInteger(wagons) || wagons < 1) {
|
||||
errs.wagons = "Enter the number of wagons needed (at least 1).";
|
||||
} else if (qty > 0 && maxPerWagon > 0 && qty / wagons > maxPerWagon) {
|
||||
// Too few wagons for the tonnage can never ride: 200T across 3 wagons
|
||||
// is 66.67T each on a 50T wagon. Mirrors the server's
|
||||
// assertWagonShareFits so the button blocks before the API 400s.
|
||||
errs.wagons =
|
||||
`${qty} tons across ${wagons} wagon${wagons === 1 ? "" : "s"} loads ` +
|
||||
`${Math.round((qty / wagons) * 1000) / 1000}T per wagon, but a wagon of ` +
|
||||
`this cargo carries at most ${maxPerWagon}T — request at least ` +
|
||||
`${Math.ceil(qty / maxPerWagon)} wagons.`;
|
||||
}
|
||||
}
|
||||
const h = Number(bulk.hazardousQuantity || 0);
|
||||
@@ -1017,7 +1043,7 @@ export default function GlCreateBookingForm() {
|
||||
errs.reefer = `Can't exceed the cargo quantity (${qty}).`;
|
||||
}
|
||||
return errs;
|
||||
}, [isContainer, bulk, bulkUom]);
|
||||
}, [isContainer, bulk, bulkUom, contract]);
|
||||
|
||||
const dateError =
|
||||
!isIntercity && !scheduledDate ? "Select a shipment date." : undefined;
|
||||
@@ -1479,12 +1505,12 @@ export default function GlCreateBookingForm() {
|
||||
const header = (
|
||||
<Group justify="space-between" align="flex-end" wrap="wrap" gap="md" mb="lg">
|
||||
<Box>
|
||||
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
|
||||
{completeBookingId ? "Complete Shipment Booking" : "New Shipment Booking"}
|
||||
<Title order={1} fw={800} fz={28} style={{ letterSpacing: "-0.01em" }}>
|
||||
{completeBookingId ? "Complete shipment booking" : "New Shipment Booking"}
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mt={4}>
|
||||
{completeBookingId
|
||||
? `Clearance is finalized — enter the cargo details and shipment day to complete the booking under contract ${contract.reference}.`
|
||||
? `Clearance is finalized. Enter cargo details and the binding shipment day to complete this booking under contract ${contract.reference}.`
|
||||
: `Book a shipment on behalf of the customer for contract ${contract.reference}.`}
|
||||
</Text>
|
||||
</Box>
|
||||
@@ -1603,20 +1629,49 @@ export default function GlCreateBookingForm() {
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
) : (
|
||||
<Paper withBorder radius="md" p="md" style={{ borderColor: "#E6ECF2" }}>
|
||||
<Text fz={14} fw={600}>
|
||||
{selectedRoute?.originYard?.label ??
|
||||
selectedRoute?.originYard?.code ??
|
||||
"—"}{" "}
|
||||
→{" "}
|
||||
{selectedRoute?.destinationYard?.label ??
|
||||
selectedRoute?.destinationYard?.code ??
|
||||
"—"}
|
||||
</Text>
|
||||
<Text fz={12} c="dimmed" mt={2}>
|
||||
<Group
|
||||
wrap="nowrap"
|
||||
gap={16}
|
||||
align="center"
|
||||
px={18}
|
||||
py={18}
|
||||
style={{
|
||||
borderRadius: 14,
|
||||
border: "1px solid #E6ECF2",
|
||||
background: "#FBFCFD",
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Text fz={15} fw={700} c="#10202F">
|
||||
{selectedRoute?.originYard?.label ??
|
||||
selectedRoute?.originYard?.code ??
|
||||
"—"}
|
||||
</Text>
|
||||
<Text fz={12} c="#6B7C8E" mt={2}>
|
||||
Origin yard
|
||||
</Text>
|
||||
</Box>
|
||||
<MoveRight size={20} color="#0A6F4D" style={{ flexShrink: 0 }} />
|
||||
<Box>
|
||||
<Text fz={15} fw={700} c="#10202F">
|
||||
{selectedRoute?.destinationYard?.label ??
|
||||
selectedRoute?.destinationYard?.code ??
|
||||
"—"}
|
||||
</Text>
|
||||
<Text fz={12} c="#6B7C8E" mt={2}>
|
||||
Destination yard
|
||||
</Text>
|
||||
</Box>
|
||||
<Box style={{ flex: 1 }} />
|
||||
<Badge
|
||||
variant="light"
|
||||
color="teal"
|
||||
radius={8}
|
||||
styles={{ label: { fontSize: 12, fontWeight: 600 } }}
|
||||
>
|
||||
{contract.tradeDirection}
|
||||
</Text>
|
||||
</Paper>
|
||||
</Badge>
|
||||
</Group>
|
||||
)}
|
||||
</StepCard>
|
||||
|
||||
@@ -2055,9 +2110,10 @@ export default function GlCreateBookingForm() {
|
||||
? bulkErrors.quantity
|
||||
: undefined
|
||||
}
|
||||
onChange={(e) =>
|
||||
setBulk((b) => ({ ...b, cargoWeightTons: e.currentTarget.value }))
|
||||
}
|
||||
onChange={(e) => {
|
||||
const value = e.currentTarget.value;
|
||||
setBulk((b) => ({ ...b, cargoWeightTons: value }));
|
||||
}}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
@@ -2074,9 +2130,10 @@ export default function GlCreateBookingForm() {
|
||||
? bulkErrors.quantity
|
||||
: undefined
|
||||
}
|
||||
onChange={(e) =>
|
||||
setBulk((b) => ({ ...b, itemCount: e.currentTarget.value }))
|
||||
}
|
||||
onChange={(e) => {
|
||||
const value = e.currentTarget.value;
|
||||
setBulk((b) => ({ ...b, itemCount: value }));
|
||||
}}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
@@ -2091,12 +2148,10 @@ export default function GlCreateBookingForm() {
|
||||
step={1}
|
||||
value={bulk.requestedWagons}
|
||||
error={showErrors ? bulkErrors.wagons : undefined}
|
||||
onChange={(e) =>
|
||||
setBulk((b) => ({
|
||||
...b,
|
||||
requestedWagons: e.currentTarget.value,
|
||||
}))
|
||||
}
|
||||
onChange={(e) => {
|
||||
const value = e.currentTarget.value;
|
||||
setBulk((b) => ({ ...b, requestedWagons: value }));
|
||||
}}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
@@ -2110,12 +2165,10 @@ export default function GlCreateBookingForm() {
|
||||
step={1}
|
||||
value={bulk.hazardousQuantity}
|
||||
error={showErrors ? bulkErrors.hazardous : undefined}
|
||||
onChange={(e) =>
|
||||
setBulk((b) => ({
|
||||
...b,
|
||||
hazardousQuantity: e.currentTarget.value,
|
||||
}))
|
||||
}
|
||||
onChange={(e) => {
|
||||
const value = e.currentTarget.value;
|
||||
setBulk((b) => ({ ...b, hazardousQuantity: value }));
|
||||
}}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
@@ -2129,12 +2182,10 @@ export default function GlCreateBookingForm() {
|
||||
step={1}
|
||||
value={bulk.reeferQuantity}
|
||||
error={showErrors ? bulkErrors.reefer : undefined}
|
||||
onChange={(e) =>
|
||||
setBulk((b) => ({
|
||||
...b,
|
||||
reeferQuantity: e.currentTarget.value,
|
||||
}))
|
||||
}
|
||||
onChange={(e) => {
|
||||
const value = e.currentTarget.value;
|
||||
setBulk((b) => ({ ...b, reeferQuantity: value }));
|
||||
}}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
@@ -2326,16 +2377,6 @@ export default function GlCreateBookingForm() {
|
||||
even numbers. Add one more 20ft container or remove one — book{" "}
|
||||
{ft20Total + 1} or {ft20Total - 1} instead of {ft20Total}.
|
||||
</Alert>
|
||||
) : showErrors && !formValid ? (
|
||||
<Alert
|
||||
color="red"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<AlertCircle size={16} />}
|
||||
mb="sm"
|
||||
>
|
||||
Fix the highlighted fields before reviewing the price.
|
||||
</Alert>
|
||||
) : partnerError ? (
|
||||
// The review button is disabled while the parent booking is
|
||||
// incomplete, so the click that would reveal the errors never
|
||||
@@ -2350,7 +2391,35 @@ export default function GlCreateBookingForm() {
|
||||
{partnerError}
|
||||
</Alert>
|
||||
) : null}
|
||||
<Group justify="flex-end">
|
||||
<Group justify="space-between" wrap="nowrap" gap="md">
|
||||
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
{showErrors && !formValid && !oddBlocksSubmit && (
|
||||
<>
|
||||
<AlertCircle
|
||||
size={15}
|
||||
color="#C0392B"
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
<Text fz={13} fw={500} c="#C0392B">
|
||||
Fix the highlighted fields to review the price.
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
completeBookingId
|
||||
? `/dashboard/clearance/${completeBookingId}`
|
||||
: "/dashboard/contracts/clearance",
|
||||
)
|
||||
}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Tooltip
|
||||
label={
|
||||
oddBlocksSubmit
|
||||
@@ -2380,6 +2449,7 @@ export default function GlCreateBookingForm() {
|
||||
</Button>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Group>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { Badge, Group, Pagination, Paper, Stack, Text, ThemeIcon, Timeline } from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ArrowLeftRight, History, MapPin, Minus, Plus, TrainFront, User } from "lucide-react";
|
||||
import {
|
||||
ArrowLeftRight,
|
||||
History,
|
||||
MapPin,
|
||||
MessageSquare,
|
||||
Minus,
|
||||
Plus,
|
||||
TrainFront,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
@@ -49,7 +58,8 @@ export default function TrainHistoryPanel({ trainId }: { trainId: string }) {
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Who attached, detached or switched which wagon on this train — from
|
||||
the builder and from its trips — newest first.
|
||||
the builder and from its trips — newest first, with the reason
|
||||
given for detaching off a scheduled run.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
@@ -120,6 +130,17 @@ export default function TrainHistoryPanel({ trainId }: { trainId: string }) {
|
||||
</Group>
|
||||
) : null}
|
||||
</Group>
|
||||
{entry.reason ? (
|
||||
<Group gap={4} wrap="nowrap" align="flex-start" mt={4}>
|
||||
<MessageSquare
|
||||
size={12}
|
||||
style={{ flexShrink: 0, marginTop: 3 }}
|
||||
/>
|
||||
<Text size="xs" c="dimmed" style={{ fontStyle: "italic" }}>
|
||||
{entry.reason}
|
||||
</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
</Timeline.Item>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { Box, Button, Group, Stack, Table, Text } from "@mantine/core";
|
||||
import { MapPin, Pencil } from "lucide-react";
|
||||
|
||||
import type { TrainCheckpoint } from "@/types/trainScheduling";
|
||||
import { handlingHours } from "./JourneySpine";
|
||||
import { Chip } from "./trackPrimitives";
|
||||
import { KIND_TONE, track } from "./trackTheme";
|
||||
|
||||
const fmt = (iso: string) =>
|
||||
new Date(iso).toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
const TH = {
|
||||
fontSize: 9.5,
|
||||
fontWeight: 700,
|
||||
letterSpacing: 0.7,
|
||||
color: track.muted,
|
||||
textTransform: "uppercase",
|
||||
} as const;
|
||||
|
||||
/** Raw event trail under the spine — every logged pass, with its correction. */
|
||||
export function CheckpointLogTable({
|
||||
checkpoints,
|
||||
onEdit,
|
||||
}: {
|
||||
checkpoints: TrainCheckpoint[];
|
||||
onEdit?: (checkpoint: TrainCheckpoint) => void;
|
||||
}) {
|
||||
if (checkpoints.length === 0) {
|
||||
return (
|
||||
<Stack
|
||||
align="center"
|
||||
gap="xs"
|
||||
py={42}
|
||||
mx={24}
|
||||
mb={24}
|
||||
style={{
|
||||
borderRadius: 14,
|
||||
border: `1px solid ${track.borderSoft}`,
|
||||
background: track.surface2,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: 52,
|
||||
height: 52,
|
||||
borderRadius: 999,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: track.brandDim,
|
||||
color: track.brand,
|
||||
}}
|
||||
>
|
||||
<MapPin size={22} />
|
||||
</Box>
|
||||
<Text size="13.5px" fw={700} c={track.text}>
|
||||
No checkpoints yet
|
||||
</Text>
|
||||
<Text size="12px" c={track.muted} ta="center" maw={320}>
|
||||
Each station the train passes will be logged here with its timestamp.
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Table.ScrollContainer minWidth={820}>
|
||||
<Table verticalSpacing={13} horizontalSpacing={24} highlightOnHover>
|
||||
<Table.Thead style={{ background: track.surface2 }}>
|
||||
<Table.Tr>
|
||||
<Table.Th style={{ ...TH, width: 220 }}>Station</Table.Th>
|
||||
<Table.Th style={{ ...TH, width: 110 }}>Event</Table.Th>
|
||||
<Table.Th style={{ ...TH, width: 160 }}>Time</Table.Th>
|
||||
<Table.Th style={{ ...TH, width: 130 }}>Handling</Table.Th>
|
||||
<Table.Th style={TH}>Note</Table.Th>
|
||||
<Table.Th style={{ ...TH, width: 70 }} />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{checkpoints.map((cp) => {
|
||||
const hours = handlingHours(cp);
|
||||
const tone = KIND_TONE[cp.kind] ?? KIND_TONE.PASSED;
|
||||
return (
|
||||
<Table.Tr key={cp.id}>
|
||||
<Table.Td>
|
||||
<Group gap={9} wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: 999,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: track.brandDim,
|
||||
color: track.brand,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<MapPin size={11} />
|
||||
</Box>
|
||||
<Text size="12.5px" fw={600} c={track.text}>
|
||||
{cp.label ?? `Station ${cp.sequenceNo}`}
|
||||
</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Chip bg={tone.bg} fg={tone.fg}>
|
||||
{cp.kind}
|
||||
</Chip>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="11.5px" c={track.text2} style={{ fontFamily: track.mono }}>
|
||||
{fmt(cp.occurredAt)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="11.5px" c={hours === null ? track.text3 : track.text2}>
|
||||
{hours === null ? "—" : `${hours} h`}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="11.5px" c={cp.note ? track.muted : track.text3}>
|
||||
{cp.note || "—"}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{onEdit ? (
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
radius={8}
|
||||
variant="default"
|
||||
leftSection={<Pencil size={11} />}
|
||||
onClick={() => onEdit(cp)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</Group>
|
||||
) : null}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button, Divider, Group, Modal, SimpleGrid, Stack, Text, Textarea } from "@mantine/core";
|
||||
import { Button, Group, Modal, Stack, Text, Textarea } from "@mantine/core";
|
||||
import { DateTimePicker } from "@mantine/dates";
|
||||
import { useMediaQuery } from "@mantine/hooks";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -67,6 +67,10 @@ export function CheckpointTimeModal({
|
||||
const isSmallScreen = useMediaQuery("(max-width: 48em)");
|
||||
const [at, setAt] = useState<Date | null>(null);
|
||||
const [note, setNote] = useState("");
|
||||
// The four station-work stamps are no longer edited HERE — the track page's
|
||||
// "Loading & unloading windows" section owns start/end with its own
|
||||
// permissions. The modal still carries any existing stamps through
|
||||
// unchanged on submit, so editing a checkpoint never wipes them.
|
||||
const [handling, setHandling] = useState<HandlingState>(EMPTY_HANDLING);
|
||||
useEffect(() => {
|
||||
if (!opened) return;
|
||||
@@ -94,7 +98,7 @@ export function CheckpointTimeModal({
|
||||
onClose={onClose}
|
||||
centered
|
||||
fullScreen={isSmallScreen}
|
||||
radius="lg"
|
||||
radius={18}
|
||||
title={
|
||||
<Group gap={8}>
|
||||
{icon}
|
||||
@@ -121,34 +125,6 @@ export function CheckpointTimeModal({
|
||||
radius="md"
|
||||
/>
|
||||
|
||||
<Divider
|
||||
label="Station work (optional)"
|
||||
labelPosition="left"
|
||||
styles={{ label: { fontWeight: 600 } }}
|
||||
/>
|
||||
<Text size="xs" c="dimmed" mt={-8}>
|
||||
Loading and unloading times for this stop. Total handling is unloading start to
|
||||
loading finish; the rest of the stay reports as other activity.
|
||||
</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
|
||||
{HANDLING_FIELDS.map(([field, label]) => (
|
||||
<DateTimePicker
|
||||
key={field}
|
||||
label={label}
|
||||
value={handling[field]}
|
||||
onChange={(v) =>
|
||||
setHandling((prev) => ({ ...prev, [field]: v ? new Date(v) : null }))
|
||||
}
|
||||
maxDate={new Date()}
|
||||
dropdownType={isSmallScreen ? "modal" : "popover"}
|
||||
popoverProps={{ withinPortal: true }}
|
||||
valueFormat="DD MMM YYYY HH:mm"
|
||||
clearable
|
||||
radius="md"
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
<Textarea
|
||||
label="Note"
|
||||
placeholder="Optional"
|
||||
@@ -161,10 +137,11 @@ export function CheckpointTimeModal({
|
||||
radius="md"
|
||||
/>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={onClose} disabled={loading}>
|
||||
<Button variant="default" radius={9} onClick={onClose} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
radius={9}
|
||||
color={submitColor}
|
||||
loading={loading}
|
||||
disabled={!at}
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
import { Box, Button, Group, Stack, Text } from "@mantine/core";
|
||||
import { Check, Flag, MapPin, Pencil, Timer } from "lucide-react";
|
||||
|
||||
import { StationWorkControls } from "@/components/trainScheduling/StationWorkControls";
|
||||
import type {
|
||||
StationWorkLog,
|
||||
TrackStation,
|
||||
TrainCheckpoint,
|
||||
} from "@/types/trainScheduling";
|
||||
import { Chip } from "./trackPrimitives";
|
||||
import { KIND_TONE, track } from "./trackTheme";
|
||||
|
||||
const NODE = 30;
|
||||
|
||||
/** Total handling at a stop: earliest start → latest finish. Null when unlogged. */
|
||||
export function handlingHours(cp: TrainCheckpoint): number | null {
|
||||
const starts = [cp.unloadingStartedAt, cp.loadingStartedAt]
|
||||
.filter((v): v is string => Boolean(v))
|
||||
.map((v) => new Date(v).getTime());
|
||||
const ends = [cp.loadingCompletedAt, cp.unloadingCompletedAt]
|
||||
.filter((v): v is string => Boolean(v))
|
||||
.map((v) => new Date(v).getTime());
|
||||
if (!starts.length || !ends.length) return null;
|
||||
return Math.round(((Math.max(...ends) - Math.min(...starts)) / 3_600_000) * 10) / 10;
|
||||
}
|
||||
|
||||
function fmt(iso?: string | null) {
|
||||
if (!iso) return "—";
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export interface JourneySpineProps {
|
||||
scheduleId: string;
|
||||
stations: TrackStation[];
|
||||
currentSequenceNo: number;
|
||||
checkpoints: TrainCheckpoint[];
|
||||
stationWorkLogs?: Record<string, StationWorkLog>;
|
||||
/** True when the train is DISPATCHED and staff may log progress. */
|
||||
canLog: boolean;
|
||||
loggingSeq?: number | null;
|
||||
onLogCheckpoint?: (sequenceNo: number) => void;
|
||||
/** Present when logged legs may be corrected (dispatched or arrived). */
|
||||
onEditCheckpoint?: (checkpoint: TrainCheckpoint) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The journey: one vertical spine where every stop carries its pass time and
|
||||
* its loading/unloading windows together, so an operator reads a station's
|
||||
* whole story in one row instead of cross-referencing two lists.
|
||||
*/
|
||||
export function JourneySpine({
|
||||
scheduleId,
|
||||
stations,
|
||||
currentSequenceNo,
|
||||
checkpoints,
|
||||
stationWorkLogs,
|
||||
canLog,
|
||||
loggingSeq,
|
||||
onLogCheckpoint,
|
||||
onEditCheckpoint,
|
||||
}: JourneySpineProps) {
|
||||
const bySeq = new Map(checkpoints.map((c) => [c.sequenceNo, c]));
|
||||
const lastIndex = stations.length - 1;
|
||||
|
||||
return (
|
||||
<Stack gap={0} px={24} pt={6} pb={20}>
|
||||
{stations.map((station, index) => {
|
||||
const isLast = index === lastIndex;
|
||||
const isFirst = index === 0;
|
||||
const passed = station.sequenceNo <= currentSequenceNo;
|
||||
const isCurrent = station.sequenceNo === currentSequenceNo;
|
||||
const isNext = canLog && station.sequenceNo === currentSequenceNo + 1;
|
||||
const checkpoint = bySeq.get(station.sequenceNo);
|
||||
const workLog = stationWorkLogs?.[station.yardId];
|
||||
const hours = checkpoint ? handlingHours(checkpoint) : null;
|
||||
const kindTone = checkpoint ? KIND_TONE[checkpoint.kind] : null;
|
||||
|
||||
return (
|
||||
<Group
|
||||
key={station.yardId}
|
||||
gap={16}
|
||||
align="stretch"
|
||||
wrap="nowrap"
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
{/* gutter: node + the line running to the next stop */}
|
||||
<Stack
|
||||
gap={0}
|
||||
align="center"
|
||||
style={{ width: NODE, flexShrink: 0, alignSelf: "stretch" }}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: NODE,
|
||||
height: NODE,
|
||||
borderRadius: 999,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
background: passed
|
||||
? track.brand
|
||||
: isNext
|
||||
? track.surface
|
||||
: track.surface2,
|
||||
border: `2px solid ${
|
||||
passed ? track.brand : isNext ? track.brand : track.border
|
||||
}`,
|
||||
color: passed ? "#FFFFFF" : isNext ? track.brand : track.text3,
|
||||
}}
|
||||
>
|
||||
{passed ? (
|
||||
<Check size={14} />
|
||||
) : isLast ? (
|
||||
<Flag size={14} />
|
||||
) : (
|
||||
<MapPin size={14} />
|
||||
)}
|
||||
</Box>
|
||||
{!isLast ? (
|
||||
<Box
|
||||
style={{
|
||||
width: 2,
|
||||
flex: 1,
|
||||
minHeight: 24,
|
||||
background: passed ? track.brand : track.border,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
{/* body */}
|
||||
<Stack gap={11} pt={2} pb={isLast ? 4 : 24} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group gap={10} align="center" wrap="wrap">
|
||||
<Text
|
||||
size="14.5px"
|
||||
fw={700}
|
||||
c={passed || isNext ? track.text : track.text2}
|
||||
>
|
||||
{station.label}
|
||||
</Text>
|
||||
{isFirst ? (
|
||||
<Chip bg={track.surface3} fg={track.muted}>
|
||||
ORIGIN
|
||||
</Chip>
|
||||
) : null}
|
||||
{isLast ? (
|
||||
<Chip bg={track.surface3} fg={track.muted}>
|
||||
DESTINATION
|
||||
</Chip>
|
||||
) : null}
|
||||
{isCurrent ? (
|
||||
<Chip bg={track.brand} fg="#FFFFFF">
|
||||
TRAIN HERE
|
||||
</Chip>
|
||||
) : null}
|
||||
{checkpoint && kindTone ? (
|
||||
<Chip bg={kindTone.bg} fg={kindTone.fg}>
|
||||
{checkpoint.kind}
|
||||
</Chip>
|
||||
) : null}
|
||||
|
||||
<Box style={{ flex: 1, minWidth: 0 }} />
|
||||
|
||||
{checkpoint ? (
|
||||
<Group gap={10} wrap="nowrap">
|
||||
<Text size="11.5px" c={track.text2} style={{ fontFamily: track.mono }}>
|
||||
{fmt(checkpoint.occurredAt)}
|
||||
</Text>
|
||||
{onEditCheckpoint ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
radius={8}
|
||||
variant="default"
|
||||
leftSection={<Pencil size={11} />}
|
||||
onClick={() => onEditCheckpoint(checkpoint)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
) : isNext ? (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
radius={9}
|
||||
color="edr-green"
|
||||
leftSection={isLast ? <Flag size={13} /> : <MapPin size={13} />}
|
||||
loading={loggingSeq === station.sequenceNo}
|
||||
onClick={() => onLogCheckpoint?.(station.sequenceNo)}
|
||||
>
|
||||
{isLast ? "Mark arrived" : "Log pass"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="compact-sm" radius={9} variant="default" disabled>
|
||||
{isLast ? "Mark arrived" : "Log pass"}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{hours !== null || checkpoint?.note ? (
|
||||
<Group gap={9} align="center" wrap="wrap">
|
||||
{hours !== null ? (
|
||||
<>
|
||||
<Timer size={12} color={track.text3} />
|
||||
<Text size="11.5px" c={track.muted}>
|
||||
{hours} h handling
|
||||
</Text>
|
||||
</>
|
||||
) : null}
|
||||
{hours !== null && checkpoint?.note ? (
|
||||
<Box
|
||||
style={{
|
||||
width: 3,
|
||||
height: 3,
|
||||
borderRadius: 999,
|
||||
background: track.text3,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{checkpoint?.note ? (
|
||||
<Text size="11.5px" c={track.muted} style={{ flex: 1, minWidth: 0 }}>
|
||||
{checkpoint.note}
|
||||
</Text>
|
||||
) : null}
|
||||
</Group>
|
||||
) : null}
|
||||
|
||||
{/* the station's work windows, inline */}
|
||||
<Stack
|
||||
gap={8}
|
||||
p={14}
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
background: isCurrent ? "rgba(228,245,239,0.5)" : track.surface2,
|
||||
border: `1px solid ${isCurrent ? "#B6E4D5" : track.borderSoft}`,
|
||||
}}
|
||||
>
|
||||
{!isFirst ? (
|
||||
<StationWorkControls
|
||||
scheduleId={scheduleId}
|
||||
yardId={station.yardId}
|
||||
phase="unloading"
|
||||
log={workLog?.unloading}
|
||||
/>
|
||||
) : null}
|
||||
{!isLast ? (
|
||||
<StationWorkControls
|
||||
scheduleId={scheduleId}
|
||||
yardId={station.yardId}
|
||||
phase="loading"
|
||||
log={workLog?.loading}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Divider,
|
||||
Group,
|
||||
@@ -26,6 +25,8 @@ import { Freight } from "@edr/types";
|
||||
|
||||
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||
import { StationWorkControls } from "@/components/trainScheduling/StationWorkControls";
|
||||
import { Chip } from "./trackPrimitives";
|
||||
import { DIRECTION_TONE, track as T } from "./trackTheme";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "@/lib/permissions";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
@@ -44,20 +45,15 @@ const fmtDate = (iso: string) => {
|
||||
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
|
||||
};
|
||||
|
||||
const DIRECTION_COLORS: Record<string, string> = {
|
||||
IMPORT: "blue",
|
||||
EXPORT: "teal",
|
||||
DOMESTIC: "violet",
|
||||
};
|
||||
|
||||
/** DOMESTIC displays as "Intercity" — shared with the rest of the platform. */
|
||||
const DIRECTION_LABELS: Record<string, string> = Freight.TRADE_DIRECTION_LABELS;
|
||||
|
||||
function DirectionChip({ direction }: { direction: string }) {
|
||||
const tone = DIRECTION_TONE[direction] ?? { bg: T.surface3, fg: T.muted };
|
||||
return (
|
||||
<Badge size="sm" variant="light" color={DIRECTION_COLORS[direction] ?? "gray"}>
|
||||
<Chip bg={tone.bg} fg={tone.fg}>
|
||||
{DIRECTION_LABELS[direction] ?? direction}
|
||||
</Badge>
|
||||
</Chip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,15 +68,15 @@ function SectionLabel({
|
||||
}) {
|
||||
return (
|
||||
<Group gap={8} align="center">
|
||||
<ThemeIcon size={26} radius="md" variant="light" color="edr-green">
|
||||
<ThemeIcon size={28} radius={8} variant="light" color="edr-green">
|
||||
{icon}
|
||||
</ThemeIcon>
|
||||
<Text fw={700} size="sm">
|
||||
<Text fw={700} size="13.5px" c={T.text}>
|
||||
{title}
|
||||
</Text>
|
||||
<Badge size="sm" variant="light" color="gray" radius="sm">
|
||||
{count}
|
||||
</Badge>
|
||||
<Chip bg={T.surface3} fg={T.text2}>
|
||||
{String(count)}
|
||||
</Chip>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -263,7 +259,7 @@ export function LogPassYardWorkModal({
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
size="xl"
|
||||
radius="lg"
|
||||
radius={18}
|
||||
title={
|
||||
<Group gap={8}>
|
||||
{isFinal ? <Flag size={18} /> : <MapPin size={18} />}
|
||||
@@ -271,9 +267,9 @@ export function LogPassYardWorkModal({
|
||||
{isFinal ? "Arrival" : "Yard work"} — {station?.label ?? ""}
|
||||
</Text>
|
||||
{logged ? (
|
||||
<Badge size="sm" variant="light" color="edr-green" radius="sm">
|
||||
{isFinal ? "Arrived" : "Pass logged"}
|
||||
</Badge>
|
||||
<Chip bg={T.brandDim} fg={T.brand}>
|
||||
{isFinal ? "ARRIVED" : "PASS LOGGED"}
|
||||
</Chip>
|
||||
) : null}
|
||||
</Group>
|
||||
}
|
||||
@@ -316,7 +312,20 @@ export function LogPassYardWorkModal({
|
||||
</Text>
|
||||
) : null}
|
||||
<Table.ScrollContainer minWidth={620}>
|
||||
<Table verticalSpacing="xs" highlightOnHover>
|
||||
<Table
|
||||
verticalSpacing={11}
|
||||
highlightOnHover
|
||||
styles={{
|
||||
th: {
|
||||
fontSize: 9.5,
|
||||
fontWeight: 700,
|
||||
letterSpacing: 0.7,
|
||||
textTransform: "uppercase",
|
||||
color: T.muted,
|
||||
background: T.surface2,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Booking</Table.Th>
|
||||
@@ -331,12 +340,12 @@ export function LogPassYardWorkModal({
|
||||
{arrivals.map((row) => (
|
||||
<Table.Tr key={row.id}>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={600}>
|
||||
<Text size="12px" fw={600} style={{ fontFamily: T.mono }}>
|
||||
{row.reference ?? row.id.slice(0, 8)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{row.customer}</Text>
|
||||
<Text size="12.5px" c={T.text2}>{row.customer}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<DirectionChip direction={row.tradeDirection} />
|
||||
@@ -364,6 +373,7 @@ export function LogPassYardWorkModal({
|
||||
>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
radius={8}
|
||||
variant="light"
|
||||
color="teal"
|
||||
leftSection={<PackageCheck size={13} />}
|
||||
@@ -417,7 +427,20 @@ export function LogPassYardWorkModal({
|
||||
</Text>
|
||||
) : null}
|
||||
<Table.ScrollContainer minWidth={620}>
|
||||
<Table verticalSpacing="xs" highlightOnHover>
|
||||
<Table
|
||||
verticalSpacing={11}
|
||||
highlightOnHover
|
||||
styles={{
|
||||
th: {
|
||||
fontSize: 9.5,
|
||||
fontWeight: 700,
|
||||
letterSpacing: 0.7,
|
||||
textTransform: "uppercase",
|
||||
color: T.muted,
|
||||
background: T.surface2,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Booking</Table.Th>
|
||||
@@ -433,18 +456,18 @@ export function LogPassYardWorkModal({
|
||||
<Table.Tr key={row.id}>
|
||||
<Table.Td>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm" fw={600}>
|
||||
<Text size="12px" fw={600} style={{ fontFamily: T.mono }}>
|
||||
{row.reference ?? row.id.slice(0, 8)}
|
||||
</Text>
|
||||
{row.isGovernment ? (
|
||||
<Badge size="xs" variant="light" color="grape">
|
||||
<Chip bg={T.grapeDim} fg={T.grape}>
|
||||
GOV
|
||||
</Badge>
|
||||
</Chip>
|
||||
) : null}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{row.customer}</Text>
|
||||
<Text size="12.5px" c={T.text2}>{row.customer}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<DirectionChip direction={row.tradeDirection} />
|
||||
@@ -487,7 +510,8 @@ export function LogPassYardWorkModal({
|
||||
>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
radius={8}
|
||||
color="edr-green"
|
||||
leftSection={<PackageCheck size={13} />}
|
||||
disabled={!canLoad || !logged || !loadingStarted || !row.canLoad}
|
||||
loading={
|
||||
@@ -509,6 +533,7 @@ export function LogPassYardWorkModal({
|
||||
>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
radius={8}
|
||||
variant="light"
|
||||
color="red"
|
||||
disabled={!canLeave || row.isGovernment}
|
||||
@@ -554,26 +579,25 @@ export function LogPassYardWorkModal({
|
||||
: ""}
|
||||
</Text>
|
||||
<Group gap="sm">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
<Button variant="default" radius={9} onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
{!logged ? (
|
||||
<Tooltip
|
||||
label="Start unloading first — arrival marks the remaining bookings arrived, so the unloading window must be open"
|
||||
disabled={!(isFinal && !unloadingStarted && arrivals.some((r) => r.canUnload))}
|
||||
// Arrival comes BEFORE unloading: the train is marked arrived
|
||||
// whenever it physically gets there, and the unloading window
|
||||
// opens afterwards. Bookings then unload per booking inside the
|
||||
// started window (the buttons above enforce that).
|
||||
<Button
|
||||
radius={9}
|
||||
color={isFinal ? "teal" : "edr-green"}
|
||||
leftSection={isFinal ? <Flag size={15} /> : <MapPin size={15} />}
|
||||
loading={recordCheckpoint.isPending}
|
||||
onClick={doLogPass}
|
||||
>
|
||||
<Button
|
||||
color={isFinal ? "teal" : "edr-green"}
|
||||
leftSection={isFinal ? <Flag size={15} /> : <MapPin size={15} />}
|
||||
loading={recordCheckpoint.isPending}
|
||||
disabled={isFinal && !unloadingStarted && arrivals.some((r) => r.canUnload)}
|
||||
onClick={doLogPass}
|
||||
>
|
||||
{isFinal
|
||||
? `Mark arrived at ${station?.label ?? "destination"}`
|
||||
: `Log pass at ${station?.label ?? "station"}`}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{isFinal
|
||||
? `Mark arrived at ${station?.label ?? "destination"}`
|
||||
: `Log pass at ${station?.label ?? "station"}`}
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
@@ -44,6 +46,7 @@ import { api } from "@/services/api";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type {
|
||||
BookingWagonRow,
|
||||
EligibleContainerBooking,
|
||||
FreightType,
|
||||
TrainScheduleDetail,
|
||||
@@ -298,6 +301,12 @@ export function ScheduleWorkspacePanel({
|
||||
);
|
||||
|
||||
const [moveBookingId, setMoveBookingId] = useState<string | null>(null);
|
||||
// Per-wagon loading/unloading modal for one booking.
|
||||
const [wagonModal, setWagonModal] = useState<{
|
||||
bookingId: string;
|
||||
ref: string;
|
||||
phase: "load" | "unload";
|
||||
} | null>(null);
|
||||
const [moveTarget, setMoveTarget] = useState<string | null>(null);
|
||||
|
||||
// Pool → pick a same-day schedule with free wagons and place the booking there.
|
||||
@@ -887,6 +896,25 @@ export function ScheduleWorkspacePanel({
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{showLoad && boardHere ? (
|
||||
<Tooltip
|
||||
label="Load wagon by wagon — and cancel any wagon that will not ride"
|
||||
withArrow
|
||||
>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
disabled={!canLoad || !loadWindowStarted}
|
||||
onClick={() =>
|
||||
setWagonModal({ bookingId: b.id, ref, phase: "load" })
|
||||
}
|
||||
>
|
||||
Wagons
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{showTruckToTrain ? (
|
||||
<Tooltip
|
||||
label={
|
||||
@@ -950,6 +978,22 @@ export function ScheduleWorkspacePanel({
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{showUnload && alightHere ? (
|
||||
<Tooltip label="Unload wagon by wagon" withArrow>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
color="orange"
|
||||
radius="md"
|
||||
disabled={!canUnload || !unloadWindowStarted}
|
||||
onClick={() =>
|
||||
setWagonModal({ bookingId: b.id, ref, phase: "unload" })
|
||||
}
|
||||
>
|
||||
Wagons
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{canManage && !riding && !done ? (
|
||||
journey?.isGovernment ? null : (
|
||||
<Tooltip label="Remove from this train" withArrow>
|
||||
@@ -984,6 +1028,18 @@ export function ScheduleWorkspacePanel({
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
{/* Per-wagon load/unload for one booking */}
|
||||
{wagonModal ? (
|
||||
<PerWagonModal
|
||||
scheduleId={schedule.id}
|
||||
bookingId={wagonModal.bookingId}
|
||||
reference={wagonModal.ref}
|
||||
phase={wagonModal.phase}
|
||||
onClose={() => setWagonModal(null)}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Pool → same-day train assignment modal */}
|
||||
<Modal
|
||||
opened={Boolean(poolAssign)}
|
||||
@@ -1355,3 +1411,246 @@ function BookingCard({
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-wagon loading/unloading of one booking. Load phase also offers the
|
||||
* at-loading cancel of everything not yet loaded: the booking shrinks to its
|
||||
* loaded wagons (CUSTOMER fault invoices the cancellation fee to pay after;
|
||||
* EDR fault charges nothing) — required before the train may dispatch.
|
||||
*/
|
||||
function PerWagonModal({
|
||||
scheduleId,
|
||||
bookingId,
|
||||
reference,
|
||||
phase,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
scheduleId: string;
|
||||
bookingId: string;
|
||||
reference: string;
|
||||
phase: "load" | "unload";
|
||||
onClose: () => void;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const [cancelOpen, setCancelOpen] = useState(false);
|
||||
const [reason, setReason] = useState("");
|
||||
const [edrFault, setEdrFault] = useState(false);
|
||||
|
||||
const wagonsQuery = useQuery(api.trainScheduling.bookingWagons.queryOptions({
|
||||
input: { bookingId },
|
||||
}));
|
||||
const wagons: BookingWagonRow[] = wagonsQuery.data ?? [];
|
||||
const isDone = (w: BookingWagonRow) =>
|
||||
phase === "load"
|
||||
? w.status === "LOADED" || w.status === "DEPARTED"
|
||||
: w.status === "DEPARTED";
|
||||
const doneCount = wagons.filter(isDone).length;
|
||||
const pending = wagons.filter((w) => !isDone(w));
|
||||
|
||||
const loadWagon = useMutation(api.trainScheduling.loadScheduleBookingWagon.mutationOptions());
|
||||
const unloadWagon = useMutation(
|
||||
api.trainScheduling.unloadScheduleBookingWagon.mutationOptions(),
|
||||
);
|
||||
const cancelRemaining = useMutation(
|
||||
api.trainScheduling.cancelRemainingWagons.mutationOptions(),
|
||||
);
|
||||
const act = phase === "load" ? loadWagon : unloadWagon;
|
||||
|
||||
const errText = (err: unknown) =>
|
||||
isAxiosError(err)
|
||||
? ((err.response?.data as { message?: string })?.message ?? err.message)
|
||||
: String(err);
|
||||
|
||||
const onWagon = (allocationId: string) => {
|
||||
act
|
||||
.mutateAsync({ scheduleId, bookingId, allocationId })
|
||||
.then((r) => {
|
||||
void wagonsQuery.refetch();
|
||||
if (r.completed) {
|
||||
toast({
|
||||
title: phase === "load" ? "Booking fully loaded" : "Booking fully unloaded",
|
||||
description: `${reference}: every wagon is ${phase === "load" ? "loaded — the booking is in transit" : "unloaded — the booking arrived"}.`,
|
||||
});
|
||||
onChanged();
|
||||
onClose();
|
||||
} else {
|
||||
onChanged();
|
||||
}
|
||||
})
|
||||
.catch((err) =>
|
||||
toast({
|
||||
title: phase === "load" ? "Wagon load failed" : "Wagon unload failed",
|
||||
description: errText(err),
|
||||
variant: "destructive",
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const onCancelRemaining = () => {
|
||||
cancelRemaining
|
||||
.mutateAsync({ bookingId, scheduleId, reason: reason.trim(), edrFault })
|
||||
.then(() => {
|
||||
toast({
|
||||
title: "Remaining wagons cancelled",
|
||||
description: edrFault
|
||||
? `${reference}: ${pending.length} wagon(s) cancelled at EDR's fault — no fee charged; the credit is rebookable.`
|
||||
: `${reference}: ${pending.length} wagon(s) cancelled — the cancellation fee was invoiced to the customer; the credit is rebookable.`,
|
||||
});
|
||||
onChanged();
|
||||
onClose();
|
||||
})
|
||||
.catch((err) =>
|
||||
toast({
|
||||
title: "Cancellation failed",
|
||||
description: errText(err),
|
||||
variant: "destructive",
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened
|
||||
onClose={onClose}
|
||||
title={
|
||||
<Group gap={8}>
|
||||
<Train size={18} />
|
||||
<Text fw={700}>
|
||||
{phase === "load" ? "Load" : "Unload"} {reference} wagon by wagon
|
||||
</Text>
|
||||
</Group>
|
||||
}
|
||||
centered
|
||||
radius="lg"
|
||||
size="lg"
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Group gap={8}>
|
||||
<Badge size="sm" radius="sm" variant="light" color={doneCount ? "edr-green" : "gray"}>
|
||||
{doneCount}/{wagons.length} {phase === "load" ? "loaded" : "unloaded"}
|
||||
</Badge>
|
||||
{phase === "load" && doneCount > 0 && pending.length > 0 ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
The train cannot dispatch until the rest are loaded or cancelled.
|
||||
</Text>
|
||||
) : null}
|
||||
</Group>
|
||||
{wagonsQuery.isLoading ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
Loading wagons…
|
||||
</Text>
|
||||
) : wagons.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No wagon allocations yet — use the whole-booking button instead.
|
||||
</Text>
|
||||
) : (
|
||||
wagons.map((w) => (
|
||||
<Paper key={w.allocationId} withBorder radius="md" p="xs">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Badge size="sm" radius="sm" variant="outline" color="gray">
|
||||
{w.sequenceNo != null ? `#${w.sequenceNo}` : "—"}
|
||||
</Badge>
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{w.wagonNumber ?? w.wagonType ?? "Wagon"}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{w.wagonTypeCode ?? ""}
|
||||
{w.allocatedWeightTons
|
||||
? ` · ${Number(w.allocatedWeightTons).toFixed(1)}T`
|
||||
: ""}
|
||||
{w.containers?.length ? ` · ${w.containers.length} ctr` : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
{isDone(w) ? (
|
||||
<Badge
|
||||
size="sm"
|
||||
radius="sm"
|
||||
variant="filled"
|
||||
color={phase === "load" ? "edr-green" : "orange"}
|
||||
leftSection={<CheckCircle2 size={11} />}
|
||||
>
|
||||
{phase === "load" ? "Loaded" : "Unloaded"}
|
||||
</Badge>
|
||||
) : (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="filled"
|
||||
color={phase === "load" ? "edr-green" : "orange"}
|
||||
radius="md"
|
||||
leftSection={
|
||||
phase === "load" ? <PackageCheck size={13} /> : <PackageOpen size={13} />
|
||||
}
|
||||
loading={
|
||||
act.isPending && act.variables?.allocationId === w.allocationId
|
||||
}
|
||||
onClick={() => onWagon(w.allocationId)}
|
||||
>
|
||||
{phase === "load" ? "Load" : "Unload"}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
))
|
||||
)}
|
||||
|
||||
{phase === "load" && doneCount > 0 && pending.length > 0 ? (
|
||||
!cancelOpen ? (
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
radius="md"
|
||||
leftSection={<X size={14} />}
|
||||
onClick={() => setCancelOpen(true)}
|
||||
>
|
||||
Cancel the {pending.length} remaining wagon{pending.length === 1 ? "" : "s"}…
|
||||
</Button>
|
||||
) : (
|
||||
<Paper withBorder radius="md" p="sm">
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={600}>
|
||||
Cancel {pending.length} unloaded wagon
|
||||
{pending.length === 1 ? "" : "s"} of {reference}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
The booking shrinks to its loaded wagons and the freed freight
|
||||
becomes a rebookable credit. Customer fault: the cancellation
|
||||
fee is invoiced, payable afterwards. EDR fault: no fee.
|
||||
</Text>
|
||||
<Textarea
|
||||
label="Reason"
|
||||
placeholder="Why are these wagons not riding?"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.currentTarget.value)}
|
||||
minRows={2}
|
||||
required
|
||||
/>
|
||||
<Checkbox
|
||||
label="EDR's fault (wagon shortage, yard problem) — charge no fee"
|
||||
checked={edrFault}
|
||||
onChange={(e) => setEdrFault(e.currentTarget.checked)}
|
||||
/>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" radius="md" onClick={() => setCancelOpen(false)}>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
radius="md"
|
||||
disabled={!reason.trim()}
|
||||
loading={cancelRemaining.isPending}
|
||||
onClick={onCancelRemaining}
|
||||
>
|
||||
Cancel wagons{edrFault ? " (no fee)" : " (fee applies)"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)
|
||||
) : null}
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Popover,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { ActionIcon, Box, Button, Group, Popover, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { DateTimePicker } from "@mantine/dates";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Pencil, PlayCircle, StopCircle } from "lucide-react";
|
||||
@@ -18,6 +9,8 @@ import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { api } from "@/services/api";
|
||||
import type { StationWorkPhaseLog } from "@/types/trainScheduling";
|
||||
import { Chip, PhaseChip, phaseState } from "./trackPrimitives";
|
||||
import { track } from "./trackTheme";
|
||||
|
||||
const parseError = (error: unknown, fallback: string) => {
|
||||
const message = (error as { response?: { data?: { message?: string | string[] } } })
|
||||
@@ -28,7 +21,14 @@ const parseError = (error: unknown, fallback: string) => {
|
||||
|
||||
const fmtTime = (iso: string) => {
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
|
||||
return Number.isNaN(d.getTime())
|
||||
? iso
|
||||
: d.toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
const fmtElapsed = (fromIso: string, toIso?: string | null) => {
|
||||
@@ -71,9 +71,9 @@ function EditTimeButton({
|
||||
<Popover.Target>
|
||||
<Tooltip label={disabled ? disabledReason : `Correct the ${label} time`}>
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size={26}
|
||||
radius={7}
|
||||
variant="default"
|
||||
disabled={disabled}
|
||||
onClick={() => setOpened((o) => !o)}
|
||||
>
|
||||
@@ -100,6 +100,7 @@ function EditTimeButton({
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="edr-green"
|
||||
loading={saving}
|
||||
disabled={!draft}
|
||||
onClick={() => {
|
||||
@@ -179,80 +180,84 @@ export function StationWorkControls({
|
||||
);
|
||||
};
|
||||
|
||||
const title = phase === "loading" ? "Loading" : "Unloading";
|
||||
const started = Boolean(log?.startedAt);
|
||||
const ended = Boolean(log?.endedAt);
|
||||
const state = phaseState(log);
|
||||
const started = state !== "idle";
|
||||
const ended = state === "done";
|
||||
const who = log?.endedByName ?? log?.startedByName;
|
||||
|
||||
return (
|
||||
<Group gap="sm" wrap="wrap" align="center">
|
||||
<Badge variant="light" color={ended ? "gray" : started ? "edr-green" : "yellow"} radius="sm">
|
||||
{title}
|
||||
{ended ? " done" : started ? " in progress" : " not started"}
|
||||
</Badge>
|
||||
<Group gap={10} wrap="wrap" align="center" style={{ width: "100%" }}>
|
||||
<PhaseChip phase={phase} state={state} />
|
||||
|
||||
{!started ? (
|
||||
<Tooltip
|
||||
label={
|
||||
canStart
|
||||
? `Record the moment ${phase} work begins at this station`
|
||||
: `You don't have permission to start ${phase}`
|
||||
}
|
||||
>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
leftSection={<PlayCircle size={14} />}
|
||||
disabled={!canStart}
|
||||
loading={record.isPending}
|
||||
onClick={() => doRecord("start")}
|
||||
<>
|
||||
<Box style={{ flex: 1, minWidth: 0 }} />
|
||||
<Tooltip
|
||||
label={
|
||||
canStart
|
||||
? `Record the moment ${phase} work begins at this station`
|
||||
: `You don't have permission to start ${phase}`
|
||||
}
|
||||
>
|
||||
Start {phase}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
radius={8}
|
||||
variant="default"
|
||||
leftSection={<PlayCircle size={12} color={track.brand} />}
|
||||
disabled={!canStart}
|
||||
loading={record.isPending}
|
||||
onClick={() => doRecord("start")}
|
||||
styles={{ label: { color: track.brand, fontSize: 11.5 } }}
|
||||
>
|
||||
Start {phase}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Text size="xs" c="dimmed">
|
||||
{fmtTime(log!.startedAt!)} → {ended ? fmtTime(log!.endedAt!) : "…"} (
|
||||
{fmtElapsed(log!.startedAt!, log?.endedAt)})
|
||||
</Text>
|
||||
{log?.startedByName || log?.endedByName ? (
|
||||
<Tooltip
|
||||
label={[
|
||||
log?.startedByName ? `Started by ${log.startedByName}` : null,
|
||||
log?.endedByName ? `Ended by ${log.endedByName}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
>
|
||||
<Badge size="xs" variant="light" color="gray" radius="sm">
|
||||
{log?.endedByName ?? log?.startedByName}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<Text size="11px" c={track.text2} style={{ fontFamily: track.mono }}>
|
||||
{fmtTime(log!.startedAt!)} → {ended ? fmtTime(log!.endedAt!) : "…"}
|
||||
</Text>
|
||||
<Chip bg={track.surface} fg={track.text2} border={track.border}>
|
||||
{fmtElapsed(log!.startedAt!, log?.endedAt)}
|
||||
</Chip>
|
||||
{who ? (
|
||||
<Tooltip
|
||||
label={[
|
||||
log?.startedByName ? `Started by ${log.startedByName}` : null,
|
||||
log?.endedByName ? `Ended by ${log.endedByName}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
>
|
||||
<Text size="11px" c={track.muted}>
|
||||
{who}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
|
||||
<Box style={{ flex: 1, minWidth: 0 }} />
|
||||
|
||||
<EditTimeButton
|
||||
label={`${phase} start`}
|
||||
value={log!.startedAt!}
|
||||
disabled={!canStart}
|
||||
disabledReason={`You don't have permission to edit the ${phase} start`}
|
||||
maxDate={log?.endedAt ? new Date(log.endedAt) : new Date()}
|
||||
onSave={(at) => doRecord("start", at)}
|
||||
saving={record.isPending}
|
||||
/>
|
||||
{ended ? (
|
||||
<EditTimeButton
|
||||
label={`${phase} start`}
|
||||
value={log!.startedAt!}
|
||||
disabled={!canStart}
|
||||
disabledReason={`You don't have permission to edit the ${phase} start`}
|
||||
maxDate={log?.endedAt ? new Date(log.endedAt) : new Date()}
|
||||
onSave={(at) => doRecord("start", at)}
|
||||
label={`${phase} end`}
|
||||
value={log!.endedAt!}
|
||||
disabled={!canEnd}
|
||||
disabledReason={`You don't have permission to edit the ${phase} end`}
|
||||
minDate={new Date(log!.startedAt!)}
|
||||
onSave={(at) => doRecord("end", at)}
|
||||
saving={record.isPending}
|
||||
/>
|
||||
{ended ? (
|
||||
<EditTimeButton
|
||||
label={`${phase} end`}
|
||||
value={log!.endedAt!}
|
||||
disabled={!canEnd}
|
||||
disabledReason={`You don't have permission to edit the ${phase} end`}
|
||||
minDate={new Date(log!.startedAt!)}
|
||||
onSave={(at) => doRecord("end", at)}
|
||||
saving={record.isPending}
|
||||
/>
|
||||
) : null}
|
||||
</Group>
|
||||
{!ended ? (
|
||||
) : (
|
||||
<Tooltip
|
||||
label={
|
||||
canEnd
|
||||
@@ -262,17 +267,21 @@ export function StationWorkControls({
|
||||
>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<StopCircle size={14} />}
|
||||
radius={8}
|
||||
variant="default"
|
||||
leftSection={<StopCircle size={12} color={track.amber} />}
|
||||
disabled={!canEnd}
|
||||
loading={record.isPending}
|
||||
onClick={() => doRecord("end")}
|
||||
styles={{
|
||||
root: { background: track.amberDim, borderColor: track.amberBorder },
|
||||
label: { color: track.amber, fontSize: 11.5 },
|
||||
}}
|
||||
>
|
||||
End {phase}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { Box, Group, RingProgress, Stack, Text } from "@mantine/core";
|
||||
import { ArrowRight, CircleDot, Flag, Navigation } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
import { statusMeta } from "@/components/trainScheduling/scheduleVisuals";
|
||||
import { Chip } from "./trackPrimitives";
|
||||
import { track } from "./trackTheme";
|
||||
|
||||
export interface TrackStatValue {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Left-rail identity card: gradient cap (train number, progress ring, status,
|
||||
* current station) over the route strip and the stat list.
|
||||
*/
|
||||
export function TrackStatusCard({
|
||||
trainNumber,
|
||||
direction,
|
||||
status,
|
||||
progressPct,
|
||||
reached,
|
||||
totalStations,
|
||||
currentStation,
|
||||
stateLine,
|
||||
origin,
|
||||
destination,
|
||||
stats,
|
||||
}: {
|
||||
trainNumber?: string | null;
|
||||
direction?: string | null;
|
||||
status: string;
|
||||
progressPct: number;
|
||||
reached: number;
|
||||
totalStations: number;
|
||||
currentStation: string;
|
||||
stateLine: string;
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
stats: TrackStatValue[];
|
||||
}) {
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
background: track.surface,
|
||||
border: `1px solid ${track.border}`,
|
||||
borderRadius: 16,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Stack gap={18} p="22px 22px 20px" style={{ background: track.capGradient }}>
|
||||
<Group gap={12} align="center" wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 13,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "rgba(255,255,255,0.18)",
|
||||
border: "1px solid rgba(255,255,255,0.36)",
|
||||
color: "white",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Navigation size={21} />
|
||||
</Box>
|
||||
<Stack gap={4} style={{ minWidth: 0, flex: 1 }}>
|
||||
<Text fw={700} fz={19} c="white" lh={1.2} truncate>
|
||||
{trainNumber ?? "Train tracking"}
|
||||
</Text>
|
||||
<Text
|
||||
fz={9.5}
|
||||
fw={700}
|
||||
tt="uppercase"
|
||||
style={{ letterSpacing: 1, color: "rgba(255,255,255,0.72)" }}
|
||||
>
|
||||
Train tracking
|
||||
</Text>
|
||||
</Stack>
|
||||
{direction ? (
|
||||
<Chip
|
||||
bg="rgba(255,255,255,0.16)"
|
||||
fg="#FFFFFF"
|
||||
border="rgba(255,255,255,0.36)"
|
||||
>
|
||||
{direction}
|
||||
</Chip>
|
||||
) : null}
|
||||
</Group>
|
||||
|
||||
<Group gap={18} align="center" wrap="nowrap">
|
||||
<RingProgress
|
||||
size={104}
|
||||
thickness={9}
|
||||
roundCaps
|
||||
sections={[{ value: progressPct, color: "white" }]}
|
||||
rootColor="rgba(255,255,255,0.24)"
|
||||
label={
|
||||
<Stack gap={1} align="center">
|
||||
<Text fw={700} fz={23} lh={1} c="white">
|
||||
{Math.round(progressPct)}%
|
||||
</Text>
|
||||
<Text
|
||||
fz={8.5}
|
||||
fw={700}
|
||||
tt="uppercase"
|
||||
style={{ letterSpacing: 0.7, color: "rgba(255,255,255,0.78)" }}
|
||||
>
|
||||
{reached}/{totalStations} stops
|
||||
</Text>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
<Stack gap={9} style={{ minWidth: 0, flex: 1 }}>
|
||||
<Box
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 7,
|
||||
padding: "6px 12px",
|
||||
borderRadius: 999,
|
||||
background: "white",
|
||||
width: "fit-content",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: 999,
|
||||
background: statusMeta(status).dot,
|
||||
}}
|
||||
/>
|
||||
<Text fz={10.5} fw={700} c={track.brandDark} style={{ letterSpacing: 0.6 }}>
|
||||
{status}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text
|
||||
fz={11.5}
|
||||
fw={600}
|
||||
style={{ letterSpacing: 0.4, color: "rgba(255,255,255,0.72)" }}
|
||||
>
|
||||
{stateLine}
|
||||
</Text>
|
||||
<Text fz={16} fw={700} c="white" truncate>
|
||||
{currentStation}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<Group
|
||||
gap={10}
|
||||
px={20}
|
||||
py={14}
|
||||
wrap="nowrap"
|
||||
align="center"
|
||||
style={{
|
||||
background: track.surface2,
|
||||
borderBottom: `1px solid ${track.borderSoft}`,
|
||||
}}
|
||||
>
|
||||
<CircleDot size={14} color={track.brand} style={{ flexShrink: 0 }} />
|
||||
<Text size="12.5px" fw={600} c={track.text} truncate>
|
||||
{origin ?? "—"}
|
||||
</Text>
|
||||
<Box style={{ flex: 1 }} />
|
||||
<ArrowRight size={14} color={track.text3} style={{ flexShrink: 0 }} />
|
||||
<Box style={{ flex: 1 }} />
|
||||
<Text size="12.5px" fw={600} c={track.text} truncate>
|
||||
{destination ?? "—"}
|
||||
</Text>
|
||||
<Flag size={13} color={track.muted} style={{ flexShrink: 0 }} />
|
||||
</Group>
|
||||
|
||||
<Stack gap={0} px={20} pt={6} pb={14}>
|
||||
{stats.map((s, i) => {
|
||||
const Icon = s.icon;
|
||||
return (
|
||||
<Group
|
||||
key={s.label}
|
||||
gap={10}
|
||||
py={11}
|
||||
wrap="nowrap"
|
||||
align="center"
|
||||
style={i ? { borderTop: `1px solid ${track.borderSoft}` } : undefined}
|
||||
>
|
||||
<Icon size={15} color={track.muted} style={{ flexShrink: 0 }} />
|
||||
<Text size="12.5px" c={track.text2} style={{ flex: 1, minWidth: 0 }}>
|
||||
{s.label}
|
||||
</Text>
|
||||
<Text size="12.5px" fw={700} c={track.text} style={{ flexShrink: 0 }}>
|
||||
{s.value}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { Box, Group, Stack, Text } from "@mantine/core";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import type { StationWorkPhaseLog } from "@/types/trainScheduling";
|
||||
import { PHASE_TONE, track, type PhaseState } from "./trackTheme";
|
||||
|
||||
/** Small uppercase tag — the design's one chip shape, tinted per use. */
|
||||
export function Chip({
|
||||
children,
|
||||
bg,
|
||||
fg,
|
||||
border,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
bg: string;
|
||||
fg: string;
|
||||
border?: string;
|
||||
}) {
|
||||
return (
|
||||
<Box
|
||||
component="span"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "4px 9px",
|
||||
borderRadius: 6,
|
||||
background: bg,
|
||||
border: border ? `1px solid ${border}` : undefined,
|
||||
color: fg,
|
||||
fontSize: 9.5,
|
||||
fontWeight: 700,
|
||||
letterSpacing: 0.6,
|
||||
lineHeight: 1.4,
|
||||
whiteSpace: "nowrap",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/** Card header: tinted icon chip + title + one-line hint, optional right slot. */
|
||||
export function SectionHead({
|
||||
icon,
|
||||
title,
|
||||
hint,
|
||||
right,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
hint: string;
|
||||
right?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Group
|
||||
gap={13}
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
px={24}
|
||||
py={18}
|
||||
style={{ borderBottom: `1px solid ${track.borderSoft}` }}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: 11,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: track.brandDim,
|
||||
color: track.brand,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
<Stack gap={3} style={{ minWidth: 0, flex: 1 }}>
|
||||
<Text fw={700} size="15px" c={track.text}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text size="12px" c={track.muted}>
|
||||
{hint}
|
||||
</Text>
|
||||
</Stack>
|
||||
{right}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
/** Which of the three window states a phase log is in. */
|
||||
export function phaseState(log?: StationWorkPhaseLog | null): PhaseState {
|
||||
if (log?.endedAt) return "done";
|
||||
if (log?.startedAt) return "active";
|
||||
return "idle";
|
||||
}
|
||||
|
||||
export function phaseChipLabel(
|
||||
phase: "loading" | "unloading",
|
||||
state: PhaseState,
|
||||
) {
|
||||
const title = phase === "loading" ? "Loading" : "Unloading";
|
||||
const suffix =
|
||||
state === "done"
|
||||
? "done"
|
||||
: state === "active"
|
||||
? "in progress"
|
||||
: "not started";
|
||||
return `${title} ${suffix}`;
|
||||
}
|
||||
|
||||
export function PhaseChip({
|
||||
phase,
|
||||
state,
|
||||
}: {
|
||||
phase: "loading" | "unloading";
|
||||
state: PhaseState;
|
||||
}) {
|
||||
const tone = PHASE_TONE[state];
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "5px 10px",
|
||||
borderRadius: 7,
|
||||
background: tone.bg,
|
||||
width: 150,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 999,
|
||||
background: tone.fg,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<Text size="10.5px" fw={700} c={tone.fg} style={{ lineHeight: 1.4 }}>
|
||||
{phaseChipLabel(phase, state)}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Design tokens for the train-tracking surface, mirroring ui/scheule/track.pen.
|
||||
*
|
||||
* The rest of the scheduling pages key off `scheduleVisuals`/`freightBrand`;
|
||||
* tracking is its own light "work surface" palette, so the tokens live here
|
||||
* rather than widening the shared brand file. Brand green is darkened from the
|
||||
* shared #1B9E7A to #0E8C68 so label text clears AA contrast on white.
|
||||
*/
|
||||
export const track = {
|
||||
bg: "#F6F8FA",
|
||||
surface: "#FFFFFF",
|
||||
surface2: "#F4F7F9",
|
||||
surface3: "#E9EEF3",
|
||||
border: "#DCE4EC",
|
||||
borderSoft: "#E8EDF2",
|
||||
brand: "#0E8C68",
|
||||
brandDark: "#0A6B50",
|
||||
brandLight: "#12A87D",
|
||||
brandDim: "#E4F5EF",
|
||||
text: "#0F1D2B",
|
||||
text2: "#48606F",
|
||||
text3: "#9BAEBE",
|
||||
muted: "#6A8296",
|
||||
teal: "#0E8C82",
|
||||
tealDim: "#DFF3F1",
|
||||
blue: "#2563C9",
|
||||
blueDim: "#E4EDFB",
|
||||
amber: "#A66A08",
|
||||
amberDim: "#FDF2DC",
|
||||
amberBorder: "#E8C88C",
|
||||
amberText: "#8A6420",
|
||||
red: "#C43D3D",
|
||||
redDim: "#FBE9E9",
|
||||
grape: "#7C4BC4",
|
||||
grapeDim: "#F0E7FB",
|
||||
/** Status-cap wash on the left rail's identity card. */
|
||||
capGradient:
|
||||
"linear-gradient(115deg, #0A6B50 0%, #0E8C68 55%, #12A87D 100%)",
|
||||
mono: "'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace",
|
||||
} as const;
|
||||
|
||||
/** Checkpoint-kind chip colors, keyed by TrainCheckpointKind. */
|
||||
export const KIND_TONE: Record<string, { bg: string; fg: string }> = {
|
||||
DEPARTED: { bg: track.blueDim, fg: track.blue },
|
||||
PASSED: { bg: track.brandDim, fg: track.brand },
|
||||
ARRIVED: { bg: track.tealDim, fg: track.teal },
|
||||
};
|
||||
|
||||
/** Loading/unloading window state chips. */
|
||||
export const PHASE_TONE = {
|
||||
done: { bg: track.surface3, fg: track.muted },
|
||||
active: { bg: track.amberDim, fg: track.amber },
|
||||
idle: { bg: track.surface2, fg: track.text3 },
|
||||
} as const;
|
||||
|
||||
export type PhaseState = keyof typeof PHASE_TONE;
|
||||
|
||||
/** Trade-direction chips in the yard-work tables. */
|
||||
export const DIRECTION_TONE: Record<string, { bg: string; fg: string }> = {
|
||||
IMPORT: { bg: track.blueDim, fg: track.blue },
|
||||
EXPORT: { bg: track.tealDim, fg: track.teal },
|
||||
DOMESTIC: { bg: track.grapeDim, fg: track.grape },
|
||||
};
|
||||
|
||||
export const cardStyle = {
|
||||
background: track.surface,
|
||||
border: `1px solid ${track.border}`,
|
||||
borderRadius: 16,
|
||||
} as const;
|
||||
@@ -493,6 +493,13 @@ export const URL_CONSTANTS = {
|
||||
`/train-scheduling/schedules/${id}/bookings/${bookingId}/load`,
|
||||
BOOKING_UNLOAD: (id: string, bookingId: string) =>
|
||||
`/train-scheduling/schedules/${id}/bookings/${bookingId}/unload`,
|
||||
BOOKING_WAGON_LOAD: (id: string, bookingId: string, allocationId: string) =>
|
||||
`/train-scheduling/schedules/${id}/bookings/${bookingId}/wagons/${allocationId}/load`,
|
||||
BOOKING_WAGON_UNLOAD: (id: string, bookingId: string, allocationId: string) =>
|
||||
`/train-scheduling/schedules/${id}/bookings/${bookingId}/wagons/${allocationId}/unload`,
|
||||
BOOKING_WAGONS: (bookingId: string) => `/bookings/${bookingId}/wagons`,
|
||||
CANCEL_REMAINING_WAGONS: (bookingId: string) =>
|
||||
`/bookings/${bookingId}/wagon-cancellations/at-loading`,
|
||||
INTERCITY_BOOKINGS: "/train-scheduling/intercity/bookings",
|
||||
INTERCITY_CANDIDATES: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/intercity-candidates`,
|
||||
|
||||
@@ -90,17 +90,8 @@ export default function TrainBuilderDetailPage() {
|
||||
const [yardModalOpen, setYardModalOpen] = useState(false);
|
||||
const [disbandOpen, setDisbandOpen] = useState(false);
|
||||
const [deactivateOpen, setDeactivateOpen] = useState(false);
|
||||
const [maintenanceTarget, setMaintenanceTarget] =
|
||||
useState<TrainCompositionWagon | null>(null);
|
||||
const [maintenanceNote, setMaintenanceNote] = useState("");
|
||||
// Clearing the note with the target stops one wagon's reason being carried
|
||||
// over onto the next wagon sent to maintenance.
|
||||
const closeMaintenance = () => {
|
||||
setMaintenanceTarget(null);
|
||||
setMaintenanceNote("");
|
||||
};
|
||||
// Detach-approval flow: on a SCHEDULED run, detach/maintenance is filed as a
|
||||
// request (with reason) and executed by a second staffer's approval.
|
||||
// Every detach / maintenance move asks for a reason first — it is recorded
|
||||
// as an auto-approved audit row and on the train's wagon history.
|
||||
const [requestTarget, setRequestTarget] = useState<{
|
||||
wagon: TrainCompositionWagon;
|
||||
action: "DETACH" | "MAINTENANCE";
|
||||
@@ -110,15 +101,8 @@ export default function TrainBuilderDetailPage() {
|
||||
setRequestTarget(null);
|
||||
setRequestReason("");
|
||||
};
|
||||
const [rejectTarget, setRejectTarget] = useState<WagonDetachRequestRow | null>(null);
|
||||
const [rejectNote, setRejectNote] = useState("");
|
||||
const closeReject = () => {
|
||||
setRejectTarget(null);
|
||||
setRejectNote("");
|
||||
};
|
||||
const { user } = useAuth();
|
||||
const canAssign = hasPermission(user, FREIGHT_PERMS.trains.assignWagons);
|
||||
const canApproveDetach = hasPermission(user, FREIGHT_PERMS.trains.approveWagonDetach);
|
||||
const canChangeLocomotives = hasPermission(user, FREIGHT_PERMS.trains.changeLocomotives);
|
||||
const canChangeYard = hasPermission(user, FREIGHT_PERMS.trains.changeYard);
|
||||
const canChangeWagonYard = hasPermission(user, FREIGHT_PERMS.trains.changeWagonYard);
|
||||
@@ -149,35 +133,17 @@ export default function TrainBuilderDetailPage() {
|
||||
enabled: Boolean(id),
|
||||
}),
|
||||
);
|
||||
const createDetachRequest = useMutation(
|
||||
api.trainBuilder.createDetachRequest.mutationOptions(),
|
||||
);
|
||||
const approveDetachRequest = useMutation(
|
||||
api.trainBuilder.approveDetachRequest.mutationOptions(),
|
||||
);
|
||||
const rejectDetachRequest = useMutation(
|
||||
api.trainBuilder.rejectDetachRequest.mutationOptions(),
|
||||
);
|
||||
const disband = useMutation(api.trainBuilder.disband.mutationOptions());
|
||||
const deactivate = useMutation(api.trainBuilder.deactivate.mutationOptions());
|
||||
const activate = useMutation(api.trainBuilder.activate.mutationOptions());
|
||||
|
||||
const composition = compositionQuery.data;
|
||||
|
||||
// Approval kicks in once a run is SCHEDULED. DRAFT stays direct-edit; a
|
||||
// dispatched train is frozen outright (composition.editable is false).
|
||||
const requiresDetachApproval = (composition?.activeSchedules ?? []).some(
|
||||
(s) => s.status === "SCHEDULED",
|
||||
);
|
||||
const detachRequests = useMemo(
|
||||
() => detachRequestsQuery.data ?? [],
|
||||
[detachRequestsQuery.data],
|
||||
);
|
||||
const pendingDetachRequests = detachRequests.filter((r) => r.status === "PENDING");
|
||||
const pendingWagonIds = useMemo(
|
||||
() => new Set(detachRequests.filter((r) => r.status === "PENDING").map((r) => r.wagonId)),
|
||||
[detachRequests],
|
||||
);
|
||||
|
||||
// The diagram memoizes off its `locomotives`/`wagons` props; building those
|
||||
// arrays inline in JSX would hand it a new identity on every render and
|
||||
@@ -270,32 +236,19 @@ export default function TrainBuilderDetailPage() {
|
||||
[withToast, reorderWagons.mutateAsync, trainId],
|
||||
);
|
||||
const wagons = composition?.wagons;
|
||||
const openDetachRequest = useCallback(
|
||||
const openDetachReason = useCallback(
|
||||
(wagonId: string, action: "DETACH" | "MAINTENANCE") => {
|
||||
if (pendingWagonIds.has(wagonId)) {
|
||||
toast({
|
||||
title: "A detach request for this wagon is already pending approval",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const wagon = wagons?.find((w) => w.id === wagonId);
|
||||
if (wagon) setRequestTarget({ wagon, action });
|
||||
},
|
||||
[pendingWagonIds, wagons, toast],
|
||||
[wagons],
|
||||
);
|
||||
const handleRemove = useCallback(
|
||||
(wagonId: string) => {
|
||||
if (!trainId) return;
|
||||
if (requiresDetachApproval) {
|
||||
openDetachRequest(wagonId, "DETACH");
|
||||
return;
|
||||
}
|
||||
void withToast(
|
||||
() => removeWagon.mutateAsync({ id: trainId, wagonId }),
|
||||
"Could not detach wagon",
|
||||
);
|
||||
openDetachReason(wagonId, "DETACH");
|
||||
},
|
||||
[withToast, removeWagon.mutateAsync, trainId, requiresDetachApproval, openDetachRequest],
|
||||
[trainId, openDetachReason],
|
||||
);
|
||||
const handleChangeWagonYard = useCallback(
|
||||
(wagonId: string, currentYardId: string) => {
|
||||
@@ -319,13 +272,9 @@ export default function TrainBuilderDetailPage() {
|
||||
);
|
||||
const handleMaintenance = useCallback(
|
||||
(wagon: TrainCompositionWagon) => {
|
||||
if (requiresDetachApproval) {
|
||||
openDetachRequest(wagon.id, "MAINTENANCE");
|
||||
return;
|
||||
}
|
||||
setMaintenanceTarget(wagon);
|
||||
openDetachReason(wagon.id, "MAINTENANCE");
|
||||
},
|
||||
[requiresDetachApproval, openDetachRequest],
|
||||
[openDetachReason],
|
||||
);
|
||||
|
||||
if (compositionQuery.isLoading) {
|
||||
@@ -502,9 +451,11 @@ export default function TrainBuilderDetailPage() {
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{!composition.editable ? (
|
||||
<Alert color="yellow" icon={<AlertTriangle size={16} />}>
|
||||
This train is out on a dispatched run — its composition is frozen until arrival.
|
||||
{(composition.activeSchedules ?? []).some((s) => s.status === "DISPATCHED") ? (
|
||||
<Alert color="blue" icon={<AlertTriangle size={16} />}>
|
||||
This train is out on a dispatched run. You can still edit its composition —
|
||||
the dispatched run keeps the wagon plan it departed with, and your changes
|
||||
apply to scheduled (not yet departed) runs only.
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
@@ -563,7 +514,7 @@ export default function TrainBuilderDetailPage() {
|
||||
))}
|
||||
</Group>
|
||||
|
||||
{detachRequests.length ? (
|
||||
{/* {detachRequests.length ? (
|
||||
<Card>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between">
|
||||
@@ -576,8 +527,8 @@ export default function TrainBuilderDetailPage() {
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
While this train is on a scheduled run, detaching a wagon (or sending it to
|
||||
maintenance) needs a second staff member's approval. Decided requests stay
|
||||
here as the audit trail.
|
||||
maintenance) requires a reason — recorded here as the audit trail of who
|
||||
did it and why.
|
||||
</Text>
|
||||
{detachRequests.map((req) => {
|
||||
const isOwn = Boolean(req.requestedById && user?.id === req.requestedById);
|
||||
@@ -622,49 +573,9 @@ export default function TrainBuilderDetailPage() {
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
{req.status === "PENDING" && canApproveDetach ? (
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Tooltip
|
||||
label="You filed this request — a different staff member must approve it"
|
||||
disabled={!isOwn}
|
||||
withArrow
|
||||
>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="green"
|
||||
disabled={isOwn}
|
||||
loading={approveDetachRequest.isPending}
|
||||
onClick={() =>
|
||||
void withToast(async () => {
|
||||
await approveDetachRequest.mutateAsync({
|
||||
id: composition.id,
|
||||
requestId: req.id,
|
||||
});
|
||||
toast({
|
||||
title: `Wagon ${req.wagonNumber} ${
|
||||
req.action === "MAINTENANCE"
|
||||
? "sent to maintenance"
|
||||
: "detached"
|
||||
}`,
|
||||
});
|
||||
}, "Could not approve request")
|
||||
}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
color="red"
|
||||
onClick={() => setRejectTarget(req)}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</Group>
|
||||
) : req.status === "PENDING" ? (
|
||||
{req.status === "PENDING" ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
Awaiting approval
|
||||
Legacy request — approval flow removed
|
||||
</Text>
|
||||
) : null}
|
||||
</Group>
|
||||
@@ -672,7 +583,7 @@ export default function TrainBuilderDetailPage() {
|
||||
})}
|
||||
</Stack>
|
||||
</Card>
|
||||
) : null}
|
||||
) : null} */}
|
||||
|
||||
<Stack gap="sm">
|
||||
<TrainCompositionDiagram
|
||||
@@ -811,71 +722,14 @@ export default function TrainBuilderDetailPage() {
|
||||
onClose={() => setYardModalOpen(false)}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
opened={Boolean(maintenanceTarget)}
|
||||
onClose={closeMaintenance}
|
||||
title={<Text fw={600}>Send wagon to maintenance?</Text>}
|
||||
radius="lg"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Wagon{" "}
|
||||
<Text span fw={700} ff="monospace" c="dark">
|
||||
{maintenanceTarget?.wagonNumber}
|
||||
</Text>{" "}
|
||||
is detached from train{" "}
|
||||
<Text span fw={700} c="dark">
|
||||
{trainRunLabel}
|
||||
</Text>{" "}
|
||||
and set to MAINTENANCE — it stays out of the available pool until it
|
||||
clears. The detach is stamped with the time and this train's run
|
||||
numbers in the wagon's history.
|
||||
</Text>
|
||||
<Textarea
|
||||
label="Note"
|
||||
placeholder="Optional note (e.g. reason for maintenance)"
|
||||
value={maintenanceNote}
|
||||
onChange={(e) => setMaintenanceNote(e.currentTarget.value)}
|
||||
autosize
|
||||
minRows={2}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={closeMaintenance}>
|
||||
Keep in consist
|
||||
</Button>
|
||||
<Button
|
||||
color="orange"
|
||||
leftSection={<Wrench size={16} />}
|
||||
loading={maintenanceWagon.isPending}
|
||||
onClick={() =>
|
||||
void withToast(async () => {
|
||||
await maintenanceWagon.mutateAsync({
|
||||
id: composition.id,
|
||||
wagonId: maintenanceTarget!.id,
|
||||
note: maintenanceNote.trim() || undefined,
|
||||
});
|
||||
toast({
|
||||
title: `Wagon ${maintenanceTarget!.wagonNumber} sent to maintenance`,
|
||||
});
|
||||
closeMaintenance();
|
||||
}, "Could not send wagon to maintenance")
|
||||
}
|
||||
>
|
||||
Send to maintenance
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
opened={Boolean(requestTarget)}
|
||||
onClose={closeRequest}
|
||||
title={
|
||||
<Text fw={600}>
|
||||
{requestTarget?.action === "MAINTENANCE"
|
||||
? "Request maintenance approval?"
|
||||
: "Request detach approval?"}
|
||||
? "Send wagon to maintenance?"
|
||||
: "Detach wagon?"}
|
||||
</Text>
|
||||
}
|
||||
radius="lg"
|
||||
@@ -883,22 +737,28 @@ export default function TrainBuilderDetailPage() {
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Train{" "}
|
||||
<Text span fw={700} c="dark">
|
||||
{trainRunLabel}
|
||||
</Text>{" "}
|
||||
is on a scheduled run, so wagon{" "}
|
||||
Wagon{" "}
|
||||
<Text span fw={700} ff="monospace" c="dark">
|
||||
{requestTarget?.wagon.wagonNumber}
|
||||
</Text>{" "}
|
||||
is not detached now — your request goes to a staff member with approval
|
||||
rights, and the{" "}
|
||||
{requestTarget?.action === "MAINTENANCE" ? "maintenance move" : "detach"}{" "}
|
||||
happens the moment they approve it.
|
||||
{requestTarget?.action === "MAINTENANCE"
|
||||
? "leaves train "
|
||||
: "is detached from train "}
|
||||
<Text span fw={700} c="dark">
|
||||
{trainRunLabel}
|
||||
</Text>{" "}
|
||||
{requestTarget?.action === "MAINTENANCE"
|
||||
? "and is set to MAINTENANCE — it stays out of the available pool until it clears."
|
||||
: "immediately."}{" "}
|
||||
The reason is required and shows in this train's History tab.
|
||||
</Text>
|
||||
<Textarea
|
||||
label="Reason"
|
||||
placeholder="Why must this wagon leave the scheduled consist? (required)"
|
||||
placeholder={
|
||||
requestTarget?.action === "MAINTENANCE"
|
||||
? "Why is this wagon going to maintenance? (required)"
|
||||
: "Why is this wagon leaving the consist? (required)"
|
||||
}
|
||||
value={requestReason}
|
||||
onChange={(e) => setRequestReason(e.currentTarget.value)}
|
||||
autosize
|
||||
@@ -919,73 +779,36 @@ export default function TrainBuilderDetailPage() {
|
||||
)
|
||||
}
|
||||
disabled={!requestReason.trim()}
|
||||
loading={createDetachRequest.isPending}
|
||||
loading={removeWagon.isPending || maintenanceWagon.isPending}
|
||||
onClick={() =>
|
||||
void withToast(async () => {
|
||||
await createDetachRequest.mutateAsync({
|
||||
id: composition.id,
|
||||
wagonId: requestTarget!.wagon.id,
|
||||
action: requestTarget!.action,
|
||||
reason: requestReason.trim(),
|
||||
});
|
||||
if (requestTarget!.action === "MAINTENANCE") {
|
||||
await maintenanceWagon.mutateAsync({
|
||||
id: composition.id,
|
||||
wagonId: requestTarget!.wagon.id,
|
||||
note: requestReason.trim(),
|
||||
});
|
||||
} else {
|
||||
await removeWagon.mutateAsync({
|
||||
id: composition.id,
|
||||
wagonId: requestTarget!.wagon.id,
|
||||
reason: requestReason.trim(),
|
||||
});
|
||||
}
|
||||
toast({
|
||||
title: `Request for wagon ${requestTarget!.wagon.wagonNumber} filed — awaiting approval`,
|
||||
title: `Wagon ${requestTarget!.wagon.wagonNumber} ${
|
||||
requestTarget!.action === "MAINTENANCE"
|
||||
? "sent to maintenance"
|
||||
: "detached"
|
||||
}`,
|
||||
});
|
||||
closeRequest();
|
||||
}, "Could not file the request")
|
||||
}, "Could not detach the wagon")
|
||||
}
|
||||
>
|
||||
Request approval
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
opened={Boolean(rejectTarget)}
|
||||
onClose={closeReject}
|
||||
title={<Text fw={600}>Reject this request?</Text>}
|
||||
radius="lg"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Wagon{" "}
|
||||
<Text span fw={700} ff="monospace" c="dark">
|
||||
{rejectTarget?.wagonNumber}
|
||||
</Text>{" "}
|
||||
stays in the consist. The requester sees your note in the request history.
|
||||
</Text>
|
||||
<Textarea
|
||||
label="Why is it rejected?"
|
||||
placeholder="Required"
|
||||
value={rejectNote}
|
||||
onChange={(e) => setRejectNote(e.currentTarget.value)}
|
||||
autosize
|
||||
minRows={2}
|
||||
required
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={closeReject}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
disabled={!rejectNote.trim()}
|
||||
loading={rejectDetachRequest.isPending}
|
||||
onClick={() =>
|
||||
void withToast(async () => {
|
||||
await rejectDetachRequest.mutateAsync({
|
||||
id: composition.id,
|
||||
requestId: rejectTarget!.id,
|
||||
note: rejectNote.trim(),
|
||||
});
|
||||
toast({ title: `Request for wagon ${rejectTarget!.wagonNumber} rejected` });
|
||||
closeReject();
|
||||
}, "Could not reject the request")
|
||||
}
|
||||
>
|
||||
Reject request
|
||||
{requestTarget?.action === "MAINTENANCE"
|
||||
? "Send to maintenance"
|
||||
: "Detach wagon"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -4,48 +4,31 @@ import { useState } from "react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
CalendarClock,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
Flag,
|
||||
ListChecks,
|
||||
MapPin,
|
||||
Navigation,
|
||||
Package,
|
||||
PackageCheck,
|
||||
Pencil,
|
||||
Train,
|
||||
Route,
|
||||
TrainFront,
|
||||
} from "lucide-react";
|
||||
import { StationWorkControls } from "@/components/trainScheduling/StationWorkControls";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
RingProgress,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Timeline,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { Box, Button, Group, Loader, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { PageContainer } from "@/components/page";
|
||||
import { CheckpointTimeModal } from "@/components/trainScheduling/CheckpointTimeModal";
|
||||
import { CheckpointLogTable } from "@/components/trainScheduling/CheckpointLogTable";
|
||||
import { JourneySpine } from "@/components/trainScheduling/JourneySpine";
|
||||
import { LogPassYardWorkModal } from "@/components/trainScheduling/LogPassYardWorkModal";
|
||||
import { RouteCorridorTrack } from "@/components/trainScheduling/RouteCorridorTrack";
|
||||
import { TrackStatusCard } from "@/components/trainScheduling/TrackStatusCard";
|
||||
import { Chip, SectionHead } from "@/components/trainScheduling/trackPrimitives";
|
||||
import { track as T } from "@/components/trainScheduling/trackTheme";
|
||||
import type {
|
||||
CheckpointHandlingTimes,
|
||||
TrackStation,
|
||||
TrainCheckpoint,
|
||||
} from "@/types/trainScheduling";
|
||||
import {
|
||||
RouteCorridor,
|
||||
StatusPill,
|
||||
scheduleBrand,
|
||||
} from "@/components/trainScheduling/scheduleVisuals";
|
||||
import { freightBrand } from "@/theme/freight-brand";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/services/api";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
@@ -80,23 +63,6 @@ const pickHandling = (
|
||||
.filter(([, value]) => keepNulls || value !== null),
|
||||
);
|
||||
|
||||
/**
|
||||
* Total loading and unloading at a stop, the way the reports measure it:
|
||||
* earliest start to latest finish, so a stop that only loaded or only unloaded
|
||||
* still reads. Null when nothing was logged.
|
||||
*/
|
||||
const handlingHours = (cp: TrainCheckpoint): number | null => {
|
||||
const times = [cp.unloadingStartedAt, cp.loadingStartedAt]
|
||||
.filter((v): v is string => Boolean(v))
|
||||
.map((v) => new Date(v).getTime());
|
||||
const ends = [cp.loadingCompletedAt, cp.unloadingCompletedAt]
|
||||
.filter((v): v is string => Boolean(v))
|
||||
.map((v) => new Date(v).getTime());
|
||||
if (!times.length || !ends.length) return null;
|
||||
const hours = (Math.max(...ends) - Math.min(...times)) / 3_600_000;
|
||||
return Math.round(hours * 10) / 10;
|
||||
};
|
||||
|
||||
const parseError = (error: unknown, fallback: string) => {
|
||||
if (isAxiosError(error)) {
|
||||
const data = error.response?.data as Record<string, unknown> | undefined;
|
||||
@@ -117,85 +83,12 @@ function formatDateTime(iso?: string | null) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A single fact in the hero's glass meta strip — icon chip + uppercase label +
|
||||
* value, laid on the translucent panel over the gradient.
|
||||
*/
|
||||
function HeroStat({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Box
|
||||
style={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 10,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "rgba(255,255,255,0.16)",
|
||||
border: "1px solid rgba(255,255,255,0.24)",
|
||||
color: "white",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
<Stack gap={1} style={{ minWidth: 0 }}>
|
||||
<Text
|
||||
size="10px"
|
||||
fw={700}
|
||||
tt="uppercase"
|
||||
style={{ letterSpacing: 0.6, color: "rgba(255,255,255,0.72)" }}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="sm" fw={700} c="white" truncate>
|
||||
{value}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
/** Section header — icon chip + title + one-line hint. Shared by the cards. */
|
||||
function SectionHead({
|
||||
icon,
|
||||
title,
|
||||
hint,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
hint: string;
|
||||
}) {
|
||||
return (
|
||||
<Group gap="sm" align="center" wrap="nowrap">
|
||||
<ThemeIcon size={36} radius="md" variant="light" color="edr-green">
|
||||
{icon}
|
||||
</ThemeIcon>
|
||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text fw={800} size="sm">
|
||||
{title}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{hint}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
const CARD_STYLE = {
|
||||
borderColor: scheduleBrand.mutedBorder,
|
||||
boxShadow: scheduleBrand.shadowSm,
|
||||
} as const;
|
||||
const CARD = {
|
||||
background: T.surface,
|
||||
border: `1px solid ${T.border}`,
|
||||
borderRadius: 16,
|
||||
overflow: "hidden" as const,
|
||||
};
|
||||
|
||||
export default function TrainScheduleTrackPage() {
|
||||
const { scheduleId } = useParams<{ scheduleId: string }>();
|
||||
@@ -259,22 +152,18 @@ export default function TrainScheduleTrackPage() {
|
||||
|
||||
if (trackQuery.isLoading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<Group justify="center" py="xl">
|
||||
<Loader size="sm" color="edr-green" />
|
||||
</Group>
|
||||
</PageContainer>
|
||||
<Group justify="center" py="xl">
|
||||
<Loader size="sm" color="edr-green" />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
const track = trackQuery.data;
|
||||
if (!track || !scheduleId) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<Text c="dimmed" py="xl">
|
||||
Tracking data not found.
|
||||
</Text>
|
||||
</PageContainer>
|
||||
<Text c="dimmed" py="xl" px="lg">
|
||||
Tracking data not found.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -386,27 +275,50 @@ export default function TrainScheduleTrackPage() {
|
||||
const forgottenBoarders =
|
||||
currentYard?.toLoad.filter((r) => !r.loadedAt) ?? [];
|
||||
|
||||
// The stop the operator acts on next — drives the left rail's action card.
|
||||
const nextStation = canLog
|
||||
? track.stations.find((s) => s.sequenceNo === track.currentSequenceNo + 1)
|
||||
: undefined;
|
||||
const nextIsFinal =
|
||||
nextStation?.sequenceNo === track.stations[totalStations - 1]?.sequenceNo;
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Group justify="space-between" w="100%">
|
||||
<Box style={{ background: T.bg, minHeight: "100%" }}>
|
||||
{/* ── Top bar ── */}
|
||||
<Group
|
||||
gap={14}
|
||||
px={36}
|
||||
py={16}
|
||||
wrap="nowrap"
|
||||
align="center"
|
||||
style={{ background: T.surface, borderBottom: `1px solid ${T.border}` }}
|
||||
>
|
||||
<Button
|
||||
component={Link}
|
||||
to={`/dashboard/operations/train-scheduling-v2/${scheduleId}`}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
variant="default"
|
||||
radius={9}
|
||||
size="compact-sm"
|
||||
leftSection={<ArrowLeft size={16} />}
|
||||
w="fit-content"
|
||||
leftSection={<ArrowLeft size={15} />}
|
||||
>
|
||||
Back to schedule
|
||||
</Button>
|
||||
<Group gap={8} align="center" wrap="nowrap" visibleFrom="sm">
|
||||
<Text size="12.5px" c={T.muted}>
|
||||
Train scheduling
|
||||
</Text>
|
||||
<ChevronRight size={13} color={T.text3} />
|
||||
<Text size="12.5px" fw={600} c={T.text}>
|
||||
{track.trainNumber ?? "Schedule"} · Tracking
|
||||
</Text>
|
||||
</Group>
|
||||
<Box style={{ flex: 1 }} />
|
||||
{inTransit || arrived ? (
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="lg"
|
||||
variant="default"
|
||||
radius={9}
|
||||
size="compact-sm"
|
||||
leftSection={<FileText size={16} />}
|
||||
leftSection={<FileText size={15} color={T.brand} />}
|
||||
loading={intercityMarshalling.isPending}
|
||||
onClick={() => void openIntercityMarshalling()}
|
||||
>
|
||||
@@ -415,423 +327,239 @@ export default function TrainScheduleTrackPage() {
|
||||
) : null}
|
||||
</Group>
|
||||
|
||||
{/* ── Hero: gradient wash, route + a bold progress ring woven together ── */}
|
||||
<Paper
|
||||
radius="lg"
|
||||
p={0}
|
||||
style={{ overflow: "hidden", boxShadow: scheduleBrand.shadow }}
|
||||
{/* ── Two-column work surface ── */}
|
||||
<Group
|
||||
align="flex-start"
|
||||
gap={28}
|
||||
px={36}
|
||||
pt={28}
|
||||
pb={56}
|
||||
wrap="wrap"
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
background: scheduleBrand.heroGradient,
|
||||
padding: "26px 28px",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{/* soft decorative glow, purely artistic */}
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -80,
|
||||
right: -60,
|
||||
width: 260,
|
||||
height: 260,
|
||||
borderRadius: "50%",
|
||||
background: "rgba(255,255,255,0.10)",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="flex-start"
|
||||
wrap="wrap"
|
||||
gap="xl"
|
||||
style={{ position: "relative" }}
|
||||
>
|
||||
{/* left — identity + route */}
|
||||
<Stack gap={14} style={{ minWidth: 260, flex: 1 }}>
|
||||
<Group gap="md" align="center" wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: 52,
|
||||
height: 52,
|
||||
borderRadius: 14,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "rgba(255,255,255,0.16)",
|
||||
border: "1px solid rgba(255,255,255,0.26)",
|
||||
color: "white",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Navigation size={26} />
|
||||
</Box>
|
||||
<Stack gap={6} style={{ minWidth: 0 }}>
|
||||
<Group gap="sm" align="center" wrap="wrap">
|
||||
<Title order={3} fw={800} c="white">
|
||||
Train tracking
|
||||
</Title>
|
||||
{track.trainNumber ? (
|
||||
<Badge
|
||||
variant="white"
|
||||
color="dark"
|
||||
radius="sm"
|
||||
styles={{ root: { color: freightBrand.primaryDark } }}
|
||||
>
|
||||
{track.trainNumber}
|
||||
</Badge>
|
||||
) : null}
|
||||
{track.direction ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
radius="sm"
|
||||
styles={{
|
||||
root: {
|
||||
color: "white",
|
||||
borderColor: "rgba(255,255,255,0.5)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{track.direction}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
<Box maw={380}>
|
||||
<RouteCorridor
|
||||
origin={track.origin}
|
||||
destination={track.destination}
|
||||
variant="compact"
|
||||
onDark
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
<Group gap="sm">
|
||||
<StatusPill status={track.status} size="md" />
|
||||
<Box
|
||||
px={12}
|
||||
py={5}
|
||||
style={{
|
||||
borderRadius: 999,
|
||||
background: "rgba(255,255,255,0.16)",
|
||||
border: "1px solid rgba(255,255,255,0.24)",
|
||||
}}
|
||||
>
|
||||
<Text size="xs" fw={700} c="white" style={{ letterSpacing: 0.2 }}>
|
||||
{arrived
|
||||
? "Journey complete"
|
||||
: inTransit
|
||||
? `En route · ${currentStation}`
|
||||
: "Awaiting dispatch"}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
{/* right — progress ring, the artistic focal point */}
|
||||
<RingProgress
|
||||
size={132}
|
||||
thickness={11}
|
||||
roundCaps
|
||||
sections={[{ value: clampedPct, color: "white" }]}
|
||||
rootColor="rgba(255,255,255,0.22)"
|
||||
label={
|
||||
<Stack gap={0} align="center">
|
||||
<Text fw={800} fz={26} lh={1} c="white">
|
||||
{Math.round(clampedPct)}%
|
||||
</Text>
|
||||
<Text
|
||||
size="10px"
|
||||
fw={700}
|
||||
tt="uppercase"
|
||||
style={{ letterSpacing: 0.6, color: "rgba(255,255,255,0.8)" }}
|
||||
>
|
||||
{reached}/{totalStations} stops
|
||||
</Text>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
{/* glass meta strip below the wash */}
|
||||
<Group
|
||||
justify="space-between"
|
||||
wrap="wrap"
|
||||
gap="lg"
|
||||
px={28}
|
||||
py="md"
|
||||
style={{
|
||||
background: freightBrand.primaryDark,
|
||||
borderTop: "1px solid rgba(255,255,255,0.12)",
|
||||
}}
|
||||
>
|
||||
<HeroStat icon={<MapPin size={16} />} label="Current" value={currentStation} />
|
||||
<HeroStat
|
||||
icon={<CalendarClock size={16} />}
|
||||
label="Departed"
|
||||
value={formatDateTime(track.actualDepartureAt)}
|
||||
/>
|
||||
<HeroStat
|
||||
icon={<Flag size={16} />}
|
||||
label="Arrived"
|
||||
value={formatDateTime(track.actualArrivalAt)}
|
||||
/>
|
||||
<HeroStat
|
||||
icon={<Train size={16} />}
|
||||
label="Stations"
|
||||
value={`${reached} of ${totalStations}`}
|
||||
/>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
{/* ── Route corridor ── */}
|
||||
<Paper radius="lg" p="lg" withBorder style={CARD_STYLE}>
|
||||
<Stack gap="md">
|
||||
<SectionHead
|
||||
icon={<Navigation size={17} />}
|
||||
title="Route corridor"
|
||||
hint={
|
||||
canLog
|
||||
? "Log the train passing each station; the final station marks arrival."
|
||||
: arrived
|
||||
? "This train has arrived at its destination."
|
||||
: "Tracking becomes available once the train is dispatched."
|
||||
{/* left rail */}
|
||||
<Stack gap={16} style={{ width: 352, flexShrink: 0, flexGrow: 1, maxWidth: "100%" }}>
|
||||
<TrackStatusCard
|
||||
trainNumber={track.trainNumber}
|
||||
direction={track.direction}
|
||||
status={track.status}
|
||||
progressPct={clampedPct}
|
||||
reached={reached}
|
||||
totalStations={totalStations}
|
||||
currentStation={currentStation}
|
||||
stateLine={
|
||||
arrived
|
||||
? "Journey complete"
|
||||
: inTransit
|
||||
? "En route"
|
||||
: "Awaiting dispatch"
|
||||
}
|
||||
/>
|
||||
<RouteCorridorTrack
|
||||
stations={track.stations}
|
||||
currentSequenceNo={track.currentSequenceNo}
|
||||
checkpoints={track.checkpoints}
|
||||
canLog={canLog}
|
||||
loggingSeq={
|
||||
recordCheckpoint.isPending
|
||||
? recordCheckpoint.variables?.payload.sequenceNo
|
||||
: null
|
||||
}
|
||||
onLogCheckpoint={handleLog}
|
||||
onEditCheckpoint={canEdit ? setEditModal : undefined}
|
||||
origin={track.origin}
|
||||
destination={track.destination}
|
||||
stats={[
|
||||
{
|
||||
icon: CalendarClock,
|
||||
label: "Departed",
|
||||
value: formatDateTime(track.actualDepartureAt),
|
||||
},
|
||||
{
|
||||
icon: Flag,
|
||||
label: "Arrived",
|
||||
value: formatDateTime(track.actualArrivalAt),
|
||||
},
|
||||
{ icon: MapPin, label: "Current station", value: currentStation },
|
||||
{
|
||||
icon: TrainFront,
|
||||
label: "Stations reached",
|
||||
value: `${reached} of ${totalStations}`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Cargo the operator forgot: boarders at the CURRENT station stay
|
||||
loadable until the next pass is logged. */}
|
||||
{currentStationObj && forgottenBoarders.length > 0 ? (
|
||||
<Alert
|
||||
color="yellow"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<PackageCheck size={16} />}
|
||||
title={`${forgottenBoarders.length} booking${
|
||||
forgottenBoarders.length === 1 ? "" : "s"
|
||||
} at ${currentStationObj.label} not loaded yet`}
|
||||
>
|
||||
<Group justify="space-between" align="center" wrap="wrap" gap="sm">
|
||||
<Text size="sm">
|
||||
The train is at {currentStationObj.label} — cargo boarding here can
|
||||
still be loaded before the next station is logged.
|
||||
{/* next action */}
|
||||
{nextStation ? (
|
||||
<Stack gap={14} p={18} style={CARD}>
|
||||
<Group gap={9} align="center" wrap="nowrap">
|
||||
<Text
|
||||
size="9.5px"
|
||||
fw={700}
|
||||
tt="uppercase"
|
||||
c={T.muted}
|
||||
style={{ letterSpacing: 1 }}
|
||||
>
|
||||
Next action
|
||||
</Text>
|
||||
<Box style={{ flex: 1 }} />
|
||||
<Chip bg={T.surface3} fg={T.text2}>
|
||||
{`STOP ${reached + 1} OF ${totalStations}`}
|
||||
</Chip>
|
||||
</Group>
|
||||
<Text size="15px" fw={700} c={T.text} lh={1.3}>
|
||||
{nextIsFinal
|
||||
? `Mark arrived at ${nextStation.label}`
|
||||
: `Log pass at ${nextStation.label}`}
|
||||
</Text>
|
||||
<Text size="12px" c={T.text2} lh={1.45}>
|
||||
{nextIsFinal
|
||||
? "Marks the train arrived: remaining bookings arrive, assets are freed."
|
||||
: "Logging the pass marks arriving bookings and unlocks loading for cargo boarding here."}
|
||||
</Text>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius={9}
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
color="yellow"
|
||||
style={{ flex: 1 }}
|
||||
leftSection={nextIsFinal ? <Flag size={14} /> : <MapPin size={14} />}
|
||||
loading={
|
||||
recordCheckpoint.isPending &&
|
||||
recordCheckpoint.variables?.payload.sequenceNo ===
|
||||
nextStation.sequenceNo
|
||||
}
|
||||
onClick={() => handleLog(nextStation.sequenceNo)}
|
||||
>
|
||||
{nextIsFinal ? "Mark arrived" : "Log pass"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
radius={9}
|
||||
size="compact-sm"
|
||||
leftSection={<Package size={14} />}
|
||||
onClick={() =>
|
||||
setYardModal({
|
||||
station: currentStationObj,
|
||||
isFinal:
|
||||
currentStationObj.sequenceNo ===
|
||||
track.stations[totalStations - 1]?.sequenceNo,
|
||||
alreadyLogged: true,
|
||||
station: nextStation,
|
||||
isFinal: Boolean(nextIsFinal),
|
||||
alreadyLogged: false,
|
||||
})
|
||||
}
|
||||
>
|
||||
Open yard work
|
||||
Yard work
|
||||
</Button>
|
||||
</Group>
|
||||
</Alert>
|
||||
</Stack>
|
||||
) : null}
|
||||
|
||||
{/* forgotten boarders */}
|
||||
{currentStationObj && forgottenBoarders.length > 0 ? (
|
||||
<Stack
|
||||
gap={11}
|
||||
p={16}
|
||||
style={{
|
||||
background: T.amberDim,
|
||||
border: `1px solid ${T.amberBorder}`,
|
||||
borderRadius: 14,
|
||||
}}
|
||||
>
|
||||
<Group gap={9} align="center" wrap="nowrap">
|
||||
<PackageCheck size={16} color={T.amber} style={{ flexShrink: 0 }} />
|
||||
<Text size="13px" fw={700} c={T.amber}>
|
||||
{forgottenBoarders.length} booking
|
||||
{forgottenBoarders.length === 1 ? "" : "s"} not loaded
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="11.5px" c={T.amberText} lh={1.45}>
|
||||
The train is at {currentStationObj.label} — cargo boarding here can still
|
||||
be loaded before the next station is logged.
|
||||
</Text>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
radius={9}
|
||||
variant="white"
|
||||
w="fit-content"
|
||||
styles={{
|
||||
root: { borderColor: T.amberBorder, border: `1px solid ${T.amberBorder}` },
|
||||
label: { color: T.amber, fontWeight: 700, fontSize: 12.5 },
|
||||
}}
|
||||
onClick={() =>
|
||||
setYardModal({
|
||||
station: currentStationObj,
|
||||
isFinal:
|
||||
currentStationObj.sequenceNo ===
|
||||
track.stations[totalStations - 1]?.sequenceNo,
|
||||
alreadyLogged: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
Open yard work
|
||||
</Button>
|
||||
</Stack>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* ── Loading / unloading windows per station ── */}
|
||||
<Paper radius="lg" p="lg" withBorder style={CARD_STYLE}>
|
||||
<SectionHead
|
||||
icon={<Clock size={17} />}
|
||||
title="Loading & unloading windows"
|
||||
hint="Start and end each station's work window — times, duration and who recorded them"
|
||||
/>
|
||||
<Stack gap="sm" mt="md">
|
||||
{track.stations.map((s, i) => {
|
||||
const isFirst = i === 0;
|
||||
const isLast = i === track.stations.length - 1;
|
||||
const workLog = track.stationWorkLogs?.[s.yardId];
|
||||
return (
|
||||
<Paper
|
||||
key={s.yardId}
|
||||
withBorder
|
||||
radius="md"
|
||||
p="sm"
|
||||
style={{
|
||||
background:
|
||||
track.currentSequenceNo === s.sequenceNo
|
||||
? "var(--mantine-color-green-0)"
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<Group gap={10} mb={6} wrap="nowrap">
|
||||
<ThemeIcon size={30} radius="xl" variant="light" color="edr-green">
|
||||
{isLast ? <Flag size={15} /> : <MapPin size={15} />}
|
||||
</ThemeIcon>
|
||||
<Text fw={700} size="sm">
|
||||
{s.label}
|
||||
</Text>
|
||||
{isFirst ? (
|
||||
<Badge size="xs" variant="light" color="edr-green">
|
||||
origin
|
||||
</Badge>
|
||||
) : null}
|
||||
{isLast ? (
|
||||
<Badge size="xs" variant="light" color="gray">
|
||||
destination
|
||||
</Badge>
|
||||
) : null}
|
||||
{track.currentSequenceNo === s.sequenceNo ? (
|
||||
<Badge size="xs" variant="filled" color="edr-green">
|
||||
train here
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
<Stack gap={6} pl={40}>
|
||||
{!isLast ? (
|
||||
<StationWorkControls
|
||||
scheduleId={scheduleId ?? ""}
|
||||
yardId={s.yardId}
|
||||
phase="loading"
|
||||
log={workLog?.loading}
|
||||
/>
|
||||
) : null}
|
||||
{!isFirst ? (
|
||||
<StationWorkControls
|
||||
scheduleId={scheduleId ?? ""}
|
||||
yardId={s.yardId}
|
||||
phase="unloading"
|
||||
log={workLog?.unloading}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* ── Checkpoint log ── */}
|
||||
<Paper radius="lg" p="lg" withBorder style={CARD_STYLE}>
|
||||
<Group justify="space-between" wrap="nowrap" mb="md">
|
||||
<SectionHead
|
||||
icon={<CheckCircle2 size={17} />}
|
||||
title="Checkpoint log"
|
||||
hint={`${track.checkpoints.length} event${
|
||||
track.checkpoints.length === 1 ? "" : "s"
|
||||
} recorded`}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{track.checkpoints.length === 0 ? (
|
||||
<Stack
|
||||
align="center"
|
||||
gap="xs"
|
||||
py={40}
|
||||
style={{
|
||||
borderRadius: 14,
|
||||
border: `1px dashed ${scheduleBrand.mutedBorder}`,
|
||||
background: scheduleBrand.softSurface,
|
||||
}}
|
||||
>
|
||||
<ThemeIcon size={48} radius="xl" variant="light" color="edr-green">
|
||||
<MapPin size={22} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" fw={700} c="gray.7">
|
||||
No checkpoints yet
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" ta="center" maw={320}>
|
||||
Each station the train passes will be logged here with its
|
||||
timestamp.
|
||||
</Text>
|
||||
</Stack>
|
||||
) : (
|
||||
<Timeline
|
||||
active={track.checkpoints.length}
|
||||
bulletSize={24}
|
||||
lineWidth={2}
|
||||
color="edr-green"
|
||||
>
|
||||
{track.checkpoints.map((cp) => (
|
||||
<Timeline.Item
|
||||
key={cp.id}
|
||||
bullet={
|
||||
cp.kind === "ARRIVED" ? (
|
||||
<CheckCircle2 size={13} />
|
||||
) : (
|
||||
<MapPin size={12} />
|
||||
)
|
||||
}
|
||||
title={
|
||||
<Group gap="sm" justify="space-between" wrap="nowrap">
|
||||
<Group gap="sm">
|
||||
<Text fw={700} size="sm">
|
||||
{cp.label ?? `Station ${cp.sequenceNo}`}
|
||||
{/* main column */}
|
||||
<Stack gap={20} style={{ flex: 1, minWidth: 520 }}>
|
||||
<Box style={CARD}>
|
||||
<SectionHead
|
||||
icon={<Route size={17} />}
|
||||
title="Journey & station work"
|
||||
hint={
|
||||
canLog
|
||||
? "Every stop with its pass time and loading windows — the final station marks arrival."
|
||||
: arrived
|
||||
? "This train has arrived at its destination."
|
||||
: "Tracking becomes available once the train is dispatched."
|
||||
}
|
||||
right={
|
||||
<Group gap={12} wrap="nowrap" visibleFrom="md">
|
||||
{[
|
||||
[T.brand, "Passed"],
|
||||
[T.amber, "Active"],
|
||||
[T.text3, "Upcoming"],
|
||||
].map(([color, label]) => (
|
||||
<Group key={label} gap={5} wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: 999,
|
||||
background: color,
|
||||
}}
|
||||
/>
|
||||
<Text size="11px" fw={600} c={T.muted}>
|
||||
{label}
|
||||
</Text>
|
||||
<Badge
|
||||
size="xs"
|
||||
radius="sm"
|
||||
variant="light"
|
||||
color={
|
||||
cp.kind === "ARRIVED"
|
||||
? "teal"
|
||||
: cp.kind === "DEPARTED"
|
||||
? "blue"
|
||||
: "edr-green"
|
||||
}
|
||||
>
|
||||
{cp.kind}
|
||||
</Badge>
|
||||
</Group>
|
||||
{canEdit ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
radius="md"
|
||||
variant="light"
|
||||
color="gray"
|
||||
leftSection={<Pencil size={12} />}
|
||||
onClick={() => setEditModal(cp)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatDateTime(cp.occurredAt)}
|
||||
</Text>
|
||||
{handlingHours(cp) !== null ? (
|
||||
<Text size="xs" c="dimmed" mt={2}>
|
||||
Loading + unloading {handlingHours(cp)} h
|
||||
</Text>
|
||||
) : null}
|
||||
{cp.note ? (
|
||||
<Text size="xs" mt={2}>
|
||||
{cp.note}
|
||||
</Text>
|
||||
) : null}
|
||||
</Timeline.Item>
|
||||
))}
|
||||
</Timeline>
|
||||
)}
|
||||
</Paper>
|
||||
))}
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
<JourneySpine
|
||||
scheduleId={scheduleId}
|
||||
stations={track.stations}
|
||||
currentSequenceNo={track.currentSequenceNo}
|
||||
checkpoints={track.checkpoints}
|
||||
stationWorkLogs={track.stationWorkLogs}
|
||||
canLog={canLog}
|
||||
loggingSeq={
|
||||
recordCheckpoint.isPending
|
||||
? recordCheckpoint.variables?.payload.sequenceNo
|
||||
: null
|
||||
}
|
||||
onLogCheckpoint={handleLog}
|
||||
onEditCheckpoint={canEdit ? setEditModal : undefined}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box style={CARD}>
|
||||
<SectionHead
|
||||
icon={<ListChecks size={16} />}
|
||||
title="Checkpoint log"
|
||||
hint="Raw event trail — every logged pass with its correction history"
|
||||
right={
|
||||
<Chip bg={T.surface3} fg={T.text2}>
|
||||
{`${track.checkpoints.length} EVENT${
|
||||
track.checkpoints.length === 1 ? "" : "S"
|
||||
}`}
|
||||
</Chip>
|
||||
}
|
||||
/>
|
||||
<CheckpointLogTable
|
||||
checkpoints={track.checkpoints}
|
||||
onEdit={canEdit ? setEditModal : undefined}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
<CheckpointTimeModal
|
||||
opened={logModal !== null}
|
||||
@@ -875,6 +603,6 @@ export default function TrainScheduleTrackPage() {
|
||||
isFinal={yardModal?.isFinal ?? false}
|
||||
alreadyLogged={yardModal?.alreadyLogged ?? false}
|
||||
/>
|
||||
</PageContainer>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -135,14 +135,8 @@ export default function TrainScheduleV2DetailPage() {
|
||||
// Actual departure — staff often dispatch on paper first and record it later,
|
||||
// so the time is picked (defaults to now when the dialog opens).
|
||||
const [dispatchAt, setDispatchAt] = useState<Date | null>(null);
|
||||
// Loading is manual: dispatch decides the fate of every unloaded origin
|
||||
// boarder — checked = loaded and departs, unchecked = left behind (wagon
|
||||
// freed, booking back to the pool). Default unchecked; government bookings
|
||||
// cannot be removed from a train so they are forced on.
|
||||
const [dispatchLoadedIds, setDispatchLoadedIds] = useState<Set<string>>(new Set());
|
||||
const openDispatchConfirm = () => {
|
||||
setDispatchAt(new Date());
|
||||
setDispatchLoadedIds(new Set());
|
||||
setDispatchConfirmOpen(true);
|
||||
};
|
||||
const [switchTarget, setSwitchTarget] = useState<EligibleContainerBooking | null>(null);
|
||||
@@ -473,9 +467,8 @@ export default function TrainScheduleV2DetailPage() {
|
||||
// per yard from the track page's log-pass flow. Everything below is advisory.
|
||||
const hasDispatchWarnings =
|
||||
unassignedCount > 0 || unloadedCount > 0 || intercityNotLoadedCount > 0;
|
||||
// Unloaded boarders at the TRAIN's origin — the dispatch dialog's manual
|
||||
// load/leave list. Mirrors the API's unloadedOriginBoarderIds predicate
|
||||
// (plus government, which is shown but forced-loaded).
|
||||
// Unloaded boarders at the TRAIN's origin — all sent as loaded on dispatch.
|
||||
// Mirrors the API's unloadedOriginBoarderIds predicate (plus government).
|
||||
const originYardId = schedule.originStation?.id;
|
||||
const pendingOriginBoarders = dispatchBookings.filter(
|
||||
(b) =>
|
||||
@@ -489,26 +482,18 @@ export default function TrainScheduleV2DetailPage() {
|
||||
// Shipping-line bookings ride from accept on the credit ledger.
|
||||
(Boolean(b.shippingLineCompanyId) && b.status === "FULLY_EXECUTED")),
|
||||
);
|
||||
const dispatchLeftCount = pendingOriginBoarders.filter(
|
||||
(b) => !b.isGovernment && !dispatchLoadedIds.has(b.id),
|
||||
).length;
|
||||
// Origin loading time window: dispatch (which marks the ticked boarders
|
||||
// loaded) is server-rejected until "Start loading" was clicked for the
|
||||
// origin yard, so the button mirrors that gate.
|
||||
// Origin loading time window: dispatch (which marks the boarders loaded)
|
||||
// is server-rejected until "Start loading" was clicked for the origin
|
||||
// yard, so the button mirrors that gate.
|
||||
const originLoadingLog = originYardId
|
||||
? schedule.stationWorkLogs?.[originYardId]?.loading
|
||||
: undefined;
|
||||
const originLoadingStarted = Boolean(originLoadingLog?.startedAt);
|
||||
const originLoadingEnded = Boolean(originLoadingLog?.endedAt);
|
||||
const dispatchBoardersKept = pendingOriginBoarders.some(
|
||||
(b) => b.isGovernment || dispatchLoadedIds.has(b.id),
|
||||
);
|
||||
const dispatchNeedsLoadingStart = dispatchBoardersKept && !originLoadingStarted;
|
||||
// A train never departs mid-loading: once the window opened (or cargo is to
|
||||
// board), it must be ENDED before dispatch — same gate the server enforces.
|
||||
const dispatchNeedsLoadingEnd =
|
||||
(dispatchBoardersKept || originLoadingStarted) && !originLoadingEnded;
|
||||
const dispatchBlockedByLoading = dispatchNeedsLoadingStart || dispatchNeedsLoadingEnd;
|
||||
// Dispatch requires the origin's loading window to be COMPLETE (started AND
|
||||
// ended): not started → disabled, in progress → disabled, ended → active.
|
||||
// Same gate the server enforces.
|
||||
const dispatchBlockedByLoading = !originLoadingEnded;
|
||||
|
||||
const finalizeStep = hasContainerStep ? 3 : 2;
|
||||
const canModifyBookings = canEditBookings && !["DISPATCHED", "ARRIVED"].includes(schedule.status);
|
||||
@@ -562,9 +547,9 @@ export default function TrainScheduleV2DetailPage() {
|
||||
id: scheduleId,
|
||||
payload: {
|
||||
...(dispatchAt ? { actualDepartureAt: dispatchAt.toISOString() } : {}),
|
||||
loadedBookingIds: pendingOriginBoarders
|
||||
.filter((b) => b.isGovernment || dispatchLoadedIds.has(b.id))
|
||||
.map((b) => b.id),
|
||||
// No per-booking ticking in the dispatch dialog: every pending origin
|
||||
// boarder rides — none are left behind at dispatch time.
|
||||
loadedBookingIds: pendingOriginBoarders.map((b) => b.id),
|
||||
},
|
||||
});
|
||||
await openMarshallingDocument({
|
||||
@@ -967,15 +952,11 @@ export default function TrainScheduleV2DetailPage() {
|
||||
phase="loading"
|
||||
log={originLoadingLog}
|
||||
/>
|
||||
{dispatchNeedsLoadingStart ? (
|
||||
{dispatchBlockedByLoading ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
Start loading before dispatching — the ticked bookings are marked
|
||||
loaded at dispatch, which needs an open loading window.
|
||||
</Text>
|
||||
) : dispatchNeedsLoadingEnd ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
End the loading window before dispatching — a train never departs
|
||||
mid-loading.
|
||||
{originLoadingStarted
|
||||
? "End the loading window before dispatching — a train never departs mid-loading."
|
||||
: "Start and end the loading window before dispatching — dispatch needs a completed loading window."}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
@@ -1599,56 +1580,6 @@ export default function TrainScheduleV2DetailPage() {
|
||||
radius="md"
|
||||
/>
|
||||
|
||||
{pendingOriginBoarders.length > 0 ? (
|
||||
<Stack gap={6}>
|
||||
<Text size="sm" fw={700}>
|
||||
Cargo boarding at {schedule.originStation?.label ?? "the origin yard"} —
|
||||
tick what was loaded
|
||||
</Text>
|
||||
{originYardId ? (
|
||||
<StationWorkControls
|
||||
scheduleId={scheduleId}
|
||||
yardId={originYardId}
|
||||
phase="loading"
|
||||
log={originLoadingLog}
|
||||
/>
|
||||
) : null}
|
||||
<Text size="xs" c="dimmed">
|
||||
Unticked bookings are left behind: removed from this train, their
|
||||
wagons freed, and the booking returned to the pool for a later
|
||||
schedule. The customer is notified.
|
||||
</Text>
|
||||
<Stack gap={6} mah={220} style={{ overflowY: "auto" }}>
|
||||
{pendingOriginBoarders.map((b) => (
|
||||
<Checkbox
|
||||
key={b.id}
|
||||
size="sm"
|
||||
checked={b.isGovernment || dispatchLoadedIds.has(b.id)}
|
||||
disabled={b.isGovernment}
|
||||
onChange={(e) => {
|
||||
const next = new Set(dispatchLoadedIds);
|
||||
if (e.currentTarget.checked) next.add(b.id);
|
||||
else next.delete(b.id);
|
||||
setDispatchLoadedIds(next);
|
||||
}}
|
||||
label={
|
||||
<Text size="sm" span>
|
||||
{b.reference ?? b.id.slice(0, 8)} — {b.customer ?? "Unknown customer"}
|
||||
{b.isGovernment ? " (government — always rides)" : ""}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
{dispatchLeftCount > 0 ? (
|
||||
<Text size="xs" c="orange.7" fw={600}>
|
||||
{dispatchLeftCount} booking{dispatchLeftCount === 1 ? "" : "s"} will
|
||||
be left behind and returned to the booking pool.
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
) : null}
|
||||
|
||||
{hasDispatchWarnings ? (
|
||||
<Alert
|
||||
color="orange"
|
||||
@@ -1711,9 +1642,9 @@ export default function TrainScheduleV2DetailPage() {
|
||||
</Button>
|
||||
<Tooltip
|
||||
label={
|
||||
dispatchNeedsLoadingStart
|
||||
? "Start loading at the origin station first — dispatch marks the ticked bookings loaded"
|
||||
: "End the loading window at the origin station first — a train never departs mid-loading"
|
||||
originLoadingStarted
|
||||
? "End the loading window at the origin station first — a train never departs mid-loading"
|
||||
: "Start and end the loading window at the origin station first — dispatch needs a completed loading window"
|
||||
}
|
||||
disabled={!dispatchBlockedByLoading}
|
||||
>
|
||||
|
||||
@@ -941,6 +941,52 @@ export const api = {
|
||||
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
||||
),
|
||||
|
||||
loadScheduleBookingWagon: endpoint<
|
||||
{ scheduleId: string; bookingId: string; allocationId: string },
|
||||
import("@/types/trainScheduling").WagonLoadResult
|
||||
>(
|
||||
"train-scheduling",
|
||||
"booking-wagon-load",
|
||||
({ scheduleId, bookingId, allocationId }) =>
|
||||
trainSchedulingService.loadScheduleBookingWagon(scheduleId, bookingId, allocationId),
|
||||
undefined,
|
||||
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
||||
),
|
||||
|
||||
unloadScheduleBookingWagon: endpoint<
|
||||
{ scheduleId: string; bookingId: string; allocationId: string },
|
||||
import("@/types/trainScheduling").WagonLoadResult
|
||||
>(
|
||||
"train-scheduling",
|
||||
"booking-wagon-unload",
|
||||
({ scheduleId, bookingId, allocationId }) =>
|
||||
trainSchedulingService.unloadScheduleBookingWagon(scheduleId, bookingId, allocationId),
|
||||
undefined,
|
||||
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
||||
),
|
||||
|
||||
bookingWagons: endpoint<
|
||||
{ bookingId: string },
|
||||
import("@/types/trainScheduling").BookingWagonRow[]
|
||||
>(
|
||||
"train-scheduling",
|
||||
"booking-wagons",
|
||||
({ bookingId }) => trainSchedulingService.bookingWagons(bookingId),
|
||||
({ bookingId }) => ["train-scheduling", "booking-wagons", bookingId],
|
||||
),
|
||||
|
||||
cancelRemainingWagons: endpoint<
|
||||
{ bookingId: string; scheduleId: string; reason: string; edrFault?: boolean },
|
||||
unknown
|
||||
>(
|
||||
"train-scheduling",
|
||||
"cancel-remaining-wagons",
|
||||
({ bookingId, scheduleId, reason, edrFault }) =>
|
||||
trainSchedulingService.cancelRemainingWagons(bookingId, { scheduleId, reason, edrFault }),
|
||||
undefined,
|
||||
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
||||
),
|
||||
|
||||
intercityBookings: endpoint<
|
||||
void,
|
||||
import("@/types/trainScheduling").IntercityRideAlongRow[]
|
||||
@@ -2245,11 +2291,14 @@ export const api = {
|
||||
seedComposition,
|
||||
),
|
||||
|
||||
removeWagon: endpoint<{ id: string; wagonId: string }, TrainComposition>(
|
||||
removeWagon: endpoint<
|
||||
{ id: string; wagonId: string; reason?: string },
|
||||
TrainComposition
|
||||
>(
|
||||
"train-builder",
|
||||
"removeWagon",
|
||||
({ id, wagonId }) =>
|
||||
trainBuilderService.removeWagon(id, wagonId).then((r) => r.data),
|
||||
({ id, wagonId, reason }) =>
|
||||
trainBuilderService.removeWagon(id, wagonId, reason).then((r) => r.data),
|
||||
undefined,
|
||||
() => TRAIN_BUILDER_WAGON_INVALIDATIONS,
|
||||
seedComposition,
|
||||
@@ -2276,43 +2325,6 @@ export const api = {
|
||||
({ id }) => trainBuilderService.detachRequests(id).then((r) => r.data),
|
||||
),
|
||||
|
||||
createDetachRequest: endpoint<
|
||||
{ id: string; wagonId: string; action: "DETACH" | "MAINTENANCE"; reason: string },
|
||||
WagonDetachRequestRow
|
||||
>(
|
||||
"train-builder",
|
||||
"createDetachRequest",
|
||||
({ id, wagonId, action, reason }) =>
|
||||
trainBuilderService.createDetachRequest(id, wagonId, { action, reason }).then((r) => r.data),
|
||||
undefined,
|
||||
() => TRAIN_BUILDER_INVALIDATIONS,
|
||||
),
|
||||
|
||||
approveDetachRequest: endpoint<
|
||||
{ id: string; requestId: string; note?: string },
|
||||
TrainComposition
|
||||
>(
|
||||
"train-builder",
|
||||
"approveDetachRequest",
|
||||
({ id, requestId, note }) =>
|
||||
trainBuilderService.approveDetachRequest(id, requestId, note).then((r) => r.data),
|
||||
undefined,
|
||||
() => TRAIN_BUILDER_WAGON_INVALIDATIONS,
|
||||
seedComposition,
|
||||
),
|
||||
|
||||
rejectDetachRequest: endpoint<
|
||||
{ id: string; requestId: string; note: string },
|
||||
TrainComposition
|
||||
>(
|
||||
"train-builder",
|
||||
"rejectDetachRequest",
|
||||
({ id, requestId, note }) =>
|
||||
trainBuilderService.rejectDetachRequest(id, requestId, note).then((r) => r.data),
|
||||
undefined,
|
||||
() => TRAIN_BUILDER_INVALIDATIONS,
|
||||
),
|
||||
|
||||
reorderWagons: endpoint<{ id: string; wagonIds: string[] }, TrainComposition>(
|
||||
"train-builder",
|
||||
"reorderWagons",
|
||||
|
||||
@@ -311,6 +311,11 @@ export interface TrainHistoryEntry {
|
||||
actor: string | null;
|
||||
/** Set when the change came from a trip (schedule); null = train-builder edit. */
|
||||
scheduleReference: string | null;
|
||||
/**
|
||||
* Why the wagon left the consist — required for a detach / maintenance move
|
||||
* on a SCHEDULED run. Null for trip events and unscheduled builder edits.
|
||||
*/
|
||||
reason: string | null;
|
||||
occurredAt: string;
|
||||
}
|
||||
|
||||
@@ -436,37 +441,20 @@ export const trainBuilderService = {
|
||||
}),
|
||||
assignWagons: (id: string, wagonIds: string[]) =>
|
||||
apiClient.post<TrainComposition>(`${BASE}/${id}/wagons`, { wagonIds }),
|
||||
removeWagon: (id: string, wagonId: string) =>
|
||||
apiClient.delete<TrainComposition>(`${BASE}/${id}/wagons/${wagonId}`),
|
||||
/** `reason` is required by the API while the train is on a SCHEDULED run. */
|
||||
removeWagon: (id: string, wagonId: string, reason?: string) =>
|
||||
apiClient.delete<TrainComposition>(`${BASE}/${id}/wagons/${wagonId}`, {
|
||||
data: reason ? { reason } : undefined,
|
||||
}),
|
||||
/** Detach a wagon and move it to MAINTENANCE status. */
|
||||
/** `note` is the maintenance reason — recorded with the train it came off. */
|
||||
sendWagonToMaintenance: (id: string, wagonId: string, note?: string) =>
|
||||
apiClient.post<TrainComposition>(`${BASE}/${id}/wagons/${wagonId}/maintenance`, {
|
||||
note,
|
||||
}),
|
||||
/** Requests to detach a wagon from a SCHEDULED train, newest first. */
|
||||
/** Detach/maintenance audit rows of a SCHEDULED-run train, newest first. */
|
||||
detachRequests: (id: string) =>
|
||||
apiClient.get<WagonDetachRequestRow[]>(`${BASE}/${id}/detach-requests`),
|
||||
/** File a detach/maintenance approval request (reason required). */
|
||||
createDetachRequest: (
|
||||
id: string,
|
||||
wagonId: string,
|
||||
payload: { action: "DETACH" | "MAINTENANCE"; reason: string },
|
||||
) =>
|
||||
apiClient.post<WagonDetachRequestRow>(
|
||||
`${BASE}/${id}/wagons/${wagonId}/detach-requests`,
|
||||
payload,
|
||||
),
|
||||
/** Approve a pending request — executes the detach immediately. */
|
||||
approveDetachRequest: (id: string, requestId: string, note?: string) =>
|
||||
apiClient.post<TrainComposition>(`${BASE}/${id}/detach-requests/${requestId}/approve`, {
|
||||
note,
|
||||
}),
|
||||
/** Reject a pending request — a note explaining why is required. */
|
||||
rejectDetachRequest: (id: string, requestId: string, note: string) =>
|
||||
apiClient.post<TrainComposition>(`${BASE}/${id}/detach-requests/${requestId}/reject`, {
|
||||
note,
|
||||
}),
|
||||
reorderWagons: (id: string, wagonIds: string[]) =>
|
||||
apiClient.post<TrainComposition>(`${BASE}/${id}/reorder-wagons`, { wagonIds }),
|
||||
/** Park the train indefinitely — only allowed with no active schedule. */
|
||||
|
||||
@@ -11,6 +11,8 @@ import type {
|
||||
BookingWindow,
|
||||
AssignBookingsPayload,
|
||||
BookingLoadResult,
|
||||
BookingWagonRow,
|
||||
WagonLoadResult,
|
||||
BookingUnloadResult,
|
||||
CompositionRemovalEntry,
|
||||
DocReviewAlert,
|
||||
@@ -510,6 +512,48 @@ export const trainSchedulingService = {
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
loadScheduleBookingWagon: async (
|
||||
scheduleId: string,
|
||||
bookingId: string,
|
||||
allocationId: string,
|
||||
): Promise<WagonLoadResult> => {
|
||||
const response = await client.post<WagonLoadResult>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.BOOKING_WAGON_LOAD(scheduleId, bookingId, allocationId),
|
||||
{},
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
unloadScheduleBookingWagon: async (
|
||||
scheduleId: string,
|
||||
bookingId: string,
|
||||
allocationId: string,
|
||||
): Promise<WagonLoadResult> => {
|
||||
const response = await client.post<WagonLoadResult>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.BOOKING_WAGON_UNLOAD(scheduleId, bookingId, allocationId),
|
||||
{},
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
bookingWagons: async (bookingId: string): Promise<BookingWagonRow[]> => {
|
||||
const response = await client.get<BookingWagonRow[]>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.BOOKING_WAGONS(bookingId),
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
cancelRemainingWagons: async (
|
||||
bookingId: string,
|
||||
payload: { scheduleId: string; reason: string; edrFault?: boolean },
|
||||
): Promise<unknown> => {
|
||||
const response = await client.post(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.CANCEL_REMAINING_WAGONS(bookingId),
|
||||
payload,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
listIntercityBookings: async (): Promise<
|
||||
import("@/types/trainScheduling").IntercityRideAlongRow[]
|
||||
> => {
|
||||
|
||||
@@ -1206,6 +1206,34 @@ export interface BookingLoadResult {
|
||||
loadedAt: string;
|
||||
}
|
||||
|
||||
/** Per-wagon load/unload confirmation; `completed` = the booking finished with it. */
|
||||
export interface WagonLoadResult {
|
||||
bookingId: string;
|
||||
allocationId: string;
|
||||
status: string;
|
||||
loadedWagons?: number;
|
||||
unloadedWagons?: number;
|
||||
totalWagons: number;
|
||||
completed: boolean;
|
||||
}
|
||||
|
||||
/** One allocated wagon of a booking, from GET /bookings/:id/wagons. */
|
||||
export interface BookingWagonRow {
|
||||
allocationId: string;
|
||||
sequenceNo: number | null;
|
||||
wagonNumber: string | null;
|
||||
wagonType: string | null;
|
||||
wagonTypeCode: string | null;
|
||||
allocatedWeightTons: number | string | null;
|
||||
loadType: string | null;
|
||||
status: string;
|
||||
containers: Array<{
|
||||
containerNumber: string | null;
|
||||
sizeFt: number | null;
|
||||
grossWeightTons: number | string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface BookingUnloadResult {
|
||||
bookingId: string;
|
||||
/** 'ARRIVED' for import/export, 'COMPLETED' for intercity. */
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
@@ -41,6 +42,7 @@ import {
|
||||
FileUp,
|
||||
Flame,
|
||||
MapPin,
|
||||
MoveRight,
|
||||
Package,
|
||||
Receipt,
|
||||
Repeat,
|
||||
@@ -237,6 +239,19 @@ export default function NewShipmentPage() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Heaviest load one wagon of this contract's bulk cargo may take, as computed
|
||||
* by the API from the cargo type's allowed wagon types. Undefined when no
|
||||
* wagon type is configured — the wagon-count check then falls away.
|
||||
*/
|
||||
function bulkMaxTonsPerWagon(
|
||||
contract: Freight.IContract,
|
||||
): number | null | undefined {
|
||||
return contract.cargoScope?.find(
|
||||
(scope) => scope.cargoType?.maxTonsPerWagon != null,
|
||||
)?.cargoType?.maxTonsPerWagon;
|
||||
}
|
||||
|
||||
function bulkUnitOfMeasure(
|
||||
contract: Freight.IContract,
|
||||
): "PER_TON" | "PER_ITEM" | "NUMBER_OF_WAGONS" {
|
||||
@@ -434,6 +449,7 @@ function NewShipmentBookingForm({
|
||||
contract.freightType === "CONTAINER" &&
|
||||
contract.equipmentReturn === "WITH_RETURN",
|
||||
unitOfMeasure: bulkUnitOfMeasure(contract),
|
||||
maxTonsPerWagon: bulkMaxTonsPerWagon(contract),
|
||||
// Intercity rides a passing train staff pick later — no date to choose.
|
||||
requiresDate: contract.tradeDirection !== "DOMESTIC",
|
||||
// Export completion locks onto a specific train — the pick is required
|
||||
@@ -688,20 +704,20 @@ function NewShipmentBookingForm({
|
||||
<Title
|
||||
order={1}
|
||||
fw={800}
|
||||
fz={26}
|
||||
fz={28}
|
||||
style={{ letterSpacing: "-0.01em" }}
|
||||
>
|
||||
{completeBookingId
|
||||
? isResubmit
|
||||
? "Change Your Booking"
|
||||
: "Complete Your Booking"
|
||||
? "Change shipment booking"
|
||||
: "Complete shipment booking"
|
||||
: "New Shipment Booking"}
|
||||
</Title>
|
||||
<Text size="sm" c="edr-muted" mt={4}>
|
||||
{completeBookingId
|
||||
? isResubmit
|
||||
? `Update the details below and pick a new shipment day, then resubmit your booking under contract ${contract.reference}.`
|
||||
: `Clearance is finalized — enter the cargo details and shipment day to complete your booking under contract ${contract.reference}.`
|
||||
: `Clearance is finalized. Enter cargo details and the binding shipment day to complete ${completeBooking?.reference ?? "this booking"} under contract ${contract.reference}.`
|
||||
: `Book a shipment against contract ${contract.reference}.`}
|
||||
</Text>
|
||||
</Box>
|
||||
@@ -791,17 +807,6 @@ function NewShipmentBookingForm({
|
||||
}}
|
||||
>
|
||||
<Box className="mx-auto max-w-4xl">
|
||||
{showValidationSummary ? (
|
||||
<Alert
|
||||
color="red"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<AlertCircle size={16} />}
|
||||
mb="sm"
|
||||
>
|
||||
Fix the highlighted fields before reviewing the price.
|
||||
</Alert>
|
||||
) : null}
|
||||
{blockOdd20ft ? (
|
||||
<Alert
|
||||
color="red"
|
||||
@@ -823,16 +828,42 @@ function NewShipmentBookingForm({
|
||||
{`${ft20Total} is an odd number of 20ft containers — this booking will be paired with another customer's odd booking to share a wagon, or held until one is available.`}
|
||||
</Alert>
|
||||
) : null}
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
type="button"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Receipt size={16} />}
|
||||
onClick={handleReview}
|
||||
>
|
||||
{isResubmit ? "Change booking" : "Review price & book"}
|
||||
</Button>
|
||||
<Group justify="space-between" wrap="nowrap" gap="md">
|
||||
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
{showValidationSummary && (
|
||||
<>
|
||||
<AlertCircle
|
||||
size={15}
|
||||
color="#C0392B"
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
<Text fz={13} fw={500} c="#C0392B">
|
||||
Fix the highlighted fields to review the price.
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
radius="md"
|
||||
onClick={() =>
|
||||
navigate(`/contracts/${contract.id}`)
|
||||
}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Receipt size={16} />}
|
||||
onClick={handleReview}
|
||||
>
|
||||
{isResubmit ? "Change booking" : "Review price & book"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -1212,15 +1243,45 @@ function RouteStep({
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Paper withBorder radius="md" p="md" style={{ borderColor: "#E6ECF2" }}>
|
||||
<Text fz={14} fw={600} c="#10202F">
|
||||
{routes[0]?.originYard?.label ?? "—"} →{" "}
|
||||
{routes[0]?.destinationYard?.label ?? "—"}
|
||||
</Text>
|
||||
<Text fz={12} c="dimmed" mt={2}>
|
||||
<Group
|
||||
wrap="nowrap"
|
||||
gap={16}
|
||||
align="center"
|
||||
px={18}
|
||||
py={18}
|
||||
style={{
|
||||
borderRadius: 14,
|
||||
border: "1px solid #E6ECF2",
|
||||
background: "#FBFCFD",
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Text fz={15} fw={700} c="#10202F">
|
||||
{routes[0]?.originYard?.label ?? "—"}
|
||||
</Text>
|
||||
<Text fz={12} c="#6B7C8E" mt={2}>
|
||||
Origin yard
|
||||
</Text>
|
||||
</Box>
|
||||
<MoveRight size={20} color="#0A6F4D" style={{ flexShrink: 0 }} />
|
||||
<Box>
|
||||
<Text fz={15} fw={700} c="#10202F">
|
||||
{routes[0]?.destinationYard?.label ?? "—"}
|
||||
</Text>
|
||||
<Text fz={12} c="#6B7C8E" mt={2}>
|
||||
Destination yard
|
||||
</Text>
|
||||
</Box>
|
||||
<Box style={{ flex: 1 }} />
|
||||
<Badge
|
||||
variant="light"
|
||||
color="teal"
|
||||
radius={8}
|
||||
styles={{ label: { fontSize: 12, fontWeight: 600 } }}
|
||||
>
|
||||
{contract.tradeDirection}
|
||||
</Text>
|
||||
</Paper>
|
||||
</Badge>
|
||||
</Group>
|
||||
)}
|
||||
</StepCard>
|
||||
);
|
||||
@@ -1300,8 +1361,16 @@ function ScheduleStep({
|
||||
const selectedTrainId = form.watch("trainScheduleId");
|
||||
const isExportPick =
|
||||
contract.tradeDirection === "EXPORT" && Boolean(completeBookingId);
|
||||
const requestedWagonsValue = form.watch("requestedWagons");
|
||||
const wagonsEstimate = useMemo(() => {
|
||||
if (contract.freightType !== "CONTAINER") return undefined;
|
||||
// NUMBER_OF_WAGONS bulk states its wagon count outright — pass it through
|
||||
// so the train picker sizes fits/free against the real need instead of
|
||||
// falling back to the server's tonnage estimate.
|
||||
if (contract.freightType !== "CONTAINER") {
|
||||
if (bulkUnitOfMeasure(contract) !== "NUMBER_OF_WAGONS") return undefined;
|
||||
const wagons = Math.floor(Number(requestedWagonsValue || 0));
|
||||
return wagons >= 1 ? wagons : undefined;
|
||||
}
|
||||
const lines = containerLines ?? [];
|
||||
const ft20 = lines
|
||||
.filter((l) => l.containerSize === "20ft")
|
||||
@@ -1311,7 +1380,7 @@ function ScheduleStep({
|
||||
.reduce((s, l) => s + Number(l.quantity || 0), 0);
|
||||
const wagons = Math.ceil(ft20 / 2) + ft40;
|
||||
return wagons > 0 ? wagons : undefined;
|
||||
}, [contract.freightType, containerLines]);
|
||||
}, [contract, containerLines, requestedWagonsValue]);
|
||||
const exportTrainsQuery = useQuery({
|
||||
...api.bookings.getExportTrains.queryOptions({
|
||||
input: {
|
||||
|
||||
@@ -25,6 +25,13 @@ export interface ShipmentValidationContext {
|
||||
*/
|
||||
withReturnService?: boolean;
|
||||
unitOfMeasure?: "PER_TON" | "PER_ITEM" | "NUMBER_OF_WAGONS";
|
||||
/**
|
||||
* NUMBER_OF_WAGONS: the most tons one wagon of this cargo may carry. The
|
||||
* requested count must spread the tonnage no heavier than this, or the
|
||||
* server rejects the booking (assertWagonShareFits). Undefined when the
|
||||
* cargo type has no wagon type configured — the check then falls away.
|
||||
*/
|
||||
maxTonsPerWagon?: number | null;
|
||||
/**
|
||||
* Intercity (DOMESTIC) shipments ride a passing import/export train that
|
||||
* staff pick later, so no shipment day is chosen. Defaults to true.
|
||||
@@ -312,6 +319,23 @@ export function createShipmentFormSchema(ctx: ShipmentValidationContext) {
|
||||
path: ["requestedWagons"],
|
||||
message: "Enter the number of wagons needed (at least 1).",
|
||||
});
|
||||
} else {
|
||||
// Too few wagons for the tonnage can never ride: 200T across 3
|
||||
// wagons is 66.67T each on a 50T wagon. Mirrors the server's
|
||||
// assertWagonShareFits so the button blocks before the API 400s.
|
||||
const tons = Number(data.cargoWeightTons || 0);
|
||||
const maxPerWagon = Number(ctx.maxTonsPerWagon || 0);
|
||||
if (tons > 0 && maxPerWagon > 0 && tons / wagons > maxPerWagon) {
|
||||
refineCtx.addIssue({
|
||||
code: "custom",
|
||||
path: ["requestedWagons"],
|
||||
message:
|
||||
`${tons} tons across ${wagons} wagon${wagons === 1 ? "" : "s"} loads ` +
|
||||
`${Math.round((tons / wagons) * 1000) / 1000}T per wagon, but a wagon of ` +
|
||||
`this cargo carries at most ${maxPerWagon}T — request at least ` +
|
||||
`${Math.ceil(tons / maxPerWagon)} wagons.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
"helmet": "^8.0.0",
|
||||
"jose": "^5.10.0",
|
||||
"minio": "7.1.3",
|
||||
"multer": "^2.1.1",
|
||||
"pg": "^8.21.0",
|
||||
"qrcode": "^1.5.3",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "TrainSchedule" ADD COLUMN IF NOT EXISTS "isGroupBookingOnly" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "TravelPackage" ADD COLUMN IF NOT EXISTS "imageUrl" TEXT;
|
||||
@@ -372,6 +372,7 @@ model TrainSchedule {
|
||||
carbonRating String @default("A")
|
||||
notes String?
|
||||
isPackageOnly Boolean @default(false)
|
||||
isGroupBookingOnly Boolean @default(false)
|
||||
train Train @relation(fields: [trainId], references: [id])
|
||||
route Route? @relation(fields: [routeId], references: [id])
|
||||
originStation Station @relation("OriginTrips", fields: [originStationId], references: [id])
|
||||
@@ -1529,6 +1530,7 @@ model TravelPackage {
|
||||
code String @unique
|
||||
name String
|
||||
description String?
|
||||
imageUrl String?
|
||||
status PackageStatus @default(DRAFT)
|
||||
outboundScheduleId String
|
||||
returnScheduleId String
|
||||
|
||||
@@ -3,6 +3,7 @@ import { APP_FILTER } from "@nestjs/core";
|
||||
import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||
import { ScheduleModule } from "@nestjs/schedule";
|
||||
import { EventEmitterModule } from "@nestjs/event-emitter";
|
||||
import { ThrottlerModule } from "@nestjs/throttler";
|
||||
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
|
||||
import { IamBaselineSeeder, IamSeedModule } from "@edr/iam-seed";
|
||||
import { IamModule as TriaIamModule } from "@tria-plc/iamapi-common/iam.module";
|
||||
@@ -82,6 +83,17 @@ import { EOtpType } from "@tria-plc/iamapi-common";
|
||||
}),
|
||||
ScheduleModule.forRoot(),
|
||||
EventEmitterModule.forRoot(),
|
||||
// Named tiers only — no APP_GUARD is registered, so nothing is throttled until a
|
||||
// controller opts in with @UseGuards(ThrottlerGuard). AuthController is currently the
|
||||
// only one that does, because the staged sign-in exposes an account-existence lookup.
|
||||
ThrottlerModule.forRoot([
|
||||
// 20/min, not the 5/min the commented-out decorators suggested: the staged sign-in
|
||||
// legitimately costs 3-5 calls (lookup → request code → resend → complete → a retry
|
||||
// after a typo), and the throttler keys on IP, so users sharing a NAT or mobile CGNAT
|
||||
// address share the budget. 5 would lock real passengers out.
|
||||
{ name: "auth", limit: 20, ttl: 60_000 },
|
||||
{ name: "strict", limit: 20, ttl: 60_000 },
|
||||
]),
|
||||
TypeOrmModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService): TypeOrmModuleOptions =>
|
||||
@@ -97,8 +109,11 @@ import { EOtpType } from "@tria-plc/iamapi-common";
|
||||
`Your EDR Passenger phone verification code is ${otp}. It will expire in 5 minutes.`,
|
||||
[EOtpType.RESET_PASSWORD]: ({ route }) =>
|
||||
`Reset your EDR Passenger password using this link: ${route}`,
|
||||
[EOtpType.SET_PASSWORD]: ({ route }) =>
|
||||
`Set your EDR Passenger password using this link: ${route}`,
|
||||
// Carries the bare code as well as the link: the staged sign-in asks for the code
|
||||
// inline, while the link is still what a `/set-password` deep link from an older SMS
|
||||
// relies on. `OtpMessageContext` supplies both.
|
||||
[EOtpType.SET_PASSWORD]: ({ otp, route }) =>
|
||||
`Your EDR Passenger code is ${otp}. Or set your password here: ${route}`,
|
||||
},
|
||||
}),
|
||||
// Replaces the package's DataSeeder. Shared with edr-freight-api, which
|
||||
|
||||
77
apps/edr-passenger-api/src/common/utils/phone.utils.ts
Normal file
77
apps/edr-passenger-api/src/common/utils/phone.utils.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Phone normalisation shared by any lookup that has to match a number a customer typed
|
||||
* against one already stored. Ethiopian numbers reach us in three interchangeable shapes
|
||||
* (+2519…, 2519…, 09…) depending on whether they came from IAM, a guest booking form or a
|
||||
* saved profile, so an exact-string match silently misses.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Returns all plausible normalised variants of a raw phone string so that the
|
||||
* DB query matches regardless of how the number was stored (local 09… vs international +251…).
|
||||
* Returns an empty array when the input is clearly invalid (< 7 digits).
|
||||
*/
|
||||
export function normalizePhoneVariants(raw: string): string[] {
|
||||
// Strip whitespace, dashes, dots, parentheses — keep digits and a leading +
|
||||
const stripped = raw.replace(/[^\d+]/g, '');
|
||||
const digits = stripped.replace(/^\+/, '');
|
||||
if (digits.length < 7) return [];
|
||||
|
||||
const variants = new Set<string>([stripped]);
|
||||
|
||||
if (stripped.startsWith('+251') && digits.length === 12) {
|
||||
// +251 9XXXXXXXX → 251 9XXXXXXXX (no +) → 09XXXXXXXX
|
||||
variants.add(digits); // 251XXXXXXXXX
|
||||
variants.add('0' + digits.slice(3)); // 09XXXXXXXXX
|
||||
} else if (stripped.startsWith('251') && digits.length === 12) {
|
||||
// 251 9XXXXXXXX → +251 9XXXXXXXX → 09XXXXXXXX
|
||||
variants.add('+' + stripped); // +251XXXXXXXXX
|
||||
variants.add('0' + digits.slice(3)); // 09XXXXXXXXX
|
||||
} else if (stripped.startsWith('0') && digits.length === 10) {
|
||||
// 09XXXXXXXX → +251 9XXXXXXXX → 251 9XXXXXXXX (no +)
|
||||
variants.add('+251' + digits.slice(1)); // +251XXXXXXXXX
|
||||
variants.add('251' + digits.slice(1)); // 251XXXXXXXXX
|
||||
} else if (!stripped.startsWith('+') && digits.length >= 9) {
|
||||
// bare international digits without +
|
||||
variants.add('+' + digits);
|
||||
}
|
||||
|
||||
return [...variants];
|
||||
}
|
||||
|
||||
/**
|
||||
* A sign-in identifier is a single free-text field: the passenger types either an email
|
||||
* address or a phone number and the server works out which. Phone is the default reading —
|
||||
* an email must contain an `@` with something either side of it, everything else is treated
|
||||
* as a number so that malformed emails don't silently fall through to a phone lookup that
|
||||
* can never match.
|
||||
*/
|
||||
export type ResolvedIdentifier = {
|
||||
kind: 'email' | 'phone';
|
||||
/** Lower-cased email, or null when the input is a phone number. */
|
||||
email: string | null;
|
||||
/** Every stored shape the number could have, or [] when the input is an email. */
|
||||
phoneVariants: string[];
|
||||
};
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
export function resolveIdentifier(raw: string): ResolvedIdentifier {
|
||||
const trimmed = raw.trim();
|
||||
if (EMAIL_RE.test(trimmed)) {
|
||||
return { kind: 'email', email: trimmed.toLowerCase(), phoneVariants: [] };
|
||||
}
|
||||
return { kind: 'phone', email: null, phoneVariants: normalizePhoneVariants(trimmed) };
|
||||
}
|
||||
|
||||
/**
|
||||
* `+251912345678` → `+2519****678`. Shown on the OTP screen so the passenger can tell which
|
||||
* number the code went to without the server handing back the full number to an unauthenticated
|
||||
* caller.
|
||||
*/
|
||||
export function maskPhone(phone: string): string {
|
||||
const stripped = phone.replace(/[^\d+]/g, '');
|
||||
if (stripped.length <= 7) return stripped;
|
||||
const head = stripped.slice(0, stripped.startsWith('+') ? 5 : 4);
|
||||
const tail = stripped.slice(-3);
|
||||
return `${head}${'*'.repeat(4)}${tail}`;
|
||||
}
|
||||
@@ -4,9 +4,11 @@
|
||||
import "dotenv/config";
|
||||
import "reflect-metadata";
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Logger, ValidationPipe, VersioningType } from "@nestjs/common";
|
||||
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
|
||||
import helmet from "helmet";
|
||||
import { join } from "path";
|
||||
import { AppModule } from "./app.module";
|
||||
import { HttpExceptionFilter } from "./common/filters/http-exception.filter";
|
||||
import { ResponseTransformInterceptor } from "./common/interceptors/response-transform.interceptor";
|
||||
@@ -23,10 +25,20 @@ if (process.env.NODE_ENV === 'production' && process.env.WAAFI_INSECURE_TLS ===
|
||||
async function bootstrap() {
|
||||
// rawBody: true buffers the unparsed request body onto req.rawBody so webhook handlers
|
||||
// (e.g. Waafi HMAC verification) can sign over the exact bytes the provider signed.
|
||||
const app = await NestFactory.create(AppModule, { rawBody: true });
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, { rawBody: true });
|
||||
|
||||
// Security headers
|
||||
app.use(helmet());
|
||||
// Security headers. crossOriginResourcePolicy defaults to 'same-origin' in helmet, which
|
||||
// would make browsers refuse to actually render package images (served from this origin)
|
||||
// inside <img> tags on the portal/backoffice (different origins) even though the request
|
||||
// itself succeeds — relaxed to 'cross-origin' since this API already serves all its JSON to
|
||||
// those exact same origins per the CORS allowlist below; nothing new is being exposed.
|
||||
app.use(helmet({ crossOriginResourcePolicy: { policy: "cross-origin" } }));
|
||||
|
||||
// Serves apps/edr-passenger-api/public/* at the site root — package images live at
|
||||
// public/uploads/packages/<file>, reachable as GET /uploads/packages/<file>. Local-disk
|
||||
// storage is a deliberate, explicit stopgap (see packages.service.ts's uploadImage) rather
|
||||
// than this app's usual MinIO-backed upload pattern (see modules/support's attachments).
|
||||
app.useStaticAssets(join(__dirname, "..", "public"));
|
||||
|
||||
// URI versioning: the @tria-plc IAM controllers declare `version: "1"` so they register under
|
||||
// `/v1/...` (e.g. /v1/auth/login). Passenger controllers declare no version, so they stay
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
ApiBearerAuth,
|
||||
} from "@nestjs/swagger";
|
||||
import { IsPublic } from "@tria-plc/api-common/modules/auth/decorators/public.decorator";
|
||||
import { Throttle, ThrottlerGuard } from "@nestjs/throttler";
|
||||
import { PassengerAuthService } from "./passenger-auth.service";
|
||||
import {
|
||||
RegisterDto,
|
||||
@@ -28,12 +29,19 @@ import {
|
||||
ResendRegistrationCodeDto,
|
||||
FaydaRequestPasswordSetupDto,
|
||||
FaydaVerifyAndLoginDto,
|
||||
IdentifierLookupDto,
|
||||
PasswordSetupRequestDto,
|
||||
PasswordSetupCompleteDto,
|
||||
} from "./auth.dto";
|
||||
import { JwtGuard } from "../../common/jwt.guard";
|
||||
|
||||
@ApiTags("Passenger Auth")
|
||||
@Controller("auth")
|
||||
// @Throttle({ auth: { limit: 5, ttl: 60_000 } })
|
||||
// Scoped to this controller rather than registered as a global APP_GUARD: the staged sign-in
|
||||
// exposes an account-existence lookup, and rate limiting is the mitigation for it. Applying the
|
||||
// guard app-wide would change the behaviour of every other module at the same time.
|
||||
@UseGuards(ThrottlerGuard)
|
||||
@Throttle({ auth: { limit: 20, ttl: 60_000 } })
|
||||
export class AuthController {
|
||||
constructor(private passengerAuthService: PassengerAuthService) {}
|
||||
|
||||
@@ -189,6 +197,63 @@ export class AuthController {
|
||||
return this.passengerAuthService.resetUserPassword(id, body.tempPassword);
|
||||
}
|
||||
|
||||
@Post("identifier/lookup")
|
||||
@IsPublic()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
// Tighter than the rest of the controller: this is the endpoint that answers "does this
|
||||
// account exist", so it is the one worth making expensive to sweep. Still roomy enough
|
||||
// that a passenger correcting a typo two or three times is unaffected.
|
||||
@Throttle({ auth: { limit: 10, ttl: 60_000 } })
|
||||
@ApiOperation({
|
||||
summary: "Step 1 of sign-in — decide what to ask the user for next",
|
||||
description:
|
||||
"Takes a phone number or an email and reports whether the account exists and whether it " +
|
||||
"already has a password. PASSWORD → ask for the password. NEEDS_PASSWORD_SETUP → send a " +
|
||||
"code and let them set one. NOT_FOUND → sign them up.",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "{ status, method?, maskedPhone? } — never returns email or user id",
|
||||
})
|
||||
@ApiBody({ type: IdentifierLookupDto })
|
||||
lookupIdentifier(@Body() dto: IdentifierLookupDto) {
|
||||
return this.passengerAuthService.lookupIdentifier(dto.identifier);
|
||||
}
|
||||
|
||||
@Post("password-setup/request")
|
||||
@IsPublic()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: "Send the SMS code that lets an account with no password set one",
|
||||
description:
|
||||
"Covers Fayda-created accounts and abandoned registrations alike. Always returns " +
|
||||
"{ sent: true } regardless of whether the account exists.",
|
||||
})
|
||||
@ApiResponse({ status: 200, description: "{ sent: true }" })
|
||||
@ApiBody({ type: PasswordSetupRequestDto })
|
||||
requestPasswordSetup(@Body() dto: PasswordSetupRequestDto, @Request() req: any) {
|
||||
return this.passengerAuthService.requestPasswordSetup(dto.identifier, req);
|
||||
}
|
||||
|
||||
@Post("password-setup/complete")
|
||||
@IsPublic()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: "Redeem the code, set the password, and sign in",
|
||||
description:
|
||||
"Accepts both set-password codes (from password-setup/request) and verify-phone-number " +
|
||||
"codes (from POST /auth/register), so one screen finishes both branches.",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Same shape as POST /auth/login — token, refreshToken and user.",
|
||||
})
|
||||
@ApiResponse({ status: 401, description: "Invalid or expired code" })
|
||||
@ApiBody({ type: PasswordSetupCompleteDto })
|
||||
completePasswordSetup(@Body() dto: PasswordSetupCompleteDto) {
|
||||
return this.passengerAuthService.completePasswordSetup(dto);
|
||||
}
|
||||
|
||||
@Post("fayda/request-password-setup")
|
||||
@IsPublic()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { IsEmail, IsNotEmpty, IsString, ValidateNested } from 'class-validator';
|
||||
import {
|
||||
IsEmail,
|
||||
IsNotEmpty,
|
||||
IsString,
|
||||
IsStrongPassword,
|
||||
Length,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
@@ -75,3 +82,55 @@ export class FaydaVerifyAndLoginDto {
|
||||
@IsString()
|
||||
otp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 1 of the staged sign-in. One field: the passenger types either their phone number
|
||||
* or their email and the server decides which of the three branches follows.
|
||||
*/
|
||||
export class IdentifierLookupDto {
|
||||
@ApiProperty({
|
||||
example: '+251912345678',
|
||||
description: 'Phone number or email address — the server detects which',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
identifier: string;
|
||||
}
|
||||
|
||||
/** Step 2a: ask for the SMS code that lets an account with no password set one. */
|
||||
export class PasswordSetupRequestDto {
|
||||
@ApiProperty({ example: '+251912345678', description: 'Phone number or email address' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
identifier: string;
|
||||
}
|
||||
|
||||
/** Step 2b: redeem the code, set the password, and receive a session in one call. */
|
||||
export class PasswordSetupCompleteDto {
|
||||
@ApiProperty({ example: '+251912345678', description: 'Phone number or email address' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
identifier: string;
|
||||
|
||||
@ApiProperty({ example: '123456', description: '6-digit code received via SMS' })
|
||||
@IsString()
|
||||
@Length(4, 10)
|
||||
otp: string;
|
||||
|
||||
// The credential is written directly against iam.user_credentials rather than through
|
||||
// the IAM's own set-password route, so the IAM's @IsStrongPassword rule has to be
|
||||
// restated here or weak passwords would slip in unvalidated.
|
||||
@ApiProperty({ example: 'Str0ng!Pass', format: 'password' })
|
||||
@IsStrongPassword({
|
||||
minLength: 8,
|
||||
minLowercase: 1,
|
||||
minUppercase: 1,
|
||||
minNumbers: 1,
|
||||
minSymbols: 1,
|
||||
})
|
||||
newPassword: string;
|
||||
|
||||
@ApiProperty({ example: 'Str0ng!Pass', format: 'password' })
|
||||
@IsString()
|
||||
confirmPassword: string;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
ConflictException,
|
||||
InternalServerErrorException,
|
||||
@@ -13,7 +14,22 @@ import { AuthService as IamAuthService } from '@tria-plc/iamapi-common/module/au
|
||||
import { EUserType } from '@tria-plc/api-common/utils/enums/user.enum';
|
||||
import { EOtpType } from '@tria-plc/iamapi-common/enums/otp.enum';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { RegisterDto, LoginDto } from './auth.dto';
|
||||
import { RegisterDto, LoginDto, PasswordSetupCompleteDto } from './auth.dto';
|
||||
import { maskPhone, resolveIdentifier } from '../../common/utils/phone.utils';
|
||||
|
||||
/**
|
||||
* The `iam.users` columns every sign-in branch needs. Kept separate from `IamUserRow`
|
||||
* (which is profile-shaped) because the auth branches key off credential state, not metadata.
|
||||
*/
|
||||
type IamAuthRow = {
|
||||
id: string;
|
||||
email: string | null;
|
||||
name: { en: string; am: string } | null;
|
||||
username: string;
|
||||
phone_number: string | null;
|
||||
has_set_password: boolean;
|
||||
verified_by: string | null;
|
||||
};
|
||||
|
||||
type IamUserRow = {
|
||||
id: string;
|
||||
@@ -170,9 +186,19 @@ export class PassengerAuthService {
|
||||
async login(dto: LoginDto, req: any) {
|
||||
const iamAuthService = await this.resolveIamAuthService(req);
|
||||
|
||||
// `dto.email` may hold an email OR a phone number, in any of the shapes a passenger might
|
||||
// type. Resolve it to the exact string the IAM stores before handing it over: the IAM
|
||||
// matches the identifier literally, so someone entering `0912…` for a number stored as
|
||||
// `+2519…` would be told their credentials are invalid despite a correct password.
|
||||
const known = await this.findUserByIdentifier(dto.email);
|
||||
const loginIdentifier = known?.email ?? known?.phone_number ?? dto.email;
|
||||
|
||||
let iamResult: { token: string; refreshToken: string } | { mfaRequired: boolean };
|
||||
try {
|
||||
iamResult = await iamAuthService.login({ email: dto.email, password: dto.password });
|
||||
iamResult = await iamAuthService.login({
|
||||
email: loginIdentifier,
|
||||
password: dto.password,
|
||||
});
|
||||
} catch {
|
||||
this.eventEmitter.emit('auth.login.failed', { email: dto.email });
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
@@ -184,14 +210,16 @@ export class PassengerAuthService {
|
||||
|
||||
const { token, refreshToken } = iamResult as { token: string; refreshToken: string };
|
||||
|
||||
// `dto.email` may hold an email OR a phone number (passengers without an email log in
|
||||
// with their phone). Match on either so the post-auth lookup works regardless of which
|
||||
// identifier was used.
|
||||
const iamRows = await this.dataSource.query<IamUserRow[]>(
|
||||
`SELECT id, email, name, phone_number, metadata FROM iam.users WHERE email = $1 OR phone_number = $1 LIMIT 1`,
|
||||
[dto.email],
|
||||
);
|
||||
const iamUser = iamRows[0];
|
||||
// `known` is the same row the identifier resolved to; only fall back to a fresh lookup if
|
||||
// the resolve missed but the IAM authenticated anyway.
|
||||
let iamUser: { id: string; email: string | null } | null = known;
|
||||
if (!iamUser) {
|
||||
const iamRows = await this.dataSource.query<IamUserRow[]>(
|
||||
`SELECT id, email, name, phone_number, metadata FROM iam.users WHERE email = $1 OR phone_number = $1 LIMIT 1`,
|
||||
[loginIdentifier],
|
||||
);
|
||||
iamUser = iamRows[0] ?? null;
|
||||
}
|
||||
if (!iamUser) {
|
||||
throw new InternalServerErrorException('IAM user not found after successful authentication');
|
||||
}
|
||||
@@ -496,19 +524,26 @@ export class PassengerAuthService {
|
||||
}
|
||||
|
||||
async resetUserPassword(id: string, tempPassword: string) {
|
||||
await this.writeActiveCredential(id, tempPassword);
|
||||
return { success: true, message: 'Password reset successfully' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the user's active credential. The IAM keeps credential history and relies on
|
||||
* exactly one row per user having `is_active = true`, so the old row is deactivated in the
|
||||
* same call rather than deleted.
|
||||
*/
|
||||
private async writeActiveCredential(userId: string, password: string): Promise<void> {
|
||||
const { hashPassword } = await import('@tria-plc/api-common/utils/argon');
|
||||
const passwordHash = await hashPassword(tempPassword);
|
||||
// Deactivate existing credentials first (IAM keeps history, only one active at a time)
|
||||
const passwordHash = await hashPassword(password);
|
||||
await this.dataSource.query(
|
||||
`UPDATE iam.user_credentials SET is_active = false WHERE user_id = $1 AND is_active = true`,
|
||||
[id],
|
||||
[userId],
|
||||
);
|
||||
// Insert new active credential
|
||||
await this.dataSource.query(
|
||||
`INSERT INTO iam.user_credentials (user_id, password, is_active) VALUES ($1, $2, true)`,
|
||||
[id, passwordHash],
|
||||
[userId, passwordHash],
|
||||
);
|
||||
return { success: true, message: 'Password reset successfully' };
|
||||
}
|
||||
|
||||
async requestFaydaPasswordSetup(phoneNumber: string, req: any): Promise<{ sent: boolean }> {
|
||||
@@ -554,15 +589,45 @@ export class PassengerAuthService {
|
||||
if (!users.length) throw new UnauthorizedException('Invalid phone number or OTP');
|
||||
const u = users[0];
|
||||
|
||||
await this.consumeSetupOtp(u.id, otp, 'Invalid phone number or OTP');
|
||||
|
||||
const { token, refreshToken } = await this.mintSession(
|
||||
{ ...u, verified_by: 'fayda' },
|
||||
'fayda-otp-setup',
|
||||
);
|
||||
|
||||
return { token, refreshToken, requiresPassword: !u.has_set_password, iamUserId: u.id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies and burns a one-time code from `iam.user_verifications`.
|
||||
*
|
||||
* Both password-setup entry points land here: `set-password` codes come from
|
||||
* `password-setup/request`, `verify-phone-number` codes from `POST /auth/register`. Accepting
|
||||
* both is what lets a single screen finish the "existing account with no password" branch and
|
||||
* the "brand new signup" branch.
|
||||
*
|
||||
* Codes are argon2-hashed at rest, so this is a verify rather than an equality check. The
|
||||
* attempt counter is incremented *before* the comparison so a crash mid-verify still costs an
|
||||
* attempt, and the code is burned on the 6th try.
|
||||
*/
|
||||
private async consumeSetupOtp(
|
||||
userId: string,
|
||||
otp: string,
|
||||
failureMessage = 'Invalid or expired code',
|
||||
): Promise<void> {
|
||||
const verifications = await this.dataSource.query<{
|
||||
id: string; verification_code: string; attempt_count: number;
|
||||
}[]>(
|
||||
`SELECT id, verification_code, attempt_count FROM iam.user_verifications
|
||||
WHERE user_id = $1 AND otp_type = 'set-password' AND "isUsed" = false AND expires_at > NOW()
|
||||
WHERE user_id = $1
|
||||
AND otp_type IN ('set-password', 'verify-phone-number')
|
||||
AND "isUsed" = false
|
||||
AND expires_at > NOW()
|
||||
ORDER BY created_at DESC LIMIT 1`,
|
||||
[u.id],
|
||||
[userId],
|
||||
);
|
||||
if (!verifications.length) throw new UnauthorizedException('Invalid phone number or OTP');
|
||||
if (!verifications.length) throw new UnauthorizedException(failureMessage);
|
||||
const v = verifications[0];
|
||||
|
||||
if (v.attempt_count >= 5) {
|
||||
@@ -578,12 +643,24 @@ export class PassengerAuthService {
|
||||
|
||||
const { verifyPassword } = await import('@tria-plc/api-common/utils/argon');
|
||||
const valid = await verifyPassword(otp, v.verification_code);
|
||||
if (!valid) throw new UnauthorizedException('Invalid phone number or OTP');
|
||||
if (!valid) throw new UnauthorizedException(failureMessage);
|
||||
|
||||
await this.dataSource.query(
|
||||
`UPDATE iam.user_verifications SET "isUsed" = true WHERE id = $1`, [v.id],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts (or refreshes) an `iam.sessions` row and mints the token pair for it. The JWT payload
|
||||
* is only the session id — `JwtGuard` resolves everything else from the table.
|
||||
*
|
||||
* `device` participates in a unique constraint on `(user_id, device)`, so each flow passes its
|
||||
* own value and none of them clobbers a session another flow established.
|
||||
*/
|
||||
private async mintSession(
|
||||
u: IamAuthRow,
|
||||
device: string,
|
||||
): Promise<{ token: string; refreshToken: string }> {
|
||||
const userInfo = {
|
||||
id: u.id,
|
||||
email: u.email ?? '',
|
||||
@@ -604,19 +681,183 @@ export class PassengerAuthService {
|
||||
const sessions = await this.dataSource.query<{ id: string }[]>(
|
||||
`INSERT INTO iam.sessions
|
||||
(id, email, device, "userInfo", expiry_time, refresh_count, status, user_id)
|
||||
VALUES (gen_random_uuid(), $1, 'fayda-otp-setup', $2::jsonb, NOW() + INTERVAL '1 day', 0, 'ACTIVE', $3)
|
||||
VALUES (gen_random_uuid(), $1, $2, $3::jsonb, NOW() + INTERVAL '1 day', 0, 'ACTIVE', $4)
|
||||
ON CONFLICT (user_id, device) DO UPDATE
|
||||
SET status = 'ACTIVE', "userInfo" = EXCLUDED."userInfo",
|
||||
expiry_time = NOW() + INTERVAL '1 day', updated_at = NOW()
|
||||
RETURNING id`,
|
||||
[u.email ?? '', JSON.stringify(userInfo), u.id],
|
||||
[u.email ?? '', device, JSON.stringify(userInfo), u.id],
|
||||
);
|
||||
|
||||
const { generateToken, generateRefreshToken } = await import('@tria-plc/api-common/utils/token');
|
||||
const token = generateToken({ id: sessions[0].id });
|
||||
const refreshToken = generateRefreshToken({ id: sessions[0].id });
|
||||
return {
|
||||
token: generateToken({ id: sessions[0].id }),
|
||||
refreshToken: generateRefreshToken({ id: sessions[0].id }),
|
||||
};
|
||||
}
|
||||
|
||||
return { token, refreshToken, requiresPassword: !u.has_set_password, iamUserId: u.id };
|
||||
/**
|
||||
* Resolves the single sign-in identifier field to an `iam.users` row.
|
||||
*
|
||||
* A phone number reaches us in three interchangeable shapes (`+2519…`, `2519…`, `09…`)
|
||||
* depending on whether the account was created by IAM signup, a guest booking or Fayda, so
|
||||
* matching on one canonical form silently misses. `normalizePhoneVariants` produces every
|
||||
* shape and the query matches any of them.
|
||||
*
|
||||
* `ORDER BY has_set_password DESC` makes a fully-registered account win over a leftover
|
||||
* pending row that shares the same phone — otherwise a passenger with an abandoned signup
|
||||
* would be pushed into password setup for an account they already finished.
|
||||
*/
|
||||
private async findUserByIdentifier(identifier: string): Promise<IamAuthRow | null> {
|
||||
const resolved = resolveIdentifier(identifier);
|
||||
if (!resolved.email && resolved.phoneVariants.length === 0) return null;
|
||||
|
||||
const rows = await this.dataSource.query<IamAuthRow[]>(
|
||||
`SELECT id, email, name, username, phone_number, has_set_password, verified_by
|
||||
FROM iam.users
|
||||
WHERE ($1::text IS NOT NULL AND lower(email) = $1)
|
||||
OR phone_number = ANY($2::text[])
|
||||
ORDER BY has_set_password DESC
|
||||
LIMIT 1`,
|
||||
[resolved.email, resolved.phoneVariants],
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 1 of the staged sign-in: decide which of the three branches the portal should render.
|
||||
*
|
||||
* This deliberately reports whether an account exists — the whole point of the flow is that the
|
||||
* passenger stops guessing — so it is a user-enumeration oracle by design. `POST
|
||||
* /v1/auth/forgot-password` already leaks the same fact by throwing `user_not_found`; the
|
||||
* mitigation here is the throttle on this controller, not secrecy. Nothing identifying is
|
||||
* returned: no email, no user id, and the phone only ever masked.
|
||||
*/
|
||||
async lookupIdentifier(identifier: string): Promise<{
|
||||
status: 'PASSWORD' | 'NEEDS_PASSWORD_SETUP' | 'NOT_FOUND';
|
||||
method?: 'fayda' | 'pending';
|
||||
maskedPhone?: string;
|
||||
}> {
|
||||
const user = await this.findUserByIdentifier(identifier);
|
||||
if (!user) return { status: 'NOT_FOUND' };
|
||||
if (user.has_set_password) return { status: 'PASSWORD' };
|
||||
|
||||
return {
|
||||
status: 'NEEDS_PASSWORD_SETUP',
|
||||
method: user.verified_by === 'fayda' ? 'fayda' : 'pending',
|
||||
maskedPhone: user.phone_number ? maskPhone(user.phone_number) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the SMS code that lets an account with no password set one. Covers both Fayda-created
|
||||
* accounts and abandoned registrations — the distinction only changes the copy the portal
|
||||
* shows, not what happens here.
|
||||
*
|
||||
* Always resolves `{ sent: true }`. Returning a real result would make this a cheaper
|
||||
* enumeration oracle than `lookupIdentifier`, which at least sits behind the same throttle.
|
||||
*/
|
||||
async requestPasswordSetup(identifier: string, req: any): Promise<{ sent: boolean }> {
|
||||
const user = await this.findUserByIdentifier(identifier);
|
||||
if (!user || user.has_set_password) return { sent: true };
|
||||
|
||||
if (!user.phone_number) {
|
||||
// OTP delivery is SMS + in-app only; there is no email channel. Every account-creation
|
||||
// path requires a phone, so this should be unreachable — log it rather than fail silently.
|
||||
this.logger.warn(
|
||||
`requestPasswordSetup: user ${user.id} has no phone number — no channel to send a code on`,
|
||||
);
|
||||
return { sent: true };
|
||||
}
|
||||
|
||||
const iamAuthService = await this.resolveIamAuthService(req);
|
||||
try {
|
||||
await iamAuthService.generateVerificationCode({
|
||||
// Both fields must match the stored row exactly: the IAM looks the user up with
|
||||
// `where: { phoneNumber, email }`, which is AND, not OR. Passing the values we just
|
||||
// read back guarantees the match — including a null email, which TypeORM renders as
|
||||
// `IS NULL` and which coercing to '' would break.
|
||||
email: user.email as string,
|
||||
phoneNumber: user.phone_number,
|
||||
type: EOtpType.SET_PASSWORD,
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`[PassengerAuthService] password setup code failed for user ${user.id}`,
|
||||
(err as Error).message,
|
||||
);
|
||||
}
|
||||
return { sent: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Redeems the code, writes the password, and returns a session — the passenger lands signed in
|
||||
* rather than being bounced back to the login form.
|
||||
*
|
||||
* Returns the same shape as `login()` so the portal can store the result through one code path.
|
||||
*/
|
||||
async completePasswordSetup(dto: PasswordSetupCompleteDto): Promise<{
|
||||
token: string;
|
||||
refreshToken: string;
|
||||
user: { id: string; iamUserId: string; email: string | null; passengerId: string };
|
||||
}> {
|
||||
if (dto.newPassword !== dto.confirmPassword) {
|
||||
throw new BadRequestException('Passwords do not match');
|
||||
}
|
||||
|
||||
const user = await this.findUserByIdentifier(dto.identifier);
|
||||
// Same message whether the account is missing or the code is wrong: the branch was already
|
||||
// disclosed by `lookupIdentifier`, but there is no reason to re-confirm it on every attempt.
|
||||
if (!user) throw new UnauthorizedException('Invalid or expired code');
|
||||
if (user.has_set_password) {
|
||||
throw new BadRequestException(
|
||||
'This account already has a password. Sign in with it instead.',
|
||||
);
|
||||
}
|
||||
|
||||
await this.consumeSetupOtp(user.id, dto.otp);
|
||||
await this.writeActiveCredential(user.id, dto.newPassword);
|
||||
|
||||
// Redeeming the code proves ownership of the phone, which is what promotes a Fayda-created
|
||||
// `submitted` row or a pending signup to a usable account.
|
||||
await this.dataSource.query(
|
||||
`UPDATE iam.users
|
||||
SET has_set_password = true,
|
||||
status = 'accepted',
|
||||
is_active = true,
|
||||
is_phone_number_verified = true,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[user.id],
|
||||
);
|
||||
|
||||
const { token, refreshToken } = await this.mintSession(
|
||||
{ ...user, has_set_password: true },
|
||||
'password-setup',
|
||||
);
|
||||
|
||||
let passenger = await this.prisma.passenger.findUnique({
|
||||
where: { iamUserId: user.id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!passenger) {
|
||||
const result = await this.provisionPassengerSatellite({
|
||||
iamUserId: user.id,
|
||||
auditAction: 'USER_AUTO_PROVISIONED',
|
||||
});
|
||||
passenger = { id: result.passengerId };
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
refreshToken,
|
||||
user: {
|
||||
id: user.id,
|
||||
iamUserId: user.id,
|
||||
email: user.email,
|
||||
passengerId: passenger.id,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private standardizePhone(phone: string): string {
|
||||
|
||||
@@ -368,26 +368,22 @@ export class BookingsController {
|
||||
summary: "Create a group booking — staff bulk/group reservation, one PNR for the whole group",
|
||||
description: `Staff-only entry point for bulk/group bookings (e.g. tour groups booked via an uploaded passenger list and auto-assigned seats from POST /seats/auto-assign-hold).
|
||||
|
||||
Same body shape as POST /bookings/guest (CreateGuestBookingDto) and the same underlying pipeline — fare engine, ADULT/CHILD age pricing — just gated to staff and always ONE_WAY.
|
||||
Same body shape as POST /bookings/guest (CreateGuestBookingDto) and the same underlying pipeline — fare engine, ADULT/CHILD age pricing — just gated to staff. Supports ONE_WAY (default) and ROUND_TRIP via \`bookingType\`; for ROUND_TRIP, supply \`returnScheduleId\`/\`returnHoldId\`/\`returnOriginStationId\`/\`returnDestinationStationId\`/\`returnSeatClassId\` and each passenger's \`returnSeatId\`, exactly as POST /bookings/guest does.
|
||||
|
||||
Skips Verifayda national-ID verification: the roster comes from a staff-uploaded spreadsheet, not a live Fayda identity flow, so there is nothing to verify an ID number against. Passenger fields (name, DOB, nationality) are trusted exactly as uploaded.
|
||||
|
||||
Deliberately does NOT forward the staff caller's identity into booking creation: the acting staff member is not a Passenger, so the underlying guest-booking flow (which tries to resolve an authenticated caller as an existing Passenger profile) would reject the request. The booking is created exactly like a guest booking — a fresh passenger record, contact info from the first passenger in the list — with staff authorization enforced only at this route.
|
||||
|
||||
If booking creation fails after the seats were already held, the hold is released immediately so the seats don't sit locked for the rest of the hold TTL.`,
|
||||
If booking creation fails after the seats were already held, every hold involved (outbound and, for ROUND_TRIP, return) is released immediately so the seats don't sit locked for the rest of the hold TTL.`,
|
||||
})
|
||||
@ApiResponse({ status: 201, description: "Group booking created successfully with fareBreakdown" })
|
||||
@ApiResponse({ status: 400, description: "Missing required seat IDs" })
|
||||
async createGroup(@Body() dto: CreateGuestBookingDto) {
|
||||
try {
|
||||
return await this.guestService.createGuestBooking({ ...dto, bookingType: "ONE_WAY", skipIdentityVerification: true });
|
||||
return await this.guestService.createGuestBooking({ ...dto, bookingType: dto.bookingType || "ONE_WAY", skipIdentityVerification: true });
|
||||
} catch (err) {
|
||||
try {
|
||||
await this.seatsService.releaseHold(dto.holdId);
|
||||
} catch (releaseErr) {
|
||||
// Best-effort — the hold may already be gone (e.g. it expired mid-request). The
|
||||
// original booking-creation error is what the caller actually needs to see.
|
||||
}
|
||||
const holdIdsToRelease = [dto.holdId, dto.returnHoldId].filter((id): id is string => !!id);
|
||||
await Promise.allSettled(holdIdsToRelease.map((id) => this.seatsService.releaseHold(id)));
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { CurrencyService } from '../currency/currency.service';
|
||||
import { FareEngineService } from '../fare-engine/fare-engine.service';
|
||||
import { PaymentsService } from '../payments/payments.service';
|
||||
import { MAX_PAYMENT_HOURS } from '../../common/utils/payment-deadline.utils';
|
||||
import { normalizePhoneVariants } from '../../common/utils/phone.utils';
|
||||
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
|
||||
import { JourneyDirection } from '../seats/seats.dto';
|
||||
import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto';
|
||||
@@ -46,39 +47,6 @@ function resolvePackageRoundTripTotal(
|
||||
return adultCount * adultFareMinor + paidChildren * adultFareMinor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all plausible normalised variants of a raw phone string so that the
|
||||
* DB query matches regardless of how the number was stored (local 09… vs international +251…).
|
||||
* Returns an empty array when the input is clearly invalid (< 7 digits).
|
||||
*/
|
||||
function normalizePhoneVariants(raw: string): string[] {
|
||||
// Strip whitespace, dashes, dots, parentheses — keep digits and a leading +
|
||||
const stripped = raw.replace(/[^\d+]/g, '');
|
||||
const digits = stripped.replace(/^\+/, '');
|
||||
if (digits.length < 7) return [];
|
||||
|
||||
const variants = new Set<string>([stripped]);
|
||||
|
||||
if (stripped.startsWith('+251') && digits.length === 12) {
|
||||
// +251 9XXXXXXXX → 251 9XXXXXXXX (no +) → 09XXXXXXXX
|
||||
variants.add(digits); // 251XXXXXXXXX
|
||||
variants.add('0' + digits.slice(3)); // 09XXXXXXXXX
|
||||
} else if (stripped.startsWith('251') && digits.length === 12) {
|
||||
// 251 9XXXXXXXX → +251 9XXXXXXXX → 09XXXXXXXX
|
||||
variants.add('+' + stripped); // +251XXXXXXXXX
|
||||
variants.add('0' + digits.slice(3)); // 09XXXXXXXXX
|
||||
} else if (stripped.startsWith('0') && digits.length === 10) {
|
||||
// 09XXXXXXXX → +251 9XXXXXXXX → 251 9XXXXXXXX (no +)
|
||||
variants.add('+251' + digits.slice(1)); // +251XXXXXXXXX
|
||||
variants.add('251' + digits.slice(1)); // 251XXXXXXXXX
|
||||
} else if (!stripped.startsWith('+') && digits.length >= 9) {
|
||||
// bare international digits without +
|
||||
variants.add('+' + digits);
|
||||
}
|
||||
|
||||
return [...variants];
|
||||
}
|
||||
|
||||
function calculateAge(dateOfBirth: Date): number {
|
||||
const today = new Date();
|
||||
let age = today.getFullYear() - dateOfBirth.getFullYear();
|
||||
|
||||
@@ -392,7 +392,10 @@ export class GuestBookingService {
|
||||
contactEmail: contact.contactEmail,
|
||||
contactPhone: contact.contactPhone,
|
||||
seats: {
|
||||
create: passengersWithFares.map((p) => ({
|
||||
// A free child (no seatId — see passengersWithFares above) has nothing to connect to;
|
||||
// `connect: { id: undefined }` throws PrismaClientValidationError immediately if this
|
||||
// filter is missing, so it's never optional here despite the map below looking safe.
|
||||
create: passengersWithFares.filter((p) => p.seatId).map((p) => ({
|
||||
seat: { connect: { id: p.seatId } },
|
||||
scheduleId: dto.scheduleId,
|
||||
passengerName: p.passengerName,
|
||||
@@ -763,7 +766,7 @@ export class GuestBookingService {
|
||||
passenger.idDocumentType === IdDocumentType.NATIONAL_ID;
|
||||
|
||||
if (isEthiopian && passenger.idDocumentType === IdDocumentType.NATIONAL_ID) {
|
||||
if (passenger.idDocumentNumber) {
|
||||
if (passenger.idDocumentNumber && !dto.skipIdentityVerification) {
|
||||
const verification = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber);
|
||||
if (!verification.verified) throw new BadRequestException(`Verifayda verification failed for ${passenger.passengerName}: ${verification.failureReason}`);
|
||||
passengerName = verification.passengerData?.fullName || passengerName;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { diskStorage } from 'multer';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { extname, join } from 'path';
|
||||
import { mkdirSync } from 'fs';
|
||||
import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
|
||||
|
||||
/** Multipart field name carrying the package image file. */
|
||||
export const PACKAGE_IMAGE_FIELD = 'image';
|
||||
|
||||
/** Local-disk stopgap (see packages.service.ts) — not this app's usual MinIO-backed upload pattern. */
|
||||
export const PACKAGE_IMAGE_UPLOAD_DIR = join(__dirname, '..', '..', '..', 'public', 'uploads', 'packages');
|
||||
|
||||
export const PACKAGE_IMAGE_MAX_BYTES = 5 * 1024 * 1024; // 5MB
|
||||
|
||||
const ALLOWED_MIME_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
|
||||
|
||||
export const packageImageMulterOptions: MulterOptions = {
|
||||
storage: diskStorage({
|
||||
destination: (_req, _file, cb) => {
|
||||
// mkdir on every request rather than once at module load — this directory is
|
||||
// gitignored (see public/uploads/packages/.gitignore) so a fresh checkout/deploy
|
||||
// won't have it yet, and recursive mkdir on an already-existing dir is a no-op.
|
||||
mkdirSync(PACKAGE_IMAGE_UPLOAD_DIR, { recursive: true });
|
||||
cb(null, PACKAGE_IMAGE_UPLOAD_DIR);
|
||||
},
|
||||
// Unique filename so two packages (or two uploads for the same package) never collide —
|
||||
// never trust or reuse the original filename.
|
||||
filename: (_req, file, cb) => {
|
||||
cb(null, `${randomUUID()}${extname(file.originalname).toLowerCase()}`);
|
||||
},
|
||||
}),
|
||||
limits: {
|
||||
fileSize: PACKAGE_IMAGE_MAX_BYTES,
|
||||
files: 1,
|
||||
},
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (!ALLOWED_MIME_TYPES.has(file.mimetype)) {
|
||||
cb(new BadRequestException('Image must be JPEG, PNG, WEBP, or GIF.'), false);
|
||||
return;
|
||||
}
|
||||
cb(null, true);
|
||||
},
|
||||
};
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Body, Controller, Get, Param, Post, Patch, Delete, UseGuards, Request, Query } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
||||
import { Body, Controller, Get, Param, Post, Patch, Delete, UseGuards, UseInterceptors, UploadedFile, Request, Query, BadRequestException } from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery, ApiConsumes, ApiBody } from '@nestjs/swagger';
|
||||
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
||||
import { PackagesService } from './packages.service';
|
||||
import { CreatePackageDto, BookPackageDto, CreatePriceTierDto, UpdatePriceTierDto, CreateInquiryDto, UpdateInquiryStatusDto, PackageBookingContextDto } from './packages.dto';
|
||||
import { PACKAGE_IMAGE_FIELD, packageImageMulterOptions } from './package-image-upload.options';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard';
|
||||
import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
|
||||
@@ -148,6 +150,29 @@ export class PackagesController {
|
||||
return this.service.remove(id, cascade === 'true');
|
||||
}
|
||||
|
||||
@Post(':id/image')
|
||||
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin])
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@UseInterceptors(FileInterceptor(PACKAGE_IMAGE_FIELD, packageImageMulterOptions))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiBody({ schema: { type: 'object', properties: { [PACKAGE_IMAGE_FIELD]: { type: 'string', format: 'binary' } } } })
|
||||
@ApiOperation({
|
||||
summary: 'Upload or replace a package image (admin)',
|
||||
description: 'JPEG/PNG/WEBP/GIF, max 5MB. Replaces and deletes the previous image file if one exists — works the same whether the package currently has an image or not, so this one route covers both the initial upload and later replacement.',
|
||||
})
|
||||
uploadImage(@Param('id') id: string, @UploadedFile() file?: Express.Multer.File) {
|
||||
if (!file) throw new BadRequestException('No image file provided.');
|
||||
return this.service.uploadImage(id, file);
|
||||
}
|
||||
|
||||
@Delete(':id/image')
|
||||
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin])
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'Remove a package image without deleting the package (admin)' })
|
||||
removeImage(@Param('id') id: string) {
|
||||
return this.service.removeImage(id);
|
||||
}
|
||||
|
||||
@Patch(':id/activate')
|
||||
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin])
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { CurrencyService } from '../currency/currency.service';
|
||||
import { CreatePackageDto, BookPackageDto, UpdatePriceTierDto, CreatePriceTierDto, CreateInquiryDto } from './packages.dto';
|
||||
@@ -7,6 +7,9 @@ import { BookingsService } from '../bookings/bookings.service';
|
||||
import { GuestBookingService } from '../bookings/guest-booking.service';
|
||||
import { AuditService } from '../../common/audit.service';
|
||||
import { computePaymentDeadline, CUTOFF_MINUTES } from '../../common/utils/payment-deadline.utils';
|
||||
import { PACKAGE_IMAGE_UPLOAD_DIR } from './package-image-upload.options';
|
||||
import { join } from 'path';
|
||||
import { unlink } from 'fs/promises';
|
||||
|
||||
/** Package-specific fare rules */
|
||||
const PKG_MAX_ADULTS = 5;
|
||||
@@ -46,6 +49,8 @@ function generateRef(): string {
|
||||
|
||||
@Injectable()
|
||||
export class PackagesService {
|
||||
private readonly logger = new Logger(PackagesService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly currencyService: CurrencyService,
|
||||
@@ -325,6 +330,55 @@ export class PackagesService {
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Public URL prefix main.ts's app.useStaticAssets serves public/uploads/packages under. */
|
||||
private readonly PACKAGE_IMAGE_URL_PREFIX = '/uploads/packages/';
|
||||
|
||||
private packageImagePublicUrl(filename: string): string {
|
||||
const base = (process.env.APP_PUBLIC_URL || `http://localhost:${process.env.PORT || 4000}`).replace(/\/$/, '');
|
||||
return `${base}${this.PACKAGE_IMAGE_URL_PREFIX}${filename}`;
|
||||
}
|
||||
|
||||
/** Best-effort delete of the file backing a package's current imageUrl — never throws, since a
|
||||
* missing file (already deleted, moved, or from before this feature existed) shouldn't block
|
||||
* the DB update that's actually replacing/clearing the field. */
|
||||
private async deletePackageImageFile(imageUrl: string | null): Promise<void> {
|
||||
if (!imageUrl) return;
|
||||
const idx = imageUrl.indexOf(this.PACKAGE_IMAGE_URL_PREFIX);
|
||||
if (idx === -1) return; // not a file this app manages (e.g. an external URL) — nothing to delete
|
||||
const filename = imageUrl.slice(idx + this.PACKAGE_IMAGE_URL_PREFIX.length);
|
||||
if (!filename || filename.includes('/') || filename.includes('..')) return; // defensive: never touch paths outside the uploads dir
|
||||
try {
|
||||
await unlink(join(PACKAGE_IMAGE_UPLOAD_DIR, filename));
|
||||
} catch (err: any) {
|
||||
if (err?.code !== 'ENOENT') this.logger.warn(`Failed to delete package image file ${filename}: ${err?.message ?? err}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Multer's diskStorage has already written the file to PACKAGE_IMAGE_UPLOAD_DIR by the time
|
||||
* this runs (see package-image-upload.options.ts) — this just points the package at it and
|
||||
* cleans up whatever it's replacing. */
|
||||
async uploadImage(id: string, file: Express.Multer.File) {
|
||||
const pkg = await this.prisma.travelPackage.findUnique({ where: { id }, select: { imageUrl: true } });
|
||||
if (!pkg) throw new NotFoundException('Package not found');
|
||||
|
||||
const imageUrl = this.packageImagePublicUrl(file.filename);
|
||||
const updated = await this.prisma.travelPackage.update({ where: { id }, data: { imageUrl } });
|
||||
await this.deletePackageImageFile(pkg.imageUrl);
|
||||
await this.auditService.log({ action: 'UPDATE', entityType: 'Package', entityId: id, newData: { imageUrl } });
|
||||
return updated;
|
||||
}
|
||||
|
||||
async removeImage(id: string) {
|
||||
const pkg = await this.prisma.travelPackage.findUnique({ where: { id }, select: { imageUrl: true } });
|
||||
if (!pkg) throw new NotFoundException('Package not found');
|
||||
if (!pkg.imageUrl) return this.prisma.travelPackage.findUnique({ where: { id } });
|
||||
|
||||
const updated = await this.prisma.travelPackage.update({ where: { id }, data: { imageUrl: null } });
|
||||
await this.deletePackageImageFile(pkg.imageUrl);
|
||||
await this.auditService.log({ action: 'UPDATE', entityType: 'Package', entityId: id, newData: { imageUrl: null } });
|
||||
return updated;
|
||||
}
|
||||
|
||||
async addTier(packageId: string, dto: CreatePriceTierDto) {
|
||||
const pkg = await this.prisma.travelPackage.findUnique({ where: { id: packageId } });
|
||||
if (!pkg) throw new NotFoundException('Package not found');
|
||||
@@ -369,6 +423,7 @@ export class PackagesService {
|
||||
await this.prisma.packageInquiry.deleteMany({ where: { packageId: id } });
|
||||
await this.prisma.packagePriceTier.deleteMany({ where: { packageId: id } });
|
||||
await this.prisma.travelPackage.delete({ where: { id } });
|
||||
await this.deletePackageImageFile(pkg.imageUrl);
|
||||
await this.auditService.log({ action: 'DELETE', entityType: 'Package', entityId: id });
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsString, IsDateString, IsInt, IsOptional, IsEnum, IsArray, ValidateNested, IsObject, Min } from 'class-validator';
|
||||
import { IsString, IsDateString, IsInt, IsOptional, IsEnum, IsArray, ValidateNested, IsObject, IsBoolean, Min } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
@@ -56,6 +56,9 @@ export class CreateScheduleDto {
|
||||
@ApiPropertyOptional({ type: [String], description: 'Coach UUIDs to assign, in consist order. Overrides the route coach template if provided. A schedule must end up with at least one coach.' })
|
||||
@IsOptional() @IsArray() @IsString({ each: true })
|
||||
coachIds?: string[];
|
||||
|
||||
@ApiPropertyOptional({ example: false, description: 'Exclude from public search (reserved for packages)' }) @IsOptional() @IsBoolean() isPackageOnly?: boolean;
|
||||
@ApiPropertyOptional({ example: false, description: 'Exclude from public search (reserved for staff group bookings)' }) @IsOptional() @IsBoolean() isGroupBookingOnly?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateScheduleDto {
|
||||
@@ -64,6 +67,7 @@ export class UpdateScheduleDto {
|
||||
@ApiPropertyOptional({ enum: TripStatus, example: TripStatus.SCHEDULED }) @IsOptional() @IsEnum(TripStatus) status?: TripStatus;
|
||||
@ApiPropertyOptional({ type: Array, description: 'List of coaches to assign' }) @IsOptional() @IsArray() coaches?: Array<{ coachId: string; positionNumber: number }>;
|
||||
@ApiPropertyOptional({ example: false, description: 'Exclude from public search (reserved for packages)' }) @IsOptional() isPackageOnly?: boolean;
|
||||
@ApiPropertyOptional({ example: false, description: 'Exclude from public search (reserved for staff group bookings)' }) @IsOptional() @IsBoolean() isGroupBookingOnly?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateStopTimeDto {
|
||||
|
||||
@@ -263,6 +263,8 @@ export class SchedulesService {
|
||||
arrivalAt: arr,
|
||||
durationMinutes: Math.round((arr.getTime() - dep.getTime()) / 60_000),
|
||||
stopsCount: Math.max(0, route.stops.length - 2),
|
||||
isPackageOnly: dto.isPackageOnly ?? false,
|
||||
isGroupBookingOnly: dto.isGroupBookingOnly ?? false,
|
||||
},
|
||||
include: { train: true, originStation: true, destinationStation: true },
|
||||
});
|
||||
@@ -1000,6 +1002,7 @@ export class SchedulesService {
|
||||
|
||||
if (dto.status) updateData.status = dto.status;
|
||||
if (dto.isPackageOnly !== undefined) updateData.isPackageOnly = dto.isPackageOnly;
|
||||
if (dto.isGroupBookingOnly !== undefined) updateData.isGroupBookingOnly = dto.isGroupBookingOnly;
|
||||
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
await this.prisma.trainSchedule.update({ where: { id }, data: updateData });
|
||||
|
||||
@@ -27,6 +27,13 @@ export class SearchTripsDto {
|
||||
|
||||
@ApiPropertyOptional({ example: '2026-06-20', description: 'Return date (YYYY-MM-DD) — required for ROUND_TRIP, must be after outbound date' })
|
||||
@IsOptional() @IsDateString() returnDate?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: 'PORTAL',
|
||||
enum: ['PORTAL', 'GROUP_BOOKING'],
|
||||
description: 'Calling surface. Omit or PORTAL for normal ticket search (default) — only sees schedules with isGroupBookingOnly=false. GROUP_BOOKING sees only schedules with isGroupBookingOnly=true — the two are an exclusive partition, not additive; each channel sees a disjoint set of schedules.',
|
||||
})
|
||||
@IsOptional() @IsEnum(['PORTAL', 'GROUP_BOOKING']) channel?: string;
|
||||
}
|
||||
|
||||
export class AvailableDatesQueryDto {
|
||||
|
||||
@@ -82,6 +82,10 @@ export class SearchService {
|
||||
) {}
|
||||
|
||||
async searchTrips(dto: SearchTripsDto) {
|
||||
// GROUP_BOOKING is the staff-only bulk-booking wizard's own calling surface — isGroupBookingOnly
|
||||
// is an exclusive partition, not additive: this channel sees ONLY schedules explicitly created
|
||||
// for group booking, and the normal ticket channel (the default, PORTAL) sees only the rest.
|
||||
const forGroupBooking = dto.channel === "GROUP_BOOKING";
|
||||
const [direct, transit] = await Promise.all([
|
||||
this.searchSchedules(
|
||||
dto.originStationId,
|
||||
@@ -90,6 +94,7 @@ export class SearchService {
|
||||
dto.adultCount,
|
||||
dto.childCount,
|
||||
dto.nationality,
|
||||
forGroupBooking,
|
||||
),
|
||||
this.searchTransitOptions(
|
||||
dto.originStationId,
|
||||
@@ -98,6 +103,7 @@ export class SearchService {
|
||||
dto.adultCount,
|
||||
dto.childCount,
|
||||
dto.nationality,
|
||||
forGroupBooking,
|
||||
),
|
||||
]);
|
||||
|
||||
@@ -112,8 +118,9 @@ export class SearchService {
|
||||
dto.adultCount,
|
||||
dto.childCount,
|
||||
dto.nationality,
|
||||
forGroupBooking,
|
||||
),
|
||||
this.classifyEmptySearch(dto.originStationId, dto.destinationStationId, dto.date),
|
||||
this.classifyEmptySearch(dto.originStationId, dto.destinationStationId, dto.date, forGroupBooking),
|
||||
]);
|
||||
return {
|
||||
journeyType: "ONE_WAY",
|
||||
@@ -133,6 +140,7 @@ export class SearchService {
|
||||
dto.adultCount,
|
||||
dto.childCount,
|
||||
dto.nationality,
|
||||
forGroupBooking,
|
||||
),
|
||||
this.searchTransitOptions(
|
||||
dto.destinationStationId,
|
||||
@@ -141,6 +149,7 @@ export class SearchService {
|
||||
dto.adultCount,
|
||||
dto.childCount,
|
||||
dto.nationality,
|
||||
forGroupBooking,
|
||||
),
|
||||
]);
|
||||
|
||||
@@ -172,6 +181,7 @@ export class SearchService {
|
||||
dto.adultCount,
|
||||
dto.childCount,
|
||||
dto.nationality,
|
||||
forGroupBooking,
|
||||
)
|
||||
: Promise.resolve([]),
|
||||
inbound.length === 0
|
||||
@@ -182,13 +192,14 @@ export class SearchService {
|
||||
dto.adultCount,
|
||||
dto.childCount,
|
||||
dto.nationality,
|
||||
forGroupBooking,
|
||||
)
|
||||
: Promise.resolve([]),
|
||||
outbound.length === 0
|
||||
? this.classifyEmptySearch(dto.originStationId, dto.destinationStationId, dto.date)
|
||||
? this.classifyEmptySearch(dto.originStationId, dto.destinationStationId, dto.date, forGroupBooking)
|
||||
: Promise.resolve(undefined),
|
||||
inbound.length === 0
|
||||
? this.classifyEmptySearch(dto.destinationStationId, dto.originStationId, returnDate)
|
||||
? this.classifyEmptySearch(dto.destinationStationId, dto.originStationId, returnDate, forGroupBooking)
|
||||
: Promise.resolve(undefined),
|
||||
]);
|
||||
return {
|
||||
@@ -223,6 +234,7 @@ export class SearchService {
|
||||
adultCount: number,
|
||||
childCount?: number,
|
||||
nationality?: string,
|
||||
forGroupBooking = false,
|
||||
) {
|
||||
// Real millisecond arithmetic, not string-padded day-of-month increment — the latter
|
||||
// produces an invalid date (e.g. "2026-07-32") for any search on the last day of a month.
|
||||
@@ -241,6 +253,10 @@ export class SearchService {
|
||||
const baseWhere: Prisma.TrainScheduleWhereInput = {
|
||||
status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] },
|
||||
isPackageOnly: false,
|
||||
// Group Booking's search is exclusive, not additive: staff only ever see schedules
|
||||
// explicitly created for group booking, never the normal passenger-facing ones, and the
|
||||
// portal never sees group-only ones. Each channel is a strict partition of the other.
|
||||
isGroupBookingOnly: forGroupBooking,
|
||||
stopTimes: { some: { stationId: originStationId } },
|
||||
coachAssignments: { some: {} },
|
||||
};
|
||||
@@ -310,6 +326,7 @@ export class SearchService {
|
||||
adultCount: number,
|
||||
childCount?: number,
|
||||
nationality?: string,
|
||||
forGroupBooking = false,
|
||||
) {
|
||||
// Real millisecond arithmetic, not string-padded day-of-month increment — the latter
|
||||
// produces an invalid date (e.g. "2026-07-32") for any search on the last day of a month.
|
||||
@@ -329,6 +346,8 @@ export class SearchService {
|
||||
// statuses, not booking-closed signals (see comment on searchAlternatives' baseWhere).
|
||||
status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] },
|
||||
isPackageOnly: false,
|
||||
// Exclusive partition — see the comment on searchAlternatives' baseWhere.
|
||||
isGroupBookingOnly: forGroupBooking,
|
||||
departureAt: { gte: date, lt: nextDay },
|
||||
stopTimes: { some: { stationId: originStationId } },
|
||||
coachAssignments: { some: {} },
|
||||
@@ -363,6 +382,7 @@ export class SearchService {
|
||||
originStationId: string,
|
||||
destinationStationId: string,
|
||||
dateStr: string,
|
||||
forGroupBooking = false,
|
||||
): Promise<Passenger.ISearchEmptyReason> {
|
||||
const [origin, destination] = await Promise.all([
|
||||
this.prisma.station.findUnique({ where: { id: originStationId }, select: { name: true } }),
|
||||
@@ -393,6 +413,7 @@ export class SearchService {
|
||||
select: {
|
||||
status: true,
|
||||
isPackageOnly: true,
|
||||
isGroupBookingOnly: true,
|
||||
departureAt: true,
|
||||
route: {
|
||||
select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } },
|
||||
@@ -409,13 +430,20 @@ export class SearchService {
|
||||
if (sameDayForPair.length === 0) return withCode(Passenger.SearchEmptyReasonCode.NoScheduleOnDate);
|
||||
|
||||
// 3. Schedules exist that date — narrow to ones that would otherwise be bookable
|
||||
// (right status, not package-only, has at least one coach assigned).
|
||||
const bookable = sameDayForPair.filter((s) => this.isBookableSchedule(s));
|
||||
// (right status, not package-only, not group-booking-only unless this IS a group-booking
|
||||
// search, has at least one coach assigned).
|
||||
const bookable = sameDayForPair.filter((s) => this.isBookableSchedule(s, forGroupBooking));
|
||||
if (bookable.length === 0) {
|
||||
if (sameDayForPair.every((s) => s.status === "CANCELLED"))
|
||||
return withCode(Passenger.SearchEmptyReasonCode.Cancelled);
|
||||
if (sameDayForPair.every((s) => s.isPackageOnly))
|
||||
return withCode(Passenger.SearchEmptyReasonCode.PackageOnly);
|
||||
// isGroupBookingOnly is an exclusive partition (see isBookableSchedule) — this same reason
|
||||
// code covers both directions: the portal finding only group-reserved schedules, and Group
|
||||
// Booking finding only normal ones (nothing set up for it on this date). The frontend picks
|
||||
// the right copy per caller.
|
||||
if (sameDayForPair.every((s) => s.isGroupBookingOnly !== forGroupBooking))
|
||||
return withCode(Passenger.SearchEmptyReasonCode.GroupBookingOnly);
|
||||
return withCode(Passenger.SearchEmptyReasonCode.NoScheduleOnDate);
|
||||
}
|
||||
|
||||
@@ -483,11 +511,22 @@ export class SearchService {
|
||||
/** Bounds the stop-time fallback scan — connectivity is a yes/no, not a survey. */
|
||||
private readonly ROUTE_EXISTS_SCHEDULE_SCAN_LIMIT = 200;
|
||||
|
||||
/** Status/package/coach bookability only — ignores date, cutoff, and seat-level availability. */
|
||||
private isBookableSchedule(s: { status: string; isPackageOnly: boolean; coachAssignments: { id: string }[] }): boolean {
|
||||
/**
|
||||
* Status/package/group-booking/coach bookability only — ignores date, cutoff, and seat-level
|
||||
* availability. `forGroupBooking` defaults false so existing single-arg callers (e.g.
|
||||
* getAvailableDates, the portal's calendar) keep hiding group-booking-only schedules.
|
||||
* isGroupBookingOnly is an exclusive partition, not an additive one: a schedule is bookable
|
||||
* for a given channel only when its flag exactly matches that channel (normal schedules for
|
||||
* the portal, group-only schedules for Group Booking — never both from one channel).
|
||||
*/
|
||||
private isBookableSchedule(
|
||||
s: { status: string; isPackageOnly: boolean; isGroupBookingOnly: boolean; coachAssignments: { id: string }[] },
|
||||
forGroupBooking = false,
|
||||
): boolean {
|
||||
return (
|
||||
(["SCHEDULED", "BOARDING", "EN_ROUTE"] as string[]).includes(s.status) &&
|
||||
!s.isPackageOnly &&
|
||||
s.isGroupBookingOnly === forGroupBooking &&
|
||||
s.coachAssignments.length > 0
|
||||
);
|
||||
}
|
||||
@@ -542,6 +581,7 @@ export class SearchService {
|
||||
departureAt: true,
|
||||
status: true,
|
||||
isPackageOnly: true,
|
||||
isGroupBookingOnly: true,
|
||||
route: {
|
||||
select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } },
|
||||
},
|
||||
@@ -581,6 +621,7 @@ export class SearchService {
|
||||
adultCount: number,
|
||||
childCount?: number,
|
||||
nationality?: string,
|
||||
forGroupBooking = false,
|
||||
) {
|
||||
// Real millisecond arithmetic, not string-padded day-of-month increment — the latter
|
||||
// produces an invalid date (e.g. "2026-07-32") for any search on the last day of a month.
|
||||
@@ -599,6 +640,8 @@ export class SearchService {
|
||||
// BOARDING included alongside SCHEDULED — see comment on searchAlternatives' baseWhere.
|
||||
status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] },
|
||||
isPackageOnly: false,
|
||||
// Exclusive partition — see the comment on searchAlternatives' baseWhere.
|
||||
isGroupBookingOnly: forGroupBooking,
|
||||
departureAt: { gte: dayStart, lt: dayEnd },
|
||||
stopTimes: { some: { stationId: originStationId } },
|
||||
coachAssignments: { some: {} },
|
||||
@@ -609,6 +652,7 @@ export class SearchService {
|
||||
where: {
|
||||
status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] },
|
||||
isPackageOnly: false,
|
||||
isGroupBookingOnly: forGroupBooking,
|
||||
departureAt: { gte: dayStart, lt: leg2WindowEnd },
|
||||
coachAssignments: { some: {} },
|
||||
},
|
||||
|
||||
@@ -191,10 +191,12 @@ This makes it clear which segment of the route each seat is held for, enabling s
|
||||
@ApiBearerAuth("IAM-auth")
|
||||
@ApiOperation({
|
||||
summary: "Auto-assign and hold N seats of a class — staff bulk/group booking only",
|
||||
description: `Picks the requested number of available seats of the given class (preferring a contiguous row) and holds them in one step, so the caller never shows an assignment it could lose to a race before the passenger data is submitted.
|
||||
description: `Picks the requested number of available seats of the given class (filling Lower berths first, then Middle, then Upper, ascending seat number within each tier) and holds them in one step, so the caller never shows an assignment it could lose to a race before the passenger data is submitted.
|
||||
|
||||
No manual seat selection — this is for bulk/group booking flows where staff upload a passenger list rather than picking seats on a seat map. Returns the same hold shape as POST /seats/hold.
|
||||
|
||||
For a round-trip group booking, call this twice — once per leg — passing \`journeyDirection: 'OUTBOUND'\`/\`'RETURN'\` so a same-schedule turnaround round trip isn't mistaken for a double-hold conflict.
|
||||
|
||||
Throws 409 with no partial hold created if fewer than the requested seats are available in that class.`,
|
||||
})
|
||||
@ApiResponse({ status: 201, description: "Seats auto-assigned and held" })
|
||||
@@ -207,6 +209,7 @@ Throws 409 with no partial hold created if fewer than the requested seats are av
|
||||
dto.destinationStationId,
|
||||
dto.seatClassName,
|
||||
passengerCount,
|
||||
dto.journeyDirection,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +67,13 @@ export class AutoAssignHoldDto {
|
||||
|
||||
@ApiPropertyOptional({ example: 1, minimum: 0, description: 'Number of child passengers to assign seats for.' })
|
||||
@IsOptional() @IsInt() @Min(0) childCount?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: JourneyDirection,
|
||||
example: JourneyDirection.OUTBOUND,
|
||||
description: 'Round-trip leg direction — OUTBOUND or RETURN. Omit for a plain one-way group booking (the hold defaults to ONE_WAY), preserving current behavior.',
|
||||
})
|
||||
@IsOptional() @IsEnum(JourneyDirection) journeyDirection?: JourneyDirection;
|
||||
}
|
||||
|
||||
export class ReleaseHoldDto {
|
||||
|
||||
@@ -920,6 +920,7 @@ export class SeatsService {
|
||||
destinationStationId: string,
|
||||
seatClassName: string,
|
||||
passengerCount: number,
|
||||
journeyDirection?: JourneyDirection,
|
||||
) {
|
||||
const seatIds = await this.autoAssignSeats(scheduleId, passengerCount, seatClassName);
|
||||
// Scope the synthetic passengerId to this attempt (not just its row index) — a fixed
|
||||
@@ -933,6 +934,7 @@ export class SeatsService {
|
||||
scheduleId,
|
||||
originStationId,
|
||||
destinationStationId,
|
||||
journeyDirection,
|
||||
passengers,
|
||||
} as HoldSeatsDto);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
type SupportedPaymentMethod,
|
||||
} from '@/lib/api/group-booking';
|
||||
import { buildPassengerTemplate } from '@/lib/export/passenger-template';
|
||||
import { countByType, parsePassengerExcel, type ParsedPassengerRow } from '@/lib/import/passenger-excel';
|
||||
import { countByType, parsePassengerExcel, resolveFreeChildIndexes, type ParsedPassengerRow } from '@/lib/import/passenger-excel';
|
||||
import ActionButton from '@/components/ui/ActionButton';
|
||||
import DatePicker from '@/components/ui/DatePicker';
|
||||
import Skeleton from '@/components/ui/Skeleton';
|
||||
@@ -69,6 +69,11 @@ function emptySearchMessage(reason: SearchEmptyReason | undefined): string {
|
||||
return `Every departure from ${o} to ${d} on this date was cancelled.`;
|
||||
case 'PACKAGE_ONLY':
|
||||
return `Departures on this date are reserved for travel packages, not regular ticketing.`;
|
||||
case 'GROUP_BOOKING_ONLY':
|
||||
// isGroupBookingOnly is an exclusive partition — this page only ever sees group-booking
|
||||
// schedules, so an empty result here means a regular (non-group) train runs on this date
|
||||
// but nothing has been set up for group booking specifically.
|
||||
return `A regular train runs from ${o} to ${d} on this date, but no schedule has been set up for group booking yet — ask fleet/schedule management to create one, or try another date.`;
|
||||
case 'CHECKIN_CLOSED':
|
||||
return `Check-in has already closed for every departure on this date.`;
|
||||
case 'FULLY_BOOKED':
|
||||
@@ -246,6 +251,8 @@ function GroupBookingPageContent() {
|
||||
const [originStationId, setOriginStationId] = useState('');
|
||||
const [destinationStationId, setDestinationStationId] = useState('');
|
||||
const [travelDate, setTravelDate] = useState('');
|
||||
const [tripType, setTripType] = useState<'ONE_WAY' | 'ROUND_TRIP'>('ONE_WAY');
|
||||
const [returnDate, setReturnDate] = useState('');
|
||||
const [adultCount, setAdultCount] = useState<number>(1);
|
||||
const [childCount, setChildCount] = useState<number>(0);
|
||||
const [searchTouched, setSearchTouched] = useState(false);
|
||||
@@ -265,7 +272,7 @@ function GroupBookingPageContent() {
|
||||
|
||||
const totalPassengers = (adultCount || 0) + (childCount || 0);
|
||||
const searchValid = !!originStationId && !!destinationStationId && originStationId !== destinationStationId
|
||||
&& !!travelDate && totalPassengers > 0;
|
||||
&& !!travelDate && totalPassengers > 0 && (tripType === 'ONE_WAY' || !!returnDate);
|
||||
|
||||
const searchMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
@@ -275,14 +282,19 @@ function GroupBookingPageContent() {
|
||||
date: travelDate,
|
||||
adultCount: adultCount || 0,
|
||||
childCount: childCount || 0,
|
||||
journeyType: 'ONE_WAY',
|
||||
journeyType: tripType,
|
||||
returnDate: tripType === 'ROUND_TRIP' ? returnDate : undefined,
|
||||
nationality: fareTier === 'LOCAL' ? 'Ethiopian' : 'Other',
|
||||
channel: 'GROUP_BOOKING',
|
||||
}),
|
||||
});
|
||||
|
||||
const runSearch = () => {
|
||||
setSearchTouched(true);
|
||||
if (!searchValid) return;
|
||||
setResultsPhase('outbound');
|
||||
setSelectedReturnSchedule(null);
|
||||
setSelectedReturnClass(null);
|
||||
setStep('results');
|
||||
searchMutation.mutate();
|
||||
};
|
||||
@@ -290,13 +302,23 @@ function GroupBookingPageContent() {
|
||||
// ── Step 2: results / class selection ───────────────────────────────────
|
||||
const [selectedSchedule, setSelectedSchedule] = useState<ScheduleResult | null>(null);
|
||||
const [selectedClass, setSelectedClass] = useState<SelectedClass | null>(null);
|
||||
// Round trip only — the outbound/return picks happen as two phases of this same step,
|
||||
// mirroring the passenger portal's results page (search once, pick outbound, then return).
|
||||
const [resultsPhase, setResultsPhase] = useState<'outbound' | 'return'>('outbound');
|
||||
const [selectedReturnSchedule, setSelectedReturnSchedule] = useState<ScheduleResult | null>(null);
|
||||
const [selectedReturnClass, setSelectedReturnClass] = useState<SelectedClass | null>(null);
|
||||
|
||||
const seatClassId = useMemo(() => {
|
||||
if (!selectedClass) return null;
|
||||
return seatClassOptions.find((sc) => sc.name === selectedClass.className)?.id ?? null;
|
||||
}, [selectedClass, seatClassOptions]);
|
||||
|
||||
const chooseClass = (schedule: ScheduleResult, cls: ScheduleClassOption, category: string, totalAvailable: number) => {
|
||||
const returnSeatClassId = useMemo(() => {
|
||||
if (!selectedReturnClass) return null;
|
||||
return seatClassOptions.find((sc) => sc.name === selectedReturnClass.className)?.id ?? null;
|
||||
}, [selectedReturnClass, seatClassOptions]);
|
||||
|
||||
const chooseOutboundClass = (schedule: ScheduleResult, cls: ScheduleClassOption, category: string, totalAvailable: number) => {
|
||||
setSelectedSchedule(schedule);
|
||||
setSelectedClass({
|
||||
scheduleId: schedule.scheduleId,
|
||||
@@ -306,6 +328,26 @@ function GroupBookingPageContent() {
|
||||
displayCurrency: cls.displayCurrency,
|
||||
available: totalAvailable,
|
||||
});
|
||||
if (tripType === 'ROUND_TRIP') {
|
||||
// Force re-confirming the return leg if staff changes their mind on outbound later.
|
||||
setSelectedReturnSchedule(null);
|
||||
setSelectedReturnClass(null);
|
||||
setResultsPhase('return');
|
||||
} else {
|
||||
setStep('passengers');
|
||||
}
|
||||
};
|
||||
|
||||
const chooseReturnClass = (schedule: ScheduleResult, cls: ScheduleClassOption, category: string, totalAvailable: number) => {
|
||||
setSelectedReturnSchedule(schedule);
|
||||
setSelectedReturnClass({
|
||||
scheduleId: schedule.scheduleId,
|
||||
className: cls.name,
|
||||
category,
|
||||
fareMinor: cls.baseFareMinor,
|
||||
displayCurrency: cls.displayCurrency,
|
||||
available: totalAvailable,
|
||||
});
|
||||
setStep('passengers');
|
||||
};
|
||||
|
||||
@@ -322,6 +364,16 @@ function GroupBookingPageContent() {
|
||||
const countMismatch = passengerRows.length > 0 && (uploadedAdults !== adultCount || uploadedChildren !== childCount);
|
||||
const passengersValid = passengerRows.length > 0 && fileErrors.length === 0 && !rowsHaveErrors && !countMismatch;
|
||||
|
||||
// One-way only — the portal's own "1 free child per adult, no seat" rule (fare-utils.ts's
|
||||
// isFirstChild). Round trip can't offer this: createGuestRoundTripBooking hard-requires a
|
||||
// returnSeatId on every passenger, so every child there still needs a real seat both ways.
|
||||
const freeChildFlags = useMemo(
|
||||
() => (tripType === 'ONE_WAY' ? resolveFreeChildIndexes(passengerRows, adultCount) : passengerRows.map(() => false)),
|
||||
[passengerRows, adultCount, tripType],
|
||||
);
|
||||
const freeChildrenCount = freeChildFlags.filter(Boolean).length;
|
||||
const paidChildrenCount = uploadedChildren - freeChildrenCount;
|
||||
|
||||
const downloadTemplate = async () => {
|
||||
if (!selectedSchedule || !selectedClass) return;
|
||||
const blob = await buildPassengerTemplate({
|
||||
@@ -370,25 +422,52 @@ function GroupBookingPageContent() {
|
||||
// ── Step 4: auto-assign + hold ───────────────────────────────────────────
|
||||
const [assignError, setAssignError] = useState<string | null>(null);
|
||||
const [hold, setHold] = useState<AutoAssignHoldResponse | null>(null);
|
||||
const [returnHold, setReturnHold] = useState<AutoAssignHoldResponse | null>(null);
|
||||
|
||||
const autoAssignMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
mutationFn: async () => {
|
||||
if (!selectedSchedule || !selectedClass) throw new Error('No schedule/class selected');
|
||||
return groupBookingApi.autoAssignHold({
|
||||
// One-way's free children (see freeChildFlags above) need no seat at all — only ask for
|
||||
// seats covering adults + paid children. Round trip can't offer that (every passenger
|
||||
// needs both a seatId and a returnSeatId), so it still requests one seat per child.
|
||||
const outboundHold = await groupBookingApi.autoAssignHold({
|
||||
scheduleId: selectedSchedule.scheduleId,
|
||||
originStationId: selectedSchedule.origin.id,
|
||||
destinationStationId: selectedSchedule.destination.id,
|
||||
seatClassName: selectedClass.className,
|
||||
adultCount,
|
||||
childCount,
|
||||
childCount: tripType === 'ONE_WAY' ? paidChildrenCount : childCount,
|
||||
journeyDirection: tripType === 'ROUND_TRIP' ? 'OUTBOUND' : undefined,
|
||||
});
|
||||
if (tripType !== 'ROUND_TRIP') return { outboundHold, returnHold: null };
|
||||
if (!selectedReturnSchedule || !selectedReturnClass) throw new Error('No return schedule/class selected');
|
||||
try {
|
||||
const returnHoldResp = await groupBookingApi.autoAssignHold({
|
||||
scheduleId: selectedReturnSchedule.scheduleId,
|
||||
originStationId: selectedReturnSchedule.origin.id,
|
||||
destinationStationId: selectedReturnSchedule.destination.id,
|
||||
seatClassName: selectedReturnClass.className,
|
||||
adultCount,
|
||||
childCount,
|
||||
journeyDirection: 'RETURN',
|
||||
});
|
||||
return { outboundHold, returnHold: returnHoldResp };
|
||||
} catch (err) {
|
||||
// Return leg failed after outbound already succeeded — release the outbound hold
|
||||
// immediately instead of leaving it locked for the rest of the hold TTL.
|
||||
await groupBookingApi.releaseHold(outboundHold.holdId).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setHold(data);
|
||||
onSuccess: ({ outboundHold, returnHold: returnHoldResp }) => {
|
||||
setHold(outboundHold);
|
||||
setReturnHold(returnHoldResp);
|
||||
setAssignError(null);
|
||||
setStep('confirm');
|
||||
},
|
||||
onError: (err: any) => {
|
||||
setHold(null);
|
||||
setReturnHold(null);
|
||||
setAssignError(err?.response?.data?.message ?? err?.message ?? 'Not enough seats are available for this class.');
|
||||
},
|
||||
});
|
||||
@@ -399,14 +478,23 @@ function GroupBookingPageContent() {
|
||||
autoAssignMutation.mutate();
|
||||
};
|
||||
|
||||
// Pairs each validated passenger row (upload order) with its auto-assigned seat (same order).
|
||||
// Pairs each validated passenger row (upload order) with its auto-assigned seat(s). A free
|
||||
// child (freeChildFlags[i]) consumes no seat at all — it's skipped when walking the hold's
|
||||
// seat list, so seat N goes to the Nth non-free passenger, not the Nth row.
|
||||
const seatAssignments = useMemo(() => {
|
||||
if (!hold) return [];
|
||||
return passengerRows.map((row, i) => ({
|
||||
row,
|
||||
seat: hold.passengers[i]?.seat ?? null,
|
||||
}));
|
||||
}, [hold, passengerRows]);
|
||||
let seatIdx = 0;
|
||||
return passengerRows.map((row, i) => {
|
||||
const isFree = freeChildFlags[i];
|
||||
const seat = isFree ? null : (hold.passengers[seatIdx++]?.seat ?? null);
|
||||
return {
|
||||
row,
|
||||
seat,
|
||||
returnSeat: returnHold?.passengers[i]?.seat ?? null,
|
||||
isFree,
|
||||
};
|
||||
});
|
||||
}, [hold, returnHold, passengerRows, freeChildFlags]);
|
||||
|
||||
// ── Step 5: create booking ───────────────────────────────────────────────
|
||||
const [bookingError, setBookingError] = useState<string | null>(null);
|
||||
@@ -417,15 +505,29 @@ function GroupBookingPageContent() {
|
||||
if (!selectedSchedule || !selectedClass || !hold || !seatClassId) {
|
||||
throw new Error('Missing schedule, class, or hold — go back and try again.');
|
||||
}
|
||||
if (tripType === 'ROUND_TRIP' && (!selectedReturnSchedule || !selectedReturnClass || !returnHold || !returnSeatClassId)) {
|
||||
throw new Error('Missing return schedule, class, or hold — go back and try again.');
|
||||
}
|
||||
return groupBookingApi.createGroupBooking({
|
||||
scheduleId: selectedSchedule.scheduleId,
|
||||
holdId: hold.holdId,
|
||||
originStationId: selectedSchedule.origin.id,
|
||||
destinationStationId: selectedSchedule.destination.id,
|
||||
seatClassId,
|
||||
bookingType: 'ONE_WAY',
|
||||
passengers: seatAssignments.map(({ row, seat }) => ({
|
||||
seatId: seat!.id,
|
||||
bookingType: tripType,
|
||||
...(tripType === 'ROUND_TRIP' ? {
|
||||
returnScheduleId: selectedReturnSchedule!.scheduleId,
|
||||
returnHoldId: returnHold!.holdId,
|
||||
returnOriginStationId: selectedReturnSchedule!.origin.id,
|
||||
returnDestinationStationId: selectedReturnSchedule!.destination.id,
|
||||
returnSeatClassId: returnSeatClassId!,
|
||||
} : {}),
|
||||
passengers: seatAssignments.map(({ row, seat, returnSeat, isFree }) => ({
|
||||
// Free children (ONE_WAY only) have no seat at all — omit seatId entirely,
|
||||
// matching the portal's own convention (guest-booking.service.ts treats a missing
|
||||
// seatId as "unseated = free (0)").
|
||||
...(isFree ? {} : { seatId: seat!.id }),
|
||||
...(tripType === 'ROUND_TRIP' ? { returnSeatId: returnSeat!.id } : {}),
|
||||
passengerName: row.fullName,
|
||||
dateOfBirth: row.dateOfBirth,
|
||||
idDocumentType: row.idDocumentType as any,
|
||||
@@ -444,8 +546,9 @@ function GroupBookingPageContent() {
|
||||
setStep('success');
|
||||
},
|
||||
onError: (err: any) => {
|
||||
// The hold was released server-side on failure — a retry needs a fresh one.
|
||||
// Both holds were released server-side on failure — a retry needs fresh ones.
|
||||
setHold(null);
|
||||
setReturnHold(null);
|
||||
setBookingError(err?.response?.data?.message ?? err?.message ?? 'Could not create the booking.');
|
||||
},
|
||||
});
|
||||
@@ -456,12 +559,16 @@ function GroupBookingPageContent() {
|
||||
};
|
||||
|
||||
// ── Step 6: pay ───────────────────────────────────────────────────────────
|
||||
const { data: paymentMethods = [] } = useQuery<SupportedPaymentMethod[]>({
|
||||
const { data: paymentMethodsData } = useQuery<SupportedPaymentMethod[]>({
|
||||
queryKey: ['payment-methods'],
|
||||
queryFn: () => groupBookingApi.getPaymentMethods(),
|
||||
enabled: step === 'success',
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
// Defensive: never let a malformed/unexpected response shape (e.g. an unwrap mismatch, or a
|
||||
// proxy/error page returned in place of JSON) crash the page with a raw TypeError — an empty
|
||||
// list here just shows "Loading payment options…" a beat longer instead.
|
||||
const paymentMethods = Array.isArray(paymentMethodsData) ? paymentMethodsData : [];
|
||||
const enabledPaymentMethods = paymentMethods.filter((m) => m.enabled);
|
||||
|
||||
const [selectedPaymentType, setSelectedPaymentType] = useState<string | null>(null);
|
||||
@@ -506,8 +613,12 @@ function GroupBookingPageContent() {
|
||||
setStep('search');
|
||||
setSelectedSchedule(null);
|
||||
setSelectedClass(null);
|
||||
setResultsPhase('outbound');
|
||||
setSelectedReturnSchedule(null);
|
||||
setSelectedReturnClass(null);
|
||||
clearUpload();
|
||||
setHold(null);
|
||||
setReturnHold(null);
|
||||
setAssignError(null);
|
||||
setBooking(null);
|
||||
setBookingError(null);
|
||||
@@ -532,16 +643,31 @@ function GroupBookingPageContent() {
|
||||
{/* Selection summary bar — visible from Step 2 onward */}
|
||||
{selectedSchedule && selectedClass && step !== 'search' && step !== 'results' && (
|
||||
<div className="card flex items-center justify-between flex-wrap gap-3 animate-fade-up">
|
||||
<div className="flex items-center gap-3 text-sm flex-wrap">
|
||||
<div className="rounded-lg bg-primary/10 p-1.5 shrink-0">
|
||||
<Train className="h-4 w-4 text-primary" />
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-3 text-sm flex-wrap">
|
||||
<div className="rounded-lg bg-primary/10 p-1.5 shrink-0">
|
||||
<Train className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{tripType === 'ROUND_TRIP' ? 'Outbound' : 'Trip'}</span>
|
||||
<span className="font-semibold">{selectedSchedule.trainNumber}</span>
|
||||
<span className="text-muted-foreground">{selectedSchedule.origin.name} → {selectedSchedule.destination.name}</span>
|
||||
<span className="text-muted-foreground">· {formatDateTime(selectedSchedule.departureAt)}</span>
|
||||
<span className="text-muted-foreground">· {selectedClass.category}</span>
|
||||
<span className="text-muted-foreground">· {fareTier === 'LOCAL' ? 'Local' : 'International'} rates</span>
|
||||
<span className="text-muted-foreground">· {adultCount} Adult{adultCount === 1 ? '' : 's'}{childCount > 0 ? ` + ${childCount} Child${childCount === 1 ? '' : 'ren'}` : ''}</span>
|
||||
</div>
|
||||
<span className="font-semibold">{selectedSchedule.trainNumber}</span>
|
||||
<span className="text-muted-foreground">{selectedSchedule.origin.name} → {selectedSchedule.destination.name}</span>
|
||||
<span className="text-muted-foreground">· {formatDateTime(selectedSchedule.departureAt)}</span>
|
||||
<span className="text-muted-foreground">· {selectedClass.category}</span>
|
||||
<span className="text-muted-foreground">· {fareTier === 'LOCAL' ? 'Local' : 'International'} rates</span>
|
||||
<span className="text-muted-foreground">· {adultCount} Adult{adultCount === 1 ? '' : 's'}{childCount > 0 ? ` + ${childCount} Child${childCount === 1 ? '' : 'ren'}` : ''}</span>
|
||||
{tripType === 'ROUND_TRIP' && selectedReturnSchedule && selectedReturnClass && (
|
||||
<div className="flex items-center gap-3 text-sm flex-wrap">
|
||||
<div className="rounded-lg bg-primary/10 p-1.5 shrink-0 invisible">
|
||||
<Train className="h-4 w-4" />
|
||||
</div>
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Return</span>
|
||||
<span className="font-semibold">{selectedReturnSchedule.trainNumber}</span>
|
||||
<span className="text-muted-foreground">{selectedReturnSchedule.origin.name} → {selectedReturnSchedule.destination.name}</span>
|
||||
<span className="text-muted-foreground">· {formatDateTime(selectedReturnSchedule.departureAt)}</span>
|
||||
<span className="text-muted-foreground">· {selectedReturnClass.category}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" onClick={startOver} className="text-xs text-primary hover:underline shrink-0">
|
||||
Start over
|
||||
@@ -559,6 +685,24 @@ function GroupBookingPageContent() {
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Search Availability</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{(['ONE_WAY', 'ROUND_TRIP'] as const).map((tt) => (
|
||||
<button
|
||||
key={tt}
|
||||
type="button"
|
||||
onClick={() => { setTripType(tt); if (tt === 'ONE_WAY') setReturnDate(''); }}
|
||||
className={cn(
|
||||
'flex-1 rounded-lg border px-3 py-2 text-sm transition-colors',
|
||||
tripType === tt
|
||||
? 'border-primary bg-primary/5 text-foreground font-medium'
|
||||
: 'border-border text-muted-foreground hover:border-primary/50',
|
||||
)}
|
||||
>
|
||||
{tt === 'ONE_WAY' ? 'One Way' : 'Round Trip'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<div>
|
||||
<label className="label">Origin</label>
|
||||
@@ -610,6 +754,21 @@ function GroupBookingPageContent() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tripType === 'ROUND_TRIP' && (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<div>
|
||||
<label className="label">Return Date</label>
|
||||
<DatePicker
|
||||
value={returnDate}
|
||||
onChange={setReturnDate}
|
||||
placeholder="Pick a return date"
|
||||
minDate={travelDate ? new Date(`${travelDate}T00:00:00`) : new Date(new Date().setHours(0, 0, 0, 0))}
|
||||
className="w-full [&>button]:w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="label">Fare Tier</label>
|
||||
<div className="flex gap-2">
|
||||
@@ -648,7 +807,9 @@ function GroupBookingPageContent() {
|
||||
? 'Enter at least one adult or child.'
|
||||
: originStationId && originStationId === destinationStationId
|
||||
? 'Origin and destination must be different.'
|
||||
: 'Fill in origin, destination, and travel date.'}
|
||||
: tripType === 'ROUND_TRIP' && travelDate && !returnDate
|
||||
? 'Pick a return date.'
|
||||
: 'Fill in origin, destination, and travel date.'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -661,10 +822,35 @@ function GroupBookingPageContent() {
|
||||
{/* ── Step 2: Results ────────────────────────────────────────────── */}
|
||||
{step === 'results' && (
|
||||
<div className="space-y-4 animate-fade-up">
|
||||
<button type="button" onClick={() => setStep('search')} className="flex items-center gap-1 text-xs text-primary hover:underline">
|
||||
<ArrowLeft className="h-3.5 w-3.5" /> Back to search
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => tripType === 'ROUND_TRIP' && resultsPhase === 'return' ? setResultsPhase('outbound') : setStep('search')}
|
||||
className="flex items-center gap-1 text-xs text-primary hover:underline"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" /> {tripType === 'ROUND_TRIP' && resultsPhase === 'return' ? 'Back to outbound' : 'Back to search'}
|
||||
</button>
|
||||
|
||||
{tripType === 'ROUND_TRIP' && (
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{resultsPhase === 'outbound' ? 'Step 2a — Choose the outbound trip' : 'Step 2b — Choose the return trip'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{tripType === 'ROUND_TRIP' && resultsPhase === 'return' && selectedSchedule && selectedClass && (
|
||||
<div className="card flex items-center justify-between flex-wrap gap-3">
|
||||
<div className="flex items-center gap-3 text-sm flex-wrap">
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-600 dark:text-emerald-400 shrink-0" />
|
||||
<span className="font-semibold">{selectedSchedule.trainNumber}</span>
|
||||
<span className="text-muted-foreground">{selectedSchedule.origin.name} → {selectedSchedule.destination.name}</span>
|
||||
<span className="text-muted-foreground">· {formatDateTime(selectedSchedule.departureAt)}</span>
|
||||
<span className="text-muted-foreground">· {selectedClass.category}</span>
|
||||
</div>
|
||||
<button type="button" onClick={() => setResultsPhase('outbound')} className="text-xs text-primary hover:underline shrink-0">
|
||||
Change outbound
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{searchMutation.isPending && (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 2 }).map((_, i) => (
|
||||
@@ -696,28 +882,59 @@ function GroupBookingPageContent() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{searchMutation.isSuccess && searchMutation.data.outbound.length === 0 && (
|
||||
<div className="card py-12 text-center text-muted-foreground">
|
||||
<Train className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||
<p className="text-foreground font-medium">No schedules found for this search.</p>
|
||||
<p className="text-xs mt-1">{emptySearchMessage(searchMutation.data.outboundReason)}</p>
|
||||
</div>
|
||||
)}
|
||||
{searchMutation.isSuccess && resultsPhase === 'outbound' && (
|
||||
<>
|
||||
{searchMutation.data.outbound.length === 0 && (
|
||||
<div className="card py-12 text-center text-muted-foreground">
|
||||
<Train className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||
<p className="text-foreground font-medium">No schedules found for this search.</p>
|
||||
<p className="text-xs mt-1">{emptySearchMessage(searchMutation.data.outboundReason)}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{searchMutation.isSuccess && (searchMutation.data.alternativeOutbound?.length ?? 0) > 0 && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground flex items-center gap-1.5">
|
||||
<Sparkles className="h-3.5 w-3.5" /> Nearby schedules for the same route
|
||||
</p>
|
||||
{searchMutation.data!.alternativeOutbound!.map((schedule) => (
|
||||
<ScheduleCard key={schedule.scheduleId} schedule={schedule} totalPassengers={totalPassengers} onChoose={chooseClass} />
|
||||
{(searchMutation.data.alternativeOutbound?.length ?? 0) > 0 && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground flex items-center gap-1.5">
|
||||
<Sparkles className="h-3.5 w-3.5" /> Nearby schedules for the same route
|
||||
</p>
|
||||
{searchMutation.data.alternativeOutbound!.map((schedule) => (
|
||||
<ScheduleCard key={schedule.scheduleId} schedule={schedule} totalPassengers={totalPassengers} onChoose={chooseOutboundClass} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{searchMutation.data.outbound.map((schedule) => (
|
||||
<ScheduleCard key={schedule.scheduleId} schedule={schedule} totalPassengers={totalPassengers} onChoose={chooseOutboundClass} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(searchMutation.data?.outbound ?? []).map((schedule) => (
|
||||
<ScheduleCard key={schedule.scheduleId} schedule={schedule} totalPassengers={totalPassengers} onChoose={chooseClass} />
|
||||
))}
|
||||
{searchMutation.isSuccess && tripType === 'ROUND_TRIP' && resultsPhase === 'return' && (
|
||||
<>
|
||||
{(searchMutation.data.inbound?.length ?? 0) === 0 && (
|
||||
<div className="card py-12 text-center text-muted-foreground">
|
||||
<Train className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||
<p className="text-foreground font-medium">No return schedules found for this search.</p>
|
||||
<p className="text-xs mt-1">{emptySearchMessage(searchMutation.data.inboundReason)}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(searchMutation.data.alternativeInbound?.length ?? 0) > 0 && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground flex items-center gap-1.5">
|
||||
<Sparkles className="h-3.5 w-3.5" /> Nearby return schedules for the same route
|
||||
</p>
|
||||
{searchMutation.data.alternativeInbound!.map((schedule) => (
|
||||
<ScheduleCard key={schedule.scheduleId} schedule={schedule} totalPassengers={totalPassengers} onChoose={chooseReturnClass} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(searchMutation.data.inbound ?? []).map((schedule) => (
|
||||
<ScheduleCard key={schedule.scheduleId} schedule={schedule} totalPassengers={totalPassengers} onChoose={chooseReturnClass} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -735,10 +952,20 @@ function GroupBookingPageContent() {
|
||||
</div>
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Passenger Information</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-4 ml-9">
|
||||
<p className="text-sm text-muted-foreground mb-1 ml-9">
|
||||
Download the template, fill in one row per passenger, then upload the completed file.
|
||||
Need exactly <strong className="text-foreground">{totalPassengers}</strong> passenger{totalPassengers === 1 ? '' : 's'} ({adultCount} Adult{adultCount === 1 ? '' : 's'}{childCount > 0 ? `, ${childCount} Child${childCount === 1 ? '' : 'ren'}` : ''}).
|
||||
</p>
|
||||
{tripType === 'ONE_WAY' && childCount > 0 && (
|
||||
<p className="text-xs text-muted-foreground mb-4 ml-9">
|
||||
The first {Math.min(childCount, adultCount)} child{Math.min(childCount, adultCount) === 1 ? '' : 'ren'} under 5 (by row order) travel{Math.min(childCount, adultCount) === 1 ? 's' : ''} free with no assigned seat, matching the passenger portal's policy — one free child per adult. Any additional children get a seat and pay the child fare.
|
||||
</p>
|
||||
)}
|
||||
{tripType === 'ROUND_TRIP' && childCount > 0 && (
|
||||
<p className="text-xs text-muted-foreground mb-4 ml-9">
|
||||
Round trip requires a seat for every child on both legs — the free-child policy only applies to one-way bookings.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="ml-9 flex items-center gap-3 flex-wrap mb-4">
|
||||
<ActionButton icon={Download} variant="secondary" onClick={downloadTemplate}>
|
||||
@@ -810,12 +1037,19 @@ function GroupBookingPageContent() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{passengerRows.map((row) => (
|
||||
{passengerRows.map((row, i) => (
|
||||
<tr key={row.rowNumber} className={row.errors.length > 0 ? 'bg-red-50/60 dark:bg-red-950/20' : undefined}>
|
||||
<td className="px-4 py-2 text-xs text-muted-foreground">{row.rowNumber}</td>
|
||||
<td className="px-4 py-2 whitespace-nowrap">{row.fullName || '—'}</td>
|
||||
<td className="px-4 py-2 whitespace-nowrap text-muted-foreground">{row.dateOfBirth || '—'}</td>
|
||||
<td className="px-4 py-2 whitespace-nowrap">{row.passengerType || '—'}</td>
|
||||
<td className="px-4 py-2 whitespace-nowrap">
|
||||
{row.passengerType || '—'}
|
||||
{freeChildFlags[i] && (
|
||||
<span className="ml-1.5 inline-flex items-center rounded-full bg-emerald-100 dark:bg-emerald-900/30 px-1.5 py-0.5 text-[10px] font-semibold text-emerald-700 dark:text-emerald-400">
|
||||
Free · no seat
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 whitespace-nowrap text-muted-foreground">{row.idDocumentType || '—'}</td>
|
||||
<td className="px-4 py-2 whitespace-nowrap text-muted-foreground">{row.nationality || '—'}</td>
|
||||
<td className="px-4 py-2 text-xs">
|
||||
@@ -876,20 +1110,53 @@ function GroupBookingPageContent() {
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-4">Read-only — seats are assigned by the system, not selected manually.</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
{seatAssignments.map(({ row, seat }) => (
|
||||
{seatAssignments.map(({ row, seat, isFree }) => (
|
||||
<div key={row.rowNumber} className="flex items-center gap-3 rounded-lg border border-border p-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 font-mono text-xs font-bold text-primary">
|
||||
{seat ? (seat.seatNumber ?? seat.label ?? '?') : '—'}
|
||||
<div className={cn(
|
||||
'flex h-10 w-10 shrink-0 items-center justify-center rounded-lg font-mono text-xs font-bold',
|
||||
isFree ? 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-400' : 'bg-primary/10 text-primary',
|
||||
)}>
|
||||
{isFree ? 'Free' : seat ? (seat.seatNumber ?? seat.label ?? '?') : '—'}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{row.fullName}</p>
|
||||
<p className="text-xs text-muted-foreground">{row.passengerType} · {seat?.coach ?? '—'}</p>
|
||||
<p className="text-xs text-muted-foreground">{row.passengerType} · {isFree ? 'no seat' : (seat?.coach ?? '—')}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tripType === 'ROUND_TRIP' && returnHold && (
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="rounded-lg bg-primary/10 p-1.5">
|
||||
<ArmchairIcon className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Return Seats Assigned Automatically
|
||||
</h3>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">Held for {Math.floor(returnHold.ttlSeconds / 60)}m {returnHold.ttlSeconds % 60}s</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-4">Read-only — seats are assigned by the system, not selected manually.</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
{seatAssignments.map(({ row, returnSeat }) => (
|
||||
<div key={row.rowNumber} className="flex items-center gap-3 rounded-lg border border-border p-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 font-mono text-xs font-bold text-primary">
|
||||
{returnSeat ? (returnSeat.seatNumber ?? returnSeat.label ?? '?') : '—'}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{row.fullName}</p>
|
||||
<p className="text-xs text-muted-foreground">{row.passengerType} · {returnSeat?.coach ?? '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bookingError && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-950/30 p-3 text-sm text-red-700 dark:text-red-300 flex items-center justify-between gap-3">
|
||||
<span className="flex items-center gap-2"><AlertTriangle className="h-4 w-4 shrink-0" /> {bookingError}</span>
|
||||
@@ -899,7 +1166,16 @@ function GroupBookingPageContent() {
|
||||
|
||||
<div className="card flex items-center justify-between flex-wrap gap-3">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{totalPassengers} passenger{totalPassengers === 1 ? '' : 's'} · {selectedClass.category} · <span className="font-semibold text-foreground">{formatCurrency(selectedClass.fareMinor * totalPassengers, selectedClass.displayCurrency)}</span> estimated total
|
||||
{totalPassengers} passenger{totalPassengers === 1 ? '' : 's'}
|
||||
{tripType === 'ONE_WAY' && freeChildrenCount > 0 && <> ({freeChildrenCount} free)</>} · {selectedClass.category}
|
||||
{tripType === 'ROUND_TRIP' && selectedReturnClass && <> / {selectedReturnClass.category}</>} ·{' '}
|
||||
<span className="font-semibold text-foreground">
|
||||
{formatCurrency(
|
||||
selectedClass.fareMinor * (tripType === 'ONE_WAY' ? adultCount + paidChildrenCount : totalPassengers)
|
||||
+ (selectedReturnClass?.fareMinor ?? 0) * totalPassengers,
|
||||
selectedClass.displayCurrency,
|
||||
)}
|
||||
</span> estimated total
|
||||
</div>
|
||||
<ActionButton
|
||||
icon={CheckCircle2}
|
||||
@@ -947,7 +1223,10 @@ function GroupBookingPageContent() {
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Coach / Class</p>
|
||||
<p className="font-semibold">{selectedClass?.category ?? '—'}</p>
|
||||
<p className="font-semibold">
|
||||
{selectedClass?.category ?? '—'}
|
||||
{tripType === 'ROUND_TRIP' && selectedReturnClass && <> / {selectedReturnClass.category}</>}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Adults / Children</p>
|
||||
@@ -955,7 +1234,7 @@ function GroupBookingPageContent() {
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Total Passengers</p>
|
||||
<p className="font-semibold">{booking.seats.length}</p>
|
||||
<p className="font-semibold">{totalPassengers}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Total Fare</p>
|
||||
@@ -964,12 +1243,38 @@ function GroupBookingPageContent() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tripType === 'ROUND_TRIP' && selectedReturnSchedule && (
|
||||
<div className="card">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-3">Return Trip</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Train</p>
|
||||
<p className="font-semibold">{selectedReturnSchedule.trainNumber} · {selectedReturnSchedule.trainName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Origin → Destination</p>
|
||||
<p className="font-semibold">{selectedReturnSchedule.origin.name} → {selectedReturnSchedule.destination.name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Departure → Arrival</p>
|
||||
<p className="font-semibold">{formatDateTime(selectedReturnSchedule.departureAt)} → {formatDateTime(selectedReturnSchedule.arrivalAt)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Class</p>
|
||||
<p className="font-semibold">{selectedReturnClass?.category ?? '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card p-0">
|
||||
<div className="px-4 pt-4 pb-3">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Passenger List</h3>
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{tripType === 'ROUND_TRIP' ? 'Outbound Passenger List' : 'Passenger List'}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3 px-4 pb-4">
|
||||
{booking.seats.map((s, i) => (
|
||||
{booking.seats.filter((s) => s.leg !== 2).map((s, i) => (
|
||||
<div key={i} className="flex items-center gap-3 rounded-lg border border-border p-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 font-mono text-xs font-bold text-primary">
|
||||
{s.seat?.seatNumber ?? '?'}
|
||||
@@ -982,9 +1287,45 @@ function GroupBookingPageContent() {
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{/* Free children have no BookingSeat row at all — list them separately so they
|
||||
don't silently disappear from the confirmation. */}
|
||||
{seatAssignments.filter((a) => a.isFree).map(({ row }) => (
|
||||
<div key={row.rowNumber} className="flex items-center gap-3 rounded-lg border border-border p-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-emerald-100 dark:bg-emerald-900/30 font-mono text-xs font-bold text-emerald-700 dark:text-emerald-400">
|
||||
Free
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{row.fullName}</p>
|
||||
<p className="text-xs text-muted-foreground">CHILD · no seat (free)</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tripType === 'ROUND_TRIP' && (
|
||||
<div className="card p-0">
|
||||
<div className="px-4 pt-4 pb-3">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Return Passenger List</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3 px-4 pb-4">
|
||||
{booking.seats.filter((s) => s.leg === 2).map((s, i) => (
|
||||
<div key={i} className="flex items-center gap-3 rounded-lg border border-border p-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 font-mono text-xs font-bold text-primary">
|
||||
{s.seat?.seatNumber ?? '?'}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{s.passengerName}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{s.passengerCategory} · {seatTypeLabel(s.seat?.bedPosition)} · Seat {s.seat?.seatNumber ?? '—'} · Coach {s.seat?.coach?.number}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Pay now ─────────────────────────────────────────────────── */}
|
||||
{!paymentResult && (
|
||||
<div className="card">
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
TicketCheck, Users, TrendingUp, ShieldCheck, MailCheck,
|
||||
} from 'lucide-react';
|
||||
import { iamAuthApi } from '@/lib/api/auth';
|
||||
import { getErrorMessage } from '@/lib/api-client';
|
||||
|
||||
const EDR_GREEN = 'rgb(20, 113, 76)';
|
||||
|
||||
@@ -52,11 +53,11 @@ export default function LoginPage() {
|
||||
await login(identifier.trim(), password);
|
||||
router.push('/dashboard');
|
||||
} catch (err: any) {
|
||||
const msg = err.message || err.response?.data?.message || '';
|
||||
if (msg === 'ACCESS_DENIED') {
|
||||
const rawMessage = err.response?.data?.message;
|
||||
if (rawMessage === 'ACCESS_DENIED') {
|
||||
setError('This account does not have back-office access. Contact your administrator.');
|
||||
} else {
|
||||
setError(err.response?.data?.message || msg || 'Invalid credentials. Please try again.');
|
||||
setError(getErrorMessage(err, 'Invalid credentials. Please try again.'));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -71,11 +72,11 @@ export default function LoginPage() {
|
||||
await iamAuthApi.forgotPassword(forgotIdentifier.trim());
|
||||
setForgotSent(true);
|
||||
} catch (err: any) {
|
||||
const msg = err.response?.data?.message || err.message || '';
|
||||
const rawMessage = err.response?.data?.message;
|
||||
setForgotError(
|
||||
msg === 'user_not_found'
|
||||
rawMessage === 'user_not_found'
|
||||
? 'No account found with that email or phone number.'
|
||||
: msg || 'Failed to send the reset link. Please try again.'
|
||||
: getErrorMessage(err, 'Failed to send the reset link. Please try again.')
|
||||
);
|
||||
} finally {
|
||||
setForgotLoading(false);
|
||||
|
||||
@@ -10,6 +10,7 @@ import Modal from '@/components/ui/Modal';
|
||||
import Pagination from '@/components/ui/Pagination';
|
||||
import { packagesApi } from '@/lib/api';
|
||||
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
||||
import { getErrorMessage } from '@/lib/api-client';
|
||||
|
||||
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
|
||||
<div className="bg-muted/40 rounded-lg p-3">
|
||||
@@ -116,7 +117,7 @@ export default function PackageBookingsPage() {
|
||||
<div className="card">
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 p-4 text-sm text-red-800 dark:text-red-200">
|
||||
Error: {(error as any)?.response?.data?.message || (error as any)?.message || String(error)}
|
||||
Error: {getErrorMessage(error)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-3 mb-4">
|
||||
|
||||
@@ -2,15 +2,21 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Edit, CheckCircle, Eye, Layers, Trash2 } from 'lucide-react';
|
||||
import { Plus, Edit, CheckCircle, Eye, Layers, Trash2, ImagePlus, ImageOff, X } from 'lucide-react';
|
||||
import DataTable from '@/components/ui/DataTable';
|
||||
import Badge from '@/components/ui/Badge';
|
||||
import ActionButton from '@/components/ui/ActionButton';
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import { packagesApi, stationsApi, schedulesApi, seatClassesApi } from '@/lib/api';
|
||||
import { getErrorMessage } from '@/lib/api-client';
|
||||
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
||||
|
||||
// Mirrors the backend's own limits (packages/package-image-upload.options.ts) so a bad file is
|
||||
// rejected instantly client-side instead of round-tripping to the server first.
|
||||
const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
|
||||
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
const toLocal = (iso?: string) => {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
@@ -48,6 +54,15 @@ export default function PackagesPage() {
|
||||
const [deletePackageConfirm, setDeletePackageConfirm] = useState<any>(null);
|
||||
const [deletePackageError, setDeletePackageError] = useState<string | null>(null);
|
||||
const [deletePackageCascade, setDeletePackageCascade] = useState(false);
|
||||
// Image upload: `imageFile`/`imagePreviewUrl` track a newly-selected-but-not-yet-uploaded file
|
||||
// (local object URL preview); `existingImageUrl` is the package's current server-side image
|
||||
// when editing, shown until/unless the admin picks a replacement.
|
||||
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||
const [imagePreviewUrl, setImagePreviewUrl] = useState<string | null>(null);
|
||||
const [existingImageUrl, setExistingImageUrl] = useState<string | null>(null);
|
||||
const [imageError, setImageError] = useState<string | null>(null);
|
||||
const [imageUploadError, setImageUploadError] = useState<string | null>(null);
|
||||
const [removeImageConfirm, setRemoveImageConfirm] = useState<any>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
@@ -141,6 +156,23 @@ export default function PackagesPage() {
|
||||
onError: (e: any) => setTierError(e?.response?.data?.message || e?.message || 'Failed to delete tier'),
|
||||
});
|
||||
|
||||
const uploadImageMutation = useMutation({
|
||||
mutationFn: ({ id, file }: { id: string; file: File }) => packagesApi.uploadImage(id, file),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['packages'] }); setImageUploadError(null); },
|
||||
onError: (e: any) => setImageUploadError(getErrorMessage(e, 'Failed to upload package image')),
|
||||
});
|
||||
|
||||
const removeImageMutation = useMutation({
|
||||
mutationFn: (id: string) => packagesApi.removeImage(id),
|
||||
onSuccess: (updated: any) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['packages'] });
|
||||
setRemoveImageConfirm(null);
|
||||
setExistingImageUrl(updated?.imageUrl ?? null);
|
||||
setViewPackage((prev: any) => (prev && prev.id === updated?.id ? { ...prev, imageUrl: null } : prev));
|
||||
},
|
||||
onError: (e: any) => setImageUploadError(getErrorMessage(e, 'Failed to remove package image')),
|
||||
});
|
||||
|
||||
const openEditTier = (tier: any) => {
|
||||
setEditingTier(tier);
|
||||
setTierForm({ seatClassId: tier.seatClassId ?? '', seatType: tier.seatType, label: tier.label, priceMinor: String(tier.priceMinor), availableSeats: String(tier.availableSeats) });
|
||||
@@ -163,13 +195,29 @@ export default function PackagesPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// Deliberately does not touch imageUploadError — that's shown in a page-level banner (outside
|
||||
// this modal) precisely because it can still be set after the modal has already auto-closed
|
||||
// (see handleSubmit), and clearing it here would wipe it out before the user ever sees it.
|
||||
const resetImageSelection = () => {
|
||||
setImageFile(null);
|
||||
if (imagePreviewUrl) URL.revokeObjectURL(imagePreviewUrl);
|
||||
setImagePreviewUrl(null);
|
||||
setImageError(null);
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setForm(emptyForm);
|
||||
setEditingId(null);
|
||||
resetImageSelection();
|
||||
setImageUploadError(null);
|
||||
setExistingImageUrl(null);
|
||||
setModalMode('create');
|
||||
};
|
||||
|
||||
const openEdit = (pkg: any) => {
|
||||
resetImageSelection();
|
||||
setImageUploadError(null);
|
||||
setExistingImageUrl(pkg.imageUrl ?? null);
|
||||
setForm({
|
||||
code: pkg.code ?? '',
|
||||
name: pkg.name ?? '',
|
||||
@@ -216,11 +264,23 @@ export default function PackagesPage() {
|
||||
validUntil: form.validUntil,
|
||||
priceTiers: [],
|
||||
};
|
||||
// The image is uploaded as a separate follow-up call (the DTO here carries no image field —
|
||||
// see packages.service.ts's uploadImage) so it must run after the package itself exists.
|
||||
let targetId = editingId;
|
||||
if (modalMode === 'edit' && editingId) {
|
||||
await updateMutation.mutateAsync({ id: editingId, data: payload });
|
||||
} else {
|
||||
await createMutation.mutateAsync(payload);
|
||||
const created = await createMutation.mutateAsync(payload);
|
||||
targetId = created?.id ?? null;
|
||||
}
|
||||
if (imageFile && targetId) {
|
||||
try {
|
||||
await uploadImageMutation.mutateAsync({ id: targetId, file: imageFile });
|
||||
} catch {
|
||||
// surfaced via imageUploadError banner — the package itself was already saved successfully
|
||||
}
|
||||
}
|
||||
resetImageSelection();
|
||||
};
|
||||
|
||||
const field = (key: keyof typeof form) => ({
|
||||
@@ -229,6 +289,24 @@ export default function PackagesPage() {
|
||||
setForm((f) => ({ ...f, [key]: e.target.value })),
|
||||
});
|
||||
|
||||
const handleImageFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = ''; // allow re-selecting the same file after a validation error
|
||||
if (!file) return;
|
||||
if (!ALLOWED_IMAGE_TYPES.includes(file.type)) {
|
||||
setImageError('Image must be JPEG, PNG, WEBP, or GIF.');
|
||||
return;
|
||||
}
|
||||
if (file.size > MAX_IMAGE_BYTES) {
|
||||
setImageError(`Image must be ${Math.round(MAX_IMAGE_BYTES / (1024 * 1024))}MB or smaller.`);
|
||||
return;
|
||||
}
|
||||
setImageError(null);
|
||||
if (imagePreviewUrl) URL.revokeObjectURL(imagePreviewUrl);
|
||||
setImageFile(file);
|
||||
setImagePreviewUrl(URL.createObjectURL(file));
|
||||
};
|
||||
|
||||
const scheduleLabel = (s: any) => {
|
||||
const from = s.originStation?.name ?? s.originStationId ?? '?';
|
||||
const to = s.destinationStation?.name ?? s.destinationStationId ?? '?';
|
||||
@@ -237,7 +315,19 @@ export default function PackagesPage() {
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ key: 'code', label: 'Package',
|
||||
{
|
||||
key: 'image', label: '',
|
||||
render: (p: any) => (
|
||||
p.imageUrl ? (
|
||||
<img src={p.imageUrl} alt="" className="h-10 w-10 rounded object-cover border border-border" />
|
||||
) : (
|
||||
<div className="h-10 w-10 rounded border border-border bg-muted flex items-center justify-center">
|
||||
<ImageOff className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
),
|
||||
},
|
||||
{ key: 'code', label: 'Package',
|
||||
render: (pkg: any) => (
|
||||
<div className="text-sm">
|
||||
<div>{pkg.code}</div>
|
||||
@@ -299,7 +389,7 @@ export default function PackagesPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||
const isPending = createMutation.isPending || updateMutation.isPending || uploadImageMutation.isPending;
|
||||
|
||||
const allItems: any[] = data?.items || [];
|
||||
const filteredItems = allItems.filter((p) => {
|
||||
@@ -320,6 +410,18 @@ export default function PackagesPage() {
|
||||
<ActionButton icon={Plus} onClick={openCreate}>New Package</ActionButton>
|
||||
</div>
|
||||
|
||||
{/* The package itself may already be saved and this modal closed by the time an image
|
||||
upload/removal fails (see handleSubmit) — surfaced here rather than inside the modal
|
||||
so it's never silently lost. */}
|
||||
{imageUploadError && (
|
||||
<div className="flex items-center justify-between rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 text-sm text-red-800 dark:text-red-300">
|
||||
<span>{imageUploadError}</span>
|
||||
<button type="button" className="ml-3 shrink-0" onClick={() => setImageUploadError(null)}>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
<div className="mb-4 space-y-3">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
@@ -366,6 +468,14 @@ export default function PackagesPage() {
|
||||
<Modal isOpen={!!viewPackage} onClose={() => setViewPackage(null)} title="Package Details" size="lg">
|
||||
{viewPackage && (
|
||||
<div className="space-y-4 text-sm">
|
||||
{viewPackage.imageUrl ? (
|
||||
<img src={viewPackage.imageUrl} alt={viewPackage.name} className="w-full max-h-56 rounded-lg object-cover border border-border" />
|
||||
) : (
|
||||
<div className="w-full h-32 rounded-lg border border-dashed border-border bg-muted flex items-center justify-center gap-2 text-muted-foreground">
|
||||
<ImageOff className="h-5 w-5" />
|
||||
<span>No image</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div><span className="label">Code</span><p className="font-mono font-semibold">{viewPackage.code}</p></div>
|
||||
<div><span className="label">Status</span><p>{viewPackage.status}</p></div>
|
||||
@@ -549,10 +659,23 @@ export default function PackagesPage() {
|
||||
error={tierError ?? undefined}
|
||||
/>
|
||||
|
||||
{/* Remove Image Confirmation */}
|
||||
<ConfirmDialog
|
||||
isOpen={!!removeImageConfirm}
|
||||
onClose={() => setRemoveImageConfirm(null)}
|
||||
onConfirm={() => removeImageMutation.mutate(removeImageConfirm.id)}
|
||||
title="Remove Package Image"
|
||||
message={`Remove the image for "${removeImageConfirm?.name}"? The package itself will not be deleted.`}
|
||||
confirmText="Remove Image"
|
||||
isDanger
|
||||
isLoading={removeImageMutation.isPending}
|
||||
error={imageUploadError ?? undefined}
|
||||
/>
|
||||
|
||||
{/* Create / Edit Modal */}
|
||||
<Modal
|
||||
isOpen={modalMode !== null}
|
||||
onClose={() => setModalMode(null)}
|
||||
onClose={() => { setModalMode(null); resetImageSelection(); }}
|
||||
title={modalMode === 'edit' ? 'Edit Package' : 'New Package'}
|
||||
size="lg"
|
||||
>
|
||||
@@ -571,6 +694,40 @@ export default function PackagesPage() {
|
||||
<textarea className="input" rows={2} placeholder="Optional description" {...field('description')} />
|
||||
</div>
|
||||
|
||||
<div className="col-span-2">
|
||||
<label className="label">Package Image</label>
|
||||
<div className="flex items-start gap-4">
|
||||
{imagePreviewUrl ? (
|
||||
<img src={imagePreviewUrl} alt="Preview" className="h-24 w-24 rounded-lg object-cover border border-border" />
|
||||
) : existingImageUrl ? (
|
||||
<img src={existingImageUrl} alt="Current" className="h-24 w-24 rounded-lg object-cover border border-border" />
|
||||
) : (
|
||||
<div className="h-24 w-24 rounded-lg border border-dashed border-border bg-muted flex items-center justify-center">
|
||||
<ImagePlus className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 space-y-2">
|
||||
<input type="file" accept="image/jpeg,image/png,image/webp,image/gif" className="input" onChange={handleImageFileChange} />
|
||||
<p className="text-xs text-muted-foreground">JPEG, PNG, WEBP, or GIF. Max 5MB.</p>
|
||||
{imageError && <p className="text-xs text-red-600 dark:text-red-400">{imageError}</p>}
|
||||
{imageFile && (
|
||||
<button type="button" className="text-xs text-primary underline" onClick={resetImageSelection}>
|
||||
<X className="h-3 w-3 inline -mt-0.5 mr-0.5" />Clear selected file
|
||||
</button>
|
||||
)}
|
||||
{!imageFile && modalMode === 'edit' && existingImageUrl && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-red-600 dark:text-red-400 underline block"
|
||||
onClick={() => { setImageUploadError(null); setRemoveImageConfirm({ id: editingId, name: form.name }); }}
|
||||
>
|
||||
Remove current image
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Origin Station *</label>
|
||||
<select className="input" required {...field('originStationId')}>
|
||||
@@ -660,7 +817,7 @@ export default function PackagesPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<ActionButton type="button" variant="secondary" onClick={() => setModalMode(null)}>Cancel</ActionButton>
|
||||
<ActionButton type="button" variant="secondary" onClick={() => { setModalMode(null); resetImageSelection(); }}>Cancel</ActionButton>
|
||||
<ActionButton type="submit" loading={isPending}>
|
||||
{modalMode === 'edit' ? 'Update Package' : 'Create Package'}
|
||||
</ActionButton>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { Eye, EyeOff, ArrowRight, ArrowLeft, Loader2, CheckCircle2 } from 'lucide-react';
|
||||
import { iamAuthApi } from '@/lib/api/auth';
|
||||
import { getErrorMessage } from '@/lib/api-client';
|
||||
|
||||
function ResetPasswordForm() {
|
||||
const router = useRouter();
|
||||
@@ -41,8 +42,7 @@ function ResetPasswordForm() {
|
||||
setSuccess(true);
|
||||
setTimeout(() => router.push('/login'), 2000);
|
||||
} catch (err: any) {
|
||||
const msg = err.response?.data?.message || err.message || '';
|
||||
setError(msg || 'Failed to reset password. The link may have expired — request a new one from the sign-in page.');
|
||||
setError(getErrorMessage(err, 'Failed to reset password. The link may have expired — request a new one from the sign-in page.'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -30,9 +30,22 @@ interface Schedule {
|
||||
destinationStation?: { id: string; name: string };
|
||||
coachAssignments?: Array<{ coachId: string; positionNumber: number; coach?: { id: string; number: string } }>;
|
||||
isPackageOnly?: boolean;
|
||||
isGroupBookingOnly?: boolean;
|
||||
liveStatus?: { delayMinutes: number } | null;
|
||||
}
|
||||
|
||||
/** Mutually-exclusive UI view over the two independent isPackageOnly/isGroupBookingOnly flags
|
||||
* the API actually stores — same pair, just presented as one choice instead of two checkboxes. */
|
||||
type ScheduleVisibility = 'NORMAL' | 'PACKAGE_ONLY' | 'GROUP_ONLY';
|
||||
function visibilityOf(isPackageOnly?: boolean, isGroupBookingOnly?: boolean): ScheduleVisibility {
|
||||
if (isGroupBookingOnly) return 'GROUP_ONLY';
|
||||
if (isPackageOnly) return 'PACKAGE_ONLY';
|
||||
return 'NORMAL';
|
||||
}
|
||||
function visibilityFlags(v: ScheduleVisibility): { isPackageOnly: boolean; isGroupBookingOnly: boolean } {
|
||||
return { isPackageOnly: v === 'PACKAGE_ONLY', isGroupBookingOnly: v === 'GROUP_ONLY' };
|
||||
}
|
||||
|
||||
interface Train {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -84,7 +97,7 @@ function SchedulesPageContent() {
|
||||
|
||||
const [bulkCoachRows, setBulkCoachRows] = useState<{ coachId: string; positionNumber: number }[]>([]);
|
||||
|
||||
const [addForm, setAddForm] = useState({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' });
|
||||
const [addForm, setAddForm] = useState({ trainId: '', routeId: '', departureAt: '', arrivalAt: '', isPackageOnly: false, isGroupBookingOnly: false });
|
||||
const [addCoachRows, setAddCoachRows] = useState<{ coachId: string; positionNumber: number }[]>([]);
|
||||
|
||||
const { data: singleRouteTemplate, isLoading: singleTemplateLoading } = useQuery({
|
||||
@@ -122,6 +135,7 @@ function SchedulesPageContent() {
|
||||
status: 'SCHEDULED',
|
||||
coachIds: [] as string[],
|
||||
isPackageOnly: false,
|
||||
isGroupBookingOnly: false,
|
||||
});
|
||||
|
||||
const [filters, setFilters] = useState({
|
||||
@@ -188,7 +202,7 @@ function SchedulesPageContent() {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['schedules'] });
|
||||
setShowAddModal(false);
|
||||
setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' });
|
||||
setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '', isPackageOnly: false, isGroupBookingOnly: false });
|
||||
setAddCoachRows([]);
|
||||
setError(null);
|
||||
},
|
||||
@@ -288,6 +302,8 @@ function SchedulesPageContent() {
|
||||
routeId: addForm.routeId,
|
||||
departureAt: dep.toISOString(),
|
||||
arrivalAt: arr.toISOString(),
|
||||
isPackageOnly: addForm.isPackageOnly,
|
||||
isGroupBookingOnly: addForm.isGroupBookingOnly,
|
||||
...(validCoaches.length > 0 && { coachIds: validCoaches.map((r) => r.coachId) }),
|
||||
});
|
||||
};
|
||||
@@ -312,6 +328,7 @@ function SchedulesPageContent() {
|
||||
arrivalAt: eatLocalToISO(editForm.arrivalAt),
|
||||
status: editForm.status,
|
||||
isPackageOnly: editForm.isPackageOnly,
|
||||
isGroupBookingOnly: editForm.isGroupBookingOnly,
|
||||
coaches: editForm.coachIds.map((coachId: string, idx: number) => ({
|
||||
coachId,
|
||||
positionNumber: idx + 1,
|
||||
@@ -356,6 +373,7 @@ function SchedulesPageContent() {
|
||||
status: schedule.status,
|
||||
coachIds: schedule.coachAssignments?.map((ca: any) => ca.coachId) || [],
|
||||
isPackageOnly: schedule.isPackageOnly ?? false,
|
||||
isGroupBookingOnly: schedule.isGroupBookingOnly ?? false,
|
||||
});
|
||||
setError(null);
|
||||
setShowEditModal(true);
|
||||
@@ -494,6 +512,9 @@ function SchedulesPageContent() {
|
||||
{schedule.isPackageOnly && (
|
||||
<span className="edr-badge edr-badge-warning">PKG</span>
|
||||
)}
|
||||
{schedule.isGroupBookingOnly && (
|
||||
<span className="edr-badge edr-badge-warning">GROUP</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -778,7 +799,7 @@ function SchedulesPageContent() {
|
||||
|
||||
<Modal
|
||||
isOpen={showAddModal}
|
||||
onClose={() => { setShowAddModal(false); setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' }); setAddCoachRows([]); setError(null); }}
|
||||
onClose={() => { setShowAddModal(false); setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '', isPackageOnly: false, isGroupBookingOnly: false }); setAddCoachRows([]); setError(null); }}
|
||||
title="Add Schedule"
|
||||
size="xl"
|
||||
>
|
||||
@@ -817,6 +838,37 @@ function SchedulesPageContent() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<label className="label mb-2">Visibility</label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
{([
|
||||
{ value: 'NORMAL', title: 'Normal', desc: 'Bookable through the passenger portal like any other schedule' },
|
||||
{ value: 'PACKAGE_ONLY', title: 'Package Only', desc: 'Hide from public search — reserved for package bookings' },
|
||||
{ value: 'GROUP_ONLY', title: 'Group Booking Only', desc: 'Hide from public search — reserved for staff group bookings' },
|
||||
] as const).map((opt) => {
|
||||
const selected = visibilityOf(addForm.isPackageOnly, addForm.isGroupBookingOnly) === opt.value;
|
||||
return (
|
||||
<label
|
||||
key={opt.value}
|
||||
className={`flex items-start gap-2 p-3 rounded-lg border cursor-pointer transition-colors ${selected ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/50'}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="add-visibility"
|
||||
className="w-4 h-4 mt-0.5"
|
||||
checked={selected}
|
||||
onChange={() => setAddForm({ ...addForm, ...visibilityFlags(opt.value) })}
|
||||
/>
|
||||
<span className="text-sm">
|
||||
<span className="font-medium block">{opt.title}</span>
|
||||
<span className="block text-xs text-muted-foreground">{opt.desc}</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="label mb-0">Coaches</label>
|
||||
@@ -875,7 +927,7 @@ function SchedulesPageContent() {
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<ActionButton type="button" variant="secondary" onClick={() => { setShowAddModal(false); setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' }); setAddCoachRows([]); setError(null); }}>Cancel</ActionButton>
|
||||
<ActionButton type="button" variant="secondary" onClick={() => { setShowAddModal(false); setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '', isPackageOnly: false, isGroupBookingOnly: false }); setAddCoachRows([]); setError(null); }}>Cancel</ActionButton>
|
||||
<ActionButton type="submit" loading={createScheduleMutation.isPending}>Create Schedule</ActionButton>
|
||||
</div>
|
||||
</form>
|
||||
@@ -1155,18 +1207,35 @@ function SchedulesPageContent() {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg border border-border">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="isPackageOnly"
|
||||
checked={editForm.isPackageOnly}
|
||||
onChange={(e) => setEditForm({ ...editForm, isPackageOnly: e.target.checked })}
|
||||
className="w-4 h-4 rounded"
|
||||
/>
|
||||
<label htmlFor="isPackageOnly" className="text-sm cursor-pointer">
|
||||
<span className="font-medium">Package Only</span>
|
||||
<span className="block text-xs text-muted-foreground">Hide from public search — reserved for package bookings</span>
|
||||
</label>
|
||||
<div>
|
||||
<label className="label mb-2">Visibility</label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
{([
|
||||
{ value: 'NORMAL', title: 'Normal', desc: 'Bookable through the passenger portal like any other schedule' },
|
||||
{ value: 'PACKAGE_ONLY', title: 'Package Only', desc: 'Hide from public search — reserved for package bookings' },
|
||||
{ value: 'GROUP_ONLY', title: 'Group Booking Only', desc: 'Hide from public search — reserved for staff group bookings' },
|
||||
] as const).map((opt) => {
|
||||
const selected = visibilityOf(editForm.isPackageOnly, editForm.isGroupBookingOnly) === opt.value;
|
||||
return (
|
||||
<label
|
||||
key={opt.value}
|
||||
className={`flex items-start gap-2 p-3 rounded-lg border cursor-pointer transition-colors ${selected ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/50'}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="edit-visibility"
|
||||
className="w-4 h-4 mt-0.5"
|
||||
checked={selected}
|
||||
onChange={() => setEditForm({ ...editForm, ...visibilityFlags(opt.value) })}
|
||||
/>
|
||||
<span className="text-sm">
|
||||
<span className="font-medium block">{opt.title}</span>
|
||||
<span className="block text-xs text-muted-foreground">{opt.desc}</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -2,6 +2,30 @@ import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
|
||||
|
||||
const GENERIC_ERROR_MESSAGE = 'Something went wrong. Please try again.';
|
||||
const NETWORK_ERROR_MESSAGE = 'Could not reach the server. Please check your connection and try again.';
|
||||
|
||||
/**
|
||||
* Extracts a user-facing message from a failed request. Prefers a real backend-provided message
|
||||
* (joining a NestJS validation array into one line); otherwise falls back to a friendly generic
|
||||
* message — never raw client/network text like "Request failed with status code 500" or
|
||||
* "Network Error", which is what axios puts in `error.message` when there's nothing better.
|
||||
* Every page in this app should use this (or rely on the response interceptor below, which
|
||||
* normalizes the same error in place) instead of reading `err.message` directly.
|
||||
*/
|
||||
export function getErrorMessage(error: unknown, fallback: string = GENERIC_ERROR_MESSAGE): string {
|
||||
const err = error as any;
|
||||
const raw = err?.response?.data?.message;
|
||||
if (Array.isArray(raw) && raw.length > 0) {
|
||||
const joined = raw.filter((m: unknown) => typeof m === 'string' && m.trim()).join('; ');
|
||||
if (joined) return joined;
|
||||
} else if (typeof raw === 'string' && raw.trim()) {
|
||||
return raw;
|
||||
}
|
||||
if (err?.isAxiosError && !err.response) return NETWORK_ERROR_MESSAGE;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
class ApiClient {
|
||||
private client: AxiosInstance;
|
||||
|
||||
@@ -30,6 +54,20 @@ class ApiClient {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize in place so every existing `err?.response?.data?.message || err?.message ||
|
||||
// '<fallback>'` call site across the app picks up a friendly message automatically,
|
||||
// instead of raw axios/network text or an unjoined NestJS validation array.
|
||||
try {
|
||||
const friendly = getErrorMessage(error);
|
||||
if (error.response?.data && typeof error.response.data === 'object') {
|
||||
error.response.data.message = friendly;
|
||||
}
|
||||
error.message = friendly;
|
||||
} catch {
|
||||
// Best-effort — never let normalization itself break the original rejection.
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -8,9 +8,15 @@ export interface SearchTripsRequest {
|
||||
date: string;
|
||||
adultCount: number;
|
||||
childCount?: number;
|
||||
journeyType: 'ONE_WAY';
|
||||
journeyType: 'ONE_WAY' | 'ROUND_TRIP';
|
||||
/** Required when journeyType is ROUND_TRIP. */
|
||||
returnDate?: string;
|
||||
/** Drives which fare tier (Local vs International) gets quoted — see fareTier in the page component. */
|
||||
nationality?: string;
|
||||
/** Always 'GROUP_BOOKING' for this app — search returns ONLY schedules marked
|
||||
* isGroupBookingOnly (an exclusive partition, not additive): normal passenger-facing
|
||||
* schedules never show up here, and group-only schedules never show up in the portal. */
|
||||
channel?: 'PORTAL' | 'GROUP_BOOKING';
|
||||
}
|
||||
|
||||
export interface ScheduleClassOption {
|
||||
@@ -50,6 +56,7 @@ export type SearchEmptyReasonCode =
|
||||
| 'NO_SCHEDULE_ON_DATE'
|
||||
| 'CANCELLED'
|
||||
| 'PACKAGE_ONLY'
|
||||
| 'GROUP_BOOKING_ONLY'
|
||||
| 'CHECKIN_CLOSED'
|
||||
| 'FULLY_BOOKED';
|
||||
|
||||
@@ -67,6 +74,11 @@ export interface SearchTripsResponse {
|
||||
outboundReason?: SearchEmptyReason;
|
||||
/** Nearby schedules for the same station pair on a different date, offered when `outbound` is empty. */
|
||||
alternativeOutbound?: ScheduleResult[];
|
||||
/** Return-leg schedules — present when the request's journeyType was ROUND_TRIP. */
|
||||
inbound?: ScheduleResult[];
|
||||
requestedReturnDate?: string;
|
||||
inboundReason?: SearchEmptyReason;
|
||||
alternativeInbound?: ScheduleResult[];
|
||||
}
|
||||
|
||||
// ── Seat classes (GET /seat-classes) ───────────────────────────────────────
|
||||
@@ -85,6 +97,8 @@ export interface AutoAssignHoldRequest {
|
||||
seatClassName: string;
|
||||
adultCount: number;
|
||||
childCount?: number;
|
||||
/** Round-trip leg tag — omit for a one-way booking. */
|
||||
journeyDirection?: 'OUTBOUND' | 'RETURN';
|
||||
}
|
||||
|
||||
export interface HeldPassengerSeat {
|
||||
@@ -110,7 +124,8 @@ export interface AutoAssignHoldResponse {
|
||||
// ── Group booking creation (POST /bookings/group) ──────────────────────────
|
||||
|
||||
export interface GroupBookingPassengerInput {
|
||||
seatId: string;
|
||||
/** Omit for a free child (ONE_WAY only) — matches guest-booking.dto.ts's own optional seatId. */
|
||||
seatId?: string;
|
||||
passengerName: string;
|
||||
dateOfBirth: string;
|
||||
idDocumentType: 'NATIONAL_ID' | 'PASSPORT' | 'DRIVING_LICENSE' | 'OTHER';
|
||||
@@ -120,6 +135,8 @@ export interface GroupBookingPassengerInput {
|
||||
nationality?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
/** Return-leg seat ID — required when the booking is ROUND_TRIP. */
|
||||
returnSeatId?: string;
|
||||
}
|
||||
|
||||
export interface CreateGroupBookingRequest {
|
||||
@@ -128,14 +145,23 @@ export interface CreateGroupBookingRequest {
|
||||
originStationId: string;
|
||||
destinationStationId: string;
|
||||
seatClassId: string;
|
||||
bookingType: 'ONE_WAY';
|
||||
bookingType: 'ONE_WAY' | 'ROUND_TRIP';
|
||||
passengers: GroupBookingPassengerInput[];
|
||||
/** ROUND_TRIP only. */
|
||||
returnScheduleId?: string;
|
||||
returnHoldId?: string;
|
||||
returnOriginStationId?: string;
|
||||
returnDestinationStationId?: string;
|
||||
/** Falls back to seatClassId on the backend if omitted. */
|
||||
returnSeatClassId?: string;
|
||||
}
|
||||
|
||||
export interface GroupBookingSeat {
|
||||
seatId: string;
|
||||
passengerName: string;
|
||||
passengerCategory: 'ADULT' | 'CHILD';
|
||||
/** 1 = outbound leg, 2 = return leg. Absent on a plain ONE_WAY booking. */
|
||||
leg?: number;
|
||||
seat: { seatNumber: string; bedPosition?: string | null; coach: { number: string } };
|
||||
}
|
||||
|
||||
@@ -209,10 +235,23 @@ export const groupBookingApi = {
|
||||
autoAssignHold: (dto: AutoAssignHoldRequest) =>
|
||||
apiClient.post<AutoAssignHoldResponse>('/seats/auto-assign-hold', dto),
|
||||
|
||||
/** Best-effort early release — e.g. freeing an outbound hold when the return leg's auto-assign fails. */
|
||||
releaseHold: (holdId: string) => apiClient.delete(`/seats/hold/${holdId}`),
|
||||
|
||||
createGroupBooking: (dto: CreateGroupBookingRequest) =>
|
||||
apiClient.post<CreateGroupBookingResponse>('/bookings/group', dto),
|
||||
|
||||
getPaymentMethods: () => apiClient.get<SupportedPaymentMethod[]>('/payments/methods'),
|
||||
// Guards against a non-array response the same way dashboardApi.getPaymentMethods /
|
||||
// paymentsApi.getMethods already do elsewhere in this app — never lets a bad/unexpected
|
||||
// response shape reach a caller expecting a plain array.
|
||||
getPaymentMethods: async (): Promise<SupportedPaymentMethod[]> => {
|
||||
try {
|
||||
const response = await apiClient.get<SupportedPaymentMethod[]>('/payments/methods');
|
||||
return Array.isArray(response) ? response : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
initiatePayment: (dto: InitiatePaymentRequest) =>
|
||||
apiClient.post<InitiatePaymentResponse>('/payments/initiate', dto),
|
||||
|
||||
@@ -475,6 +475,15 @@ export const packagesApi = {
|
||||
addTier: (packageId: string, data: any) => apiClient.post<any>(`/packages/${packageId}/tiers`, data),
|
||||
updateTier: (tierId: string, data: any) => apiClient.patch<any>(`/packages/tiers/${tierId}`, data),
|
||||
deleteTier: (tierId: string) => apiClient.delete(`/packages/tiers/${tierId}`),
|
||||
// `apiClient` pins Content-Type: application/json on every request — clearing it (rather than
|
||||
// setting multipart/form-data by hand, which omits the boundary) is what lets the browser
|
||||
// generate a proper boundary of its own. Same pattern as features/support/supportApi.ts.
|
||||
uploadImage: (id: string, file: File) => {
|
||||
const form = new FormData();
|
||||
form.append('image', file);
|
||||
return apiClient.post<any>(`/packages/${id}/image`, form, { headers: { 'Content-Type': undefined } });
|
||||
},
|
||||
removeImage: (id: string) => apiClient.delete<any>(`/packages/${id}/image`),
|
||||
};
|
||||
|
||||
// Package Inquiries API
|
||||
|
||||
@@ -2,6 +2,16 @@ import ExcelJS from 'exceljs';
|
||||
|
||||
const VALID_ID_TYPES = ['NATIONAL_ID', 'PASSPORT', 'DRIVING_LICENSE', 'OTHER'];
|
||||
|
||||
/** Exact mirror of guest-booking.service.ts's calculateAge — calendar-based, not a 365.25-day
|
||||
* approximation, so this file's CHILD/ADULT determination never disagrees with the backend's. */
|
||||
function calculateAge(dateOfBirth: Date): number {
|
||||
const today = new Date();
|
||||
let age = today.getFullYear() - dateOfBirth.getFullYear();
|
||||
const monthDiff = today.getMonth() - dateOfBirth.getMonth();
|
||||
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < dateOfBirth.getDate())) age--;
|
||||
return age;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors guest-booking.service.ts's per-passenger nationality inference exactly (NATIONAL_ID
|
||||
* always forces 'Ethiopian'; PASSPORT falls back to Djiboutian/Other by passport country), so a
|
||||
@@ -25,6 +35,8 @@ export interface ParsedPassengerRow {
|
||||
fullName: string;
|
||||
dateOfBirth: string; // normalized YYYY-MM-DD, empty if invalid/missing
|
||||
passengerType: 'Adult' | 'Child' | '';
|
||||
/** Backend-authoritative category from DOB alone (age < 5), regardless of the Type column. */
|
||||
isChildByAge: boolean;
|
||||
idDocumentType: string;
|
||||
idDocumentNumber: string;
|
||||
passportNumber: string;
|
||||
@@ -145,7 +157,7 @@ export async function parsePassengerExcel(file: File, quotedFareTier?: 'LOCAL' |
|
||||
if (!fullName) errors.push('Full Name is required');
|
||||
|
||||
let dateOfBirth = '';
|
||||
let ageYears: number | null = null;
|
||||
let isChildByAge = false;
|
||||
if (!dobRaw) {
|
||||
errors.push('Date of Birth is required');
|
||||
} else {
|
||||
@@ -156,7 +168,7 @@ export async function parsePassengerExcel(file: File, quotedFareTier?: 'LOCAL' |
|
||||
errors.push('Date of Birth cannot be in the future');
|
||||
} else {
|
||||
dateOfBirth = parsed.toISOString().split('T')[0];
|
||||
ageYears = (Date.now() - parsed.getTime()) / (365.25 * 24 * 60 * 60 * 1000);
|
||||
isChildByAge = calculateAge(parsed) < 5;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,8 +180,8 @@ export async function parsePassengerExcel(file: File, quotedFareTier?: 'LOCAL' |
|
||||
|
||||
// The backend computes ADULT/CHILD from date of birth alone (under 5 = Child), regardless
|
||||
// of this column — flag a mismatch so the uploader notices before it surprises them later.
|
||||
if (passengerType && ageYears !== null) {
|
||||
const impliedType = ageYears < 5 ? 'Child' : 'Adult';
|
||||
if (passengerType && dateOfBirth) {
|
||||
const impliedType = isChildByAge ? 'Child' : 'Adult';
|
||||
if (impliedType !== passengerType) {
|
||||
warnings.push(`Date of Birth implies ${impliedType}, but Passenger Type is set to ${passengerType} — seats/fare are priced by age, not this column`);
|
||||
}
|
||||
@@ -202,6 +214,7 @@ export async function parsePassengerExcel(file: File, quotedFareTier?: 'LOCAL' |
|
||||
fullName,
|
||||
dateOfBirth,
|
||||
passengerType,
|
||||
isChildByAge,
|
||||
idDocumentType: docTypeRaw,
|
||||
idDocumentNumber: docNumber,
|
||||
passportNumber,
|
||||
@@ -223,7 +236,23 @@ export async function parsePassengerExcel(file: File, quotedFareTier?: 'LOCAL' |
|
||||
|
||||
export function countByType(rows: ParsedPassengerRow[]): { adults: number; children: number } {
|
||||
return {
|
||||
adults: rows.filter((r) => r.passengerType === 'Adult').length,
|
||||
children: rows.filter((r) => r.passengerType === 'Child').length,
|
||||
adults: rows.filter((r) => !r.isChildByAge).length,
|
||||
children: rows.filter((r) => r.isChildByAge).length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors the passenger portal's isFirstChild rule exactly (fare-utils.ts): the first
|
||||
* `adultCount` children in passenger order travel free with no assigned seat; any child
|
||||
* beyond that gets a real seat and pays the child fare. Returns one boolean per row, true
|
||||
* where that row is a free, unseated child.
|
||||
*/
|
||||
export function resolveFreeChildIndexes(rows: ParsedPassengerRow[], adultCount: number): boolean[] {
|
||||
let childrenSeen = 0;
|
||||
return rows.map((row) => {
|
||||
if (!row.isChildByAge) return false;
|
||||
const free = childrenSeen < adultCount;
|
||||
childrenSeen++;
|
||||
return free;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,20 @@
|
||||
// Package images are served by edr-passenger-api's own origin (public/uploads/packages via
|
||||
// app.useStaticAssets — see apps/edr-passenger-api/src/main.ts), a different origin than this
|
||||
// app, so next/image needs it explicitly whitelisted or it 400s every package image at runtime.
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
|
||||
let apiImagePattern;
|
||||
try {
|
||||
const apiUrl = new URL(API_URL);
|
||||
apiImagePattern = {
|
||||
protocol: apiUrl.protocol.replace(':', ''),
|
||||
hostname: apiUrl.hostname,
|
||||
port: apiUrl.port || '',
|
||||
pathname: '/uploads/**',
|
||||
};
|
||||
} catch {
|
||||
apiImagePattern = { protocol: 'http', hostname: 'localhost', port: '4000', pathname: '/uploads/**' };
|
||||
}
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: 'standalone',
|
||||
@@ -5,6 +22,7 @@ const nextConfig = {
|
||||
transpilePackages: ['@edr/types', '@edr/ui-common'],
|
||||
images: {
|
||||
unoptimized: false,
|
||||
remotePatterns: [apiImagePattern],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ const COUNTRIES = [
|
||||
'Bolivia','Bosnia and Herzegovina','Botswana','Brazil','Brunei','Bulgaria','Burkina Faso','Burundi','Cabo Verde','Cambodia',
|
||||
'Cameroon','Canada','Central African Republic','Chad','Chile','China','Colombia','Comoros','Congo','Costa Rica',
|
||||
'Croatia','Cuba','Cyprus','Czech Republic','Denmark','Dominica','Dominican Republic','Ecuador','Egypt',
|
||||
'El Salvador','Equatorial Guinea','Eritrea','Estonia','Eswatini','Fiji','Finland','France','Gabon',
|
||||
'El Salvador','Equatorial Guinea','Eritrea','Estonia','Eswatini','Ethiopia','Fiji','Finland','France','Gabon',
|
||||
'Gambia','Georgia','Germany','Ghana','Greece','Grenada','Guatemala','Guinea','Guinea-Bissau','Guyana',
|
||||
'Haiti','Honduras','Hungary','Iceland','India','Indonesia','Iran','Iraq','Ireland','Israel',
|
||||
'Italy','Jamaica','Japan','Jordan','Kazakhstan','Kenya','Kiribati','Kuwait','Kyrgyzstan','Laos',
|
||||
|
||||
@@ -93,6 +93,12 @@ function emptyReasonCopy(
|
||||
message: `The only train between ${originStationName} and ${destinationStationName} on ${date} is bookable as part of a travel package, not as a standalone ticket. Please choose another date below.`,
|
||||
showAlternatives: true,
|
||||
};
|
||||
case Passenger.SearchEmptyReasonCode.GroupBookingOnly:
|
||||
return {
|
||||
title: "Reserved for a group booking",
|
||||
message: `The only train between ${originStationName} and ${destinationStationName} on ${date} is reserved for a group booking, not individual tickets. Please choose another date below.`,
|
||||
showAlternatives: true,
|
||||
};
|
||||
case Passenger.SearchEmptyReasonCode.NoScheduleOnDate:
|
||||
default:
|
||||
return {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { apiClient } from '@/lib/api-client';
|
||||
import { format } from 'date-fns';
|
||||
import { formatTime, getTimePeriod, toZonedDate } from '@/utils/format';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { ChevronLeft, Loader2 } from 'lucide-react';
|
||||
import { isChild, isFirstChild, formatFare } from '@/utils/fare-utils';
|
||||
|
||||
// Helper function to decode JWT token and extract passengerId
|
||||
@@ -54,6 +54,17 @@ export default function ReviewPage() {
|
||||
const [fareBreakdown, setFareBreakdown] = useState<any>(null);
|
||||
const [returnFareBreakdown, setReturnFareBreakdown] = useState<any>(null);
|
||||
const [computedTotal, setComputedTotal] = useState<number>(0);
|
||||
// createBookingMutation.isPending only covers the mutation's own network call, but
|
||||
// handleConfirm does real async work (seat-class lookup, passengerId resolution) before ever
|
||||
// calling it — during that window the button showed no loading state and stayed clickable,
|
||||
// letting a double-click race through and create two bookings for the same seat hold. This
|
||||
// covers the whole handleConfirm run, not just the mutation's slice of it.
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
// setIsSubmitting alone isn't enough to stop a fast double-click: React state updates aren't
|
||||
// synchronous, so a second click event dispatched before the first setIsSubmitting(true) has
|
||||
// actually re-rendered the button as disabled would still slip through. A ref updates
|
||||
// immediately, closing that gap regardless of render timing.
|
||||
const isSubmittingRef = useRef(false);
|
||||
|
||||
const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP';
|
||||
|
||||
@@ -228,6 +239,9 @@ const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p =>
|
||||
});
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (isSubmittingRef.current || createBookingMutation.isPending) return;
|
||||
isSubmittingRef.current = true;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const { searchCriteria } = useBookingStore.getState();
|
||||
|
||||
@@ -287,6 +301,8 @@ const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p =>
|
||||
|
||||
if (!seatClassId) {
|
||||
alert('Unable to determine seat class. Please go back and re-select your seats.');
|
||||
isSubmittingRef.current = false;
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -489,8 +505,16 @@ const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p =>
|
||||
bookingData.reviewedTotalMinor = computedTotal;
|
||||
|
||||
await createBookingMutation.mutateAsync(bookingData);
|
||||
// Deliberately not resetting isSubmitting here: every path that reaches this point is
|
||||
// about to navigate away (router.push, either here or inside onSuccess's setTimeout above).
|
||||
// Clearing it now would flip the button back to its idle label for the gap between the
|
||||
// booking actually being created and the navigation landing — exactly the "did it get
|
||||
// stuck?" flash this state exists to prevent. It only needs resetting on a genuine failure,
|
||||
// handled in the catch block below, so the user can retry.
|
||||
} catch (error) {
|
||||
alert(error instanceof Error ? error.message : 'An unexpected error occurred. Please try again.');
|
||||
isSubmittingRef.current = false;
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -691,10 +715,16 @@ const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p =>
|
||||
)}
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={createBookingMutation.isPending}
|
||||
className="btn-primary w-full"
|
||||
disabled={isSubmitting || createBookingMutation.isPending}
|
||||
className="btn-primary w-full disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{createBookingMutation.isPending ? 'Creating booking...' : `Confirm ${isAuthenticated ? '' : 'and pay'}`}
|
||||
{isSubmitting || createBookingMutation.isPending ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> Creating booking...
|
||||
</span>
|
||||
) : (
|
||||
`Confirm ${isAuthenticated ? '' : 'and pay'}`
|
||||
)}
|
||||
</button>
|
||||
<button onClick={() => router.back()} className="btn-secondary w-full flex items-center justify-center gap-2">
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
@@ -1032,10 +1062,16 @@ const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p =>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={createBookingMutation.isPending}
|
||||
className="btn-primary flex-1 py-2.5"
|
||||
disabled={isSubmitting || createBookingMutation.isPending}
|
||||
className="btn-primary flex-1 py-2.5 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{createBookingMutation.isPending ? 'Creating...' : `Confirm ${isAuthenticated ? '' : '& pay'}`}
|
||||
{isSubmitting || createBookingMutation.isPending ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> Creating...
|
||||
</span>
|
||||
) : (
|
||||
`Confirm ${isAuthenticated ? '' : '& pay'}`
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,50 +1,295 @@
|
||||
'use client';
|
||||
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import { useState, Suspense } from 'react';
|
||||
import { iamAuthApi } from '@/lib/api/auth';
|
||||
import { isStrongPassword, PASSWORD_RULE } from '@/lib/password';
|
||||
import { useState, useRef, Suspense } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Train, ShieldCheck, Eye, EyeOff } from 'lucide-react';
|
||||
import { Train, Eye, EyeOff, Pencil } from 'lucide-react';
|
||||
|
||||
const loginSchema = z.object({
|
||||
// Accepts either an email or a phone number. Passengers who registered without an
|
||||
// email sign in with their phone number, which is sent in the same `email` field —
|
||||
// the IAM matches on either identifier.
|
||||
email: z.string().min(1, 'Phone or email is required'),
|
||||
password: z.string().min(6, 'Password must be at least 6 characters'),
|
||||
});
|
||||
/**
|
||||
* Staged sign-in.
|
||||
*
|
||||
* The passenger gives one identifier — phone or email — and the server decides which of three
|
||||
* things happens next. Previously this page asked for identifier *and* password up front and
|
||||
* offered three competing links underneath ("Create account", "Already verified with Fayda?",
|
||||
* "Forgot password?"), which made the user guess something only the server knows: whether their
|
||||
* number has an account, and whether that account has a password yet. Guessing wrong dead-ended.
|
||||
*
|
||||
* Now exactly one branch is ever on screen.
|
||||
*/
|
||||
type Step = 'identifier' | 'password' | 'setup' | 'signup';
|
||||
|
||||
type LoginForm = z.infer<typeof loginSchema>;
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
/**
|
||||
* Loose enough to accept every shape a passenger might type (`+2519…`, `2519…`, `09…`) and the
|
||||
* occasional foreign number, strict enough that free text never reaches the signup branch — an
|
||||
* identifier that is neither an email nor a number would otherwise be stored as a phone the SMS
|
||||
* code can never reach. Mirrors the 7-digit floor in the API's `normalizePhoneVariants`.
|
||||
*/
|
||||
const looksLikePhone = (v: string) => v.replace(/[^\d]/g, '').length >= 7;
|
||||
|
||||
function LoginContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const login = useAuthStore((s) => s.login);
|
||||
const registerUser = useAuthStore((s) => s.register);
|
||||
const setUser = useAuthStore((s) => s.setUser);
|
||||
|
||||
const [step, setStep] = useState<Step>('identifier');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const { register, handleSubmit, formState: { errors } } = useForm<LoginForm>({
|
||||
resolver: zodResolver(loginSchema as any),
|
||||
});
|
||||
// Step 1
|
||||
const [identifier, setIdentifier] = useState('');
|
||||
const identifierRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const onSubmit = async (data: LoginForm) => {
|
||||
// Step 2 — sign in
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
// Step 3 — set a password (existing account with none, or a fresh signup)
|
||||
const [maskedPhone, setMaskedPhone] = useState('');
|
||||
const [setupMethod, setSetupMethod] = useState<'fayda' | 'pending' | 'new'>('pending');
|
||||
const [otp, setOtp] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [resendNote, setResendNote] = useState('');
|
||||
|
||||
// Step 4 — signup
|
||||
const [fullName, setFullName] = useState('');
|
||||
const [secondaryContact, setSecondaryContact] = useState('');
|
||||
|
||||
const identifierIsEmail = EMAIL_RE.test(identifier.trim());
|
||||
|
||||
const finish = () => {
|
||||
const redirect = searchParams.get('redirect') || '/booking/search';
|
||||
router.push(redirect);
|
||||
};
|
||||
|
||||
const goBackToIdentifier = () => {
|
||||
setStep('identifier');
|
||||
setError('');
|
||||
setPassword('');
|
||||
setOtp('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
setResendNote('');
|
||||
// Keep what they typed — they are usually fixing a typo, not starting over — but select
|
||||
// it, so typing replaces the value instead of appending to it. Without this, clicking
|
||||
// into a controlled input that still holds the old identifier silently concatenates.
|
||||
setTimeout(() => identifierRef.current?.select(), 0);
|
||||
};
|
||||
|
||||
const apiMessage = (err: any, fallback: string) =>
|
||||
err?.response?.data?.message || fallback;
|
||||
|
||||
// --- Step 1: who are you? ---------------------------------------------------
|
||||
const submitIdentifier = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const value = identifier.trim();
|
||||
if (!value) {
|
||||
setError('Enter your phone number or email');
|
||||
return;
|
||||
}
|
||||
if (!EMAIL_RE.test(value) && !looksLikePhone(value)) {
|
||||
setError('Enter a valid phone number or email address');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
await login(data.email, data.password);
|
||||
const redirect = searchParams.get('redirect') || '/booking/search';
|
||||
router.push(redirect);
|
||||
const res = await iamAuthApi.lookupIdentifier(identifier.trim());
|
||||
const result = res.data.data;
|
||||
|
||||
if (result.status === 'PASSWORD') {
|
||||
setStep('password');
|
||||
return;
|
||||
}
|
||||
if (result.status === 'NEEDS_PASSWORD_SETUP') {
|
||||
setMaskedPhone(result.maskedPhone || '');
|
||||
setSetupMethod(result.method || 'pending');
|
||||
// Fire the code now so the next screen is already actionable. It resolves even for
|
||||
// an unknown identifier, so a failure here is a transport problem, not a verdict.
|
||||
await iamAuthApi.requestPasswordSetup(identifier.trim());
|
||||
setStep('setup');
|
||||
return;
|
||||
}
|
||||
setStep('signup');
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Login failed. Please check your credentials.');
|
||||
setError(apiMessage(err, 'Something went wrong. Please try again.'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Step 2: existing account, has a password -------------------------------
|
||||
const submitPassword = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!password) {
|
||||
setError('Enter your password');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
await login(identifier.trim(), password);
|
||||
finish();
|
||||
} catch (err: any) {
|
||||
setError(apiMessage(err, 'Incorrect password. Please try again.'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Step 3: set a password with the SMS code -------------------------------
|
||||
const submitSetup = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!otp.trim()) {
|
||||
setError('Enter the code we sent you');
|
||||
return;
|
||||
}
|
||||
if (!isStrongPassword(newPassword)) {
|
||||
setError(PASSWORD_RULE);
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await iamAuthApi.completePasswordSetup({
|
||||
identifier: identifier.trim(),
|
||||
otp: otp.trim(),
|
||||
newPassword,
|
||||
confirmPassword,
|
||||
});
|
||||
const { token, user } = res.data.data;
|
||||
// The response carries a real session, so the user lands signed in instead of being
|
||||
// sent back to the form. `setUser` is the same action `login()` persists through.
|
||||
setUser(user as any, token);
|
||||
finish();
|
||||
} catch (err: any) {
|
||||
setError(apiMessage(err, 'That code is not valid. Please try again.'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resend = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setResendNote('');
|
||||
try {
|
||||
await iamAuthApi.requestPasswordSetup(identifier.trim());
|
||||
setResendNote('We sent a new code.');
|
||||
} catch (err: any) {
|
||||
setError(apiMessage(err, 'Could not send a new code. Please try again.'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Step 4: no account yet --------------------------------------------------
|
||||
const submitSignup = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const name = fullName.trim();
|
||||
const other = secondaryContact.trim();
|
||||
if (name.length < 2) {
|
||||
setError('Enter your full name');
|
||||
return;
|
||||
}
|
||||
// A phone is always required — the verification code is sent by SMS and there is no
|
||||
// email channel for it. An email is optional.
|
||||
if (identifierIsEmail) {
|
||||
if (!other) {
|
||||
setError('Enter your phone number');
|
||||
return;
|
||||
}
|
||||
if (!looksLikePhone(other)) {
|
||||
setError('Enter a valid phone number — your verification code is sent by SMS');
|
||||
return;
|
||||
}
|
||||
} else if (other && !EMAIL_RE.test(other)) {
|
||||
// Only validate the shape when they actually typed something.
|
||||
setError('Enter a valid email address');
|
||||
return;
|
||||
}
|
||||
|
||||
const phone = identifierIsEmail ? other : identifier.trim();
|
||||
// The IAM requires a non-empty account identifier in its `email` field but never checks
|
||||
// that it is email-shaped, so a passenger with no email address signs up under their phone
|
||||
// number — the same fallback `/register` uses. Both then match on either identifier.
|
||||
const email = identifierIsEmail ? identifier.trim() : other || phone;
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
await registerUser({ fullName: name, email, phone });
|
||||
setMaskedPhone(phone);
|
||||
setSetupMethod('new');
|
||||
setStep('setup');
|
||||
} catch (err: any) {
|
||||
if (err?.response?.status === 409) {
|
||||
setError('An account with this email or phone number already exists. Go back and sign in.');
|
||||
} else {
|
||||
setError(apiMessage(err, 'Could not create your account. Please try again.'));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* The identifier, shown on every step after the first, with one way back to change it.
|
||||
*
|
||||
* It is a real `autocomplete="username"` input rather than a `<span>`, and it is rendered
|
||||
* *inside* each form. That is what makes password managers behave: a password field sitting
|
||||
* alone in a form gives Chrome nothing to match a saved credential against, so it fills
|
||||
* whichever password it holds for the origin — a password belonging to some other account.
|
||||
* Pairing it with the username lets the manager fill the right credential, or none at all.
|
||||
*/
|
||||
const identifierChip = (
|
||||
<div className="flex items-center justify-between gap-3 mb-4 px-3 py-2 rounded bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
|
||||
<input
|
||||
type="text"
|
||||
value={identifier}
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
autoComplete="username"
|
||||
aria-label="Signing in as"
|
||||
onFocus={(e) => e.currentTarget.blur()}
|
||||
className="flex-1 min-w-0 truncate bg-transparent border-0 p-0 text-sm text-gray-700 dark:text-gray-300 focus:outline-none focus:ring-0 cursor-default"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={goBackToIdentifier}
|
||||
className="flex items-center gap-1 text-sm text-[rgb(20_113_76)] dark:text-emerald-400 hover:underline shrink-0"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
Change
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const heading = {
|
||||
identifier: { title: 'Sign in', subtitle: 'Enter your phone number or email to continue' },
|
||||
password: { title: 'Welcome back', subtitle: 'Enter your password to sign in' },
|
||||
setup: { title: 'Set your password', subtitle: 'Enter the code we sent, then choose a password' },
|
||||
signup: { title: 'Create your account', subtitle: 'We just need a couple of details' },
|
||||
}[step];
|
||||
|
||||
const setupBlurb =
|
||||
setupMethod === 'fayda'
|
||||
? 'Your Fayda-verified account does not have a password yet.'
|
||||
: setupMethod === 'new'
|
||||
? 'Your account is almost ready.'
|
||||
: 'You started signing up but never chose a password.';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-[rgb(20_113_76)] from-10% via-transparent to-[rgb(20_113_76)] to-90% dark:from-gray-900 dark:to-gray-800 flex items-center justify-center py-12 px-4">
|
||||
<div className="max-w-md w-full">
|
||||
@@ -54,86 +299,227 @@ function LoginContent() {
|
||||
<Train className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">Sign in</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-2">Welcome back</p>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">{heading.title}</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-2">{heading.subtitle}</p>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-300 px-4 py-3 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Phone or email</label>
|
||||
<input
|
||||
type="text"
|
||||
{...register('email')}
|
||||
className="input-field"
|
||||
placeholder="+251912345678 or your@email.com"
|
||||
autoComplete="username"
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-red-500 text-sm mt-1">{errors.email.message}</p>
|
||||
)}
|
||||
{error && (
|
||||
<div className="bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-300 px-4 py-3 rounded mb-4">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Password</label>
|
||||
<div className="relative">
|
||||
{step === 'identifier' && (
|
||||
<form onSubmit={submitIdentifier} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
|
||||
Phone number or email
|
||||
</label>
|
||||
<input
|
||||
ref={identifierRef}
|
||||
type="text"
|
||||
value={identifier}
|
||||
onChange={(e) => { setIdentifier(e.target.value); setError(''); }}
|
||||
className="input-field"
|
||||
placeholder="+251912345678 or your@email.com"
|
||||
autoComplete="username"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="btn-primary w-full" disabled={loading}>
|
||||
{loading ? 'Checking...' : 'Continue'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{step === 'password' && (
|
||||
<form onSubmit={submitPassword} className="space-y-4">
|
||||
{identifierChip}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => { setPassword(e.target.value); setError(''); }}
|
||||
className="input-field pr-10"
|
||||
placeholder="••••••••"
|
||||
autoComplete="current-password"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex justify-end mt-1">
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="text-sm text-[rgb(20_113_76)] dark:text-emerald-400 hover:underline"
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" className="btn-primary w-full" disabled={loading}>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{step === 'setup' && (
|
||||
<form onSubmit={submitSetup} className="space-y-4">
|
||||
{identifierChip}
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{setupBlurb}{' '}
|
||||
{maskedPhone
|
||||
? <>We sent a code to <span className="font-medium">{maskedPhone}</span>.</>
|
||||
: 'We sent a code to your registered phone.'}
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
|
||||
Verification code
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={otp}
|
||||
onChange={(e) => { setOtp(e.target.value); setError(''); }}
|
||||
className="input-field tracking-widest"
|
||||
placeholder="A1b2C3"
|
||||
// The IAM issues codes with generateRandomString(6): letters and digits,
|
||||
// and case-sensitive — so no numeric keypad and no autocapitalise.
|
||||
inputMode="text"
|
||||
autoComplete="one-time-code"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
maxLength={6}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
|
||||
New password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={newPassword}
|
||||
onChange={(e) => { setNewPassword(e.target.value); setError(''); }}
|
||||
className="input-field pr-10"
|
||||
placeholder="••••••••"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">{PASSWORD_RULE}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
|
||||
Confirm password
|
||||
</label>
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
{...register('password')}
|
||||
className="input-field pr-10"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => { setConfirmPassword(e.target.value); setError(''); }}
|
||||
className="input-field"
|
||||
placeholder="••••••••"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<p className="text-red-500 text-sm mt-1">{errors.password.message}</p>
|
||||
)}
|
||||
<div className="flex justify-end mt-1">
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="text-sm text-[rgb(20_113_76)] dark:text-emerald-400 hover:underline"
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
|
||||
<button type="submit" className="btn-primary w-full" disabled={loading}>
|
||||
{loading ? 'Setting password...' : 'Set password and sign in'}
|
||||
</button>
|
||||
|
||||
<div className="text-center">
|
||||
{resendNote ? (
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">{resendNote}</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={resend}
|
||||
disabled={loading}
|
||||
className="text-sm text-[rgb(20_113_76)] dark:text-emerald-400 hover:underline disabled:opacity-50"
|
||||
>
|
||||
Didn't get a code? Send it again
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<button type="submit" className="btn-primary w-full" disabled={loading}>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
{step === 'signup' && (
|
||||
<form onSubmit={submitSignup} className="space-y-4">
|
||||
{identifierChip}
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
We couldn't find an account for that {identifierIsEmail ? 'email' : 'number'}, so
|
||||
let's create one.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700 space-y-3">
|
||||
<div className="text-center">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">Don't have an account? </span>
|
||||
<Link href="/register" className="text-sm font-medium text-[rgb(20_113_76)] dark:text-emerald-400 hover:underline">
|
||||
Create account
|
||||
</Link>
|
||||
</div>
|
||||
<Link
|
||||
href="/fayda-setup"
|
||||
className="flex items-center justify-center gap-2 text-sm text-gray-600 dark:text-gray-400 hover:text-[rgb(20_113_76)] dark:hover:text-emerald-400 transition-colors"
|
||||
>
|
||||
<ShieldCheck className="w-4 h-4" />
|
||||
Already verified with Fayda? Set up your password
|
||||
</Link>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
|
||||
Full name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={fullName}
|
||||
onChange={(e) => { setFullName(e.target.value); setError(''); }}
|
||||
className="input-field"
|
||||
placeholder="e.g. Abebe Kebede"
|
||||
autoComplete="name"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">
|
||||
{identifierIsEmail ? 'Phone number' : 'Email address (optional)'}
|
||||
</label>
|
||||
<input
|
||||
type={identifierIsEmail ? 'tel' : 'email'}
|
||||
value={secondaryContact}
|
||||
onChange={(e) => { setSecondaryContact(e.target.value); setError(''); }}
|
||||
className="input-field"
|
||||
placeholder={identifierIsEmail ? '+251912345678' : 'your@email.com'}
|
||||
autoComplete={identifierIsEmail ? 'tel' : 'email'}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{identifierIsEmail
|
||||
? "We'll text your verification code to this number."
|
||||
: "For receipts and booking confirmations. Your verification code is sent by SMS either way."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn-primary w-full" disabled={loading}>
|
||||
{loading ? 'Creating account...' : 'Create account'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700 text-center">
|
||||
<button
|
||||
onClick={() => router.push('/booking/search')}
|
||||
className="text-sm text-gray-600 dark:text-gray-400 hover:text-primary dark:hover:text-primary-400"
|
||||
|
||||
@@ -83,6 +83,7 @@ interface PackageDetail {
|
||||
code: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
imageUrl?: string | null;
|
||||
status: string;
|
||||
boardingTime: string;
|
||||
departureTime: string;
|
||||
@@ -1042,7 +1043,7 @@ export default function PackageDetailPage() {
|
||||
{/* Hero */}
|
||||
<div className="relative h-56 md:h-80 bg-gradient-to-br from-[rgb(14,80,54)] to-[rgb(20,140,90)] overflow-hidden">
|
||||
<Image
|
||||
src="/packages/package.jpeg"
|
||||
src={pkg.imageUrl || "/packages/package.jpeg"}
|
||||
alt={pkg.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { Train, CheckCircle, ArrowLeft, ArrowRight } from 'lucide-react';
|
||||
import { iamAuthApi } from '@/lib/api/auth';
|
||||
import { isStrongPassword, PASSWORD_RULE } from '@/lib/password';
|
||||
|
||||
function ResetPasswordContent() {
|
||||
const router = useRouter();
|
||||
@@ -24,8 +25,8 @@ function ResetPasswordContent() {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
if (newPassword.length < 6) {
|
||||
setError('Password must be at least 6 characters.');
|
||||
if (!isStrongPassword(newPassword)) {
|
||||
setError(PASSWORD_RULE);
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
|
||||
@@ -6,18 +6,8 @@ import Link from 'next/link';
|
||||
import { Train, ArrowLeft, ShieldCheck } from 'lucide-react';
|
||||
import { iamAuthApi } from '@/lib/api/auth';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import { isStrongPassword } from '@/lib/password';
|
||||
|
||||
// Mirrors the IAM set-password requirement (class-validator @IsStrongPassword defaults):
|
||||
// min length 8, with lower- and upper-case letters, a number, and a symbol.
|
||||
function isStrongPassword(pw: string): boolean {
|
||||
return (
|
||||
pw.length >= 8 &&
|
||||
/[a-z]/.test(pw) &&
|
||||
/[A-Z]/.test(pw) &&
|
||||
/[0-9]/.test(pw) &&
|
||||
/[^A-Za-z0-9]/.test(pw)
|
||||
);
|
||||
}
|
||||
|
||||
function VerifyAccountContent() {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
@@ -192,18 +192,12 @@ export default function AppSidebar() {
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 px-1 pt-1">
|
||||
<div className="px-1 pt-1">
|
||||
<Link
|
||||
href="/login"
|
||||
className="flex-1 text-center px-3 py-2 text-sm font-medium text-gray-100 hover:bg-white/10 rounded-lg transition-colors"
|
||||
className="block text-center px-3 py-2 text-sm font-medium bg-white text-[rgb(20_113_76)] hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
<Link
|
||||
href="/register"
|
||||
className="flex-1 text-center px-3 py-2 text-sm font-medium bg-white text-[rgb(20_113_76)] hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
Register
|
||||
Sign in or register
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { X, CheckCircle } from 'lucide-react';
|
||||
import { iamAuthApi } from '@/lib/api/auth';
|
||||
import { isStrongPassword, PASSWORD_RULE } from '@/lib/password';
|
||||
|
||||
interface ChangePasswordModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -32,8 +33,8 @@ export default function ChangePasswordModal({ isOpen, onClose }: ChangePasswordM
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
if (newPassword.length < 6) {
|
||||
setError('New password must be at least 6 characters.');
|
||||
if (!isStrongPassword(newPassword)) {
|
||||
setError(PASSWORD_RULE);
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { Train, ShieldCheck, CheckCircle, Info, ArrowLeft, ArrowRight } from 'lucide-react';
|
||||
import { iamAuthApi } from '@/lib/api/auth';
|
||||
import { isStrongPassword, PASSWORD_RULE } from '@/lib/password';
|
||||
|
||||
interface FaydaSetupWizardProps {
|
||||
// Prefilled OTP when landing from the SMS link (/set-password?verificationCode=...)
|
||||
@@ -41,8 +42,8 @@ export default function FaydaSetupWizard({ initialOtp }: FaydaSetupWizardProps)
|
||||
const handleSetPassword = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
if (newPassword.length < 6) {
|
||||
setError('Password must be at least 6 characters.');
|
||||
if (!isStrongPassword(newPassword)) {
|
||||
setError(PASSWORD_RULE);
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
|
||||
@@ -42,6 +42,7 @@ interface HolidayPackage {
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
imageUrl?: string | null;
|
||||
status: string;
|
||||
departureTime: string;
|
||||
validFrom: string;
|
||||
@@ -165,7 +166,7 @@ function FeaturedCard({ pkg }: { pkg: HolidayPackage }) {
|
||||
{/* ── Left: Image ── */}
|
||||
<div className="relative md:w-[46%] h-64 md:h-auto flex-shrink-0 overflow-hidden">
|
||||
<Image
|
||||
src="/packages/package.jpeg"
|
||||
src={pkg.imageUrl || "/packages/package.jpeg"}
|
||||
alt={pkg.name}
|
||||
fill
|
||||
className="object-cover group-hover:scale-105 transition-transform duration-700 ease-out"
|
||||
@@ -330,11 +331,21 @@ function PackageCard({ pkg }: { pkg: HolidayPackage }) {
|
||||
return (
|
||||
<Link href={`/packages/${pkg.id}`} className="group block h-full">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-2xl overflow-hidden border border-gray-200 dark:border-gray-800 hover:border-primary/60 hover:shadow-xl transition-all duration-300 flex flex-col h-full">
|
||||
{/* Image / Gradient */}
|
||||
{/* Image / Gradient fallback */}
|
||||
<div className="relative h-44 overflow-hidden bg-gradient-to-br from-[rgb(14,80,54)] to-[rgb(20,140,90)] flex-shrink-0">
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-6xl opacity-20">🌍</span>
|
||||
</div>
|
||||
{pkg.imageUrl ? (
|
||||
<Image
|
||||
src={pkg.imageUrl}
|
||||
alt={pkg.name}
|
||||
fill
|
||||
className="object-cover group-hover:scale-105 transition-transform duration-700 ease-out"
|
||||
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-6xl opacity-20">🌍</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/50 via-transparent to-transparent" />
|
||||
|
||||
<div className="absolute top-3 left-3 flex items-center gap-2">
|
||||
|
||||
@@ -36,6 +36,42 @@ export const iamAuthApi = {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem('auth_token')}` },
|
||||
}),
|
||||
|
||||
// --- Staged sign-in (/login) -------------------------------------------------
|
||||
// Step 1: hand the server one field and let it say which branch follows. `identifier`
|
||||
// is a phone number or an email; the server works out which.
|
||||
lookupIdentifier: (identifier: string) =>
|
||||
axios.post<{
|
||||
success: boolean;
|
||||
data: {
|
||||
status: 'PASSWORD' | 'NEEDS_PASSWORD_SETUP' | 'NOT_FOUND';
|
||||
method?: 'fayda' | 'pending';
|
||||
maskedPhone?: string;
|
||||
};
|
||||
}>(`${API_URL}/auth/identifier/lookup`, { identifier }),
|
||||
|
||||
// Step 2a: SMS the code for an account that exists but has no password yet.
|
||||
// Always resolves — the server reports { sent: true } even for an unknown identifier.
|
||||
requestPasswordSetup: (identifier: string) =>
|
||||
axios.post(`${API_URL}/auth/password-setup/request`, { identifier }),
|
||||
|
||||
// Step 2b: redeem the code and set the password. Unlike the older Fayda dance this
|
||||
// returns a usable session directly, so the user lands signed in rather than back on
|
||||
// the login form. Same response shape as POST /auth/login.
|
||||
completePasswordSetup: (data: {
|
||||
identifier: string;
|
||||
otp: string;
|
||||
newPassword: string;
|
||||
confirmPassword: string;
|
||||
}) =>
|
||||
axios.post<{
|
||||
success: boolean;
|
||||
data: {
|
||||
token: string;
|
||||
refreshToken: string;
|
||||
user: { id: string; iamUserId: string; email: string | null; passengerId: string };
|
||||
};
|
||||
}>(`${API_URL}/auth/password-setup/complete`, data),
|
||||
|
||||
faydaRequestPasswordSetup: (phoneNumber: string) =>
|
||||
axios.post(`${API_URL}/auth/fayda/request-password-setup`, { phoneNumber }),
|
||||
|
||||
|
||||
@@ -113,13 +113,10 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
login: async (email: string, password: string) => {
|
||||
const response: any = await apiClient.post('/auth/login', { email, password });
|
||||
const { token, user } = response.data || response;
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('auth_token', token);
|
||||
localStorage.setItem('auth_user', JSON.stringify(user));
|
||||
}
|
||||
|
||||
set({ user, token, isAuthenticated: true });
|
||||
// `setUser` is the one place a session is persisted. The staged sign-in's
|
||||
// password-setup branch establishes a session without going through /auth/login,
|
||||
// so it calls the same action rather than duplicating the storage writes.
|
||||
get().setUser(user, token);
|
||||
},
|
||||
|
||||
register: async (data: RegisterData): Promise<RegisterResult> => {
|
||||
|
||||
21
apps/edr-passenger-web/portal/src/lib/password.ts
Normal file
21
apps/edr-passenger-web/portal/src/lib/password.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* The one password rule the portal enforces.
|
||||
*
|
||||
* It mirrors class-validator's `@IsStrongPassword` defaults, which is what the IAM applies on
|
||||
* `PATCH /v1/auth/set-password` and what `POST /auth/password-setup/complete` applies on the
|
||||
* passenger API. Screens that used a looser check (`length < 6`) accepted passwords the server
|
||||
* then rejected with an opaque 400, so every screen shares this instead.
|
||||
*/
|
||||
export function isStrongPassword(pw: string): boolean {
|
||||
return (
|
||||
pw.length >= 8 &&
|
||||
/[a-z]/.test(pw) &&
|
||||
/[A-Z]/.test(pw) &&
|
||||
/[0-9]/.test(pw) &&
|
||||
/[^A-Za-z0-9]/.test(pw)
|
||||
);
|
||||
}
|
||||
|
||||
/** The rule stated for humans. Shown as helper text and reused as the validation message. */
|
||||
export const PASSWORD_RULE =
|
||||
'Password must be at least 8 characters and include an upper-case letter, a lower-case letter, a number and a symbol.';
|
||||
@@ -179,6 +179,13 @@ export interface IContractCargoScope {
|
||||
code?: string | null;
|
||||
cargoTypeName?: string | null;
|
||||
unitOfMeasure?: string | null;
|
||||
/**
|
||||
* Most tons of this cargo one wagon may carry, across the cargo's allowed
|
||||
* wagon types (rated capacity, capped by the type's loading limit). Lets
|
||||
* the booking forms reject a wagon count whose even share overloads a
|
||||
* wagon before the server does. Null when no wagon type is configured.
|
||||
*/
|
||||
maxTonsPerWagon?: number | null;
|
||||
} | null;
|
||||
cargoFreeText?: string | null;
|
||||
/**
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { BaseEntity } from "../common";
|
||||
export * from "./support-chat";
|
||||
export * from "./blocked-seat-revenue-loss";
|
||||
|
||||
|
||||
export enum TicketStatus {
|
||||
Reserved = "RESERVED",
|
||||
Confirmed = "CONFIRMED",
|
||||
@@ -38,9 +39,9 @@ export enum ScheduleStatus {
|
||||
/**
|
||||
* Why a /search leg (outbound or inbound) came back with zero bookable schedules.
|
||||
* Priority order applied by the API when classifying: NoRoute > NoScheduleOnDate >
|
||||
* Cancelled > PackageOnly > CheckinClosed > FullyBooked (see search.service.ts
|
||||
* classifyEmptySearch). The frontend uses this to show a specific empty-state
|
||||
* message instead of a generic "no trains available".
|
||||
* Cancelled > PackageOnly > GroupBookingOnly > CheckinClosed > FullyBooked (see
|
||||
* search.service.ts classifyEmptySearch). The frontend uses this to show a specific
|
||||
* empty-state message instead of a generic "no trains available".
|
||||
*/
|
||||
export enum SearchEmptyReasonCode {
|
||||
/** No route (in either direction) ever connects these two stations. */
|
||||
@@ -51,6 +52,8 @@ export enum SearchEmptyReasonCode {
|
||||
Cancelled = "CANCELLED",
|
||||
/** Every schedule for this pair on this date is package-only (excluded from ticket search). */
|
||||
PackageOnly = "PACKAGE_ONLY",
|
||||
/** Every schedule for this pair on this date is reserved for staff group bookings (excluded from normal ticket search). */
|
||||
GroupBookingOnly = "GROUP_BOOKING_ONLY",
|
||||
/** A bookable schedule exists, but its check-in cutoff has already passed for every option. */
|
||||
CheckinClosed = "CHECKIN_CLOSED",
|
||||
/** A bookable, still-open schedule exists but has no seats left for the requested party. */
|
||||
|
||||
285
pnpm-lock.yaml
generated
285
pnpm-lock.yaml
generated
@@ -604,7 +604,7 @@ importers:
|
||||
version: 5.101.0(react@19.2.6)
|
||||
'@tria-plc/iamui':
|
||||
specifier: file:../../../local-packages/tria-plc-iamui-0.1.1.tgz
|
||||
version: file:local-packages/tria-plc-iamui-0.1.1.tgz(a5d0bdee55164ae56064fb273b530e00)
|
||||
version: file:local-packages/tria-plc-iamui-0.1.1.tgz(0ce39b7e349029277dcd938d06eeb0f7)
|
||||
'@vis.gl/react-google-maps':
|
||||
specifier: ^1.8.3
|
||||
version: 1.8.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
@@ -874,6 +874,9 @@ importers:
|
||||
minio:
|
||||
specifier: 7.1.3
|
||||
version: 7.1.3
|
||||
multer:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
pg:
|
||||
specifier: ^8.21.0
|
||||
version: 8.21.0
|
||||
@@ -13081,11 +13084,11 @@ snapshots:
|
||||
'@babel/helpers': 7.29.7
|
||||
'@babel/parser': 7.29.7
|
||||
'@babel/template': 7.29.7
|
||||
'@babel/traverse': 7.29.7
|
||||
'@babel/traverse': 7.29.7(supports-color@5.5.0)
|
||||
'@babel/types': 7.29.7
|
||||
'@jridgewell/remapping': 2.3.5
|
||||
convert-source-map: 2.0.0
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
gensync: 1.0.0-beta.2
|
||||
json5: 2.2.3
|
||||
semver: 6.3.1
|
||||
@@ -13120,7 +13123,7 @@ snapshots:
|
||||
'@babel/helper-optimise-call-expression': 7.29.7
|
||||
'@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7)
|
||||
'@babel/helper-skip-transparent-expression-wrappers': 7.29.7
|
||||
'@babel/traverse': 7.29.7
|
||||
'@babel/traverse': 7.29.7(supports-color@5.5.0)
|
||||
semver: 6.3.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -13129,14 +13132,7 @@ snapshots:
|
||||
|
||||
'@babel/helper-member-expression-to-functions@7.29.7':
|
||||
dependencies:
|
||||
'@babel/traverse': 7.29.7
|
||||
'@babel/types': 7.29.7
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@babel/helper-module-imports@7.29.7':
|
||||
dependencies:
|
||||
'@babel/traverse': 7.29.7
|
||||
'@babel/traverse': 7.29.7(supports-color@5.5.0)
|
||||
'@babel/types': 7.29.7
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -13151,9 +13147,9 @@ snapshots:
|
||||
'@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/helper-module-imports': 7.29.7
|
||||
'@babel/helper-module-imports': 7.29.7(supports-color@5.5.0)
|
||||
'@babel/helper-validator-identifier': 7.29.7
|
||||
'@babel/traverse': 7.29.7
|
||||
'@babel/traverse': 7.29.7(supports-color@5.5.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -13168,13 +13164,13 @@ snapshots:
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/helper-member-expression-to-functions': 7.29.7
|
||||
'@babel/helper-optimise-call-expression': 7.29.7
|
||||
'@babel/traverse': 7.29.7
|
||||
'@babel/traverse': 7.29.7(supports-color@5.5.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@babel/helper-skip-transparent-expression-wrappers@7.29.7':
|
||||
dependencies:
|
||||
'@babel/traverse': 7.29.7
|
||||
'@babel/traverse': 7.29.7(supports-color@5.5.0)
|
||||
'@babel/types': 7.29.7
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -13327,18 +13323,6 @@ snapshots:
|
||||
'@babel/parser': 7.29.7
|
||||
'@babel/types': 7.29.7
|
||||
|
||||
'@babel/traverse@7.29.7':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.7
|
||||
'@babel/generator': 7.29.7
|
||||
'@babel/helper-globals': 7.29.7
|
||||
'@babel/parser': 7.29.7
|
||||
'@babel/template': 7.29.7
|
||||
'@babel/types': 7.29.7
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@babel/traverse@7.29.7(supports-color@5.5.0)':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.7
|
||||
@@ -13823,7 +13807,7 @@ snapshots:
|
||||
|
||||
'@emotion/babel-plugin@11.13.5':
|
||||
dependencies:
|
||||
'@babel/helper-module-imports': 7.29.7
|
||||
'@babel/helper-module-imports': 7.29.7(supports-color@5.5.0)
|
||||
'@babel/runtime': 7.29.7
|
||||
'@emotion/hash': 0.9.2
|
||||
'@emotion/memoize': 0.9.0
|
||||
@@ -13989,7 +13973,7 @@ snapshots:
|
||||
'@eslint/eslintrc@2.1.4':
|
||||
dependencies:
|
||||
ajv: 6.15.0
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
espree: 9.6.1
|
||||
globals: 13.24.0
|
||||
ignore: 5.3.2
|
||||
@@ -14149,7 +14133,7 @@ snapshots:
|
||||
'@humanwhocodes/config-array@0.13.0':
|
||||
dependencies:
|
||||
'@humanwhocodes/object-schema': 2.0.3
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
minimatch: 3.1.5
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -15748,7 +15732,7 @@ snapshots:
|
||||
|
||||
'@puppeteer/browsers@2.13.2':
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
extract-zip: 2.0.1
|
||||
progress: 2.0.3
|
||||
proxy-agent: 6.5.0
|
||||
@@ -17822,7 +17806,7 @@ snapshots:
|
||||
|
||||
'@tokenizer/inflate@0.4.1':
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
token-types: 6.1.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -18109,130 +18093,6 @@ snapshots:
|
||||
- utf-8-validate
|
||||
- vite
|
||||
|
||||
'@tria-plc/iamui@file:local-packages/tria-plc-iamui-0.1.1.tgz(a5d0bdee55164ae56064fb273b530e00)':
|
||||
dependencies:
|
||||
'@emotion/react': 11.14.0(@types/react@18.3.31)(react@19.2.6)
|
||||
'@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react@19.2.6)
|
||||
'@hookform/resolvers': 5.4.0(react-hook-form@7.77.0(react@19.2.6))
|
||||
'@lottiefiles/react-lottie-player': 3.6.0(react@19.2.6)
|
||||
'@mantine/charts': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(recharts@3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1))
|
||||
'@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@mantine/dates': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@mantine/hooks': 7.17.8(react@19.2.6)
|
||||
'@mantine/notifications': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-accordion': 1.2.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-alert-dialog': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-avatar': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-checkbox': 1.3.4(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-collapsible': 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-context-menu': 2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-dialog': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-dropdown-menu': 2.1.17(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-hover-card': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-label': 2.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-navigation-menu': 1.2.15(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-popover': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-progress': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-radio-group': 1.4.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-scroll-area': 1.2.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-select': 2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-separator': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-slot': 1.2.5(@types/react@18.3.31)(react@19.2.6)
|
||||
'@radix-ui/react-switch': 1.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-tabs': 1.1.14(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-toast': 1.2.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-tooltip': 1.2.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@react-pdf-viewer/core': 3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@react-pdf-viewer/default-layout': 3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@react-pdf/renderer': 4.5.1(react@19.2.6)
|
||||
'@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@18.3.31)(react@19.2.6)(redux@5.0.1))(react@19.2.6)
|
||||
'@tabler/icons-react': 3.44.0(react@19.2.6)
|
||||
'@tailwindcss/vite': 4.3.0(vite@5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0))
|
||||
'@tanstack/react-query': 5.101.0(react@19.2.6)
|
||||
'@tanstack/react-query-devtools': 5.101.0(@tanstack/react-query@5.101.0(react@19.2.6))(react@19.2.6)
|
||||
'@tanstack/react-table': 8.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@tinymce/tinymce-react': 6.3.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tinymce@7.9.3)
|
||||
'@types/dompurify': 3.2.0
|
||||
'@types/node': 24.13.1
|
||||
'@types/tinymce': 4.6.9
|
||||
axios: 1.17.0
|
||||
class-variance-authority: 0.7.1
|
||||
clsx: 2.1.1
|
||||
cmdk: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
date-fns: 3.6.0
|
||||
dayjs: 1.11.21
|
||||
dompurify: 3.4.8
|
||||
ethiopian-calendar-date-converter: 2.1.6
|
||||
ethiopian-calendar-new: 1.1.0
|
||||
file-type: 18.7.0
|
||||
framer-motion: 12.40.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
html2canvas: 1.4.1
|
||||
i18next: 25.10.10(typescript@5.9.3)
|
||||
i18next-browser-languagedetector: 8.2.1
|
||||
jquery: 3.7.1
|
||||
js-cookie: 3.0.8
|
||||
jspdf: 3.0.4
|
||||
lodash: 4.18.1
|
||||
lucide-react: 0.513.0(react@19.2.6)
|
||||
mantine-react-table: 2.0.0-beta.9(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/dates@7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(@tabler/icons-react@3.44.0(react@19.2.6))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
mui-ethiopian-datepicker: 0.3.2(4b3af212eafdf0059f009b005d7e343d)
|
||||
next-themes: 0.4.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
path: 0.12.7
|
||||
pdf-lib: 1.17.1
|
||||
qs: 6.15.2
|
||||
react: 19.2.6
|
||||
react-cookie: 8.1.2(@types/react@18.3.31)(react@19.2.6)
|
||||
react-css-nocode-editor: 1.0.13(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)
|
||||
react-day-picker: 8.10.2(date-fns@3.6.0)(react@19.2.6)
|
||||
react-dom: 19.2.6(react@19.2.6)
|
||||
react-dropzone: 14.4.1(react@19.2.6)
|
||||
react-hook-form: 7.77.0(react@19.2.6)
|
||||
react-i18next: 15.7.4(i18next@25.10.10(typescript@5.9.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
|
||||
react-icons: 5.6.0(react@19.2.6)
|
||||
react-image-crop: 11.0.10(react@19.2.6)
|
||||
react-intersection-observer: 9.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
react-pdf: 10.4.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
react-pdf-html: 2.1.5(@react-pdf/renderer@4.5.1(react@19.2.6))(react@19.2.6)
|
||||
react-redux: 9.3.0(@types/react@18.3.31)(react@19.2.6)(redux@5.0.1)
|
||||
react-resizable-panels: 3.0.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
react-router-dom: 7.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
react-signature-canvas: 1.1.0-alpha.2(@types/prop-types@15.7.15)(@types/react@18.3.31)(prop-types@15.8.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
recharts: 3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1)
|
||||
rollup-plugin-visualizer: 7.0.1(rollup@4.61.1)
|
||||
socket.io-client: 4.8.3
|
||||
sonner: 2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
tailwind-merge: 3.6.0
|
||||
tailwind-scrollbar-hide: 4.0.0(tailwindcss@4.3.0)
|
||||
tailwindcss: 4.3.0
|
||||
tailwindcss-animate: 1.0.7(tailwindcss@4.3.0)
|
||||
tinymce: 7.9.3
|
||||
url: 0.11.4
|
||||
vaul: 1.1.2(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
xlsx: 0.18.5
|
||||
zod: 3.25.76
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
- '@emotion/is-prop-valid'
|
||||
- '@mui/icons-material'
|
||||
- '@mui/material'
|
||||
- '@mui/x-date-pickers'
|
||||
- '@types/prop-types'
|
||||
- '@types/react'
|
||||
- '@types/react-dom'
|
||||
- bufferutil
|
||||
- debug
|
||||
- pdfjs-dist
|
||||
- prop-types
|
||||
- react-is
|
||||
- react-native
|
||||
- redux
|
||||
- rolldown
|
||||
- rollup
|
||||
- supports-color
|
||||
- typescript
|
||||
- utf-8-validate
|
||||
- vite
|
||||
|
||||
'@ts-morph/common@0.27.0':
|
||||
dependencies:
|
||||
fast-glob: 3.3.3
|
||||
@@ -18630,7 +18490,7 @@ snapshots:
|
||||
'@typescript-eslint/types': 8.60.1
|
||||
'@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3)
|
||||
'@typescript-eslint/visitor-keys': 8.60.1
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
eslint: 8.57.1
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
@@ -18640,7 +18500,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3)
|
||||
'@typescript-eslint/types': 8.60.1
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -18659,7 +18519,7 @@ snapshots:
|
||||
'@typescript-eslint/types': 8.60.1
|
||||
'@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3)
|
||||
'@typescript-eslint/utils': 8.60.1(eslint@8.57.1)(typescript@5.9.3)
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
eslint: 8.57.1
|
||||
ts-api-utils: 2.5.0(typescript@5.9.3)
|
||||
typescript: 5.9.3
|
||||
@@ -18674,7 +18534,7 @@ snapshots:
|
||||
'@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3)
|
||||
'@typescript-eslint/types': 8.60.1
|
||||
'@typescript-eslint/visitor-keys': 8.60.1
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
minimatch: 10.2.5
|
||||
semver: 7.8.2
|
||||
tinyglobby: 0.2.17
|
||||
@@ -18963,7 +18823,7 @@ snapshots:
|
||||
|
||||
agent-base@6.0.2:
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -19472,16 +19332,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
babel-plugin-styled-components@2.3.0(styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6))(supports-color@5.5.0):
|
||||
dependencies:
|
||||
'@babel/helper-annotate-as-pure': 7.29.7
|
||||
'@babel/helper-module-imports': 7.29.7(supports-color@5.5.0)
|
||||
'@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7)
|
||||
picomatch: 4.0.4
|
||||
styled-components: 5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
babel-polyfill@6.26.0:
|
||||
dependencies:
|
||||
babel-runtime: 6.26.0
|
||||
@@ -19637,7 +19487,7 @@ snapshots:
|
||||
dependencies:
|
||||
bytes: 3.1.2
|
||||
content-type: 1.0.5
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
http-errors: 2.0.1
|
||||
iconv-lite: 0.7.2
|
||||
on-finished: 2.4.1
|
||||
@@ -20686,7 +20536,7 @@ snapshots:
|
||||
engine.io-client@6.6.5:
|
||||
dependencies:
|
||||
'@socket.io/component-emitter': 3.1.2
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
engine.io-parser: 5.2.3
|
||||
ws: 8.20.1
|
||||
xmlhttprequest-ssl: 2.1.2
|
||||
@@ -20706,7 +20556,7 @@ snapshots:
|
||||
base64id: 2.0.0
|
||||
cookie: 0.7.2
|
||||
cors: 2.8.6
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
engine.io-parser: 5.2.3
|
||||
ws: 8.21.0
|
||||
transitivePeerDependencies:
|
||||
@@ -20937,7 +20787,7 @@ snapshots:
|
||||
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1):
|
||||
dependencies:
|
||||
'@nolyfill/is-core-module': 1.0.39
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
eslint: 8.57.1
|
||||
get-tsconfig: 4.14.0
|
||||
is-bun-module: 2.0.0
|
||||
@@ -21065,7 +20915,7 @@ snapshots:
|
||||
ajv: 6.15.0
|
||||
chalk: 4.1.2
|
||||
cross-spawn: 7.0.6
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
doctrine: 3.0.0
|
||||
escape-string-regexp: 4.0.0
|
||||
eslint-scope: 7.2.2
|
||||
@@ -21302,7 +21152,7 @@ snapshots:
|
||||
content-type: 1.0.5
|
||||
cookie: 0.7.2
|
||||
cookie-signature: 1.2.2
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
depd: 2.0.0
|
||||
encodeurl: 2.0.0
|
||||
escape-html: 1.0.3
|
||||
@@ -21355,7 +21205,7 @@ snapshots:
|
||||
|
||||
extract-zip@2.0.1:
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
get-stream: 5.2.0
|
||||
yauzl: 2.10.0
|
||||
optionalDependencies:
|
||||
@@ -21510,7 +21360,7 @@ snapshots:
|
||||
|
||||
finalhandler@2.1.1:
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
encodeurl: 2.0.0
|
||||
escape-html: 1.0.3
|
||||
on-finished: 2.4.1
|
||||
@@ -21758,7 +21608,7 @@ snapshots:
|
||||
dependencies:
|
||||
basic-ftp: 5.3.1
|
||||
data-uri-to-buffer: 6.0.2
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -22067,7 +21917,7 @@ snapshots:
|
||||
http-proxy-agent@7.0.2:
|
||||
dependencies:
|
||||
agent-base: 7.1.4
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -22080,14 +21930,14 @@ snapshots:
|
||||
https-proxy-agent@5.0.1:
|
||||
dependencies:
|
||||
agent-base: 6.0.2
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
https-proxy-agent@7.0.6:
|
||||
dependencies:
|
||||
agent-base: 7.1.4
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -22527,7 +22377,7 @@ snapshots:
|
||||
|
||||
istanbul-lib-source-maps@4.0.1:
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
istanbul-lib-coverage: 3.2.2
|
||||
source-map: 0.6.1
|
||||
transitivePeerDependencies:
|
||||
@@ -23187,7 +23037,7 @@ snapshots:
|
||||
dependencies:
|
||||
chalk: 5.6.2
|
||||
commander: 13.1.0
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
execa: 8.0.1
|
||||
lilconfig: 3.1.3
|
||||
listr2: 8.3.3
|
||||
@@ -23874,7 +23724,7 @@ snapshots:
|
||||
micromark@4.0.2:
|
||||
dependencies:
|
||||
'@types/debug': 4.1.13
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
decode-named-character-reference: 1.3.0
|
||||
devlop: 1.1.0
|
||||
micromark-core-commonmark: 2.0.3
|
||||
@@ -24400,7 +24250,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@tootallnate/quickjs-emscripten': 0.23.0
|
||||
agent-base: 7.1.4
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
get-uri: 6.0.5
|
||||
http-proxy-agent: 7.0.2
|
||||
https-proxy-agent: 7.0.6
|
||||
@@ -24750,7 +24600,7 @@ snapshots:
|
||||
proxy-agent@6.5.0:
|
||||
dependencies:
|
||||
agent-base: 7.1.4
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
http-proxy-agent: 7.0.2
|
||||
https-proxy-agent: 7.0.6
|
||||
lru-cache: 7.18.3
|
||||
@@ -24779,7 +24629,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@puppeteer/browsers': 2.13.2
|
||||
chromium-bidi: 14.0.0(devtools-protocol@0.0.1608973)
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
devtools-protocol: 0.0.1608973
|
||||
typed-query-selector: 2.12.2
|
||||
webdriver-bidi-protocol: 0.4.1
|
||||
@@ -25032,15 +24882,6 @@ snapshots:
|
||||
- '@babel/core'
|
||||
- react-is
|
||||
|
||||
react-css-nocode-editor@1.0.13(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6):
|
||||
dependencies:
|
||||
react: 19.2.6
|
||||
react-dom: 19.2.6(react@19.2.6)
|
||||
styled-components: 5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
- react-is
|
||||
|
||||
react-day-picker@8.10.2(date-fns@3.6.0)(react@19.2.6):
|
||||
dependencies:
|
||||
date-fns: 3.6.0
|
||||
@@ -25651,7 +25492,7 @@ snapshots:
|
||||
|
||||
router@2.2.0:
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
depd: 2.0.0
|
||||
is-promise: 4.0.0
|
||||
parseurl: 1.3.3
|
||||
@@ -25773,7 +25614,7 @@ snapshots:
|
||||
|
||||
send@1.2.1:
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
encodeurl: 2.0.0
|
||||
escape-html: 1.0.3
|
||||
etag: 1.8.1
|
||||
@@ -25989,7 +25830,7 @@ snapshots:
|
||||
|
||||
socket.io-adapter@2.5.8:
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
ws: 8.21.0
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
@@ -25999,7 +25840,7 @@ snapshots:
|
||||
socket.io-client@4.8.3:
|
||||
dependencies:
|
||||
'@socket.io/component-emitter': 3.1.2
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
engine.io-client: 6.6.5
|
||||
socket.io-parser: 4.2.6
|
||||
transitivePeerDependencies:
|
||||
@@ -26010,7 +25851,7 @@ snapshots:
|
||||
socket.io-parser@4.2.6:
|
||||
dependencies:
|
||||
'@socket.io/component-emitter': 3.1.2
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -26019,7 +25860,7 @@ snapshots:
|
||||
accepts: 1.3.8
|
||||
base64id: 2.0.0
|
||||
cors: 2.8.6
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
engine.io: 6.6.9
|
||||
socket.io-adapter: 2.5.8
|
||||
socket.io-parser: 4.2.6
|
||||
@@ -26031,7 +25872,7 @@ snapshots:
|
||||
socks-proxy-agent@8.0.5:
|
||||
dependencies:
|
||||
agent-base: 7.1.4
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
socks: 2.8.9
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -26326,24 +26167,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
|
||||
styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6):
|
||||
dependencies:
|
||||
'@babel/helper-module-imports': 7.29.7(supports-color@5.5.0)
|
||||
'@babel/traverse': 7.29.7(supports-color@5.5.0)
|
||||
'@emotion/is-prop-valid': 1.4.0
|
||||
'@emotion/stylis': 0.8.5
|
||||
'@emotion/unitless': 0.7.5
|
||||
babel-plugin-styled-components: 2.3.0(styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6))(supports-color@5.5.0)
|
||||
css-to-react-native: 3.2.0
|
||||
hoist-non-react-statics: 3.3.2
|
||||
react: 19.2.6
|
||||
react-dom: 19.2.6(react@19.2.6)
|
||||
react-is: 19.2.7
|
||||
shallowequal: 1.1.0
|
||||
supports-color: 5.5.0
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
|
||||
styled-jsx@5.1.1(babel-plugin-macros@3.1.0)(react@18.3.1):
|
||||
dependencies:
|
||||
client-only: 0.0.1
|
||||
@@ -26369,7 +26192,7 @@ snapshots:
|
||||
dependencies:
|
||||
component-emitter: 1.3.1
|
||||
cookiejar: 2.1.4
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
fast-safe-stringify: 2.1.1
|
||||
form-data: 4.0.5
|
||||
formidable: 3.5.4
|
||||
@@ -26882,7 +26705,7 @@ snapshots:
|
||||
app-root-path: 3.1.0
|
||||
buffer: 6.0.3
|
||||
dayjs: 1.11.21
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
dedent: 1.7.2(babel-plugin-macros@3.1.0)
|
||||
dotenv: 16.6.1
|
||||
glob: 10.5.0
|
||||
@@ -26906,7 +26729,7 @@ snapshots:
|
||||
app-root-path: 3.1.0
|
||||
buffer: 6.0.3
|
||||
dayjs: 1.11.21
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
dedent: 1.7.2(babel-plugin-macros@3.1.0)
|
||||
dotenv: 16.6.1
|
||||
glob: 10.5.0
|
||||
@@ -27267,7 +27090,7 @@ snapshots:
|
||||
vite-node@2.1.9(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0):
|
||||
dependencies:
|
||||
cac: 6.7.14
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
es-module-lexer: 1.7.0
|
||||
pathe: 1.1.2
|
||||
vite: 5.4.21(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0)
|
||||
@@ -27285,7 +27108,7 @@ snapshots:
|
||||
vite-node@2.1.9(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0):
|
||||
dependencies:
|
||||
cac: 6.7.14
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
es-module-lexer: 1.7.0
|
||||
pathe: 1.1.2
|
||||
vite: 5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0)
|
||||
@@ -27332,7 +27155,7 @@ snapshots:
|
||||
'@vitest/spy': 2.1.9
|
||||
'@vitest/utils': 2.1.9
|
||||
chai: 5.3.3
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
expect-type: 1.3.0
|
||||
magic-string: 0.30.21
|
||||
pathe: 1.1.2
|
||||
@@ -27368,7 +27191,7 @@ snapshots:
|
||||
'@vitest/spy': 2.1.9
|
||||
'@vitest/utils': 2.1.9
|
||||
chai: 5.3.3
|
||||
debug: 4.4.3(supports-color@8.1.1)
|
||||
debug: 4.4.3(supports-color@5.5.0)
|
||||
expect-type: 1.3.0
|
||||
magic-string: 0.30.21
|
||||
pathe: 1.1.2
|
||||
|
||||
Reference in New Issue
Block a user