'use client'; import { useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Plus, Trash2, Loader2, Edit } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import Modal from '@/components/ui/Modal'; import ActionButton from '@/components/ui/ActionButton'; import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { apiClient } from '@/lib/api-client'; interface Schedule { id: string; trainId: string; departureAt: string; arrivalAt: string; train?: { id: string; name: string; number: string }; originStation?: { id: string; name: string }; destinationStation?: { id: string; name: string }; } interface Route { id: string; code: string; name: string; stops?: Array<{ id: string; sequence: number; stationId: string; station?: { name: string } }>; } interface SeatClass { id: string; name: string; baseFare?: number; baseFareMinor?: number; } export default function PricingPage() { const [tab, setTab] = useState<'schedule' | 'segment' | 'baggage'>('schedule'); const [showModal, setShowModal] = useState(false); const [selectedSchedule, setSelectedSchedule] = useState(''); const [selectedRoute, setSelectedRoute] = useState(''); const [editingFare, setEditingFare] = useState(null); const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; id: string | null }>({ isOpen: false, id: null, }); const [error, setError] = useState(null); const queryClient = useQueryClient(); const [fareForm, setFareForm] = useState({ seatClassId: '', baseFare: '', nationality: '', passengerCategory: '', route: '', validFrom: new Date().toISOString().split('T')[0], validUntil: '', }); const [segmentForm, setSegmentForm] = useState({ seatClassId: '', originStationId: '', destinationStationId: '', baseFare: '', nationality: '', passengerCategory: '', validFrom: new Date().toISOString().split('T')[0], validUntil: '', }); const [baggageForm, setBaggageForm] = useState({ seatClassId: '', maxWeightKg: '', maxPiecesCount: '', excessFeePerKg: '', }); const [editingAllowance, setEditingAllowance] = useState(null); const [baggageError, setBaggageError] = useState(null); const [baggageModal, setBaggageModal] = useState(false); const [deleteAllowanceConfirm, setDeleteAllowanceConfirm] = useState<{ isOpen: boolean; id: string | null }>({ isOpen: false, id: null }); const { data: schedules = [] } = useQuery({ queryKey: ['schedules'], queryFn: () => apiClient.get('/schedules'), }); const { data: routes = [] } = useQuery({ queryKey: ['routes'], queryFn: () => apiClient.get('/routes'), }); const { data: seatClasses = [] } = useQuery({ queryKey: ['seat-classes'], queryFn: () => apiClient.get('/seat-classes'), }); const { data: stations = [] } = useQuery({ queryKey: ['stations'], queryFn: () => apiClient.get('/stations'), }); const { data: fares = [], isLoading: faresLoading, refetch: refetchFares } = useQuery({ queryKey: ['schedule-fares', selectedSchedule], queryFn: async () => { if (!selectedSchedule) return []; try { const response = await apiClient.get(`/schedules/${selectedSchedule}/fares/all`); return Array.isArray(response) ? response : (response as any)?.data || []; } catch (err: any) { const errMsg = err.response?.data?.message || err.message || 'Failed to load fares'; setError(`Error loading fares: ${errMsg}`); return []; } }, enabled: !!selectedSchedule && tab === 'schedule', }); const { data: segmentFares = [], isLoading: segmentFaresLoading, refetch: refetchSegmentFares } = useQuery({ queryKey: ['segment-fares', selectedRoute], queryFn: () => (selectedRoute ? apiClient.get(`/schedules/routes/${selectedRoute}/segment-fares`) : Promise.resolve([])), enabled: !!selectedRoute && tab === 'segment', }); const { data: allowances = [], isLoading: allowancesLoading, refetch: refetchAllowances } = useQuery({ queryKey: ['baggage-allowances'], queryFn: () => apiClient.get('/agents/excess-baggage/allowances'), enabled: tab === 'baggage', }); const createAllowanceMutation = useMutation({ mutationFn: (data: any) => apiClient.post('/agents/excess-baggage/allowances', data), onSuccess: () => { refetchAllowances(); setBaggageModal(false); setBaggageError(null); }, onError: (e: any) => setBaggageError(e?.response?.data?.message || 'Failed to save'), }); const updateAllowanceMutation = useMutation({ mutationFn: ({ id, ...data }: any) => apiClient.patch(`/agents/excess-baggage/allowances/${id}`, data), onSuccess: () => { refetchAllowances(); setBaggageModal(false); setEditingAllowance(null); setBaggageError(null); }, onError: (e: any) => setBaggageError(e?.response?.data?.message || 'Failed to update'), }); const deleteAllowanceMutation = useMutation({ mutationFn: (id: string) => apiClient.delete(`/agents/excess-baggage/allowances/${id}`), onSuccess: () => { refetchAllowances(); setDeleteAllowanceConfirm({ isOpen: false, id: null }); }, onError: (e: any) => setBaggageError(e?.response?.data?.message || 'Failed to delete'), }); const handleSaveAllowance = async () => { setBaggageError(null); if (!baggageForm.seatClassId || !baggageForm.maxWeightKg || !baggageForm.maxPiecesCount || !baggageForm.excessFeePerKg) { setBaggageError('All fields are required'); return; } const payload = { seatClassId: baggageForm.seatClassId, maxWeightKg: parseInt(baggageForm.maxWeightKg), maxPiecesCount: parseInt(baggageForm.maxPiecesCount), excessFeePerKg: Math.round(parseFloat(baggageForm.excessFeePerKg) * 100), }; if (editingAllowance) { await updateAllowanceMutation.mutateAsync({ id: editingAllowance.id, ...payload }); } else { await createAllowanceMutation.mutateAsync(payload); } }; const createFareMutation = useMutation({ mutationFn: (data: any) => apiClient.post(`/schedules/fares`, data), onSuccess: () => { refetchFares(); resetForm(); setError(null); }, onError: (err: any) => { setError(err.response?.data?.message || 'Failed to create fare rule'); }, }); const updateFareMutation = useMutation({ mutationFn: (data: any) => apiClient.patch(`/schedules/fares/${data.id}`, data), onSuccess: () => { refetchFares(); setEditingFare(null); resetForm(); setError(null); }, onError: (err: any) => { setError(err.response?.data?.message || 'Failed to update fare rule'); }, }); const deleteFareMutation = useMutation({ mutationFn: (id: string) => apiClient.delete(`/schedules/fares/${id}`), onSuccess: () => { refetchFares(); setDeleteConfirm({ isOpen: false, id: null }); }, onError: (err: any) => { setError(err.response?.data?.message || 'Failed to delete fare rule'); }, }); const createSegmentFareMutation = useMutation({ mutationFn: (data: any) => apiClient.post(`/schedules/segment-fares`, data), onSuccess: () => { refetchSegmentFares(); resetSegmentForm(); setError(null); }, onError: (err: any) => { setError(err.response?.data?.message || 'Failed to create segment fare rule'); }, }); const updateSegmentFareMutation = useMutation({ mutationFn: (data: any) => apiClient.patch(`/schedules/segment-fares/${data.id}`, data), onSuccess: () => { refetchSegmentFares(); setEditingFare(null); resetSegmentForm(); setError(null); }, onError: (err: any) => { setError(err.response?.data?.message || 'Failed to update segment fare rule'); }, }); const deleteSegmentFareMutation = useMutation({ mutationFn: (id: string) => apiClient.delete(`/schedules/segment-fares/${id}`), onSuccess: () => { refetchSegmentFares(); setDeleteConfirm({ isOpen: false, id: null }); }, onError: (err: any) => { setError(err.response?.data?.message || 'Failed to delete segment fare rule'); }, }); const resetForm = () => { setFareForm({ seatClassId: '', baseFare: '', nationality: '', passengerCategory: '', route: '', validFrom: new Date().toISOString().split('T')[0], validUntil: '', }); setEditingFare(null); setShowModal(false); }; const resetSegmentForm = () => { setSegmentForm({ seatClassId: '', originStationId: '', destinationStationId: '', baseFare: '', nationality: '', passengerCategory: '', validFrom: new Date().toISOString().split('T')[0], validUntil: '', }); setEditingFare(null); setShowModal(false); }; const handleEditFare = (fare: any) => { setEditingFare(fare); setFareForm({ seatClassId: fare.seatClassId || '', baseFare: (fare.baseFare || fare.baseFareMinor || 0).toString(), nationality: fare.nationality || '', passengerCategory: fare.passengerCategory || '', route: fare.route || '', validFrom: fare.validFrom ? new Date(fare.validFrom).toISOString().split('T')[0] : new Date().toISOString().split('T')[0], validUntil: fare.validUntil ? new Date(fare.validUntil).toISOString().split('T')[0] : '', }); setError(null); setShowModal(true); }; const handleEditSegmentFare = (fare: any) => { setEditingFare(fare); const routeStops = currentRoute?.stops || []; const originStop = routeStops.find((s: any) => s.sequence === fare.originStopSequence); const destStop = routeStops.find((s: any) => s.sequence === fare.destinationStopSequence); setSegmentForm({ seatClassId: fare.seatClassId || '', originStationId: originStop?.stationId || '', destinationStationId: destStop?.stationId || '', baseFare: (fare.baseFare || fare.baseFareMinor || 0).toString(), nationality: fare.nationality || '', passengerCategory: fare.passengerCategory || '', validFrom: fare.validFrom ? new Date(fare.validFrom).toISOString().split('T')[0] : new Date().toISOString().split('T')[0], validUntil: fare.validUntil ? new Date(fare.validUntil).toISOString().split('T')[0] : '', }); setError(null); setShowModal(true); }; const handleSaveFare = async () => { setError(null); if (!fareForm.seatClassId || !fareForm.baseFare) { setError('Seat class and base fare are required'); return; } const baseFareMinor = parseInt(fareForm.baseFare, 10); if (editingFare) { await updateFareMutation.mutateAsync({ id: editingFare.id, seatClassId: fareForm.seatClassId, baseFareMinor, nationality: fareForm.nationality || undefined, passengerCategory: fareForm.passengerCategory || undefined, route: fareForm.route || undefined, validFrom: fareForm.validFrom, validUntil: fareForm.validUntil || undefined, }); } else { await createFareMutation.mutateAsync({ scheduleId: selectedSchedule || undefined, seatClassId: fareForm.seatClassId, baseFareMinor, nationality: fareForm.nationality || undefined, passengerCategory: fareForm.passengerCategory || undefined, route: fareForm.route || undefined, validFrom: fareForm.validFrom, validUntil: fareForm.validUntil || undefined, }); } }; const handleSaveSegmentFare = async () => { setError(null); if (!segmentForm.seatClassId || !segmentForm.baseFare || !segmentForm.originStationId || !segmentForm.destinationStationId) { setError('Seat class, origin station, destination station, and fare are required'); return; } const routeStops = currentRoute?.stops || []; const originStop = routeStops.find((s: any) => s.stationId === segmentForm.originStationId); const destStop = routeStops.find((s: any) => s.stationId === segmentForm.destinationStationId); if (!originStop || !destStop) { setError('Selected stations must be on the route'); return; } if (originStop.sequence >= destStop.sequence) { setError('Destination station must be after origin station'); return; } const baseFareMinor = parseInt(segmentForm.baseFare, 10); if (editingFare) { await updateSegmentFareMutation.mutateAsync({ id: editingFare.id, routeId: selectedRoute, seatClassId: segmentForm.seatClassId, originStopSequence: originStop.sequence, destinationStopSequence: destStop.sequence, baseFareMinor, nationality: segmentForm.nationality || undefined, passengerCategory: segmentForm.passengerCategory || undefined, validFrom: segmentForm.validFrom, validUntil: segmentForm.validUntil || undefined, }); } else { await createSegmentFareMutation.mutateAsync({ routeId: selectedRoute, seatClassId: segmentForm.seatClassId, originStopSequence: originStop.sequence, destinationStopSequence: destStop.sequence, baseFareMinor, nationality: segmentForm.nationality || undefined, passengerCategory: segmentForm.passengerCategory || undefined, validFrom: segmentForm.validFrom, validUntil: segmentForm.validUntil || undefined, }); } }; const confirmDelete = async () => { if (deleteConfirm.id) { if (tab === 'schedule') { await deleteFareMutation.mutateAsync(deleteConfirm.id); } else { await deleteSegmentFareMutation.mutateAsync(deleteConfirm.id); } } }; const schedulesArray = Array.isArray(schedules) ? schedules : (schedules as any)?.items || []; const routesArray = Array.isArray(routes) ? routes : (routes as any)?.items || []; const seatClassesArray = Array.isArray(seatClasses) ? seatClasses : (seatClasses as any)?.items || []; const stationsArray = Array.isArray(stations) ? stations : (stations as any)?.items || []; const faresArray = Array.isArray(fares) ? fares : (fares as any)?.items || []; const segmentFaresArray = Array.isArray(segmentFares) ? segmentFares : (segmentFares as any)?.items || []; const allowancesArray = Array.isArray(allowances) ? allowances : (allowances as any)?.items || []; const currentRoute = routesArray.find((r: Route) => r.id === selectedRoute); const fareColumns = [ { key: 'seatClass', label: 'Seat Class', render: (fare: any) => { const className = fare.seatClass?.name || fare.seatClassName || 'N/A'; return {className}; }, }, { key: 'passengerCategory', label: 'Passenger Type', render: (fare: any) => ( {fare.passengerCategory || 'All'} ), }, { key: 'baseFare', label: 'Fare (ETB)', render: (fare: any) => { const fareValue = fare.baseFare || fare.baseFareMinor; if (!fareValue && fareValue !== 0) return N/A; return {fareValue} ETB; }, }, { key: 'nationality', label: 'Nationality', render: (fare: any) => ( {fare.nationality || 'All'} ), }, { key: 'route', label: 'Route', render: (fare: any) => ( {fare.route || '-'} ), }, { key: 'validFrom', label: 'Valid From', render: (fare: any) => { if (!fare.validFrom) return Not set; return {new Date(fare.validFrom).toLocaleDateString()}; }, }, { key: 'validUntil', label: 'Valid Until', render: (fare: any) => { if (!fare.validUntil) return Indefinite; return {new Date(fare.validUntil).toLocaleDateString()}; }, }, ]; const segmentFareColumns = [ { key: 'segment', label: 'Segment', render: (fare: any) => { const stops = currentRoute?.stops || []; const originStop = stops.find((s: any) => s.sequence === fare.originStopSequence); const destStop = stops.find((s: any) => s.sequence === fare.destinationStopSequence); return ( Stop {fare.originStopSequence} → {fare.destinationStopSequence} ); }, }, { key: 'seatClass', label: 'Seat Class', render: (fare: any) => { const className = fare.seatClass?.name || 'N/A'; return {className}; }, }, { key: 'passengerCategory', label: 'Passenger Type', render: (fare: any) => ( {fare.passengerCategory || 'All'} ), }, { key: 'baseFare', label: 'Fare (ETB)', render: (fare: any) => { const fareValue = fare.baseFare || fare.baseFareMinor; if (!fareValue && fareValue !== 0) return N/A; return {fareValue} ETB; }, }, { key: 'nationality', label: 'Nationality', render: (fare: any) => ( {fare.nationality || 'All'} ), }, { key: 'validFrom', label: 'Valid From', render: (fare: any) => { if (!fare.validFrom) return Not set; return {new Date(fare.validFrom).toLocaleDateString()}; }, }, { key: 'validUntil', label: 'Valid Until', render: (fare: any) => { if (!fare.validUntil) return Indefinite; return {new Date(fare.validUntil).toLocaleDateString()}; }, }, ]; const fareActions = [ { label: 'Edit', onClick: tab === 'schedule' ? handleEditFare : handleEditSegmentFare, variant: 'secondary' as const, icon: Edit, disabled: tab === 'schedule', // Schedule fares are computed, not stored }, { label: 'Delete', onClick: (fare: any) => setDeleteConfirm({ isOpen: true, id: fare.id }), variant: 'danger' as const, icon: Trash2, disabled: tab === 'schedule', // Schedule fares are computed, not stored }, ]; return (

Pricing & Fares

Manage fares by schedule and route segments with passenger type pricing

{ setError(null); setEditingFare(null); if (tab === 'baggage') { setBaggageForm({ seatClassId: '', maxWeightKg: '', maxPiecesCount: '', excessFeePerKg: '' }); setEditingAllowance(null); setBaggageError(null); setBaggageModal(true); } else if (tab === 'schedule') { setFareForm({ seatClassId: '', baseFare: '', nationality: '', passengerCategory: '', route: '', validFrom: new Date().toISOString().split('T')[0], validUntil: '', }); } else { setSegmentForm({ seatClassId: '', originStationId: '', destinationStationId: '', baseFare: '', nationality: '', passengerCategory: '', validFrom: new Date().toISOString().split('T')[0], validUntil: '', }); } setShowModal(true); }} > {tab === 'baggage' ? 'Add Allowance Rule' : 'Add Fare Rule'}
{tab === 'schedule' && ( <>
{selectedSchedule && (

Calculated Fares

These are dynamically calculated fares based on the fare engine. To create custom override fares, click "Add Fare Rule" above.
{faresLoading ? (
) : faresArray.length === 0 ? (
No fares available for this schedule.
) : ( <>
{faresArray.length} seat class(es) available
)}
)} )} {tab === 'segment' && ( <>
{selectedRoute && (

Segment Fare Rules

{segmentFaresLoading ? (
) : segmentFaresArray.length === 0 ? (
{`No segment fares defined. Click "Add Fare Rule" to create one.`}
) : ( <>
{segmentFaresArray.length} segment fare rule(s) found
)}
)} )} {tab === 'baggage' && ( <> {allowancesLoading ? (
) : allowancesArray.length === 0 ? (
No baggage allowance rules defined. Click "Add Allowance Rule" to create one.
) : ( {a.seatClass?.name ?? a.seatClassId} }, { key: 'maxWeightKg', label: 'Free Allowance', render: (a: any) => {a.maxWeightKg} kg, {a.maxPiecesCount} pcs }, { key: 'excessFeePerKg', label: 'Excess Fee / kg', render: (a: any) => {(a.excessFeePerKg / 100).toFixed(2)} ETB }, ]} actions={[ { label: 'Edit', icon: Edit, variant: 'secondary' as const, onClick: (a: any) => { setEditingAllowance(a); setBaggageForm({ seatClassId: a.seatClassId, maxWeightKg: String(a.maxWeightKg), maxPiecesCount: String(a.maxPiecesCount), excessFeePerKg: (a.excessFeePerKg / 100).toFixed(2), }); setBaggageError(null); setBaggageModal(true); }, }, { label: 'Delete', icon: Trash2, variant: 'danger' as const, onClick: (a: any) => setDeleteAllowanceConfirm({ isOpen: true, id: a.id }), }, ]} loading={false} emptyMessage="No allowance rules found." /> )} )}

