mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
972 lines
34 KiB
TypeScript
972 lines
34 KiB
TypeScript
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { Plus, Search, Grid3x3, Edit, Trash2, Bed, Armchair } from 'lucide-react';
|
|
import DataTable from '@/components/ui/DataTable';
|
|
import ActionButton from '@/components/ui/ActionButton';
|
|
import Modal from '@/components/ui/Modal';
|
|
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
|
import { fleetApi, apiClient } from '@/lib/api';
|
|
|
|
type Tab = 'types' | 'coaches' | 'utilization';
|
|
|
|
const getBedLabel = (bedPosition: string | null): string => {
|
|
if (bedPosition === 'upper') return 'U';
|
|
if (bedPosition === 'middle') return 'M';
|
|
if (bedPosition === 'lower') return 'L';
|
|
return '';
|
|
};
|
|
|
|
const renderBedVisualization = (coach: any) => {
|
|
const seats = coach.seats || [];
|
|
const validSeats = seats.filter((s: any) => s.seatNumber && !s.seatNumber.startsWith('-'));
|
|
|
|
if (validSeats.length === 0) {
|
|
return <div className="text-xs text-muted-foreground">No seats</div>;
|
|
}
|
|
|
|
const hasBedPositionData = validSeats.some((s: any) => s.bedPosition);
|
|
const isBedCoach = coach.coachType?.name?.toLowerCase().includes('bed');
|
|
|
|
if (!isBedCoach || !hasBedPositionData) {
|
|
// Regular seat layout
|
|
const arrangement = coach.seatArrangement || coach.arrangement || '2+2';
|
|
const [left, right] = arrangement.split('+').map((p: string) => parseInt(p.trim()));
|
|
const cols = new Map<number, any[]>();
|
|
|
|
for (const seat of validSeats) {
|
|
if (!cols.has(seat.row)) cols.set(seat.row, []);
|
|
cols.get(seat.row)!.push(seat);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-1">
|
|
{Array.from(cols.entries()).map(([row, rowSeats]) => (
|
|
<div key={row} className="flex gap-3 justify-start">
|
|
<div className="flex gap-0.5">
|
|
{rowSeats.slice(0, left).map((s: any) => (
|
|
<div key={s.id} className="w-6 h-6 rounded bg-green-500 flex items-center justify-center">
|
|
<Armchair className="w-3 h-3 text-white" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="flex gap-0.5">
|
|
{rowSeats.slice(left).map((s: any) => (
|
|
<div key={s.id} className="w-6 h-6 rounded bg-green-500 flex items-center justify-center">
|
|
<Armchair className="w-3 h-3 text-white" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Bed layout with pairing
|
|
const seatsByRow = new Map<number, any[]>();
|
|
for (const seat of validSeats) {
|
|
if (!seatsByRow.has(seat.row)) seatsByRow.set(seat.row, []);
|
|
seatsByRow.get(seat.row)!.push(seat);
|
|
}
|
|
|
|
const beds = coach.coachType?.name?.toLowerCase().includes('vip') ? 'w-12' : 'w-10';
|
|
const rows = Array.from(seatsByRow.entries()).map(([r, s]) => s);
|
|
|
|
return (
|
|
<div className="space-y-1">
|
|
{rows.map((rowSeats: any[], idx: number) => {
|
|
const rowNumber = rowSeats[0]?.row || (idx + 1);
|
|
const isFirstInPair = (rowNumber - 1) % 2 === 0;
|
|
const isLastRow = idx === rows.length - 1;
|
|
const nextRowSeats = !isLastRow ? rows[idx + 1] : null;
|
|
|
|
return (
|
|
<div key={`row-${idx}`}>
|
|
{/* Row 1 of pair - label above */}
|
|
{isFirstInPair && (
|
|
<div className="flex gap-0.5 text-xs text-gray-500 mb-0.5">
|
|
{rowSeats.map((s: any) => (
|
|
<div key={`label-${s.id}`} className={`${beds} h-2 flex items-center justify-center text-xs font-bold leading-3`}>
|
|
{s.seatNumber}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
{/* Row 1 of pair - beds */}
|
|
<div className="flex gap-0.5">
|
|
{rowSeats.map((s: any) => (
|
|
<div
|
|
key={s.id}
|
|
className={`${beds} h-5 rounded flex items-center justify-center bg-green-500`}
|
|
style={isFirstInPair ? { transform: 'scaleY(-1)' } : undefined}
|
|
>
|
|
<Bed className="w-3 h-3 text-white" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
{/* Numbers between rows */}
|
|
{isFirstInPair && nextRowSeats && (
|
|
<div className="flex gap-0.5 text-xs text-gray-500 my-0.5">
|
|
{rowSeats.map((s: any, idx: number) => {
|
|
const nextSeat = nextRowSeats[idx];
|
|
return (
|
|
<div key={`between-${s.id}`} className={`${beds} h-2 flex items-center justify-center text-xs font-bold leading-3`}>
|
|
{nextSeat?.seatNumber}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
{/* Row 2 of pair - beds */}
|
|
{!isFirstInPair && (
|
|
<div className="flex gap-0.5">
|
|
{rowSeats.map((s: any) => (
|
|
<div
|
|
key={s.id}
|
|
className={`${beds} h-5 rounded flex items-center justify-center bg-green-500`}
|
|
>
|
|
<Bed className="w-3 h-3 text-white" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
{!isFirstInPair && <div className="h-1" />}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default function CoachesPage() {
|
|
const [activeTab, setActiveTab] = useState<Tab>('coaches');
|
|
const [search, setSearch] = useState('');
|
|
const [showModal, setShowModal] = useState(false);
|
|
const [showPreviewModal, setShowPreviewModal] = useState(false);
|
|
const [seatMapPreview, setSeatMapPreview] = useState<any>(null);
|
|
const [editingItem, setEditingItem] = useState<any>(null);
|
|
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; error?: string }>({ isOpen: false, item: null });
|
|
const [selectedCoachTypeId, setSelectedCoachTypeId] = useState<string>('');
|
|
const queryClient = useQueryClient();
|
|
|
|
// Coach Types Queries
|
|
const { data: coachTypesData, isLoading: typesLoading } = useQuery({
|
|
queryKey: ['coach-types'],
|
|
queryFn: () => apiClient.get('/fleet/coach-types'),
|
|
});
|
|
|
|
// Coaches Queries
|
|
const { data: coachesData, isLoading: coachesLoading } = useQuery({
|
|
queryKey: ['coaches'],
|
|
queryFn: () => fleetApi.getCoaches({}),
|
|
});
|
|
|
|
const { data: utilizationData, isLoading: utilizationLoading } = useQuery({
|
|
queryKey: ['coach-utilization'],
|
|
queryFn: () => apiClient.get<any[]>('/fleet/coaches/utilization'),
|
|
enabled: activeTab === 'utilization',
|
|
});
|
|
|
|
// Coach Type Mutations
|
|
const createCoachTypeMutation = useMutation({
|
|
mutationFn: (data: any) => apiClient.post('/fleet/coach-types', data),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['coach-types'] });
|
|
setShowModal(false);
|
|
setEditingItem(null);
|
|
},
|
|
});
|
|
|
|
const updateCoachTypeMutation = useMutation({
|
|
mutationFn: ({ id, data }: { id: string; data: any }) => apiClient.patch(`/fleet/coach-types/${id}`, data),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['coach-types'] });
|
|
setShowModal(false);
|
|
setEditingItem(null);
|
|
},
|
|
});
|
|
|
|
const deleteCoachTypeMutation = useMutation({
|
|
mutationFn: (id: string) => apiClient.delete(`/fleet/coach-types/${id}`),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['coach-types'] });
|
|
},
|
|
});
|
|
|
|
// Coach Mutations
|
|
const createCoachMutation = useMutation({
|
|
mutationFn: fleetApi.createCoach,
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['coaches'] });
|
|
setShowModal(false);
|
|
setEditingItem(null);
|
|
},
|
|
});
|
|
|
|
const updateCoachMutation = useMutation({
|
|
mutationFn: ({ id, data }: { id: string; data: any }) => fleetApi.updateCoach(id, data),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['coaches'] });
|
|
setShowModal(false);
|
|
setEditingItem(null);
|
|
},
|
|
});
|
|
|
|
const deleteCoachMutation = useMutation({
|
|
mutationFn: fleetApi.deleteCoach,
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['coaches'] });
|
|
},
|
|
});
|
|
|
|
const generateSeatMapMutation = useMutation({
|
|
mutationFn: fleetApi.generateSeatMap,
|
|
onSuccess: (data) => {
|
|
setSeatMapPreview(data);
|
|
setShowPreviewModal(true);
|
|
},
|
|
});
|
|
|
|
const handleCoachTypeSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
|
e.preventDefault();
|
|
const formData = new FormData(e.currentTarget);
|
|
const data = {
|
|
code: formData.get('code') as string,
|
|
name: formData.get('name') as string,
|
|
type: formData.get('type') as string,
|
|
};
|
|
|
|
if (editingItem?.isCoachType) {
|
|
await updateCoachTypeMutation.mutateAsync({ id: editingItem.id, data });
|
|
} else {
|
|
await createCoachTypeMutation.mutateAsync(data);
|
|
}
|
|
};
|
|
|
|
const handleCoachSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
|
e.preventDefault();
|
|
const formData = new FormData(e.currentTarget);
|
|
|
|
const data: any = {
|
|
number: formData.get('number') as string,
|
|
coachTypeId: formData.get('coachTypeId') as string,
|
|
arrangement: formData.get('arrangement') as string,
|
|
capacity: parseInt(formData.get('capacity') as string),
|
|
sequence: parseInt(formData.get('sequence') as string),
|
|
status: formData.get('status') as string,
|
|
};
|
|
|
|
// Add bed-specific fields if bed coach is selected
|
|
const bedCategory = formData.get('bedCategory') as string;
|
|
if (bedCategory) {
|
|
data.bedCategory = bedCategory as 'ECONOMY_BED' | 'VIP_BED';
|
|
const bedsPerRoom = formData.get('bedsPerRoom') as string;
|
|
if (bedsPerRoom) {
|
|
data.bedsPerRoom = parseInt(bedsPerRoom);
|
|
}
|
|
}
|
|
|
|
if (editingItem?.isCoach) {
|
|
await updateCoachMutation.mutateAsync({ id: editingItem.id, data });
|
|
} else {
|
|
await createCoachMutation.mutateAsync(data);
|
|
}
|
|
};
|
|
|
|
const handlePreviewSeatMap = async () => {
|
|
const form = document.querySelector('form') as HTMLFormElement;
|
|
const formData = new FormData(form);
|
|
const bedCategory = formData.get('bedCategory') as string;
|
|
const capacity = parseInt(formData.get('capacity') as string);
|
|
|
|
if (!bedCategory || !capacity) {
|
|
alert('Please select a bed category and enter capacity to preview seat map');
|
|
return;
|
|
}
|
|
|
|
const bedsPerRoom = bedCategory === 'VIP_BED' ? 4 : 6;
|
|
const roomsPerCoach = Math.ceil(capacity / bedsPerRoom);
|
|
|
|
await generateSeatMapMutation.mutateAsync({
|
|
coachCount: 1,
|
|
roomsPerCoach,
|
|
roomType: bedCategory,
|
|
});
|
|
};
|
|
|
|
const handleDelete = (item: any, isCoachType: boolean) => {
|
|
setDeleteConfirm({ isOpen: true, item: { ...item, isCoachType } });
|
|
};
|
|
|
|
const confirmDelete = async () => {
|
|
try {
|
|
if (deleteConfirm.item?.isCoachType) {
|
|
await deleteCoachTypeMutation.mutateAsync(deleteConfirm.item.id);
|
|
} else {
|
|
await deleteCoachMutation.mutateAsync(deleteConfirm.item.id);
|
|
}
|
|
setDeleteConfirm({ isOpen: false, item: null });
|
|
} catch (err: any) {
|
|
const msg = err?.response?.data?.message || err?.message || 'Delete failed';
|
|
setDeleteConfirm((prev) => ({ ...prev, error: msg }));
|
|
}
|
|
};
|
|
|
|
const coachTypesArray = Array.isArray(coachTypesData) ? coachTypesData : (coachTypesData as any)?.items || (coachTypesData as any)?.data || [];
|
|
const coaches = coachesData?.items || coachesData?.data || [];
|
|
|
|
const filteredCoachTypes = coachTypesArray.filter((ct: any) => {
|
|
if (!search) return true;
|
|
const searchLower = search.toLowerCase();
|
|
return (
|
|
ct.code?.toLowerCase().includes(searchLower) ||
|
|
ct.name?.toLowerCase().includes(searchLower) ||
|
|
ct.type?.toLowerCase().includes(searchLower)
|
|
);
|
|
});
|
|
|
|
const filteredCoaches = coaches.filter((coach: any) => {
|
|
if (!search) return true;
|
|
const searchLower = search.toLowerCase();
|
|
return (
|
|
coach.number?.toLowerCase().includes(searchLower) ||
|
|
coach.coachNumber?.toLowerCase().includes(searchLower) ||
|
|
coach.coachType?.name?.toLowerCase().includes(searchLower) ||
|
|
coach.arrangement?.toLowerCase().includes(searchLower)
|
|
);
|
|
});
|
|
|
|
const typeColorMap: Record<string, string> = {
|
|
passenger: 'edr-badge-info',
|
|
sleeper: 'edr-badge-warning',
|
|
dining: 'edr-badge-success',
|
|
baggage: 'edr-badge-danger',
|
|
};
|
|
|
|
const statusMap: Record<string, string> = {
|
|
ACTIVE: 'edr-badge-success',
|
|
INACTIVE: 'edr-badge-danger',
|
|
};
|
|
|
|
// Coach Types Columns
|
|
const coachTypeColumns = [
|
|
{
|
|
key: 'code',
|
|
label: 'Code',
|
|
sortable: true,
|
|
render: (ct: 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)]">
|
|
<Grid3x3 className="h-4 w-4 text-white" />
|
|
</div>
|
|
<span className="font-mono font-medium">{ct.code}</span>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'name',
|
|
label: 'Name',
|
|
sortable: true,
|
|
render: (ct: any) => <span className="font-medium">{ct.name}</span>,
|
|
},
|
|
{
|
|
key: 'type',
|
|
label: 'Type',
|
|
render: (ct: any) => (
|
|
<span className={`edr-badge ${typeColorMap[ct.type] || 'edr-badge-info'}`}>
|
|
{ct.type}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'coaches',
|
|
label: 'Coaches',
|
|
render: (ct: any) => (
|
|
<span className="text-sm font-medium">{ct.coaches?.length || 0}</span>
|
|
),
|
|
},
|
|
];
|
|
|
|
// Coaches Columns
|
|
const coachColumns = [
|
|
{
|
|
key: 'sequence',
|
|
label: 'Sequence',
|
|
sortable: true,
|
|
render: (coach: any) => (
|
|
<span className="font-mono font-semibold text-sm">{coach.sequence}</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'number',
|
|
label: 'Number',
|
|
sortable: true,
|
|
render: (coach: 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)]">
|
|
<Grid3x3 className="h-4 w-4 text-white" />
|
|
</div>
|
|
<span className="font-medium">{coach.number || coach.coachNumber}</span>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'coachType',
|
|
label: 'Coach Type',
|
|
render: (coach: any) => (
|
|
<span className="text-sm">{coach.coachType?.name || 'N/A'}</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'arrangement',
|
|
label: 'Type/Arrangement',
|
|
render: (coach: any) => {
|
|
// Check if this is a bed coach based on coach type name containing 'bed'
|
|
const coachTypeName = coach.coachType?.name?.toLowerCase() || '';
|
|
const isBedCoach = coachTypeName.includes('bed') || coachTypeName.includes('sleeper') || coachTypeName.includes('berth');
|
|
|
|
if (isBedCoach) {
|
|
// Determine if it's VIP or Economy based on coach type name
|
|
const isVIP = coachTypeName.includes('vip');
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
<Bed className="h-4 w-4 text-blue-600" />
|
|
<span className="text-sm font-mono">{coach.arrangement || 'N/A'}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
<Armchair className="h-4 w-4 text-green-600" />
|
|
<span className="text-sm font-mono">{coach.arrangement || 'N/A'}</span>
|
|
</div>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
key: 'capacity',
|
|
label: 'Capacity',
|
|
render: (coach: any) => (
|
|
<span className="font-mono text-sm font-medium">{coach.capacity || 0}</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'status',
|
|
label: 'Status',
|
|
render: (coach: any) => {
|
|
const status = coach.status || 'ACTIVE';
|
|
return (
|
|
<span className={`edr-badge ${statusMap[status] || 'edr-badge-info'}`}>
|
|
{status}
|
|
</span>
|
|
);
|
|
},
|
|
},
|
|
];
|
|
|
|
const coachTypeActions = [
|
|
{
|
|
label: 'Edit',
|
|
onClick: (item: any) => {
|
|
setEditingItem({ ...item, isCoachType: true });
|
|
setShowModal(true);
|
|
},
|
|
variant: 'secondary' as const,
|
|
icon: Edit,
|
|
},
|
|
{
|
|
label: 'Delete',
|
|
onClick: (item: any) => handleDelete(item, true),
|
|
variant: 'danger' as const,
|
|
icon: Trash2,
|
|
},
|
|
];
|
|
|
|
const coachActions = [
|
|
{
|
|
label: 'Edit',
|
|
onClick: (item: any) => {
|
|
setEditingItem({ ...item, isCoach: true });
|
|
setSelectedCoachTypeId(item.coachTypeId || '');
|
|
setShowModal(true);
|
|
},
|
|
variant: 'secondary' as const,
|
|
icon: Edit,
|
|
},
|
|
{
|
|
label: 'Delete',
|
|
onClick: (item: any) => handleDelete(item, false),
|
|
variant: 'danger' as const,
|
|
icon: Trash2,
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-3xl font-bold text-foreground">Coach Management</h1>
|
|
<p className="text-muted-foreground mt-1">Manage coach types and train coaches</p>
|
|
</div>
|
|
<ActionButton
|
|
icon={Plus}
|
|
onClick={() => {
|
|
setEditingItem(null);
|
|
setSelectedCoachTypeId('');
|
|
setSearch('');
|
|
setShowModal(true);
|
|
}}
|
|
>
|
|
{activeTab === 'types' ? 'Add Coach Type' : 'Add Coach'}
|
|
</ActionButton>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="card">
|
|
<div className="flex border-b border-border">
|
|
<button
|
|
onClick={() => {
|
|
setActiveTab('types');
|
|
setSearch('');
|
|
}}
|
|
className={`px-4 py-3 font-medium transition-colors ${
|
|
activeTab === 'types'
|
|
? 'border-b-2 border-primary text-primary'
|
|
: 'text-muted-foreground hover:text-foreground'
|
|
}`}
|
|
>
|
|
Coach Types
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
setActiveTab('coaches');
|
|
setSearch('');
|
|
}}
|
|
className={`px-4 py-3 font-medium transition-colors ${
|
|
activeTab === 'coaches'
|
|
? 'border-b-2 border-primary text-primary'
|
|
: 'text-muted-foreground hover:text-foreground'
|
|
}`}
|
|
>
|
|
Coaches
|
|
</button>
|
|
<button
|
|
onClick={() => { setActiveTab('utilization'); setSearch(''); }}
|
|
className={`px-4 py-3 font-medium transition-colors ${
|
|
activeTab === 'utilization'
|
|
? 'border-b-2 border-primary text-primary'
|
|
: 'text-muted-foreground hover:text-foreground'
|
|
}`}
|
|
>
|
|
Utilization Report
|
|
</button>
|
|
</div>
|
|
|
|
{/* Coach Types Tab */}
|
|
{activeTab === 'types' && (
|
|
<div className="pt-6 space-y-4">
|
|
<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 code, name, or type..."
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="input pl-10 w-full"
|
|
/>
|
|
</div>
|
|
<DataTable
|
|
columns={coachTypeColumns}
|
|
data={filteredCoachTypes}
|
|
actions={coachTypeActions}
|
|
loading={typesLoading}
|
|
emptyMessage={search ? "No coach types match your search" : "No coach types found. Create one to get started."}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Coaches Tab */}
|
|
{activeTab === 'coaches' && (
|
|
<div className="pt-6 space-y-4">
|
|
<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, type, or arrangement..."
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="input pl-10 w-full"
|
|
/>
|
|
</div>
|
|
<DataTable
|
|
columns={coachColumns}
|
|
data={filteredCoaches}
|
|
actions={coachActions}
|
|
loading={coachesLoading}
|
|
emptyMessage={search ? "No coaches match your search" : "No coaches found. Create one to get started."}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Utilization Tab */}
|
|
{activeTab === 'utilization' && (() => {
|
|
const rows = Array.isArray(utilizationData) ? utilizationData : (utilizationData as any)?.data || [];
|
|
return (
|
|
<div className="pt-6 space-y-4">
|
|
<DataTable
|
|
columns={[
|
|
{ key: 'sequence', label: 'Seq', render: (r: any) => <span className="font-mono">{r.sequence}</span> },
|
|
{ key: 'number', label: 'Coach', render: (r: any) => <span className="font-medium">{r.number}</span> },
|
|
{ key: 'coachType', label: 'Type', render: (r: any) => <span className="text-sm">{r.coachType || 'N/A'}</span> },
|
|
{ key: 'totalSeats', label: 'Total Seats', render: (r: any) => <span className="font-mono">{r.totalSeats}</span> },
|
|
{ key: 'availableSeats', label: 'Available', render: (r: any) => <span className="font-mono text-green-600">{r.availableSeats}</span> },
|
|
{ key: 'bookedSeats', label: 'Booked', render: (r: any) => <span className="font-mono text-red-600">{r.bookedSeats}</span> },
|
|
{ key: 'blockedSeats', label: 'Blocked', render: (r: any) => <span className="font-mono text-gray-500">{r.blockedSeats}</span> },
|
|
{ key: 'maintenanceSeats', label: 'Maintenance', render: (r: any) => <span className="font-mono text-orange-500">{r.maintenanceSeats}</span> },
|
|
{
|
|
key: 'utilizationRate', label: 'Utilization',
|
|
render: (r: any) => (
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-20 h-2 bg-muted rounded-full overflow-hidden">
|
|
<div className="h-full bg-primary rounded-full" style={{ width: `${r.utilizationRate}%` }} />
|
|
</div>
|
|
<span className="font-mono text-sm">{r.utilizationRate}%</span>
|
|
</div>
|
|
),
|
|
},
|
|
{ key: 'totalAssignments', label: 'Assignments', render: (r: any) => <span className="font-mono">{r.totalAssignments}</span> },
|
|
{ key: 'totalBookings', label: 'Total Bookings', render: (r: any) => <span className="font-mono font-semibold">{r.totalBookings}</span> },
|
|
]}
|
|
data={rows}
|
|
actions={[]}
|
|
loading={utilizationLoading}
|
|
emptyMessage="No coach utilization data available"
|
|
/>
|
|
</div>
|
|
);
|
|
})()}
|
|
</div>
|
|
|
|
{/* Delete Confirmation */}
|
|
<ConfirmDialog
|
|
isOpen={deleteConfirm.isOpen}
|
|
onClose={() => setDeleteConfirm({ isOpen: false, item: null })}
|
|
onConfirm={confirmDelete}
|
|
title={`Delete ${deleteConfirm.item?.isCoachType ? 'Coach Type' : 'Coach'}`}
|
|
message={`Are you sure you want to delete ${deleteConfirm.item?.name || deleteConfirm.item?.number}?`}
|
|
confirmText="Delete"
|
|
isDanger={true}
|
|
isLoading={deleteCoachTypeMutation.isPending || deleteCoachMutation.isPending}
|
|
error={deleteConfirm.error}
|
|
warning={
|
|
deleteConfirm.item?.isCoachType
|
|
? 'This coach type may have coaches assigned. Deleting it may impact these systems.'
|
|
: 'This coach may be assigned to schedules. Deleting it may impact these systems.'
|
|
}
|
|
/>
|
|
|
|
{/* Add/Edit Modal */}
|
|
<Modal
|
|
isOpen={showModal}
|
|
onClose={() => {
|
|
setShowModal(false);
|
|
setEditingItem(null);
|
|
setSelectedCoachTypeId('');
|
|
}}
|
|
title={
|
|
activeTab === 'types'
|
|
? `${editingItem?.isCoachType ? 'Edit' : 'Add'} Coach Type`
|
|
: `${editingItem?.isCoach ? 'Edit' : 'Add'} Coach`
|
|
}
|
|
size={activeTab === 'types' ? 'md' : 'lg'}
|
|
>
|
|
{/* Coach Type Form */}
|
|
{activeTab === 'types' && (
|
|
<form onSubmit={handleCoachTypeSubmit} className="space-y-4">
|
|
<div className="grid grid-cols-1 gap-4">
|
|
<div>
|
|
<label className="label">Code</label>
|
|
<input
|
|
type="text"
|
|
name="code"
|
|
className="input"
|
|
defaultValue={editingItem?.code || ''}
|
|
required
|
|
placeholder="e.g., HSC"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="label">Name</label>
|
|
<input
|
|
type="text"
|
|
name="name"
|
|
className="input"
|
|
defaultValue={editingItem?.name || ''}
|
|
required
|
|
placeholder="e.g., Hard Seat Coach"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="label">Type</label>
|
|
<input
|
|
type="text"
|
|
name="type"
|
|
className="input"
|
|
defaultValue={editingItem?.type || ''}
|
|
required
|
|
placeholder="e.g., Regular Seat"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-2 pt-4">
|
|
<ActionButton
|
|
type="button"
|
|
variant="secondary"
|
|
onClick={() => {
|
|
setShowModal(false);
|
|
setEditingItem(null);
|
|
}}
|
|
>
|
|
Cancel
|
|
</ActionButton>
|
|
<ActionButton
|
|
type="submit"
|
|
loading={createCoachTypeMutation.isPending || updateCoachTypeMutation.isPending}
|
|
>
|
|
{editingItem?.isCoachType ? 'Update' : 'Create'} Coach Type
|
|
</ActionButton>
|
|
</div>
|
|
</form>
|
|
)}
|
|
|
|
{/* Coach Form */}
|
|
{activeTab === 'coaches' && (
|
|
<form onSubmit={handleCoachSubmit} className="space-y-4">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="label">Coach Type *</label>
|
|
<select
|
|
name="coachTypeId"
|
|
className="input"
|
|
defaultValue={editingItem?.coachTypeId || ''}
|
|
onChange={(e) => setSelectedCoachTypeId(e.target.value)}
|
|
required
|
|
>
|
|
<option value="">Select Coach Type</option>
|
|
{coachTypesArray.map((ct: any) => (
|
|
<option key={ct.id} value={ct.id}>
|
|
{ct.code} - {ct.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="label">Number *</label>
|
|
<input
|
|
type="text"
|
|
name="number"
|
|
className="input"
|
|
defaultValue={editingItem?.number || editingItem?.coachNumber || ''}
|
|
required
|
|
placeholder="e.g., HSC-0001"
|
|
/>
|
|
</div>
|
|
|
|
{/* Conditionally show bed fields only for Economy and Regular coach types */}
|
|
{(() => {
|
|
const selectedCoachType = coachTypesArray.find((ct: any) => ct.id === (selectedCoachTypeId || editingItem?.coachTypeId));
|
|
const isEconomyOrRegular = selectedCoachType &&
|
|
(selectedCoachType.name?.toLowerCase().includes('economy') ||
|
|
selectedCoachType.name?.toLowerCase().includes('regular') ||
|
|
selectedCoachType.type?.toLowerCase().includes('economy') ||
|
|
selectedCoachType.type?.toLowerCase().includes('regular'));
|
|
|
|
return isEconomyOrRegular ? (
|
|
<>
|
|
<div>
|
|
<label className="label">Bed Category</label>
|
|
<select
|
|
name="bedCategory"
|
|
className="input"
|
|
defaultValue={editingItem?.bedCategory || ''}
|
|
>
|
|
<option value="">Select bed category</option>
|
|
<option value="ECONOMY_BED">Economy Bed</option>
|
|
<option value="VIP_BED">VIP Bed</option>
|
|
</select>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
Select if this is a bed coach
|
|
</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="label">Beds Per Room</label>
|
|
<select
|
|
name="bedsPerRoom"
|
|
className="input"
|
|
defaultValue={editingItem?.bedsPerRoom || ''}
|
|
>
|
|
<option value="">Auto (VIP: 4, Economy: 6)</option>
|
|
<option value="2">2 beds per room</option>
|
|
<option value="4">4 beds per room</option>
|
|
<option value="6">6 beds per room</option>
|
|
</select>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
Only applies to bed coaches
|
|
</p>
|
|
</div>
|
|
</>
|
|
) : null;
|
|
})()}
|
|
|
|
<div>
|
|
<label className="label">Arrangement *</label>
|
|
<input
|
|
type="text"
|
|
name="arrangement"
|
|
className="input"
|
|
defaultValue={editingItem?.arrangement || editingItem?.seatArrangement || '2+2'}
|
|
required
|
|
placeholder="e.g., 2+2, 3+2"
|
|
/>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
For regular seats: columns separated by +
|
|
</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="label">Capacity *</label>
|
|
<input
|
|
type="number"
|
|
name="capacity"
|
|
className="input"
|
|
defaultValue={editingItem?.capacity || editingItem?.totalUnits || ''}
|
|
required
|
|
min="1"
|
|
placeholder="60"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="label">Sequence Number *</label>
|
|
<input
|
|
type="number"
|
|
name="sequence"
|
|
className="input"
|
|
defaultValue={editingItem?.sequence || 1}
|
|
min="1"
|
|
required
|
|
placeholder="1"
|
|
/>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
Position in train consist
|
|
</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="label">Status *</label>
|
|
<select
|
|
name="status"
|
|
className="input"
|
|
defaultValue={editingItem?.status || 'ACTIVE'}
|
|
required
|
|
>
|
|
<option value="ACTIVE">Active</option>
|
|
<option value="INACTIVE">Inactive</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-2 pt-4">
|
|
<ActionButton
|
|
type="button"
|
|
variant="secondary"
|
|
onClick={handlePreviewSeatMap}
|
|
loading={generateSeatMapMutation.isPending}
|
|
>
|
|
Preview Bed Layout
|
|
</ActionButton>
|
|
<ActionButton
|
|
type="button"
|
|
variant="secondary"
|
|
onClick={() => {
|
|
setShowModal(false);
|
|
setEditingItem(null);
|
|
setSelectedCoachTypeId('');
|
|
}}
|
|
>
|
|
Cancel
|
|
</ActionButton>
|
|
<ActionButton
|
|
type="submit"
|
|
loading={createCoachMutation.isPending || updateCoachMutation.isPending}
|
|
>
|
|
{editingItem?.isCoach ? 'Update' : 'Create'} Coach
|
|
</ActionButton>
|
|
</div>
|
|
</form>
|
|
)}
|
|
</Modal>
|
|
|
|
{/* Seat Map Preview Modal */}
|
|
<Modal
|
|
isOpen={showPreviewModal}
|
|
onClose={() => {
|
|
setShowPreviewModal(false);
|
|
setSeatMapPreview(null);
|
|
}}
|
|
title="Bed Layout Preview"
|
|
size="lg"
|
|
>
|
|
{seatMapPreview && (
|
|
<div className="space-y-4">
|
|
<div className="bg-muted/50 p-4 rounded-lg">
|
|
<h4 className="font-semibold mb-2">Configuration</h4>
|
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
|
<div>Room Type: <span className="font-medium">{seatMapPreview.roomType}</span></div>
|
|
<div>Rooms per Coach: <span className="font-medium">{seatMapPreview.roomsPerCoach}</span></div>
|
|
<div>Beds per Room: <span className="font-medium">{seatMapPreview.bedsPerRoom}</span></div>
|
|
<div>Total Beds: <span className="font-medium">{seatMapPreview.totalBeds}</span></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<h4 className="font-semibold">Bed Layout Sample (First Few Rooms)</h4>
|
|
<div className="bg-gray-50 p-4 rounded border max-h-64 overflow-y-auto">
|
|
{seatMapPreview.seats?.slice(0, 24).map((seat: any, idx: number) => (
|
|
<div key={idx} className="text-xs mb-1 font-mono">
|
|
{seat.seat_id} - Room: {seat.room_id} - {seat.position} {seat.bed_type}
|
|
</div>
|
|
))}
|
|
{seatMapPreview.seats?.length > 24 && (
|
|
<div className="text-xs text-muted-foreground mt-2">
|
|
... and {seatMapPreview.seats.length - 24} more beds
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-end">
|
|
<ActionButton
|
|
onClick={() => {
|
|
setShowPreviewModal(false);
|
|
setSeatMapPreview(null);
|
|
}}
|
|
>
|
|
Close
|
|
</ActionButton>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Modal>
|
|
</div>
|
|
);
|
|
}
|