Files
edr-platform/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx
2026-07-10 10:33:36 +03:00

436 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { 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>
);
}