Updated the passenger web portal and added more endpoints to the api

This commit is contained in:
Stephanos A
2026-06-02 14:51:12 +03:00
parent 5059a1fe58
commit ed30ab0cda
34 changed files with 3396 additions and 674 deletions

View File

@@ -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([]);
}}
>

View File

@@ -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">

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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}`),