Files
edr-platform/apps/edr-passenger-web/backoffice/src/components/upgrade/UpgradePolicyManager.tsx

364 lines
12 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 {
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<UpgradePolicyRow[]>([]);
const [available, setAvailable] = useState<UpgradePolicyCoachType[]>([]);
const [loading, setLoading] = useState(true);
const [message, setMessage] = useState('');
const [showModal, setShowModal] = useState(false);
const [editing, setEditing] = useState<UpgradePolicyRow | null>(null);
const [coachTypeId, setCoachTypeId] = useState('');
const [form, setForm] = useState<UpgradePolicyValues>(EMPTY_POLICY);
const [saving, setSaving] = useState(false);
const [formError, setFormError] = useState('');
const [deleting, setDeleting] = useState<UpgradePolicyRow | null>(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<UpgradePolicyValues>) => 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) => (
<div>
<span className="font-semibold text-foreground">{row.coachType?.code}</span>
<span className="text-muted-foreground"> {row.coachType?.name}</span>
</div>
),
},
{
key: 'rank',
label: 'Rank',
render: (row: UpgradePolicyRow) => <span className="font-mono text-sm">{row.rank}</span>,
},
{
key: 'fee',
label: 'Change fee',
render: (row: UpgradePolicyRow) => <span className="text-sm">{feeLabel(row)}</span>,
},
{
key: 'isUpgradable',
label: 'Upgrade from',
render: (row: UpgradePolicyRow) => (
<span className={`edr-badge ${row.isUpgradable ? 'edr-badge-success' : 'edr-badge-warning'}`}>
{row.isUpgradable ? 'Allowed' : 'No'}
</span>
),
},
{
key: 'isTargetable',
label: 'Upgrade to',
render: (row: UpgradePolicyRow) => (
<span className={`edr-badge ${row.isTargetable ? 'edr-badge-success' : 'edr-badge-warning'}`}>
{row.isTargetable ? 'Allowed' : 'No'}
</span>
),
},
{
key: 'isActive',
label: 'Status',
render: (row: UpgradePolicyRow) => (
<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 },
{
label: 'Delete',
onClick: (row: UpgradePolicyRow) => setDeleting(row),
variant: 'danger' as const,
icon: Trash2,
},
];
return (
<div className="space-y-4">
<div className="flex items-start justify-between gap-4">
<p className="text-xs text-muted-foreground max-w-3xl">
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&apos;s original fare, minimum), read from the class being upgraded
<em> to</em> and charged per upgraded passenger. A fare class with no policy here can be neither
upgraded from nor to.
</p>
<ActionButton icon={Plus} onClick={openCreate} disabled={available.length === 0}>
Add Upgrade 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 upgrade policies yet — add one to allow fare-class upgrades."
/>
<Modal
isOpen={showModal}
onClose={() => setShowModal(false)}
title={editing ? `Edit Upgrade Policy — ${editing.coachType?.code}` : 'Add Upgrade 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
/>
<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">Rank</label>
<input
type="number"
min="0"
className="input"
value={form.rank}
onChange={(e) => setField({ rank: Number(e.target.value) })}
/>
<p className="text-xs text-muted-foreground">Higher beats lower. Must be unique among active policies.</p>
</div>
<div className="space-y-1">
<label className="label">Change fee</label>
<label className="flex items-center gap-2 text-sm h-10">
<input
type="checkbox"
checked={form.feeWaived}
onChange={(e) => setField({ feeWaived: e.target.checked })}
/>
Waived charge only the fare difference
</label>
</div>
<div className="space-y-1">
<label className="label">Fee (% of original fare)</label>
<input
type="number"
min="0"
max="100"
className="input"
disabled={form.feeWaived}
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"
disabled={form.feeWaived}
value={etb(form.feeMinMinor)}
onChange={(e) => setField({ feeMinMinor: toMinor(e.target.value) })}
/>
</div>
<div className="space-y-1">
<label className="label">Upgrade from this class</label>
<label className="flex items-center gap-2 text-sm h-10">
<input
type="checkbox"
checked={form.isUpgradable}
onChange={(e) => setField({ isUpgradable: e.target.checked })}
/>
Allowed
</label>
</div>
<div className="space-y-1">
<label className="label">Upgrade to this class</label>
<label className="flex items-center gap-2 text-sm h-10">
<input
type="checkbox"
checked={form.isTargetable}
onChange={(e) => setField({ isTargetable: 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>
{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}>
{editing ? 'Update Policy' : 'Create Policy'}
</ActionButton>
</div>
</div>
</Modal>
<ConfirmDialog
isOpen={!!deleting}
onClose={() => 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}
/>
</div>
);
}