mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 05:25:41 +00:00
feat: ( reschedule ) implement booking reschedule
This commit is contained in:
@@ -2,9 +2,9 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Save } from 'lucide-react';
|
||||
import { systemConfigApi } from '@/lib/api';
|
||||
import { systemConfigApi, reschedulePolicyApi, type ReschedulePolicyRow } from '@/lib/api';
|
||||
|
||||
type Tab = 'general' | 'payment' | 'integrations' | 'configurations';
|
||||
type Tab = 'general' | 'payment' | 'integrations' | 'configurations' | 'reschedule';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('general');
|
||||
@@ -57,6 +57,7 @@ export default function SettingsPage() {
|
||||
const tabs: { id: Tab; label: string }[] = [
|
||||
{ id: 'general', label: 'General' },
|
||||
{ id: 'configurations', label: 'Configurations' },
|
||||
{ id: 'reschedule', label: 'Reschedule Policy' },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -111,6 +112,8 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'reschedule' && <ReschedulePolicyTab />}
|
||||
|
||||
{activeTab === 'configurations' && (
|
||||
<div className="card space-y-6">
|
||||
<h3 className="text-lg font-semibold text-foreground">Rate Limiting (requests / minute / IP)</h3>
|
||||
@@ -224,3 +227,125 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type PolicyForm = NonNullable<ReschedulePolicyRow['policy']>;
|
||||
|
||||
const EMPTY_POLICY: PolicyForm = {
|
||||
feePercent: 0, feeMinMinor: 0, routeChangeAllowed: true, sameDayAllowed: true,
|
||||
sameDayFeePercent: 0, sameDayFeeMinMinor: 0, cutoffMinutes: 60, isActive: true,
|
||||
};
|
||||
|
||||
/** Policy §3 — one editable row per fare class (HSC = Standard, HBC = Flex, SBC = Premium). Money is entered in ETB, stored in minor units. */
|
||||
function ReschedulePolicyTab() {
|
||||
const [rows, setRows] = useState<ReschedulePolicyRow[]>([]);
|
||||
const [forms, setForms] = useState<Record<string, PolicyForm>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
reschedulePolicyApi.list()
|
||||
.then((data) => {
|
||||
const list = Array.isArray(data) ? data : [];
|
||||
setRows(list);
|
||||
setForms(Object.fromEntries(list.map((r) => [r.coachTypeId, { ...EMPTY_POLICY, ...(r.policy ?? {}) }])));
|
||||
})
|
||||
.catch(() => setMessage('Failed to load policies.'))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const setField = (id: string, patch: Partial<PolicyForm>) =>
|
||||
setForms((f) => ({ ...f, [id]: { ...f[id], ...patch } }));
|
||||
|
||||
const save = async (id: string) => {
|
||||
setSaving(id);
|
||||
setMessage('');
|
||||
try {
|
||||
await reschedulePolicyApi.update(id, forms[id]);
|
||||
setMessage('Saved.');
|
||||
} catch {
|
||||
setMessage('Failed to save.');
|
||||
} finally {
|
||||
setSaving(null);
|
||||
}
|
||||
};
|
||||
|
||||
const etb = (minor: number) => String(minor / 100);
|
||||
const minor = (etbValue: string) => Math.round(Number(etbValue || 0) * 100);
|
||||
|
||||
if (loading) return <div className="card"><p className="text-sm text-muted-foreground">Loading...</p></div>;
|
||||
|
||||
return (
|
||||
<div className="card space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground">Rescheduling rules per fare class</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
{rows.map((r) => {
|
||||
const f = forms[r.coachTypeId];
|
||||
return (
|
||||
<div key={r.coachTypeId} className="border border-border rounded-lg p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="font-semibold text-foreground">{r.code}</span>
|
||||
<span className="text-muted-foreground"> — {r.name}</span>
|
||||
{!r.policy && <span className="ml-2 text-xs text-amber-600">no policy yet (rescheduling disabled)</span>}
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={f.isActive} onChange={(e) => setField(r.coachTypeId, { isActive: e.target.checked })} />
|
||||
Enabled
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="space-y-1">
|
||||
<label className="label">Fee (% of fare)</label>
|
||||
<input type="number" min="0" max="100" className="input" value={f.feePercent} onChange={(e) => setField(r.coachTypeId, { 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(f.feeMinMinor)} onChange={(e) => setField(r.coachTypeId, { feeMinMinor: minor(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={f.cutoffMinutes} onChange={(e) => setField(r.coachTypeId, { 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={f.routeChangeAllowed} onChange={(e) => setField(r.coachTypeId, { 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={f.sameDayAllowed} onChange={(e) => setField(r.coachTypeId, { sameDayAllowed: e.target.checked })} />
|
||||
Allowed
|
||||
</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={!f.sameDayAllowed} value={f.sameDayFeePercent} onChange={(e) => setField(r.coachTypeId, { 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={!f.sameDayAllowed} value={etb(f.sameDayFeeMinMinor)} onChange={(e) => setField(r.coachTypeId, { sameDayFeeMinMinor: minor(e.target.value) })} />
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<button className="btn btn-primary flex items-center gap-2" disabled={saving === r.coachTypeId} onClick={() => save(r.coachTypeId)}>
|
||||
<Save className="h-4 w-4" />
|
||||
{saving === r.coachTypeId ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{rows.length === 0 && <p className="text-sm text-muted-foreground">No passenger coach types found.</p>}
|
||||
{message && <span className="text-sm text-muted-foreground">{message}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -527,6 +527,28 @@ export const systemConfigApi = {
|
||||
update: (data: Record<string, string>) => apiClient.patch<Record<string, string>>('/config', data),
|
||||
};
|
||||
|
||||
// Reschedule Policy API (one row per coach type = fare class)
|
||||
export interface ReschedulePolicyRow {
|
||||
coachTypeId: string;
|
||||
code: string;
|
||||
name: string;
|
||||
policy: {
|
||||
feePercent: number;
|
||||
feeMinMinor: number;
|
||||
routeChangeAllowed: boolean;
|
||||
sameDayAllowed: boolean;
|
||||
sameDayFeePercent: number;
|
||||
sameDayFeeMinMinor: number;
|
||||
cutoffMinutes: number;
|
||||
isActive: boolean;
|
||||
} | null;
|
||||
}
|
||||
export const reschedulePolicyApi = {
|
||||
list: () => apiClient.get<ReschedulePolicyRow[]>('/reschedule/policies'),
|
||||
update: (coachTypeId: string, data: Partial<NonNullable<ReschedulePolicyRow['policy']>>) =>
|
||||
apiClient.patch<any>(`/reschedule/policies/${coachTypeId}`, data),
|
||||
};
|
||||
|
||||
// App Releases API
|
||||
export const appReleasesApi = {
|
||||
getAll: async () => {
|
||||
|
||||
Reference in New Issue
Block a user