'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 { upgradePolicyApi, type UpgradePolicyCoachType, type UpgradePolicyRow, type UpgradePolicyValues, } from '@/lib/api'; const EMPTY_POLICY: UpgradePolicyValues = { rank: 0, feePercent: 0, feeMinMinor: 0, feeWaived: false, isUpgradable: true, isTargetable: true, 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 = (p: UpgradePolicyRow) => p.feeWaived ? 'Waived' : p.feePercent > 0 || p.feeMinMinor > 0 ? `${p.feePercent}% · min ETB ${etb(p.feeMinMinor)}` : 'Free'; /** * Policy US-17 — one upgrade policy per fare class (coach type), listed as a table and edited in * a dialog, the same shape as Reschedule Policies and Coach Management. */ export default function UpgradePolicyManager() { const [rows, setRows] = useState([]); const [available, setAvailable] = useState([]); const [loading, setLoading] = useState(true); const [message, setMessage] = useState(''); const [showModal, setShowModal] = useState(false); const [editing, setEditing] = useState(null); const [coachTypeId, setCoachTypeId] = useState(''); const [form, setForm] = useState(EMPTY_POLICY); const [saving, setSaving] = useState(false); const [formError, setFormError] = useState(''); const [deleting, setDeleting] = useState(null); const [deleteBusy, setDeleteBusy] = useState(false); const load = async () => { setLoading(true); try { const [policies, coachTypes] = await Promise.all([ upgradePolicyApi.list(), upgradePolicyApi.availableCoachTypes(), ]); setRows(Array.isArray(policies) ? policies : []); setAvailable(Array.isArray(coachTypes) ? coachTypes : []); } catch { setMessage('Failed to load upgrade policies.'); } finally { setLoading(false); } }; useEffect(() => { void load(); }, []); const openCreate = () => { setEditing(null); setCoachTypeId(''); // Suggest the next free rung rather than 0, which would clash with an existing policy. setForm({ ...EMPTY_POLICY, rank: Math.max(0, ...rows.map((r) => r.rank)) + 1 }); setFormError(''); setShowModal(true); }; const openEdit = (row: UpgradePolicyRow) => { setEditing(row); setCoachTypeId(row.coachTypeId); setForm({ rank: row.rank, feePercent: row.feePercent, feeMinMinor: row.feeMinMinor, feeWaived: row.feeWaived, isUpgradable: row.isUpgradable, isTargetable: row.isTargetable, isActive: row.isActive, }); setFormError(''); setShowModal(true); }; const setField = (patch: Partial) => setForm((f) => ({ ...f, ...patch })); const submit = async () => { if (!editing && !coachTypeId) { setFormError('Pick a fare class.'); return; } setSaving(true); setFormError(''); try { if (editing) await upgradePolicyApi.update(editing.coachTypeId, form); else await upgradePolicyApi.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 upgradePolicyApi.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: UpgradePolicyRow) => (
{row.coachType?.code} — {row.coachType?.name}
), }, { key: 'rank', label: 'Rank', render: (row: UpgradePolicyRow) => {row.rank}, }, { key: 'fee', label: 'Change fee', render: (row: UpgradePolicyRow) => {feeLabel(row)}, }, { key: 'isUpgradable', label: 'Upgrade from', render: (row: UpgradePolicyRow) => ( {row.isUpgradable ? 'Allowed' : 'No'} ), }, { key: 'isTargetable', label: 'Upgrade to', render: (row: UpgradePolicyRow) => ( {row.isTargetable ? 'Allowed' : 'No'} ), }, { key: 'isActive', label: 'Status', render: (row: UpgradePolicyRow) => ( {row.isActive ? 'Active' : 'Disabled'} ), }, ]; const actions = [ { label: 'Edit', onClick: openEdit, variant: 'secondary' as const, icon: Edit }, { label: 'Delete', onClick: (row: UpgradePolicyRow) => setDeleting(row), variant: 'danger' as const, icon: Trash2, }, ]; return (

Rank orders the ladder — a passenger may only move to a class with a higher rank, on the same train. Fee = max(fee % × the passenger's original fare, minimum), read from the class being upgraded to and charged per upgraded passenger. A fare class with no policy here can be neither upgraded from nor to.

Add Upgrade Policy
{!loading && available.length === 0 && (

Every fare class already has a policy.

)} {message &&

{message}

} setShowModal(false)} title={editing ? `Edit Upgrade Policy — ${editing.coachType?.code}` : 'Add Upgrade Policy'} size="lg" >
{editing ? ( <>

A policy stays attached to its fare class.

) : ( )}
setField({ rank: Number(e.target.value) })} />

Higher beats lower. Must be unique among active policies.

setField({ feePercent: Number(e.target.value) })} />
setField({ feeMinMinor: toMinor(e.target.value) })} />
{formError &&

{formError}

}
setShowModal(false)}> Cancel {editing ? 'Update Policy' : 'Create Policy'}
setDeleting(null)} onConfirm={confirmDelete} title="Delete upgrade policy" message={`Delete the upgrade policy for ${deleting?.coachType?.code ?? ''}?`} warning="Passengers will no longer be able to upgrade out of or into this fare class. Upgrades already applied are unaffected." confirmText="Delete" isDanger isLoading={deleteBusy} />
); }