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. +
+ +
+ + + 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" + > +
+ {form.error && ( +
{form.error}
+ )} + + {isAddMode ? ( +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ ) : ( +
+ + {form.rule && stopLabel(form.rule.originStopSequence)} → {form.rule && stopLabel(form.rule.destinationStopSequence)} + + {' · '}{form.rule?.seatClass?.name ?? form.rule?.seatClassId} + {form.rule?.nationality ? ` · ${form.rule.nationality}` : ' · All nationalities'} +
+ )} + +
+ + + {Object.keys(rateMap).length > 0 && ( +

+ Exchange rates applied at booking: {Object.entries(rateMap).map(([c, r]) => `1 ETB = ${r} ${c}`).join(', ')} +

+ )} +
+ +
+ setForm({ isOpen: false, rule: null, error: null })}>Cancel + + {isAddMode ? 'Create Override' : 'Update Override'} + +
+
+
+ + 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 (