mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 03:10:54 +00:00
Merge branch 'alpha' of https://github.com/Tria-plc/edr-platform into alpha
This commit is contained in:
@@ -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<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.warehouse_fee_rules ADD COLUMN IF NOT EXISTS vehicle_type varchar(20)`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE freight.warehouse_fee_rules DROP COLUMN IF EXISTS vehicle_type`);
|
||||
}
|
||||
}
|
||||
@@ -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.',
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<FeePreview> {
|
||||
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(
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
</Text>
|
||||
)}
|
||||
{preview.tiers && preview.tiers.length > 0 ? (
|
||||
{preview.groups && preview.groups.length > 1 ? (
|
||||
<Table withTableBorder withColumnBorders verticalSpacing="xs" fz="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Truck type</Table.Th>
|
||||
<Table.Th>Trucks</Table.Th>
|
||||
<Table.Th>Days</Table.Th>
|
||||
<Table.Th ta="right">Rate / truck / day</Table.Th>
|
||||
<Table.Th ta="right">Amount</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{preview.groups.map((g, i) => (
|
||||
<Table.Tr key={i}>
|
||||
<Table.Td>
|
||||
{g.vehicleType ?? 'Unknown'}
|
||||
{!g.ruleId && (
|
||||
<Text span size="xs" c="red">
|
||||
{' '}· no rule
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>{g.truckCount}</Table.Td>
|
||||
<Table.Td>{g.chargeableDays}</Table.Td>
|
||||
<Table.Td ta="right">{money(g.ratePerDay, preview.currency)}</Table.Td>
|
||||
<Table.Td ta="right">{money(g.amount, preview.currency)}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
) : preview.tiers && preview.tiers.length > 0 ? (
|
||||
<Table withTableBorder withColumnBorders verticalSpacing="xs" fz="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
|
||||
@@ -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<string, string> = {
|
||||
@@ -29,224 +33,275 @@ const STAGE_COLOR: Record<string, string> = {
|
||||
|
||||
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<string, BookingGroup>();
|
||||
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<string | null>(null);
|
||||
const [tab, setTab] = useState('received');
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
|
||||
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<Set<string>>(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 (
|
||||
<Group justify="center" py="lg">
|
||||
<Loader />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
if (trains.length === 0) {
|
||||
return (
|
||||
<Alert color="gray" variant="light">
|
||||
No allocated EXPORT trains awaiting loading. Trains appear here after train and wagon allocation.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Table.ScrollContainer minWidth={720}>
|
||||
<Table verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th w={40} />
|
||||
<Table.Th>Train</Table.Th>
|
||||
<Table.Th>Route</Table.Th>
|
||||
<Table.Th ta="center">Ready</Table.Th>
|
||||
<Table.Th ta="center">Loaded</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{trains.map((t) => (
|
||||
<TrainRow
|
||||
key={t.scheduleId}
|
||||
train={t}
|
||||
expanded={expanded.has(t.scheduleId)}
|
||||
onToggle={() => toggle(t.scheduleId)}
|
||||
/>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<Table.Tr style={{ cursor: 'pointer' }} onClick={onToggle}>
|
||||
<Table.Td>{expanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<TrainFront size={16} />
|
||||
<Text fw={600}>{train.trainNumber ?? train.scheduleId.slice(0, 8)}</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>{route}</Table.Td>
|
||||
<Table.Td ta="center">
|
||||
<Badge color="blue" variant="light">
|
||||
{train.readyCount}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td ta="center">
|
||||
<Badge color="green" variant="light">
|
||||
{train.loadedCount}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
{expanded && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={5} p={0}>
|
||||
<Box p="sm" bg="var(--mantine-color-gray-0)">
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="md">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
) : bookings.length === 0 ? (
|
||||
<Alert color="gray" variant="light">
|
||||
No arrived containers/cargoes allocated to this train yet.
|
||||
</Alert>
|
||||
) : (
|
||||
<Stack gap="xs">
|
||||
{bookings.map((b) => (
|
||||
<BookingBlock key={b.bookingId ?? b.bookingReference} scheduleId={train.scheduleId} booking={b} />
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function BookingBlock({ scheduleId, booking }: { scheduleId: string; booking: BookingGroup }) {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
|
||||
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) => (
|
||||
<Table.Tr key={i.id}>
|
||||
<Table.Td>
|
||||
<Checkbox
|
||||
checked={selected.includes(i.id)}
|
||||
onChange={() => toggle(i.id)}
|
||||
disabled={!i.loadable}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fw={600}>{i.containerNumber ?? i.cargoType ?? '—'}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{i.cargoType ?? '—'}</Table.Td>
|
||||
<Table.Td>{weight(i.weight)}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={STAGE_COLOR[i.status] ?? 'gray'} variant="light">
|
||||
{i.status.replace(/_/g, ' ')}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{i.wagonNumber ? (
|
||||
<Badge variant="outline" color="indigo">
|
||||
{i.wagonNumber}
|
||||
</Badge>
|
||||
) : (
|
||||
<Text size="xs" c="red">
|
||||
Not allocated
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>{i.bookingReference ?? '—'}</Table.Td>
|
||||
<Table.Td>{i.customerName ?? '—'}</Table.Td>
|
||||
<Table.Td>
|
||||
{i.inspectionStatus ? (
|
||||
<Badge size="xs" variant="light" color={i.inspectionStatus === 'PASSED' ? 'green' : 'orange'}>
|
||||
{i.inspectionStatus}
|
||||
</Badge>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group align="flex-end" justify="space-between">
|
||||
<Select
|
||||
label="Train"
|
||||
description="Allocated EXPORT trains awaiting loading"
|
||||
placeholder={trainsLoading ? 'Loading trains…' : trainOptions.length ? 'Select a train' : 'No trains to load'}
|
||||
data={trainOptions}
|
||||
value={scheduleId}
|
||||
onChange={(v) => {
|
||||
setScheduleId(v);
|
||||
setSelected([]);
|
||||
setTab('received');
|
||||
}}
|
||||
disabled={trainOptions.length === 0}
|
||||
leftSection={<TrainFront size={16} />}
|
||||
w={460}
|
||||
searchable
|
||||
/>
|
||||
<Paper withBorder radius="sm" p="xs">
|
||||
<Group justify="space-between" style={{ cursor: 'pointer' }} onClick={() => setOpen((o) => !o)} wrap="nowrap">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{open ? <ChevronDown size={15} /> : <ChevronRight size={15} />}
|
||||
<Text fw={600}>{booking.bookingReference ?? booking.bookingId?.slice(0, 8) ?? '—'}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{booking.customerName ?? '—'}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Badge variant="light" color="blue">
|
||||
{booking.items.length} item(s)
|
||||
</Badge>
|
||||
{loadedCount > 0 && (
|
||||
<Badge variant="light" color="green">
|
||||
{loadedCount} loaded
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{!scheduleId ? (
|
||||
<Alert color="gray" variant="light">
|
||||
Pick a train to see the arrived containers/cargoes allocated to it. Items appear here only
|
||||
after train and wagon allocation.
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<Tabs value={tab} onChange={(v) => setTab(v ?? 'received')}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab
|
||||
value="received"
|
||||
rightSection={
|
||||
<Badge size="xs" variant="light" color="blue">
|
||||
{received.length}
|
||||
{open && (
|
||||
<>
|
||||
<Table striped highlightOnHover verticalSpacing="xs" mt="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th w={36}>
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
indeterminate={!allSelected && selected.length > 0}
|
||||
onChange={toggleAll}
|
||||
disabled={selectable.length === 0}
|
||||
/>
|
||||
</Table.Th>
|
||||
<Table.Th>Container / Cargo</Table.Th>
|
||||
<Table.Th>Goods</Table.Th>
|
||||
<Table.Th>Weight</Table.Th>
|
||||
<Table.Th>Stage</Table.Th>
|
||||
<Table.Th>Wagon</Table.Th>
|
||||
<Table.Th>Inspection</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{booking.items.map((i) => (
|
||||
<Table.Tr key={i.id}>
|
||||
<Table.Td>
|
||||
<Checkbox checked={selected.includes(i.id)} onChange={() => toggleItem(i.id)} disabled={!i.loadable} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fw={600}>{i.containerNumber ?? i.cargoType ?? '—'}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{i.cargoType ?? '—'}</Table.Td>
|
||||
<Table.Td>{weight(i.weight)}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={STAGE_COLOR[i.status] ?? 'gray'} variant="light">
|
||||
{i.status.replace(/_/g, ' ')}
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
Received
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab
|
||||
value="loaded"
|
||||
rightSection={
|
||||
<Badge size="xs" variant="light" color="green">
|
||||
{loaded.length}
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
Loaded
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="lg">
|
||||
<Loader />
|
||||
</Group>
|
||||
) : visible.length === 0 ? (
|
||||
<Alert color="gray" variant="light">
|
||||
{tab === 'loaded' ? 'Nothing loaded onto this train yet.' : 'No arrived items ready for this train.'}
|
||||
</Alert>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={900}>
|
||||
<Table striped highlightOnHover verticalSpacing="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>
|
||||
{tab === 'received' && (
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
indeterminate={!allSelected && selected.length > 0}
|
||||
onChange={toggleAll}
|
||||
disabled={selectableVisible.length === 0}
|
||||
/>
|
||||
)}
|
||||
</Table.Th>
|
||||
<Table.Th>Container / Cargo</Table.Th>
|
||||
<Table.Th>Goods</Table.Th>
|
||||
<Table.Th>Weight</Table.Th>
|
||||
<Table.Th>Stage</Table.Th>
|
||||
<Table.Th>Wagon</Table.Th>
|
||||
<Table.Th>Booking</Table.Th>
|
||||
<Table.Th>Customer</Table.Th>
|
||||
<Table.Th>Inspection</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>{visible.map(renderRow)}</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
|
||||
{tab === 'received' && (
|
||||
<Group justify="space-between" align="center">
|
||||
<Text size="sm" c="dimmed">
|
||||
{selected.length} selected · only READY_FOR_LOADING items with an allocated wagon can be loaded
|
||||
</Text>
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<TrainFront size={16} />}
|
||||
disabled={selected.length === 0}
|
||||
loading={loadMutation.isPending}
|
||||
onClick={() => loadMutation.mutate()}
|
||||
>
|
||||
Load {selected.length || ''} onto train
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{i.wagonNumber ? (
|
||||
<Badge variant="outline" color="indigo">
|
||||
{i.wagonNumber}
|
||||
</Badge>
|
||||
) : (
|
||||
<Text size="xs" c="red">
|
||||
Not allocated
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{i.inspectionStatus ? (
|
||||
<Badge size="xs" variant="light" color={i.inspectionStatus === 'PASSED' ? 'green' : 'orange'}>
|
||||
{i.inspectionStatus}
|
||||
</Badge>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
<Group justify="space-between" align="center" mt="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
{selected.length} selected · only READY_FOR_LOADING items with a wagon can be loaded
|
||||
</Text>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
leftSection={<TrainFront size={14} />}
|
||||
disabled={selected.length === 0}
|
||||
loading={loadMutation.isPending}
|
||||
onClick={() => loadMutation.mutate()}
|
||||
>
|
||||
Load {selected.length || ''} onto train
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1282,6 +1282,17 @@ const LastMilePage = () => {
|
||||
Boolean(releaseRow?.releaseOrderReference) && !releaseRow?.releaseDate && !pastTransit;
|
||||
return (
|
||||
<Group gap={4} justify="flex-end" wrap="nowrap">
|
||||
{pastTransit && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<Receipt size={13} />}
|
||||
onClick={() => setDetentionRecord(row.original)}
|
||||
>
|
||||
Detention
|
||||
</Button>
|
||||
)}
|
||||
<Menu
|
||||
position="bottom-end"
|
||||
width={200}
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
FEE_RULE_BASIS_LABELS,
|
||||
FEE_RULE_TYPES,
|
||||
FEE_RULE_TYPE_LABELS,
|
||||
VEHICLE_TYPES,
|
||||
type FeeRuleBasis,
|
||||
type FeeRuleType,
|
||||
} from '@/types/warehouse';
|
||||
@@ -372,6 +373,7 @@ function FeeRules() {
|
||||
tradeDirection: '',
|
||||
cargoTypeCode: '',
|
||||
containerType: '',
|
||||
vehicleType: '',
|
||||
freeDays: 3,
|
||||
freeHours: 3,
|
||||
ratePerDay: 0,
|
||||
@@ -389,6 +391,8 @@ function FeeRules() {
|
||||
// Truck detention: per truck per day after an HOURS-based grace (default 3h),
|
||||
// with day tiers. Uses "free hours" instead of "free days".
|
||||
const isTruckDetention = form.ruleType === 'TRUCK_DETENTION_FEE';
|
||||
// Double handling + truck detention apply to IMPORT only — trade direction is locked.
|
||||
const isImportOnly = isDoubleHandling || isTruckDetention;
|
||||
|
||||
const resetForm = () =>
|
||||
setForm({
|
||||
@@ -399,6 +403,7 @@ function FeeRules() {
|
||||
tradeDirection: '',
|
||||
cargoTypeCode: '',
|
||||
containerType: '',
|
||||
vehicleType: '',
|
||||
freeDays: 3,
|
||||
freeHours: 3,
|
||||
ratePerDay: 0,
|
||||
@@ -461,7 +466,7 @@ function FeeRules() {
|
||||
name: form.name.trim(),
|
||||
ruleType: form.ruleType,
|
||||
freightType: clean(form.freightType) ?? null,
|
||||
tradeDirection: clean(form.tradeDirection) ?? null,
|
||||
tradeDirection: isImportOnly ? 'IMPORT' : clean(form.tradeDirection) ?? null,
|
||||
cargoTypeCode: isBulkRule ? (clean(form.cargoTypeCode) ?? null) : null,
|
||||
containerType: isContainerRule ? (clean(form.containerType) ?? null) : null,
|
||||
// Double handling: flat basis × rate. Truck detention: HOURS-based grace.
|
||||
@@ -469,7 +474,7 @@ function FeeRules() {
|
||||
ratePerDay: form.ratePerDay,
|
||||
currency: form.currency || 'USD',
|
||||
...(isDoubleHandling ? { basis: form.basis } : {}),
|
||||
...(isTruckDetention ? { freeHours: form.freeHours } : {}),
|
||||
...(isTruckDetention ? { freeHours: form.freeHours, vehicleType: clean(form.vehicleType) ?? null } : {}),
|
||||
...(!isDoubleHandling && tiers.length ? { tiers } : {}),
|
||||
};
|
||||
|
||||
@@ -633,11 +638,23 @@ function FeeRules() {
|
||||
/>
|
||||
<Select
|
||||
label="Trade direction"
|
||||
description={isImportOnly ? 'Import only for this fee type' : undefined}
|
||||
data={TRADE}
|
||||
value={form.tradeDirection || null}
|
||||
value={isImportOnly ? 'IMPORT' : form.tradeDirection || null}
|
||||
onChange={(value) => setForm((f) => ({ ...f, tradeDirection: selectValue(value) }))}
|
||||
clearable
|
||||
disabled={isImportOnly}
|
||||
clearable={!isImportOnly}
|
||||
/>
|
||||
{isTruckDetention && (
|
||||
<Select
|
||||
label="Truck type"
|
||||
placeholder="Any truck type"
|
||||
data={VEHICLE_TYPES.map((v) => ({ value: v, label: v.charAt(0) + v.slice(1).toLowerCase() }))}
|
||||
value={form.vehicleType || null}
|
||||
onChange={(value) => setForm((f) => ({ ...f, vehicleType: selectValue(value) }))}
|
||||
clearable
|
||||
/>
|
||||
)}
|
||||
{isBulkRule && (
|
||||
<Select
|
||||
label="Cargo type"
|
||||
|
||||
@@ -763,6 +763,10 @@ export const FEE_RULE_BASIS_LABELS: Record<FeeRuleBasis, string> = {
|
||||
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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 ?? '',
|
||||
|
||||
@@ -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 (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="card text-center">
|
||||
<div className="w-20 h-20 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<Calendar className="w-10 h-10 text-gray-400 dark:text-gray-500" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold mb-3 text-gray-900 dark:text-gray-100">No trains found</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-8">
|
||||
We couldn't find any trains for your round trip. Try adjusting your dates or route.
|
||||
</p>
|
||||
<button onClick={() => router.push(buildSearchUrl())} className="btn-primary">Modify search</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isOneWayNoOutbound) {
|
||||
const requestedDateLabel = requestedDate
|
||||
? format(new Date(`${requestedDate}T00:00:00`), 'EEEE, MMMM d, yyyy')
|
||||
@@ -843,6 +864,26 @@ export default function ResultsPage() {
|
||||
<div className="space-y-4">
|
||||
{outboundSchedules.map((schedule: Schedule) => renderScheduleCard(schedule, true))}
|
||||
</div>
|
||||
{outboundSchedules.length === 0 && alternativeOutbound.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<div className="card text-center mb-6 max-w-3xl mx-auto">
|
||||
<div className="w-16 h-16 bg-amber-100 dark:bg-amber-900/30 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Calendar className="w-8 h-8 text-amber-500" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold mb-2 text-gray-900 dark:text-gray-100">No trains available</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
No trains are available on <span className="font-semibold text-gray-900 dark:text-gray-100">{requestedDate ? format(new Date(`${requestedDate}T00:00:00`), 'EEEE, MMMM d, yyyy') : 'your selected date'}</span>. This may be due to no scheduled service or full capacity. Please check the alternative options below or try a different date.
|
||||
</p>
|
||||
<button onClick={() => router.push(buildSearchUrl())} className="btn-primary">Change travel dates</button>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<h3 className="text-base font-bold text-gray-900 dark:text-white">Alternative Outbound Options</h3>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{alternativeOutbound.map((schedule: Schedule) => renderScheduleCard(schedule, true, true))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
@@ -891,6 +932,26 @@ export default function ResultsPage() {
|
||||
<div className="space-y-4" id="inbound-section">
|
||||
{inboundSchedules.map((schedule: Schedule) => renderScheduleCard(schedule, false))}
|
||||
</div>
|
||||
{inboundSchedules.length === 0 && alternativeInbound.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<div className="card text-center mb-6 max-w-3xl mx-auto">
|
||||
<div className="w-16 h-16 bg-amber-100 dark:bg-amber-900/30 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Calendar className="w-8 h-8 text-amber-500" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold mb-2 text-gray-900 dark:text-gray-100">No trains available</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
No trains are available on <span className="font-semibold text-gray-900 dark:text-gray-100">{requestedReturnDate ? format(new Date(`${requestedReturnDate}T00:00:00`), 'EEEE, MMMM d, yyyy') : 'your selected return date'}</span>. This may be due to no scheduled service or full capacity. Please check the alternative options below or try a different date.
|
||||
</p>
|
||||
<button onClick={() => router.push(buildSearchUrl())} className="btn-primary">Change travel dates</button>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<h3 className="text-base font-bold text-gray-900 dark:text-white">Alternative Return Options</h3>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{alternativeInbound.map((schedule: Schedule) => renderScheduleCard(schedule, false, true))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
|
||||
@@ -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<string>();
|
||||
const inboundSeatMap: Record<number, string> = {};
|
||||
|
||||
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 (
|
||||
<div key={coach.id}>
|
||||
@@ -1580,14 +1810,16 @@ export default function SeatsPage() {
|
||||
|
||||
<button
|
||||
onClick={handleContinue}
|
||||
disabled={!allSeatsAssigned || holdMutation.isPending}
|
||||
disabled={!allSeatsAssigned || holdMutation.isPending || autoAssigningReturn}
|
||||
className="w-full py-3 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] disabled:opacity-40 disabled:cursor-not-allowed text-white font-bold text-sm rounded-xl transition-all shadow-lg"
|
||||
>
|
||||
{holdMutation.isPending
|
||||
? "Holding seats..."
|
||||
: isRoundTrip && currentJourneyType === "outbound"
|
||||
? "Continue to Return Seats"
|
||||
: "Continue"}
|
||||
{autoAssigningReturn
|
||||
? "Assigning return seats..."
|
||||
: holdMutation.isPending
|
||||
? "Holding seats..."
|
||||
: isRoundTrip && currentJourneyType === "outbound" && !isPackageBooking
|
||||
? "Continue to Return Seats"
|
||||
: "Continue"}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAutoAssign}
|
||||
@@ -1603,13 +1835,13 @@ export default function SeatsPage() {
|
||||
<>
|
||||
<CustomModal
|
||||
isOpen={modalState.isOpen}
|
||||
onClose={() => setModalState({ ...modalState, isOpen: false })}
|
||||
onClose={() => setModalState((prev) => ({ ...prev, isOpen: false }))}
|
||||
title={modalState.title}
|
||||
message={modalState.message}
|
||||
type={modalState.type}
|
||||
onConfirm={modalState.onConfirm}
|
||||
showCancel={modalState.showCancel}
|
||||
confirmText={modalState.showCancel ? "Switch Coach" : "OK"}
|
||||
confirmText={modalState.confirmText}
|
||||
/>
|
||||
|
||||
{/* Train Coach Preview */}
|
||||
@@ -1638,7 +1870,7 @@ export default function SeatsPage() {
|
||||
|
||||
{/* Desktop: right-side panel, no backdrop — seat selection stays fully usable */}
|
||||
<div
|
||||
className="hidden lg:flex fixed inset-y-0 right-0 z-[120] w-[380px] bg-white dark:bg-gray-900 shadow-2xl border-l border-gray-200 dark:border-gray-700 flex-col"
|
||||
className="hidden lg:flex fixed inset-y-0 right-0 z-[90] w-[380px] bg-white dark:bg-gray-900 shadow-2xl border-l border-gray-200 dark:border-gray-700 flex-col"
|
||||
style={{ animation: "drawer-slide-in 0.25s cubic-bezier(0.32,0.72,0,1)" }}
|
||||
>
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100 dark:border-gray-800 flex-shrink-0">
|
||||
@@ -1689,11 +1921,13 @@ export default function SeatsPage() {
|
||||
</button>
|
||||
<div className="text-center">
|
||||
<h1 className="text-base font-bold text-gray-900 dark:text-white">
|
||||
{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"}
|
||||
</h1>
|
||||
{!allSeatsAssigned && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
|
||||
@@ -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 (
|
||||
<div className="w-full max-w-sm text-center">
|
||||
<div className="mx-auto mb-5 flex h-16 w-16 items-center justify-center rounded-full bg-red-100">
|
||||
<ShieldAlert className="h-8 w-8 text-red-600" />
|
||||
</div>
|
||||
<h1 className="mb-2 text-xl font-bold text-gray-900">
|
||||
Can't continue
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
This link is missing a valid D-Money checkout address or points to an
|
||||
untrusted destination. Please start the payment again from the app.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-sm text-center">
|
||||
<div className="mx-auto mb-5 flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
<h1 className="mb-2 text-xl font-bold text-gray-900">
|
||||
Redirecting to D-Money
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
Taking you to the secure D-Money checkout to complete your payment…
|
||||
</p>
|
||||
|
||||
<a
|
||||
href={target}
|
||||
className="mt-6 inline-flex items-center justify-center gap-2 text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
Continue to D-Money
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-white px-6 text-center">
|
||||
{!target && (
|
||||
<p className="text-sm text-gray-500">
|
||||
This payment link is invalid or has expired. Please start the payment
|
||||
again from the app.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GoPage() {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-white px-4">
|
||||
<Suspense
|
||||
fallback={
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" aria-label="Loading" />
|
||||
}
|
||||
>
|
||||
<RedirectView />
|
||||
</Suspense>
|
||||
</div>
|
||||
<Suspense fallback={<div className="fixed inset-0 z-[100] bg-white" />}>
|
||||
<RedirectView />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
<div className="px-6 py-3 bg-primary/5 border-b border-primary/10">
|
||||
<p className="text-xs text-gray-400 uppercase tracking-wide font-semibold">Coach type</p>
|
||||
<p className="text-sm font-bold text-gray-900 dark:text-white mt-0.5">{tier.seatClass?.coachType?.type ? formatCoachTypeLabel(tier.seatClass.coachType.type) : tier.label.trim()}</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">{remaining} seats remaining · prices from {formatPrice(tier.priceMinor * priceMultiplier, tier.currency)} per adult · 1st child per adult free (no seat)</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">{remaining} seats remaining · from {formatPrice(minPriceMinor * priceMultiplier, tier.currency)} per adult · 1st child per adult free (no seat)</p>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-5 space-y-4">
|
||||
@@ -442,7 +444,7 @@ function PassengerCountModal({
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between pt-2 border-t border-gray-100 dark:border-gray-800">
|
||||
<span className="text-sm text-gray-500">Total{priceMultiplier === 2 ? ' (round-trip)' : ''}</span>
|
||||
<span className="text-sm text-gray-500">Total{priceMultiplier === 2 ? ' (round-trip) from:' : ''}</span>
|
||||
<span className="text-base font-extrabold text-primary">{formatPrice(totalMinor, tier.currency)}</span>
|
||||
</div>
|
||||
|
||||
@@ -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 && (
|
||||
<PassengerCountModal
|
||||
tier={representativeTier}
|
||||
minPriceMinor={selectedGroup?.minPrice ?? representativeTier.priceMinor}
|
||||
onClose={() => { setPassengerModalOpen(false); setBookingContextError(null); }}
|
||||
onConfirm={handleBookNow}
|
||||
loading={bookingContextLoading}
|
||||
|
||||
Reference in New Issue
Block a user