Merge pull request #688 from Tria-plc/alpha

Alpha
This commit is contained in:
Abubeker Yasin
2026-07-14 22:46:48 +03:00
committed by GitHub
5 changed files with 344 additions and 4 deletions

View File

@@ -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 origindestination 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."
/>
</>
);
}

View File

@@ -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],

View File

@@ -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}

View File

@@ -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';

View File

@@ -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 =