mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 22:58:17 +00:00
Updated the passenger web portal and added more endpoints to the api
This commit is contained in:
@@ -14,12 +14,16 @@ interface RouteStop {
|
||||
stationId: string;
|
||||
sequence: number;
|
||||
distanceKm?: number;
|
||||
distanceFromOrigin?: number;
|
||||
}
|
||||
|
||||
export default function RoutesPage() {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingRoute, setEditingRoute] = useState<any>(null);
|
||||
const [stops, setStops] = useState<RouteStop[]>([]);
|
||||
const [originStationId, setOriginStationId] = useState('');
|
||||
const [destinationStationId, setDestinationStationId] = useState('');
|
||||
const [destinationDistance, setDestinationDistance] = useState<number | undefined>(undefined);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: routes, isLoading: routesLoading } = useQuery({
|
||||
@@ -65,21 +69,38 @@ export default function RoutesPage() {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
|
||||
if (stops.length < 2) {
|
||||
alert('Route must have at least 2 stops');
|
||||
if (!originStationId || !destinationStationId) {
|
||||
alert('Please select origin and destination stations');
|
||||
return;
|
||||
}
|
||||
|
||||
const stopsArray = stops.map((stop, idx) => {
|
||||
const stopData: any = {
|
||||
stationId: stop.stationId,
|
||||
sequence: idx + 1,
|
||||
};
|
||||
if (idx > 0 && stop.distanceKm) {
|
||||
stopData.distanceKm = stop.distanceKm;
|
||||
}
|
||||
return stopData;
|
||||
});
|
||||
if (originStationId === destinationStationId) {
|
||||
alert('Origin and destination must be different');
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort middle stops by distance from origin
|
||||
const sortedMiddleStops = [...stops].sort((a, b) =>
|
||||
(a.distanceFromOrigin || 0) - (b.distanceFromOrigin || 0)
|
||||
);
|
||||
|
||||
// Calculate distanceKm (distance from previous stop)
|
||||
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,
|
||||
};
|
||||
}),
|
||||
{
|
||||
stationId: destinationStationId,
|
||||
sequence: sortedMiddleStops.length + 2,
|
||||
distanceKm: (destinationDistance || 0) - (sortedMiddleStops.length > 0 ? (sortedMiddleStops[sortedMiddleStops.length - 1].distanceFromOrigin || 0) : 0),
|
||||
},
|
||||
];
|
||||
|
||||
const routeData = {
|
||||
code: formData.get('code') as string,
|
||||
@@ -100,7 +121,7 @@ export default function RoutesPage() {
|
||||
};
|
||||
|
||||
const addStop = () => {
|
||||
setStops([...stops, { stationId: '', sequence: stops.length + 1 }]);
|
||||
setStops([...stops, { stationId: '', sequence: stops.length + 1, distanceFromOrigin: 0 }]);
|
||||
};
|
||||
|
||||
const removeStop = (index: number) => {
|
||||
@@ -113,6 +134,20 @@ export default function RoutesPage() {
|
||||
setStops(updated);
|
||||
};
|
||||
|
||||
const generateRouteCode = (originId: string, destId: string) => {
|
||||
if (!originId || !destId) return '';
|
||||
const origin = stations?.items?.find((s: any) => s.id === originId);
|
||||
const dest = stations?.items?.find((s: any) => s.id === destId);
|
||||
return origin && dest ? `${origin.code}-${dest.code}` : '';
|
||||
};
|
||||
|
||||
const generateRouteName = (originId: string, destId: string) => {
|
||||
if (!originId || !destId) return '';
|
||||
const origin = stations?.items?.find((s: any) => s.id === originId);
|
||||
const dest = stations?.items?.find((s: any) => s.id === destId);
|
||||
return origin && dest ? `${origin.name} - ${dest.name}` : '';
|
||||
};
|
||||
|
||||
const handleDelete = async (route: any) => {
|
||||
if (confirm(`Are you sure you want to delete ${route.name}?`)) {
|
||||
await deleteMutation.mutateAsync(route.id);
|
||||
@@ -139,6 +174,35 @@ export default function RoutesPage() {
|
||||
label: 'Edit',
|
||||
onClick: (route: any) => {
|
||||
setEditingRoute(route);
|
||||
const routeStops = route.stops || [];
|
||||
if (routeStops.length >= 2) {
|
||||
setOriginStationId(routeStops[0].stationId);
|
||||
setDestinationStationId(routeStops[routeStops.length - 1].stationId);
|
||||
|
||||
// Calculate cumulative distance for destination
|
||||
let cumulativeDistance = 0;
|
||||
routeStops.forEach((stop: any, idx: number) => {
|
||||
if (idx > 0) {
|
||||
cumulativeDistance += stop.distanceKm || 0;
|
||||
}
|
||||
});
|
||||
setDestinationDistance(cumulativeDistance);
|
||||
|
||||
// Calculate distance from origin for middle stops
|
||||
const middleStops = routeStops.slice(1, -1).map((stop: any, idx: number) => {
|
||||
let distFromOrigin = 0;
|
||||
for (let i = 1; i <= idx + 1; i++) {
|
||||
distFromOrigin += routeStops[i].distanceKm || 0;
|
||||
}
|
||||
return {
|
||||
stationId: stop.stationId,
|
||||
sequence: stop.sequence,
|
||||
distanceKm: stop.distanceKm,
|
||||
distanceFromOrigin: distFromOrigin,
|
||||
};
|
||||
});
|
||||
setStops(middleStops);
|
||||
}
|
||||
setShowModal(true);
|
||||
},
|
||||
variant: 'secondary' as const,
|
||||
@@ -163,6 +227,9 @@ export default function RoutesPage() {
|
||||
icon={Plus}
|
||||
onClick={() => {
|
||||
setEditingRoute(null);
|
||||
setOriginStationId('');
|
||||
setDestinationStationId('');
|
||||
setDestinationDistance(undefined);
|
||||
setStops([]);
|
||||
setShowModal(true);
|
||||
}}
|
||||
@@ -185,12 +252,52 @@ export default function RoutesPage() {
|
||||
onClose={() => {
|
||||
setShowModal(false);
|
||||
setEditingRoute(null);
|
||||
setOriginStationId('');
|
||||
setDestinationStationId('');
|
||||
setDestinationDistance(undefined);
|
||||
setStops([]);
|
||||
}}
|
||||
title={`${editingRoute ? 'Edit' : 'Add'} Route`}
|
||||
size="lg"
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<form onSubmit={handleSubmit} className="space-y-4 max-h-[calc(100vh-200px)] overflow-y-auto">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="label">Origin Station *</label>
|
||||
<select
|
||||
className="input"
|
||||
value={originStationId}
|
||||
onChange={(e) => setOriginStationId(e.target.value)}
|
||||
required
|
||||
disabled={!!editingRoute}
|
||||
>
|
||||
<option value="">Select Origin</option>
|
||||
{stations?.items?.map((station: any) => (
|
||||
<option key={station.id} value={station.id}>
|
||||
{station.name} ({station.code})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Destination Station *</label>
|
||||
<select
|
||||
className="input"
|
||||
value={destinationStationId}
|
||||
onChange={(e) => setDestinationStationId(e.target.value)}
|
||||
required
|
||||
disabled={!!editingRoute}
|
||||
>
|
||||
<option value="">Select Destination</option>
|
||||
{stations?.items?.map((station: any) => (
|
||||
<option key={station.id} value={station.id}>
|
||||
{station.name} ({station.code})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="label">Route Code *</label>
|
||||
@@ -198,9 +305,10 @@ export default function RoutesPage() {
|
||||
type="text"
|
||||
name="code"
|
||||
className="input"
|
||||
defaultValue={editingRoute?.code}
|
||||
value={generateRouteCode(originStationId, destinationStationId)}
|
||||
readOnly
|
||||
required
|
||||
placeholder="e.g., ADD-DJI"
|
||||
placeholder="Select stations to generate"
|
||||
disabled={!!editingRoute}
|
||||
/>
|
||||
</div>
|
||||
@@ -210,9 +318,10 @@ export default function RoutesPage() {
|
||||
type="text"
|
||||
name="name"
|
||||
className="input"
|
||||
defaultValue={editingRoute?.name}
|
||||
value={generateRouteName(originStationId, destinationStationId)}
|
||||
readOnly
|
||||
required
|
||||
placeholder="e.g., Addis Ababa – Djibouti"
|
||||
placeholder="Select stations to generate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -252,56 +361,66 @@ export default function RoutesPage() {
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<label className="label mb-0">Route Stops *</label>
|
||||
<ActionButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={Plus}
|
||||
onClick={addStop}
|
||||
>
|
||||
Add Stop
|
||||
</ActionButton>
|
||||
<label className="label mb-0">Route Stops</label>
|
||||
</div>
|
||||
|
||||
{stops.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground mb-3">No stops added. Click "Add Stop" to begin.</p>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{/* Origin Stop */}
|
||||
<div className="flex gap-2 items-center p-3 bg-primary/10 rounded border-2 border-primary">
|
||||
<div className="flex-shrink-0 w-8 h-8 bg-primary text-primary-foreground rounded-full flex items-center justify-center text-sm font-medium">
|
||||
1
|
||||
</div>
|
||||
<div className="flex-1 font-medium">
|
||||
{originStationId ? (
|
||||
<span>
|
||||
{stations?.items?.find((s: any) => s.id === originStationId)?.name || 'Unknown'}
|
||||
{' '}({stations?.items?.find((s: any) => s.id === originStationId)?.code || 'N/A'})
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Select origin station above</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
0 km
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||
{/* Intermediate Stops */}
|
||||
{stops.map((stop, index) => (
|
||||
<div key={index} className="flex gap-2 items-start p-3 bg-muted/50 rounded">
|
||||
<div className="flex-shrink-0 w-8 h-8 bg-primary text-primary-foreground rounded-full flex items-center justify-center text-sm font-medium">
|
||||
{index + 1}
|
||||
<div key={index} className="flex gap-2 items-center p-3 bg-muted/50 rounded">
|
||||
<div className="flex-shrink-0 w-8 h-8 bg-secondary text-secondary-foreground rounded-full flex items-center justify-center text-sm font-medium">
|
||||
{index + 2}
|
||||
</div>
|
||||
<div className="flex-1 grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<select
|
||||
className="input input-sm"
|
||||
value={stop.stationId}
|
||||
onChange={(e) => updateStop(index, 'stationId', e.target.value)}
|
||||
required
|
||||
>
|
||||
<option value="">Select Station</option>
|
||||
{stations?.items?.map((station: any) => (
|
||||
<option key={station.id} value={station.id}>
|
||||
{station.name} ({station.code})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
type="number"
|
||||
className="input input-sm"
|
||||
placeholder={index === 0 ? 'Origin (0 km)' : 'Distance from previous (km)'}
|
||||
value={stop.distanceKm || ''}
|
||||
onChange={(e) => updateStop(index, 'distanceKm', e.target.value ? parseFloat(e.target.value) : undefined)}
|
||||
disabled={index === 0}
|
||||
min="0"
|
||||
step="0.1"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<select
|
||||
className="input input-sm"
|
||||
value={stop.stationId}
|
||||
onChange={(e) => updateStop(index, 'stationId', e.target.value)}
|
||||
required
|
||||
>
|
||||
<option value="">Select Station</option>
|
||||
{stations?.items?.filter((s: any) =>
|
||||
s.id !== originStationId &&
|
||||
s.id !== destinationStationId &&
|
||||
!stops.some((st, idx) => idx !== index && st.stationId === s.id)
|
||||
).map((station: any) => (
|
||||
<option key={station.id} value={station.id}>
|
||||
{station.name} ({station.code})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="w-32">
|
||||
<input
|
||||
type="number"
|
||||
className="input input-sm"
|
||||
placeholder="km"
|
||||
value={stop.distanceFromOrigin || ''}
|
||||
onChange={(e) => updateStop(index, 'distanceFromOrigin', e.target.value ? parseFloat(e.target.value) : undefined)}
|
||||
min="0"
|
||||
step="0.1"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -312,6 +431,52 @@ export default function RoutesPage() {
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Add Intermediate Stop Button */}
|
||||
{originStationId && destinationStationId && (
|
||||
<div className="flex justify-center py-2">
|
||||
<ActionButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={Plus}
|
||||
onClick={addStop}
|
||||
>
|
||||
Add Intermediate Stop
|
||||
</ActionButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Destination Stop */}
|
||||
<div className="flex gap-2 items-center p-3 bg-primary/10 rounded border-2 border-primary">
|
||||
<div className="flex-shrink-0 w-8 h-8 bg-primary text-primary-foreground rounded-full flex items-center justify-center text-sm font-medium">
|
||||
{stops.length + 2}
|
||||
</div>
|
||||
<div className="flex-1 font-medium">
|
||||
{destinationStationId ? (
|
||||
<span>
|
||||
{stations?.items?.find((s: any) => s.id === destinationStationId)?.name || 'Unknown'}
|
||||
{' '}({stations?.items?.find((s: any) => s.id === destinationStationId)?.code || 'N/A'})
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Select destination station above</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-32">
|
||||
{destinationStationId && (
|
||||
<input
|
||||
type="number"
|
||||
className="input input-sm"
|
||||
placeholder="km"
|
||||
value={destinationDistance || ''}
|
||||
onChange={(e) => setDestinationDistance(e.target.value ? parseFloat(e.target.value) : undefined)}
|
||||
min="0"
|
||||
step="0.1"
|
||||
required
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -322,6 +487,9 @@ export default function RoutesPage() {
|
||||
onClick={() => {
|
||||
setShowModal(false);
|
||||
setEditingRoute(null);
|
||||
setOriginStationId('');
|
||||
setDestinationStationId('');
|
||||
setDestinationDistance(undefined);
|
||||
setStops([]);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -77,6 +77,14 @@ export default function SchedulesPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const removeCoachMutation = useMutation({
|
||||
mutationFn: ({ scheduleId, coachId }: { scheduleId: string; coachId: string }) =>
|
||||
schedulesApi.removeCoachAssignment(scheduleId, coachId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['schedules'] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
@@ -119,6 +127,12 @@ export default function SchedulesPage() {
|
||||
setShowCoachModal(true);
|
||||
};
|
||||
|
||||
const handleRemoveCoach = async (schedule: any, coachId: string) => {
|
||||
if (confirm('Remove this coach from the schedule?')) {
|
||||
await removeCoachMutation.mutateAsync({ scheduleId: schedule.id, coachId });
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleCoach = (coachId: string) => {
|
||||
setSelectedCoaches(prev => {
|
||||
const exists = prev.find(c => c.coachId === coachId);
|
||||
@@ -157,9 +171,21 @@ export default function SchedulesPage() {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{schedule.coachAssignments?.slice(0, 3).map((assignment: any) => (
|
||||
<Badge key={assignment.id} variant="status" status="CONFIRMED">
|
||||
{assignment.coach?.coachNumber || 'N/A'}
|
||||
</Badge>
|
||||
<div key={assignment.id} className="group relative inline-flex">
|
||||
<Badge variant="status" status="CONFIRMED">
|
||||
{assignment.coach?.coachNumber || 'N/A'}
|
||||
</Badge>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemoveCoach(schedule, assignment.coach.id);
|
||||
}}
|
||||
className="absolute -top-1 -right-1 hidden group-hover:flex items-center justify-center w-4 h-4 bg-destructive text-destructive-foreground rounded-full text-xs"
|
||||
title="Remove coach"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{coachCount > 3 && (
|
||||
<Badge variant="status" status="PENDING">
|
||||
|
||||
@@ -3,13 +3,17 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { seatsApi, schedulesApi } from '@/lib/api';
|
||||
import DataTable from '@/components/ui/DataTable';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import ActionButton from '@/components/ui/ActionButton';
|
||||
import { Search, Armchair, Lock, Unlock } from 'lucide-react';
|
||||
import Badge from '@/components/ui/Badge';
|
||||
import { Search, Armchair, Lock, Unlock, ChevronRight } from 'lucide-react';
|
||||
|
||||
export default function SeatsPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [selectedSchedule, setSelectedSchedule] = useState('');
|
||||
const [showBlockModal, setShowBlockModal] = useState(false);
|
||||
const [selectedSeat, setSelectedSeat] = useState<any>(null);
|
||||
const [blockReason, setBlockReason] = useState('');
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: schedulesData } = useQuery({
|
||||
@@ -17,114 +21,85 @@ export default function SeatsPage() {
|
||||
queryFn: () => schedulesApi.getAll(),
|
||||
});
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['seats', selectedSchedule],
|
||||
queryFn: () => selectedSchedule ? seatsApi.getBySchedule(selectedSchedule) : Promise.resolve([]),
|
||||
const { data: seatMapData, isLoading } = useQuery({
|
||||
queryKey: ['seatmap', selectedSchedule],
|
||||
queryFn: () => selectedSchedule ? seatsApi.getSeatMap(selectedSchedule) : Promise.resolve(null),
|
||||
enabled: !!selectedSchedule,
|
||||
});
|
||||
|
||||
const blockMutation = useMutation({
|
||||
mutationFn: ({ seatId, reason }: any) => seatsApi.block(seatId, { reason }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['seats'] }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['seatmap'] });
|
||||
setShowBlockModal(false);
|
||||
setSelectedSeat(null);
|
||||
setBlockReason('');
|
||||
},
|
||||
});
|
||||
|
||||
const unblockMutation = useMutation({
|
||||
mutationFn: (seatId: string) => seatsApi.unblock(seatId),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['seats'] }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['seatmap'] });
|
||||
},
|
||||
});
|
||||
|
||||
const seats = Array.isArray(data) ? data : data?.items || data?.data || [];
|
||||
const schedules = schedulesData?.items || schedulesData?.data || [];
|
||||
const coaches = seatMapData?.coaches || [];
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'seatNumber',
|
||||
label: 'Seat Number',
|
||||
render: (seat: any) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-[rgb(20,113,76)]">
|
||||
<Armchair className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
<span className="font-medium">{seat.seatNumber}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'coach',
|
||||
label: 'Coach',
|
||||
render: (seat: any) => (
|
||||
<span className="text-sm">{seat.coach?.coachNumber || 'N/A'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'seatClass',
|
||||
label: 'Class',
|
||||
render: (seat: any) => {
|
||||
const seatClass = seat.coach?.serviceClass || 'N/A';
|
||||
const colorMap: Record<string, string> = {
|
||||
'ECONOMY_REGULAR': 'edr-badge-info',
|
||||
'ECONOMY_BED': 'edr-badge-warning',
|
||||
'VIP_BED': 'edr-badge-success',
|
||||
};
|
||||
return (
|
||||
<span className={`edr-badge ${colorMap[seatClass] || 'edr-badge-info'}`}>
|
||||
{seatClass.replace(/_/g, ' ')}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'position',
|
||||
label: 'Position',
|
||||
render: (seat: any) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{seat.position || seat.seatPosition || 'N/A'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
render: (seat: any) => {
|
||||
const isBlocked = seat.isBlocked || seat.status === 'BLOCKED';
|
||||
const isBooked = seat.isBooked || seat.status === 'BOOKED';
|
||||
|
||||
if (isBlocked) return <span className="edr-badge edr-badge-danger">Blocked</span>;
|
||||
if (isBooked) return <span className="edr-badge edr-badge-warning">Booked</span>;
|
||||
return <span className="edr-badge edr-badge-success">Available</span>;
|
||||
},
|
||||
},
|
||||
];
|
||||
const handleBlock = (seat: any) => {
|
||||
setSelectedSeat(seat);
|
||||
setShowBlockModal(true);
|
||||
};
|
||||
|
||||
const actions = [
|
||||
{
|
||||
label: 'Block',
|
||||
onClick: (seat: any) => blockMutation.mutate({ seatId: seat.id, reason: 'Manual block' }),
|
||||
variant: 'secondary' as const,
|
||||
icon: Lock,
|
||||
show: (seat: any) => !seat.isBlocked && seat.status !== 'BLOCKED',
|
||||
},
|
||||
{
|
||||
label: 'Unblock',
|
||||
onClick: (seat: any) => unblockMutation.mutate(seat.id),
|
||||
variant: 'secondary' as const,
|
||||
icon: Unlock,
|
||||
show: (seat: any) => seat.isBlocked || seat.status === 'BLOCKED',
|
||||
},
|
||||
];
|
||||
const handleUnblock = async (seat: any) => {
|
||||
if (confirm('Are you sure you want to unblock this seat?')) {
|
||||
await unblockMutation.mutateAsync(seat.id);
|
||||
}
|
||||
};
|
||||
|
||||
const submitBlock = async () => {
|
||||
if (!blockReason.trim()) {
|
||||
alert('Please provide a reason for blocking');
|
||||
return;
|
||||
}
|
||||
await blockMutation.mutateAsync({ seatId: selectedSeat.id, reason: blockReason });
|
||||
};
|
||||
|
||||
const getSeatStatus = (seat: any) => {
|
||||
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';
|
||||
default: return 'bg-gray-300';
|
||||
}
|
||||
};
|
||||
|
||||
const filteredCoaches = coaches.filter((coach: any) =>
|
||||
search ? coach.coachNumber?.toLowerCase().includes(search.toLowerCase()) : true
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">Seat Management</h1>
|
||||
<p className="text-muted-foreground mt-1">Manage seat availability and blocking</p>
|
||||
<p className="text-muted-foreground mt-1">View and manage seat availability by schedule</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<div className="flex-1">
|
||||
<label className="label">Select Schedule</label>
|
||||
<select
|
||||
value={selectedSchedule}
|
||||
onChange={(e) => setSelectedSchedule(e.target.value)}
|
||||
@@ -133,41 +108,191 @@ export default function SeatsPage() {
|
||||
<option value="">Select a schedule...</option>
|
||||
{schedules.map((schedule: any) => {
|
||||
const trainNumber = schedule.train?.trainNumber || schedule.train?.name || 'N/A';
|
||||
const routeCode = schedule.route?.code || '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} - {routeCode} - {date}
|
||||
{trainNumber} - {routeName} - {date}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search seats..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="input pl-10"
|
||||
/>
|
||||
<label className="label">Search Coaches</label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by coach number..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="input pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedSchedule ? (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={seats}
|
||||
actions={actions}
|
||||
loading={isLoading}
|
||||
/>
|
||||
) : (
|
||||
{!selectedSchedule ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
Select a schedule to view seats
|
||||
<Armchair className="h-12 w-12 mx-auto mb-3 opacity-50" />
|
||||
<p>Select a schedule to view seat map</p>
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<div className="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>
|
||||
) : filteredCoaches.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<p>No coaches found for this schedule</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Legend */}
|
||||
<div className="flex items-center gap-6 p-4 bg-muted/50 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-green-500"></div>
|
||||
<span className="text-sm">Available</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-red-500"></div>
|
||||
<span className="text-sm">Booked</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-yellow-500"></div>
|
||||
<span className="text-sm">Held</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-gray-500"></div>
|
||||
<span className="text-sm">Blocked</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Coaches */}
|
||||
{filteredCoaches.map((coach: any) => {
|
||||
const seats = coach.seats || [];
|
||||
const seatClass = coach.seatClass?.name || 'N/A';
|
||||
const availableCount = seats.filter((s: any) => getSeatStatus(s) === 'AVAILABLE').length;
|
||||
const bookedCount = seats.filter((s: any) => getSeatStatus(s) === 'BOOKED').length;
|
||||
const blockedCount = seats.filter((s: any) => getSeatStatus(s) === 'BLOCKED').length;
|
||||
|
||||
return (
|
||||
<div key={coach.id} className="border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">
|
||||
Coach {coach.coachNumber} - {coach.label}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{seatClass} • {seats.length} seats
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3 text-sm">
|
||||
<span className="text-green-600">Available: {availableCount}</span>
|
||||
<span className="text-red-600">Booked: {bookedCount}</span>
|
||||
<span className="text-gray-600">Blocked: {blockedCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-8 gap-2">
|
||||
{seats.map((seat: any) => {
|
||||
const status = getSeatStatus(seat);
|
||||
const color = getSeatColor(status);
|
||||
const canBlock = status === 'AVAILABLE';
|
||||
const canUnblock = status === 'BLOCKED';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={seat.id}
|
||||
className="relative group"
|
||||
>
|
||||
<div
|
||||
className={`${color} text-white rounded-lg p-2 text-center text-sm font-medium cursor-pointer hover:opacity-80 transition-opacity`}
|
||||
title={`${seat.seatNumber} - ${status}`}
|
||||
>
|
||||
{seat.seatNumber}
|
||||
</div>
|
||||
{(canBlock || canUnblock) && (
|
||||
<div className="absolute inset-0 bg-black/60 rounded-lg opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1">
|
||||
{canBlock && (
|
||||
<button
|
||||
onClick={() => handleBlock(seat)}
|
||||
className="p-1 bg-white rounded hover:bg-gray-100"
|
||||
title="Block seat"
|
||||
>
|
||||
<Lock className="h-3 w-3 text-gray-700" />
|
||||
</button>
|
||||
)}
|
||||
{canUnblock && (
|
||||
<button
|
||||
onClick={() => handleUnblock(seat)}
|
||||
className="p-1 bg-white rounded hover:bg-gray-100"
|
||||
title="Unblock seat"
|
||||
>
|
||||
<Unlock className="h-3 w-3 text-gray-700" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Block Modal */}
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { ReactNode, useState } from 'react';
|
||||
import { ReactNode, useState, useRef, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ChevronUp, ChevronDown, MoreHorizontal } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import ActionButton from './ActionButton';
|
||||
@@ -42,6 +43,22 @@ export default function DataTable<T extends Record<string, any>>({
|
||||
}: DataTableProps<T>) {
|
||||
const [sortConfig, setSortConfig] = useState<{ key: string; direction: 'asc' | 'desc' } | null>(null);
|
||||
const [expandedActions, setExpandedActions] = useState<string | null>(null);
|
||||
const [dropdownPosition, setDropdownPosition] = useState<{ top: number; left: number } | null>(null);
|
||||
const buttonRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
const target = e.target as Node;
|
||||
const isButtonClick = Object.values(buttonRefs.current).some(ref => ref?.contains(target));
|
||||
const isDropdownClick = document.querySelector('[data-dropdown-menu]')?.contains(target);
|
||||
|
||||
if (expandedActions && !isButtonClick && !isDropdownClick) {
|
||||
setExpandedActions(null);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [expandedActions]);
|
||||
|
||||
// Ensure data is always an array
|
||||
const safeData = Array.isArray(data) ? data : [];
|
||||
@@ -81,7 +98,7 @@ export default function DataTable<T extends Record<string, any>>({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('card p-0 overflow-visible', className)}>
|
||||
<div className={cn('card p-0', className)}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||
@@ -145,66 +162,45 @@ export default function DataTable<T extends Record<string, any>>({
|
||||
))}
|
||||
{actions && actions.length > 0 && (
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="relative">
|
||||
{(() => {
|
||||
const visibleActions = actions.filter(action => !action.show || action.show(item));
|
||||
|
||||
if (visibleActions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (visibleActions.length === 1) {
|
||||
const action = visibleActions[0];
|
||||
return (
|
||||
<ActionButton
|
||||
onClick={() => action.onClick(item)}
|
||||
variant={action.variant || 'secondary'}
|
||||
size="sm"
|
||||
icon={action.icon}
|
||||
>
|
||||
{action.label}
|
||||
</ActionButton>
|
||||
);
|
||||
}
|
||||
|
||||
{(() => {
|
||||
const visibleActions = actions.filter(action => !action.show || action.show(item));
|
||||
|
||||
if (visibleActions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (visibleActions.length === 1) {
|
||||
const action = visibleActions[0];
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setExpandedActions(expandedActions === item.id ? null : item.id);
|
||||
}}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</button>
|
||||
{expandedActions === item.id && (
|
||||
<div className="absolute right-0 top-full mt-1 w-48 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-10">
|
||||
<div className="py-1">
|
||||
{visibleActions.map((action, actionIndex) => (
|
||||
<button
|
||||
key={actionIndex}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
action.onClick(item);
|
||||
setExpandedActions(null);
|
||||
}}
|
||||
className={cn(
|
||||
'w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center gap-2',
|
||||
action.variant === 'danger' && 'text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20'
|
||||
)}
|
||||
>
|
||||
{action.icon && <action.icon className="h-4 w-4" />}
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
<ActionButton
|
||||
onClick={() => action.onClick(item)}
|
||||
variant={action.variant || 'secondary'}
|
||||
size="sm"
|
||||
icon={action.icon}
|
||||
>
|
||||
{action.label}
|
||||
</ActionButton>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={(el) => { buttonRefs.current[item.id] = 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
|
||||
});
|
||||
setExpandedActions(expandedActions === item.id ? null : item.id);
|
||||
}}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
@@ -218,6 +214,47 @@ export default function DataTable<T extends Record<string, any>>({
|
||||
{emptyMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{expandedActions && dropdownPosition && typeof window !== 'undefined' && createPortal(
|
||||
<div
|
||||
data-dropdown-menu
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: `${dropdownPosition.top}px`,
|
||||
left: `${dropdownPosition.left}px`,
|
||||
zIndex: 9999,
|
||||
}}
|
||||
className="w-48 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<div className="py-1">
|
||||
{actions
|
||||
?.filter(action => !action.show || action.show(sortedData.find(item => item.id === expandedActions)!))
|
||||
.map((action, actionIndex) => {
|
||||
const item = sortedData.find(item => item.id === expandedActions);
|
||||
if (!item) return null;
|
||||
return (
|
||||
<button
|
||||
key={actionIndex}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setExpandedActions(null);
|
||||
action.onClick(item);
|
||||
}}
|
||||
className={cn(
|
||||
'w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center gap-2',
|
||||
action.variant === 'danger' && 'text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20'
|
||||
)}
|
||||
>
|
||||
{action.icon && <action.icon className="h-4 w-4" />}
|
||||
{action.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -111,6 +111,10 @@ export const schedulesApi = {
|
||||
|
||||
// Seats API
|
||||
export const seatsApi = {
|
||||
getSeatMap: (scheduleId: string, coachId?: string) => {
|
||||
const params = coachId ? `?coachId=${coachId}` : '';
|
||||
return apiClient.get<any>(`/seats/seatmap/${scheduleId}${params}`);
|
||||
},
|
||||
getBySchedule: (scheduleId: string) => apiClient.get<any>(`/seats/schedule/${scheduleId}`),
|
||||
hold: (data: any) => apiClient.post<any>('/seats/hold', data),
|
||||
release: (holdId: string) => apiClient.delete(`/seats/hold/${holdId}`),
|
||||
|
||||
Reference in New Issue
Block a user