mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 05:18:11 +00:00
1106 lines
44 KiB
TypeScript
1106 lines
44 KiB
TypeScript
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
import { seatsApi, schedulesApi, fleetApi, routeCoachTemplatesApi } from '@/lib/api';
|
|
import { routesApi } from '@/lib/api/routes';
|
|
import Modal from '@/components/ui/Modal';
|
|
import ActionButton from '@/components/ui/ActionButton'
|
|
import { Armchair, Lock, Unlock, Bed, X, RotateCcw, ChevronDown, Train, Wrench } from 'lucide-react';
|
|
|
|
export default function SeatsPage() {
|
|
const [activeTab, setActiveTab] = useState<'route' | 'schedule'>('route');
|
|
const [selectedSchedule, setSelectedSchedule] = useState('');
|
|
const [selectedRoute, setSelectedRoute] = useState('');
|
|
const [expandedCoaches, setExpandedCoaches] = useState<Set<string>>(new Set());
|
|
const [showBlockModal, setShowBlockModal] = useState(false);
|
|
const [showRemoveModal, setShowRemoveModal] = useState(false);
|
|
const [selectedSeat, setSelectedSeat] = useState<any>(null);
|
|
const [blockReason, setBlockReason] = useState('');
|
|
const [showBlockCoachModal, setShowBlockCoachModal] = useState(false);
|
|
const [selectedCoach, setSelectedCoach] = useState<any>(null);
|
|
const [blockCoachReason, setBlockCoachReason] = useState('');
|
|
const [showUnblockCoachModal, setShowUnblockCoachModal] = useState(false);
|
|
const [coachToUnblock, setCoachToUnblock] = useState<any>(null);
|
|
const [showMaintenanceModal, setShowMaintenanceModal] = useState(false);
|
|
const [maintenanceReason, setMaintenanceReason] = useState('');
|
|
const queryClient = useQueryClient();
|
|
|
|
const { data: schedulesData } = useQuery({
|
|
queryKey: ['schedules'],
|
|
queryFn: () => schedulesApi.getAll(),
|
|
});
|
|
|
|
const { data: seatMapData, isLoading } = useQuery({
|
|
queryKey: ['seatmap', selectedSchedule],
|
|
queryFn: () => selectedSchedule ? seatsApi.getSeatMap(selectedSchedule) : Promise.resolve(null),
|
|
enabled: !!selectedSchedule,
|
|
staleTime: 0,
|
|
});
|
|
|
|
const { data: coachTypesData } = useQuery({
|
|
queryKey: ['coachTypes'],
|
|
queryFn: () => fleetApi.getCoaches(),
|
|
});
|
|
|
|
const { data: routesData } = useQuery({
|
|
queryKey: ['routes'],
|
|
queryFn: () => routesApi.getAll(),
|
|
});
|
|
|
|
const { data: routeCoachesData, isLoading: routeCoachesLoading } = useQuery({
|
|
queryKey: ['routeCoaches', selectedRoute],
|
|
staleTime: 0,
|
|
queryFn: async () => {
|
|
if (!selectedRoute) return null;
|
|
const template: any[] = await routeCoachTemplatesApi.get(selectedRoute);
|
|
if (template?.length) {
|
|
const fullCoaches = await Promise.all(
|
|
template.map((entry: any) => fleetApi.getCoach(entry.coachId ?? entry.coach?.id))
|
|
);
|
|
return fullCoaches.map((coach: any, i: number) => ({
|
|
...coach,
|
|
coachNumber: coach.number,
|
|
positionNumber: template[i].positionNumber,
|
|
seatArrangement: coach.arrangement,
|
|
}));
|
|
}
|
|
// No template — fetch coaches from the most recent schedule for this route
|
|
const schedules: any = await schedulesApi.getAll({ routeId: selectedRoute });
|
|
const scheduleList: any[] = schedules?.items || schedules?.data || (Array.isArray(schedules) ? schedules : []);
|
|
if (!scheduleList.length) return [];
|
|
const latestSchedule = scheduleList[scheduleList.length - 1];
|
|
const seatMap: any = await seatsApi.getSeatMap(latestSchedule.id);
|
|
return (seatMap?.coaches || []).map((coach: any, i: number) => ({
|
|
...coach,
|
|
coachNumber: coach.number ?? coach.coachNumber,
|
|
positionNumber: coach.positionNumber ?? i + 1,
|
|
seatArrangement: coach.arrangement ?? coach.seatArrangement,
|
|
}));
|
|
},
|
|
enabled: !!selectedRoute,
|
|
});
|
|
|
|
const invalidateSeatData = () => {
|
|
queryClient.refetchQueries({ queryKey: ['seatmap', selectedSchedule] });
|
|
queryClient.refetchQueries({ queryKey: ['routeCoaches', selectedRoute] });
|
|
};
|
|
|
|
const blockMutation = useMutation({
|
|
mutationFn: ({ seatId, reason }: any) => seatsApi.block(seatId, { reason }),
|
|
onSuccess: () => {
|
|
invalidateSeatData();
|
|
setShowBlockModal(false);
|
|
setSelectedSeat(null);
|
|
setBlockReason('');
|
|
},
|
|
});
|
|
|
|
const unblockMutation = useMutation({
|
|
mutationFn: (seatId: string) => seatsApi.unblock(seatId),
|
|
onSuccess: () => {
|
|
invalidateSeatData();
|
|
},
|
|
});
|
|
|
|
const removeSeatMutation = useMutation({
|
|
mutationFn: (seatId: string) => seatsApi.removeSeat(seatId),
|
|
onSuccess: () => {
|
|
invalidateSeatData();
|
|
setShowRemoveModal(false);
|
|
setSelectedSeat(null);
|
|
},
|
|
});
|
|
|
|
const undoRemoveMutation = useMutation({
|
|
mutationFn: (seatId: string) => seatsApi.undoRemove(seatId),
|
|
onSuccess: () => {
|
|
invalidateSeatData();
|
|
},
|
|
});
|
|
|
|
const maintenanceMutation = useMutation({
|
|
mutationFn: ({ seatId, reason }: { seatId: string; reason: string }) =>
|
|
seatsApi.setMaintenance(seatId, reason),
|
|
onSuccess: () => {
|
|
invalidateSeatData();
|
|
setShowMaintenanceModal(false);
|
|
setSelectedSeat(null);
|
|
setMaintenanceReason('');
|
|
},
|
|
});
|
|
|
|
const clearMaintenanceMutation = useMutation({
|
|
mutationFn: (seatId: string) => seatsApi.clearMaintenance(seatId),
|
|
onSuccess: () => invalidateSeatData(),
|
|
});
|
|
|
|
const schedules = schedulesData?.items || schedulesData?.data || [];
|
|
const routes = routesData?.items || routesData?.data || [];
|
|
const coaches = activeTab === 'schedule' ? (seatMapData?.coaches || []) : (Array.isArray(routeCoachesData) ? routeCoachesData : []);
|
|
|
|
const blockCoachMutation = useMutation({
|
|
mutationFn: async ({ coachId, reason }: any) => {
|
|
const coachSeats = coaches.find((c: any) => c.id === coachId)?.seats || [];
|
|
const seatIds = coachSeats.map((s: any) => s.id).filter((id: any) => id);
|
|
return Promise.all(seatIds.map((seatId: string) => seatsApi.block(seatId, { reason })));
|
|
},
|
|
onSuccess: () => {
|
|
invalidateSeatData();
|
|
setShowBlockCoachModal(false);
|
|
setSelectedCoach(null);
|
|
setBlockCoachReason('');
|
|
},
|
|
});
|
|
|
|
const unblockCoachMutation = useMutation({
|
|
mutationFn: async ({ coachId }: any) => {
|
|
const coachSeats = coaches.find((c: any) => c.id === coachId)?.seats || [];
|
|
const seatIds = coachSeats.map((s: any) => s.id).filter((id: any) => id);
|
|
return Promise.all(seatIds.map((seatId: string) => seatsApi.unblock(seatId)));
|
|
},
|
|
onSuccess: () => {
|
|
invalidateSeatData();
|
|
setShowUnblockCoachModal(false);
|
|
setCoachToUnblock(null);
|
|
},
|
|
});
|
|
|
|
const toggleCoach = (coachId: string) => {
|
|
const newExpanded = new Set(expandedCoaches);
|
|
if (newExpanded.has(coachId)) {
|
|
newExpanded.delete(coachId);
|
|
} else {
|
|
newExpanded.add(coachId);
|
|
}
|
|
setExpandedCoaches(newExpanded);
|
|
};
|
|
|
|
const handleBlock = (seat: any) => {
|
|
setSelectedSeat(seat);
|
|
setShowBlockModal(true);
|
|
};
|
|
|
|
const handleUnblock = async (seat: any) => {
|
|
if (confirm('Are you sure you want to unblock this seat?')) {
|
|
await unblockMutation.mutateAsync(seat.id);
|
|
}
|
|
};
|
|
|
|
const handleRemoveSeat = (seat: any) => {
|
|
setSelectedSeat(seat);
|
|
setShowRemoveModal(true);
|
|
};
|
|
|
|
const handleUndoRemove = async (seat: any) => {
|
|
if (confirm('Restore this removed seat?')) {
|
|
await undoRemoveMutation.mutateAsync(seat.id);
|
|
}
|
|
};
|
|
|
|
const handleSetMaintenance = (seat: any) => {
|
|
setSelectedSeat(seat);
|
|
setShowMaintenanceModal(true);
|
|
};
|
|
|
|
const handleClearMaintenance = async (seat: any) => {
|
|
if (confirm('Clear maintenance status for this seat?')) {
|
|
await clearMaintenanceMutation.mutateAsync(seat.id);
|
|
}
|
|
};
|
|
|
|
const handleBlockCoach = (coach: any) => {
|
|
setSelectedCoach(coach);
|
|
setShowBlockCoachModal(true);
|
|
};
|
|
|
|
const handleUnblockCoach = (coach: any) => {
|
|
const isBlocked = coach.seats?.some((s: any) => s.status === 'BLOCKED' || s.isBlocked);
|
|
if (isBlocked) {
|
|
setCoachToUnblock(coach);
|
|
setShowUnblockCoachModal(true);
|
|
}
|
|
};
|
|
|
|
const confirmUnblockCoach = async () => {
|
|
if (coachToUnblock) {
|
|
await unblockCoachMutation.mutateAsync({ coachId: coachToUnblock.id });
|
|
}
|
|
};
|
|
|
|
const isCoachBlocked = (coach: any) => {
|
|
const seats = (coach.seats || []).filter((s: any) => s.seatNumber && !s.seatNumber.startsWith('-'));
|
|
return seats.length > 0 && seats.every((s: any) => s.status === 'BLOCKED' || s.isBlocked);
|
|
};
|
|
|
|
const isCoachUnblocked = (coach: any) => {
|
|
const seats = (coach.seats || []).filter((s: any) => s.seatNumber && !s.seatNumber.startsWith('-'));
|
|
return seats.length > 0 && seats.every((s: any) => s.status !== 'BLOCKED' && !s.isBlocked);
|
|
};
|
|
|
|
const submitBlockCoach = async () => {
|
|
if (!blockCoachReason.trim()) {
|
|
alert('Please provide a reason for blocking');
|
|
return;
|
|
}
|
|
await blockCoachMutation.mutateAsync({ coachId: selectedCoach.id, reason: blockCoachReason });
|
|
};
|
|
|
|
const submitBlock = async () => {
|
|
if (!blockReason.trim()) {
|
|
alert('Please provide a reason for blocking');
|
|
return;
|
|
}
|
|
await blockMutation.mutateAsync({ seatId: selectedSeat.id, reason: blockReason });
|
|
};
|
|
|
|
const submitRemoveSeat = async () => {
|
|
await removeSeatMutation.mutateAsync(selectedSeat.id);
|
|
};
|
|
|
|
const getSeatStatus = (seat: any) => {
|
|
if (seat.status === 'UNDER_MAINTENANCE') return 'UNDER_MAINTENANCE';
|
|
if (seat.status === 'BLOCKED' || seat.isBlocked) return 'BLOCKED';
|
|
if (seat.status === 'BOOKED' || seat.isBooked) return 'BOOKED';
|
|
if (seat.status === 'HELD') return 'HELD';
|
|
return 'AVAILABLE';
|
|
};
|
|
|
|
const getSeatColor = (status: string) => {
|
|
switch (status) {
|
|
case 'AVAILABLE': return 'bg-green-500';
|
|
case 'BOOKED': return 'bg-red-500';
|
|
case 'HELD': return 'bg-yellow-500';
|
|
case 'BLOCKED': return 'bg-gray-500';
|
|
case 'UNDER_MAINTENANCE': return 'bg-orange-500';
|
|
default: return 'bg-gray-300';
|
|
}
|
|
};
|
|
|
|
const parseSeatArrangement = (arrangement: string | null): number[] => {
|
|
if (!arrangement) return [2, 2];
|
|
const parts = arrangement.split('+').map(p => parseInt(p.trim()));
|
|
return parts.length === 2 ? parts : [2, 2];
|
|
};
|
|
|
|
const getBedLabel = (bedPosition: string | null): string => {
|
|
if (bedPosition === 'upper') return 'U';
|
|
if (bedPosition === 'middle') return 'M';
|
|
if (bedPosition === 'lower') return 'L';
|
|
return '';
|
|
};
|
|
|
|
const formatBedSeatNumber = (seat: any): string => {
|
|
if (!seat.seatNumber || !seat.bedPosition) return seat.seatNumber || '';
|
|
const label = getBedLabel(seat.bedPosition);
|
|
return `${seat.seatNumber}${label}`;
|
|
};
|
|
|
|
const renderCoachSeats = (coach: any, isBedCoach: boolean) => {
|
|
const allSeats = coach.seats || [];
|
|
const validSeats = allSeats.filter((s: any) => s.seatNumber && !s.seatNumber.startsWith('-'));
|
|
const removedSeats = allSeats.filter((s: any) => s.seatNumber && s.seatNumber.startsWith('-'));
|
|
|
|
if (validSeats.length === 0 && removedSeats.length === 0) {
|
|
return <div className="text-xs text-muted-foreground">No seats</div>;
|
|
}
|
|
|
|
const hasBedPositionData = validSeats.some((s: any) => s.bedPosition);
|
|
|
|
if (isBedCoach) {
|
|
const arrangement = parseSeatArrangement(coach.seatArrangement);
|
|
const seatsPerRow = arrangement[0] + (arrangement[1] || 0);
|
|
const allSeatsForLayout = [...validSeats, ...removedSeats];
|
|
const rows: any[][] = [];
|
|
const seatClassStr = typeof coach?.seatClass === 'string' ? coach.seatClass : (coach?.seatClass?.name || '');
|
|
const isVipBed = seatClassStr.toLowerCase().includes('vip');
|
|
const bedWidth = isVipBed ? 'w-20' : 'w-16';
|
|
|
|
for (let i = 0; i < allSeatsForLayout.length; i += seatsPerRow) {
|
|
rows.push(allSeatsForLayout.slice(i, i + seatsPerRow));
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-0">
|
|
{rows.map((rowSeats: any[], idx: number) => {
|
|
const isFirstInPair = idx % 2 === 0;
|
|
const shouldFlipIcon = !isFirstInPair;
|
|
const isLastRow = idx === rows.length - 1;
|
|
const nextRowSeats = !isLastRow ? rows[idx + 1] : null;
|
|
|
|
return (
|
|
<div key={`bed-row-${idx}`}>
|
|
<div className="flex gap-0.5 justify-center">
|
|
{rowSeats.map((seat: any) => (
|
|
<SeatIcon
|
|
key={seat.id}
|
|
seat={seat}
|
|
coach={coach}
|
|
isBedCoach={true}
|
|
shouldFlipIcon={shouldFlipIcon}
|
|
getSeatStatus={getSeatStatus}
|
|
getSeatColor={getSeatColor}
|
|
handleBlock={handleBlock}
|
|
handleRemoveSeat={handleRemoveSeat}
|
|
handleUnblock={handleUnblock}
|
|
handleUndoRemove={handleUndoRemove}
|
|
handleSetMaintenance={handleSetMaintenance}
|
|
handleClearMaintenance={handleClearMaintenance}
|
|
hideNumber={true}
|
|
/>
|
|
))}
|
|
</div>
|
|
{isFirstInPair && nextRowSeats && (
|
|
<div className="flex gap-0.5 justify-center text-xs my-1">
|
|
{rowSeats.map((seat: any, seatIdx: number) => {
|
|
const currentSeat = rowSeats[seatIdx];
|
|
const nextSeat = nextRowSeats[seatIdx];
|
|
const currentFormatted = currentSeat ? formatBedSeatNumber(currentSeat) : '';
|
|
const nextFormatted = nextSeat ? formatBedSeatNumber(nextSeat) : '';
|
|
return (
|
|
<div key={`num-between-${seat.id}`} className={`${bedWidth} flex flex-col items-center justify-center text-xs font-bold mb-1 leading-3 text-foreground`}>
|
|
<div className="mb-1">{currentFormatted}</div>
|
|
<div className="mt-1">{nextFormatted}</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
{!isFirstInPair && <div className="h-2" />}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const arrangement = parseSeatArrangement(coach.seatArrangement);
|
|
const leftCount = arrangement[0];
|
|
const rightCount = arrangement[1] || 0;
|
|
|
|
const rows = [];
|
|
const processedRows = new Set();
|
|
const allSeatsForLayout = [...validSeats, ...removedSeats];
|
|
for (const seat of allSeatsForLayout) {
|
|
if (!processedRows.has(seat.row)) {
|
|
rows.push(allSeatsForLayout.filter((s: any) => s.row === seat.row).sort((a: any, b: any) => {
|
|
const colA = a.col.charCodeAt(0);
|
|
const colB = b.col.charCodeAt(0);
|
|
return colA - colB;
|
|
}));
|
|
processedRows.add(seat.row);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-0">
|
|
{rows.map((rowSeats: any[], rowIdx: number) => {
|
|
const leftSeats = rowSeats.slice(0, leftCount);
|
|
const rightSeats = rowSeats.slice(leftCount);
|
|
const rowNumber = rowSeats[0]?.row || 1;
|
|
const shouldFlipArmchair = rowNumber % 2 === 0;
|
|
const showSpacing = rowIdx % 2 === 1;
|
|
|
|
return (
|
|
<div key={`row-${rowSeats[0]?.id}`}>
|
|
{shouldFlipArmchair && (
|
|
<div className="flex gap-0.5 justify-center text-xs text-muted-foreground mb-1">
|
|
<div className="flex gap-0.5">
|
|
{leftSeats.map((seat: any) => (
|
|
<div key={`num-before-left-${seat.id}`} className="w-11 h-4 flex items-center justify-center text-xs font-bold mb-0.5 leading-3 text-foreground">
|
|
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
|
|
</div>
|
|
))}
|
|
</div>
|
|
{rightSeats.length > 0 && <div className="w-8" />}
|
|
{rightSeats.length > 0 && (
|
|
<div className="flex gap-0.5">
|
|
{rightSeats.map((seat: any) => (
|
|
<div key={`num-before-right-${seat.id}`} className="w-11 h-4 flex items-center justify-center text-xs font-bold mb-0.5 leading-3 text-foreground">
|
|
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
<div className="flex gap-0.5 justify-center">
|
|
<div className="flex gap-0.5">
|
|
{leftSeats.map((seat: any) => (
|
|
<SeatIcon
|
|
key={seat.id}
|
|
seat={seat}
|
|
coach={coach}
|
|
isBedCoach={false}
|
|
shouldFlipIcon={shouldFlipArmchair}
|
|
getSeatStatus={getSeatStatus}
|
|
getSeatColor={getSeatColor}
|
|
handleBlock={handleBlock}
|
|
handleRemoveSeat={handleRemoveSeat}
|
|
handleUnblock={handleUnblock}
|
|
handleUndoRemove={handleUndoRemove}
|
|
handleSetMaintenance={handleSetMaintenance}
|
|
handleClearMaintenance={handleClearMaintenance}
|
|
hideNumber={true}
|
|
/>
|
|
))}
|
|
</div>
|
|
{rightSeats.length > 0 && <div className="w-8" />}
|
|
{rightSeats.length > 0 && (
|
|
<div className="flex gap-0.5">
|
|
{rightSeats.map((seat: any) => (
|
|
<SeatIcon
|
|
key={seat.id}
|
|
seat={seat}
|
|
coach={coach}
|
|
isBedCoach={false}
|
|
shouldFlipIcon={shouldFlipArmchair}
|
|
getSeatStatus={getSeatStatus}
|
|
getSeatColor={getSeatColor}
|
|
handleBlock={handleBlock}
|
|
handleRemoveSeat={handleRemoveSeat}
|
|
handleUnblock={handleUnblock}
|
|
handleUndoRemove={handleUndoRemove}
|
|
handleSetMaintenance={handleSetMaintenance}
|
|
handleClearMaintenance={handleClearMaintenance}
|
|
hideNumber={true}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{!shouldFlipArmchair && (
|
|
<div className="flex gap-0.5 justify-center text-xs text-muted-foreground mb-1">
|
|
<div className="flex gap-0.5">
|
|
{leftSeats.map((seat: any) => (
|
|
<div key={`num-left-${seat.id}`} className="w-11 h-4 flex items-center justify-center text-xs font-bold mb-0.5 leading-3 text-foreground">
|
|
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
|
|
</div>
|
|
))}
|
|
</div>
|
|
{rightSeats.length > 0 && <div className="w-8" />}
|
|
{rightSeats.length > 0 && (
|
|
<div className="flex gap-0.5">
|
|
{rightSeats.map((seat: any) => (
|
|
<div key={`num-right-${seat.id}`} className="w-11 h-4 flex items-center justify-center text-xs font-bold mb-0.5 leading-3 text-foreground">
|
|
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{showSpacing && <div className="h-2" />}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const coachesWithSeats = coaches
|
|
.filter((coach: any) => {
|
|
const seats = (coach.seats || []).filter((s: any) => s.seatNumber);
|
|
return seats.length > 0;
|
|
})
|
|
.sort((a: any, b: any) => {
|
|
// Try multiple sequence field possibilities
|
|
const seqA = a.positionNumber ?? a.sequence ?? a.coach?.sequence ?? 999;
|
|
const seqB = b.positionNumber ?? b.sequence ?? b.coach?.sequence ?? 999;
|
|
return seqA - seqB;
|
|
});
|
|
|
|
const activeSelection = activeTab === 'schedule' ? selectedSchedule : selectedRoute;
|
|
const isLoadingData = activeTab === 'schedule' ? isLoading : routeCoachesLoading;
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<h1 className="text-3xl font-bold text-foreground">Seat Management</h1>
|
|
<p className="text-muted-foreground mt-1">View and manage seats by coach</p>
|
|
</div>
|
|
|
|
{/* Tab switcher */}
|
|
<div className="flex gap-1 p-1 bg-muted rounded-lg w-fit">
|
|
|
|
<button
|
|
onClick={() => setActiveTab('route')}
|
|
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
|
activeTab === 'route'
|
|
? 'bg-background text-foreground shadow-sm'
|
|
: 'text-muted-foreground hover:text-foreground'
|
|
}`}
|
|
>
|
|
By Route
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('schedule')}
|
|
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
|
activeTab === 'schedule'
|
|
? 'bg-background text-foreground shadow-sm'
|
|
: 'text-muted-foreground hover:text-foreground'
|
|
}`}
|
|
>
|
|
By Schedule
|
|
</button>
|
|
</div>
|
|
|
|
{!activeSelection ? (
|
|
<div className="card">
|
|
{activeTab === 'schedule' ? (
|
|
<>
|
|
<label className="label">Select Schedule</label>
|
|
<select value={selectedSchedule} onChange={(e) => setSelectedSchedule(e.target.value)} className="input">
|
|
<option value="">Select a schedule...</option>
|
|
{schedules.map((schedule: any) => {
|
|
const routeName = schedule.route?.name || 'N/A';
|
|
const date = schedule.departureAt ? new Date(schedule.departureAt).toLocaleDateString() : 'N/A';
|
|
return <option key={schedule.id} value={schedule.id}>{date} - {routeName}</option>;
|
|
})}
|
|
</select>
|
|
</>
|
|
) : (
|
|
<>
|
|
<label className="label">Select Route</label>
|
|
<select value={selectedRoute} onChange={(e) => setSelectedRoute(e.target.value)} className="input">
|
|
<option value="">Select a route...</option>
|
|
{routes.map((route: any) => (
|
|
<option key={route.id} value={route.id}>{route.name}</option>
|
|
))}
|
|
</select>
|
|
</>
|
|
)}
|
|
<div className="text-center py-12 text-muted-foreground mt-8">
|
|
<Armchair className="h-12 w-12 mx-auto mb-3 opacity-50" />
|
|
<p>Select a {activeTab === 'schedule' ? 'schedule' : 'route'} to view seat map</p>
|
|
</div>
|
|
</div>
|
|
) : isLoadingData ? (
|
|
<div className="card text-center py-12">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
|
|
<p className="text-muted-foreground mt-3">Loading seats...</p>
|
|
</div>
|
|
) : coachesWithSeats.length === 0 ? (
|
|
<div className="space-y-6">
|
|
<div className="card">
|
|
{activeTab === 'schedule' ? (
|
|
<>
|
|
<label className="label">Select Schedule</label>
|
|
<select value={selectedSchedule} onChange={(e) => setSelectedSchedule(e.target.value)} className="input">
|
|
<option value="">Select a schedule...</option>
|
|
{schedules.map((schedule: any) => {
|
|
const trainNumber = schedule.train?.trainNumber || schedule.train?.name || 'N/A';
|
|
const routeName = schedule.route?.name || 'N/A';
|
|
const date = schedule.departureAt ? new Date(schedule.departureAt).toLocaleDateString() : 'N/A';
|
|
return <option key={schedule.id} value={schedule.id}>{trainNumber} - {routeName} - {date}</option>;
|
|
})}
|
|
</select>
|
|
</>
|
|
) : (
|
|
<>
|
|
<label className="label">Select Route</label>
|
|
<select value={selectedRoute} onChange={(e) => setSelectedRoute(e.target.value)} className="input">
|
|
<option value="">Select a route...</option>
|
|
{routes.map((route: any) => (
|
|
<option key={route.id} value={route.id}>{route.name}</option>
|
|
))}
|
|
</select>
|
|
</>
|
|
)}
|
|
</div>
|
|
<div className="card text-center py-12 text-muted-foreground">
|
|
<p>No coaches with seats found for this {activeTab === 'schedule' ? 'schedule' : 'route'}</p>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
<div className="card h-fit sticky top-6 space-y-6">
|
|
<div>
|
|
{activeTab === 'schedule' ? (
|
|
<>
|
|
<label className="label">Select Schedule</label>
|
|
<select value={selectedSchedule} onChange={(e) => setSelectedSchedule(e.target.value)} className="input">
|
|
<option value="">Select a schedule...</option>
|
|
{schedules.map((schedule: any) => {
|
|
const trainNumber = schedule.train?.trainNumber || schedule.train?.name || 'N/A';
|
|
const routeName = schedule.route?.name || 'N/A';
|
|
const date = schedule.departureAt ? new Date(schedule.departureAt).toLocaleDateString() : 'N/A';
|
|
return <option key={schedule.id} value={schedule.id}>{trainNumber} - {routeName} - {date}</option>;
|
|
})}
|
|
</select>
|
|
</>
|
|
) : (
|
|
<>
|
|
<label className="label">Select Route</label>
|
|
<select value={selectedRoute} onChange={(e) => setSelectedRoute(e.target.value)} className="input">
|
|
<option value="">Select a route...</option>
|
|
{routes.map((route: any) => (
|
|
<option key={route.id} value={route.id}>{route.name}</option>
|
|
))}
|
|
</select>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-3 pt-4 border-t border-gray-200 dark:border-gray-700">
|
|
<h3 className="font-semibold text-sm text-foreground">Seat Status</h3>
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-5 h-5 rounded bg-green-500"></div>
|
|
<span className="text-sm text-muted-foreground">Available</span>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-5 h-5 rounded bg-red-500"></div>
|
|
<span className="text-sm text-muted-foreground">Booked</span>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-5 h-5 rounded bg-yellow-500"></div>
|
|
<span className="text-sm text-muted-foreground">Held</span>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-5 h-5 rounded bg-gray-500"></div>
|
|
<span className="text-sm text-muted-foreground">Blocked</span>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-5 h-5 rounded bg-orange-500"></div>
|
|
<span className="text-sm text-muted-foreground">Under Maintenance</span>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-5 h-5 rounded border-2 border-dashed border-gray-400"></div>
|
|
<span className="text-sm text-muted-foreground">Removed</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-4 w-80">
|
|
<div className="bg-gradient-to-r from-[rgb(20,113,76)] to-[rgb(15,85,57)] rounded-lg border-2 border-[rgb(20,113,76)] flex items-center justify-center shadow-lg p-6 h-24">
|
|
<Train className="w-14 h-14 text-white" />
|
|
</div>
|
|
|
|
{coachesWithSeats.map((coach: any, index: number) => {
|
|
const coachData = coachTypesData?.items?.find((c: any) => c.id === coach.id) || coach;
|
|
const coachTypeName = coachData?.coachType?.type || coachData?.coachType?.name || 'Coach';
|
|
const seatClassName = coachData?.seatClass?.name || coach?.seatClass?.name || coach?.coachClass || '';
|
|
const isBedCoach = seatClassName.toLowerCase().includes('bed') ||
|
|
coachTypeName.toLowerCase().includes('bed') ||
|
|
(coach.seats || []).some((s: any) => s.bedPosition);
|
|
const seats = (coach.seats || []).filter((s: any) => s.seatNumber);
|
|
const isExpanded = expandedCoaches.has(coach.id);
|
|
const seatOrBedLabel = isBedCoach ? 'beds' : 'seats';
|
|
const sequence = coach.positionNumber ?? coach.sequence ?? coachData?.sequence ?? index + 1;
|
|
|
|
return (
|
|
<div key={coach.id} className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden bg-white dark:bg-gray-800/50 shadow-md hover:shadow-lg transition-shadow">
|
|
<div className="px-4 py-3 bg-gradient-to-r from-[rgb(20,113,76)]/10 to-[rgb(20,113,76)]/5 dark:from-[rgb(20,113,76)]/20 dark:to-[rgb(20,113,76)]/10 border-b border-[rgb(20,113,76)]/20 dark:border-[rgb(20,113,76)]/30 flex items-center justify-between">
|
|
<button
|
|
onClick={() => toggleCoach(coach.id)}
|
|
className="flex-1 flex items-center gap-3 hover:opacity-75 transition-opacity"
|
|
>
|
|
<div className={`transform transition-transform ${isExpanded ? 'rotate-180' : ''}`}>
|
|
<ChevronDown className="w-5 h-5 text-[rgb(20,113,76)]" />
|
|
</div>
|
|
<div className="text-left">
|
|
<p className="font-semibold text-foreground">{sequence} - {coach.coachNumber}</p>
|
|
<p className="text-xs text-muted-foreground">{coachTypeName} • {seats.length} {seatOrBedLabel}</p>
|
|
</div>
|
|
</button>
|
|
<ActionButton
|
|
variant={isCoachBlocked(coach) ? 'danger' : 'secondary'}
|
|
size="sm"
|
|
onClick={() => isCoachBlocked(coach) ? handleUnblockCoach(coach) : handleBlockCoach(coach)}
|
|
className="ml-2"
|
|
disabled={!isCoachBlocked(coach) && !isCoachUnblocked(coach)}
|
|
>
|
|
{isCoachBlocked(coach) ? (
|
|
<>
|
|
<Unlock className="w-4 h-4" /> Unblock
|
|
</>
|
|
) : isCoachUnblocked(coach) ? (
|
|
<>
|
|
<Lock className="w-4 h-4" /> Block
|
|
</>
|
|
) : (
|
|
'Mixed Status'
|
|
)}
|
|
</ActionButton>
|
|
</div>
|
|
|
|
{isExpanded && (
|
|
<div className="px-4 py-4 bg-white dark:bg-gray-900/50 border-t border-gray-200 dark:border-gray-700">
|
|
<div className="bg-gray-50 dark:bg-gray-900/30 rounded-lg p-3 inline-block">
|
|
{renderCoachSeats(coach, isBedCoach)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<Modal
|
|
isOpen={showBlockModal}
|
|
onClose={() => {
|
|
setShowBlockModal(false);
|
|
setSelectedSeat(null);
|
|
setBlockReason('');
|
|
}}
|
|
title="Block Seat"
|
|
size="md"
|
|
>
|
|
<div className="space-y-4">
|
|
<p className="text-sm text-muted-foreground">
|
|
Block seat <strong>{selectedSeat?.seatNumber}</strong> in Coach <strong>{selectedSeat?.coach?.coachNumber}</strong>
|
|
</p>
|
|
<div>
|
|
<label className="label">Reason for Blocking *</label>
|
|
<textarea
|
|
className="input"
|
|
rows={3}
|
|
value={blockReason}
|
|
onChange={(e) => setBlockReason(e.target.value)}
|
|
placeholder="e.g., Maintenance required, Damaged seat, Reserved for staff"
|
|
/>
|
|
</div>
|
|
<div className="flex justify-end gap-2">
|
|
<ActionButton
|
|
variant="secondary"
|
|
onClick={() => {
|
|
setShowBlockModal(false);
|
|
setSelectedSeat(null);
|
|
setBlockReason('');
|
|
}}
|
|
>
|
|
Cancel
|
|
</ActionButton>
|
|
<ActionButton
|
|
onClick={submitBlock}
|
|
loading={blockMutation.isPending}
|
|
disabled={!blockReason.trim()}
|
|
>
|
|
Block Seat
|
|
</ActionButton>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
|
|
<Modal
|
|
isOpen={showRemoveModal}
|
|
onClose={() => {
|
|
setShowRemoveModal(false);
|
|
setSelectedSeat(null);
|
|
}}
|
|
title="Remove Seat"
|
|
size="md"
|
|
>
|
|
<div className="space-y-4">
|
|
<p className="text-sm text-muted-foreground">
|
|
Remove seat <strong>{selectedSeat?.seatNumber}</strong> from Coach <strong>{selectedSeat?.coach?.coachNumber}</strong>
|
|
</p>
|
|
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
|
|
<p className="text-sm text-yellow-800">
|
|
This will mark the seat as removed. The seat will show as an empty space on the seat map.
|
|
You can undo this action anytime by clicking the undo button on the removed seat.
|
|
</p>
|
|
</div>
|
|
<div className="flex justify-end gap-2">
|
|
<ActionButton
|
|
variant="secondary"
|
|
onClick={() => {
|
|
setShowRemoveModal(false);
|
|
setSelectedSeat(null);
|
|
}}
|
|
>
|
|
Cancel
|
|
</ActionButton>
|
|
<ActionButton
|
|
variant="danger"
|
|
onClick={submitRemoveSeat}
|
|
loading={removeSeatMutation.isPending}
|
|
>
|
|
Remove Seat
|
|
</ActionButton>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
|
|
<Modal
|
|
isOpen={showBlockCoachModal}
|
|
onClose={() => {
|
|
setShowBlockCoachModal(false);
|
|
setSelectedCoach(null);
|
|
setBlockCoachReason('');
|
|
}}
|
|
title="Block Coach"
|
|
size="md"
|
|
>
|
|
<div className="space-y-4">
|
|
<p className="text-sm text-muted-foreground">
|
|
Block all seats in Coach <strong>{selectedCoach?.coachNumber}</strong>
|
|
</p>
|
|
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
|
|
<p className="text-sm text-red-800">
|
|
This will block all {selectedCoach?.seats?.length || 0} seats in this coach.
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<label className="label">Reason for Blocking *</label>
|
|
<textarea
|
|
className="input"
|
|
rows={3}
|
|
value={blockCoachReason}
|
|
onChange={(e) => setBlockCoachReason(e.target.value)}
|
|
placeholder="e.g., Major maintenance, Safety inspection, Temporary withdrawal"
|
|
/>
|
|
</div>
|
|
<div className="flex justify-end gap-2">
|
|
<ActionButton
|
|
variant="secondary"
|
|
onClick={() => {
|
|
setShowBlockCoachModal(false);
|
|
setSelectedCoach(null);
|
|
setBlockCoachReason('');
|
|
}}
|
|
>
|
|
Cancel
|
|
</ActionButton>
|
|
<ActionButton
|
|
variant="danger"
|
|
onClick={submitBlockCoach}
|
|
loading={blockCoachMutation.isPending}
|
|
disabled={!blockCoachReason.trim()}
|
|
>
|
|
Block Coach
|
|
</ActionButton>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
|
|
<Modal
|
|
isOpen={showUnblockCoachModal}
|
|
onClose={() => {
|
|
setShowUnblockCoachModal(false);
|
|
setCoachToUnblock(null);
|
|
}}
|
|
title="Unblock Coach"
|
|
size="md"
|
|
>
|
|
<div className="space-y-4">
|
|
<p className="text-sm text-muted-foreground">
|
|
Unblock all seats in Coach <strong>{coachToUnblock?.coachNumber}</strong>
|
|
</p>
|
|
<div className="bg-green-50 border border-green-200 rounded-lg p-3">
|
|
<p className="text-sm text-green-800">
|
|
This will unblock all {coachToUnblock?.seats?.filter((s: any) => s.status === 'BLOCKED' || s.isBlocked).length || 0} blocked seats in this coach.
|
|
</p>
|
|
</div>
|
|
<div className="flex justify-end gap-2">
|
|
<ActionButton
|
|
variant="secondary"
|
|
onClick={() => {
|
|
setShowUnblockCoachModal(false);
|
|
setCoachToUnblock(null);
|
|
}}
|
|
>
|
|
Cancel
|
|
</ActionButton>
|
|
<ActionButton
|
|
onClick={confirmUnblockCoach}
|
|
loading={unblockCoachMutation.isPending}
|
|
>
|
|
Unblock Coach
|
|
</ActionButton>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
|
|
<Modal
|
|
isOpen={showMaintenanceModal}
|
|
onClose={() => { setShowMaintenanceModal(false); setSelectedSeat(null); setMaintenanceReason(''); }}
|
|
title="Set Seat Under Maintenance"
|
|
size="md"
|
|
>
|
|
<div className="space-y-4">
|
|
<p className="text-sm text-muted-foreground">
|
|
Set seat <strong>{selectedSeat?.seatNumber}</strong> to Under Maintenance
|
|
</p>
|
|
<div>
|
|
<label className="label">Reason *</label>
|
|
<textarea
|
|
className="input"
|
|
rows={3}
|
|
value={maintenanceReason}
|
|
onChange={(e) => setMaintenanceReason(e.target.value)}
|
|
placeholder="e.g., Seat mechanism broken, Upholstery replacement"
|
|
/>
|
|
</div>
|
|
<div className="flex justify-end gap-2">
|
|
<ActionButton
|
|
variant="secondary"
|
|
onClick={() => { setShowMaintenanceModal(false); setSelectedSeat(null); setMaintenanceReason(''); }}
|
|
>
|
|
Cancel
|
|
</ActionButton>
|
|
<ActionButton
|
|
onClick={() => maintenanceMutation.mutate({ seatId: selectedSeat.id, reason: maintenanceReason })}
|
|
loading={maintenanceMutation.isPending}
|
|
disabled={!maintenanceReason.trim()}
|
|
>
|
|
Set Maintenance
|
|
</ActionButton>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface SeatIconProps {
|
|
seat: any;
|
|
coach: any;
|
|
isBedCoach: boolean;
|
|
shouldFlipIcon?: boolean;
|
|
hideNumber?: boolean;
|
|
getSeatStatus: (seat: any) => string;
|
|
getSeatColor: (status: string) => string;
|
|
handleBlock: (seat: any) => void;
|
|
handleRemoveSeat: (seat: any) => void;
|
|
handleUnblock: (seat: any) => void;
|
|
handleUndoRemove: (seat: any) => void;
|
|
handleSetMaintenance: (seat: any) => void;
|
|
handleClearMaintenance: (seat: any) => void;
|
|
}
|
|
|
|
function SeatIcon({
|
|
seat,
|
|
coach,
|
|
isBedCoach,
|
|
shouldFlipIcon = false,
|
|
hideNumber = false,
|
|
getSeatStatus,
|
|
getSeatColor,
|
|
handleBlock,
|
|
handleRemoveSeat,
|
|
handleUnblock,
|
|
handleUndoRemove,
|
|
handleSetMaintenance,
|
|
handleClearMaintenance,
|
|
}: SeatIconProps) {
|
|
const isRemoved = seat.seatNumber && seat.seatNumber.startsWith('-');
|
|
const seatClassStr = typeof coach?.seatClass === 'string' ? coach.seatClass : (coach?.seatClass?.name || coach?.coachClass || '');
|
|
const isVipBed = isBedCoach && seatClassStr.toLowerCase().includes('vip');
|
|
const bedWidth = isVipBed ? 'w-24' : 'w-16';
|
|
const width = isBedCoach ? bedWidth : 'w-10';
|
|
|
|
if (!seat.seatNumber) {
|
|
return <div className="w-7 h-7" />;
|
|
}
|
|
|
|
if (isRemoved) {
|
|
return (
|
|
<div className="relative group flex flex-col items-center">
|
|
<div className="w-11 h-11 rounded border-2 border-dashed border-gray-400 flex items-center justify-center hover:opacity-80 transition-opacity" title="Removed seat">
|
|
</div>
|
|
<div className="absolute top-full mt-1 bg-black/80 rounded shadow-lg flex items-center gap-1 p-1 z-20 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none group-hover:pointer-events-auto">
|
|
<button
|
|
onClick={() => handleUndoRemove(seat)}
|
|
className="p-1 bg-white rounded hover:bg-gray-100 pointer-events-auto"
|
|
title="Undo remove"
|
|
>
|
|
<RotateCcw className="h-3 w-3 text-gray-700" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const status = getSeatStatus(seat);
|
|
const color = getSeatColor(status);
|
|
const canBlock = status === 'AVAILABLE';
|
|
const canUnblock = status === 'BLOCKED';
|
|
const canMaintenance = false;
|
|
const canClearMaintenance = status === 'UNDER_MAINTENANCE';
|
|
|
|
return (
|
|
<div className="relative group flex flex-col items-center">
|
|
{!hideNumber && (
|
|
<span className="text-xs font-bold mb-0.5 leading-3 text-foreground">
|
|
{seat.seatNumber}
|
|
</span>
|
|
)}
|
|
|
|
{isBedCoach ? (
|
|
<div
|
|
className={`${width} h-11 rounded flex items-center justify-center cursor-pointer hover:opacity-80 transition-opacity ${color}`}
|
|
title={`${seat.seatNumber} - ${seat.bedPosition} - ${status}`}
|
|
style={!shouldFlipIcon ? { transform: 'scaleY(-1)' } : undefined}
|
|
>
|
|
<Bed className="w-7 h-7 text-white" />
|
|
</div>
|
|
) : (
|
|
<div
|
|
className={`w-11 h-11 rounded flex items-center justify-center cursor-pointer hover:opacity-80 transition-opacity ${color}`}
|
|
title={`${seat.seatNumber} - ${status}`}
|
|
style={seat.row % 2 === 0 ? { transform: 'scaleY(-1)' } : undefined}
|
|
>
|
|
<Armchair className="w-7 h-7 text-white" />
|
|
</div>
|
|
)}
|
|
|
|
{(canBlock || canUnblock || canMaintenance || canClearMaintenance) && (
|
|
<div className="absolute top-full mt-1 bg-black/80 rounded shadow-lg flex items-center gap-1 p-1 z-20 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none group-hover:pointer-events-auto">
|
|
{canBlock && (
|
|
<>
|
|
<button
|
|
onClick={() => handleBlock(seat)}
|
|
className="p-1 bg-white rounded hover:bg-gray-100 pointer-events-auto"
|
|
title="Block seat"
|
|
>
|
|
<Lock className="h-3 w-3 text-gray-700" />
|
|
</button>
|
|
<button
|
|
onClick={() => handleRemoveSeat(seat)}
|
|
className="p-1 bg-white rounded hover:bg-gray-100 pointer-events-auto"
|
|
title="Remove seat"
|
|
>
|
|
<X className="h-3 w-3 text-gray-700" />
|
|
</button>
|
|
</>
|
|
)}
|
|
{canUnblock && (
|
|
<button
|
|
onClick={() => handleUnblock(seat)}
|
|
className="p-1 bg-white rounded hover:bg-gray-100 pointer-events-auto"
|
|
title="Unblock seat"
|
|
>
|
|
<Unlock className="h-3 w-3 text-gray-700" />
|
|
</button>
|
|
)}
|
|
{canMaintenance && (
|
|
<button
|
|
onClick={() => handleSetMaintenance(seat)}
|
|
className="p-1 bg-white rounded hover:bg-gray-100 pointer-events-auto"
|
|
title="Set under maintenance"
|
|
>
|
|
<Wrench className="h-3 w-3 text-orange-600" />
|
|
</button>
|
|
)}
|
|
{canClearMaintenance && (
|
|
<button
|
|
onClick={() => handleClearMaintenance(seat)}
|
|
className="p-1 bg-white rounded hover:bg-gray-100 pointer-events-auto"
|
|
title="Clear maintenance"
|
|
>
|
|
<Unlock className="h-3 w-3 text-orange-600" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|