Pricing Structure

  • Schedule Fares: Set custom pricing for each schedule by seat class and passenger type
  • Segment Fares: Set fares for specific stop-to-stop segments (e.g., Addis → Dire Dawa)
  • Passenger Type: ADULT (5+ years) or CHILD (<5) — first child travels free, subsequent children pay full fare
  • Nationality-based: Override fares for specific nationalities (Ethiopian, Djiboutian, Other)
{/* Delete Confirmation */} setDeleteConfirm({ isOpen: false, id: null })} onConfirm={confirmDelete} title={`Delete ${tab === 'schedule' ? 'Fare Rule' : 'Segment Fare Rule'}`} message={`Are you sure you want to delete this ${tab === 'schedule' ? 'fare rule' : 'segment fare rule'}?`} confirmText="Delete" isDanger={true} warning="This action cannot be undone." /> {/* Add/Edit Modal */} { if (tab === 'schedule') resetForm(); else resetSegmentForm(); }} title={`${editingFare ? 'Edit' : 'Add'} ${tab === 'schedule' ? 'Fare Rule' : 'Segment Fare Rule'}`} size="lg" >
{error && (
{error}
)} {tab === 'schedule' && ( <>

Leave empty to apply to all schedules

Scope this fare to a specific route

setFareForm({ ...fareForm, baseFare: e.target.value })} className="input w-full" placeholder="e.g., 350" required />

Scope pricing to specific passenger type

setFareForm({ ...fareForm, validFrom: e.target.value })} className="input w-full" required />
setFareForm({ ...fareForm, validUntil: e.target.value })} className="input w-full" />
)} {tab === 'segment' && ( <>
setSegmentForm({ ...segmentForm, baseFare: e.target.value })} className="input w-full" placeholder="e.g., 150" required />

Scope pricing to specific passenger type

setSegmentForm({ ...segmentForm, validFrom: e.target.value })} className="input w-full" required />
setSegmentForm({ ...segmentForm, validUntil: e.target.value })} className="input w-full" />
)}

