Added discrepancy management for duplicate seats

This commit is contained in:
Roba Boru
2026-07-17 10:20:35 +03:00
parent 4bc7d0ecf5
commit 610580e15e
8 changed files with 1068 additions and 2 deletions

View File

@@ -0,0 +1,5 @@
import DashboardLayout from '../dashboard/layout';
export default function DiscrepancyLayout({ children }: { children: React.ReactNode }) {
return <DashboardLayout>{children}</DashboardLayout>;
}

View File

@@ -0,0 +1,519 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation } from '@tanstack/react-query';
import { Search, Layers, ChevronDown, ChevronUp, CheckSquare, Square, AlertCircle, CheckCircle2, X, Loader2 } from 'lucide-react';
import { seatsApi } from '@/lib/api';
import { formatDateTime } from '@/lib/utils';
// ── Types ─────────────────────────────────────────────────────────────────
interface DuplicateBooking {
bookingSeatId: string;
bookingId: string;
bookingRef: string;
passengerName: string;
contactPhone: string | null;
createdAt: string;
}
interface DuplicateSeatGroup {
seatId: string;
seatNumber: string;
leg: number;
bookings: DuplicateBooking[];
}
interface AvailableSeat {
seatId: string;
seatNumber: string;
}
interface CoachReport {
coachId: string;
coachNumber: string;
coachTypeName: string;
duplicates: DuplicateSeatGroup[];
availableSeats: AvailableSeat[];
}
interface ScheduleReport {
scheduleId: string;
origin: string;
destination: string;
departureAt: string;
coaches: CoachReport[];
}
interface DuplicatesResponse {
date: string;
schedules: ScheduleReport[];
totalDuplicates: number;
}
// ── Helpers ───────────────────────────────────────────────────────────────
function today() {
return new Date().toISOString().slice(0, 10);
}
function scheduleDuplicateCount(s: ScheduleReport) {
return s.coaches.reduce((sum, c) => sum + c.duplicates.length, 0);
}
// ── Resolve modal ─────────────────────────────────────────────────────────
interface ResolveModalProps {
schedule: ScheduleReport;
coach: CoachReport;
onClose: () => void;
onSuccess: () => void;
}
function ResolveModal({ schedule, coach, onClose, onSuccess }: ResolveModalProps) {
// Default: pre-select all-but-first passenger in every duplicate group
const defaultSelected = new Set<string>(
coach.duplicates.flatMap(g => g.bookings.slice(1).map(b => b.bookingSeatId)),
);
const [selectedSeats, setSelectedSeats] = useState<Set<string>>(defaultSelected);
// Coaches that have at least one available seat (pre-select all)
const coachesWithSeats = schedule.coaches.filter(c => c.availableSeats.length > 0);
const [selectedCoachIds, setSelectedCoachIds] = useState<Set<string>>(
new Set(coachesWithSeats.map(c => c.coachId)),
);
const [successMsg, setSuccessMsg] = useState<string | null>(null);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const mutation = useMutation({
mutationFn: (data: { bookingSeatIds: string[]; coachIds: string[] }) =>
seatsApi.resolveDuplicates(data),
onSuccess: (res) => {
setSuccessMsg(
`${res.resolved ?? 0} passenger(s) successfully reassigned.` +
(res.unresolved > 0 ? ` ${res.unresolved} could not be resolved (no available seat).` : ''),
);
setErrorMsg(null);
onSuccess();
},
onError: (err: any) => {
setErrorMsg(err?.response?.data?.message ?? 'Failed to resolve duplicates.');
},
});
function toggleBookingSeat(id: string) {
setSelectedSeats(prev => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}
function toggleCoach(id: string) {
setSelectedCoachIds(prev => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}
function handleAssign() {
setErrorMsg(null);
if (selectedSeats.size === 0) {
setErrorMsg('Select at least one passenger to reassign.');
return;
}
if (selectedCoachIds.size === 0) {
setErrorMsg('Select at least one coach to source the replacement seat from.');
return;
}
mutation.mutate({
bookingSeatIds: [...selectedSeats],
coachIds: [...selectedCoachIds],
});
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-2xl max-h-[90vh] overflow-y-auto rounded-xl bg-white dark:bg-gray-900 shadow-2xl flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
Resolve Duplicates {coach.coachNumber}
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">
{schedule.origin} {schedule.destination} · {formatDateTime(schedule.departureAt)}
</p>
</div>
<button onClick={onClose} className="p-1 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800">
<X className="w-5 h-5 text-gray-500" />
</button>
</div>
<div className="flex-1 overflow-y-auto px-6 py-5 space-y-6">
{/* Duplicate seat groups */}
<div className="space-y-4">
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">
Duplicate seat assignments
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400">
Check the passengers you want to reassign to a new seat. Unchecked passengers keep their current seat.
</p>
{coach.duplicates.map(group => (
<div
key={`${group.seatId}-${group.leg}`}
className="rounded-lg border border-orange-200 dark:border-orange-800 bg-orange-50 dark:bg-orange-950/30 p-4"
>
<div className="flex items-center gap-2 mb-3">
<AlertCircle className="w-4 h-4 text-orange-500 shrink-0" />
<span className="text-sm font-medium text-orange-800 dark:text-orange-300">
Seat {group.seatNumber} {group.bookings.length} passengers assigned
</span>
</div>
<div className="space-y-2">
{group.bookings.map((b, idx) => {
const checked = selectedSeats.has(b.bookingSeatId);
return (
<label
key={b.bookingSeatId}
className="flex items-start gap-3 cursor-pointer rounded-lg px-3 py-2 hover:bg-orange-100 dark:hover:bg-orange-900/30 transition-colors"
>
<input
type="checkbox"
checked={checked}
onChange={() => toggleBookingSeat(b.bookingSeatId)}
className="mt-0.5 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-gray-900 dark:text-white">
{b.passengerName || '—'}
</span>
<span className="text-xs font-mono bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 px-1.5 py-0.5 rounded">
{b.bookingRef}
</span>
{idx === 0 && (
<span className="text-xs bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 px-1.5 py-0.5 rounded">
earliest
</span>
)}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{b.contactPhone ?? 'No phone'} · Booked {formatDateTime(b.createdAt)}
</div>
</div>
</label>
);
})}
</div>
</div>
))}
</div>
{/* Coach selection */}
<div className="space-y-3">
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">
Reassign to seats in
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400">
The system picks the first available seat in the selected coaches.
</p>
<div className="space-y-2">
{coachesWithSeats.length === 0 ? (
<p className="text-sm text-red-500">No coaches have available seats on this schedule.</p>
) : (
coachesWithSeats.map(c => (
<label
key={c.coachId}
className="flex items-center gap-3 cursor-pointer rounded-lg border border-gray-200 dark:border-gray-700 px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
<input
type="checkbox"
checked={selectedCoachIds.has(c.coachId)}
onChange={() => toggleCoach(c.coachId)}
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<div className="flex-1">
<span className="text-sm font-medium text-gray-900 dark:text-white">
{c.coachNumber}
</span>
<span className="text-xs text-gray-500 dark:text-gray-400 ml-2">
{c.coachTypeName}
</span>
</div>
<span className="text-xs text-green-600 dark:text-green-400 font-medium">
{c.availableSeats.length} available
</span>
</label>
))
)}
</div>
</div>
{/* Feedback */}
{errorMsg && (
<div className="flex items-start gap-2 rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 px-4 py-3">
<AlertCircle className="w-4 h-4 text-red-500 shrink-0 mt-0.5" />
<p className="text-sm text-red-700 dark:text-red-400">{errorMsg}</p>
</div>
)}
{successMsg && (
<div className="flex items-start gap-2 rounded-lg bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-800 px-4 py-3">
<CheckCircle2 className="w-4 h-4 text-green-500 shrink-0 mt-0.5" />
<p className="text-sm text-green-700 dark:text-green-400">{successMsg}</p>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between px-6 py-4 border-t border-gray-200 dark:border-gray-700 gap-3">
<button
onClick={onClose}
className="px-4 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
{successMsg ? 'Close' : 'Cancel'}
</button>
{!successMsg && (
<button
onClick={handleAssign}
disabled={mutation.isPending || selectedSeats.size === 0 || selectedCoachIds.size === 0}
className="flex items-center gap-2 px-5 py-2 text-sm font-medium rounded-lg bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{mutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
Assign {selectedSeats.size > 0 ? `${selectedSeats.size} passenger${selectedSeats.size > 1 ? 's' : ''}` : ''}
</button>
)}
</div>
</div>
</div>
);
}
// ── Coach card ────────────────────────────────────────────────────────────
interface CoachCardProps {
coach: CoachReport;
schedule: ScheduleReport;
onResolve: () => void;
}
function CoachCard({ coach, schedule, onResolve }: CoachCardProps) {
const [expanded, setExpanded] = useState(false);
const hasDuplicates = coach.duplicates.length > 0;
return (
<div className={`rounded-xl border ${hasDuplicates ? 'border-orange-200 dark:border-orange-800' : 'border-gray-200 dark:border-gray-700'} bg-white dark:bg-gray-900 overflow-hidden`}>
{/* Card header */}
<div className="flex items-center gap-4 px-5 py-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-gray-900 dark:text-white">{coach.coachNumber}</span>
<span className="text-sm text-gray-500 dark:text-gray-400">{coach.coachTypeName}</span>
</div>
<div className="flex items-center gap-3 mt-1 text-xs text-gray-500 dark:text-gray-400">
<span>{coach.availableSeats.length} available seats</span>
{hasDuplicates && (
<span className="flex items-center gap-1 text-orange-600 dark:text-orange-400 font-medium">
<AlertCircle className="w-3.5 h-3.5" />
{coach.duplicates.length} duplicate{coach.duplicates.length > 1 ? 's' : ''}
</span>
)}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{hasDuplicates && (
<button
onClick={onResolve}
className="px-3 py-1.5 text-xs font-medium rounded-lg bg-orange-500 text-white hover:bg-orange-600 transition-colors"
>
Resolve
</button>
)}
{hasDuplicates && (
<button
onClick={() => setExpanded(v => !v)}
className="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 transition-colors"
title={expanded ? 'Collapse' : 'View passengers'}
>
{expanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
)}
</div>
</div>
{/* Expanded passenger list */}
{expanded && hasDuplicates && (
<div className="border-t border-gray-100 dark:border-gray-800 divide-y divide-gray-100 dark:divide-gray-800">
{coach.duplicates.map(group => (
<div key={`${group.seatId}-${group.leg}`} className="px-5 py-3">
<p className="text-xs font-semibold text-orange-600 dark:text-orange-400 mb-2">
Seat {group.seatNumber} {group.bookings.length} passengers
</p>
<div className="space-y-2">
{group.bookings.map((b, idx) => (
<div key={b.bookingSeatId} className="flex items-center gap-3 text-sm">
<span className="w-5 h-5 rounded-full bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 text-xs flex items-center justify-center font-medium shrink-0">
{idx + 1}
</span>
<div className="flex-1 min-w-0">
<span className="font-medium text-gray-900 dark:text-white">{b.passengerName || '—'}</span>
<span className="ml-2 text-xs font-mono text-gray-500 dark:text-gray-400">{b.bookingRef}</span>
</div>
<span className="text-xs text-gray-400 dark:text-gray-500 shrink-0">{b.contactPhone ?? '—'}</span>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
);
}
// ── Main page ─────────────────────────────────────────────────────────────
export default function DiscrepancyPage() {
const [date, setDate] = useState(today());
const [searchDate, setSearchDate] = useState('');
const [resolveTarget, setResolveTarget] = useState<{ schedule: ScheduleReport; coach: CoachReport } | null>(null);
const { data, isLoading, isError, refetch } = useQuery<DuplicatesResponse>({
queryKey: ['seat-duplicates', searchDate],
queryFn: () => seatsApi.getDuplicates(searchDate),
enabled: !!searchDate,
});
function handleSearch() {
if (date) setSearchDate(date);
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === 'Enter') handleSearch();
}
return (
<div className="p-6 space-y-6 max-w-5xl mx-auto">
{/* Page header */}
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-orange-100 dark:bg-orange-950/40">
<Layers className="w-6 h-6 text-orange-600 dark:text-orange-400" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Seat Discrepancy</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">
Detect and resolve duplicate seat assignments by schedule date
</p>
</div>
</div>
{/* Date picker */}
<div className="flex items-center gap-3">
<input
type="date"
value={date}
onChange={e => setDate(e.target.value)}
onKeyDown={handleKeyDown}
className="rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
onClick={handleSearch}
disabled={!date || isLoading}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Search className="w-4 h-4" />}
Search
</button>
</div>
{/* Error */}
{isError && (
<div className="flex items-center gap-2 rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 px-4 py-3 text-sm text-red-700 dark:text-red-400">
<AlertCircle className="w-4 h-4 shrink-0" />
Failed to load duplicate seat data. Please try again.
</div>
)}
{/* Summary banner */}
{data && (
<div className={`rounded-xl border px-5 py-4 flex items-center gap-3 ${
data.totalDuplicates > 0
? 'bg-orange-50 dark:bg-orange-950/20 border-orange-200 dark:border-orange-800'
: 'bg-green-50 dark:bg-green-950/20 border-green-200 dark:border-green-800'
}`}>
{data.totalDuplicates > 0 ? (
<AlertCircle className="w-5 h-5 text-orange-500 shrink-0" />
) : (
<CheckCircle2 className="w-5 h-5 text-green-500 shrink-0" />
)}
<span className={`text-sm font-medium ${
data.totalDuplicates > 0
? 'text-orange-800 dark:text-orange-300'
: 'text-green-800 dark:text-green-300'
}`}>
{data.totalDuplicates > 0
? `${data.totalDuplicates} duplicate seat assignment${data.totalDuplicates > 1 ? 's' : ''} found across ${data.schedules.length} schedule${data.schedules.length > 1 ? 's' : ''} on ${data.date}`
: `No duplicate seat assignments found on ${data.date}`}
</span>
</div>
)}
{/* Results per schedule */}
{data?.schedules.map(schedule => (
<div key={schedule.scheduleId} className="space-y-3">
{/* Schedule header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-base font-semibold text-gray-900 dark:text-white">
{schedule.origin} {schedule.destination}
</h2>
<p className="text-xs text-gray-500 dark:text-gray-400">
{formatDateTime(schedule.departureAt)} · {scheduleDuplicateCount(schedule)} duplicate{scheduleDuplicateCount(schedule) !== 1 ? 's' : ''}
</p>
</div>
</div>
{/* Coach cards grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{schedule.coaches.map(coach => (
<CoachCard
key={coach.coachId}
coach={coach}
schedule={schedule}
onResolve={() => setResolveTarget({ schedule, coach })}
/>
))}
</div>
</div>
))}
{/* Empty state when searched but no results */}
{data && data.schedules.length === 0 && data.totalDuplicates === 0 && searchDate && (
<div className="flex flex-col items-center justify-center py-16 text-center">
<CheckCircle2 className="w-12 h-12 text-green-400 mb-3" />
<p className="text-gray-500 dark:text-gray-400">All seats are correctly assigned for {data.date}</p>
</div>
)}
{/* Resolve modal */}
{resolveTarget && (
<ResolveModal
schedule={resolveTarget.schedule}
coach={resolveTarget.coach}
onClose={() => setResolveTarget(null)}
onSuccess={() => {
refetch();
// Keep modal open to show success message; user closes manually
}}
/>
)}
</div>
);
}