mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Fix auto assign seat
This commit is contained in:
@@ -379,36 +379,50 @@ export default function ResultsPage() {
|
||||
const coachCurrency = "ETB";
|
||||
const CoachIcon = getCoachIcon(coachType.coachTypeName);
|
||||
|
||||
const selectThisCoach = () =>
|
||||
handleSelectCoachType(
|
||||
scheduleId,
|
||||
coachType.coachTypeId,
|
||||
coachType.coachTypeCode,
|
||||
coachType.coachTypeName,
|
||||
coachType.classes?.[0]?.name ||
|
||||
coachType.coachTypeName,
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
<div
|
||||
key={coachType.coachId}
|
||||
onClick={() =>
|
||||
handleSelectCoachType(
|
||||
scheduleId,
|
||||
coachType.coachTypeId,
|
||||
coachType.coachTypeCode,
|
||||
coachType.coachTypeName,
|
||||
coachType.classes?.[0]?.name ||
|
||||
coachType.coachTypeName,
|
||||
)
|
||||
}
|
||||
className={`group relative w-full p-5 rounded-2xl border-2 text-left transition-all duration-200 ${
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={selectThisCoach}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
selectThisCoach();
|
||||
}
|
||||
}}
|
||||
className={`group relative w-full p-5 rounded-2xl border-2 text-left transition-all duration-200 cursor-pointer ${
|
||||
isSelected
|
||||
? "border-primary bg-gradient-to-br from-primary/8 to-primary/3 dark:from-primary/15 dark:to-primary/5 shadow-lg shadow-primary/20 scale-[1.02]"
|
||||
: "border-gray-200 dark:border-gray-700 hover:border-primary/40 hover:shadow-md hover:scale-[1.01] bg-white dark:bg-gray-800/50"
|
||||
: "border-gray-200 dark:border-gray-700 shadow-sm hover:border-primary/40 hover:shadow-md hover:scale-[1.01] bg-white dark:bg-gray-800/50"
|
||||
}`}
|
||||
style={{
|
||||
animation: `fade-in-up 0.3s ease-out ${index * 0.1}s both`,
|
||||
}}
|
||||
>
|
||||
{isSelected && (
|
||||
<div className="absolute top-4 right-4 w-7 h-7 bg-primary rounded-full flex items-center justify-center shadow-lg animate-scale-in">
|
||||
<Check
|
||||
className="w-4 h-4 text-white"
|
||||
strokeWidth={3}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Radio indicator — top-right, persistent (not hover-only) so the
|
||||
card's selection state is clear on touch too. */}
|
||||
<span
|
||||
className={`absolute top-4 right-4 w-5 h-5 rounded-full border-2 flex items-center justify-center flex-shrink-0 transition-all ${
|
||||
isSelected
|
||||
? "border-primary"
|
||||
: "border-gray-300 dark:border-gray-600 group-hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
{isSelected && (
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-primary animate-scale-in" />
|
||||
)}
|
||||
</span>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-start gap-4 pr-2">
|
||||
@@ -474,7 +488,7 @@ export default function ResultsPage() {
|
||||
(cls: any, idx: number) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-center justify-between py-2 px-3 rounded-lg bg-gray-50/80 dark:bg-gray-800/40 hover:bg-gray-100/80 dark:hover:bg-gray-800/60 transition-colors"
|
||||
className="flex items-center justify-between py-2 px-3 rounded-lg bg-gray-50/80 dark:bg-gray-800/40"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<CoachIcon className="w-3.5 h-3.5 text-gray-500 dark:text-gray-400" />
|
||||
@@ -496,8 +510,37 @@ export default function ResultsPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Note — only shown while unselected; once picked, the Continue
|
||||
button below takes its place. */}
|
||||
{!isSelected && (
|
||||
<p className="mt-4 pt-3 border-t border-gray-200/60 dark:border-gray-700/60 text-xs text-gray-400 dark:text-gray-500 italic text-center">
|
||||
Click to select this coach
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Continue only appears on the card the user has actually picked —
|
||||
a real nested button (the outer card is a div, not a button, so
|
||||
this doesn't create invalid/ambiguous nested-button behavior). */}
|
||||
{isSelected && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSelect(classModal, isOutbound);
|
||||
}}
|
||||
className="mt-3 w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-gradient-to-r from-[rgb(20,113,76)] to-[rgb(16,95,65)] hover:from-[rgb(16,89,60)] hover:to-[rgb(12,75,50)] text-white font-bold text-sm rounded-xl transition-all shadow-md shadow-primary/30 hover:shadow-lg active:scale-[0.98]"
|
||||
>
|
||||
<span>
|
||||
{isRoundTrip && isOutbound
|
||||
? "Continue to Return Journey"
|
||||
: "Continue to Passenger Details"}
|
||||
</span>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -512,33 +555,6 @@ export default function ResultsPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-5 border-t border-gray-100 dark:border-gray-800 flex-shrink-0 bg-gray-50/50 dark:bg-gray-800/30 flex justify-center">
|
||||
<div className="w-full max-w-md">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (selectedCoachType) {
|
||||
handleSelect(classModal, isOutbound);
|
||||
}
|
||||
}}
|
||||
disabled={!selectedCoachType}
|
||||
className="w-full flex items-center justify-center gap-2.5 px-6 py-3.5 bg-gradient-to-r from-[rgb(20,113,76)] to-[rgb(16,95,65)] hover:from-[rgb(16,89,60)] hover:to-[rgb(12,75,50)] text-white font-bold text-sm rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-lg shadow-primary/30 disabled:shadow-none hover:shadow-xl hover:scale-[1.02] active:scale-[0.98]"
|
||||
>
|
||||
<span>
|
||||
{isRoundTrip && isOutbound
|
||||
? "Continue to Return Journey"
|
||||
: "Continue to Passenger Details"}
|
||||
</span>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</button>
|
||||
{!selectedCoachType && (
|
||||
<p className="text-center text-xs text-gray-500 dark:text-gray-400 mt-3 flex items-center justify-center gap-1">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-gray-400 animate-pulse" />
|
||||
Select a coach type to continue
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes drawer-slide-in{from{transform:translateX(100%)}to{transform:translateX(0)}}
|
||||
|
||||
@@ -315,6 +315,11 @@ export default function SeatsPage() {
|
||||
// first real pick (no fare-change modal) even though the map already has an entry for
|
||||
// them — only a click AFTER that (replacing their own real pick) is an actual change.
|
||||
const restoredIndicesRef = useRef<Set<number>>(new Set());
|
||||
// Set by handleAutoAssign right before it updates passengerSeatMap, so the effect below
|
||||
// can proceed straight to Continue once that state update actually lands — calling
|
||||
// handleContinue() synchronously in the same tick would still see the pre-update
|
||||
// passengerSeatMap/allSeatsAssigned from this render's closure.
|
||||
const autoContinueAfterAssignRef = useRef(false);
|
||||
useEffect(() => {
|
||||
const legKey = `${currentSchedule?.id || ''}-${currentJourneyType}`;
|
||||
if (restoredLegRef.current === legKey) return;
|
||||
@@ -689,10 +694,6 @@ export default function SeatsPage() {
|
||||
() => filteredCoaches.find((c: any) => c.id === selectedCoach),
|
||||
[filteredCoaches, selectedCoach],
|
||||
);
|
||||
const allSeats = useMemo(
|
||||
() => selectedCoachData?.seats || [],
|
||||
[selectedCoachData],
|
||||
);
|
||||
|
||||
const getBedPosition = (selectedClass: string): string | null => {
|
||||
const lowerClass = selectedClass.toLowerCase();
|
||||
@@ -702,59 +703,87 @@ export default function SeatsPage() {
|
||||
return null;
|
||||
};
|
||||
|
||||
const validSeats = useMemo(() => {
|
||||
// If coach has rooms, extract all beds from rooms
|
||||
if (selectedCoachData?.rooms?.length > 0) {
|
||||
const allBeds: any[] = [];
|
||||
selectedCoachData.rooms.forEach((room: any) => {
|
||||
if (room.beds) {
|
||||
allBeds.push(...room.beds);
|
||||
}
|
||||
});
|
||||
// Extracted so it can be applied to ANY coach, not just the one currently expanded —
|
||||
// Auto Assign needs to look across every coach of this type, not just selectedCoachData.
|
||||
const getValidSeatsForCoach = useCallback(
|
||||
(coachData: any): any[] => {
|
||||
if (!coachData) return [];
|
||||
|
||||
let beds = allBeds.filter((s: any) => {
|
||||
// If coach has rooms, extract all beds from rooms
|
||||
if (coachData.rooms?.length > 0) {
|
||||
const allBeds: any[] = [];
|
||||
coachData.rooms.forEach((room: any) => {
|
||||
if (room.beds) {
|
||||
allBeds.push(...room.beds);
|
||||
}
|
||||
});
|
||||
|
||||
let beds = allBeds.filter((s: any) => {
|
||||
const seatLabel = s.label || s.number || s.seatNumber || "";
|
||||
return seatLabel && !seatLabel.startsWith("-");
|
||||
});
|
||||
|
||||
const isBedCoach =
|
||||
coachData.seatClass?.toLowerCase().includes("bed") ||
|
||||
coachData.mode?.toLowerCase().includes("bed");
|
||||
|
||||
if (isBedCoach && currentSchedule?.selectedSeatClass) {
|
||||
const selectedBedPosition = getBedPosition(
|
||||
currentSchedule.selectedSeatClass,
|
||||
);
|
||||
if (selectedBedPosition) {
|
||||
beds = beds.filter((s: any) => s.bedPosition === selectedBedPosition);
|
||||
}
|
||||
}
|
||||
|
||||
return beds;
|
||||
}
|
||||
|
||||
// Fallback to old seat structure
|
||||
let seats = (coachData.seats || []).filter((s: any) => {
|
||||
const seatLabel = s.label || s.number || s.seatNumber || "";
|
||||
return seatLabel && !seatLabel.startsWith("-");
|
||||
});
|
||||
|
||||
const isBedCoach =
|
||||
selectedCoachData?.seatClass?.toLowerCase().includes("bed") ||
|
||||
selectedCoachData?.mode?.toLowerCase().includes("bed");
|
||||
coachData.isBedCoach === true ||
|
||||
seats.some((s: any) => s.bedPosition) ||
|
||||
coachData.seatClass?.toLowerCase().includes("bed") ||
|
||||
coachData.mode?.toLowerCase().includes("bed");
|
||||
|
||||
if (isBedCoach && currentSchedule?.selectedSeatClass) {
|
||||
const selectedBedPosition = getBedPosition(
|
||||
currentSchedule.selectedSeatClass,
|
||||
);
|
||||
if (selectedBedPosition) {
|
||||
beds = beds.filter((s: any) => s.bedPosition === selectedBedPosition);
|
||||
seats = seats.filter((s: any) => s.bedPosition === selectedBedPosition);
|
||||
}
|
||||
}
|
||||
|
||||
return beds;
|
||||
}
|
||||
return seats;
|
||||
},
|
||||
[currentSchedule?.selectedSeatClass],
|
||||
);
|
||||
|
||||
// Fallback to old seat structure
|
||||
let seats = allSeats.filter((s: any) => {
|
||||
const seatLabel = s.label || s.number || s.seatNumber || "";
|
||||
return seatLabel && !seatLabel.startsWith("-");
|
||||
});
|
||||
const isBedCoach =
|
||||
selectedCoachData?.isBedCoach === true ||
|
||||
seats.some((s: any) => s.bedPosition) ||
|
||||
selectedCoachData?.seatClass?.toLowerCase().includes("bed") ||
|
||||
selectedCoachData?.mode?.toLowerCase().includes("bed");
|
||||
const validSeats = useMemo(
|
||||
() => getValidSeatsForCoach(selectedCoachData),
|
||||
[getValidSeatsForCoach, selectedCoachData],
|
||||
);
|
||||
|
||||
if (isBedCoach && currentSchedule?.selectedSeatClass) {
|
||||
const selectedBedPosition = getBedPosition(
|
||||
currentSchedule.selectedSeatClass,
|
||||
);
|
||||
if (selectedBedPosition) {
|
||||
seats = seats.filter((s: any) => s.bedPosition === selectedBedPosition);
|
||||
}
|
||||
}
|
||||
|
||||
return seats;
|
||||
}, [allSeats, selectedCoachData, currentSchedule?.selectedSeatClass]);
|
||||
// Every valid seat across every coach of the current type, tagged with which coach it
|
||||
// belongs to — lets Auto Assign search the whole train (not just the one expanded coach)
|
||||
// and lets handleContinue resolve a passenger's coach label even when their seat came
|
||||
// from a coach they never manually expanded.
|
||||
const allCoachSeats = useMemo(
|
||||
() =>
|
||||
filteredCoaches.flatMap((c: any) =>
|
||||
getValidSeatsForCoach(c).map((s: any) => ({
|
||||
...s,
|
||||
_coachId: c.id,
|
||||
_coachLabel: c.label || c.name || c.coachNumber || c.number || "",
|
||||
})),
|
||||
),
|
||||
[filteredCoaches, getValidSeatsForCoach],
|
||||
);
|
||||
|
||||
// Seats already claimed by any passenger in this journey leg, plus outbound
|
||||
const assignedSeatIds = useMemo(
|
||||
@@ -989,12 +1018,15 @@ export default function SeatsPage() {
|
||||
try {
|
||||
await ensureLegHold(seatIdsForHold);
|
||||
const updatedPassengers = passengers.map((p, i) => {
|
||||
const seatData = validSeats?.find((s: any) => s.id === seatIds[i]);
|
||||
// Look up from allCoachSeats (every coach of this type), not just the currently
|
||||
// expanded one — Auto Assign can place passengers in a coach that was never
|
||||
// manually expanded.
|
||||
const seatData = allCoachSeats?.find((s: any) => s.id === seatIds[i]);
|
||||
return {
|
||||
...p,
|
||||
outboundSeatId: seatIds[i],
|
||||
outboundSeatNumber: seatData ? buildSeatLabel(seatData) : '',
|
||||
outboundCoachNumber: selectedCoachData?.label || selectedCoachData?.name || selectedCoachData?.number || '',
|
||||
outboundCoachNumber: seatData?._coachLabel || selectedCoachData?.label || selectedCoachData?.name || selectedCoachData?.number || '',
|
||||
outboundSeatFareMinor: seatData ? (getSeatFare(seatData) ?? undefined) : undefined,
|
||||
outboundBedPosition: seatData?.bedPosition || undefined,
|
||||
};
|
||||
@@ -1170,13 +1202,16 @@ export default function SeatsPage() {
|
||||
try {
|
||||
await ensureLegHold(seatIdsForHold);
|
||||
const updatedPassengers = passengers.map((p, i) => {
|
||||
const seatData = validSeats?.find((s: any) => s.id === seatIds[i]);
|
||||
// See the outbound branch above — resolve from allCoachSeats so a passenger whose
|
||||
// seat came from a coach Auto Assign picked (but was never manually expanded)
|
||||
// still gets the right coach label.
|
||||
const seatData = allCoachSeats?.find((s: any) => s.id === seatIds[i]);
|
||||
if (isRoundTrip && currentJourneyType === "inbound") {
|
||||
return {
|
||||
...p,
|
||||
inboundSeatId: seatIds[i],
|
||||
inboundSeatNumber: seatData ? buildSeatLabel(seatData) : '',
|
||||
inboundCoachNumber: selectedCoachData?.label || selectedCoachData?.name || selectedCoachData?.number || '',
|
||||
inboundCoachNumber: seatData?._coachLabel || selectedCoachData?.label || selectedCoachData?.name || selectedCoachData?.number || '',
|
||||
inboundSeatFareMinor: seatData ? (getSeatFare(seatData) ?? undefined) : undefined,
|
||||
inboundBedPosition: seatData?.bedPosition || undefined,
|
||||
};
|
||||
@@ -1185,7 +1220,7 @@ export default function SeatsPage() {
|
||||
...p,
|
||||
seatId: seatIds[i],
|
||||
seatNumber: seatData ? buildSeatLabel(seatData) : '',
|
||||
coachNumber: selectedCoachData?.label || selectedCoachData?.name || selectedCoachData?.number || '',
|
||||
coachNumber: seatData?._coachLabel || selectedCoachData?.label || selectedCoachData?.name || selectedCoachData?.number || '',
|
||||
seatFareMinor: seatData ? (getSeatFare(seatData) ?? undefined) : undefined,
|
||||
bedPosition: seatData?.bedPosition || undefined,
|
||||
};
|
||||
@@ -1197,7 +1232,7 @@ export default function SeatsPage() {
|
||||
if (!isRoundTrip && isPackageBooking && packageId) {
|
||||
const firstEligibleIdx = seatEligibleIndices[0];
|
||||
const firstSeatData = firstEligibleIdx != null
|
||||
? validSeats?.find((s: any) => s.id === seatIds[firstEligibleIdx])
|
||||
? allCoachSeats?.find((s: any) => s.id === seatIds[firstEligibleIdx])
|
||||
: null;
|
||||
const berthFare = firstSeatData ? getSeatFare(firstSeatData) : null;
|
||||
if (berthFare != null) {
|
||||
@@ -1228,20 +1263,38 @@ export default function SeatsPage() {
|
||||
router.push("/booking/review");
|
||||
};
|
||||
|
||||
// Fires once the passengerSeatMap update from handleAutoAssign has actually landed (and
|
||||
// every eligible passenger now has a seat) — proceeds straight to Continue so the user
|
||||
// doesn't have to click it separately after auto-assigning.
|
||||
useEffect(() => {
|
||||
if (autoContinueAfterAssignRef.current && allSeatsAssigned) {
|
||||
autoContinueAfterAssignRef.current = false;
|
||||
handleContinue();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [passengerSeatMap, allSeatsAssigned]);
|
||||
|
||||
const handleAutoAssign = () => {
|
||||
const unassignedIndices = seatEligibleIndices.filter((i) => !passengerSeatMap[i]);
|
||||
|
||||
if (unassignedIndices.length === 0) return;
|
||||
|
||||
const availableSeats = (
|
||||
validSeats?.filter((s: any) => s.status === "AVAILABLE") || []
|
||||
).filter((s: any) => !assignedSeatIds.has(s.id));
|
||||
// Search the whole train (every coach of this type), not just whichever one happens to
|
||||
// be expanded — fill the currently-expanded coach first so the result stays visible
|
||||
// without switching coaches, then spill over into other coaches only if needed.
|
||||
const availableAcrossTrain = allCoachSeats
|
||||
.filter((s: any) => s.status === "AVAILABLE" && !assignedSeatIds.has(s.id))
|
||||
.sort((a: any, b: any) => {
|
||||
const aCurrent = a._coachId === selectedCoach ? 0 : 1;
|
||||
const bCurrent = b._coachId === selectedCoach ? 0 : 1;
|
||||
return aCurrent - bCurrent;
|
||||
});
|
||||
|
||||
if (availableSeats.length < unassignedIndices.length) {
|
||||
if (availableAcrossTrain.length < unassignedIndices.length) {
|
||||
setModalState({
|
||||
isOpen: true,
|
||||
title: "Not Enough Seats",
|
||||
message: `Only ${availableSeats.length} seat(s) available in this coach, but you need ${unassignedIndices.length} more seat(s). Please select another coach.`,
|
||||
message: `Only ${availableAcrossTrain.length} seat(s) available across this coach type, but you need ${unassignedIndices.length} more seat(s). Please select another coach type.`,
|
||||
type: "warning",
|
||||
onConfirm: undefined,
|
||||
showCancel: false,
|
||||
@@ -1251,11 +1304,21 @@ export default function SeatsPage() {
|
||||
}
|
||||
|
||||
const next = { ...passengerSeatMap };
|
||||
let lastAssignedCoachId: string | null = null;
|
||||
unassignedIndices.forEach((passengerIndex, offset) => {
|
||||
next[passengerIndex] = availableSeats[offset].id;
|
||||
const seat = availableAcrossTrain[offset];
|
||||
next[passengerIndex] = seat.id;
|
||||
lastAssignedCoachId = seat._coachId;
|
||||
});
|
||||
// Proceed straight to Continue once this update lands — no separate click needed.
|
||||
autoContinueAfterAssignRef.current = true;
|
||||
setPassengerSeatMap(next);
|
||||
setActivePassengerIndex(seatEligibleIndices[seatEligibleIndices.length - 1] ?? 0);
|
||||
// Bring whichever coach the last passenger landed in into view, since seats may now
|
||||
// span coaches beyond the one that was expanded when Auto Assign was clicked.
|
||||
if (lastAssignedCoachId && lastAssignedCoachId !== selectedCoach) {
|
||||
setSelectedCoach(lastAssignedCoachId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackToPassengers = () => {
|
||||
@@ -1857,8 +1920,10 @@ export default function SeatsPage() {
|
||||
}
|
||||
|
||||
const assignedSeatId = passengerSeatMap[i];
|
||||
// allCoachSeats (not validSeats) so a seat Auto Assign placed in a coach the user
|
||||
// never manually expanded still shows correctly here.
|
||||
const assignedSeat = assignedSeatId
|
||||
? validSeats?.find((s: any) => s.id === assignedSeatId)
|
||||
? allCoachSeats?.find((s: any) => s.id === assignedSeatId)
|
||||
: null;
|
||||
const seatLabel = assignedSeat ? buildSeatLabel(assignedSeat) : "";
|
||||
const seatFare = assignedSeat
|
||||
|
||||
Reference in New Issue
Block a user