mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 15:25:45 +00:00
273 lines
10 KiB
TypeScript
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 }))}
|
|
/>
|
|
</>
|
|
);
|
|
}
|