{tab === 'schedule' ? 'Schedule Fare' : 'Segment Fare'} Options:

{tab === 'schedule' && (
  • Schedule: Apply to specific schedule only
  • Route Code: Apply to all schedules on that route
  • Passenger Type: ADULT or CHILD pricing
  • Nationality: Override for specific nationalities
  • All empty: Apply globally to all schedules
)} {tab === 'segment' && (
  • Segments: Define pricing for specific stop-to-stop segments
  • Stops: Use sequence numbers from the route
  • Passenger Type: ADULT or CHILD pricing
  • Nationality: Optional scope to specific nationalities
)}
{ if (tab === 'schedule') resetForm(); else resetSegmentForm(); }} type="button" > Cancel {editingFare ? `Update ${tab === 'schedule' ? 'Fare' : 'Segment Fare'} Rule` : `Save ${tab === 'schedule' ? 'Fare' : 'Segment Fare'} Rule`}
{/* Baggage Allowance Modal */} { setBaggageModal(false); setEditingAllowance(null); setBaggageError(null); }} title={editingAllowance ? 'Edit Allowance Rule' : 'Add Allowance Rule'} size="md">
{baggageError &&
{baggageError}
}
{editingAllowance &&

Seat class cannot be changed. Delete and recreate to change.

}
setBaggageForm({ ...baggageForm, maxWeightKg: e.target.value })} />
setBaggageForm({ ...baggageForm, maxPiecesCount: e.target.value })} />
setBaggageForm({ ...baggageForm, excessFeePerKg: e.target.value })} />

Amount charged per kg above the free allowance

{ setBaggageModal(false); setEditingAllowance(null); setBaggageError(null); }}>Cancel {editingAllowance ? 'Update' : 'Save'}
{/* Delete Allowance Confirm */} setDeleteAllowanceConfirm({ isOpen: false, id: null })} onConfirm={() => deleteAllowanceMutation.mutateAsync(deleteAllowanceConfirm.id!)} title="Delete Allowance Rule" message="Are you sure you want to delete this baggage allowance rule?" confirmText="Delete" isDanger isLoading={deleteAllowanceMutation.isPending} warning="The excess baggage fallback rate (50 ETB/kg) will apply until a new rule is created." />
); }