mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 09:35:44 +00:00
Update group booking and package
This commit is contained in:
@@ -23,7 +23,7 @@ import {
|
||||
type SupportedPaymentMethod,
|
||||
} from '@/lib/api/group-booking';
|
||||
import { buildPassengerTemplate } from '@/lib/export/passenger-template';
|
||||
import { countByType, parsePassengerExcel, type ParsedPassengerRow } from '@/lib/import/passenger-excel';
|
||||
import { countByType, parsePassengerExcel, resolveFreeChildIndexes, type ParsedPassengerRow } from '@/lib/import/passenger-excel';
|
||||
import ActionButton from '@/components/ui/ActionButton';
|
||||
import DatePicker from '@/components/ui/DatePicker';
|
||||
import Skeleton from '@/components/ui/Skeleton';
|
||||
@@ -69,6 +69,11 @@ function emptySearchMessage(reason: SearchEmptyReason | undefined): string {
|
||||
return `Every departure from ${o} to ${d} on this date was cancelled.`;
|
||||
case 'PACKAGE_ONLY':
|
||||
return `Departures on this date are reserved for travel packages, not regular ticketing.`;
|
||||
case 'GROUP_BOOKING_ONLY':
|
||||
// isGroupBookingOnly is an exclusive partition — this page only ever sees group-booking
|
||||
// schedules, so an empty result here means a regular (non-group) train runs on this date
|
||||
// but nothing has been set up for group booking specifically.
|
||||
return `A regular train runs from ${o} to ${d} on this date, but no schedule has been set up for group booking yet — ask fleet/schedule management to create one, or try another date.`;
|
||||
case 'CHECKIN_CLOSED':
|
||||
return `Check-in has already closed for every departure on this date.`;
|
||||
case 'FULLY_BOOKED':
|
||||
@@ -246,6 +251,8 @@ function GroupBookingPageContent() {
|
||||
const [originStationId, setOriginStationId] = useState('');
|
||||
const [destinationStationId, setDestinationStationId] = useState('');
|
||||
const [travelDate, setTravelDate] = useState('');
|
||||
const [tripType, setTripType] = useState<'ONE_WAY' | 'ROUND_TRIP'>('ONE_WAY');
|
||||
const [returnDate, setReturnDate] = useState('');
|
||||
const [adultCount, setAdultCount] = useState<number>(1);
|
||||
const [childCount, setChildCount] = useState<number>(0);
|
||||
const [searchTouched, setSearchTouched] = useState(false);
|
||||
@@ -265,7 +272,7 @@ function GroupBookingPageContent() {
|
||||
|
||||
const totalPassengers = (adultCount || 0) + (childCount || 0);
|
||||
const searchValid = !!originStationId && !!destinationStationId && originStationId !== destinationStationId
|
||||
&& !!travelDate && totalPassengers > 0;
|
||||
&& !!travelDate && totalPassengers > 0 && (tripType === 'ONE_WAY' || !!returnDate);
|
||||
|
||||
const searchMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
@@ -275,14 +282,19 @@ function GroupBookingPageContent() {
|
||||
date: travelDate,
|
||||
adultCount: adultCount || 0,
|
||||
childCount: childCount || 0,
|
||||
journeyType: 'ONE_WAY',
|
||||
journeyType: tripType,
|
||||
returnDate: tripType === 'ROUND_TRIP' ? returnDate : undefined,
|
||||
nationality: fareTier === 'LOCAL' ? 'Ethiopian' : 'Other',
|
||||
channel: 'GROUP_BOOKING',
|
||||
}),
|
||||
});
|
||||
|
||||
const runSearch = () => {
|
||||
setSearchTouched(true);
|
||||
if (!searchValid) return;
|
||||
setResultsPhase('outbound');
|
||||
setSelectedReturnSchedule(null);
|
||||
setSelectedReturnClass(null);
|
||||
setStep('results');
|
||||
searchMutation.mutate();
|
||||
};
|
||||
@@ -290,13 +302,23 @@ function GroupBookingPageContent() {
|
||||
// ── Step 2: results / class selection ───────────────────────────────────
|
||||
const [selectedSchedule, setSelectedSchedule] = useState<ScheduleResult | null>(null);
|
||||
const [selectedClass, setSelectedClass] = useState<SelectedClass | null>(null);
|
||||
// Round trip only — the outbound/return picks happen as two phases of this same step,
|
||||
// mirroring the passenger portal's results page (search once, pick outbound, then return).
|
||||
const [resultsPhase, setResultsPhase] = useState<'outbound' | 'return'>('outbound');
|
||||
const [selectedReturnSchedule, setSelectedReturnSchedule] = useState<ScheduleResult | null>(null);
|
||||
const [selectedReturnClass, setSelectedReturnClass] = useState<SelectedClass | null>(null);
|
||||
|
||||
const seatClassId = useMemo(() => {
|
||||
if (!selectedClass) return null;
|
||||
return seatClassOptions.find((sc) => sc.name === selectedClass.className)?.id ?? null;
|
||||
}, [selectedClass, seatClassOptions]);
|
||||
|
||||
const chooseClass = (schedule: ScheduleResult, cls: ScheduleClassOption, category: string, totalAvailable: number) => {
|
||||
const returnSeatClassId = useMemo(() => {
|
||||
if (!selectedReturnClass) return null;
|
||||
return seatClassOptions.find((sc) => sc.name === selectedReturnClass.className)?.id ?? null;
|
||||
}, [selectedReturnClass, seatClassOptions]);
|
||||
|
||||
const chooseOutboundClass = (schedule: ScheduleResult, cls: ScheduleClassOption, category: string, totalAvailable: number) => {
|
||||
setSelectedSchedule(schedule);
|
||||
setSelectedClass({
|
||||
scheduleId: schedule.scheduleId,
|
||||
@@ -306,6 +328,26 @@ function GroupBookingPageContent() {
|
||||
displayCurrency: cls.displayCurrency,
|
||||
available: totalAvailable,
|
||||
});
|
||||
if (tripType === 'ROUND_TRIP') {
|
||||
// Force re-confirming the return leg if staff changes their mind on outbound later.
|
||||
setSelectedReturnSchedule(null);
|
||||
setSelectedReturnClass(null);
|
||||
setResultsPhase('return');
|
||||
} else {
|
||||
setStep('passengers');
|
||||
}
|
||||
};
|
||||
|
||||
const chooseReturnClass = (schedule: ScheduleResult, cls: ScheduleClassOption, category: string, totalAvailable: number) => {
|
||||
setSelectedReturnSchedule(schedule);
|
||||
setSelectedReturnClass({
|
||||
scheduleId: schedule.scheduleId,
|
||||
className: cls.name,
|
||||
category,
|
||||
fareMinor: cls.baseFareMinor,
|
||||
displayCurrency: cls.displayCurrency,
|
||||
available: totalAvailable,
|
||||
});
|
||||
setStep('passengers');
|
||||
};
|
||||
|
||||
@@ -322,6 +364,16 @@ function GroupBookingPageContent() {
|
||||
const countMismatch = passengerRows.length > 0 && (uploadedAdults !== adultCount || uploadedChildren !== childCount);
|
||||
const passengersValid = passengerRows.length > 0 && fileErrors.length === 0 && !rowsHaveErrors && !countMismatch;
|
||||
|
||||
// One-way only — the portal's own "1 free child per adult, no seat" rule (fare-utils.ts's
|
||||
// isFirstChild). Round trip can't offer this: createGuestRoundTripBooking hard-requires a
|
||||
// returnSeatId on every passenger, so every child there still needs a real seat both ways.
|
||||
const freeChildFlags = useMemo(
|
||||
() => (tripType === 'ONE_WAY' ? resolveFreeChildIndexes(passengerRows, adultCount) : passengerRows.map(() => false)),
|
||||
[passengerRows, adultCount, tripType],
|
||||
);
|
||||
const freeChildrenCount = freeChildFlags.filter(Boolean).length;
|
||||
const paidChildrenCount = uploadedChildren - freeChildrenCount;
|
||||
|
||||
const downloadTemplate = async () => {
|
||||
if (!selectedSchedule || !selectedClass) return;
|
||||
const blob = await buildPassengerTemplate({
|
||||
@@ -370,25 +422,52 @@ function GroupBookingPageContent() {
|
||||
// ── Step 4: auto-assign + hold ───────────────────────────────────────────
|
||||
const [assignError, setAssignError] = useState<string | null>(null);
|
||||
const [hold, setHold] = useState<AutoAssignHoldResponse | null>(null);
|
||||
const [returnHold, setReturnHold] = useState<AutoAssignHoldResponse | null>(null);
|
||||
|
||||
const autoAssignMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
mutationFn: async () => {
|
||||
if (!selectedSchedule || !selectedClass) throw new Error('No schedule/class selected');
|
||||
return groupBookingApi.autoAssignHold({
|
||||
// One-way's free children (see freeChildFlags above) need no seat at all — only ask for
|
||||
// seats covering adults + paid children. Round trip can't offer that (every passenger
|
||||
// needs both a seatId and a returnSeatId), so it still requests one seat per child.
|
||||
const outboundHold = await groupBookingApi.autoAssignHold({
|
||||
scheduleId: selectedSchedule.scheduleId,
|
||||
originStationId: selectedSchedule.origin.id,
|
||||
destinationStationId: selectedSchedule.destination.id,
|
||||
seatClassName: selectedClass.className,
|
||||
adultCount,
|
||||
childCount,
|
||||
childCount: tripType === 'ONE_WAY' ? paidChildrenCount : childCount,
|
||||
journeyDirection: tripType === 'ROUND_TRIP' ? 'OUTBOUND' : undefined,
|
||||
});
|
||||
if (tripType !== 'ROUND_TRIP') return { outboundHold, returnHold: null };
|
||||
if (!selectedReturnSchedule || !selectedReturnClass) throw new Error('No return schedule/class selected');
|
||||
try {
|
||||
const returnHoldResp = await groupBookingApi.autoAssignHold({
|
||||
scheduleId: selectedReturnSchedule.scheduleId,
|
||||
originStationId: selectedReturnSchedule.origin.id,
|
||||
destinationStationId: selectedReturnSchedule.destination.id,
|
||||
seatClassName: selectedReturnClass.className,
|
||||
adultCount,
|
||||
childCount,
|
||||
journeyDirection: 'RETURN',
|
||||
});
|
||||
return { outboundHold, returnHold: returnHoldResp };
|
||||
} catch (err) {
|
||||
// Return leg failed after outbound already succeeded — release the outbound hold
|
||||
// immediately instead of leaving it locked for the rest of the hold TTL.
|
||||
await groupBookingApi.releaseHold(outboundHold.holdId).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setHold(data);
|
||||
onSuccess: ({ outboundHold, returnHold: returnHoldResp }) => {
|
||||
setHold(outboundHold);
|
||||
setReturnHold(returnHoldResp);
|
||||
setAssignError(null);
|
||||
setStep('confirm');
|
||||
},
|
||||
onError: (err: any) => {
|
||||
setHold(null);
|
||||
setReturnHold(null);
|
||||
setAssignError(err?.response?.data?.message ?? err?.message ?? 'Not enough seats are available for this class.');
|
||||
},
|
||||
});
|
||||
@@ -399,14 +478,23 @@ function GroupBookingPageContent() {
|
||||
autoAssignMutation.mutate();
|
||||
};
|
||||
|
||||
// Pairs each validated passenger row (upload order) with its auto-assigned seat (same order).
|
||||
// Pairs each validated passenger row (upload order) with its auto-assigned seat(s). A free
|
||||
// child (freeChildFlags[i]) consumes no seat at all — it's skipped when walking the hold's
|
||||
// seat list, so seat N goes to the Nth non-free passenger, not the Nth row.
|
||||
const seatAssignments = useMemo(() => {
|
||||
if (!hold) return [];
|
||||
return passengerRows.map((row, i) => ({
|
||||
row,
|
||||
seat: hold.passengers[i]?.seat ?? null,
|
||||
}));
|
||||
}, [hold, passengerRows]);
|
||||
let seatIdx = 0;
|
||||
return passengerRows.map((row, i) => {
|
||||
const isFree = freeChildFlags[i];
|
||||
const seat = isFree ? null : (hold.passengers[seatIdx++]?.seat ?? null);
|
||||
return {
|
||||
row,
|
||||
seat,
|
||||
returnSeat: returnHold?.passengers[i]?.seat ?? null,
|
||||
isFree,
|
||||
};
|
||||
});
|
||||
}, [hold, returnHold, passengerRows, freeChildFlags]);
|
||||
|
||||
// ── Step 5: create booking ───────────────────────────────────────────────
|
||||
const [bookingError, setBookingError] = useState<string | null>(null);
|
||||
@@ -417,15 +505,29 @@ function GroupBookingPageContent() {
|
||||
if (!selectedSchedule || !selectedClass || !hold || !seatClassId) {
|
||||
throw new Error('Missing schedule, class, or hold — go back and try again.');
|
||||
}
|
||||
if (tripType === 'ROUND_TRIP' && (!selectedReturnSchedule || !selectedReturnClass || !returnHold || !returnSeatClassId)) {
|
||||
throw new Error('Missing return schedule, class, or hold — go back and try again.');
|
||||
}
|
||||
return groupBookingApi.createGroupBooking({
|
||||
scheduleId: selectedSchedule.scheduleId,
|
||||
holdId: hold.holdId,
|
||||
originStationId: selectedSchedule.origin.id,
|
||||
destinationStationId: selectedSchedule.destination.id,
|
||||
seatClassId,
|
||||
bookingType: 'ONE_WAY',
|
||||
passengers: seatAssignments.map(({ row, seat }) => ({
|
||||
seatId: seat!.id,
|
||||
bookingType: tripType,
|
||||
...(tripType === 'ROUND_TRIP' ? {
|
||||
returnScheduleId: selectedReturnSchedule!.scheduleId,
|
||||
returnHoldId: returnHold!.holdId,
|
||||
returnOriginStationId: selectedReturnSchedule!.origin.id,
|
||||
returnDestinationStationId: selectedReturnSchedule!.destination.id,
|
||||
returnSeatClassId: returnSeatClassId!,
|
||||
} : {}),
|
||||
passengers: seatAssignments.map(({ row, seat, returnSeat, isFree }) => ({
|
||||
// Free children (ONE_WAY only) have no seat at all — omit seatId entirely,
|
||||
// matching the portal's own convention (guest-booking.service.ts treats a missing
|
||||
// seatId as "unseated = free (0)").
|
||||
...(isFree ? {} : { seatId: seat!.id }),
|
||||
...(tripType === 'ROUND_TRIP' ? { returnSeatId: returnSeat!.id } : {}),
|
||||
passengerName: row.fullName,
|
||||
dateOfBirth: row.dateOfBirth,
|
||||
idDocumentType: row.idDocumentType as any,
|
||||
@@ -444,8 +546,9 @@ function GroupBookingPageContent() {
|
||||
setStep('success');
|
||||
},
|
||||
onError: (err: any) => {
|
||||
// The hold was released server-side on failure — a retry needs a fresh one.
|
||||
// Both holds were released server-side on failure — a retry needs fresh ones.
|
||||
setHold(null);
|
||||
setReturnHold(null);
|
||||
setBookingError(err?.response?.data?.message ?? err?.message ?? 'Could not create the booking.');
|
||||
},
|
||||
});
|
||||
@@ -456,12 +559,16 @@ function GroupBookingPageContent() {
|
||||
};
|
||||
|
||||
// ── Step 6: pay ───────────────────────────────────────────────────────────
|
||||
const { data: paymentMethods = [] } = useQuery<SupportedPaymentMethod[]>({
|
||||
const { data: paymentMethodsData } = useQuery<SupportedPaymentMethod[]>({
|
||||
queryKey: ['payment-methods'],
|
||||
queryFn: () => groupBookingApi.getPaymentMethods(),
|
||||
enabled: step === 'success',
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
// Defensive: never let a malformed/unexpected response shape (e.g. an unwrap mismatch, or a
|
||||
// proxy/error page returned in place of JSON) crash the page with a raw TypeError — an empty
|
||||
// list here just shows "Loading payment options…" a beat longer instead.
|
||||
const paymentMethods = Array.isArray(paymentMethodsData) ? paymentMethodsData : [];
|
||||
const enabledPaymentMethods = paymentMethods.filter((m) => m.enabled);
|
||||
|
||||
const [selectedPaymentType, setSelectedPaymentType] = useState<string | null>(null);
|
||||
@@ -506,8 +613,12 @@ function GroupBookingPageContent() {
|
||||
setStep('search');
|
||||
setSelectedSchedule(null);
|
||||
setSelectedClass(null);
|
||||
setResultsPhase('outbound');
|
||||
setSelectedReturnSchedule(null);
|
||||
setSelectedReturnClass(null);
|
||||
clearUpload();
|
||||
setHold(null);
|
||||
setReturnHold(null);
|
||||
setAssignError(null);
|
||||
setBooking(null);
|
||||
setBookingError(null);
|
||||
@@ -532,16 +643,31 @@ function GroupBookingPageContent() {
|
||||
{/* Selection summary bar — visible from Step 2 onward */}
|
||||
{selectedSchedule && selectedClass && step !== 'search' && step !== 'results' && (
|
||||
<div className="card flex items-center justify-between flex-wrap gap-3 animate-fade-up">
|
||||
<div className="flex items-center gap-3 text-sm flex-wrap">
|
||||
<div className="rounded-lg bg-primary/10 p-1.5 shrink-0">
|
||||
<Train className="h-4 w-4 text-primary" />
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-3 text-sm flex-wrap">
|
||||
<div className="rounded-lg bg-primary/10 p-1.5 shrink-0">
|
||||
<Train className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{tripType === 'ROUND_TRIP' ? 'Outbound' : 'Trip'}</span>
|
||||
<span className="font-semibold">{selectedSchedule.trainNumber}</span>
|
||||
<span className="text-muted-foreground">{selectedSchedule.origin.name} → {selectedSchedule.destination.name}</span>
|
||||
<span className="text-muted-foreground">· {formatDateTime(selectedSchedule.departureAt)}</span>
|
||||
<span className="text-muted-foreground">· {selectedClass.category}</span>
|
||||
<span className="text-muted-foreground">· {fareTier === 'LOCAL' ? 'Local' : 'International'} rates</span>
|
||||
<span className="text-muted-foreground">· {adultCount} Adult{adultCount === 1 ? '' : 's'}{childCount > 0 ? ` + ${childCount} Child${childCount === 1 ? '' : 'ren'}` : ''}</span>
|
||||
</div>
|
||||
<span className="font-semibold">{selectedSchedule.trainNumber}</span>
|
||||
<span className="text-muted-foreground">{selectedSchedule.origin.name} → {selectedSchedule.destination.name}</span>
|
||||
<span className="text-muted-foreground">· {formatDateTime(selectedSchedule.departureAt)}</span>
|
||||
<span className="text-muted-foreground">· {selectedClass.category}</span>
|
||||
<span className="text-muted-foreground">· {fareTier === 'LOCAL' ? 'Local' : 'International'} rates</span>
|
||||
<span className="text-muted-foreground">· {adultCount} Adult{adultCount === 1 ? '' : 's'}{childCount > 0 ? ` + ${childCount} Child${childCount === 1 ? '' : 'ren'}` : ''}</span>
|
||||
{tripType === 'ROUND_TRIP' && selectedReturnSchedule && selectedReturnClass && (
|
||||
<div className="flex items-center gap-3 text-sm flex-wrap">
|
||||
<div className="rounded-lg bg-primary/10 p-1.5 shrink-0 invisible">
|
||||
<Train className="h-4 w-4" />
|
||||
</div>
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Return</span>
|
||||
<span className="font-semibold">{selectedReturnSchedule.trainNumber}</span>
|
||||
<span className="text-muted-foreground">{selectedReturnSchedule.origin.name} → {selectedReturnSchedule.destination.name}</span>
|
||||
<span className="text-muted-foreground">· {formatDateTime(selectedReturnSchedule.departureAt)}</span>
|
||||
<span className="text-muted-foreground">· {selectedReturnClass.category}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" onClick={startOver} className="text-xs text-primary hover:underline shrink-0">
|
||||
Start over
|
||||
@@ -559,6 +685,24 @@ function GroupBookingPageContent() {
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Search Availability</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{(['ONE_WAY', 'ROUND_TRIP'] as const).map((tt) => (
|
||||
<button
|
||||
key={tt}
|
||||
type="button"
|
||||
onClick={() => { setTripType(tt); if (tt === 'ONE_WAY') setReturnDate(''); }}
|
||||
className={cn(
|
||||
'flex-1 rounded-lg border px-3 py-2 text-sm transition-colors',
|
||||
tripType === tt
|
||||
? 'border-primary bg-primary/5 text-foreground font-medium'
|
||||
: 'border-border text-muted-foreground hover:border-primary/50',
|
||||
)}
|
||||
>
|
||||
{tt === 'ONE_WAY' ? 'One Way' : 'Round Trip'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<div>
|
||||
<label className="label">Origin</label>
|
||||
@@ -610,6 +754,21 @@ function GroupBookingPageContent() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tripType === 'ROUND_TRIP' && (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<div>
|
||||
<label className="label">Return Date</label>
|
||||
<DatePicker
|
||||
value={returnDate}
|
||||
onChange={setReturnDate}
|
||||
placeholder="Pick a return date"
|
||||
minDate={travelDate ? new Date(`${travelDate}T00:00:00`) : new Date(new Date().setHours(0, 0, 0, 0))}
|
||||
className="w-full [&>button]:w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="label">Fare Tier</label>
|
||||
<div className="flex gap-2">
|
||||
@@ -648,7 +807,9 @@ function GroupBookingPageContent() {
|
||||
? 'Enter at least one adult or child.'
|
||||
: originStationId && originStationId === destinationStationId
|
||||
? 'Origin and destination must be different.'
|
||||
: 'Fill in origin, destination, and travel date.'}
|
||||
: tripType === 'ROUND_TRIP' && travelDate && !returnDate
|
||||
? 'Pick a return date.'
|
||||
: 'Fill in origin, destination, and travel date.'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -661,10 +822,35 @@ function GroupBookingPageContent() {
|
||||
{/* ── Step 2: Results ────────────────────────────────────────────── */}
|
||||
{step === 'results' && (
|
||||
<div className="space-y-4 animate-fade-up">
|
||||
<button type="button" onClick={() => setStep('search')} className="flex items-center gap-1 text-xs text-primary hover:underline">
|
||||
<ArrowLeft className="h-3.5 w-3.5" /> Back to search
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => tripType === 'ROUND_TRIP' && resultsPhase === 'return' ? setResultsPhase('outbound') : setStep('search')}
|
||||
className="flex items-center gap-1 text-xs text-primary hover:underline"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" /> {tripType === 'ROUND_TRIP' && resultsPhase === 'return' ? 'Back to outbound' : 'Back to search'}
|
||||
</button>
|
||||
|
||||
{tripType === 'ROUND_TRIP' && (
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{resultsPhase === 'outbound' ? 'Step 2a — Choose the outbound trip' : 'Step 2b — Choose the return trip'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{tripType === 'ROUND_TRIP' && resultsPhase === 'return' && selectedSchedule && selectedClass && (
|
||||
<div className="card flex items-center justify-between flex-wrap gap-3">
|
||||
<div className="flex items-center gap-3 text-sm flex-wrap">
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-600 dark:text-emerald-400 shrink-0" />
|
||||
<span className="font-semibold">{selectedSchedule.trainNumber}</span>
|
||||
<span className="text-muted-foreground">{selectedSchedule.origin.name} → {selectedSchedule.destination.name}</span>
|
||||
<span className="text-muted-foreground">· {formatDateTime(selectedSchedule.departureAt)}</span>
|
||||
<span className="text-muted-foreground">· {selectedClass.category}</span>
|
||||
</div>
|
||||
<button type="button" onClick={() => setResultsPhase('outbound')} className="text-xs text-primary hover:underline shrink-0">
|
||||
Change outbound
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{searchMutation.isPending && (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 2 }).map((_, i) => (
|
||||
@@ -696,28 +882,59 @@ function GroupBookingPageContent() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{searchMutation.isSuccess && searchMutation.data.outbound.length === 0 && (
|
||||
<div className="card py-12 text-center text-muted-foreground">
|
||||
<Train className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||
<p className="text-foreground font-medium">No schedules found for this search.</p>
|
||||
<p className="text-xs mt-1">{emptySearchMessage(searchMutation.data.outboundReason)}</p>
|
||||
</div>
|
||||
)}
|
||||
{searchMutation.isSuccess && resultsPhase === 'outbound' && (
|
||||
<>
|
||||
{searchMutation.data.outbound.length === 0 && (
|
||||
<div className="card py-12 text-center text-muted-foreground">
|
||||
<Train className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||
<p className="text-foreground font-medium">No schedules found for this search.</p>
|
||||
<p className="text-xs mt-1">{emptySearchMessage(searchMutation.data.outboundReason)}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{searchMutation.isSuccess && (searchMutation.data.alternativeOutbound?.length ?? 0) > 0 && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground flex items-center gap-1.5">
|
||||
<Sparkles className="h-3.5 w-3.5" /> Nearby schedules for the same route
|
||||
</p>
|
||||
{searchMutation.data!.alternativeOutbound!.map((schedule) => (
|
||||
<ScheduleCard key={schedule.scheduleId} schedule={schedule} totalPassengers={totalPassengers} onChoose={chooseClass} />
|
||||
{(searchMutation.data.alternativeOutbound?.length ?? 0) > 0 && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground flex items-center gap-1.5">
|
||||
<Sparkles className="h-3.5 w-3.5" /> Nearby schedules for the same route
|
||||
</p>
|
||||
{searchMutation.data.alternativeOutbound!.map((schedule) => (
|
||||
<ScheduleCard key={schedule.scheduleId} schedule={schedule} totalPassengers={totalPassengers} onChoose={chooseOutboundClass} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{searchMutation.data.outbound.map((schedule) => (
|
||||
<ScheduleCard key={schedule.scheduleId} schedule={schedule} totalPassengers={totalPassengers} onChoose={chooseOutboundClass} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(searchMutation.data?.outbound ?? []).map((schedule) => (
|
||||
<ScheduleCard key={schedule.scheduleId} schedule={schedule} totalPassengers={totalPassengers} onChoose={chooseClass} />
|
||||
))}
|
||||
{searchMutation.isSuccess && tripType === 'ROUND_TRIP' && resultsPhase === 'return' && (
|
||||
<>
|
||||
{(searchMutation.data.inbound?.length ?? 0) === 0 && (
|
||||
<div className="card py-12 text-center text-muted-foreground">
|
||||
<Train className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||
<p className="text-foreground font-medium">No return schedules found for this search.</p>
|
||||
<p className="text-xs mt-1">{emptySearchMessage(searchMutation.data.inboundReason)}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(searchMutation.data.alternativeInbound?.length ?? 0) > 0 && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground flex items-center gap-1.5">
|
||||
<Sparkles className="h-3.5 w-3.5" /> Nearby return schedules for the same route
|
||||
</p>
|
||||
{searchMutation.data.alternativeInbound!.map((schedule) => (
|
||||
<ScheduleCard key={schedule.scheduleId} schedule={schedule} totalPassengers={totalPassengers} onChoose={chooseReturnClass} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(searchMutation.data.inbound ?? []).map((schedule) => (
|
||||
<ScheduleCard key={schedule.scheduleId} schedule={schedule} totalPassengers={totalPassengers} onChoose={chooseReturnClass} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -735,10 +952,20 @@ function GroupBookingPageContent() {
|
||||
</div>
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Passenger Information</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-4 ml-9">
|
||||
<p className="text-sm text-muted-foreground mb-1 ml-9">
|
||||
Download the template, fill in one row per passenger, then upload the completed file.
|
||||
Need exactly <strong className="text-foreground">{totalPassengers}</strong> passenger{totalPassengers === 1 ? '' : 's'} ({adultCount} Adult{adultCount === 1 ? '' : 's'}{childCount > 0 ? `, ${childCount} Child${childCount === 1 ? '' : 'ren'}` : ''}).
|
||||
</p>
|
||||
{tripType === 'ONE_WAY' && childCount > 0 && (
|
||||
<p className="text-xs text-muted-foreground mb-4 ml-9">
|
||||
The first {Math.min(childCount, adultCount)} child{Math.min(childCount, adultCount) === 1 ? '' : 'ren'} under 5 (by row order) travel{Math.min(childCount, adultCount) === 1 ? 's' : ''} free with no assigned seat, matching the passenger portal's policy — one free child per adult. Any additional children get a seat and pay the child fare.
|
||||
</p>
|
||||
)}
|
||||
{tripType === 'ROUND_TRIP' && childCount > 0 && (
|
||||
<p className="text-xs text-muted-foreground mb-4 ml-9">
|
||||
Round trip requires a seat for every child on both legs — the free-child policy only applies to one-way bookings.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="ml-9 flex items-center gap-3 flex-wrap mb-4">
|
||||
<ActionButton icon={Download} variant="secondary" onClick={downloadTemplate}>
|
||||
@@ -810,12 +1037,19 @@ function GroupBookingPageContent() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{passengerRows.map((row) => (
|
||||
{passengerRows.map((row, i) => (
|
||||
<tr key={row.rowNumber} className={row.errors.length > 0 ? 'bg-red-50/60 dark:bg-red-950/20' : undefined}>
|
||||
<td className="px-4 py-2 text-xs text-muted-foreground">{row.rowNumber}</td>
|
||||
<td className="px-4 py-2 whitespace-nowrap">{row.fullName || '—'}</td>
|
||||
<td className="px-4 py-2 whitespace-nowrap text-muted-foreground">{row.dateOfBirth || '—'}</td>
|
||||
<td className="px-4 py-2 whitespace-nowrap">{row.passengerType || '—'}</td>
|
||||
<td className="px-4 py-2 whitespace-nowrap">
|
||||
{row.passengerType || '—'}
|
||||
{freeChildFlags[i] && (
|
||||
<span className="ml-1.5 inline-flex items-center rounded-full bg-emerald-100 dark:bg-emerald-900/30 px-1.5 py-0.5 text-[10px] font-semibold text-emerald-700 dark:text-emerald-400">
|
||||
Free · no seat
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 whitespace-nowrap text-muted-foreground">{row.idDocumentType || '—'}</td>
|
||||
<td className="px-4 py-2 whitespace-nowrap text-muted-foreground">{row.nationality || '—'}</td>
|
||||
<td className="px-4 py-2 text-xs">
|
||||
@@ -876,20 +1110,53 @@ function GroupBookingPageContent() {
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-4">Read-only — seats are assigned by the system, not selected manually.</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
{seatAssignments.map(({ row, seat }) => (
|
||||
{seatAssignments.map(({ row, seat, isFree }) => (
|
||||
<div key={row.rowNumber} className="flex items-center gap-3 rounded-lg border border-border p-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 font-mono text-xs font-bold text-primary">
|
||||
{seat ? (seat.seatNumber ?? seat.label ?? '?') : '—'}
|
||||
<div className={cn(
|
||||
'flex h-10 w-10 shrink-0 items-center justify-center rounded-lg font-mono text-xs font-bold',
|
||||
isFree ? 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-400' : 'bg-primary/10 text-primary',
|
||||
)}>
|
||||
{isFree ? 'Free' : seat ? (seat.seatNumber ?? seat.label ?? '?') : '—'}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{row.fullName}</p>
|
||||
<p className="text-xs text-muted-foreground">{row.passengerType} · {seat?.coach ?? '—'}</p>
|
||||
<p className="text-xs text-muted-foreground">{row.passengerType} · {isFree ? 'no seat' : (seat?.coach ?? '—')}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tripType === 'ROUND_TRIP' && returnHold && (
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="rounded-lg bg-primary/10 p-1.5">
|
||||
<ArmchairIcon className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Return Seats Assigned Automatically
|
||||
</h3>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">Held for {Math.floor(returnHold.ttlSeconds / 60)}m {returnHold.ttlSeconds % 60}s</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-4">Read-only — seats are assigned by the system, not selected manually.</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
{seatAssignments.map(({ row, returnSeat }) => (
|
||||
<div key={row.rowNumber} className="flex items-center gap-3 rounded-lg border border-border p-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 font-mono text-xs font-bold text-primary">
|
||||
{returnSeat ? (returnSeat.seatNumber ?? returnSeat.label ?? '?') : '—'}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{row.fullName}</p>
|
||||
<p className="text-xs text-muted-foreground">{row.passengerType} · {returnSeat?.coach ?? '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bookingError && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-950/30 p-3 text-sm text-red-700 dark:text-red-300 flex items-center justify-between gap-3">
|
||||
<span className="flex items-center gap-2"><AlertTriangle className="h-4 w-4 shrink-0" /> {bookingError}</span>
|
||||
@@ -899,7 +1166,16 @@ function GroupBookingPageContent() {
|
||||
|
||||
<div className="card flex items-center justify-between flex-wrap gap-3">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{totalPassengers} passenger{totalPassengers === 1 ? '' : 's'} · {selectedClass.category} · <span className="font-semibold text-foreground">{formatCurrency(selectedClass.fareMinor * totalPassengers, selectedClass.displayCurrency)}</span> estimated total
|
||||
{totalPassengers} passenger{totalPassengers === 1 ? '' : 's'}
|
||||
{tripType === 'ONE_WAY' && freeChildrenCount > 0 && <> ({freeChildrenCount} free)</>} · {selectedClass.category}
|
||||
{tripType === 'ROUND_TRIP' && selectedReturnClass && <> / {selectedReturnClass.category}</>} ·{' '}
|
||||
<span className="font-semibold text-foreground">
|
||||
{formatCurrency(
|
||||
selectedClass.fareMinor * (tripType === 'ONE_WAY' ? adultCount + paidChildrenCount : totalPassengers)
|
||||
+ (selectedReturnClass?.fareMinor ?? 0) * totalPassengers,
|
||||
selectedClass.displayCurrency,
|
||||
)}
|
||||
</span> estimated total
|
||||
</div>
|
||||
<ActionButton
|
||||
icon={CheckCircle2}
|
||||
@@ -947,7 +1223,10 @@ function GroupBookingPageContent() {
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Coach / Class</p>
|
||||
<p className="font-semibold">{selectedClass?.category ?? '—'}</p>
|
||||
<p className="font-semibold">
|
||||
{selectedClass?.category ?? '—'}
|
||||
{tripType === 'ROUND_TRIP' && selectedReturnClass && <> / {selectedReturnClass.category}</>}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Adults / Children</p>
|
||||
@@ -955,7 +1234,7 @@ function GroupBookingPageContent() {
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Total Passengers</p>
|
||||
<p className="font-semibold">{booking.seats.length}</p>
|
||||
<p className="font-semibold">{totalPassengers}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Total Fare</p>
|
||||
@@ -964,12 +1243,38 @@ function GroupBookingPageContent() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tripType === 'ROUND_TRIP' && selectedReturnSchedule && (
|
||||
<div className="card">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-3">Return Trip</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Train</p>
|
||||
<p className="font-semibold">{selectedReturnSchedule.trainNumber} · {selectedReturnSchedule.trainName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Origin → Destination</p>
|
||||
<p className="font-semibold">{selectedReturnSchedule.origin.name} → {selectedReturnSchedule.destination.name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Departure → Arrival</p>
|
||||
<p className="font-semibold">{formatDateTime(selectedReturnSchedule.departureAt)} → {formatDateTime(selectedReturnSchedule.arrivalAt)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Class</p>
|
||||
<p className="font-semibold">{selectedReturnClass?.category ?? '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card p-0">
|
||||
<div className="px-4 pt-4 pb-3">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Passenger List</h3>
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{tripType === 'ROUND_TRIP' ? 'Outbound Passenger List' : 'Passenger List'}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3 px-4 pb-4">
|
||||
{booking.seats.map((s, i) => (
|
||||
{booking.seats.filter((s) => s.leg !== 2).map((s, i) => (
|
||||
<div key={i} className="flex items-center gap-3 rounded-lg border border-border p-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 font-mono text-xs font-bold text-primary">
|
||||
{s.seat?.seatNumber ?? '?'}
|
||||
@@ -982,9 +1287,45 @@ function GroupBookingPageContent() {
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{/* Free children have no BookingSeat row at all — list them separately so they
|
||||
don't silently disappear from the confirmation. */}
|
||||
{seatAssignments.filter((a) => a.isFree).map(({ row }) => (
|
||||
<div key={row.rowNumber} className="flex items-center gap-3 rounded-lg border border-border p-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-emerald-100 dark:bg-emerald-900/30 font-mono text-xs font-bold text-emerald-700 dark:text-emerald-400">
|
||||
Free
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{row.fullName}</p>
|
||||
<p className="text-xs text-muted-foreground">CHILD · no seat (free)</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tripType === 'ROUND_TRIP' && (
|
||||
<div className="card p-0">
|
||||
<div className="px-4 pt-4 pb-3">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Return Passenger List</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3 px-4 pb-4">
|
||||
{booking.seats.filter((s) => s.leg === 2).map((s, i) => (
|
||||
<div key={i} className="flex items-center gap-3 rounded-lg border border-border p-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 font-mono text-xs font-bold text-primary">
|
||||
{s.seat?.seatNumber ?? '?'}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{s.passengerName}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{s.passengerCategory} · {seatTypeLabel(s.seat?.bedPosition)} · Seat {s.seat?.seatNumber ?? '—'} · Coach {s.seat?.coach?.number}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Pay now ─────────────────────────────────────────────────── */}
|
||||
{!paymentResult && (
|
||||
<div className="card">
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
TicketCheck, Users, TrendingUp, ShieldCheck, MailCheck,
|
||||
} from 'lucide-react';
|
||||
import { iamAuthApi } from '@/lib/api/auth';
|
||||
import { getErrorMessage } from '@/lib/api-client';
|
||||
|
||||
const EDR_GREEN = 'rgb(20, 113, 76)';
|
||||
|
||||
@@ -52,11 +53,11 @@ export default function LoginPage() {
|
||||
await login(identifier.trim(), password);
|
||||
router.push('/dashboard');
|
||||
} catch (err: any) {
|
||||
const msg = err.message || err.response?.data?.message || '';
|
||||
if (msg === 'ACCESS_DENIED') {
|
||||
const rawMessage = err.response?.data?.message;
|
||||
if (rawMessage === 'ACCESS_DENIED') {
|
||||
setError('This account does not have back-office access. Contact your administrator.');
|
||||
} else {
|
||||
setError(err.response?.data?.message || msg || 'Invalid credentials. Please try again.');
|
||||
setError(getErrorMessage(err, 'Invalid credentials. Please try again.'));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -71,11 +72,11 @@ export default function LoginPage() {
|
||||
await iamAuthApi.forgotPassword(forgotIdentifier.trim());
|
||||
setForgotSent(true);
|
||||
} catch (err: any) {
|
||||
const msg = err.response?.data?.message || err.message || '';
|
||||
const rawMessage = err.response?.data?.message;
|
||||
setForgotError(
|
||||
msg === 'user_not_found'
|
||||
rawMessage === 'user_not_found'
|
||||
? 'No account found with that email or phone number.'
|
||||
: msg || 'Failed to send the reset link. Please try again.'
|
||||
: getErrorMessage(err, 'Failed to send the reset link. Please try again.')
|
||||
);
|
||||
} finally {
|
||||
setForgotLoading(false);
|
||||
|
||||
@@ -10,6 +10,7 @@ import Modal from '@/components/ui/Modal';
|
||||
import Pagination from '@/components/ui/Pagination';
|
||||
import { packagesApi } from '@/lib/api';
|
||||
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
||||
import { getErrorMessage } from '@/lib/api-client';
|
||||
|
||||
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
|
||||
<div className="bg-muted/40 rounded-lg p-3">
|
||||
@@ -116,7 +117,7 @@ export default function PackageBookingsPage() {
|
||||
<div className="card">
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 p-4 text-sm text-red-800 dark:text-red-200">
|
||||
Error: {(error as any)?.response?.data?.message || (error as any)?.message || String(error)}
|
||||
Error: {getErrorMessage(error)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-3 mb-4">
|
||||
|
||||
@@ -2,15 +2,21 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Edit, CheckCircle, Eye, Layers, Trash2 } from 'lucide-react';
|
||||
import { Plus, Edit, CheckCircle, Eye, Layers, Trash2, ImagePlus, ImageOff, X } from 'lucide-react';
|
||||
import DataTable from '@/components/ui/DataTable';
|
||||
import Badge from '@/components/ui/Badge';
|
||||
import ActionButton from '@/components/ui/ActionButton';
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import { packagesApi, stationsApi, schedulesApi, seatClassesApi } from '@/lib/api';
|
||||
import { getErrorMessage } from '@/lib/api-client';
|
||||
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
||||
|
||||
// Mirrors the backend's own limits (packages/package-image-upload.options.ts) so a bad file is
|
||||
// rejected instantly client-side instead of round-tripping to the server first.
|
||||
const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
|
||||
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
const toLocal = (iso?: string) => {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
@@ -48,6 +54,15 @@ export default function PackagesPage() {
|
||||
const [deletePackageConfirm, setDeletePackageConfirm] = useState<any>(null);
|
||||
const [deletePackageError, setDeletePackageError] = useState<string | null>(null);
|
||||
const [deletePackageCascade, setDeletePackageCascade] = useState(false);
|
||||
// Image upload: `imageFile`/`imagePreviewUrl` track a newly-selected-but-not-yet-uploaded file
|
||||
// (local object URL preview); `existingImageUrl` is the package's current server-side image
|
||||
// when editing, shown until/unless the admin picks a replacement.
|
||||
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||
const [imagePreviewUrl, setImagePreviewUrl] = useState<string | null>(null);
|
||||
const [existingImageUrl, setExistingImageUrl] = useState<string | null>(null);
|
||||
const [imageError, setImageError] = useState<string | null>(null);
|
||||
const [imageUploadError, setImageUploadError] = useState<string | null>(null);
|
||||
const [removeImageConfirm, setRemoveImageConfirm] = useState<any>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
@@ -141,6 +156,23 @@ export default function PackagesPage() {
|
||||
onError: (e: any) => setTierError(e?.response?.data?.message || e?.message || 'Failed to delete tier'),
|
||||
});
|
||||
|
||||
const uploadImageMutation = useMutation({
|
||||
mutationFn: ({ id, file }: { id: string; file: File }) => packagesApi.uploadImage(id, file),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['packages'] }); setImageUploadError(null); },
|
||||
onError: (e: any) => setImageUploadError(getErrorMessage(e, 'Failed to upload package image')),
|
||||
});
|
||||
|
||||
const removeImageMutation = useMutation({
|
||||
mutationFn: (id: string) => packagesApi.removeImage(id),
|
||||
onSuccess: (updated: any) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['packages'] });
|
||||
setRemoveImageConfirm(null);
|
||||
setExistingImageUrl(updated?.imageUrl ?? null);
|
||||
setViewPackage((prev: any) => (prev && prev.id === updated?.id ? { ...prev, imageUrl: null } : prev));
|
||||
},
|
||||
onError: (e: any) => setImageUploadError(getErrorMessage(e, 'Failed to remove package image')),
|
||||
});
|
||||
|
||||
const openEditTier = (tier: any) => {
|
||||
setEditingTier(tier);
|
||||
setTierForm({ seatClassId: tier.seatClassId ?? '', seatType: tier.seatType, label: tier.label, priceMinor: String(tier.priceMinor), availableSeats: String(tier.availableSeats) });
|
||||
@@ -163,13 +195,29 @@ export default function PackagesPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// Deliberately does not touch imageUploadError — that's shown in a page-level banner (outside
|
||||
// this modal) precisely because it can still be set after the modal has already auto-closed
|
||||
// (see handleSubmit), and clearing it here would wipe it out before the user ever sees it.
|
||||
const resetImageSelection = () => {
|
||||
setImageFile(null);
|
||||
if (imagePreviewUrl) URL.revokeObjectURL(imagePreviewUrl);
|
||||
setImagePreviewUrl(null);
|
||||
setImageError(null);
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setForm(emptyForm);
|
||||
setEditingId(null);
|
||||
resetImageSelection();
|
||||
setImageUploadError(null);
|
||||
setExistingImageUrl(null);
|
||||
setModalMode('create');
|
||||
};
|
||||
|
||||
const openEdit = (pkg: any) => {
|
||||
resetImageSelection();
|
||||
setImageUploadError(null);
|
||||
setExistingImageUrl(pkg.imageUrl ?? null);
|
||||
setForm({
|
||||
code: pkg.code ?? '',
|
||||
name: pkg.name ?? '',
|
||||
@@ -216,11 +264,23 @@ export default function PackagesPage() {
|
||||
validUntil: form.validUntil,
|
||||
priceTiers: [],
|
||||
};
|
||||
// The image is uploaded as a separate follow-up call (the DTO here carries no image field —
|
||||
// see packages.service.ts's uploadImage) so it must run after the package itself exists.
|
||||
let targetId = editingId;
|
||||
if (modalMode === 'edit' && editingId) {
|
||||
await updateMutation.mutateAsync({ id: editingId, data: payload });
|
||||
} else {
|
||||
await createMutation.mutateAsync(payload);
|
||||
const created = await createMutation.mutateAsync(payload);
|
||||
targetId = created?.id ?? null;
|
||||
}
|
||||
if (imageFile && targetId) {
|
||||
try {
|
||||
await uploadImageMutation.mutateAsync({ id: targetId, file: imageFile });
|
||||
} catch {
|
||||
// surfaced via imageUploadError banner — the package itself was already saved successfully
|
||||
}
|
||||
}
|
||||
resetImageSelection();
|
||||
};
|
||||
|
||||
const field = (key: keyof typeof form) => ({
|
||||
@@ -229,6 +289,24 @@ export default function PackagesPage() {
|
||||
setForm((f) => ({ ...f, [key]: e.target.value })),
|
||||
});
|
||||
|
||||
const handleImageFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = ''; // allow re-selecting the same file after a validation error
|
||||
if (!file) return;
|
||||
if (!ALLOWED_IMAGE_TYPES.includes(file.type)) {
|
||||
setImageError('Image must be JPEG, PNG, WEBP, or GIF.');
|
||||
return;
|
||||
}
|
||||
if (file.size > MAX_IMAGE_BYTES) {
|
||||
setImageError(`Image must be ${Math.round(MAX_IMAGE_BYTES / (1024 * 1024))}MB or smaller.`);
|
||||
return;
|
||||
}
|
||||
setImageError(null);
|
||||
if (imagePreviewUrl) URL.revokeObjectURL(imagePreviewUrl);
|
||||
setImageFile(file);
|
||||
setImagePreviewUrl(URL.createObjectURL(file));
|
||||
};
|
||||
|
||||
const scheduleLabel = (s: any) => {
|
||||
const from = s.originStation?.name ?? s.originStationId ?? '?';
|
||||
const to = s.destinationStation?.name ?? s.destinationStationId ?? '?';
|
||||
@@ -237,7 +315,19 @@ export default function PackagesPage() {
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ key: 'code', label: 'Package',
|
||||
{
|
||||
key: 'image', label: '',
|
||||
render: (p: any) => (
|
||||
p.imageUrl ? (
|
||||
<img src={p.imageUrl} alt="" className="h-10 w-10 rounded object-cover border border-border" />
|
||||
) : (
|
||||
<div className="h-10 w-10 rounded border border-border bg-muted flex items-center justify-center">
|
||||
<ImageOff className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
),
|
||||
},
|
||||
{ key: 'code', label: 'Package',
|
||||
render: (pkg: any) => (
|
||||
<div className="text-sm">
|
||||
<div>{pkg.code}</div>
|
||||
@@ -299,7 +389,7 @@ export default function PackagesPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||
const isPending = createMutation.isPending || updateMutation.isPending || uploadImageMutation.isPending;
|
||||
|
||||
const allItems: any[] = data?.items || [];
|
||||
const filteredItems = allItems.filter((p) => {
|
||||
@@ -320,6 +410,18 @@ export default function PackagesPage() {
|
||||
<ActionButton icon={Plus} onClick={openCreate}>New Package</ActionButton>
|
||||
</div>
|
||||
|
||||
{/* The package itself may already be saved and this modal closed by the time an image
|
||||
upload/removal fails (see handleSubmit) — surfaced here rather than inside the modal
|
||||
so it's never silently lost. */}
|
||||
{imageUploadError && (
|
||||
<div className="flex items-center justify-between rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 text-sm text-red-800 dark:text-red-300">
|
||||
<span>{imageUploadError}</span>
|
||||
<button type="button" className="ml-3 shrink-0" onClick={() => setImageUploadError(null)}>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
<div className="mb-4 space-y-3">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
@@ -366,6 +468,14 @@ export default function PackagesPage() {
|
||||
<Modal isOpen={!!viewPackage} onClose={() => setViewPackage(null)} title="Package Details" size="lg">
|
||||
{viewPackage && (
|
||||
<div className="space-y-4 text-sm">
|
||||
{viewPackage.imageUrl ? (
|
||||
<img src={viewPackage.imageUrl} alt={viewPackage.name} className="w-full max-h-56 rounded-lg object-cover border border-border" />
|
||||
) : (
|
||||
<div className="w-full h-32 rounded-lg border border-dashed border-border bg-muted flex items-center justify-center gap-2 text-muted-foreground">
|
||||
<ImageOff className="h-5 w-5" />
|
||||
<span>No image</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div><span className="label">Code</span><p className="font-mono font-semibold">{viewPackage.code}</p></div>
|
||||
<div><span className="label">Status</span><p>{viewPackage.status}</p></div>
|
||||
@@ -549,10 +659,23 @@ export default function PackagesPage() {
|
||||
error={tierError ?? undefined}
|
||||
/>
|
||||
|
||||
{/* Remove Image Confirmation */}
|
||||
<ConfirmDialog
|
||||
isOpen={!!removeImageConfirm}
|
||||
onClose={() => setRemoveImageConfirm(null)}
|
||||
onConfirm={() => removeImageMutation.mutate(removeImageConfirm.id)}
|
||||
title="Remove Package Image"
|
||||
message={`Remove the image for "${removeImageConfirm?.name}"? The package itself will not be deleted.`}
|
||||
confirmText="Remove Image"
|
||||
isDanger
|
||||
isLoading={removeImageMutation.isPending}
|
||||
error={imageUploadError ?? undefined}
|
||||
/>
|
||||
|
||||
{/* Create / Edit Modal */}
|
||||
<Modal
|
||||
isOpen={modalMode !== null}
|
||||
onClose={() => setModalMode(null)}
|
||||
onClose={() => { setModalMode(null); resetImageSelection(); }}
|
||||
title={modalMode === 'edit' ? 'Edit Package' : 'New Package'}
|
||||
size="lg"
|
||||
>
|
||||
@@ -571,6 +694,40 @@ export default function PackagesPage() {
|
||||
<textarea className="input" rows={2} placeholder="Optional description" {...field('description')} />
|
||||
</div>
|
||||
|
||||
<div className="col-span-2">
|
||||
<label className="label">Package Image</label>
|
||||
<div className="flex items-start gap-4">
|
||||
{imagePreviewUrl ? (
|
||||
<img src={imagePreviewUrl} alt="Preview" className="h-24 w-24 rounded-lg object-cover border border-border" />
|
||||
) : existingImageUrl ? (
|
||||
<img src={existingImageUrl} alt="Current" className="h-24 w-24 rounded-lg object-cover border border-border" />
|
||||
) : (
|
||||
<div className="h-24 w-24 rounded-lg border border-dashed border-border bg-muted flex items-center justify-center">
|
||||
<ImagePlus className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 space-y-2">
|
||||
<input type="file" accept="image/jpeg,image/png,image/webp,image/gif" className="input" onChange={handleImageFileChange} />
|
||||
<p className="text-xs text-muted-foreground">JPEG, PNG, WEBP, or GIF. Max 5MB.</p>
|
||||
{imageError && <p className="text-xs text-red-600 dark:text-red-400">{imageError}</p>}
|
||||
{imageFile && (
|
||||
<button type="button" className="text-xs text-primary underline" onClick={resetImageSelection}>
|
||||
<X className="h-3 w-3 inline -mt-0.5 mr-0.5" />Clear selected file
|
||||
</button>
|
||||
)}
|
||||
{!imageFile && modalMode === 'edit' && existingImageUrl && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-red-600 dark:text-red-400 underline block"
|
||||
onClick={() => { setImageUploadError(null); setRemoveImageConfirm({ id: editingId, name: form.name }); }}
|
||||
>
|
||||
Remove current image
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Origin Station *</label>
|
||||
<select className="input" required {...field('originStationId')}>
|
||||
@@ -660,7 +817,7 @@ export default function PackagesPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<ActionButton type="button" variant="secondary" onClick={() => setModalMode(null)}>Cancel</ActionButton>
|
||||
<ActionButton type="button" variant="secondary" onClick={() => { setModalMode(null); resetImageSelection(); }}>Cancel</ActionButton>
|
||||
<ActionButton type="submit" loading={isPending}>
|
||||
{modalMode === 'edit' ? 'Update Package' : 'Create Package'}
|
||||
</ActionButton>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { Eye, EyeOff, ArrowRight, ArrowLeft, Loader2, CheckCircle2 } from 'lucide-react';
|
||||
import { iamAuthApi } from '@/lib/api/auth';
|
||||
import { getErrorMessage } from '@/lib/api-client';
|
||||
|
||||
function ResetPasswordForm() {
|
||||
const router = useRouter();
|
||||
@@ -41,8 +42,7 @@ function ResetPasswordForm() {
|
||||
setSuccess(true);
|
||||
setTimeout(() => router.push('/login'), 2000);
|
||||
} catch (err: any) {
|
||||
const msg = err.response?.data?.message || err.message || '';
|
||||
setError(msg || 'Failed to reset password. The link may have expired — request a new one from the sign-in page.');
|
||||
setError(getErrorMessage(err, 'Failed to reset password. The link may have expired — request a new one from the sign-in page.'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -30,9 +30,22 @@ interface Schedule {
|
||||
destinationStation?: { id: string; name: string };
|
||||
coachAssignments?: Array<{ coachId: string; positionNumber: number; coach?: { id: string; number: string } }>;
|
||||
isPackageOnly?: boolean;
|
||||
isGroupBookingOnly?: boolean;
|
||||
liveStatus?: { delayMinutes: number } | null;
|
||||
}
|
||||
|
||||
/** Mutually-exclusive UI view over the two independent isPackageOnly/isGroupBookingOnly flags
|
||||
* the API actually stores — same pair, just presented as one choice instead of two checkboxes. */
|
||||
type ScheduleVisibility = 'NORMAL' | 'PACKAGE_ONLY' | 'GROUP_ONLY';
|
||||
function visibilityOf(isPackageOnly?: boolean, isGroupBookingOnly?: boolean): ScheduleVisibility {
|
||||
if (isGroupBookingOnly) return 'GROUP_ONLY';
|
||||
if (isPackageOnly) return 'PACKAGE_ONLY';
|
||||
return 'NORMAL';
|
||||
}
|
||||
function visibilityFlags(v: ScheduleVisibility): { isPackageOnly: boolean; isGroupBookingOnly: boolean } {
|
||||
return { isPackageOnly: v === 'PACKAGE_ONLY', isGroupBookingOnly: v === 'GROUP_ONLY' };
|
||||
}
|
||||
|
||||
interface Train {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -84,7 +97,7 @@ function SchedulesPageContent() {
|
||||
|
||||
const [bulkCoachRows, setBulkCoachRows] = useState<{ coachId: string; positionNumber: number }[]>([]);
|
||||
|
||||
const [addForm, setAddForm] = useState({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' });
|
||||
const [addForm, setAddForm] = useState({ trainId: '', routeId: '', departureAt: '', arrivalAt: '', isPackageOnly: false, isGroupBookingOnly: false });
|
||||
const [addCoachRows, setAddCoachRows] = useState<{ coachId: string; positionNumber: number }[]>([]);
|
||||
|
||||
const { data: singleRouteTemplate, isLoading: singleTemplateLoading } = useQuery({
|
||||
@@ -122,6 +135,7 @@ function SchedulesPageContent() {
|
||||
status: 'SCHEDULED',
|
||||
coachIds: [] as string[],
|
||||
isPackageOnly: false,
|
||||
isGroupBookingOnly: false,
|
||||
});
|
||||
|
||||
const [filters, setFilters] = useState({
|
||||
@@ -188,7 +202,7 @@ function SchedulesPageContent() {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['schedules'] });
|
||||
setShowAddModal(false);
|
||||
setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' });
|
||||
setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '', isPackageOnly: false, isGroupBookingOnly: false });
|
||||
setAddCoachRows([]);
|
||||
setError(null);
|
||||
},
|
||||
@@ -288,6 +302,8 @@ function SchedulesPageContent() {
|
||||
routeId: addForm.routeId,
|
||||
departureAt: dep.toISOString(),
|
||||
arrivalAt: arr.toISOString(),
|
||||
isPackageOnly: addForm.isPackageOnly,
|
||||
isGroupBookingOnly: addForm.isGroupBookingOnly,
|
||||
...(validCoaches.length > 0 && { coachIds: validCoaches.map((r) => r.coachId) }),
|
||||
});
|
||||
};
|
||||
@@ -312,6 +328,7 @@ function SchedulesPageContent() {
|
||||
arrivalAt: eatLocalToISO(editForm.arrivalAt),
|
||||
status: editForm.status,
|
||||
isPackageOnly: editForm.isPackageOnly,
|
||||
isGroupBookingOnly: editForm.isGroupBookingOnly,
|
||||
coaches: editForm.coachIds.map((coachId: string, idx: number) => ({
|
||||
coachId,
|
||||
positionNumber: idx + 1,
|
||||
@@ -356,6 +373,7 @@ function SchedulesPageContent() {
|
||||
status: schedule.status,
|
||||
coachIds: schedule.coachAssignments?.map((ca: any) => ca.coachId) || [],
|
||||
isPackageOnly: schedule.isPackageOnly ?? false,
|
||||
isGroupBookingOnly: schedule.isGroupBookingOnly ?? false,
|
||||
});
|
||||
setError(null);
|
||||
setShowEditModal(true);
|
||||
@@ -494,6 +512,9 @@ function SchedulesPageContent() {
|
||||
{schedule.isPackageOnly && (
|
||||
<span className="edr-badge edr-badge-warning">PKG</span>
|
||||
)}
|
||||
{schedule.isGroupBookingOnly && (
|
||||
<span className="edr-badge edr-badge-warning">GROUP</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -778,7 +799,7 @@ function SchedulesPageContent() {
|
||||
|
||||
<Modal
|
||||
isOpen={showAddModal}
|
||||
onClose={() => { setShowAddModal(false); setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' }); setAddCoachRows([]); setError(null); }}
|
||||
onClose={() => { setShowAddModal(false); setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '', isPackageOnly: false, isGroupBookingOnly: false }); setAddCoachRows([]); setError(null); }}
|
||||
title="Add Schedule"
|
||||
size="xl"
|
||||
>
|
||||
@@ -817,6 +838,37 @@ function SchedulesPageContent() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<label className="label mb-2">Visibility</label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
{([
|
||||
{ value: 'NORMAL', title: 'Normal', desc: 'Bookable through the passenger portal like any other schedule' },
|
||||
{ value: 'PACKAGE_ONLY', title: 'Package Only', desc: 'Hide from public search — reserved for package bookings' },
|
||||
{ value: 'GROUP_ONLY', title: 'Group Booking Only', desc: 'Hide from public search — reserved for staff group bookings' },
|
||||
] as const).map((opt) => {
|
||||
const selected = visibilityOf(addForm.isPackageOnly, addForm.isGroupBookingOnly) === opt.value;
|
||||
return (
|
||||
<label
|
||||
key={opt.value}
|
||||
className={`flex items-start gap-2 p-3 rounded-lg border cursor-pointer transition-colors ${selected ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/50'}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="add-visibility"
|
||||
className="w-4 h-4 mt-0.5"
|
||||
checked={selected}
|
||||
onChange={() => setAddForm({ ...addForm, ...visibilityFlags(opt.value) })}
|
||||
/>
|
||||
<span className="text-sm">
|
||||
<span className="font-medium block">{opt.title}</span>
|
||||
<span className="block text-xs text-muted-foreground">{opt.desc}</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="label mb-0">Coaches</label>
|
||||
@@ -875,7 +927,7 @@ function SchedulesPageContent() {
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<ActionButton type="button" variant="secondary" onClick={() => { setShowAddModal(false); setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' }); setAddCoachRows([]); setError(null); }}>Cancel</ActionButton>
|
||||
<ActionButton type="button" variant="secondary" onClick={() => { setShowAddModal(false); setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '', isPackageOnly: false, isGroupBookingOnly: false }); setAddCoachRows([]); setError(null); }}>Cancel</ActionButton>
|
||||
<ActionButton type="submit" loading={createScheduleMutation.isPending}>Create Schedule</ActionButton>
|
||||
</div>
|
||||
</form>
|
||||
@@ -1155,18 +1207,35 @@ function SchedulesPageContent() {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg border border-border">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="isPackageOnly"
|
||||
checked={editForm.isPackageOnly}
|
||||
onChange={(e) => setEditForm({ ...editForm, isPackageOnly: e.target.checked })}
|
||||
className="w-4 h-4 rounded"
|
||||
/>
|
||||
<label htmlFor="isPackageOnly" className="text-sm cursor-pointer">
|
||||
<span className="font-medium">Package Only</span>
|
||||
<span className="block text-xs text-muted-foreground">Hide from public search — reserved for package bookings</span>
|
||||
</label>
|
||||
<div>
|
||||
<label className="label mb-2">Visibility</label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
{([
|
||||
{ value: 'NORMAL', title: 'Normal', desc: 'Bookable through the passenger portal like any other schedule' },
|
||||
{ value: 'PACKAGE_ONLY', title: 'Package Only', desc: 'Hide from public search — reserved for package bookings' },
|
||||
{ value: 'GROUP_ONLY', title: 'Group Booking Only', desc: 'Hide from public search — reserved for staff group bookings' },
|
||||
] as const).map((opt) => {
|
||||
const selected = visibilityOf(editForm.isPackageOnly, editForm.isGroupBookingOnly) === opt.value;
|
||||
return (
|
||||
<label
|
||||
key={opt.value}
|
||||
className={`flex items-start gap-2 p-3 rounded-lg border cursor-pointer transition-colors ${selected ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/50'}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="edit-visibility"
|
||||
className="w-4 h-4 mt-0.5"
|
||||
checked={selected}
|
||||
onChange={() => setEditForm({ ...editForm, ...visibilityFlags(opt.value) })}
|
||||
/>
|
||||
<span className="text-sm">
|
||||
<span className="font-medium block">{opt.title}</span>
|
||||
<span className="block text-xs text-muted-foreground">{opt.desc}</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -2,6 +2,30 @@ import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
|
||||
|
||||
const GENERIC_ERROR_MESSAGE = 'Something went wrong. Please try again.';
|
||||
const NETWORK_ERROR_MESSAGE = 'Could not reach the server. Please check your connection and try again.';
|
||||
|
||||
/**
|
||||
* Extracts a user-facing message from a failed request. Prefers a real backend-provided message
|
||||
* (joining a NestJS validation array into one line); otherwise falls back to a friendly generic
|
||||
* message — never raw client/network text like "Request failed with status code 500" or
|
||||
* "Network Error", which is what axios puts in `error.message` when there's nothing better.
|
||||
* Every page in this app should use this (or rely on the response interceptor below, which
|
||||
* normalizes the same error in place) instead of reading `err.message` directly.
|
||||
*/
|
||||
export function getErrorMessage(error: unknown, fallback: string = GENERIC_ERROR_MESSAGE): string {
|
||||
const err = error as any;
|
||||
const raw = err?.response?.data?.message;
|
||||
if (Array.isArray(raw) && raw.length > 0) {
|
||||
const joined = raw.filter((m: unknown) => typeof m === 'string' && m.trim()).join('; ');
|
||||
if (joined) return joined;
|
||||
} else if (typeof raw === 'string' && raw.trim()) {
|
||||
return raw;
|
||||
}
|
||||
if (err?.isAxiosError && !err.response) return NETWORK_ERROR_MESSAGE;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
class ApiClient {
|
||||
private client: AxiosInstance;
|
||||
|
||||
@@ -30,6 +54,20 @@ class ApiClient {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize in place so every existing `err?.response?.data?.message || err?.message ||
|
||||
// '<fallback>'` call site across the app picks up a friendly message automatically,
|
||||
// instead of raw axios/network text or an unjoined NestJS validation array.
|
||||
try {
|
||||
const friendly = getErrorMessage(error);
|
||||
if (error.response?.data && typeof error.response.data === 'object') {
|
||||
error.response.data.message = friendly;
|
||||
}
|
||||
error.message = friendly;
|
||||
} catch {
|
||||
// Best-effort — never let normalization itself break the original rejection.
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -8,9 +8,15 @@ export interface SearchTripsRequest {
|
||||
date: string;
|
||||
adultCount: number;
|
||||
childCount?: number;
|
||||
journeyType: 'ONE_WAY';
|
||||
journeyType: 'ONE_WAY' | 'ROUND_TRIP';
|
||||
/** Required when journeyType is ROUND_TRIP. */
|
||||
returnDate?: string;
|
||||
/** Drives which fare tier (Local vs International) gets quoted — see fareTier in the page component. */
|
||||
nationality?: string;
|
||||
/** Always 'GROUP_BOOKING' for this app — search returns ONLY schedules marked
|
||||
* isGroupBookingOnly (an exclusive partition, not additive): normal passenger-facing
|
||||
* schedules never show up here, and group-only schedules never show up in the portal. */
|
||||
channel?: 'PORTAL' | 'GROUP_BOOKING';
|
||||
}
|
||||
|
||||
export interface ScheduleClassOption {
|
||||
@@ -50,6 +56,7 @@ export type SearchEmptyReasonCode =
|
||||
| 'NO_SCHEDULE_ON_DATE'
|
||||
| 'CANCELLED'
|
||||
| 'PACKAGE_ONLY'
|
||||
| 'GROUP_BOOKING_ONLY'
|
||||
| 'CHECKIN_CLOSED'
|
||||
| 'FULLY_BOOKED';
|
||||
|
||||
@@ -67,6 +74,11 @@ export interface SearchTripsResponse {
|
||||
outboundReason?: SearchEmptyReason;
|
||||
/** Nearby schedules for the same station pair on a different date, offered when `outbound` is empty. */
|
||||
alternativeOutbound?: ScheduleResult[];
|
||||
/** Return-leg schedules — present when the request's journeyType was ROUND_TRIP. */
|
||||
inbound?: ScheduleResult[];
|
||||
requestedReturnDate?: string;
|
||||
inboundReason?: SearchEmptyReason;
|
||||
alternativeInbound?: ScheduleResult[];
|
||||
}
|
||||
|
||||
// ── Seat classes (GET /seat-classes) ───────────────────────────────────────
|
||||
@@ -85,6 +97,8 @@ export interface AutoAssignHoldRequest {
|
||||
seatClassName: string;
|
||||
adultCount: number;
|
||||
childCount?: number;
|
||||
/** Round-trip leg tag — omit for a one-way booking. */
|
||||
journeyDirection?: 'OUTBOUND' | 'RETURN';
|
||||
}
|
||||
|
||||
export interface HeldPassengerSeat {
|
||||
@@ -110,7 +124,8 @@ export interface AutoAssignHoldResponse {
|
||||
// ── Group booking creation (POST /bookings/group) ──────────────────────────
|
||||
|
||||
export interface GroupBookingPassengerInput {
|
||||
seatId: string;
|
||||
/** Omit for a free child (ONE_WAY only) — matches guest-booking.dto.ts's own optional seatId. */
|
||||
seatId?: string;
|
||||
passengerName: string;
|
||||
dateOfBirth: string;
|
||||
idDocumentType: 'NATIONAL_ID' | 'PASSPORT' | 'DRIVING_LICENSE' | 'OTHER';
|
||||
@@ -120,6 +135,8 @@ export interface GroupBookingPassengerInput {
|
||||
nationality?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
/** Return-leg seat ID — required when the booking is ROUND_TRIP. */
|
||||
returnSeatId?: string;
|
||||
}
|
||||
|
||||
export interface CreateGroupBookingRequest {
|
||||
@@ -128,14 +145,23 @@ export interface CreateGroupBookingRequest {
|
||||
originStationId: string;
|
||||
destinationStationId: string;
|
||||
seatClassId: string;
|
||||
bookingType: 'ONE_WAY';
|
||||
bookingType: 'ONE_WAY' | 'ROUND_TRIP';
|
||||
passengers: GroupBookingPassengerInput[];
|
||||
/** ROUND_TRIP only. */
|
||||
returnScheduleId?: string;
|
||||
returnHoldId?: string;
|
||||
returnOriginStationId?: string;
|
||||
returnDestinationStationId?: string;
|
||||
/** Falls back to seatClassId on the backend if omitted. */
|
||||
returnSeatClassId?: string;
|
||||
}
|
||||
|
||||
export interface GroupBookingSeat {
|
||||
seatId: string;
|
||||
passengerName: string;
|
||||
passengerCategory: 'ADULT' | 'CHILD';
|
||||
/** 1 = outbound leg, 2 = return leg. Absent on a plain ONE_WAY booking. */
|
||||
leg?: number;
|
||||
seat: { seatNumber: string; bedPosition?: string | null; coach: { number: string } };
|
||||
}
|
||||
|
||||
@@ -209,10 +235,23 @@ export const groupBookingApi = {
|
||||
autoAssignHold: (dto: AutoAssignHoldRequest) =>
|
||||
apiClient.post<AutoAssignHoldResponse>('/seats/auto-assign-hold', dto),
|
||||
|
||||
/** Best-effort early release — e.g. freeing an outbound hold when the return leg's auto-assign fails. */
|
||||
releaseHold: (holdId: string) => apiClient.delete(`/seats/hold/${holdId}`),
|
||||
|
||||
createGroupBooking: (dto: CreateGroupBookingRequest) =>
|
||||
apiClient.post<CreateGroupBookingResponse>('/bookings/group', dto),
|
||||
|
||||
getPaymentMethods: () => apiClient.get<SupportedPaymentMethod[]>('/payments/methods'),
|
||||
// Guards against a non-array response the same way dashboardApi.getPaymentMethods /
|
||||
// paymentsApi.getMethods already do elsewhere in this app — never lets a bad/unexpected
|
||||
// response shape reach a caller expecting a plain array.
|
||||
getPaymentMethods: async (): Promise<SupportedPaymentMethod[]> => {
|
||||
try {
|
||||
const response = await apiClient.get<SupportedPaymentMethod[]>('/payments/methods');
|
||||
return Array.isArray(response) ? response : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
initiatePayment: (dto: InitiatePaymentRequest) =>
|
||||
apiClient.post<InitiatePaymentResponse>('/payments/initiate', dto),
|
||||
|
||||
@@ -475,6 +475,15 @@ export const packagesApi = {
|
||||
addTier: (packageId: string, data: any) => apiClient.post<any>(`/packages/${packageId}/tiers`, data),
|
||||
updateTier: (tierId: string, data: any) => apiClient.patch<any>(`/packages/tiers/${tierId}`, data),
|
||||
deleteTier: (tierId: string) => apiClient.delete(`/packages/tiers/${tierId}`),
|
||||
// `apiClient` pins Content-Type: application/json on every request — clearing it (rather than
|
||||
// setting multipart/form-data by hand, which omits the boundary) is what lets the browser
|
||||
// generate a proper boundary of its own. Same pattern as features/support/supportApi.ts.
|
||||
uploadImage: (id: string, file: File) => {
|
||||
const form = new FormData();
|
||||
form.append('image', file);
|
||||
return apiClient.post<any>(`/packages/${id}/image`, form, { headers: { 'Content-Type': undefined } });
|
||||
},
|
||||
removeImage: (id: string) => apiClient.delete<any>(`/packages/${id}/image`),
|
||||
};
|
||||
|
||||
// Package Inquiries API
|
||||
|
||||
@@ -2,6 +2,16 @@ import ExcelJS from 'exceljs';
|
||||
|
||||
const VALID_ID_TYPES = ['NATIONAL_ID', 'PASSPORT', 'DRIVING_LICENSE', 'OTHER'];
|
||||
|
||||
/** Exact mirror of guest-booking.service.ts's calculateAge — calendar-based, not a 365.25-day
|
||||
* approximation, so this file's CHILD/ADULT determination never disagrees with the backend's. */
|
||||
function calculateAge(dateOfBirth: Date): number {
|
||||
const today = new Date();
|
||||
let age = today.getFullYear() - dateOfBirth.getFullYear();
|
||||
const monthDiff = today.getMonth() - dateOfBirth.getMonth();
|
||||
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < dateOfBirth.getDate())) age--;
|
||||
return age;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors guest-booking.service.ts's per-passenger nationality inference exactly (NATIONAL_ID
|
||||
* always forces 'Ethiopian'; PASSPORT falls back to Djiboutian/Other by passport country), so a
|
||||
@@ -25,6 +35,8 @@ export interface ParsedPassengerRow {
|
||||
fullName: string;
|
||||
dateOfBirth: string; // normalized YYYY-MM-DD, empty if invalid/missing
|
||||
passengerType: 'Adult' | 'Child' | '';
|
||||
/** Backend-authoritative category from DOB alone (age < 5), regardless of the Type column. */
|
||||
isChildByAge: boolean;
|
||||
idDocumentType: string;
|
||||
idDocumentNumber: string;
|
||||
passportNumber: string;
|
||||
@@ -145,7 +157,7 @@ export async function parsePassengerExcel(file: File, quotedFareTier?: 'LOCAL' |
|
||||
if (!fullName) errors.push('Full Name is required');
|
||||
|
||||
let dateOfBirth = '';
|
||||
let ageYears: number | null = null;
|
||||
let isChildByAge = false;
|
||||
if (!dobRaw) {
|
||||
errors.push('Date of Birth is required');
|
||||
} else {
|
||||
@@ -156,7 +168,7 @@ export async function parsePassengerExcel(file: File, quotedFareTier?: 'LOCAL' |
|
||||
errors.push('Date of Birth cannot be in the future');
|
||||
} else {
|
||||
dateOfBirth = parsed.toISOString().split('T')[0];
|
||||
ageYears = (Date.now() - parsed.getTime()) / (365.25 * 24 * 60 * 60 * 1000);
|
||||
isChildByAge = calculateAge(parsed) < 5;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,8 +180,8 @@ export async function parsePassengerExcel(file: File, quotedFareTier?: 'LOCAL' |
|
||||
|
||||
// The backend computes ADULT/CHILD from date of birth alone (under 5 = Child), regardless
|
||||
// of this column — flag a mismatch so the uploader notices before it surprises them later.
|
||||
if (passengerType && ageYears !== null) {
|
||||
const impliedType = ageYears < 5 ? 'Child' : 'Adult';
|
||||
if (passengerType && dateOfBirth) {
|
||||
const impliedType = isChildByAge ? 'Child' : 'Adult';
|
||||
if (impliedType !== passengerType) {
|
||||
warnings.push(`Date of Birth implies ${impliedType}, but Passenger Type is set to ${passengerType} — seats/fare are priced by age, not this column`);
|
||||
}
|
||||
@@ -202,6 +214,7 @@ export async function parsePassengerExcel(file: File, quotedFareTier?: 'LOCAL' |
|
||||
fullName,
|
||||
dateOfBirth,
|
||||
passengerType,
|
||||
isChildByAge,
|
||||
idDocumentType: docTypeRaw,
|
||||
idDocumentNumber: docNumber,
|
||||
passportNumber,
|
||||
@@ -223,7 +236,23 @@ export async function parsePassengerExcel(file: File, quotedFareTier?: 'LOCAL' |
|
||||
|
||||
export function countByType(rows: ParsedPassengerRow[]): { adults: number; children: number } {
|
||||
return {
|
||||
adults: rows.filter((r) => r.passengerType === 'Adult').length,
|
||||
children: rows.filter((r) => r.passengerType === 'Child').length,
|
||||
adults: rows.filter((r) => !r.isChildByAge).length,
|
||||
children: rows.filter((r) => r.isChildByAge).length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors the passenger portal's isFirstChild rule exactly (fare-utils.ts): the first
|
||||
* `adultCount` children in passenger order travel free with no assigned seat; any child
|
||||
* beyond that gets a real seat and pays the child fare. Returns one boolean per row, true
|
||||
* where that row is a free, unseated child.
|
||||
*/
|
||||
export function resolveFreeChildIndexes(rows: ParsedPassengerRow[], adultCount: number): boolean[] {
|
||||
let childrenSeen = 0;
|
||||
return rows.map((row) => {
|
||||
if (!row.isChildByAge) return false;
|
||||
const free = childrenSeen < adultCount;
|
||||
childrenSeen++;
|
||||
return free;
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user