Files
edr-platform/apps/edr-passenger-web/backoffice/src/app/tariff-rates/OverridesTab.tsx

273 lines
10 KiB
TypeScript

'use client';
import { useState } from 'react';
import { Plus, Edit, Trash2 } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import Modal from '@/components/ui/Modal';
import { useRouteFareRules, useRouteFareRuleMutations, useSeatClasses } from './hooks';
import type { Route, RouteFareRule, SeatClass } from './types';
interface Props {
routes: Route[];
}
type OverrideForm = {
isOpen: boolean;
rule: RouteFareRule | null; // null = add mode
error: string | null;
};
export default function OverridesTab({ routes }: Props) {
const [selectedRouteId, setSelectedRouteId] = useState<string | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{
isOpen: boolean;
id: string | null;
name: string;
cascade: boolean;
cascadeChecked: boolean;
error?: string;
}>({ isOpen: false, id: null, name: '', cascade: false, cascadeChecked: false });
const [form, setForm] = useState<OverrideForm>({ isOpen: false, rule: null, error: null });
const { overrides, isLoading } = useRouteFareRules(selectedRouteId);
const { update, remove, create } = useRouteFareRuleMutations(selectedRouteId);
const { allClasses } = useSeatClasses();
const handleDeleteClick = (r: RouteFareRule) => {
setDeleteConfirm({
isOpen: true, id: r.id,
name: r.seatClass?.name ?? r.seatClassId,
cascade: false, cascadeChecked: false, error: undefined,
});
};
const handleConfirmDelete = async () => {
try {
await remove.mutateAsync(deleteConfirm.id!);
setDeleteConfirm({ isOpen: false, id: null, name: '', cascade: false, cascadeChecked: false });
} catch (err: any) {
const msg = err?.response?.data?.message ?? err?.message ?? 'Delete failed';
const isFkError = msg.includes('Cannot delete') || err?.response?.status === 400;
setDeleteConfirm(prev => ({
...prev,
cascade: isFkError && !prev.cascade ? true : prev.cascade,
cascadeChecked: false,
error: msg,
}));
}
};
const handleFormSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setForm(prev => ({ ...prev, error: null }));
const fd = new FormData(e.currentTarget);
const baseFareMinor = Math.round(Number(fd.get('baseFareMinor')) * 100) || 0;
const surchargeMinor = Math.round(Number(fd.get('surchargeMinor') ?? '0') * 100) || 0;
try {
if (form.rule) {
await update.mutateAsync({ id: form.rule.id, baseFareMinor, surchargeMinor });
} else {
const routeId = fd.get('routeId') as string;
const seatClassId = fd.get('seatClassId') as string;
await create.mutateAsync({
routeId,
seatClassId,
passengerCategory: 'ADULT',
baseFareMinor,
surchargeMinor,
validFrom: new Date().toISOString(),
});
}
setForm({ isOpen: false, rule: null, error: null });
} catch (err: any) {
setForm(prev => ({ ...prev, error: err?.response?.data?.message ?? err?.message ?? 'Failed to save' }));
}
};
const columns = [
{
key: 'route', label: 'Route',
render: (r: RouteFareRule) => <span className="font-medium">{r.route?.name ?? r.routeId}</span>,
},
{
key: 'seatClass', label: 'Seat Class',
render: (r: RouteFareRule) => <span>{r.seatClass?.name ?? r.seatClassId}</span>,
},
{
key: 'passengerCategory', label: 'Category',
render: (r: RouteFareRule) => (
<Badge variant="status" status={r.passengerCategory === 'ADULT' ? 'CONFIRMED' : 'INFO'}>
{r.passengerCategory}
</Badge>
),
},
{
key: 'baseFareMinor', label: 'Rate per km',
render: (r: RouteFareRule) => <span className="font-mono font-medium">{r.baseFareMinor / 100}</span>,
},
{
key: 'surchargeMinor', label: 'Insurance Fee',
render: (r: RouteFareRule) => (
<span className="font-mono text-sm">
{r.surchargeMinor ? (r.surchargeMinor / 100).toFixed(2) : '0.00'} ETB
</span>
),
},
{
key: 'validFrom', label: 'Valid From',
render: (r: RouteFareRule) => <span className="text-sm">{new Date(r.validFrom).toLocaleDateString()}</span>,
},
{
key: 'validUntil', label: 'Valid Until',
render: (r: RouteFareRule) => (
<span className="text-sm">{r.validUntil ? new Date(r.validUntil).toLocaleDateString() : '—'}</span>
),
},
];
const isAddMode = form.isOpen && !form.rule;
const isPending = create.isPending || update.isPending;
return (
<>
<div className="flex items-center gap-3 mb-4">
<select
className="input w-64"
value={selectedRouteId ?? ''}
onChange={e => setSelectedRouteId(e.target.value || null)}
>
<option value="">All routes</option>
{routes.map(r => (
<option key={r.id} value={r.id}>{r.name}{r.code ? ` (${r.code})` : ''}</option>
))}
</select>
<ActionButton
icon={Plus}
onClick={() => setForm({ isOpen: true, rule: null, error: null })}
>
Add Override
</ActionButton>
</div>
{!selectedRouteId ? (
<div className="text-center py-8 text-muted-foreground">
Select a route above to view its fare overrides.
</div>
) : (
<DataTable
data={overrides}
columns={columns}
actions={[
{
label: 'Edit', icon: Edit, variant: 'secondary' as const,
onClick: (r: RouteFareRule) => setForm({ isOpen: true, rule: r, error: null }),
},
{ label: 'Delete', icon: Trash2, variant: 'danger' as const, onClick: handleDeleteClick },
]}
loading={isLoading}
emptyMessage="No overrides for this route. Click 'Add Override' to create one."
/>
)}
{/* Add / Edit modal */}
<Modal
isOpen={form.isOpen}
onClose={() => setForm({ isOpen: false, rule: null, error: null })}
title={isAddMode ? 'Add Route Override' : 'Edit Route Override'}
size="md"
>
<form key={form.rule?.id ?? 'add'} onSubmit={handleFormSubmit} className="space-y-4">
{form.error && (
<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">
{form.error}
</div>
)}
{isAddMode ? (
<div className="grid grid-cols-1 gap-4">
<div>
<label className="label">Route *</label>
<select name="routeId" className="input w-full" defaultValue={selectedRouteId ?? ''} required>
<option value="">Select route</option>
{routes.map(r => (
<option key={r.id} value={r.id}>{r.name}{r.code ? ` (${r.code})` : ''}</option>
))}
</select>
</div>
<div>
<label className="label">Seat Class Tariff *</label>
<select name="seatClassId" className="input w-full" required>
<option value="">Select seat class</option>
{allClasses.filter(c => c.isActive).map((c: SeatClass) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
</div>
) : (
<div className="p-3 rounded-lg bg-muted/50 text-sm text-muted-foreground">
<span className="font-medium text-foreground">{form.rule?.seatClass?.name ?? form.rule?.seatClassId}</span>
{' · '}{form.rule?.route?.name ?? form.rule?.routeId}
{' · '}{form.rule?.passengerCategory}
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Rate per km *</label>
<input
type="number"
name="baseFareMinor"
className="input"
defaultValue={form.rule ? form.rule.baseFareMinor / 100 : ''}
min="0" step="any" required
/>
</div>
<div>
<label className="label">Insurance Fee (ETB)</label>
<input
type="number"
name="surchargeMinor"
className="input"
defaultValue={form.rule ? ((form.rule.surchargeMinor ?? 0) / 100).toFixed(2) : '0.00'}
min="0" step="0.01"
/>
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<ActionButton type="button" variant="secondary" onClick={() => setForm({ isOpen: false, rule: null, error: null })}>Cancel</ActionButton>
<ActionButton type="submit" loading={isPending}>
{isAddMode ? 'Create Override' : 'Update Override'}
</ActionButton>
</div>
</form>
</Modal>
<ConfirmDialog
isOpen={deleteConfirm.isOpen}
onClose={() => setDeleteConfirm({ isOpen: false, id: null, name: '', cascade: false, cascadeChecked: false })}
onConfirm={handleConfirmDelete}
title="Delete Route Override"
message={`Delete the fare override for "${deleteConfirm.name}"? The global seat class rate will apply instead.`}
confirmText="Delete"
isDanger
isLoading={remove.isPending}
error={deleteConfirm.error}
warning={!deleteConfirm.cascade
? 'Removing this override means all future bookings on this route will fall back to the global tariff rate.'
: undefined}
cascadeWarning={deleteConfirm.cascade
? 'This override has related records that will also be permanently deleted.'
: undefined}
cascadeChecked={deleteConfirm.cascadeChecked}
onCascadeChange={checked => setDeleteConfirm(prev => ({ ...prev, cascadeChecked: checked }))}
/>
</>
);
}