From d92d1cdaa42455ce0164abc1289e54eef05a635a Mon Sep 17 00:00:00 2001
From: Stephanos A
Date: Tue, 14 Jul 2026 22:43:57 +0300
Subject: [PATCH] Segment price override enabled
---
.../app/tariff-rates/SegmentOverridesTab.tsx | 289 ++++++++++++++++++
.../backoffice/src/app/tariff-rates/hooks.ts | 31 +-
.../backoffice/src/app/tariff-rates/page.tsx | 8 +-
.../backoffice/src/app/tariff-rates/types.ts | 18 +-
.../portal/src/app/booking/seats/page.tsx | 3 +-
5 files changed, 345 insertions(+), 4 deletions(-)
create mode 100644 apps/edr-passenger-web/backoffice/src/app/tariff-rates/SegmentOverridesTab.tsx
diff --git a/apps/edr-passenger-web/backoffice/src/app/tariff-rates/SegmentOverridesTab.tsx b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/SegmentOverridesTab.tsx
new file mode 100644
index 000000000..7dcb445c4
--- /dev/null
+++ b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/SegmentOverridesTab.tsx
@@ -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(`/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('/currencies'),
+ });
+ const rates: any[] = Array.isArray(data) ? data : (data as any)?.items ?? [];
+ // Build ETB→X lookup: rate value
+ const rateMap: Record = {};
+ for (const r of rates) {
+ if (r.fromCurrency === 'ETB') rateMap[r.toCurrency] = r.rate;
+ }
+ return rateMap;
+}
+
+function formatFixed(amountMinor: number, currency = 'ETB', rateMap: Record = {}) {
+ 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(null);
+ const [form, setForm] = useState({ 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) => {
+ 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) => (
+
+ {stopLabel(r.originStopSequence)} → {stopLabel(r.destinationStopSequence)}
+
+ ),
+ },
+ {
+ key: 'seatClass', label: 'Seat Class',
+ render: (r: SegmentFareRule) => {r.seatClass?.name ?? r.seatClassId} ,
+ },
+ {
+ key: 'nationality', label: 'Nationality',
+ render: (r: SegmentFareRule) => {r.nationality ?? 'All'} ,
+ },
+ {
+ key: 'baseFareMinor', label: 'Fixed Price',
+ render: (r: SegmentFareRule) => (
+ {formatFixed(r.baseFareMinor, 'ETB', rateMap)}
+ ),
+ },
+ {
+ key: 'converted', label: 'Converted',
+ render: (r: SegmentFareRule) => (
+
+ {Object.entries(rateMap).map(([cur, rate]) => (
+
{cur} {((r.baseFareMinor / 100) * rate).toFixed(2)}
+ ))}
+
+ ),
+ },
+ {
+ key: 'validFrom', label: 'Valid From',
+ render: (r: SegmentFareRule) => {new Date(r.validFrom).toLocaleDateString()} ,
+ },
+ {
+ key: 'validUntil', label: 'Valid Until',
+ render: (r: SegmentFareRule) => {r.validUntil ? new Date(r.validUntil).toLocaleDateString() : '—'} ,
+ },
+ ];
+
+ const isAddMode = form.isOpen && !form.rule;
+ const isPending = create.isPending || update.isPending;
+
+ return (
+ <>
+
+ Segment overrides set a fixed total price for a specific origin→destination stop pair, bypassing per-km calculation.
+ Precedence: Segment Override → Route Override → Seat Class Tariff.
+
+
+
+
setSelectedRouteId(e.target.value || null)}
+ >
+ Select route
+ {routes.map(r => (
+ {r.name}{r.code ? ` (${r.code})` : ''}
+ ))}
+
+
+
setForm({ isOpen: true, rule: null, error: null })} disabled={!selectedRouteId}>
+ Add Segment Override
+
+
+
+ {!selectedRouteId ? (
+ Select a route above to view its segment overrides.
+ ) : (
+ 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."
+ />
+ )}
+
+ setForm({ isOpen: false, rule: null, error: null })}
+ title={isAddMode ? 'Add Segment Override' : 'Edit Segment Override'}
+ size="md"
+ >
+
+
+
+ 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."
+ />
+ >
+ );
+}
diff --git a/apps/edr-passenger-web/backoffice/src/app/tariff-rates/hooks.ts b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/hooks.ts
index cd6f6b5b4..5dad6f4be 100644
--- a/apps/edr-passenger-web/backoffice/src/app/tariff-rates/hooks.ts
+++ b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/hooks.ts
@@ -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(data: unknown): T[] {
if (Array.isArray(data)) return data as T[];
@@ -32,6 +32,35 @@ export function useRoutes() {
return { routes: toArray(data) };
}
+export function useSegmentFareRules(routeId: string | null) {
+ const { data, isLoading, refetch } = useQuery({
+ queryKey: ['segment-fare-rules', routeId],
+ queryFn: () => apiClient.get(`/schedules/routes/${routeId}/segment-fares`),
+ enabled: !!routeId,
+ });
+ return { segmentFares: toArray(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],
diff --git a/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx
index aef643b06..7239809f0 100644
--- a/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx
+++ b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx
@@ -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
- {tab !== 'overrides' && (
+ {tab !== 'overrides' && tab !== 'segment-overrides' && (
{
if (tab === 'baggage') {
setShowBaggageModal(true);
@@ -113,6 +115,10 @@ export default function TariffRatesPage() {
)}
+ {tab === 'segment-overrides' && (
+
+ )}
+
{tab === 'baggage' && (
s.id === assignedSeatId)
: null;
const seatLabel = assignedSeat ? buildSeatLabel(assignedSeat) : "";
+ const seatFare = assignedSeat ? getSeatFare(assignedSeat) : null;
const isActive = i === activePassengerIndex;
const isClickable = i <= maxSelectableIndex;
return (