mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
469 lines
16 KiB
TypeScript
469 lines
16 KiB
TypeScript
'use client';
|
||
|
||
import { useState } from 'react';
|
||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||
import { Download, Plus, Edit, Trash2, Train as TrainIcon } from 'lucide-react';
|
||
import DataTable from '@/components/ui/DataTable';
|
||
import Badge from '@/components/ui/Badge';
|
||
import ActionButton from '@/components/ui/ActionButton';
|
||
import Modal from '@/components/ui/Modal';
|
||
import { schedulesApi, fleetApi } from '@/lib/api';
|
||
import { routesApi } from '@/lib/api/routes';
|
||
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
||
|
||
export default function SchedulesPage() {
|
||
const [filters, setFilters] = useState({ search: '', status: '' });
|
||
const [showModal, setShowModal] = useState(false);
|
||
const [showCoachModal, setShowCoachModal] = useState(false);
|
||
const [editingSchedule, setEditingSchedule] = useState<any>(null);
|
||
const [selectedSchedule, setSelectedSchedule] = useState<any>(null);
|
||
const [selectedCoaches, setSelectedCoaches] = useState<Array<{ coachId: string; positionNumber: number }>>([]);
|
||
const queryClient = useQueryClient();
|
||
|
||
const { data, isLoading } = useQuery({
|
||
queryKey: ['schedules', filters],
|
||
queryFn: () => schedulesApi.getAll(filters),
|
||
});
|
||
|
||
const { data: trainsData } = useQuery({
|
||
queryKey: ['trains'],
|
||
queryFn: () => fleetApi.getTrains(),
|
||
});
|
||
|
||
const { data: routesData } = useQuery({
|
||
queryKey: ['routes'],
|
||
queryFn: () => routesApi.getAll(),
|
||
});
|
||
|
||
const { data: coachesData } = useQuery({
|
||
queryKey: ['coaches'],
|
||
queryFn: () => fleetApi.getCoaches(),
|
||
});
|
||
|
||
const createMutation = useMutation({
|
||
mutationFn: schedulesApi.create,
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['schedules'] });
|
||
setShowModal(false);
|
||
setEditingSchedule(null);
|
||
},
|
||
});
|
||
|
||
const updateMutation = useMutation({
|
||
mutationFn: ({ id, data }: { id: string; data: any }) => schedulesApi.update(id, data),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['schedules'] });
|
||
setShowModal(false);
|
||
setEditingSchedule(null);
|
||
},
|
||
});
|
||
|
||
const deleteMutation = useMutation({
|
||
mutationFn: schedulesApi.delete,
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['schedules'] });
|
||
},
|
||
});
|
||
|
||
const assignCoachesMutation = useMutation({
|
||
mutationFn: ({ scheduleId, coaches }: { scheduleId: string; coaches: Array<{ coachId: string; positionNumber: number }> }) =>
|
||
schedulesApi.assignCoaches(scheduleId, coaches),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['schedules'] });
|
||
setShowCoachModal(false);
|
||
setSelectedSchedule(null);
|
||
setSelectedCoaches([]);
|
||
alert('Coaches assigned successfully');
|
||
},
|
||
});
|
||
|
||
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);
|
||
|
||
const departureAt = formData.get('departureAt') as string;
|
||
const arrivalAt = formData.get('arrivalAt') as string;
|
||
|
||
// Convert datetime-local to ISO 8601
|
||
const departureISO = new Date(departureAt).toISOString();
|
||
const arrivalISO = new Date(arrivalAt).toISOString();
|
||
|
||
const scheduleData = {
|
||
trainId: formData.get('trainId') as string,
|
||
routeId: formData.get('routeId') as string,
|
||
departureAt: departureISO,
|
||
arrivalAt: arrivalISO,
|
||
plannedTimes: [], // Will be auto-generated by backend based on route stops
|
||
};
|
||
|
||
if (editingSchedule) {
|
||
await updateMutation.mutateAsync({ id: editingSchedule.id, data: scheduleData });
|
||
} else {
|
||
await createMutation.mutateAsync(scheduleData);
|
||
}
|
||
};
|
||
|
||
const trains = trainsData?.items || trainsData?.data || [];
|
||
const routes = routesData?.items || routesData?.data || [];
|
||
const coaches = coachesData?.items || coachesData?.data || [];
|
||
|
||
const handleDelete = async (schedule: any) => {
|
||
if (confirm('Are you sure you want to delete this schedule?')) {
|
||
await deleteMutation.mutateAsync(schedule.id);
|
||
}
|
||
};
|
||
|
||
const handleAssignCoaches = (schedule: any) => {
|
||
setSelectedSchedule(schedule);
|
||
setSelectedCoaches([]);
|
||
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);
|
||
if (exists) {
|
||
return prev.filter(c => c.coachId !== coachId);
|
||
} else {
|
||
const maxPosition = prev.length > 0 ? Math.max(...prev.map(c => c.positionNumber)) : 0;
|
||
return [...prev, { coachId, positionNumber: maxPosition + 1 }];
|
||
}
|
||
});
|
||
};
|
||
|
||
const handleSubmitCoaches = async () => {
|
||
if (selectedCoaches.length === 0) {
|
||
alert('Please select at least one coach');
|
||
return;
|
||
}
|
||
await assignCoachesMutation.mutateAsync({
|
||
scheduleId: selectedSchedule.id,
|
||
coaches: selectedCoaches,
|
||
});
|
||
};
|
||
|
||
const columns = [
|
||
{ key: 'train', label: 'Train', render: (schedule: any) => schedule.train?.name || 'N/A' },
|
||
{ key: 'route', label: 'Route', render: (schedule: any) => schedule.route?.name || `${schedule.originStation?.name || 'N/A'} → ${schedule.destinationStation?.name || 'N/A'}` },
|
||
{ key: 'departureAt', label: 'Departure', render: (schedule: any) => formatDateTime(schedule.departureAt) },
|
||
{
|
||
key: 'coaches',
|
||
label: 'Coaches',
|
||
render: (schedule: any) => {
|
||
const coachCount = schedule._count?.coachAssignments || 0;
|
||
if (coachCount === 0) {
|
||
return <span className="text-sm text-muted-foreground">No coaches assigned</span>;
|
||
}
|
||
return (
|
||
<div className="flex flex-wrap gap-1">
|
||
{schedule.coachAssignments?.slice(0, 3).map((assignment: any) => (
|
||
<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">
|
||
+{coachCount - 3}
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
},
|
||
{ key: 'status', label: 'Status', render: (schedule: any) => <Badge variant="status" status={schedule.status}>{schedule.status}</Badge> },
|
||
];
|
||
|
||
const actions = [
|
||
{
|
||
label: 'Assign Coaches',
|
||
onClick: handleAssignCoaches,
|
||
variant: 'primary' as const,
|
||
icon: TrainIcon,
|
||
},
|
||
{
|
||
label: 'Edit',
|
||
onClick: (schedule: any) => {
|
||
setEditingSchedule(schedule);
|
||
setShowModal(true);
|
||
},
|
||
variant: 'secondary' as const,
|
||
icon: Edit,
|
||
},
|
||
{
|
||
label: 'Delete',
|
||
onClick: handleDelete,
|
||
variant: 'danger' as const,
|
||
icon: Trash2,
|
||
},
|
||
];
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h1 className="text-2xl font-bold text-foreground">Schedules</h1>
|
||
<p className="text-muted-foreground">Manage train schedules and trips</p>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<ActionButton icon={Download} variant="secondary">Export</ActionButton>
|
||
<ActionButton
|
||
icon={Plus}
|
||
onClick={() => {
|
||
setEditingSchedule(null);
|
||
setShowModal(true);
|
||
}}
|
||
>
|
||
Add Schedule
|
||
</ActionButton>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="card">
|
||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||
<div>
|
||
<label className="label">Search</label>
|
||
<input type="text" placeholder="Search..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} />
|
||
</div>
|
||
<div>
|
||
<label className="label">Status</label>
|
||
<select className="input" value={filters.status} onChange={(e) => setFilters({ ...filters, status: e.target.value })}>
|
||
<option value="">All Status</option>
|
||
<option value="SCHEDULED">Scheduled</option>
|
||
<option value="ACTIVE">Active</option>
|
||
<option value="COMPLETED">Completed</option>
|
||
<option value="CANCELLED">Cancelled</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<DataTable
|
||
data={(data as any)?.items || (Array.isArray(data) ? data : [])}
|
||
columns={columns}
|
||
actions={actions}
|
||
loading={isLoading}
|
||
emptyMessage="No schedules found"
|
||
/>
|
||
|
||
{/* Add/Edit Modal */}
|
||
<Modal
|
||
isOpen={showModal}
|
||
onClose={() => {
|
||
setShowModal(false);
|
||
setEditingSchedule(null);
|
||
}}
|
||
title={`${editingSchedule ? 'Edit' : 'Add'} Schedule`}
|
||
size="lg"
|
||
>
|
||
<form onSubmit={handleSubmit} className="space-y-4">
|
||
<div className="grid grid-cols-1 gap-4">
|
||
<div>
|
||
<label className="label">Train *</label>
|
||
<select
|
||
name="trainId"
|
||
className="input"
|
||
defaultValue={editingSchedule?.trainId}
|
||
required
|
||
>
|
||
<option value="">Select Train</option>
|
||
{trains.map((train: any) => (
|
||
<option key={train.id} value={train.id}>
|
||
{train.trainNumber || train.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="label">Route *</label>
|
||
<select
|
||
name="routeId"
|
||
className="input"
|
||
defaultValue={editingSchedule?.routeId}
|
||
required
|
||
>
|
||
<option value="">Select Route</option>
|
||
{routes.map((route: any) => (
|
||
<option key={route.id} value={route.id}>
|
||
{route.code} - {route.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="label">Departure Time *</label>
|
||
<input
|
||
type="datetime-local"
|
||
name="departureAt"
|
||
className="input"
|
||
defaultValue={editingSchedule?.departureAt?.slice(0, 16)}
|
||
required
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="label">Arrival Time *</label>
|
||
<input
|
||
type="datetime-local"
|
||
name="arrivalAt"
|
||
className="input"
|
||
defaultValue={editingSchedule?.arrivalAt?.slice(0, 16)}
|
||
required
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="label">Status</label>
|
||
<select
|
||
name="status"
|
||
className="input"
|
||
defaultValue={editingSchedule?.status || 'SCHEDULED'}
|
||
>
|
||
<option value="SCHEDULED">Scheduled</option>
|
||
<option value="ACTIVE">Active</option>
|
||
<option value="COMPLETED">Completed</option>
|
||
<option value="CANCELLED">Cancelled</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex justify-end gap-2 pt-4">
|
||
<ActionButton
|
||
type="button"
|
||
variant="secondary"
|
||
onClick={() => {
|
||
setShowModal(false);
|
||
setEditingSchedule(null);
|
||
}}
|
||
>
|
||
Cancel
|
||
</ActionButton>
|
||
<ActionButton
|
||
type="submit"
|
||
loading={createMutation.isPending || updateMutation.isPending}
|
||
>
|
||
{editingSchedule ? 'Update' : 'Create'} Schedule
|
||
</ActionButton>
|
||
</div>
|
||
</form>
|
||
</Modal>
|
||
|
||
{/* Coach Assignment Modal */}
|
||
<Modal
|
||
isOpen={showCoachModal}
|
||
onClose={() => {
|
||
setShowCoachModal(false);
|
||
setSelectedSchedule(null);
|
||
setSelectedCoaches([]);
|
||
}}
|
||
title="Assign Coaches to Schedule"
|
||
size="lg"
|
||
>
|
||
<div className="space-y-4">
|
||
<p className="text-sm text-muted-foreground">
|
||
Select coaches to assign to this schedule. Coaches will be ordered by selection.
|
||
</p>
|
||
|
||
<div className="grid grid-cols-1 gap-3 max-h-96 overflow-y-auto">
|
||
{coaches.map((coach: any) => {
|
||
const isSelected = selectedCoaches.some(c => c.coachId === coach.id);
|
||
const position = selectedCoaches.find(c => c.coachId === coach.id)?.positionNumber;
|
||
|
||
return (
|
||
<div
|
||
key={coach.id}
|
||
onClick={() => handleToggleCoach(coach.id)}
|
||
className={`p-4 border rounded-lg cursor-pointer transition-colors ${
|
||
isSelected
|
||
? 'border-edr-green-600 bg-edr-green-50 dark:bg-edr-green-900/20'
|
||
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||
}`}
|
||
>
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<div className="font-medium">{coach.label}</div>
|
||
<div className="text-sm text-muted-foreground">
|
||
{coach.coachNumber} • {coach.seatClass?.name || 'N/A'} • {coach.totalUnits} seats
|
||
</div>
|
||
</div>
|
||
{isSelected && (
|
||
<div className="flex items-center gap-2">
|
||
<Badge variant="status" status="CONFIRMED">
|
||
Position {position}
|
||
</Badge>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{selectedCoaches.length > 0 && (
|
||
<div className="p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||
<div className="text-sm font-medium mb-2">Selected Coaches ({selectedCoaches.length}):</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
{selectedCoaches
|
||
.sort((a, b) => a.positionNumber - b.positionNumber)
|
||
.map(sc => {
|
||
const coach = coaches.find((c: any) => c.id === sc.coachId);
|
||
return (
|
||
<Badge key={sc.coachId} variant="status" status="CONFIRMED">
|
||
{sc.positionNumber}. {coach?.label || 'Unknown'}
|
||
</Badge>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex justify-end gap-2 pt-4">
|
||
<ActionButton
|
||
type="button"
|
||
variant="secondary"
|
||
onClick={() => {
|
||
setShowCoachModal(false);
|
||
setSelectedSchedule(null);
|
||
setSelectedCoaches([]);
|
||
}}
|
||
>
|
||
Cancel
|
||
</ActionButton>
|
||
<ActionButton
|
||
type="button"
|
||
onClick={handleSubmitCoaches}
|
||
loading={assignCoachesMutation.isPending}
|
||
disabled={selectedCoaches.length === 0}
|
||
>
|
||
Assign {selectedCoaches.length} Coach{selectedCoaches.length !== 1 ? 'es' : ''}
|
||
</ActionButton>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
</div>
|
||
);
|
||
}
|