Files
edr-platform/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx

734 lines
24 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';
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 [editingItem, setEditingItem] = useState<any>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null }>({ isOpen: false, item: null });
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({}),
});
// 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 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 = {
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,
};
if (editingItem?.isCoach) {
await updateCoachMutation.mutateAsync({ id: editingItem.id, data });
} else {
await createCoachMutation.mutateAsync(data);
}
};
const handleDelete = (item: any, isCoachType: boolean) => {
setDeleteConfirm({ isOpen: true, item: { ...item, isCoachType } });
};
const confirmDelete = async () => {
if (deleteConfirm.item?.isCoachType) {
await deleteCoachTypeMutation.mutateAsync(deleteConfirm.item.id);
} else {
await deleteCoachMutation.mutateAsync(deleteConfirm.item.id);
}
setDeleteConfirm({ isOpen: false, item: null });
};
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',
MAINTENANCE: 'edr-badge-warning',
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: 'Arrangement',
render: (coach: any) => (
<span className="text-sm font-mono">{coach.arrangement || 'N/A'}</span>
),
},
{
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 });
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);
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>
</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>
)}
</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}
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);
}}
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 || ''}
required
>
<option value="">Select Coach Type</option>
{coachTypesArray.map((ct: any) => (
<option key={ct.id} value={ct.id}>
{ct.code} - {ct.name} - {ct.type}
</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>
<div>
<label className="label">Arrangement *</label>
<input
type="text"
name="arrangement"
className="input"
defaultValue={editingItem?.arrangement || editingItem?.seatArrangement }
required
placeholder="e.g., 3+2, 3+0, 2+0"
/>
<p className="text-xs text-muted-foreground mt-1">Format: separate columns with +</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 || 0}
min="0"
required
placeholder="e.g., 1"
/>
<p className="text-xs text-muted-foreground mt-1">Used for ordering coaches in trains</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="MAINTENANCE">Maintenance</option>
<option value="INACTIVE">Inactive</option>
</select>
</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={createCoachMutation.isPending || updateCoachMutation.isPending}
>
{editingItem?.isCoach ? 'Update' : 'Create'} Coach
</ActionButton>
</div>
</form>
)}
</Modal>
</div>
);
}