diff --git a/apps/edr-freight-api/src/migrations/2010000000000-AddFeeRuleVehicleType.ts b/apps/edr-freight-api/src/migrations/2010000000000-AddFeeRuleVehicleType.ts new file mode 100644 index 000000000..a71f3cb5b --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2010000000000-AddFeeRuleVehicleType.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Truck-detention rules can be scoped by vehicle type (TRUCK / VAN / TRAILER / + * TANKER / FLATBED / …), so different truck types carry different detention + * rates. Null = applies to any truck type. + */ +export class AddFeeRuleVehicleType2010000000000 implements MigrationInterface { + name = 'AddFeeRuleVehicleType2010000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.warehouse_fee_rules ADD COLUMN IF NOT EXISTS vehicle_type varchar(20)`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE freight.warehouse_fee_rules DROP COLUMN IF EXISTS vehicle_type`); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts index 680eb2591..a688f3e1a 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts @@ -93,6 +93,11 @@ export class CreateFeeRuleDto { @Min(0) freeHours?: number; + @ApiPropertyOptional({ description: 'Truck detention only: scope by vehicle type (TRUCK | VAN | TRAILER | …). Null = any.' }) + @IsOptional() + @IsString() + vehicleType?: string; + @ApiPropertyOptional({ enum: FEE_RULE_BASES, description: 'Double-handling charge basis: PER_CONTAINER | PER_TON | PER_ITEM.', diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-rule.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-rule.entity.ts index f6ad7ec8f..a233143cf 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-rule.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-rule.entity.ts @@ -55,6 +55,11 @@ export class WarehouseFeeRule extends BaseEntity { @Column({ name: 'container_type', type: 'varchar', length: 40, nullable: true }) containerType?: string | null; + // Truck detention only: scope by vehicle type (TRUCK | VAN | TRAILER | TANKER + // | FLATBED | …). Null = any truck type. + @Column({ name: 'vehicle_type', type: 'varchar', length: 20, nullable: true }) + vehicleType?: string | null; + @Column({ name: 'facility_id', type: 'uuid', nullable: true }) facilityId?: string | null; diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts index c322482b2..14a3375c7 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts @@ -14,6 +14,8 @@ interface ItemAttributes { tradeDirection: string | null; cargoTypeCode: string | null; containerTypeCode: string | null; + /** Vehicle type of the truck (truck detention scoping); null otherwise. */ + vehicleType: string | null; inventoryQuantity: number; bookingContainerCount: number; /** Booking cargo total in the cargo's unit of measure: tonnes (PER_TON) or item count (PER_ITEM). */ @@ -52,6 +54,16 @@ export interface FeePreview { ratePerDay: number; amount: number; }>; + /** Truck detention: per-vehicle-type breakdown — each truck-type group billed by its own matching rule. */ + groups?: Array<{ + vehicleType: string | null; + truckCount: number; + chargeableDays: number; + ratePerDay: number; + amount: number; + ruleId: string | null; + ruleName: string | null; + }>; } const MS_PER_DAY = 24 * 60 * 60 * 1000; @@ -194,6 +206,7 @@ export class WarehouseFeeService { if (!check(rule.tradeDirection, item.tradeDirection, { allowBoth: true })) return null; if (!check(rule.cargoTypeCode, item.cargoTypeCode)) return null; if (!check(rule.containerType, item.containerTypeCode)) return null; + if (!check(rule.vehicleType, item.vehicleType)) return null; if (!check(rule.facilityId, item.facilityId)) return null; if (!check(rule.warehouseId, item.warehouseId)) return null; if (!check(rule.yardId, item.yardId)) return null; @@ -374,7 +387,9 @@ export class WarehouseFeeService { // PER_TON (tonnes) and PER_ITEM (piece count) both read the cargo total, // which is stored in the cargo's own unit of measure. const cargoQuantity = Math.max(0, Number(item.cargoQuantity) || 0); - const quantity = basis === 'PER_CONTAINER' ? containerCount : cargoQuantity; + // Double handling applies to IMPORT only — no charge for export/domestic. + const isImport = (item.tradeDirection ?? '').toUpperCase() === 'IMPORT'; + const quantity = !isImport ? 0 : basis === 'PER_CONTAINER' ? containerCount : cargoQuantity; const sourceAmount = Math.round(rate * quantity * 100) / 100; const amount = ruleCurrency ? await this.convertAmount(sourceAmount, ruleCurrency, targetCurrency) : 0; const convertedRate = ruleCurrency ? await this.convertAmount(rate, ruleCurrency, targetCurrency) : 0; @@ -430,42 +445,128 @@ export class WarehouseFeeService { * tiers by detention day) until it is delivered/returned (or now, if open). */ async previewTruckDetention(lastMileId: string, billingCurrency = 'USD'): Promise { - const [row] = await this.dataSource.query( + const [leg] = await this.dataSource.query( `SELECT lm.arrived_at AS "arrivedAt", lm.delivered_at AS "deliveredAt", b.freight_type AS "freightType", - b.trade_direction AS "tradeDirection", - (SELECT count(*) FROM freight.last_mile_vehicle_assignments va - WHERE va.last_mile_id = lm.id AND va.deleted_at IS NULL) AS "truckCount" + b.trade_direction AS "tradeDirection" FROM freight.last_mile lm LEFT JOIN freight.bookings b ON b.id = lm.booking_id WHERE lm.id = $1 AND lm.deleted_at IS NULL`, [lastMileId], ); - if (!row) throw new NotFoundException(`Last-mile record ${lastMileId} not found`); + if (!leg) throw new NotFoundException(`Last-mile record ${lastMileId} not found`); + + // Truck detention applies to IMPORT only — no charge for export/domestic. + if ((leg.tradeDirection ?? '').toUpperCase() !== 'IMPORT') { + const cur = this.normalizeCurrency(billingCurrency); + return { + ruleType: 'TRUCK_DETENTION_FEE', + basis: null, + ruleId: null, + ruleName: null, + freeDays: 0, + ratePerDay: 0, + currency: cur, + ruleCurrency: null, + billingCurrency: cur, + startDate: leg.arrivedAt ? new Date(leg.arrivedAt).toISOString() : null, + endDate: (leg.deliveredAt ? new Date(leg.deliveredAt) : new Date()).toISOString(), + endIsOpen: !leg.deliveredAt, + elapsedDays: 0, + chargeableDays: 0, + containerCount: 0, + billableUnits: 0, + amount: 0, + tiers: [], + groups: [], + }; + } + + // Group the leg's vehicles by type so each truck type is billed by its own + // matching rule (rates differ by truck type). Falls back to one untyped group. + const groupRows: Array<{ vehicleType: string | null; truckCount: number | string }> = + await this.dataSource.query( + `SELECT v.vehicle_type AS "vehicleType", count(*)::int AS "truckCount" + FROM freight.last_mile_vehicle_assignments va + JOIN freight.vehicles v ON v.id = va.vehicle_id AND v.deleted_at IS NULL + WHERE va.last_mile_id = $1 AND va.deleted_at IS NULL + GROUP BY v.vehicle_type`, + [lastMileId], + ); + const groups = groupRows.length ? groupRows : [{ vehicleType: null, truckCount: 1 }]; - const item: ItemAttributes = { - arrivedAt: null, - gateClearedAt: null, - releaseDate: null, - freightType: row.freightType ?? null, - tradeDirection: row.tradeDirection ?? null, - cargoTypeCode: null, - containerTypeCode: null, - inventoryQuantity: 1, - bookingContainerCount: 1, - cargoQuantity: 0, - facilityId: null, - warehouseId: null, - yardId: null, - zoneId: null, - }; const rules = await this.feeRuleRepository.findAll({ where: { isActive: true } }); - const rule = this.bestRule( - rules.filter((r) => r.ruleType === 'TRUCK_DETENTION_FEE'), - item, + const detentionRules = rules.filter((r) => r.ruleType === 'TRUCK_DETENTION_FEE'); + const now = new Date(); + const targetCurrency = this.normalizeCurrency(billingCurrency); + + const computed = await Promise.all( + groups.map(async (g) => { + const item: ItemAttributes = { + arrivedAt: null, + gateClearedAt: null, + releaseDate: null, + freightType: leg.freightType ?? null, + tradeDirection: leg.tradeDirection ?? null, + cargoTypeCode: null, + containerTypeCode: null, + vehicleType: g.vehicleType ?? null, + inventoryQuantity: 1, + bookingContainerCount: 1, + cargoQuantity: 0, + facilityId: null, + warehouseId: null, + yardId: null, + zoneId: null, + }; + const rule = this.bestRule(detentionRules, item); + const c = await this.computeTruckDetention( + rule, + { arrivedAt: leg.arrivedAt, deliveredAt: leg.deliveredAt, truckCount: g.truckCount }, + now, + billingCurrency, + ); + return { vehicleType: g.vehicleType ?? null, truckCount: Math.max(1, Math.round(Number(g.truckCount) || 1)), c }; + }), ); - return this.computeTruckDetention(rule, row, new Date(), billingCurrency); + + const totalAmount = Math.round(computed.reduce((s, x) => s + x.c.amount, 0) * 100) / 100; + const totalTrucks = computed.reduce((s, x) => s + x.truckCount, 0); + const totalBillable = computed.reduce((s, x) => s + x.c.billableUnits, 0); + const chargeableDays = computed[0]?.c.chargeableDays ?? 0; + const single = computed.length === 1 ? computed[0].c : null; + const anyRuleName = computed.find((x) => x.c.ruleId)?.c.ruleName ?? null; + + return { + ruleType: 'TRUCK_DETENTION_FEE', + basis: null, + ruleId: single?.ruleId ?? null, + ruleName: single ? single.ruleName : computed.length > 1 && anyRuleName ? 'Per truck-type rules' : anyRuleName, + freeDays: 0, + ratePerDay: single?.ratePerDay ?? 0, + currency: targetCurrency, + ruleCurrency: single?.ruleCurrency ?? null, + billingCurrency: targetCurrency, + startDate: leg.arrivedAt ? new Date(leg.arrivedAt).toISOString() : null, + endDate: (leg.deliveredAt ? new Date(leg.deliveredAt) : now).toISOString(), + endIsOpen: !leg.deliveredAt, + elapsedDays: chargeableDays, + chargeableDays, + containerCount: totalTrucks, + billableUnits: totalBillable, + amount: totalAmount, + tiers: single ? single.tiers : [], + groups: computed.map((x) => ({ + vehicleType: x.vehicleType, + truckCount: x.truckCount, + chargeableDays: x.c.chargeableDays, + ratePerDay: x.c.ratePerDay, + amount: x.c.amount, + ruleId: x.c.ruleId, + ruleName: x.c.ruleName, + })), + }; } private async computeTruckDetention( diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts index 4683b3bda..9ee1dc398 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts @@ -316,19 +316,44 @@ export class WarehouseInvoiceService { ); } - const truckCount = preview.containerCount; // reused as the per-truck count - const line: InvoiceLineInput = { - chargeType: "TRUCK_DETENTION", - description: `Truck detention - ${preview.chargeableDays} day(s) x ${truckCount} truck(s)${preview.tiers.length ? " using tiered tariff" : ""}`, - quantity: preview.billableUnits, - unitRate: preview.ratePerDay, - amount: preview.amount, - currency: preview.currency, - metadata: { - feeRuleId: preview.ruleId ?? null, - chargeableDays: preview.chargeableDays ?? null, - }, - }; + // One line per truck-type group (each billed by its own matching rule). Groups + // with no matching rule bill 0 and are dropped. Falls back to a single line. + const groups = preview.groups && preview.groups.length ? preview.groups : null; + const lines: InvoiceLineInput[] = groups + ? groups + .filter((g) => g.amount > 0) + .map((g) => ({ + chargeType: "TRUCK_DETENTION", + description: `Truck detention${g.vehicleType ? ` (${g.vehicleType})` : ""} - ${g.chargeableDays} day(s) x ${g.truckCount} truck(s)`, + quantity: g.truckCount * g.chargeableDays, + unitRate: g.ratePerDay, + amount: g.amount, + currency: preview.currency, + metadata: { + feeRuleId: g.ruleId ?? null, + chargeableDays: g.chargeableDays, + vehicleType: g.vehicleType ?? null, + }, + })) + : [ + { + chargeType: "TRUCK_DETENTION", + description: `Truck detention - ${preview.chargeableDays} day(s) x ${preview.containerCount} truck(s)`, + quantity: preview.billableUnits, + unitRate: preview.ratePerDay, + amount: preview.amount, + currency: preview.currency, + metadata: { + feeRuleId: preview.ruleId ?? null, + chargeableDays: preview.chargeableDays ?? null, + }, + }, + ]; + if (lines.length === 0) { + throw new BadRequestException( + "No truck detention is currently payable for this last-mile leg.", + ); + } return this.billing.generateInvoice({ source: "last_mile" as Freight.InvoiceSource, @@ -337,7 +362,7 @@ export class WarehouseInvoiceService { companyId: lm.companyId, companyProfileId: lm.companyProfileId || "", currency: billingCurrency, - lines: [line], + lines, status: Freight.InvoiceStatus.Issued, }); } diff --git a/apps/edr-freight-web/backoffice/src/components/operations/TruckDetentionModal.tsx b/apps/edr-freight-web/backoffice/src/components/operations/TruckDetentionModal.tsx index 1a0d96f96..2088db32d 100644 --- a/apps/edr-freight-web/backoffice/src/components/operations/TruckDetentionModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/operations/TruckDetentionModal.tsx @@ -154,7 +154,37 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM Still accruing — no delivery/return time yet. The amount grows until the vehicle is returned. )} - {preview.tiers && preview.tiers.length > 0 ? ( + {preview.groups && preview.groups.length > 1 ? ( + + + + Truck type + Trucks + Days + Rate / truck / day + Amount + + + + {preview.groups.map((g, i) => ( + + + {g.vehicleType ?? 'Unknown'} + {!g.ruleId && ( + + {' '}· no rule + + )} + + {g.truckCount} + {g.chargeableDays} + {money(g.ratePerDay, preview.currency)} + {money(g.amount, preview.currency)} + + ))} + +
+ ) : preview.tiers && preview.tiers.length > 0 ? ( diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/LoadToTrainPanel.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/LoadToTrainPanel.tsx index 9e017d5fc..e16a29122 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/LoadToTrainPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/LoadToTrainPanel.tsx @@ -1,22 +1,26 @@ import { Alert, Badge, + Box, Button, Checkbox, Group, Loader, - Select, + Paper, Stack, Table, - Tabs, Text, } from '@mantine/core'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { TrainFront } from 'lucide-react'; +import { ChevronDown, ChevronRight, TrainFront } from 'lucide-react'; import { useMemo, useState } from 'react'; import { useToast } from '@/hooks/use-toast'; -import { warehouseService, type TrainLoadableItem } from '@/services/warehouse.service'; +import { + warehouseService, + type LoadableTrain, + type TrainLoadableItem, +} from '@/services/warehouse.service'; import { extractErrorMessage } from './options'; const STAGE_COLOR: Record = { @@ -29,224 +33,275 @@ const STAGE_COLOR: Record = { const weight = (w: number | null) => (w == null ? '—' : `${Number(w).toLocaleString()} kg`); +interface BookingGroup { + bookingId: string | null; + bookingReference: string | null; + customerName: string | null; + items: TrainLoadableItem[]; +} + +function groupByBooking(items: TrainLoadableItem[]): BookingGroup[] { + const map = new Map(); + for (const i of items) { + const key = i.bookingId ?? i.bookingReference ?? 'unknown'; + let g = map.get(key); + if (!g) { + g = { bookingId: i.bookingId, bookingReference: i.bookingReference, customerName: i.customerName, items: [] }; + map.set(key, g); + } + g.items.push(i); + } + return [...map.values()]; +} + /** - * Load to Train — pick an allocated EXPORT train, see the arrived containers/cargoes - * assigned to it (stage tabs), multiselect the ready ones and load them onto their - * already-allocated wagons. Loading follows train + wagon allocation: only items - * that are READY_FOR_LOADING and have an allocated wagon are selectable. + * Load to Train — a datatable of allocated EXPORT trains. Expand a train to see + * the bookings allocated to it; expand a booking to see its containers/cargoes + * and load the ready ones onto their wagons. Only READY_FOR_LOADING items with an + * allocated wagon are selectable. */ export function LoadToTrainPanel() { - const { toast } = useToast(); - const queryClient = useQueryClient(); - const [scheduleId, setScheduleId] = useState(null); - const [tab, setTab] = useState('received'); - const [selected, setSelected] = useState([]); - - const trainsKey = ['loadable-trains']; - const { data: trains = [], isLoading: trainsLoading } = useQuery({ - queryKey: trainsKey, + const { data: trains = [], isLoading } = useQuery({ + queryKey: ['loadable-trains'], queryFn: () => warehouseService.getLoadableTrains(), }); - - const itemsKey = ['train-loadable-items', scheduleId]; - const { data: items = [], isLoading } = useQuery({ - queryKey: itemsKey, - queryFn: () => warehouseService.getTrainLoadableItems(scheduleId as string), - enabled: Boolean(scheduleId), - }); - - const received = useMemo(() => items.filter((i) => i.status !== 'LOADED'), [items]); - const loaded = useMemo(() => items.filter((i) => i.status === 'LOADED'), [items]); - const visible = tab === 'loaded' ? loaded : received; - - const trainOptions = trains.map((t) => ({ - value: t.scheduleId, - label: - `${t.trainNumber ?? t.scheduleId.slice(0, 8)}` + - (t.origin || t.destination ? ` · ${t.origin ?? '?'}→${t.destination ?? '?'}` : '') + - ` · ${t.readyCount} ready / ${t.loadedCount} loaded`, - })); - - const selectableVisible = visible.filter((i) => i.loadable); - const allSelected = - selectableVisible.length > 0 && selectableVisible.every((i) => selected.includes(i.id)); + const [expanded, setExpanded] = useState>(new Set()); const toggle = (id: string) => + setExpanded((s) => { + const next = new Set(s); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + + if (isLoading) { + return ( + + + + ); + } + if (trains.length === 0) { + return ( + + No allocated EXPORT trains awaiting loading. Trains appear here after train and wagon allocation. + + ); + } + + return ( + +
+ + + + Train + Route + Ready + Loaded + + + + {trains.map((t) => ( + toggle(t.scheduleId)} + /> + ))} + +
+ + ); +} + +function TrainRow({ train, expanded, onToggle }: { train: LoadableTrain; expanded: boolean; onToggle: () => void }) { + const { data: items = [], isLoading } = useQuery({ + queryKey: ['train-loadable-items', train.scheduleId], + queryFn: () => warehouseService.getTrainLoadableItems(train.scheduleId), + enabled: expanded, + }); + const bookings = useMemo(() => groupByBooking(items), [items]); + const route = + train.origin || train.destination ? `${train.origin ?? '?'} → ${train.destination ?? '?'}` : '—'; + + return ( + <> + + {expanded ? : } + + + + {train.trainNumber ?? train.scheduleId.slice(0, 8)} + + + {route} + + + {train.readyCount} + + + + + {train.loadedCount} + + + + {expanded && ( + + + + {isLoading ? ( + + + + ) : bookings.length === 0 ? ( + + No arrived containers/cargoes allocated to this train yet. + + ) : ( + + {bookings.map((b) => ( + + ))} + + )} + + + + )} + + ); +} + +function BookingBlock({ scheduleId, booking }: { scheduleId: string; booking: BookingGroup }) { + const { toast } = useToast(); + const queryClient = useQueryClient(); + const [open, setOpen] = useState(false); + const [selected, setSelected] = useState([]); + + const loadedCount = booking.items.filter((i) => i.status === 'LOADED').length; + const selectable = booking.items.filter((i) => i.loadable); + const allSelected = selectable.length > 0 && selectable.every((i) => selected.includes(i.id)); + const toggleItem = (id: string) => setSelected((s) => (s.includes(id) ? s.filter((x) => x !== id) : [...s, id])); const toggleAll = () => setSelected((s) => - allSelected - ? s.filter((id) => !selectableVisible.some((i) => i.id === id)) - : Array.from(new Set([...s, ...selectableVisible.map((i) => i.id)])), + allSelected ? s.filter((id) => !selectable.some((i) => i.id === id)) : selectable.map((i) => i.id), ); const loadMutation = useMutation({ - mutationFn: () => warehouseService.loadItemsOntoTrain(scheduleId as string, selected), + mutationFn: () => warehouseService.loadItemsOntoTrain(scheduleId, selected), onSuccess: (r) => { - queryClient.invalidateQueries({ queryKey: itemsKey }); - queryClient.invalidateQueries({ queryKey: trainsKey }); + queryClient.invalidateQueries({ queryKey: ['train-loadable-items', scheduleId] }); + queryClient.invalidateQueries({ queryKey: ['loadable-trains'] }); setSelected([]); - toast({ - title: 'Loaded onto train', - description: `Loaded ${r.loadedCount} item(s); skipped ${r.skippedCount}.`, - }); + toast({ title: 'Loaded onto train', description: `Loaded ${r.loadedCount}; skipped ${r.skippedCount}.` }); }, onError: (e) => toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(e) }), }); - const renderRow = (i: TrainLoadableItem) => ( - - - toggle(i.id)} - disabled={!i.loadable} - /> - - - {i.containerNumber ?? i.cargoType ?? '—'} - - {i.cargoType ?? '—'} - {weight(i.weight)} - - - {i.status.replace(/_/g, ' ')} - - - - {i.wagonNumber ? ( - - {i.wagonNumber} - - ) : ( - - Not allocated - - )} - - {i.bookingReference ?? '—'} - {i.customerName ?? '—'} - - {i.inspectionStatus ? ( - - {i.inspectionStatus} - - ) : ( - '—' - )} - - - ); - return ( - - - setForm((f) => ({ ...f, tradeDirection: selectValue(value) }))} - clearable + disabled={isImportOnly} + clearable={!isImportOnly} /> + {isTruckDetention && ( + = { PER_ITEM: 'Per Item', }; +/** Vehicle types a Truck Detention rule can be scoped to (rates differ by truck type). */ +export const VEHICLE_TYPES = ['TRUCK', 'VAN', 'CAR', 'BUS', 'TRAILER', 'TANKER', 'FLATBED'] as const; +export type VehicleTypeCode = (typeof VEHICLE_TYPES)[number]; + export interface FeeRule { id: string; name: string; @@ -774,6 +778,8 @@ export interface FeeRule { tradeDirection?: string | null; cargoTypeCode?: string | null; containerType?: string | null; + /** Truck detention only: scope by vehicle type (null = any). */ + vehicleType?: string | null; facilityId?: string | null; warehouseId?: string | null; yardId?: string | null; @@ -820,6 +826,16 @@ export interface FeePreview { billableUnits: number; amount: number; tiers?: FeePreviewTier[]; + /** Truck detention: per-vehicle-type breakdown. */ + groups?: Array<{ + vehicleType: string | null; + truckCount: number; + chargeableDays: number; + ratePerDay: number; + amount: number; + ruleId: string | null; + ruleName: string | null; + }>; } export interface AllocationPreviewResult { diff --git a/apps/edr-passenger-api/src/modules/packages/packages.service.ts b/apps/edr-passenger-api/src/modules/packages/packages.service.ts index a474b1edf..916a0425b 100644 --- a/apps/edr-passenger-api/src/modules/packages/packages.service.ts +++ b/apps/edr-passenger-api/src/modules/packages/packages.service.ts @@ -75,7 +75,6 @@ export class PackagesService { const maxChildren = adultCount * PKG_CHILDREN_PER_ADULT; if (childCount > maxChildren) throw new BadRequestException(`Maximum ${PKG_CHILDREN_PER_ADULT} children per adult (${maxChildren} for ${adultCount} adult${adultCount !== 1 ? 's' : ''}) allowed per package booking`); - const remaining = tier.availableSeats - tier.bookedSeats; const isRoundTrip = !!pkg.returnScheduleId; const { adultFareMinor, freeChildren, paidChildren, totalMinor } = calculatePackageFareBreakdown( tier.priceMinor, isRoundTrip, adultCount, childCount, @@ -84,6 +83,10 @@ export class PackagesService { // Only adults and paid children need seats; free children travel without a seat const seatsNeeded = adultCount + paidChildren; const passengerCount = adultCount + childCount; + // Re-fetch tier from DB to get accurate live counts + const liveTier = await this.prisma.packagePriceTier.findUnique({ where: { id: tierId } }); + if (!liveTier) throw new NotFoundException('Price tier not found'); + const remaining = liveTier.availableSeats - liveTier.bookedSeats; if (seatsNeeded > remaining) throw new BadRequestException(`Only ${remaining} seat(s) remaining in the ${tier.label} tier`); @@ -392,25 +395,29 @@ export class PackagesService { if (childCount > maxChildrenBook) throw new BadRequestException(`Maximum ${PKG_CHILDREN_PER_ADULT} children per adult (${maxChildrenBook} for ${adultCount} adult${adultCount !== 1 ? 's' : ''}) allowed per package booking`); const passengerCount = adultCount + childCount; - const remaining = tier.availableSeats - tier.bookedSeats; - const isRoundTrip = !!pkg.returnScheduleId; const { adultFareMinor, freeChildren, paidChildren, totalMinor } = calculatePackageFareBreakdown( tier.priceMinor, isRoundTrip, adultCount, childCount, ); // Only adults and paid children need seats; free children travel without a seat const seatsNeeded = adultCount + paidChildren; - if (seatsNeeded > remaining) { - throw new BadRequestException(`Only ${remaining} seats remaining in the ${tier.label} tier`); - } const displayCurrency = (dto.displayCurrency as Currency) ?? Currency.ETB; const displayTotalMinor = displayCurrency !== Currency.ETB ? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency) : totalMinor; - const [booking] = await this.prisma.$transaction([ - this.prisma.packageBooking.create({ + const [booking] = await this.prisma.$transaction(async (tx) => { + // Re-fetch tier inside transaction for race-condition-safe availability check + const freshTier = await tx.packagePriceTier.findUnique({ where: { id: dto.priceTierId } }); + if (!freshTier) throw new NotFoundException('Price tier not found'); + const remaining = freshTier.availableSeats - freshTier.bookedSeats; + if (seatsNeeded > remaining) { + throw new BadRequestException(`Only ${remaining} seats remaining in the ${tier.label} tier`); + } + + return Promise.all([ + tx.packageBooking.create({ data: { bookingRef: generateRef(), packageId: dto.packageId, @@ -448,12 +455,16 @@ export class PackagesService { }, }, }, - }), - this.prisma.packagePriceTier.update({ - where: { id: dto.priceTierId }, - data: { bookedSeats: { increment: seatsNeeded } }, - }), - ]); + }), + tx.packagePriceTier.update({ + where: { id: dto.priceTierId }, + data: { + bookedSeats: { increment: seatsNeeded }, + availableSeats: { decrement: seatsNeeded }, + }, + }), + ]); + }); return { ...booking, diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts index 2942625ca..355446544 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -64,7 +64,7 @@ export class SearchService { const outbound = [...direct, ...transit]; - if (outbound.length === 0) { + if (outbound.length === 0 && dto.journeyType !== 'ROUND_TRIP') { const alternativesOutbound = await this.searchAlternatives( dto.originStationId, dto.destinationStationId, @@ -74,7 +74,7 @@ export class SearchService { dto.nationality, ); return { - journeyType: dto.journeyType === 'ROUND_TRIP' ? 'ROUND_TRIP' : 'ONE_WAY', + journeyType: 'ONE_WAY', outbound: [], alternativeOutbound: alternativesOutbound, requestedDate: dto.date, @@ -110,19 +110,29 @@ export class SearchService { new Date(s.departureAt ?? s.leg1?.departureAt).getTime() > latestOutboundArrival ); - if (inbound.length === 0) { - const alternativeInbound = await this.searchAlternatives( - dto.destinationStationId, - dto.originStationId, - dto.returnDate ?? dto.date, - dto.adultCount, - dto.childCount, - dto.nationality, - ); - return { journeyType: 'ROUND_TRIP', outbound, inbound: [], alternativeInbound }; + const returnDate = dto.returnDate ?? dto.date; + + if (outbound.length === 0 || inbound.length === 0) { + const [alternativeOutbound, alternativeInbound] = await Promise.all([ + outbound.length === 0 + ? this.searchAlternatives(dto.originStationId, dto.destinationStationId, dto.date, dto.adultCount, dto.childCount, dto.nationality) + : Promise.resolve([]), + inbound.length === 0 + ? this.searchAlternatives(dto.destinationStationId, dto.originStationId, returnDate, dto.adultCount, dto.childCount, dto.nationality) + : Promise.resolve([]), + ]); + return { + journeyType: 'ROUND_TRIP', + outbound, + inbound, + alternativeOutbound, + alternativeInbound, + requestedDate: dto.date, + requestedReturnDate: returnDate, + }; } - return { journeyType: 'ROUND_TRIP', outbound, inbound }; + return { journeyType: 'ROUND_TRIP', outbound, inbound, requestedDate: dto.date, requestedReturnDate: returnDate }; } return { journeyType: 'ONE_WAY', outbound }; diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts index 495c394ee..b26dcb763 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -90,6 +90,7 @@ export class SeatsService { label: a.coach.number, mode: a.coach.status, name: `Coach ${a.coach.number}`, + coachTypeId: a.coach.coachType?.id ?? null, coachTypeName, isBedCoach, bedCategory, @@ -665,6 +666,7 @@ export class SeatsService { return { coachId: a.coach.id, + coachTypeId: a.coach.coachType?.id ?? null, coachNumber: a.coach.number, positionNumber: a.positionNumber, coachTypeName: a.coach.coachType?.name ?? '', diff --git a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx index b94b9ee14..77a18380f 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx @@ -143,17 +143,18 @@ export default function ResultsPage() { } } - // For one-way, check if outbound has results - // For round-trip, check if BOTH outbound and inbound have results - const hasResults = isRoundTrip - ? (outboundSchedules.length > 0 && inboundSchedules.length > 0) - : outboundSchedules.length > 0; - - // One-way searches that come back with an empty outbound list may still include - // date-shifted alternatives from the API — surface those instead of a dead end. - const isOneWayNoOutbound = !isRoundTrip && !!results && outboundSchedules.length === 0; - const alternativeOutbound: Schedule[] = isOneWayNoOutbound ? (results.alternativeOutbound || []) : []; + // Alternatives are surfaced whenever a leg returns no exact-date results. + const alternativeOutbound: Schedule[] = (!!results && outboundSchedules.length === 0) ? (results?.alternativeOutbound || []) : []; + const alternativeInbound: Schedule[] = (isRoundTrip && !!results && inboundSchedules.length === 0) ? (results?.alternativeInbound || []) : []; const requestedDate: string = (results && results.requestedDate) || searchData.date; + const requestedReturnDate: string = (results && results.requestedReturnDate) || searchData.returnDate || ''; + + const isOneWayNoOutbound = !isRoundTrip && !!results && outboundSchedules.length === 0; + // Round-trip: show results view if either leg has exact results OR alternatives. + // One-way: need at least one outbound result. + const hasResults = isRoundTrip + ? (outboundSchedules.length > 0 || alternativeOutbound.length > 0) || (inboundSchedules.length > 0 || alternativeInbound.length > 0) + : outboundSchedules.length > 0; const handleSelectCoachType = (scheduleId: string, coachTypeId: string, coachTypeCode: string, coachTypeName: string, seatClassName: string) => { setSelectedCoachTypes(prev => ({ ...prev, [scheduleId]: { id: coachTypeId, code: coachTypeCode, name: coachTypeName, seatClassName } })); @@ -687,8 +688,28 @@ export default function ResultsPage() { } if (!hasResults) { - // ONE_WAY search with an explicit empty outbound list — surface any date-shifted - // alternatives the API suggests instead of a dead-end "no trains found" screen. + const isRoundTripNoResults = isRoundTrip && !!results && outboundSchedules.length === 0 && inboundSchedules.length === 0 && alternativeOutbound.length === 0 && alternativeInbound.length === 0; + if (isRoundTripNoResults) { + return ( +
+
+
+
+
+ +
+

No trains found

+

+ We couldn't find any trains for your round trip. Try adjusting your dates or route. +

+ +
+
+
+
+ ); + } + if (isOneWayNoOutbound) { const requestedDateLabel = requestedDate ? format(new Date(`${requestedDate}T00:00:00`), 'EEEE, MMMM d, yyyy') @@ -843,6 +864,26 @@ export default function ResultsPage() {
{outboundSchedules.map((schedule: Schedule) => renderScheduleCard(schedule, true))}
+ {outboundSchedules.length === 0 && alternativeOutbound.length > 0 && ( +
+
+
+ +
+

No trains available

+

+ No trains are available on {requestedDate ? format(new Date(`${requestedDate}T00:00:00`), 'EEEE, MMMM d, yyyy') : 'your selected date'}. This may be due to no scheduled service or full capacity. Please check the alternative options below or try a different date. +

+ +
+
+

Alternative Outbound Options

+
+
+ {alternativeOutbound.map((schedule: Schedule) => renderScheduleCard(schedule, true, true))} +
+
+ )} ) : (
@@ -891,6 +932,26 @@ export default function ResultsPage() {
{inboundSchedules.map((schedule: Schedule) => renderScheduleCard(schedule, false))}
+ {inboundSchedules.length === 0 && alternativeInbound.length > 0 && ( +
+
+
+ +
+

No trains available

+

+ No trains are available on {requestedReturnDate ? format(new Date(`${requestedReturnDate}T00:00:00`), 'EEEE, MMMM d, yyyy') : 'your selected return date'}. This may be due to no scheduled service or full capacity. Please check the alternative options below or try a different date. +

+ +
+
+

Alternative Return Options

+
+
+ {alternativeInbound.map((schedule: Schedule) => renderScheduleCard(schedule, false, true))} +
+
+ )}
) ) : ( diff --git a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx index 4c751862d..95931febe 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx @@ -174,6 +174,11 @@ export default function SeatsPage() { searchCriteria, bookingId, packageName, + packageId, + priceTierId, + packageDepartureStationId, + packageDepartureStationName, + setPackageContext, } = useBookingStore(); const { isAuthenticated } = useAuthStore(); // Maps passenger index -> assigned seat id. A passenger can only get a seat while @@ -200,9 +205,12 @@ export default function SeatsPage() { type: "info" as "warning" | "error" | "success" | "info", onConfirm: undefined as (() => void) | undefined, showCancel: false, + confirmText: "OK", }); + const [autoAssigningReturn, setAutoAssigningReturn] = useState(false); const isRoundTrip = searchCriteria?.tripType === "ROUND_TRIP"; + const isPackageBooking = !!packageName; const currentSchedule = isRoundTrip && currentJourneyType === "inbound" ? inboundSchedule @@ -355,8 +363,10 @@ export default function SeatsPage() { return raw .map((c: any, idx: number) => ({ id: c.id || c.coachId || String(idx), + coachId: c.coachId || c.id || null, label: c.label || c.coachNumber || c.name || c.coachTypeName || `Coach ${idx + 1}`, type: String(c.type || c.coachType || c.category || c.coachTypeCode || c.coachTypeName || ""), + coachTypeName: String(c.coachTypeName || c.type || c.coachType || c.category || ""), typeName: c.coachTypeName || c.coachType || c.category || c.type || "", coachTypeId: c.coachTypeId ?? c.typeId ?? null, remainingSeats: c.remainingSeats ?? c.availableSeats ?? c.available ?? null, @@ -365,6 +375,22 @@ export default function SeatsPage() { .sort((a: any, b: any) => a.sequence - b.sequence); }, [trainCoachesData]); + // Resolve the CoachType UUID for a preview-list coach. The preview API now returns + // coachTypeId directly; fall back to cross-referencing the seatmap data for older + // API versions that may not include it. + const resolveCoachTypeIdFromSeatmap = useCallback((previewCoach: any): string | null => { + if (previewCoach.coachTypeId) return previewCoach.coachTypeId; + // Fallback: match by label against the current seatmap coaches + const previewLabel = previewCoach.label || previewCoach.coachNumber || ""; + const allSeatmapCoaches: any[] = (seatMapData as any)?.coaches || (seatMapData as any)?.data?.coaches || []; + const match = allSeatmapCoaches.find( + (c: any) => (c.label || c.coachNumber || "") === previewLabel || + c.id === previewCoach.coachId || + c.id === previewCoach.id + ); + return match?.coachTypeId ?? null; + }, [seatMapData]); + const isDiningCoachType = (type: string) => /dining|dpc/i.test(type); // Looks up a coach type's lowest per-adult fare from the coach-type/fare data captured @@ -412,17 +438,15 @@ export default function SeatsPage() { const applyCoachTypeSwitch = (coach: any, matchedType: any) => { const newFare = getCoachTypeFare(matchedType.coachTypeId || matchedType.coachId); const firstClass = matchedType.classes?.[0]; + const newCoachTypeId = matchedType.coachTypeId || matchedType.coachId; const updatedSchedule = { ...(currentSchedule as any), - selectedCoachTypeId: matchedType.coachTypeId || matchedType.coachId, - selectedCoachTypeCode: matchedType.coachTypeCode, - selectedCoachTypeName: matchedType.coachTypeName, - selectedSeatClass: firstClass?.name || matchedType.coachTypeName, - selectedSeatClassName: firstClass?.name || matchedType.coachTypeName, - // review/page.tsx's fare-breakdown request reads THIS field (not - // selectedSeatClassName) to resolve the seat class — must stay in sync or the - // review page keeps pricing against the coach type the user switched away from. - seatClassName: firstClass?.name || matchedType.coachTypeName, + selectedCoachTypeId: newCoachTypeId, + selectedCoachTypeCode: matchedType.coachTypeCode || coach.type || "", + selectedCoachTypeName: matchedType.coachTypeName || coach.coachTypeName || coach.typeName || "", + selectedSeatClass: firstClass?.name || matchedType.coachTypeName || coach.coachTypeName || "", + selectedSeatClassName: firstClass?.name || matchedType.coachTypeName || coach.coachTypeName || "", + seatClassName: firstClass?.name || matchedType.coachTypeName || coach.coachTypeName || "", baseFareAdult: newFare ?? (currentSchedule as any)?.baseFareAdult, baseFareChild: newFare ?? (currentSchedule as any)?.baseFareChild, }; @@ -431,6 +455,11 @@ export default function SeatsPage() { setInboundSchedule(updatedSchedule); } else if (isRoundTrip) { setOutboundSchedule(updatedSchedule); + // For package bookings both legs always use the same coach type — mirror the + // switch to the inbound schedule so the auto-assign fetches the right seatmap. + if (isPackageBooking && inboundSchedule) { + setInboundSchedule({ ...(inboundSchedule as any), ...updatedSchedule, id: inboundSchedule.id }); + } } else { setSelectedSchedule(updatedSchedule); } @@ -440,6 +469,19 @@ export default function SeatsPage() { setSelectedCoach(null); setPendingCoachLabel(coach.label); setShowCoachPreview(false); + + // For package bookings, sync the stored tier price with the new coach type's fare + // so the review page totals reflect the switched coach type. + if (isPackageBooking && packageId && newFare != null) { + setPackageContext( + packageId, + priceTierId ?? '', + newFare, + packageName ?? undefined, + packageDepartureStationId ?? undefined, + packageDepartureStationName ?? undefined, + ); + } }; // Same coach type as the one already loaded — no refetch needed, just bring this @@ -450,12 +492,6 @@ export default function SeatsPage() { setShowCoachPreview(false); }; - // Coach card click handler for the Train Coach Preview: validates availability, then - // immediately loads that coach's seat map (switching coach type if needed) — no price - // confirmation here. Individual seats within a coach type/bed coach can still be priced - // differently (e.g. Upper/Middle/Lower berths), so the fare confirmation instead happens - // at the point of actually picking a seat (see handleSeatClick), once real seat data is - // in view. const handlePreviewCoachSelect = (coach: any) => { if (coach.remainingSeats != null && coach.remainingSeats <= 0) { setModalState({ @@ -465,31 +501,71 @@ export default function SeatsPage() { type: "warning", onConfirm: undefined, showCancel: false, + confirmText: "OK", }); return; } - const types = (currentSchedule as any)?.coachTypes || []; - const matchedType = types.find( - (ct: any) => - ct.coachTypeId === coach.coachTypeId || - ct.coachId === coach.coachTypeId || - ct.coachTypeCode === coach.type || - ct.coachTypeName === coach.type, - ); - const isSameType = - matchedType && - (matchedType.coachTypeId === coachTypeId || matchedType.coachId === coachTypeId); + // Resolve the real CoachType UUID by cross-referencing the seatmap data, + // since the preview API (/seats/coaches) returns physical coach IDs, not CoachType UUIDs. + const resolvedCoachTypeId = resolveCoachTypeIdFromSeatmap(coach); - if (!matchedType || isSameType) { - // Same coach type — just bring this physical coach's seat map into view. + // Same coach type as currently loaded — just scroll to it. + // Only trust the resolved UUID; name/code comparisons are unreliable across + // different API responses and cause false positives for package bookings. + const isSameType = !!resolvedCoachTypeId && resolvedCoachTypeId === coachTypeId; + + if (isSameType) { focusCoachInPlace(coach); return; } - // Different coach type — switch to it and load its seat map; per-seat fare - // confirmation (if any) happens once the user picks an actual seat. - applyCoachTypeSwitch(coach, matchedType); + // Different coach type — build matchedType from coachTypes array or synthesise. + const types = (currentSchedule as any)?.coachTypes || []; + const matchedType = types.find( + (ct: any) => + (resolvedCoachTypeId && (ct.coachTypeId === resolvedCoachTypeId || ct.coachId === resolvedCoachTypeId)) || + (coach.coachTypeName && ct.coachTypeName === coach.coachTypeName) || + (coach.type && (ct.coachTypeCode === coach.type || ct.coachTypeName === coach.type)), + ) ?? { + coachTypeId: resolvedCoachTypeId, + coachId: resolvedCoachTypeId, + coachTypeName: coach.coachTypeName || coach.typeName || coach.type || "", + coachTypeCode: coach.type || "", + classes: [], + }; + + const newFare = getCoachTypeFare(matchedType.coachTypeId || matchedType.coachId); + const currentFare = originalFareForCurrentLeg ?? (currentSchedule as any)?.baseFareAdult ?? null; + + // Always confirm when switching to a different coach type — show fare difference + // if known, or a generic confirmation if fares can't be resolved. + if (newFare != null && currentFare != null && newFare !== currentFare) { + setModalState({ + isOpen: true, + title: "Fare Will Change", + message: `Switching to ${coach.label} (${matchedType.coachTypeName || coach.typeName || ""}) changes the fare to ETB ${(newFare / 100 * 2).toFixed(2)} per adult (currently ETB ${(currentFare / 100 * 2).toFixed(2)}). Continue?`, + type: "warning", + showCancel: true, + confirmText: "Switch Coach", + onConfirm: () => applyCoachTypeSwitch(coach, matchedType), + }); + return; + } + + // Same fare or fare unknown — still confirm the coach type switch. + const coachTypeName = matchedType.coachTypeName || coach.coachTypeName || coach.typeName || ""; + setModalState({ + isOpen: true, + title: "Switch Coach Type", + message: `Switch to ${coach.label}${coachTypeName ? ` (${coachTypeName})` : ""}?${ + newFare != null ? ` Fare: ETB ${(newFare / 100 * 2).toFixed(2)} per adult.` : " This will have a fare change." + }`, + type: "info", + showCancel: true, + confirmText: "Switch Coach", + onConfirm: () => applyCoachTypeSwitch(coach, matchedType), + }); }; const holdMutation = useMutation({ @@ -742,7 +818,9 @@ export default function SeatsPage() { const newSeat = validSeats?.find((s: any) => s.id === seatId); const newFare = newSeat ? getSeatFare(newSeat) : null; - if (newFare != null) { + // Package bookings: fare-change warning on individual seat clicks is suppressed. + // The only fare-change confirmation is when switching coach type via Train Coach Preview. + if (newFare != null && !isPackageBooking) { let referenceFare: number | null = null; let referenceLabel = "the fare you originally selected"; @@ -772,9 +850,10 @@ export default function SeatsPage() { setModalState({ isOpen: true, title: "Fare Will Change", - message: `${positionLabel} ${seatLabel} costs ETB ${(newFare / 100).toFixed(2)}, different from ${referenceLabel}. Continue with this selection?`, + message: `${positionLabel} ${seatLabel} costs ETB ${(newFare / 100 * 2).toFixed(2)}, different from ${referenceLabel}. Continue with this selection?`, type: "warning", showCancel: true, + confirmText: "Continue", onConfirm: () => commitSeatAssignment(seatId), }); return; @@ -841,6 +920,152 @@ export default function SeatsPage() { }; }); setPassengers(updatedPassengers); + + if (isPackageBooking && inboundSchedule) { + setAutoAssigningReturn(true); + try { + // Package bookings always use the same coach type for both legs — use the + // outbound's (just-confirmed) coachTypeId so a prior coach-type switch is + // reflected in the inbound seatmap fetch even if inboundSchedule wasn't updated. + const inboundCoachTypeId = (currentSchedule as any)?.selectedCoachTypeId || (inboundSchedule as any).selectedCoachTypeId; + const inboundOriginId = (inboundSchedule as any).originStationId || searchCriteria?.destinationStationId; + const inboundDestId = (inboundSchedule as any).destinationStationId || searchCriteria?.originStationId; + + // Fetch the full outbound seatmap to resolve seat number/bedPosition by ID — + // validSeats only holds the currently-expanded coach and may be empty. + const outboundCoachTypeId = (currentSchedule as any)?.selectedCoachTypeId; + const outboundOriginId = (currentSchedule as any)?.originStationId || searchCriteria?.originStationId; + const outboundDestId = (currentSchedule as any)?.destinationStationId || searchCriteria?.destinationStationId; + const outboundMapData: any = await apiClient.get( + `/seats/seatmap/${currentSchedule?.id}?coachTypeId=${outboundCoachTypeId}&journeyDirection=OUTBOUND${outboundOriginId ? `&originStationId=${outboundOriginId}` : ''}${outboundDestId ? `&destinationStationId=${outboundDestId}` : ''}` + ); + const outboundCoaches: any[] = (outboundMapData as any)?.coaches || (outboundMapData as any)?.data?.coaches || []; + const allOutboundSeats: any[] = []; + outboundCoaches.forEach((c: any) => { + if (c.rooms?.length > 0) { + c.rooms.forEach((r: any) => r.beds?.forEach((b: any) => allOutboundSeats.push(b))); + } else { + (c.seats || []).forEach((s: any) => allOutboundSeats.push(s)); + } + }); + + const inboundMapData: any = await apiClient.get( + `/seats/seatmap/${inboundSchedule.id}?coachTypeId=${inboundCoachTypeId}&journeyDirection=RETURN${inboundOriginId ? `&originStationId=${inboundOriginId}` : ''}${inboundDestId ? `&destinationStationId=${inboundDestId}` : ''}` + ); + const inboundCoaches: any[] = (inboundMapData as any)?.coaches || (inboundMapData as any)?.data?.coaches || []; + + const allInboundSeats: any[] = []; + inboundCoaches.forEach((c: any) => { + const coachLabel = c.label || c.name || c.coachNumber || ''; + if (c.rooms?.length > 0) { + c.rooms.forEach((r: any) => r.beds?.forEach((b: any) => allInboundSeats.push({ ...b, _coachLabel: coachLabel }))); + } else { + (c.seats || []).forEach((s: any) => allInboundSeats.push({ ...s, _coachLabel: coachLabel })); + } + }); + + const claimedIds = new Set(); + const inboundSeatMap: Record = {}; + + for (const i of seatEligibleIndices) { + // Resolve outbound seat from the full seatmap fetch (not validSeats which + // only holds the currently-expanded coach and is often empty). + const outboundSeat = allOutboundSeats.find((s: any) => s.id === seatIds[i]); + const outboundBase = outboundSeat ? (outboundSeat.number || outboundSeat.label || outboundSeat.seatNumber || '') : ''; + const outboundBedPos: string | null = outboundSeat?.bedPosition || null; + + // Priority 1: exact same seat number + same bed position + // Priority 2: same bed position, any available seat + // Priority 3: any available seat (fallback) + const match = + allInboundSeats.find((s: any) => s.status === 'AVAILABLE' && !claimedIds.has(s.id) && (s.number || s.label || s.seatNumber || '') === outboundBase && (!outboundBedPos || s.bedPosition === outboundBedPos)) || + allInboundSeats.find((s: any) => s.status === 'AVAILABLE' && !claimedIds.has(s.id) && outboundBedPos && s.bedPosition === outboundBedPos) || + allInboundSeats.find((s: any) => s.status === 'AVAILABLE' && !claimedIds.has(s.id)); + + if (match) { + inboundSeatMap[i] = match.id; + claimedIds.add(match.id); + } + } + + if (claimedIds.size < seatEligibleIndices.length) { + // Not enough inbound seats found — fall through to manual inbound selection + setAutoAssigningReturn(false); + setCurrentJourneyType("inbound"); + setPassengerSeatMap({}); + setActivePassengerIndex(seatEligibleIndices[0] ?? 0); + setSelectedCoach(null); + return; + } + + // Hold inbound seats directly — holdMutation reads currentJourneyType + // which is still "outbound" at this point, so we call the API directly. + const inboundHoldData: any = await apiClient.post('/seats/hold', { + scheduleId: inboundSchedule.id, + originStationId: inboundOriginId, + destinationStationId: inboundDestId, + journeyDirection: 'RETURN', + passengers: seatEligibleIndices.map((i, offset) => ({ + passengerId: `temp-${Date.now()}-${offset}`, + seatId: inboundSeatMap[i], + })), + }); + + const currentHold = useBookingStore.getState().seatHold; + setSeatHold({ + holdId: currentHold?.holdId || '', + expiresAt: currentHold?.expiresAt || '', + returnHoldId: inboundHoldData.holdId || inboundHoldData.id, + returnExpiresAt: inboundHoldData.expiresAt, + }); + + const withInbound = updatedPassengers.map((p, i) => { + const inboundSeatId = inboundSeatMap[i]; + const inboundSeatData = inboundSeatId ? allInboundSeats.find((s: any) => s.id === inboundSeatId) : undefined; + return { + ...p, + inboundSeatId, + inboundSeatNumber: inboundSeatData ? buildSeatLabel(inboundSeatData) : '', + inboundCoachNumber: inboundSeatData?._coachLabel || '', + inboundSeatFareMinor: undefined, + inboundBedPosition: inboundSeatData?.bedPosition || undefined, + }; + }); + setPassengers(withInbound); + + // Update the stored tier price with the actual berth fare so the review page + // reflects the correct price when the user picks Upper/Middle/Lower berths + // (which are priced differently within the same coach type). + if (packageId) { + const firstEligibleIdx = seatEligibleIndices[0]; + const outboundSeat = firstEligibleIdx != null + ? allOutboundSeats.find((s: any) => s.id === seatIds[firstEligibleIdx]) + : null; + const berthFare = outboundSeat ? getSeatFare(outboundSeat) : null; + if (berthFare != null) { + setPackageContext( + packageId, + priceTierId ?? '', + berthFare, + packageName ?? undefined, + packageDepartureStationId ?? undefined, + packageDepartureStationName ?? undefined, + ); + } + } + + router.push('/booking/review'); + return; + } catch { + // Auto-assign failed — fall through to manual inbound selection + setAutoAssigningReturn(false); + setCurrentJourneyType("inbound"); + setPassengerSeatMap({}); + setActivePassengerIndex(seatEligibleIndices[0] ?? 0); + setSelectedCoach(null); + return; + } + } } catch (error: any) { setModalState({ isOpen: true, @@ -851,6 +1076,7 @@ export default function SeatsPage() { type: "error", onConfirm: undefined, showCancel: false, + confirmText: "OK", }); return; } @@ -895,6 +1121,7 @@ export default function SeatsPage() { type: "error", onConfirm: undefined, showCancel: false, + confirmText: "OK", }); return; } @@ -918,6 +1145,7 @@ export default function SeatsPage() { type: "warning", onConfirm: undefined, showCancel: false, + confirmText: "OK", }); return; } @@ -1400,7 +1628,9 @@ export default function SeatsPage() { const isCurrentType = !!coachTypeId && (coach.coachTypeId === coachTypeId || + coach.coachId === coachTypeId || coach.type === (currentSchedule as any)?.selectedCoachTypeCode || + coach.coachTypeName === (currentSchedule as any)?.selectedCoachTypeName || coach.type === (currentSchedule as any)?.selectedCoachTypeName); return (
@@ -1580,14 +1810,16 @@ export default function SeatsPage() {

- {isRoundTrip - ? currentJourneyType === "outbound" - ? packageName ? `Select Outbound Seats for ${packageName}` : "Select Outbound Seats" - : packageName ? `Select Return Seats for ${packageName}` : "Select Return Seats" - : "Select Seats"} + {isPackageBooking + ? `Select Seats for ${packageName}` + : isRoundTrip + ? currentJourneyType === "outbound" + ? "Select Outbound Seats" + : "Select Return Seats" + : "Select Seats"}

{!allSeatsAssigned && (

diff --git a/apps/edr-passenger-web/portal/src/app/go/page.tsx b/apps/edr-passenger-web/portal/src/app/go/page.tsx index 255b3980a..d9f6f583f 100644 --- a/apps/edr-passenger-web/portal/src/app/go/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/go/page.tsx @@ -2,9 +2,25 @@ import { Suspense, useEffect, useMemo } from "react"; import { useSearchParams } from "next/navigation"; -import { Loader2, ShieldAlert, ExternalLink } from "lucide-react"; - +/** + * /go — payment redirect bounce page for D-Money web checkout. + * + * The redirect is done CLIENT-SIDE on purpose: the navigation must originate + * from the loaded https://edrpassenger.triaplc.com/go document so the browser + * sends `Referer: https://edrpassenger.triaplc.com` to D-Money. D-Money only + * whitelists that origin, so a server-side 307 (whose referrer on the redirect + * hop is browser-dependent and can be stripped) must NOT be used here. + * + * It fires immediately (no delay) and paints a bare white full-screen cover + * above the sticky header (z-[60]) — no portal chrome, no text on the happy + * path. A short message shows only when the link is missing/untrusted. + * + * `?url=` MUST be percent-encoded by the caller (Uri.encodeComponent() in the + * Flutter app / encodeURIComponent() on web); otherwise the query parser + * truncates the D-Money URL at its first `&` and merch_code/sign are lost. + * See scripts/test-go-redirect.mjs. + */ const ALLOWED_HOSTS = ( process.env.NEXT_PUBLIC_DMONEY_ALLOWED_HOSTS ?? "d-money.dj" @@ -29,8 +45,6 @@ function isTrustedDMoneyUrl(raw: string | null): raw is string { ); } -const REDIRECT_DELAY_MS = 1000; - function RedirectView() { const searchParams = useSearchParams(); const raw = searchParams.get("url"); @@ -38,63 +52,27 @@ function RedirectView() { useEffect(() => { if (!target) return; - const timer = setTimeout(() => { - - window.location.replace(target); - }, REDIRECT_DELAY_MS); - return () => clearTimeout(timer); + // Navigate from this document so the D-Money request carries + // Referer: https://edrpassenger.triaplc.com (the origin D-Money whitelists). + window.location.replace(target); }, [target]); - if (!target) { - return ( -

-
- -
-

- Can't continue -

-

- This link is missing a valid D-Money checkout address or points to an - untrusted destination. Please start the payment again from the app. -

-
- ); - } - return ( -
-
- -
-

- Redirecting to D-Money -

-

- Taking you to the secure D-Money checkout to complete your payment… -

- - - Continue to D-Money - - +
+ {!target && ( +

+ This payment link is invalid or has expired. Please start the payment + again from the app. +

+ )}
); } export default function GoPage() { return ( -
- - } - > - - -
+ }> + + ); } diff --git a/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx b/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx index 3e4dc04df..c7ef3fe52 100644 --- a/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx @@ -365,6 +365,7 @@ const PKG_CHILDREN_PER_ADULT = 5; function PassengerCountModal({ tier, + minPriceMinor, onClose, onConfirm, loading, @@ -373,6 +374,7 @@ function PassengerCountModal({ stations, }: { tier: PriceTier; + minPriceMinor: number; onClose: () => void; onConfirm: (adultCount: number, childCount: number, departureStationId: string, departureStationName: string) => void; loading: boolean; @@ -389,7 +391,7 @@ function PassengerCountModal({ const freeChildren = Math.min(childCount, adultCount); const paidChildren = Math.max(0, childCount - adultCount); // Only paid children need seats; free children share with an adult - const totalMinor = (adultCount * tier.priceMinor + paidChildren * tier.priceMinor) * priceMultiplier; + const totalMinor = (adultCount * minPriceMinor + paidChildren * minPriceMinor) * priceMultiplier; return ( <> @@ -406,7 +408,7 @@ function PassengerCountModal({

Coach type

{tier.seatClass?.coachType?.type ? formatCoachTypeLabel(tier.seatClass.coachType.type) : tier.label.trim()}

-

{remaining} seats remaining · prices from {formatPrice(tier.priceMinor * priceMultiplier, tier.currency)} per adult · 1st child per adult free (no seat)

+

{remaining} seats remaining · from {formatPrice(minPriceMinor * priceMultiplier, tier.currency)} per adult · 1st child per adult free (no seat)

@@ -442,7 +444,7 @@ function PassengerCountModal({
)}
- Total{priceMultiplier === 2 ? ' (round-trip)' : ''} + Total{priceMultiplier === 2 ? ' (round-trip) from:' : ''} {formatPrice(totalMinor, tier.currency)}
@@ -557,6 +559,15 @@ export default function PackageDetailPage() { selectedSeatClassName: ctx.seatClassName ?? "", seatClassName: ctx.seatClassName ?? "", selectedCoachTypeId: ctx.coachTypeId ?? "", + selectedCoachTypeCode: ctx.coachTypeCode ?? "", + selectedCoachTypeName: ctx.coachTypeName ?? "", + coachTypes: Array.isArray(ctx.coachTypes) ? ctx.coachTypes : groups.map((g) => ({ + coachId: g.coachTypeId, + coachTypeId: g.coachTypeId, + coachTypeName: g.coachTypeName, + coachTypeCode: g.coachTypeCode, + classes: [{ name: g.coachTypeName, baseFareMinor: g.minPrice }], + })), }); const outboundSched = toSchedule(ctx.outboundSchedule); @@ -648,6 +659,7 @@ export default function PackageDetailPage() { {passengerModalOpen && representativeTier && ( { setPassengerModalOpen(false); setBookingContextError(null); }} onConfirm={handleBookNow} loading={bookingContextLoading}