mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 09:35:44 +00:00
Fare and route-coach, production checklist updates
This commit is contained in:
@@ -1,15 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Edit, Trash2, X, Search } from 'lucide-react';
|
||||
import { Plus, Edit, Trash2, X, Search, Train, Save, GripVertical } 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 ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||
import { routesApi } from '@/lib/api/routes';
|
||||
import { stationsApi } from '@/lib/api';
|
||||
import { stationsApi, fleetApi, routeCoachTemplatesApi } from '@/lib/api';
|
||||
|
||||
interface RouteStop {
|
||||
stationId: string;
|
||||
@@ -18,7 +18,153 @@ interface RouteStop {
|
||||
distanceFromOrigin?: number;
|
||||
}
|
||||
|
||||
type Tab = 'routes' | 'coaches';
|
||||
|
||||
function RouteCoachesTab({ routes }: { routes: any[] }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedRouteId, setSelectedRouteId] = useState('');
|
||||
const [coachRows, setCoachRows] = useState<{ coachId: string; positionNumber: number }[]>([]);
|
||||
|
||||
const { data: coaches } = useQuery({
|
||||
queryKey: ['coaches-all'],
|
||||
queryFn: () => fleetApi.getCoaches(),
|
||||
});
|
||||
|
||||
const { data: template, isLoading: templateLoading } = useQuery({
|
||||
queryKey: ['route-coaches', selectedRouteId],
|
||||
queryFn: () => routeCoachTemplatesApi.get(selectedRouteId),
|
||||
enabled: !!selectedRouteId,
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () => routeCoachTemplatesApi.set(selectedRouteId, coachRows),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['route-coaches', selectedRouteId] }),
|
||||
});
|
||||
|
||||
const clearMutation = useMutation({
|
||||
mutationFn: () => routeCoachTemplatesApi.clear(selectedRouteId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['route-coaches', selectedRouteId] });
|
||||
setCoachRows([]);
|
||||
},
|
||||
});
|
||||
|
||||
// Sync coachRows when template loads
|
||||
useEffect(() => {
|
||||
const rows: any[] = Array.isArray(template) ? template : (template as any)?.coaches ?? [];
|
||||
if (rows.length) setCoachRows(rows.map((r: any) => ({ coachId: r.coachId ?? r.coach?.id, positionNumber: r.positionNumber })));
|
||||
}, [template]);
|
||||
|
||||
const allCoaches: any[] = (coaches as any)?.items ?? (Array.isArray(coaches) ? coaches : []);
|
||||
|
||||
const addRow = () => setCoachRows([...coachRows, { coachId: '', positionNumber: coachRows.length + 1 }]);
|
||||
const removeRow = (i: number) => {
|
||||
const updated = coachRows.filter((_, idx) => idx !== i);
|
||||
setCoachRows(updated.map((r, idx) => ({ ...r, positionNumber: idx + 1 })));
|
||||
};
|
||||
const updateRow = (i: number, field: 'coachId', val: string) => {
|
||||
const updated = [...coachRows];
|
||||
updated[i] = { ...updated[i], [field]: val };
|
||||
setCoachRows(updated);
|
||||
};
|
||||
|
||||
const selectedCoachIds = new Set(coachRows.map((r) => r.coachId).filter(Boolean));
|
||||
|
||||
const handleCoachDragStart = (e: React.DragEvent, index: number) => {
|
||||
e.dataTransfer.setData('coach-index', index.toString());
|
||||
};
|
||||
const handleCoachDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
(e.currentTarget as HTMLElement).style.opacity = '0.5';
|
||||
};
|
||||
const handleCoachDragLeave = (e: React.DragEvent) => {
|
||||
(e.currentTarget as HTMLElement).style.opacity = '1';
|
||||
};
|
||||
const handleCoachDrop = (e: React.DragEvent, targetIndex: number) => {
|
||||
e.preventDefault();
|
||||
(e.currentTarget as HTMLElement).style.opacity = '1';
|
||||
const sourceIndex = parseInt(e.dataTransfer.getData('coach-index'));
|
||||
if (sourceIndex === targetIndex) return;
|
||||
const reordered = [...coachRows];
|
||||
const [moved] = reordered.splice(sourceIndex, 1);
|
||||
reordered.splice(targetIndex, 0, moved);
|
||||
setCoachRows(reordered.map((r, idx) => ({ ...r, positionNumber: idx + 1 })));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1">
|
||||
<label className="label">Select Route</label>
|
||||
<select className="input" value={selectedRouteId} onChange={(e) => { setSelectedRouteId(e.target.value); setCoachRows([]); }}>
|
||||
<option value="">— choose a route —</option>
|
||||
{routes.map((r: any) => <option key={r.id} value={r.id}>{r.name} ({r.code})</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedRouteId && (
|
||||
<>
|
||||
{templateLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading template…</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{coachRows.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground">Drag <GripVertical className="inline h-3 w-3" /> to reorder coaches</p>
|
||||
)}
|
||||
{coachRows.map((row, i) => (
|
||||
<div
|
||||
key={i}
|
||||
draggable
|
||||
onDragStart={(e) => handleCoachDragStart(e, i)}
|
||||
onDragOver={handleCoachDragOver}
|
||||
onDragLeave={handleCoachDragLeave}
|
||||
onDrop={(e) => handleCoachDrop(e, i)}
|
||||
className="flex gap-2 items-center p-3 bg-muted/50 rounded cursor-move hover:bg-muted transition-colors"
|
||||
>
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
<div className="w-8 text-center text-sm font-medium text-muted-foreground flex-shrink-0">
|
||||
{row.positionNumber}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<select className="input input-sm" value={row.coachId} onChange={(e) => updateRow(i, 'coachId', e.target.value)}>
|
||||
<option value="">Select Coach</option>
|
||||
{allCoaches
|
||||
.filter((c: any) => !selectedCoachIds.has(c.id) || c.id === row.coachId)
|
||||
.map((c: any) => (
|
||||
<option key={c.id} value={c.id}>{c.number} — {c.coachType?.name ?? c.type}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<button type="button" onClick={() => removeRow(i)} className="p-1 text-destructive hover:bg-destructive/10 rounded flex-shrink-0">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex justify-between pt-2">
|
||||
<ActionButton type="button" variant="secondary" size="sm" icon={Plus} onClick={addRow}>Add Coach</ActionButton>
|
||||
<div className="flex gap-2">
|
||||
{coachRows.length > 0 && (
|
||||
<ActionButton type="button" variant="danger" size="sm" onClick={() => clearMutation.mutate()} loading={clearMutation.isPending}>
|
||||
Clear
|
||||
</ActionButton>
|
||||
)}
|
||||
<ActionButton type="button" size="sm" icon={Save} onClick={() => saveMutation.mutate()} loading={saveMutation.isPending}>
|
||||
Save Template
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RoutesPage() {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('routes');
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingRoute, setEditingRoute] = useState<any>(null);
|
||||
const [stops, setStops] = useState<RouteStop[]>([]);
|
||||
@@ -244,42 +390,69 @@ export default function RoutesPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Routes</h1>
|
||||
<p className="text-muted-foreground">Manage railway routes</p>
|
||||
<p className="text-muted-foreground">Manage railway routes and coach templates</p>
|
||||
</div>
|
||||
<ActionButton
|
||||
icon={Plus}
|
||||
onClick={() => {
|
||||
setEditingRoute(null);
|
||||
setOriginStationId('');
|
||||
setDestinationStationId('');
|
||||
setDestinationDistance(undefined);
|
||||
setStops([]);
|
||||
setSearch('');
|
||||
setShowModal(true);
|
||||
}}
|
||||
>
|
||||
Add Route
|
||||
</ActionButton>
|
||||
{activeTab === 'routes' && (
|
||||
<ActionButton
|
||||
icon={Plus}
|
||||
onClick={() => {
|
||||
setEditingRoute(null);
|
||||
setOriginStationId('');
|
||||
setDestinationStationId('');
|
||||
setDestinationDistance(undefined);
|
||||
setStops([]);
|
||||
setSearch('');
|
||||
setShowModal(true);
|
||||
}}
|
||||
>
|
||||
Add Route
|
||||
</ActionButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative mb-6">
|
||||
<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 description..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="input pl-10 w-full"
|
||||
/>
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 border-b">
|
||||
{(['routes', 'coaches'] as Tab[]).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`px-4 py-2 text-sm font-medium capitalize transition-colors border-b-2 -mb-px ${
|
||||
activeTab === tab
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{tab === 'coaches' ? 'Coach Templates' : 'Routes'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
data={displayedRoutes}
|
||||
columns={routeColumns}
|
||||
actions={routeActions}
|
||||
loading={routesLoading}
|
||||
emptyMessage={search ? "No routes match your search" : "No routes found"}
|
||||
/>
|
||||
{activeTab === 'routes' && (
|
||||
<>
|
||||
<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 description..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="input pl-10 w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
data={displayedRoutes}
|
||||
columns={routeColumns}
|
||||
actions={routeActions}
|
||||
loading={routesLoading}
|
||||
emptyMessage={search ? 'No routes match your search' : 'No routes found'}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'coaches' && (
|
||||
<RouteCoachesTab routes={filteredRoutes} />
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirm.isOpen}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Loader2, Zap, Trash2, Edit, Search, X } from 'lucide-react';
|
||||
import { Plus, Loader2, Zap, Trash2, Edit, Search, X, GripVertical } 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 { apiClient } from '@/lib/api-client';
|
||||
import { routeCoachTemplatesApi } from '@/lib/api';
|
||||
|
||||
interface Schedule {
|
||||
id: string;
|
||||
@@ -46,6 +47,7 @@ interface Coach {
|
||||
|
||||
export default function SchedulesPage() {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [editingSchedule, setEditingSchedule] = useState<Schedule | null>(null);
|
||||
const [selectedSchedules, setSelectedSchedules] = useState<Set<string>>(new Set());
|
||||
@@ -59,12 +61,45 @@ export default function SchedulesPage() {
|
||||
trainId: '',
|
||||
routeId: '',
|
||||
startDateTime: '',
|
||||
durationHours: '12',
|
||||
repeatEveryDays: '1',
|
||||
forNextDays: '30',
|
||||
coachIds: [] as string[],
|
||||
durationHours: '10',
|
||||
repeatEveryDays: '2',
|
||||
forNextDays: '15',
|
||||
});
|
||||
|
||||
const [bulkCoachRows, setBulkCoachRows] = useState<{ coachId: string; positionNumber: number }[]>([]);
|
||||
|
||||
const [addForm, setAddForm] = useState({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' });
|
||||
const [addCoachRows, setAddCoachRows] = useState<{ coachId: string; positionNumber: number }[]>([]);
|
||||
|
||||
const { data: singleRouteTemplate, isLoading: singleTemplateLoading } = useQuery({
|
||||
queryKey: ['route-coaches', addForm.routeId],
|
||||
queryFn: () => routeCoachTemplatesApi.get(addForm.routeId),
|
||||
enabled: !!addForm.routeId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!addForm.routeId) { setAddCoachRows([]); return; }
|
||||
const rows: any[] = Array.isArray(singleRouteTemplate) ? singleRouteTemplate : (singleRouteTemplate as any)?.coaches ?? [];
|
||||
setAddCoachRows(rows.length ? rows.map((r: any) => ({ coachId: r.coachId ?? r.coach?.id, positionNumber: r.positionNumber })) : []);
|
||||
}, [singleRouteTemplate, addForm.routeId]);
|
||||
|
||||
// Fetch route coach template when route changes
|
||||
const { data: routeTemplate, isLoading: templateLoading } = useQuery({
|
||||
queryKey: ['route-coaches', bulkForm.routeId],
|
||||
queryFn: () => routeCoachTemplatesApi.get(bulkForm.routeId),
|
||||
enabled: !!bulkForm.routeId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!bulkForm.routeId) { setBulkCoachRows([]); return; }
|
||||
const rows: any[] = Array.isArray(routeTemplate) ? routeTemplate : (routeTemplate as any)?.coaches ?? [];
|
||||
setBulkCoachRows(
|
||||
rows.length
|
||||
? rows.map((r: any) => ({ coachId: r.coachId ?? r.coach?.id, positionNumber: r.positionNumber }))
|
||||
: []
|
||||
);
|
||||
}, [routeTemplate, bulkForm.routeId]);
|
||||
|
||||
const [editForm, setEditForm] = useState({
|
||||
departureAt: '',
|
||||
arrivalAt: '',
|
||||
@@ -121,8 +156,8 @@ export default function SchedulesPage() {
|
||||
durationHours: '12',
|
||||
repeatEveryDays: '1',
|
||||
forNextDays: '30',
|
||||
coachIds: [],
|
||||
});
|
||||
setBulkCoachRows([]);
|
||||
setError(null);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
@@ -130,6 +165,20 @@ export default function SchedulesPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const createScheduleMutation = useMutation({
|
||||
mutationFn: (data: any) => apiClient.post('/schedules', data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['schedules'] });
|
||||
setShowAddModal(false);
|
||||
setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' });
|
||||
setAddCoachRows([]);
|
||||
setError(null);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
setError(err.response?.data?.message || 'Failed to create schedule');
|
||||
},
|
||||
});
|
||||
|
||||
const updateScheduleMutation = useMutation({
|
||||
mutationFn: (data: { id: string; payload: any }) =>
|
||||
apiClient.patch(`/schedules/${data.id}`, data.payload),
|
||||
@@ -187,13 +236,30 @@ export default function SchedulesPage() {
|
||||
forNextDays: parseInt(bulkForm.forNextDays),
|
||||
};
|
||||
|
||||
if (bulkForm.coachIds.length > 0) {
|
||||
payload.coachIds = bulkForm.coachIds;
|
||||
const validCoaches = bulkCoachRows.filter((r) => r.coachId);
|
||||
if (validCoaches.length > 0) {
|
||||
payload.coachIds = validCoaches.map((r) => r.coachId);
|
||||
}
|
||||
|
||||
await bulkGenerateMutation.mutateAsync(payload);
|
||||
};
|
||||
|
||||
const handleAddSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
const dep = new Date(addForm.departureAt);
|
||||
const arr = new Date(addForm.arrivalAt);
|
||||
if (arr <= dep) { setError('Arrival must be after departure'); return; }
|
||||
const validCoaches = addCoachRows.filter((r) => r.coachId);
|
||||
await createScheduleMutation.mutateAsync({
|
||||
trainId: addForm.trainId,
|
||||
routeId: addForm.routeId,
|
||||
departureAt: dep.toISOString(),
|
||||
arrivalAt: arr.toISOString(),
|
||||
...(validCoaches.length > 0 && { coachIds: validCoaches.map((r) => r.coachId) }),
|
||||
});
|
||||
};
|
||||
|
||||
const handleEditSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
@@ -396,6 +462,13 @@ export default function SchedulesPage() {
|
||||
},
|
||||
] as any;
|
||||
|
||||
const cancelScheduleMutation = useMutation({
|
||||
mutationFn: (id: string) => apiClient.patch(`/schedules/${id}/status`, { status: 'CANCELLED' }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['schedules'] }),
|
||||
});
|
||||
|
||||
const [cancelConfirm, setCancelConfirm] = useState<{ isOpen: boolean; item: Schedule | null }>({ isOpen: false, item: null });
|
||||
|
||||
const scheduleActions = [
|
||||
{
|
||||
label: 'Edit',
|
||||
@@ -403,6 +476,13 @@ export default function SchedulesPage() {
|
||||
variant: 'secondary' as const,
|
||||
icon: Edit,
|
||||
},
|
||||
{
|
||||
label: 'Cancel',
|
||||
onClick: (schedule: Schedule) => setCancelConfirm({ isOpen: true, item: schedule }),
|
||||
variant: 'danger' as const,
|
||||
icon: X,
|
||||
hidden: (schedule: Schedule) => schedule.status === 'CANCELLED',
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
onClick: handleDelete,
|
||||
@@ -428,6 +508,13 @@ export default function SchedulesPage() {
|
||||
Delete {selectedSchedules.size} Schedule{selectedSchedules.size !== 1 ? 's' : ''}
|
||||
</ActionButton>
|
||||
)}
|
||||
<ActionButton
|
||||
icon={Plus}
|
||||
variant="secondary"
|
||||
onClick={() => { setError(null); setShowAddModal(true); }}
|
||||
>
|
||||
Add Schedule
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
icon={Zap}
|
||||
onClick={() => {
|
||||
@@ -530,6 +617,22 @@ export default function SchedulesPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={cancelConfirm.isOpen}
|
||||
onClose={() => setCancelConfirm({ isOpen: false, item: null })}
|
||||
onConfirm={async () => {
|
||||
if (cancelConfirm.item) {
|
||||
await cancelScheduleMutation.mutateAsync(cancelConfirm.item.id);
|
||||
setCancelConfirm({ isOpen: false, item: null });
|
||||
}
|
||||
}}
|
||||
title="Cancel Schedule"
|
||||
message={`Cancel the schedule departing ${cancelConfirm.item ? new Date(cancelConfirm.item.departureAt).toLocaleString() : ''}? Passengers with bookings will need to be notified separately.`}
|
||||
confirmText="Cancel Schedule"
|
||||
isDanger={true}
|
||||
isLoading={cancelScheduleMutation.isPending}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirm.isOpen}
|
||||
onClose={() => setDeleteConfirm({ isOpen: false, item: null })}
|
||||
@@ -549,6 +652,107 @@ export default function SchedulesPage() {
|
||||
warning="Schedules with existing bookings cannot be deleted."
|
||||
/>
|
||||
|
||||
<Modal
|
||||
isOpen={showAddModal}
|
||||
onClose={() => { setShowAddModal(false); setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' }); setAddCoachRows([]); setError(null); }}
|
||||
title="Add Schedule"
|
||||
size="lg"
|
||||
>
|
||||
<form onSubmit={handleAddSubmit} className="space-y-4">
|
||||
{error && <div className="bg-red-50 p-3 rounded-lg text-sm text-red-800">{error}</div>}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="label">Train *</label>
|
||||
<select className="input" value={addForm.trainId} onChange={(e) => setAddForm({ ...addForm, trainId: e.target.value })} required>
|
||||
<option value="">Select Train</option>
|
||||
{trains.map((t: Train) => <option key={t.id} value={t.id}>{t.number} ({t.name})</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Route *</label>
|
||||
<select className="input" value={addForm.routeId} onChange={(e) => setAddForm({ ...addForm, routeId: e.target.value })} required>
|
||||
<option value="">Select Route</option>
|
||||
{routes.map((r: Route) => <option key={r.id} value={r.id}>{r.code} ({r.name})</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="label">Departure *</label>
|
||||
<input type="datetime-local" className="input" value={addForm.departureAt} onChange={(e) => setAddForm({ ...addForm, departureAt: e.target.value })} required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Arrival *</label>
|
||||
<input type="datetime-local" className="input" value={addForm.arrivalAt} onChange={(e) => setAddForm({ ...addForm, arrivalAt: e.target.value })} required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="label mb-0">Coaches</label>
|
||||
<div className="flex items-center gap-3">
|
||||
{singleTemplateLoading && addForm.routeId && (
|
||||
<span className="text-xs text-muted-foreground flex items-center gap-1"><Loader2 className="h-3 w-3 animate-spin" /> Loading template…</span>
|
||||
)}
|
||||
{!addForm.routeId && <span className="text-xs text-muted-foreground">Select a route to load its coach template</span>}
|
||||
<ActionButton type="button" variant="secondary" size="sm" icon={Plus}
|
||||
disabled={addCoachRows.filter(r => r.coachId).length >= coaches.length}
|
||||
onClick={() => setAddCoachRows([...addCoachRows, { coachId: '', positionNumber: addCoachRows.length + 1 }])}>
|
||||
Add Coach
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
{addCoachRows.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-2">No coaches assigned.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{addCoachRows.length > 1 && <p className="text-xs text-muted-foreground">Drag <GripVertical className="inline h-3 w-3" /> to reorder</p>}
|
||||
{addCoachRows.map((row, i) => {
|
||||
const selectedIds = new Set(addCoachRows.map((r) => r.coachId).filter(Boolean));
|
||||
return (
|
||||
<div key={i} draggable
|
||||
onDragStart={(e) => e.dataTransfer.setData('add-coach-idx', i.toString())}
|
||||
onDragOver={(e) => { e.preventDefault(); (e.currentTarget as HTMLElement).style.opacity = '0.5'; }}
|
||||
onDragLeave={(e) => { (e.currentTarget as HTMLElement).style.opacity = '1'; }}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault(); (e.currentTarget as HTMLElement).style.opacity = '1';
|
||||
const src = parseInt(e.dataTransfer.getData('add-coach-idx'));
|
||||
if (src === i) return;
|
||||
const reordered = [...addCoachRows];
|
||||
const [moved] = reordered.splice(src, 1);
|
||||
reordered.splice(i, 0, moved);
|
||||
setAddCoachRows(reordered.map((r, idx) => ({ ...r, positionNumber: idx + 1 })));
|
||||
}}
|
||||
className="flex gap-2 items-center p-2 bg-muted/50 rounded cursor-move hover:bg-muted transition-colors"
|
||||
>
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
<span className="w-6 text-center text-xs text-muted-foreground flex-shrink-0">{row.positionNumber}</span>
|
||||
<select className="input input-sm flex-1" value={row.coachId}
|
||||
onChange={(e) => { const u = [...addCoachRows]; u[i] = { ...u[i], coachId: e.target.value }; setAddCoachRows(u); }}>
|
||||
<option value="">Select Coach</option>
|
||||
{coaches.filter((c: Coach) => !selectedIds.has(c.id) || c.id === row.coachId).map((c: Coach) => (
|
||||
<option key={c.id} value={c.id}>{c.number || c.coachNumber} — {c.coachType?.name} (Cap: {c.capacity})</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="button" onClick={() => setAddCoachRows(addCoachRows.filter((_, idx) => idx !== i).map((r, idx) => ({ ...r, positionNumber: idx + 1 })))} className="p-1 text-destructive hover:bg-destructive/10 rounded flex-shrink-0">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<ActionButton type="button" variant="secondary" onClick={() => { setShowAddModal(false); setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' }); setAddCoachRows([]); setError(null); }}>Cancel</ActionButton>
|
||||
<ActionButton type="submit" loading={createScheduleMutation.isPending}>Create Schedule</ActionButton>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
onClose={() => {
|
||||
@@ -561,8 +765,8 @@ export default function SchedulesPage() {
|
||||
durationHours: '12',
|
||||
repeatEveryDays: '1',
|
||||
forNextDays: '30',
|
||||
coachIds: [],
|
||||
});
|
||||
setBulkCoachRows([]);
|
||||
}}
|
||||
title="Bulk Generate Schedules"
|
||||
size="lg"
|
||||
@@ -611,7 +815,7 @@ export default function SchedulesPage() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Start Date & Time *</label>
|
||||
<label className="label">Departure Date & Time *</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={bulkForm.startDateTime}
|
||||
@@ -627,7 +831,7 @@ export default function SchedulesPage() {
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={bulkForm.durationHours}
|
||||
placeholder={bulkForm.durationHours}
|
||||
onChange={(e) => setBulkForm({ ...bulkForm, durationHours: e.target.value })}
|
||||
className="input"
|
||||
/>
|
||||
@@ -638,7 +842,7 @@ export default function SchedulesPage() {
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={bulkForm.repeatEveryDays}
|
||||
placeholder={bulkForm.repeatEveryDays}
|
||||
onChange={(e) => setBulkForm({ ...bulkForm, repeatEveryDays: e.target.value })}
|
||||
className="input"
|
||||
/>
|
||||
@@ -649,61 +853,91 @@ export default function SchedulesPage() {
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={bulkForm.forNextDays}
|
||||
placeholder={bulkForm.forNextDays}
|
||||
onChange={(e) => setBulkForm({ ...bulkForm, forNextDays: e.target.value })}
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="border-t pt-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="label">Coaches (Optional)</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (bulkForm.coachIds.length === coaches.length) {
|
||||
setBulkForm({ ...bulkForm, coachIds: [] });
|
||||
} else {
|
||||
setBulkForm({ ...bulkForm, coachIds: coaches.map((c: Coach) => c.id) });
|
||||
}
|
||||
}}
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
{bulkForm.coachIds.length === coaches.length ? 'Deselect All' : 'Select All'}
|
||||
</button>
|
||||
<label className="label mb-0">Coaches</label>
|
||||
<div className="flex items-center gap-3">
|
||||
{templateLoading && bulkForm.routeId && (
|
||||
<span className="text-xs text-muted-foreground flex items-center gap-1"><Loader2 className="h-3 w-3 animate-spin" /> Loading template…</span>
|
||||
)}
|
||||
{!bulkForm.routeId && (
|
||||
<span className="text-xs text-muted-foreground">Select a route to load its coach template</span>
|
||||
)}
|
||||
<ActionButton type="button" variant="secondary" size="sm" icon={Plus}
|
||||
disabled={bulkCoachRows.filter(r => r.coachId).length >= coaches.length}
|
||||
onClick={() => setBulkCoachRows([...bulkCoachRows, { coachId: '', positionNumber: bulkCoachRows.length + 1 }])}>
|
||||
Add Coach
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border border-border rounded-lg p-3 max-h-64 overflow-y-auto space-y-2">
|
||||
{coaches.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No coaches available</p>
|
||||
) : (
|
||||
coaches.map((coach: Coach) => (
|
||||
<label key={coach.id} className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={bulkForm.coachIds.includes(coach.id)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setBulkForm({
|
||||
...bulkForm,
|
||||
coachIds: [...bulkForm.coachIds, coach.id],
|
||||
});
|
||||
} else {
|
||||
setBulkForm({
|
||||
...bulkForm,
|
||||
coachIds: bulkForm.coachIds.filter((id) => id !== coach.id),
|
||||
});
|
||||
}
|
||||
|
||||
{bulkCoachRows.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-2">No coaches assigned — schedules will be created without coach assignments.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{bulkCoachRows.length > 1 && (
|
||||
<p className="text-xs text-muted-foreground">Drag <GripVertical className="inline h-3 w-3" /> to reorder</p>
|
||||
)}
|
||||
{bulkCoachRows.map((row, i) => {
|
||||
const selectedIds = new Set(bulkCoachRows.map((r) => r.coachId).filter(Boolean));
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
draggable
|
||||
onDragStart={(e) => e.dataTransfer.setData('bulk-coach-idx', i.toString())}
|
||||
onDragOver={(e) => { e.preventDefault(); (e.currentTarget as HTMLElement).style.opacity = '0.5'; }}
|
||||
onDragLeave={(e) => { (e.currentTarget as HTMLElement).style.opacity = '1'; }}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
(e.currentTarget as HTMLElement).style.opacity = '1';
|
||||
const src = parseInt(e.dataTransfer.getData('bulk-coach-idx'));
|
||||
if (src === i) return;
|
||||
const reordered = [...bulkCoachRows];
|
||||
const [moved] = reordered.splice(src, 1);
|
||||
reordered.splice(i, 0, moved);
|
||||
setBulkCoachRows(reordered.map((r, idx) => ({ ...r, positionNumber: idx + 1 })));
|
||||
}}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-sm">
|
||||
{coach.sequence || 'N/A'} - {coach.number || coach.coachNumber} - {coach.coachType?.name} (Cap: {coach.capacity})
|
||||
</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
className="flex gap-2 items-center p-2 bg-muted/50 rounded cursor-move hover:bg-muted transition-colors"
|
||||
>
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
<span className="w-6 text-center text-xs text-muted-foreground flex-shrink-0">{row.positionNumber}</span>
|
||||
<select
|
||||
className="input input-sm flex-1"
|
||||
value={row.coachId}
|
||||
onChange={(e) => {
|
||||
const updated = [...bulkCoachRows];
|
||||
updated[i] = { ...updated[i], coachId: e.target.value };
|
||||
setBulkCoachRows(updated);
|
||||
}}
|
||||
>
|
||||
<option value="">Select Coach</option>
|
||||
{coaches
|
||||
.filter((c: Coach) => !selectedIds.has(c.id) || c.id === row.coachId)
|
||||
.map((c: Coach) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.number || c.coachNumber} — {c.coachType?.name} (Cap: {c.capacity})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBulkCoachRows(bulkCoachRows.filter((_, idx) => idx !== i).map((r, idx) => ({ ...r, positionNumber: idx + 1 })))}
|
||||
className="p-1 text-destructive hover:bg-destructive/10 rounded flex-shrink-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 p-4 rounded-lg">
|
||||
@@ -713,10 +947,10 @@ export default function SchedulesPage() {
|
||||
schedules, starting from the specified date, repeating every{' '}
|
||||
<strong>{bulkForm.repeatEveryDays}</strong> days for the next{' '}
|
||||
<strong>{bulkForm.forNextDays}</strong> days.
|
||||
{bulkForm.coachIds.length > 0 && (
|
||||
{bulkCoachRows.filter(r => r.coachId).length > 0 && (
|
||||
<>
|
||||
{' '}
|
||||
Each schedule will have <strong>{bulkForm.coachIds.length}</strong> coach(es) assigned.
|
||||
Each schedule will have <strong>{bulkCoachRows.filter(r => r.coachId).length}</strong> coach(es) assigned.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
@@ -736,8 +970,8 @@ export default function SchedulesPage() {
|
||||
durationHours: '12',
|
||||
repeatEveryDays: '1',
|
||||
forNextDays: '30',
|
||||
coachIds: [],
|
||||
});
|
||||
setBulkCoachRows([]);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
@@ -756,7 +990,7 @@ export default function SchedulesPage() {
|
||||
setEditingSchedule(null);
|
||||
setError(null);
|
||||
}}
|
||||
title={`Edit Schedule - ${editingSchedule?.train?.name} (${editingSchedule?.train?.number})`}
|
||||
title={`Edit Schedule: ${editingSchedule?.originStation?.name ?? ''} → ${editingSchedule?.destinationStation?.name ?? ''}`}
|
||||
size="lg"
|
||||
>
|
||||
{editingSchedule && (
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import DashboardLayout from '../dashboard/layout';
|
||||
|
||||
export default function TariffRatesLayout({ children }: { children: React.ReactNode }) {
|
||||
return <DashboardLayout>{children}</DashboardLayout>;
|
||||
}
|
||||
428
apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx
Normal file
428
apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx
Normal file
@@ -0,0 +1,428 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Edit, Trash2, Search } 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 ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
|
||||
const NATIONALITY_TYPES = ['LOCAL', 'INTERNATIONAL'] as const;
|
||||
const BED_POSITIONS = ['UPPER', 'MIDDLE', 'LOWER'] as const;
|
||||
const COACH_TYPE_LABELS: Record<string, string> = {
|
||||
HSC: 'Regular Seat (Hard Seat)',
|
||||
HBC: 'Economy Bed (Hard Berth)',
|
||||
SBC: 'VIP Bed (Soft Berth)',
|
||||
};
|
||||
|
||||
// Tariff reference rates per the official policy document
|
||||
const TARIFF_REFERENCE: Record<string, Record<string, number>> = {
|
||||
LOCAL: {
|
||||
'HSC-null': 0.03,
|
||||
'HBC-UPPER': 0.04,
|
||||
'HBC-MIDDLE': 0.055,
|
||||
'HBC-LOWER': 0.06,
|
||||
'SBC-UPPER': 0.075,
|
||||
'SBC-LOWER': 0.08,
|
||||
},
|
||||
INTERNATIONAL: {
|
||||
'HSC-null': 0.06,
|
||||
'HBC-UPPER': 0.08,
|
||||
'HBC-MIDDLE': 0.11,
|
||||
'HBC-LOWER': 0.12,
|
||||
'SBC-UPPER': 0.15,
|
||||
'SBC-LOWER': 0.16,
|
||||
},
|
||||
};
|
||||
|
||||
function getTariffRef(nationalityType: string, coachCode: string, bedPosition: string | null) {
|
||||
const key = `${coachCode}-${bedPosition ?? 'null'}`;
|
||||
return TARIFF_REFERENCE[nationalityType]?.[key];
|
||||
}
|
||||
|
||||
export default function TariffRatesPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingClass, setEditingClass] = useState<any>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; error?: string }>({ isOpen: false, item: null });
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [selectedCoachTypeId, setSelectedCoachTypeId] = useState('');
|
||||
const [selectedBedPosition, setSelectedBedPosition] = useState<string>('');
|
||||
const [selectedNationalityType, setSelectedNationalityType] = useState<string>('LOCAL');
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: classesData, isLoading } = useQuery({
|
||||
queryKey: ['seat-classes'],
|
||||
queryFn: () => apiClient.get<any>('/seat-classes'),
|
||||
});
|
||||
|
||||
const { data: coachTypesData } = useQuery({
|
||||
queryKey: ['coach-types'],
|
||||
queryFn: () => apiClient.get<any>('/fleet/coach-types'),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: any) => apiClient.post('/seat-classes', data),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['seat-classes'] }); closeModal(); },
|
||||
onError: (e: any) => setFormError(e?.response?.data?.message || e?.message || 'Failed to save'),
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: any }) => apiClient.patch(`/seat-classes/${id}`, data),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['seat-classes'] }); closeModal(); },
|
||||
onError: (e: any) => setFormError(e?.response?.data?.message || e?.message || 'Failed to update'),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => apiClient.delete(`/seat-classes/${id}`),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['seat-classes'] }); setDeleteConfirm({ isOpen: false, item: null }); },
|
||||
onError: (e: any) => setDeleteConfirm(prev => ({ ...prev, error: e?.response?.data?.message || e?.message || 'Delete failed' })),
|
||||
});
|
||||
|
||||
const closeModal = () => {
|
||||
setShowModal(false);
|
||||
setEditingClass(null);
|
||||
setFormError(null);
|
||||
setSelectedCoachTypeId('');
|
||||
setSelectedBedPosition('');
|
||||
setSelectedNationalityType('LOCAL');
|
||||
};
|
||||
|
||||
const openEdit = (cls: any) => {
|
||||
setEditingClass(cls);
|
||||
setSelectedCoachTypeId(cls.coachTypeId || '');
|
||||
setSelectedBedPosition(cls.bedPosition || '');
|
||||
setSelectedNationalityType(cls.nationalityType || 'LOCAL');
|
||||
setFormError(null);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setFormError(null);
|
||||
const fd = new FormData(e.currentTarget);
|
||||
const payload: any = {
|
||||
coachTypeId: selectedCoachTypeId,
|
||||
name: fd.get('name') as string,
|
||||
nationalityType: selectedNationalityType,
|
||||
bedPosition: selectedBedPosition || null,
|
||||
baseFareMinor: parseInt(fd.get('baseFareMinor') as string),
|
||||
isActive: fd.get('isActive') === 'true',
|
||||
};
|
||||
if (editingClass) {
|
||||
await updateMutation.mutateAsync({ id: editingClass.id, data: payload });
|
||||
} else {
|
||||
await createMutation.mutateAsync(payload);
|
||||
}
|
||||
};
|
||||
|
||||
const coachTypesArray: any[] = Array.isArray(coachTypesData)
|
||||
? coachTypesData
|
||||
: (coachTypesData as any)?.data || (coachTypesData as any)?.items || [];
|
||||
|
||||
const allClasses: any[] = Array.isArray(classesData)
|
||||
? classesData
|
||||
: (classesData as any)?.items || (classesData as any)?.data || [];
|
||||
|
||||
// Only show classes that have nationalityType set (tariff-managed rows)
|
||||
const tariffClasses = allClasses.filter((c: any) => c.nationalityType);
|
||||
|
||||
const displayed = tariffClasses.filter((c: any) => {
|
||||
if (!search) return true;
|
||||
const s = search.toLowerCase();
|
||||
return (
|
||||
c.name?.toLowerCase().includes(s) ||
|
||||
c.nationalityType?.toLowerCase().includes(s) ||
|
||||
c.bedPosition?.toLowerCase().includes(s) ||
|
||||
c.coachType?.name?.toLowerCase().includes(s)
|
||||
);
|
||||
});
|
||||
|
||||
// Auto-suggest name from selections
|
||||
const suggestName = () => {
|
||||
const ct = coachTypesArray.find((c: any) => c.id === selectedCoachTypeId);
|
||||
if (!ct) return '';
|
||||
const label = COACH_TYPE_LABELS[ct.code] || ct.name;
|
||||
const pos = selectedBedPosition ? ` ${selectedBedPosition.charAt(0) + selectedBedPosition.slice(1).toLowerCase()}` : '';
|
||||
const nat = selectedNationalityType === 'LOCAL' ? 'Local' : 'Intl';
|
||||
return `${label}${pos} (${nat})`;
|
||||
};
|
||||
|
||||
// Auto-suggest baseFareMinor from tariff reference
|
||||
const suggestRate = () => {
|
||||
const ct = coachTypesArray.find((c: any) => c.id === selectedCoachTypeId);
|
||||
if (!ct) return '';
|
||||
const ref = getTariffRef(selectedNationalityType, ct.code, selectedBedPosition || null);
|
||||
// baseFareMinor = tariff_decimal × 100000
|
||||
return ref ? Math.round(ref * 100000).toString() : '';
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'nationalityType', label: 'Passenger Type',
|
||||
render: (c: any) => (
|
||||
<Badge variant="status" status={c.nationalityType === 'LOCAL' ? 'CONFIRMED' : 'INFO'}>
|
||||
{c.nationalityType === 'LOCAL' ? 'Local' : 'International'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'coachType', label: 'Coach Type',
|
||||
render: (c: any) => <span className="text-sm">{c.coachType?.name || c.coachTypeId}</span>,
|
||||
},
|
||||
{
|
||||
key: 'bedPosition', label: 'Berth Position',
|
||||
render: (c: any) => c.bedPosition
|
||||
? <span className="font-mono text-sm">{c.bedPosition}</span>
|
||||
: <span className="text-muted-foreground text-xs">Standard</span>,
|
||||
},
|
||||
{
|
||||
key: 'name', label: 'Class Name',
|
||||
render: (c: any) => <span className="font-medium">{c.name}</span>,
|
||||
},
|
||||
{
|
||||
key: 'baseFareMinor', label: 'Rate per km (minor)',
|
||||
render: (c: any) => {
|
||||
const ct = coachTypesArray.find((t: any) => t.id === c.coachTypeId);
|
||||
const ref = ct ? getTariffRef(c.nationalityType, ct.code, c.bedPosition) : undefined;
|
||||
const tariffMinor = ref ? Math.round(ref * 100000) : undefined;
|
||||
const matches = tariffMinor === c.baseFareMinor;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono font-medium">{c.baseFareMinor}</span>
|
||||
{tariffMinor !== undefined && (
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded ${matches ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' : 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'}`}>
|
||||
{matches ? '✓ tariff' : `tariff: ${tariffMinor}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'isActive', label: 'Status',
|
||||
render: (c: any) => (
|
||||
<Badge variant="status" status={c.isActive ? 'CONFIRMED' : 'CANCELLED'}>
|
||||
{c.isActive ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const actions = [
|
||||
{ label: 'Edit', icon: Edit, variant: 'secondary' as const, onClick: openEdit },
|
||||
{
|
||||
label: 'Delete', icon: Trash2, variant: 'danger' as const,
|
||||
onClick: (c: any) => setDeleteConfirm({ isOpen: true, item: c }),
|
||||
},
|
||||
];
|
||||
|
||||
const selectedCoachType = coachTypesArray.find((c: any) => c.id === selectedCoachTypeId);
|
||||
const isBedCoach = selectedCoachType?.code === 'HBC' || selectedCoachType?.code === 'SBC';
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Tariff Rates</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage per-km fare rates by nationality, coach type, and berth position per the official EDR tariff policy
|
||||
</p>
|
||||
</div>
|
||||
<ActionButton icon={Plus} onClick={() => { setEditingClass(null); setFormError(null); setShowModal(true); }}>
|
||||
Add Rate
|
||||
</ActionButton>
|
||||
</div>
|
||||
|
||||
{/* Tariff reference card */}
|
||||
<div className="card bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800">
|
||||
<h3 className="font-semibold text-blue-900 dark:text-blue-200 mb-2">Official Tariff Formula</h3>
|
||||
<p className="text-sm text-blue-800 dark:text-blue-300 font-mono">
|
||||
Fare = KM × rate × 1.02 × ExchangeRate
|
||||
</p>
|
||||
<p className="text-xs text-blue-700 dark:text-blue-400 mt-1">
|
||||
Rate is stored as <strong>baseFareMinor = tariff_decimal × 100,000</strong> (e.g. 0.03 → 3000). The ×1.02 insurance coefficient is applied automatically by the fare engine.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="relative mb-4">
|
||||
<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 name, nationality, berth position..."
|
||||
className="input pl-10 w-full"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<DataTable
|
||||
data={displayed}
|
||||
columns={columns}
|
||||
actions={actions}
|
||||
loading={isLoading}
|
||||
emptyMessage={search ? 'No tariff rates match your search' : 'No tariff rates found'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirm.isOpen}
|
||||
onClose={() => setDeleteConfirm({ isOpen: false, item: null })}
|
||||
onConfirm={() => deleteMutation.mutate(deleteConfirm.item?.id)}
|
||||
title="Delete Tariff Rate"
|
||||
message={`Delete "${deleteConfirm.item?.name}"? This will affect fare calculations for this class.`}
|
||||
confirmText="Delete"
|
||||
isDanger
|
||||
isLoading={deleteMutation.isPending}
|
||||
error={deleteConfirm.error}
|
||||
warning="Bookings in progress may be affected. Ensure a replacement rate exists."
|
||||
/>
|
||||
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
onClose={closeModal}
|
||||
title={`${editingClass ? 'Edit' : 'Add'} Tariff Rate`}
|
||||
size="lg"
|
||||
>
|
||||
<form key={editingClass?.id ?? 'new'} onSubmit={handleSubmit} className="space-y-4">
|
||||
{formError && (
|
||||
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 text-sm text-red-800 dark:text-red-200">
|
||||
{formError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="label">Passenger Nationality *</label>
|
||||
<select
|
||||
className="input"
|
||||
value={selectedNationalityType}
|
||||
onChange={(e) => setSelectedNationalityType(e.target.value)}
|
||||
required
|
||||
>
|
||||
<option value="LOCAL">Local (Ethiopian / Djiboutian)</option>
|
||||
<option value="INTERNATIONAL">International (Foreign nationals)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Coach Type *</label>
|
||||
<select
|
||||
className="input"
|
||||
value={selectedCoachTypeId}
|
||||
onChange={(e) => { setSelectedCoachTypeId(e.target.value); setSelectedBedPosition(''); }}
|
||||
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>
|
||||
|
||||
{isBedCoach && (
|
||||
<div>
|
||||
<label className="label">Berth Position *</label>
|
||||
<select
|
||||
className="input"
|
||||
value={selectedBedPosition}
|
||||
onChange={(e) => setSelectedBedPosition(e.target.value)}
|
||||
required={isBedCoach}
|
||||
>
|
||||
<option value="">Select berth position</option>
|
||||
{(selectedCoachType?.code === 'HBC'
|
||||
? BED_POSITIONS
|
||||
: (['UPPER', 'LOWER'] as const)
|
||||
).map((pos) => (
|
||||
<option key={pos} value={pos}>{pos}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{selectedCoachType?.code === 'HBC' ? 'Economy Bed: Upper / Middle / Lower' : 'VIP Bed: Upper / Lower'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="label">Class Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
className="input"
|
||||
defaultValue={editingClass?.name || ''}
|
||||
key={editingClass?.id ?? `new-${selectedCoachTypeId}-${selectedBedPosition}-${selectedNationalityType}`}
|
||||
placeholder={suggestName() || 'e.g. Economy Bed Upper (Local)'}
|
||||
required
|
||||
/>
|
||||
{!editingClass && suggestName() && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Suggested:{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="text-primary underline"
|
||||
onClick={(e) => {
|
||||
const inp = (e.currentTarget.closest('.space-y-4')?.querySelector('input[name=name]') as HTMLInputElement);
|
||||
if (inp) inp.value = suggestName();
|
||||
}}
|
||||
>
|
||||
{suggestName()}
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Base Fare Minor (per km) *</label>
|
||||
<input
|
||||
type="number"
|
||||
name="baseFareMinor"
|
||||
className="input"
|
||||
defaultValue={editingClass?.baseFareMinor ?? ''}
|
||||
key={editingClass?.id ?? `rate-${selectedCoachTypeId}-${selectedBedPosition}-${selectedNationalityType}`}
|
||||
placeholder={suggestRate() || 'e.g. 3000'}
|
||||
min="0"
|
||||
required
|
||||
/>
|
||||
{suggestRate() && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Official tariff rate:{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="text-primary underline"
|
||||
onClick={(e) => {
|
||||
const inp = (e.currentTarget.closest('.space-y-4')?.querySelector('input[name=baseFareMinor]') as HTMLInputElement);
|
||||
if (inp) inp.value = suggestRate();
|
||||
}}
|
||||
>
|
||||
{suggestRate()}
|
||||
</button>
|
||||
{' '}(= {(parseInt(suggestRate()) / 100000).toFixed(3)} ETB/km)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Status</label>
|
||||
<select name="isActive" className="input" defaultValue={editingClass?.isActive !== false ? 'true' : 'false'}>
|
||||
<option value="true">Active</option>
|
||||
<option value="false">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<ActionButton type="button" variant="secondary" onClick={closeModal}>Cancel</ActionButton>
|
||||
<ActionButton type="submit" loading={createMutation.isPending || updateMutation.isPending}>
|
||||
{editingClass ? 'Update' : 'Create'} Rate
|
||||
</ActionButton>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -89,7 +89,8 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
|
||||
title: 'Financial',
|
||||
items: [
|
||||
{ name: 'Pricing & Fares', href: '/pricing', icon: DollarSign, permission: PERMS.admin },
|
||||
// { name: 'Configurable Fares', href: '/fare-management', icon: Settings, permission: PERMS.admin },
|
||||
{ name: 'Tariff Rates', href: '/tariff-rates', icon: Banknote, permission: PERMS.admin },
|
||||
{ name: 'Fare Rules', href: '/fare-management', icon: Settings, permission: PERMS.admin },
|
||||
{ name: 'Currencies', href: '/currencies', icon: Banknote, permission: PERMS.currencies.manage },
|
||||
{ name: 'Payment Methods', href: '/payment-methods', icon: CreditCard, permission: PERMS.payments.view },
|
||||
{ name: 'Promo Codes', href: '/promos', icon: Gift, permission: PERMS.admin },
|
||||
|
||||
@@ -442,6 +442,14 @@ export const excessBaggageApi = {
|
||||
delete: (id: string) => apiClient.delete(`/agents/excess-baggage/${id}`),
|
||||
};
|
||||
|
||||
// Route Coach Templates API
|
||||
export const routeCoachTemplatesApi = {
|
||||
get: (routeId: string) => apiClient.get<any>(`/routes/${routeId}/coaches`),
|
||||
set: (routeId: string, coaches: Array<{ coachId: string; positionNumber: number }>) =>
|
||||
apiClient.put<any>(`/routes/${routeId}/coaches`, { coaches }),
|
||||
clear: (routeId: string) => apiClient.delete(`/routes/${routeId}/coaches`),
|
||||
};
|
||||
|
||||
// System Config API
|
||||
export const systemConfigApi = {
|
||||
getAll: () => apiClient.get<Record<string, string>>('/config'),
|
||||
|
||||
Reference in New Issue
Block a user