Files
edr-platform/apps/edr-passenger-web/backoffice/src/components/reschedule/ReschedulePolicyManager.tsx

395 lines
14 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 { useEffect, useState } from 'react';
import { Edit, Plus, Save, Trash2 } 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 { useWritePermission, useDeletePermission } from '@/lib/use-permission';
import { PERMS } from '@/lib/permissions';
import {
reschedulePolicyApi,
type ReschedulePolicyCoachType,
type ReschedulePolicyRow,
type ReschedulePolicyValues,
} from '@/lib/api';
const EMPTY_POLICY: ReschedulePolicyValues = {
feePercent: 0,
feeMinMinor: 0,
routeChangeAllowed: true,
sameDayAllowed: true,
sameDayFeePercent: 0,
sameDayFeeMinMinor: 0,
cutoffMinutes: 60,
isActive: true,
};
// Money is entered in ETB and stored in minor units.
const etb = (minor: number) => String(minor / 100);
const toMinor = (value: string) => Math.round(Number(value || 0) * 100);
const feeLabel = (percent: number, minMinor: number) =>
percent > 0 || minMinor > 0 ? `${percent}% · min ETB ${etb(minMinor)}` : 'Free';
/**
* Policy §3 - one policy per fare class (coach type), listed as a table and edited in a dialog,
* the same shape as Coach Management. A fare class with no row here cannot be rescheduled at all.
*/
export default function ReschedulePolicyManager() {
const canCreate = useWritePermission(PERMS.reschedulePolicies.create, PERMS.reschedulePolicies.manage);
const canEdit = useWritePermission(PERMS.reschedulePolicies.edit, PERMS.reschedulePolicies.manage);
const canDelete = useDeletePermission(PERMS.reschedulePolicies.delete);
const [rows, setRows] = useState<ReschedulePolicyRow[]>([]);
const [available, setAvailable] = useState<ReschedulePolicyCoachType[]>([]);
const [loading, setLoading] = useState(true);
const [message, setMessage] = useState('');
const [showModal, setShowModal] = useState(false);
const [editing, setEditing] = useState<ReschedulePolicyRow | null>(null);
const [coachTypeId, setCoachTypeId] = useState('');
const [form, setForm] = useState<ReschedulePolicyValues>(EMPTY_POLICY);
const [saving, setSaving] = useState(false);
const [formError, setFormError] = useState('');
const [deleting, setDeleting] = useState<ReschedulePolicyRow | null>(null);
const [deleteBusy, setDeleteBusy] = useState(false);
const load = async () => {
setLoading(true);
try {
const [policies, coachTypes] = await Promise.all([
reschedulePolicyApi.list(),
reschedulePolicyApi.availableCoachTypes(),
]);
setRows(Array.isArray(policies) ? policies : []);
setAvailable(Array.isArray(coachTypes) ? coachTypes : []);
} catch {
setMessage('Failed to load reschedule policies.');
} finally {
setLoading(false);
}
};
useEffect(() => {
void load();
}, []);
const openCreate = () => {
setEditing(null);
setCoachTypeId('');
setForm(EMPTY_POLICY);
setFormError('');
setShowModal(true);
};
const openEdit = (row: ReschedulePolicyRow) => {
setEditing(row);
setCoachTypeId(row.coachTypeId);
setForm({
feePercent: row.feePercent,
feeMinMinor: row.feeMinMinor,
routeChangeAllowed: row.routeChangeAllowed,
sameDayAllowed: row.sameDayAllowed,
sameDayFeePercent: row.sameDayFeePercent,
sameDayFeeMinMinor: row.sameDayFeeMinMinor,
cutoffMinutes: row.cutoffMinutes,
isActive: row.isActive,
});
setFormError('');
setShowModal(true);
};
const setField = (patch: Partial<ReschedulePolicyValues>) => setForm((f) => ({ ...f, ...patch }));
const submit = async () => {
if (!editing && !coachTypeId) {
setFormError('Pick a fare class.');
return;
}
setSaving(true);
setFormError('');
try {
if (editing) await reschedulePolicyApi.update(editing.coachTypeId, form);
else await reschedulePolicyApi.create({ coachTypeId, ...form });
setShowModal(false);
setMessage(editing ? 'Policy updated.' : 'Policy created.');
await load();
} catch (err: any) {
setFormError(err?.response?.data?.message || err?.message || 'Failed to save the policy.');
} finally {
setSaving(false);
}
};
const confirmDelete = async () => {
if (!deleting) return;
setDeleteBusy(true);
try {
await reschedulePolicyApi.remove(deleting.coachTypeId);
setDeleting(null);
setMessage('Policy deleted.');
await load();
} catch {
setMessage('Failed to delete the policy.');
} finally {
setDeleteBusy(false);
}
};
const columns = [
{
key: 'coachType',
label: 'Fare class',
render: (row: ReschedulePolicyRow) => (
<div>
<span className="font-semibold text-foreground">{row.coachType?.code}</span>
<span className="text-muted-foreground"> - {row.coachType?.name}</span>
</div>
),
},
{
key: 'fee',
label: 'Change fee',
render: (row: ReschedulePolicyRow) => (
<span className="text-sm">{feeLabel(row.feePercent, row.feeMinMinor)}</span>
),
},
{
key: 'routeChangeAllowed',
label: 'Route change',
render: (row: ReschedulePolicyRow) => (
<span className={`edr-badge ${row.routeChangeAllowed ? 'edr-badge-success' : 'edr-badge-danger'}`}>
{row.routeChangeAllowed ? 'Allowed' : 'Not permitted'}
</span>
),
},
{
key: 'sameDay',
label: 'Same-day change',
render: (row: ReschedulePolicyRow) =>
row.sameDayAllowed ? (
<span className="text-sm">{feeLabel(row.sameDayFeePercent, row.sameDayFeeMinMinor)}</span>
) : (
<span className="edr-badge edr-badge-danger">Not permitted</span>
),
},
{
key: 'cutoffMinutes',
label: 'Cutoff',
render: (row: ReschedulePolicyRow) => (
<span className="font-mono text-sm">{row.cutoffMinutes} min</span>
),
},
{
key: 'isActive',
label: 'Status',
render: (row: ReschedulePolicyRow) => (
<span className={`edr-badge ${row.isActive ? 'edr-badge-success' : 'edr-badge-warning'}`}>
{row.isActive ? 'Active' : 'Disabled'}
</span>
),
},
];
const actions = [
{ label: 'Edit', onClick: openEdit, variant: 'secondary' as const, icon: Edit, show: () => canEdit },
{
label: 'Delete',
show: () => canDelete,
onClick: (row: ReschedulePolicyRow) => setDeleting(row),
variant: 'danger' as const,
icon: Trash2,
},
];
return (
<div className="space-y-4">
<div className="flex items-start justify-between gap-4">
{/* The page supplies the title; this is the rule-of-thumb the table's numbers mean. */}
<p className="text-xs text-muted-foreground max-w-3xl">
Fee = max(fee % × original leg fare, minimum). A higher new fare is always charged on top; a lower one is
not refunded. Same-day = new departure on the same calendar day as the original. A fare class with no
policy here cannot be rescheduled at all.
</p>
<ActionButton
icon={Plus}
onClick={openCreate}
disabled={available.length === 0 || !canCreate}
title={canCreate ? undefined : 'You do not have permission to create reschedule policies'}
>
Add Reschedule Policy
</ActionButton>
</div>
{!loading && available.length === 0 && (
<p className="text-xs text-muted-foreground">Every fare class already has a policy.</p>
)}
{message && <p className="text-sm text-muted-foreground">{message}</p>}
<DataTable
data={rows}
columns={columns}
actions={actions}
loading={loading}
emptyMessage="No reschedule policies yet - add one to allow rescheduling."
/>
<Modal
isOpen={showModal}
onClose={() => setShowModal(false)}
title={editing ? `Edit Reschedule Policy - ${editing.coachType?.code}` : 'Add Reschedule Policy'}
size="lg"
>
<div className="space-y-4">
<div className="space-y-1">
<label className="label">Fare class</label>
{editing ? (
<>
<input
className="input"
value={`${editing.coachType?.code} - ${editing.coachType?.name}`}
disabled
/>
{/* One policy per fare class, so editing never re-points a row at another class. */}
<p className="text-xs text-muted-foreground">A policy stays attached to its fare class.</p>
</>
) : (
<select className="input" value={coachTypeId} onChange={(e) => setCoachTypeId(e.target.value)}>
<option value="">Select a fare class...</option>
{available.map((ct) => (
<option key={ct.id} value={ct.id}>
{ct.code} - {ct.name}
</option>
))}
</select>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1">
<label className="label">Fee (% of fare)</label>
<input
type="number"
min="0"
max="100"
className="input"
value={form.feePercent}
onChange={(e) => setField({ feePercent: Number(e.target.value) })}
/>
</div>
<div className="space-y-1">
<label className="label">Minimum fee (ETB)</label>
<input
type="number"
min="0"
step="0.01"
className="input"
value={etb(form.feeMinMinor)}
onChange={(e) => setField({ feeMinMinor: toMinor(e.target.value) })}
/>
</div>
<div className="space-y-1">
<label className="label">Cutoff before departure (min)</label>
<input
type="number"
min="0"
className="input"
value={form.cutoffMinutes}
onChange={(e) => setField({ cutoffMinutes: Number(e.target.value) })}
/>
</div>
<div className="space-y-1">
<label className="label">Route change</label>
<label className="flex items-center gap-2 text-sm h-10">
<input
type="checkbox"
checked={form.routeChangeAllowed}
onChange={(e) => setField({ routeChangeAllowed: e.target.checked })}
/>
Allowed
</label>
</div>
<div className="space-y-1">
<label className="label">Same-day change</label>
<label className="flex items-center gap-2 text-sm h-10">
<input
type="checkbox"
checked={form.sameDayAllowed}
onChange={(e) => setField({ sameDayAllowed: e.target.checked })}
/>
Allowed
</label>
</div>
<div className="space-y-1">
<label className="label">Status</label>
<label className="flex items-center gap-2 text-sm h-10">
<input
type="checkbox"
checked={form.isActive}
onChange={(e) => setField({ isActive: e.target.checked })}
/>
Active
</label>
</div>
<div className="space-y-1">
<label className="label">Same-day fee (% of fare)</label>
<input
type="number"
min="0"
max="100"
className="input"
disabled={!form.sameDayAllowed}
value={form.sameDayFeePercent}
onChange={(e) => setField({ sameDayFeePercent: Number(e.target.value) })}
/>
</div>
<div className="space-y-1">
<label className="label">Same-day minimum fee (ETB)</label>
<input
type="number"
min="0"
step="0.01"
className="input"
disabled={!form.sameDayAllowed}
value={etb(form.sameDayFeeMinMinor)}
onChange={(e) => setField({ sameDayFeeMinMinor: toMinor(e.target.value) })}
/>
</div>
</div>
{formError && <p className="text-sm text-red-600 dark:text-red-400">{formError}</p>}
<div className="flex justify-end gap-2 pt-2">
<ActionButton variant="secondary" onClick={() => setShowModal(false)}>
Cancel
</ActionButton>
<ActionButton
icon={Save}
onClick={submit}
loading={saving}
disabled={editing ? !canEdit : !canCreate}
title={(editing ? canEdit : canCreate) ? undefined : 'You do not have permission to change reschedule policies'}
>
{editing ? 'Update Policy' : 'Create Policy'}
</ActionButton>
</div>
</div>
</Modal>
<ConfirmDialog
isOpen={!!deleting}
onClose={() => setDeleting(null)}
onConfirm={confirmDelete}
title="Delete reschedule policy"
message={`Delete the reschedule policy for ${deleting?.coachType?.code ?? ''}?`}
warning="Passengers on this fare class will no longer be able to reschedule. Bookings already rescheduled are unaffected."
confirmText="Delete"
isDanger
isLoading={deleteBusy}
/>
</div>
);
}