Boarding, ticketing, class fare edit updates

This commit is contained in:
Stephanos A
2026-07-02 16:20:15 +03:00
parent b511da2821
commit 9494d57343
15 changed files with 370 additions and 325 deletions

View File

@@ -15,7 +15,7 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro
const canvasRef = useRef<HTMLCanvasElement>(null);
const [isScanning, setIsScanning] = useState(false);
const [isInitializing, setIsInitializing] = useState(false);
const [stream, setStream] = useState<MediaStream | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const [cameraError, setCameraError] = useState<string | null>(null);
const scanIntervalRef = useRef<number | null>(null);
@@ -34,9 +34,9 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro
}
// First, stop any existing stream
if (stream) {
stream.getTracks().forEach(track => track.stop());
setStream(null);
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
}
// Request camera access with simpler fallback
@@ -120,7 +120,7 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro
}
// Set state to show video
setStream(mediaStream);
streamRef.current = mediaStream;
setIsScanning(true);
setIsInitializing(false);
@@ -144,9 +144,9 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro
onError(errorMsg);
// Clean up on error
if (stream) {
stream.getTracks().forEach(track => track.stop());
setStream(null);
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
}
setIsScanning(false);
setIsInitializing(false);
@@ -158,16 +158,16 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro
clearInterval(scanIntervalRef.current);
scanIntervalRef.current = null;
}
if (stream) {
stream.getTracks().forEach(track => track.stop());
setStream(null);
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
}
if (videoRef.current) {
videoRef.current.srcObject = null;
}
setIsScanning(false);
setCameraError(null);
}, [stream]);
}, []);
// QR code scanning with jsqr
const scanFrame = useCallback(() => {
@@ -201,15 +201,19 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro
useEffect(() => {
if (isScanning) {
scanIntervalRef.current = window.setInterval(scanFrame, 100); // Scan every 100ms
}
return () => {
scanIntervalRef.current = window.setInterval(scanFrame, 100);
} else {
if (scanIntervalRef.current) {
clearInterval(scanIntervalRef.current);
scanIntervalRef.current = null;
}
stopCamera();
};
}, [isScanning, scanFrame, stopCamera]);
}
}, [isScanning, scanFrame]);
// Cleanup on unmount only
useEffect(() => {
return () => stopCamera();
}, [stopCamera]);
// Load jsqr from CDN
useEffect(() => {
@@ -294,9 +298,9 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro
</div>
<button
onClick={() => {
if (stream) {
stream.getTracks().forEach(track => track.stop());
setStream(null);
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
}
setIsInitializing(false);
setCameraError('Camera initialization cancelled by user');
@@ -348,7 +352,7 @@ export default function BoardingPage() {
if (result.success) {
setSuccess('Passenger boarded successfully!');
setLastScanned(result.boarding);
setQrInput('');
setQrInput(result.boarding?.ticketNumber || result.boarding?.bookingRef || '');
// Auto-focus for next scan
setTimeout(() => inputRef.current?.focus(), 1000);
} else {
@@ -442,7 +446,13 @@ export default function BoardingPage() {
{/* Camera Scanner */}
<QRScanner
onScan={(data) => {
setQrInput(data);
let displayValue = data;
try {
const parsed = JSON.parse(data);
if (parsed.ticketNumber) displayValue = parsed.ticketNumber;
else if (parsed.ref) displayValue = parsed.ref;
} catch { /* not JSON, use raw */ }
setQrInput(displayValue);
handleScan(data);
}}
onError={setError}
@@ -537,7 +547,7 @@ export default function BoardingPage() {
)}
<div className="text-sm text-green-600 dark:text-green-400 font-mono mt-2">
Booking: {lastScanned.bookingRef} | Ticket: {lastScanned.ticketId}
Booking: {lastScanned.bookingRef} | Ticket: {lastScanned.ticketNumber}
</div>
<div className="text-xs text-green-600 dark:text-green-400 mt-2">
@@ -565,7 +575,7 @@ export default function BoardingPage() {
<li> Tap "Scan QR Code" and point at ticket QR code</li>
<li> Allow camera access when your browser prompts you</li>
<li> Hold phone steady and position QR code within the frame</li>
<li> For manual option, type or paste booking reference</li>
<li> For manual option, type or paste ticket number</li>
<li> Tickets can only be boarded on their departure date</li>
<li> First scan boards outbound leg for round trips</li>
<li> Email & SMS sent automatically to passenger contacts</li>

View File

@@ -75,9 +75,9 @@ export default function ClassesPage() {
coachTypeId: selectedCoachTypeId,
name: formData.get('name') as string,
description: formData.get('description') as string,
baseFareMinor: Math.round(parseFloat(formData.get('baseFareMinor') as string) * 100) || 0,
premiumMinor: Math.round(parseFloat(formData.get('premiumMinor') as string) * 100) || 0,
insuranceFeeMinor: Math.round(parseFloat(formData.get('insuranceFeeMinor') as string) * 100) || 0,
baseFareMinor: Math.round(Number((parseFloat(formData.get('baseFareMinor') as string) * 100).toFixed(10))) || 0,
premiumMinor: Math.round(Number((parseFloat(formData.get('premiumMinor') as string) * 100).toFixed(10))) || 0,
insuranceFeeMinor: Math.round(Number((parseFloat(formData.get('insuranceFeeMinor') as string) * 100).toFixed(10))) || 0,
isActive: formData.get('isActive') === 'true',
};
@@ -129,13 +129,6 @@ export default function ClassesPage() {
label: 'Class Name',
render: (cls: any) => <span className="font-medium">{cls.name}</span>,
},
{
key: 'description',
label: 'Description',
render: (cls: any) => (
<span className="text-sm text-muted-foreground">{cls.description || '-'}</span>
),
},
{
key: 'baseFareMinor',
label: 'Base Fare',
@@ -251,7 +244,7 @@ export default function ClassesPage() {
title={`${editingClass ? 'Edit' : 'Add'} Class`}
size="lg"
>
<form onSubmit={handleSubmit} className="space-y-4">
<form key={editingClass?.id ?? 'new'} onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 gap-4">
<div>
<label className="label">Coach Type *</label>
@@ -265,7 +258,7 @@ export default function ClassesPage() {
<option value="">Select Coach Type</option>
{coachTypesArray.map((ct: any) => (
<option key={ct.id} value={ct.id}>
{ct.code} - {ct.name}
{ct.code} - {ct.name} - {ct.type}
</option>
))}
</select>
@@ -283,17 +276,6 @@ export default function ClassesPage() {
/>
</div>
<div>
<label className="label">Description</label>
<textarea
name="description"
className="input"
rows={3}
defaultValue={editingClass?.description || ''}
placeholder="Describe this class..."
/>
</div>
<div className="border-t pt-4">
<h3 className="font-semibold text-foreground mb-4">Pricing Configuration</h3>
@@ -345,8 +327,8 @@ export default function ClassesPage() {
<div className="mt-4 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded text-sm text-blue-800 dark:text-blue-200">
<p className="font-medium mb-1">Total Fare Calculation:</p>
<p>Total = (Base Fare × Distance) + Premium + Insurance</p>
<p className="mt-2 text-xs"> Premium applies per passenger (including free child)</p>
<p className="text-xs"> Insurance applies per passenger (including free child)</p>
<p className="mt-2 text-xs"> Premium applies per passenger</p>
<p className="text-xs"> Insurance applies per passenger</p>
</div>
</div>

View File

@@ -731,7 +731,7 @@ export default function CoachesPage() {
<option value="">Select Coach Type</option>
{coachTypesArray.map((ct: any) => (
<option key={ct.id} value={ct.id}>
{ct.code} - {ct.name}
{ct.code} - {ct.name} - {ct.type}
</option>
))}
</select>

View File

@@ -34,7 +34,7 @@ interface SeatClass {
}
export default function PricingPage() {
const [tab, setTab] = useState<'schedule' | 'segment' | 'baggage'>('schedule');
const [tab, setTab] = useState<'segment' | 'schedule' | 'baggage'>('segment');
const [showModal, setShowModal] = useState(false);
const [selectedSchedule, setSelectedSchedule] = useState<string>('');
const [selectedRoute, setSelectedRoute] = useState<string>('');
@@ -103,7 +103,7 @@ export default function PricingPage() {
queryFn: async () => {
if (!selectedSchedule) return [];
try {
const response = await apiClient.get(`/schedules/${selectedSchedule}/fares/all`);
const response = await apiClient.get(`/schedules/${selectedSchedule}/fares/stored`);
return Array.isArray(response) ? response : (response as any)?.data || [];
} catch (err: any) {
const errMsg = err.response?.data?.message || err.message || 'Failed to load fares';
@@ -175,7 +175,7 @@ export default function PricingPage() {
});
const updateFareMutation = useMutation({
mutationFn: (data: any) => apiClient.patch(`/schedules/fares/${data.id}`, data),
mutationFn: ({ id, ...data }: any) => apiClient.patch(`/schedules/fares/${id}`, data),
onSuccess: () => {
refetchFares();
setEditingFare(null);
@@ -264,10 +264,12 @@ export default function PricingPage() {
};
const handleEditFare = (fare: any) => {
setEditingFare(fare);
// Engine-calculated fares have no `id` — open as new rule pre-filled with engine values
setEditingFare(fare.id ? fare : null);
const minor = fare.totalMinor ?? fare.baseFareMinor ?? fare.baseFare ?? 0;
setFareForm({
seatClassId: fare.seatClassId || '',
baseFare: (fare.baseFare || fare.baseFareMinor || 0).toString(),
baseFare: (minor / 100).toFixed(2),
nationality: fare.nationality || '',
passengerCategory: fare.passengerCategory || '',
route: fare.route || '',
@@ -283,12 +285,12 @@ export default function PricingPage() {
const routeStops = currentRoute?.stops || [];
const originStop = routeStops.find((s: any) => s.sequence === fare.originStopSequence);
const destStop = routeStops.find((s: any) => s.sequence === fare.destinationStopSequence);
setSegmentForm({
seatClassId: fare.seatClassId || '',
originStationId: originStop?.stationId || '',
destinationStationId: destStop?.stationId || '',
baseFare: (fare.baseFare || fare.baseFareMinor || 0).toString(),
baseFare: ((fare.baseFare || fare.baseFareMinor || 0) / 100).toFixed(2),
nationality: fare.nationality || '',
passengerCategory: fare.passengerCategory || '',
validFrom: fare.validFrom ? new Date(fare.validFrom).toISOString().split('T')[0] : new Date().toISOString().split('T')[0],
@@ -305,7 +307,7 @@ export default function PricingPage() {
return;
}
const baseFareMinor = parseInt(fareForm.baseFare, 10);
const baseFareMinor = Math.round(parseFloat(fareForm.baseFare) * 100);
if (editingFare) {
await updateFareMutation.mutateAsync({
@@ -353,7 +355,7 @@ export default function PricingPage() {
return;
}
const baseFareMinor = parseInt(segmentForm.baseFare, 10);
const baseFareMinor = Math.round(parseFloat(segmentForm.baseFare) * 100);
if (editingFare) {
await updateSegmentFareMutation.mutateAsync({
@@ -422,9 +424,9 @@ export default function PricingPage() {
key: 'baseFare',
label: 'Fare (ETB)',
render: (fare: any) => {
const fareValue = fare.baseFare || fare.baseFareMinor;
if (!fareValue && fareValue !== 0) return <span>N/A</span>;
return <span className="font-mono font-medium">{fareValue} ETB</span>;
const minor = fare.baseFareMinor ?? fare.baseFare;
if (minor == null) return <span className="text-muted-foreground"></span>;
return <span className="font-mono font-medium">{(minor / 100).toFixed(2)} </span>;
},
},
{
@@ -438,7 +440,7 @@ export default function PricingPage() {
key: 'route',
label: 'Route',
render: (fare: any) => (
<span className="text-sm font-mono">{fare.route || '-'}</span>
<span className="text-sm font-mono">{fare.routeCode || fare.route || '-'}</span>
),
},
{
@@ -467,9 +469,11 @@ export default function PricingPage() {
const stops = currentRoute?.stops || [];
const originStop = stops.find((s: any) => s.sequence === fare.originStopSequence);
const destStop = stops.find((s: any) => s.sequence === fare.destinationStopSequence);
const originCode = stationsArray.find((s: any) => s.id === originStop?.stationId)?.code ?? `Stop ${fare.originStopSequence}`;
const destCode = stationsArray.find((s: any) => s.id === destStop?.stationId)?.code ?? `Stop ${fare.destinationStopSequence}`;
return (
<span className="text-sm font-medium">
Stop {fare.originStopSequence} {fare.destinationStopSequence}
<span className="text-sm font-medium font-mono">
{originCode} {destCode}
</span>
);
},
@@ -479,7 +483,7 @@ export default function PricingPage() {
label: 'Seat Class',
render: (fare: any) => {
const className = fare.seatClass?.name || 'N/A';
return <span className="font-medium">{className}</span>;
return <span>{className}</span>;
},
},
{
@@ -493,9 +497,9 @@ export default function PricingPage() {
key: 'baseFare',
label: 'Fare (ETB)',
render: (fare: any) => {
const fareValue = fare.baseFare || fare.baseFareMinor;
if (!fareValue && fareValue !== 0) return <span>N/A</span>;
return <span className="font-mono font-medium">{fareValue} ETB</span>;
const fareValue = fare.baseFare ?? fare.baseFareMinor;
if (fareValue == null) return <span className="text-muted-foreground"></span>;
return <span className="font-mono font-medium">{(fareValue / 100).toFixed(2)} </span>;
},
},
{
@@ -529,14 +533,12 @@ export default function PricingPage() {
onClick: tab === 'schedule' ? handleEditFare : handleEditSegmentFare,
variant: 'secondary' as const,
icon: Edit,
disabled: tab === 'schedule', // Schedule fares are computed, not stored
},
{
label: 'Delete',
onClick: (fare: any) => setDeleteConfirm({ isOpen: true, id: fare.id }),
variant: 'danger' as const,
icon: Trash2,
disabled: tab === 'schedule', // Schedule fares are computed, not stored
},
];
@@ -588,89 +590,30 @@ export default function PricingPage() {
<div className="card">
<div className="flex gap-4 border-b mb-6">
<button
onClick={() => {
setTab('schedule');
setError(null);
}}
className={`px-4 py-2 font-medium border-b-2 transition-colors ${
tab === 'schedule' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground'
}`}
>
Schedule Fares
</button>
<button
onClick={() => { setTab('segment'); setError(null); }}
className={`px-4 py-2 font-medium border-b-2 transition-colors ${
tab === 'segment' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground'
}`}
className={`px-4 py-2 font-medium border-b-2 transition-colors ${tab === 'segment' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground'
}`}
>
Segment Fares
</button>
<button
onClick={() => { setTab('schedule'); setError(null); }}
className={`px-4 py-2 font-medium border-b-2 transition-colors ${tab === 'schedule' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground'
}`}
>
Schedule Fares
</button>
<button
onClick={() => { setTab('baggage'); setError(null); }}
className={`px-4 py-2 font-medium border-b-2 transition-colors ${
tab === 'baggage' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground'
}`}
className={`px-4 py-2 font-medium border-b-2 transition-colors ${tab === 'baggage' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground'
}`}
>
Excess Baggage Rates
</button>
</div>
<div className="space-y-6">
{tab === 'schedule' && (
<>
<div>
<label className="label">Select Schedule</label>
<select
value={selectedSchedule}
onChange={(e) => {
setSelectedSchedule(e.target.value);
setError(null);
}}
className="input w-full max-w-md"
>
<option value="">Choose a schedule...</option>
{schedulesArray.map((schedule: Schedule) => (
<option key={schedule.id} value={schedule.id}>
{schedule.train?.name} ({schedule.train?.number}) - {schedule.originStation?.name} to {schedule.destinationStation?.name} ({new Date(schedule.departureAt).toLocaleDateString()})
</option>
))}
</select>
</div>
{selectedSchedule && (
<div>
<h3 className="text-lg font-semibold mb-4">Calculated Fares</h3>
<div className="mb-4 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded text-sm text-blue-800 dark:text-blue-200">
These are <strong>dynamically calculated</strong> fares based on the fare engine. To create custom override fares, click "Add Fare Rule" above.
</div>
{faresLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : faresArray.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No fares available for this schedule.
</div>
) : (
<>
<div className="mb-4 p-3 bg-muted/50 rounded text-sm text-muted-foreground">
{faresArray.length} seat class(es) available
</div>
<DataTable
data={faresArray}
columns={fareColumns}
actions={fareActions}
loading={false}
emptyMessage="No fares available."
/>
</>
)}
</div>
)}
</>
)}
{tab === 'segment' && (
<>
@@ -722,6 +665,61 @@ export default function PricingPage() {
)}
</>
)}
{tab === 'schedule' && (
<>
<div>
<label className="label">Select Schedule</label>
<select
value={selectedSchedule}
onChange={(e) => {
setSelectedSchedule(e.target.value);
setError(null);
}}
className="input w-full max-w-md"
>
<option value="">Choose a schedule...</option>
{schedulesArray.map((schedule: Schedule) => (
<option key={schedule.id} value={schedule.id}>
{schedule.train?.name} ({schedule.train?.number}) - {schedule.originStation?.name} to {schedule.destinationStation?.name} ({new Date(schedule.departureAt).toLocaleDateString()})
</option>
))}
</select>
</div>
{selectedSchedule && (
<div>
<h3 className="text-lg font-semibold mb-4">Stored Fare Rules</h3>
{faresLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : faresArray.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No fare rules defined for this schedule. Click "Add Fare Rule" to create one.
</div>
) : (
<>
<div className="mb-4 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded text-sm text-blue-800 dark:text-blue-200">
These are <strong>stored fare override rules</strong> for this schedule. Click "Add Fare Rule" to create one. If no rules exist, the fare engine calculates fares automatically.
</div>
<div className="mb-4 p-3 bg-muted/50 rounded text-sm text-muted-foreground">
{faresArray.length} fare rule(s) defined
</div>
<DataTable
data={faresArray}
columns={fareColumns}
actions={fareActions}
loading={false}
emptyMessage="No fares available."
/>
</>
)}
</div>
)}
</>
)}
{tab === 'baggage' && (
<>
{allowancesLoading ? (
@@ -736,7 +734,7 @@ export default function PricingPage() {
columns={[
{ key: 'seatClass', label: 'Seat Class', render: (a: any) => <span className="font-medium">{a.seatClass?.name ?? a.seatClassId}</span> },
{ key: 'maxWeightKg', label: 'Free Allowance', render: (a: any) => <span>{a.maxWeightKg} kg, {a.maxPiecesCount} pcs</span> },
{ key: 'excessFeePerKg', label: 'Excess Fee / kg', render: (a: any) => <span className="font-mono font-semibold">{(a.excessFeePerKg / 100).toFixed(2)} ETB</span> },
{ key: 'excessFeePerKg', label: 'Excess Fee / kg', render: (a: any) => <span className="font-mono font-semibold">{(a.excessFeePerKg / 100).toFixed(2)} </span> },
]}
actions={[
{
@@ -771,10 +769,10 @@ export default function PricingPage() {
<h3 className="font-semibold text-blue-900 dark:text-blue-200 mb-3">Pricing Structure</h3>
<ul className="text-sm text-blue-800 dark:text-blue-300 space-y-2">
<li>
<strong>Schedule Fares:</strong> Set custom pricing for each schedule by seat class and passenger type
<strong>Segment Fares:</strong> Set fares for specific stop-to-stop segments (e.g., Addis Dire Dawa)
</li>
<li>
<strong>Segment Fares:</strong> Set fares for specific stop-to-stop segments (e.g., Addis Dire Dawa)
<strong>Schedule Fares:</strong> Set custom pricing for each schedule by seat class and passenger type
</li>
<li>
<strong>Passenger Type:</strong> ADULT (5+ years) or CHILD (&lt;5) first child travels free, subsequent children pay full fare
@@ -876,11 +874,11 @@ export default function PricingPage() {
<input
type="number"
min="0"
step="1"
step="0.01"
value={fareForm.baseFare}
onChange={(e) => setFareForm({ ...fareForm, baseFare: e.target.value })}
className="input w-full"
placeholder="e.g., 350"
placeholder="e.g., 350.00"
required
/>
</div>
@@ -1006,11 +1004,11 @@ export default function PricingPage() {
<input
type="number"
min="0"
step="1"
step="0.01"
value={segmentForm.baseFare}
onChange={(e) => setSegmentForm({ ...segmentForm, baseFare: e.target.value })}
className="input w-full"
placeholder="e.g., 150"
placeholder="e.g., 150.00"
required
/>
</div>
@@ -1092,8 +1090,8 @@ export default function PricingPage() {
</div>
<div className="flex gap-2 justify-end pt-4">
<ActionButton
variant="secondary"
<ActionButton
variant="secondary"
onClick={() => {
if (tab === 'schedule') resetForm();
else resetSegmentForm();
@@ -1102,8 +1100,8 @@ export default function PricingPage() {
>
Cancel
</ActionButton>
<ActionButton
onClick={tab === 'schedule' ? handleSaveFare : handleSaveSegmentFare}
<ActionButton
onClick={tab === 'schedule' ? handleSaveFare : handleSaveSegmentFare}
loading={
tab === 'schedule'
? createFareMutation.isPending || updateFareMutation.isPending

View File

@@ -84,21 +84,18 @@ export default function RoutesPage() {
// Keep current stop order (already rearranged by user)
const sortedMiddleStops = stops;
// Calculate distanceKm (distance from previous stop)
// distanceKm = cumulative distance from origin (fare engine uses destStop.distanceKm - originStop.distanceKm)
const stopsArray = [
{ stationId: originStationId, sequence: 1, distanceKm: 0 },
...sortedMiddleStops.map((stop, idx) => {
const prevDistance = idx === 0 ? 0 : (sortedMiddleStops[idx - 1].distanceFromOrigin || 0);
return {
stationId: stop.stationId,
sequence: idx + 2,
distanceKm: (stop.distanceFromOrigin || 0) - prevDistance,
};
}),
...sortedMiddleStops.map((stop, idx) => ({
stationId: stop.stationId,
sequence: idx + 2,
distanceKm: stop.distanceFromOrigin || 0,
})),
{
stationId: destinationStationId,
sequence: sortedMiddleStops.length + 2,
distanceKm: (destinationDistance || 0) - (sortedMiddleStops.length > 0 ? (sortedMiddleStops[sortedMiddleStops.length - 1].distanceFromOrigin || 0) : 0),
distanceKm: destinationDistance || 0,
},
];
@@ -218,19 +215,14 @@ export default function RoutesPage() {
setOriginStationId(routeStops[0].stationId);
setDestinationStationId(routeStops[routeStops.length - 1].stationId);
// Last stop's distanceKm is segment distance from previous stop, so accumulate
let cumulative = 0;
const allStops = routeStops.map((stop: any) => {
cumulative += stop.distanceKm || 0;
return { ...stop, _cumulative: cumulative };
});
setDestinationDistance(allStops[allStops.length - 1]._cumulative);
// distanceKm is cumulative from origin — read directly
setDestinationDistance(routeStops[routeStops.length - 1].distanceKm || 0);
const middleStops = routeStops.slice(1, -1).map((stop: any, idx: number) => ({
const middleStops = routeStops.slice(1, -1).map((stop: any) => ({
stationId: stop.stationId,
sequence: stop.sequence,
distanceKm: stop.distanceKm,
distanceFromOrigin: allStops[idx + 1]._cumulative,
distanceFromOrigin: stop.distanceKm || 0,
}));
setStops(middleStops);
}

View File

@@ -761,9 +761,12 @@ export default function TicketsPage() {
<p className="text-emerald-100 text-xs font-semibold uppercase tracking-widest mb-1">Ticket Number</p>
<p className="text-white text-3xl font-mono font-bold tracking-wider">{t.ticketNumber || '—'}</p>
</div>
<div className="text-right shrink-0">
<div className="text-right shrink-0 flex flex-col items-end gap-1">
<Badge variant="status" status={t.status || 'ACTIVE'}>{t.status || 'ACTIVE'}</Badge>
{t.validatedAt && <p className="text-emerald-200 text-xs mt-1">Validated {formatDateTime(t.validatedAt)}</p>}
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${t.qrCode ? 'bg-emerald-200 text-emerald-900' : 'bg-white/20 text-white/60'}`}>
{t.qrCode ? '✓ QR Available' : 'No QR'}
</span>
{t.validatedAt && <p className="text-emerald-200 text-xs">Validated {formatDateTime(t.validatedAt)}</p>}
</div>
</div>
<div className="mt-4 grid grid-cols-3 gap-3">
@@ -835,13 +838,29 @@ export default function TicketsPage() {
</section>
)}
{/* QR Code */}
{t.qrCode && (
<section>
<SectionHeader title="QR Code" />
<div className="flex justify-center">
<div className="bg-white p-4 rounded-xl border border-muted inline-block">
<img
src={t.qrCode.startsWith('data:') ? t.qrCode : `data:image/png;base64,${t.qrCode}`}
alt={`QR Code for ${t.ticketNumber}`}
className="w-48 h-48 object-contain"
/>
<p className="text-center text-xs text-muted-foreground mt-2 font-mono">{t.ticketNumber}</p>
</div>
</div>
</section>
)}
{/* Validation */}
<section>
<SectionHeader title="Validation & Timestamps" />
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<Field label="Validated At" value={t.validatedAt ? formatDateTime(t.validatedAt) : 'Not validated'} />
<Field label="Boarded At" value={t.boardedAt ? formatDateTime(t.boardedAt) : 'Not boarded'} />
<Field label="QR Code" value={t.qrCode ? 'Generated' : 'N/A'} />
<Field label="Created" value={formatDateTime(t.createdAt)} />
<Field label="Last Updated" value={formatDateTime(t.updatedAt)} />
<Field label="Ticket ID" value={t.id} mono truncate />

View File

@@ -149,7 +149,7 @@ export default function DataTable<T extends Record<string, any>>({
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
{sortedData.map((item, index) => (
<tr
key={item.id || index}
key={item.id ?? index}
onClick={() => onRowClick?.(item)}
className={cn(
'transition-colors',
@@ -186,15 +186,16 @@ export default function DataTable<T extends Record<string, any>>({
return (
<button
ref={(el) => { buttonRefs.current[item.id] = el; }}
ref={(el) => { buttonRefs.current[item.id ?? index] = el; }}
onClick={(e) => {
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
setDropdownPosition({
top: rect.bottom + window.scrollY,
left: rect.right + window.scrollX - 192, // 192px = w-48
left: rect.right + window.scrollX - 192,
});
setExpandedActions(expandedActions === item.id ? null : item.id);
const key = item.id ?? String(index);
setExpandedActions(expandedActions === key ? null : key);
}}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
@@ -229,9 +230,9 @@ export default function DataTable<T extends Record<string, any>>({
>
<div className="py-1">
{actions
?.filter(action => !action.show || action.show(sortedData.find(item => item.id === expandedActions)!))
?.filter(action => !action.show || action.show(sortedData.find((item, i) => (item.id ?? String(i)) === expandedActions)!))
.map((action, actionIndex) => {
const item = sortedData.find(item => item.id === expandedActions);
const item = sortedData.find((item, i) => (item.id ?? String(i)) === expandedActions);
if (!item) return null;
return (
<button