mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 03:05:42 +00:00
436 lines
17 KiB
TypeScript
436 lines
17 KiB
TypeScript
'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 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)',
|
||
};
|
||
|
||
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,
|
||
basePrice: Math.round(Number(fd.get('baseFareMinor') as string) * 100) || 0,
|
||
insuranceFeeMinor: Math.round(Number(fd.get('insuranceFeeMinor') as string) * 100) || 0,
|
||
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 || [];
|
||
|
||
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)
|
||
);
|
||
}).sort((a: any, b: any) => {
|
||
if (a.nationalityType === b.nationalityType) return 0;
|
||
return a.nationalityType === 'LOCAL' ? -1 : 1;
|
||
});
|
||
|
||
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})`;
|
||
};
|
||
|
||
// Returns the human-readable rate (e.g. 0.03); stored value = this × 100
|
||
const suggestRate = () => {
|
||
const ct = coachTypesArray.find((c: any) => c.id === selectedCoachTypeId);
|
||
if (!ct) return '';
|
||
const ref = getTariffRef(selectedNationalityType, ct.code, selectedBedPosition || null);
|
||
return ref ? String(ref) : '';
|
||
};
|
||
|
||
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) => {
|
||
const ct = coachTypesArray.find((t: any) => t.id === c.coachTypeId);
|
||
return <span className="text-sm">{ct ? `${ct.code} — ${ct.name}` : c.coachTypeId}</span>;
|
||
},
|
||
},
|
||
{
|
||
key: 'name', label: 'Class Name',
|
||
render: (c: any) => <span className="font-medium">{c.name}</span>,
|
||
},
|
||
{
|
||
key: 'baseFareMinor', label: 'Rate per km',
|
||
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 * 100) : undefined;
|
||
const matches = tariffMinor === c.baseFareMinor;
|
||
return (
|
||
<div className="flex items-center gap-2">
|
||
<span className="font-mono font-medium">{c.baseFareMinor / 100}</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 / 100}`}
|
||
</span>
|
||
)}
|
||
</div>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
key: 'insuranceFeeMinor', label: 'Insurance Fee',
|
||
render: (c: any) => (
|
||
<span className="font-mono text-sm">{c.insuranceFeeMinor ? (c.insuranceFeeMinor / 100).toFixed(2) : '0.00'} ETB</span>
|
||
),
|
||
},
|
||
{
|
||
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)
|
||
?? editingClass?.coachType;
|
||
const isBedCoach = selectedCoachType?.name?.toLowerCase().includes('bed') || selectedCoachType?.code?.toLowerCase().includes('bed');
|
||
|
||
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 bed position per the official EDR tariff policy
|
||
</p>
|
||
</div>
|
||
<ActionButton icon={Plus} onClick={() => { setEditingClass(null); setFormError(null); setShowModal(true); }}>
|
||
Add Rate
|
||
</ActionButton>
|
||
</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, etc."
|
||
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">Bed Position *</label>
|
||
<select
|
||
className="input"
|
||
value={selectedBedPosition}
|
||
onChange={(e) => setSelectedBedPosition(e.target.value)}
|
||
required={isBedCoach}
|
||
>
|
||
<option value="">Select bed position</option>
|
||
{(selectedCoachType?.code === 'HBC'
|
||
? BED_POSITIONS
|
||
: (['Upper','Middle', '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">Rate per km *</label>
|
||
<input
|
||
type="number"
|
||
name="baseFareMinor"
|
||
className="input"
|
||
defaultValue={editingClass ? editingClass.baseFareMinor / 100 : ''}
|
||
key={editingClass?.id ?? `rate-${selectedCoachTypeId}-${selectedBedPosition}-${selectedNationalityType}`}
|
||
placeholder={suggestRate() || 'e.g. 6'}
|
||
min="0"
|
||
step="any"
|
||
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>
|
||
{' '}(stored as {Math.round(Number(suggestRate()) * 100)})
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
<div>
|
||
<label className="label">Insurance Fee (ETB)</label>
|
||
<input
|
||
type="number"
|
||
name="insuranceFeeMinor"
|
||
className="input"
|
||
defaultValue={editingClass ? (editingClass.insuranceFeeMinor / 100).toFixed(2) : '0.00'}
|
||
min="0"
|
||
step="0.01"
|
||
placeholder="e.g. 25.00"
|
||
/>
|
||
<p className="text-xs text-muted-foreground mt-1">Flat fee per passenger (e.g., travel insurance)</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>
|
||
);
|
||
}
|