Files
edr-platform/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx
2026-06-02 22:35:07 +03:00

469 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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