mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
@@ -0,0 +1,289 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Plus, Edit, Trash2 } from 'lucide-react';
|
||||
import DataTable from '@/components/ui/DataTable';
|
||||
import ActionButton from '@/components/ui/ActionButton';
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import { useSegmentFareRules, useSegmentFareMutations, useSeatClasses, useRoutes } from './hooks';
|
||||
import type { Route, SegmentFareRule, SeatClass } from './types';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
|
||||
interface RouteStop { sequence: number; station?: { name: string; code: string } }
|
||||
|
||||
function useRouteStops(routeId: string | null) {
|
||||
const { data } = useQuery({
|
||||
queryKey: ['route-stops', routeId],
|
||||
queryFn: () => apiClient.get<RouteStop[]>(`/routes/${routeId}/stops`),
|
||||
enabled: !!routeId,
|
||||
});
|
||||
return (Array.isArray(data) ? data : (data as any)?.items ?? []) as RouteStop[];
|
||||
}
|
||||
|
||||
function useExchangeRates() {
|
||||
const { data } = useQuery({
|
||||
queryKey: ['exchange-rates'],
|
||||
queryFn: () => apiClient.get<any[]>('/currencies'),
|
||||
});
|
||||
const rates: any[] = Array.isArray(data) ? data : (data as any)?.items ?? [];
|
||||
// Build ETB→X lookup: rate value
|
||||
const rateMap: Record<string, number> = {};
|
||||
for (const r of rates) {
|
||||
if (r.fromCurrency === 'ETB') rateMap[r.toCurrency] = r.rate;
|
||||
}
|
||||
return rateMap;
|
||||
}
|
||||
|
||||
function formatFixed(amountMinor: number, currency = 'ETB', rateMap: Record<string, number> = {}) {
|
||||
const etb = (amountMinor / 100).toFixed(2);
|
||||
if (currency === 'ETB') return `ETB ${etb}`;
|
||||
const rate = rateMap[currency];
|
||||
if (!rate) return `ETB ${etb}`;
|
||||
const converted = ((amountMinor / 100) * rate).toFixed(2);
|
||||
return `ETB ${etb} ≈ ${currency} ${converted}`;
|
||||
}
|
||||
|
||||
interface Props { routes: Route[] }
|
||||
|
||||
type FormState = { isOpen: boolean; rule: SegmentFareRule | null; error: string | null };
|
||||
|
||||
export default function SegmentOverridesTab({ routes }: Props) {
|
||||
const [selectedRouteId, setSelectedRouteId] = useState<string | null>(null);
|
||||
const [form, setForm] = useState<FormState>({ isOpen: false, rule: null, error: null });
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; id: string | null; name: string; error?: string }>({ isOpen: false, id: null, name: '' });
|
||||
|
||||
const { segmentFares, isLoading } = useSegmentFareRules(selectedRouteId);
|
||||
const { create, update, remove } = useSegmentFareMutations(selectedRouteId);
|
||||
const { allClasses } = useSeatClasses();
|
||||
const stops = useRouteStops(selectedRouteId);
|
||||
const rateMap = useExchangeRates();
|
||||
|
||||
const stopLabel = (seq: number) => {
|
||||
const s = stops.find(st => st.sequence === seq);
|
||||
return s?.station ? `${s.station.name} (${s.station.code})` : `Stop ${seq}`;
|
||||
};
|
||||
|
||||
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);
|
||||
try {
|
||||
if (form.rule) {
|
||||
await update.mutateAsync({ id: form.rule.id, baseFareMinor });
|
||||
} else {
|
||||
await create.mutateAsync({
|
||||
routeId: selectedRouteId,
|
||||
originStopSequence: Number(fd.get('originStopSequence')),
|
||||
destinationStopSequence: Number(fd.get('destinationStopSequence')),
|
||||
seatClassId: fd.get('seatClassId') as string,
|
||||
nationality: (fd.get('nationality') as string) || null,
|
||||
baseFareMinor,
|
||||
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 handleDelete = async () => {
|
||||
try {
|
||||
await remove.mutateAsync(deleteConfirm.id!);
|
||||
setDeleteConfirm({ isOpen: false, id: null, name: '' });
|
||||
} catch (err: any) {
|
||||
setDeleteConfirm(prev => ({ ...prev, error: err?.response?.data?.message ?? err?.message ?? 'Delete failed' }));
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'segment', label: 'Segment',
|
||||
render: (r: SegmentFareRule) => (
|
||||
<span className="font-medium">
|
||||
{stopLabel(r.originStopSequence)} → {stopLabel(r.destinationStopSequence)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'seatClass', label: 'Seat Class',
|
||||
render: (r: SegmentFareRule) => <span>{r.seatClass?.name ?? r.seatClassId}</span>,
|
||||
},
|
||||
{
|
||||
key: 'nationality', label: 'Nationality',
|
||||
render: (r: SegmentFareRule) => <span className="text-sm">{r.nationality ?? 'All'}</span>,
|
||||
},
|
||||
{
|
||||
key: 'baseFareMinor', label: 'Fixed Price',
|
||||
render: (r: SegmentFareRule) => (
|
||||
<span className="font-mono font-medium text-sm">{formatFixed(r.baseFareMinor, 'ETB', rateMap)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'converted', label: 'Converted',
|
||||
render: (r: SegmentFareRule) => (
|
||||
<div className="text-xs text-muted-foreground space-y-0.5">
|
||||
{Object.entries(rateMap).map(([cur, rate]) => (
|
||||
<div key={cur}>{cur} {((r.baseFareMinor / 100) * rate).toFixed(2)}</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'validFrom', label: 'Valid From',
|
||||
render: (r: SegmentFareRule) => <span className="text-sm">{new Date(r.validFrom).toLocaleDateString()}</span>,
|
||||
},
|
||||
{
|
||||
key: 'validUntil', label: 'Valid Until',
|
||||
render: (r: SegmentFareRule) => <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="mb-3 text-sm text-muted-foreground bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg px-4 py-2">
|
||||
Segment overrides set a <strong>fixed total price</strong> for a specific origin→destination stop pair, bypassing per-km calculation.
|
||||
Precedence: <strong>Segment Override</strong> → Route Override → Seat Class Tariff.
|
||||
</div>
|
||||
|
||||
<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="">Select route</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 })} disabled={!selectedRouteId}>
|
||||
Add Segment Override
|
||||
</ActionButton>
|
||||
</div>
|
||||
|
||||
{!selectedRouteId ? (
|
||||
<div className="text-center py-8 text-muted-foreground">Select a route above to view its segment overrides.</div>
|
||||
) : (
|
||||
<DataTable
|
||||
data={segmentFares}
|
||||
columns={columns}
|
||||
actions={[
|
||||
{ label: 'Edit', icon: Edit, variant: 'secondary' as const, onClick: (r: SegmentFareRule) => setForm({ isOpen: true, rule: r, error: null }) },
|
||||
{ label: 'Delete', icon: Trash2, variant: 'danger' as const, onClick: (r: SegmentFareRule) => setDeleteConfirm({ isOpen: true, id: r.id, name: `${stopLabel(r.originStopSequence)} → ${stopLabel(r.destinationStopSequence)}` }) },
|
||||
]}
|
||||
loading={isLoading}
|
||||
emptyMessage="No segment overrides for this route."
|
||||
/>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
isOpen={form.isOpen}
|
||||
onClose={() => setForm({ isOpen: false, rule: null, error: null })}
|
||||
title={isAddMode ? 'Add Segment Override' : 'Edit Segment 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 className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="label">Origin Stop *</label>
|
||||
<select name="originStopSequence" className="input w-full" required>
|
||||
<option value="">Select origin</option>
|
||||
{stops.map(s => (
|
||||
<option key={s.sequence} value={s.sequence}>{stopLabel(s.sequence)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Destination Stop *</label>
|
||||
<select name="destinationStopSequence" className="input w-full" required>
|
||||
<option value="">Select destination</option>
|
||||
{stops.map(s => (
|
||||
<option key={s.sequence} value={s.sequence}>{stopLabel(s.sequence)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Seat Class *</label>
|
||||
<select name="seatClassId" className="input w-full" required>
|
||||
<option value="">Select seat class</option>
|
||||
{allClasses.filter((c: SeatClass) => c.isActive).map((c: SeatClass) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Nationality <span className="text-muted-foreground font-normal">(optional — leave blank for all)</span></label>
|
||||
<select name="nationality" className="input w-full">
|
||||
<option value="">All nationalities</option>
|
||||
<option value="Ethiopian">Ethiopian</option>
|
||||
<option value="Djiboutian">Djiboutian</option>
|
||||
<option value="Other">Other (International)</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 && stopLabel(form.rule.originStopSequence)} → {form.rule && stopLabel(form.rule.destinationStopSequence)}
|
||||
</span>
|
||||
{' · '}{form.rule?.seatClass?.name ?? form.rule?.seatClassId}
|
||||
{form.rule?.nationality ? ` · ${form.rule.nationality}` : ' · All nationalities'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="label">Fixed Price (ETB) *</label>
|
||||
<input
|
||||
type="number"
|
||||
name="baseFareMinor"
|
||||
className="input"
|
||||
defaultValue={form.rule ? (form.rule.baseFareMinor / 100).toFixed(2) : ''}
|
||||
min="0" step="0.01" required
|
||||
placeholder="e.g. 350.00"
|
||||
/>
|
||||
{Object.keys(rateMap).length > 0 && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Exchange rates applied at booking: {Object.entries(rateMap).map(([c, r]) => `1 ETB = ${r} ${c}`).join(', ')}
|
||||
</p>
|
||||
)}
|
||||
</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: '' })}
|
||||
onConfirm={handleDelete}
|
||||
title="Delete Segment Override"
|
||||
message={`Delete the segment override for "${deleteConfirm.name}"? The route override or global tariff will apply instead.`}
|
||||
confirmText="Delete"
|
||||
isDanger
|
||||
isLoading={remove.isPending}
|
||||
error={deleteConfirm.error}
|
||||
warning="Removing this override means bookings on this segment will fall back to the route override or global tariff rate."
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import type { SeatClass, CoachType, Route, RouteFareRule, BaggageAllowance } from './types';
|
||||
import type { SeatClass, CoachType, Route, RouteFareRule, SegmentFareRule, BaggageAllowance } from './types';
|
||||
|
||||
function toArray<T>(data: unknown): T[] {
|
||||
if (Array.isArray(data)) return data as T[];
|
||||
@@ -32,6 +32,35 @@ export function useRoutes() {
|
||||
return { routes: toArray<Route>(data) };
|
||||
}
|
||||
|
||||
export function useSegmentFareRules(routeId: string | null) {
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ['segment-fare-rules', routeId],
|
||||
queryFn: () => apiClient.get<unknown>(`/schedules/routes/${routeId}/segment-fares`),
|
||||
enabled: !!routeId,
|
||||
});
|
||||
return { segmentFares: toArray<SegmentFareRule>(data), isLoading, refetch };
|
||||
}
|
||||
|
||||
export function useSegmentFareMutations(routeId: string | null) {
|
||||
const queryClient = useQueryClient();
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['segment-fare-rules', routeId] });
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: (data: any) => apiClient.post('/schedules/segment-fares', data),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
const update = useMutation({
|
||||
mutationFn: ({ id, ...data }: any) => apiClient.patch(`/schedules/segment-fares/${id}`, data),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) => apiClient.delete(`/schedules/segment-fares/${id}`),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
return { create, update, remove };
|
||||
}
|
||||
|
||||
export function useRouteFareRules(routeId: string | null) {
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ['route-fare-rules', routeId],
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Plus } from 'lucide-react';
|
||||
import ActionButton from '@/components/ui/ActionButton';
|
||||
import TariffTab from './TariffTab';
|
||||
import OverridesTab from './OverridesTab';
|
||||
import SegmentOverridesTab from './SegmentOverridesTab';
|
||||
import BaggageTab from './BaggageTab';
|
||||
import RateModal from './RateModal';
|
||||
import { useSeatClasses, useCoachTypes, useRoutes, useSeatClassMutations, useRouteFareRuleMutations } from './hooks';
|
||||
@@ -56,6 +57,7 @@ export default function TariffRatesPage() {
|
||||
const tabs: { key: TabType; label: string }[] = [
|
||||
{ key: 'tariff', label: 'Seat Class Tariffs' },
|
||||
{ key: 'overrides', label: 'Route Overrides' },
|
||||
{ key: 'segment-overrides', label: 'Segment Overrides' },
|
||||
{ key: 'baggage', label: 'Excess Luggage Rates' },
|
||||
];
|
||||
|
||||
@@ -68,7 +70,7 @@ export default function TariffRatesPage() {
|
||||
Manage per-km fare rates and excess luggage allowances per the official EDR tariff policy
|
||||
</p>
|
||||
</div>
|
||||
{tab !== 'overrides' && (
|
||||
{tab !== 'overrides' && tab !== 'segment-overrides' && (
|
||||
<ActionButton icon={Plus} onClick={() => {
|
||||
if (tab === 'baggage') {
|
||||
setShowBaggageModal(true);
|
||||
@@ -113,6 +115,10 @@ export default function TariffRatesPage() {
|
||||
<OverridesTab routes={routes} />
|
||||
)}
|
||||
|
||||
{tab === 'segment-overrides' && (
|
||||
<SegmentOverridesTab routes={routes} />
|
||||
)}
|
||||
|
||||
{tab === 'baggage' && (
|
||||
<BaggageTab
|
||||
allClasses={allClasses}
|
||||
|
||||
@@ -21,6 +21,22 @@ export interface Route {
|
||||
code?: string;
|
||||
}
|
||||
|
||||
export interface SegmentFareRule {
|
||||
id: string;
|
||||
routeId: string;
|
||||
originStopSequence: number;
|
||||
destinationStopSequence: number;
|
||||
seatClassId: string;
|
||||
baseFareMinor: number;
|
||||
nationality?: string | null;
|
||||
currency: string;
|
||||
validFrom: string;
|
||||
validUntil?: string | null;
|
||||
createdAt: string;
|
||||
seatClass?: SeatClass;
|
||||
route?: Route;
|
||||
}
|
||||
|
||||
export interface RouteFareRule {
|
||||
id: string;
|
||||
routeId: string;
|
||||
@@ -44,4 +60,4 @@ export interface BaggageAllowance {
|
||||
seatClass?: SeatClass;
|
||||
}
|
||||
|
||||
export type TabType = 'tariff' | 'overrides' | 'baggage';
|
||||
export type TabType = 'tariff' | 'overrides' | 'segment-overrides' | 'baggage';
|
||||
|
||||
@@ -966,7 +966,7 @@ export default function SeatsPage() {
|
||||
|
||||
commitSeatAssignment(seatId);
|
||||
},
|
||||
[passengerSeatMap, activePassengerIndex, validSeats, getSeatFare, commitSeatAssignment, isPackageBooking, packageTierPriceMinor, packageId, priceTierId, packageName, packageDepartureStationId, packageDepartureStationName, setPackageContext, isRoundTrip],
|
||||
[passengerSeatMap, activePassengerIndex, validSeats, getSeatFare, commitSeatAssignment, isPackageBooking, packageTierPriceMinor, packageId, priceTierId, packageName, packageDepartureStationId, packageDepartureStationName, setPackageContext, isRoundTrip, currentSchedule?.displayCurrency],
|
||||
);
|
||||
|
||||
const allSeatsAssigned =
|
||||
|
||||
Reference in New Issue
Block a user