Update group booking and package

This commit is contained in:
Roba Boru
2026-08-27 22:54:59 +03:00
parent 5c2100e76d
commit 1603ff8211
35 changed files with 1170 additions and 377 deletions

View File

@@ -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">

View File

@@ -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);

View File

@@ -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">

View File

@@ -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>

View File

@@ -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);
}

View File

@@ -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>

View File

@@ -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);
},
);

View File

@@ -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),

View File

@@ -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

View File

@@ -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;
});
}