Boarding, payment methods, journey direction on seat hold, and more updates

This commit is contained in:
Stephanos A
2026-06-29 08:44:38 +03:00
parent 81ae99cee3
commit c6e56d1c4f
65 changed files with 6437 additions and 1425 deletions

View File

@@ -0,0 +1,452 @@
'use client';
import { useState, useRef, useEffect, useCallback } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { QrCode, Camera, RotateCcw, CheckCircle, XCircle, User, MapPin, Clock, Train, CameraOff } from 'lucide-react';
import { ticketsApi, apiClient } from '@/lib/api';
import { useAuthStore } from '@/lib/auth-store';
import { formatDateTime } from '@/lib/utils';
import { useRouter } from 'next/navigation';
import Header from '@/components/layout/Header';
// Add QR Scanner component
function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onError: (error: string) => void }) {
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const [isScanning, setIsScanning] = useState(false);
const [stream, setStream] = useState<MediaStream | null>(null);
const [cameraError, setCameraError] = useState<string | null>(null);
const scanIntervalRef = useRef<number | null>(null);
const startCamera = async () => {
try {
setCameraError(null);
const mediaStream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: 'environment', // Use back camera
width: { ideal: 1280 },
height: { ideal: 720 }
}
});
if (videoRef.current) {
videoRef.current.srcObject = mediaStream;
await videoRef.current.play();
setStream(mediaStream);
setIsScanning(true);
}
} catch (error: any) {
const errorMsg = 'Camera access denied. Please enable camera permissions in browser settings.';
setCameraError(errorMsg);
onError(errorMsg);
console.error('Camera error:', error);
}
};
const stopCamera = useCallback(() => {
if (scanIntervalRef.current) {
clearInterval(scanIntervalRef.current);
scanIntervalRef.current = null;
}
if (stream) {
stream.getTracks().forEach(track => track.stop());
setStream(null);
}
if (videoRef.current) {
videoRef.current.srcObject = null;
}
setIsScanning(false);
setCameraError(null);
}, [stream]);
// QR code scanning with jsqr
const scanFrame = useCallback(() => {
if (!videoRef.current || !canvasRef.current || !isScanning) return;
const video = videoRef.current;
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (ctx && video.readyState === video.HAVE_ENOUGH_DATA) {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
try {
// Try to use jsqr if available
const jsQR = (window as any).jsQR;
if (jsQR) {
const code = jsQR(imageData.data, imageData.width, imageData.height, {
inversionAttempts: 'dontInvert',
});
if (code) {
onScan(code.data);
stopCamera();
}
}
} catch (err) {
console.error('QR scan error:', err);
}
}
}, [isScanning, onScan, stopCamera]);
useEffect(() => {
if (isScanning) {
scanIntervalRef.current = window.setInterval(scanFrame, 100); // Scan every 100ms
}
return () => {
if (scanIntervalRef.current) {
clearInterval(scanIntervalRef.current);
}
stopCamera();
};
}, [isScanning, scanFrame, stopCamera]);
// Load jsqr from CDN
useEffect(() => {
if (!(window as any).jsQR) {
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.min.js';
script.async = true;
document.body.appendChild(script);
return () => {
document.body.removeChild(script);
};
}
}, []);
return (
<div className="space-y-4">
{!isScanning ? (
<div className="space-y-3">
<button
onClick={startCamera}
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold py-4 px-6 rounded-xl transition-colors flex items-center justify-center gap-2"
>
<Camera className="w-5 h-5" />
Scan QR Code
</button>
{cameraError && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-3">
<p className="text-red-700 dark:text-red-300 text-sm">{cameraError}</p>
</div>
)}
</div>
) : (
<div className="space-y-3">
<div className="relative bg-black rounded-xl overflow-hidden">
<video
ref={videoRef}
autoPlay
playsInline
muted
className="w-full h-64 object-cover"
/>
<div className="absolute inset-0 flex items-center justify-center">
<div className="relative w-48 h-48">
<div className="absolute inset-0 border-2 border-white border-dashed rounded-lg"></div>
<div className="absolute top-0 left-0 w-6 h-6 border-t-4 border-l-4 border-blue-400 rounded-tl-lg"></div>
<div className="absolute top-0 right-0 w-6 h-6 border-t-4 border-r-4 border-blue-400 rounded-tr-lg"></div>
<div className="absolute bottom-0 left-0 w-6 h-6 border-b-4 border-l-4 border-blue-400 rounded-bl-lg"></div>
<div className="absolute bottom-0 right-0 w-6 h-6 border-b-4 border-r-4 border-blue-400 rounded-br-lg"></div>
</div>
</div>
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-4">
<p className="text-white text-center text-sm font-medium">Position QR code within frame</p>
</div>
</div>
<canvas ref={canvasRef} className="hidden" />
<button
onClick={stopCamera}
className="w-full bg-gray-600 hover:bg-gray-700 text-white font-semibold py-3 px-6 rounded-xl transition-colors flex items-center justify-center gap-2"
>
<CameraOff className="w-5 h-5" />
Stop Camera
</button>
</div>
)}
</div>
);
}
export default function BoardingPage() {
const [qrInput, setQrInput] = useState('');
const [lastScanned, setLastScanned] = useState<any>(null);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [isScanning, setIsScanning] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const { user, isAuthenticated } = useAuthStore();
const router = useRouter();
// Check authentication on mount
useEffect(() => {
if (!isAuthenticated) {
router.push('/login');
return;
}
}, [isAuthenticated, router]);
// Get current agent data
const { data: agentData } = useQuery({
queryKey: ['agent-me'],
queryFn: () => apiClient.get<any>('/agents/me'),
enabled: !!user,
retry: false,
});
const boardingMutation = useMutation({
mutationFn: (qrCodeOrRef: string) =>
ticketsApi.scanAndBoard(qrCodeOrRef, {
validatorId: agentData?.id || user?.id || 'BACKOFFICE',
gateId: 'MOBILE-GATE',
}),
onSuccess: (result) => {
setError(null);
if (result.success) {
setSuccess('Passenger boarded successfully!');
setLastScanned(result.boarding);
setQrInput('');
// Auto-focus for next scan
setTimeout(() => inputRef.current?.focus(), 1000);
} else {
setError(result.error || 'Boarding failed');
setLastScanned(null);
}
},
onError: (error: any) => {
setError(error?.response?.data?.message || error.message || 'Boarding failed');
setSuccess(null);
setLastScanned(null);
},
});
const handleScan = (inputValue?: string) => {
const valueToScan = inputValue || qrInput.trim();
if (!valueToScan) {
setError('Please enter QR code or booking reference');
return;
}
setError(null);
setSuccess(null);
boardingMutation.mutate(valueToScan);
};
const handleButtonClick = () => {
handleScan();
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleButtonClick();
}
};
const clearAll = () => {
setQrInput('');
setError(null);
setSuccess(null);
setLastScanned(null);
inputRef.current?.focus();
};
useEffect(() => {
// Auto-focus on mount for mobile scanning (only if authenticated)
if (isAuthenticated) {
inputRef.current?.focus();
}
}, [isAuthenticated]);
// Show loading or redirect if not authenticated
if (!isAuthenticated) {
return (
<div className="flex h-screen items-center justify-center bg-gray-50 dark:bg-slate-950">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-edr-green-600 mx-auto"></div>
<p className="mt-4 text-gray-600 dark:text-gray-400">Redirecting to login...</p>
</div>
</div>
);
}
return (
<div className="flex h-screen overflow-hidden bg-gray-50 dark:bg-slate-950">
<div className="flex flex-1 flex-col overflow-hidden">
<Header />
<main className="flex-1 overflow-y-auto bg-gray-50 dark:bg-slate-950">
<div className="min-h-full bg-gradient-to-br from-emerald-50 to-blue-50 dark:from-slate-900 dark:to-slate-800 p-4">
{/* Mobile-optimized container */}
<div className="max-w-md mx-auto space-y-6">
{/* Header */}
<div className="text-center py-6">
<div className="inline-flex items-center justify-center w-16 h-16 bg-emerald-600 rounded-full mb-4">
<QrCode className="w-8 h-8 text-white" />
</div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Boarding</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">Scan ticket QR codes to board passengers</p>
{agentData && (
<div className="text-sm text-emerald-600 dark:text-emerald-400 mt-2">
Agent: {agentData.agentCode || agentData.name}
</div>
)}
</div>
{/* Scanner Input */}
<div className="bg-white dark:bg-slate-800 rounded-2xl shadow-xl p-6 border border-gray-100 dark:border-slate-700">
<div className="space-y-4">
{/* Camera Scanner */}
<QRScanner
onScan={(data) => {
setQrInput(data);
handleScan(data);
}}
onError={setError}
/>
{/* Manual Input */}
<div className="text-center text-gray-500 dark:text-gray-400 text-sm">OR</div>
<div className="relative">
<input
ref={inputRef}
type="text"
value={qrInput}
onChange={(e) => setQrInput(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="Type ticket number"
className="w-full px-4 py-4 text-lg border border-gray-300 dark:border-slate-600 rounded-xl
focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500
dark:bg-slate-700 dark:text-white dark:placeholder-slate-400
font-mono tracking-wide"
autoCapitalize="characters"
autoComplete="off"
autoFocus
/>
</div>
<div className="flex gap-3">
<button
onClick={handleButtonClick}
disabled={boardingMutation.isPending || !qrInput.trim()}
className="flex-1 bg-emerald-600 hover:bg-emerald-700 disabled:bg-gray-300
text-white font-semibold py-4 px-6 rounded-xl transition-colors
disabled:cursor-not-allowed text-lg"
>
{boardingMutation.isPending ? 'Boarding...' : 'Board Passenger'}
</button>
<button
onClick={clearAll}
className="bg-gray-500 hover:bg-gray-600 text-white font-semibold py-4 px-6 rounded-xl transition-colors"
>
<RotateCcw className="w-5 h-5" />
</button>
</div>
</div>
</div>
{/* Success Message */}
{success && (
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-2xl p-6">
<div className="flex items-center gap-3 mb-3">
<CheckCircle className="w-6 h-6 text-green-600 dark:text-green-400" />
<span className="text-green-800 dark:text-green-200 font-semibold text-lg">{success}</span>
</div>
{lastScanned && (
<div className="mt-4 space-y-3">
<div className="flex items-center gap-2">
<User className="w-4 h-4 text-green-600 dark:text-green-400" />
<span className="text-green-700 dark:text-green-300 font-medium">
{lastScanned.passengerName}
</span>
</div>
<div className="flex items-center gap-2">
<MapPin className="w-4 h-4 text-green-600 dark:text-green-400" />
<span className="text-green-700 dark:text-green-300">
{lastScanned.route}
</span>
</div>
<div className="flex items-center gap-2">
<Train className="w-4 h-4 text-green-600 dark:text-green-400" />
<span className="text-green-700 dark:text-green-300">
{lastScanned.trainName} - Coach {lastScanned.coach}, Seat {lastScanned.seat}
</span>
</div>
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-green-600 dark:text-green-400" />
<span className="text-green-700 dark:text-green-300">
Boarded: {formatDateTime(lastScanned.boardedAt)} ({lastScanned.leg})
</span>
</div>
{lastScanned.isRoundTrip && (
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3 mt-3">
<p className="text-blue-700 dark:text-blue-300 text-sm">
Round-trip ticket: Scan again for return journey
</p>
</div>
)}
<div className="text-sm text-green-600 dark:text-green-400 font-mono mt-2">
Booking: {lastScanned.bookingRef} | Ticket: {lastScanned.ticketId}
</div>
<div className="text-xs text-green-600 dark:text-green-400 mt-2">
📧 Email & SMS notifications sent to passenger
</div>
</div>
)}
</div>
)}
{/* Error Message */}
{error && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-2xl p-6">
<div className="flex items-center gap-3">
<XCircle className="w-6 h-6 text-red-600 dark:text-red-400" />
<span className="text-red-800 dark:text-red-200 font-semibold">{error}</span>
</div>
</div>
)}
{/* Instructions */}
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-2xl p-6">
<h3 className="text-blue-800 dark:text-blue-200 font-semibold mb-3">How to scan:</h3>
<ul className="text-blue-700 dark:text-blue-300 space-y-2 text-sm">
<li> Tap "Scan QR Code" and point at ticket QR code</li>
<li> For manual option, type or paste booking reference</li>
<li> Tickets can only be boarded on their departure date</li>
<li> First scan boards outbound leg for round trips</li>
<li> Email & SMS sent automatically to passenger contacts</li>
<li> Red error shows validation issues</li>
</ul>
</div>
{/* Quick Stats */}
<div className="bg-white dark:bg-slate-800 rounded-2xl shadow-xl p-6 border border-gray-100 dark:border-slate-700">
<h3 className="text-gray-900 dark:text-white font-semibold mb-3">Session Summary</h3>
<div className="flex justify-between items-center text-sm">
<span className="text-gray-600 dark:text-gray-400">Status:</span>
<span className="text-emerald-600 dark:text-emerald-400 font-semibold">
Ready to scan
</span>
</div>
</div>
</div>
</div>
</main>
</div>
</div>
);
}

View File

@@ -2,7 +2,7 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Download, Eye, XCircle, Trash2 } from 'lucide-react';
import { Download, Eye, Trash2 } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import Pagination from '@/components/ui/Pagination';
@@ -31,7 +31,6 @@ const SectionHeader = ({ title }: { title: string }) => (
function BookingsPageContent() {
const canManage = usePermission(PERMS.bookings.manage);
const canCancel = usePermission(PERMS.bookings.cancel);
const [filters, setFilters] = useState<BookingFilters>({ page: 1, pageSize: 20, search: '', status: '' });
const [extraFilters, setExtraFilters] = useState({ bookingType: '', dateFrom: '', dateTo: '', paymentStatus: '' });
const [showExtraFilters, setShowExtraFilters] = useState(false);
@@ -62,16 +61,6 @@ function BookingsPageContent() {
}),
});
const cancelMutation = useMutation({
mutationFn: ({ id, reason }: { id: string; reason?: string }) => bookingsApi.cancel(id, reason),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['bookings'] });
setSuccessMessage('Booking cancelled successfully');
setTimeout(() => setSuccessMessage(''), 3000);
},
onError: (error: any) => alert(`Error: ${error.message || 'Failed to cancel booking'}`),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => apiClient.delete(`/bookings/${id}`),
onSuccess: () => {
@@ -87,12 +76,6 @@ function BookingsPageContent() {
},
});
const handleCancel = async (booking: any) => {
if (window.confirm(`Cancel booking ${booking.bookingRef}? This will process a refund.`)) {
await cancelMutation.mutateAsync({ id: booking.id, reason: 'Cancelled by admin' });
}
};
const BOOKING_COLS = [
{ key: 'bookingRef', label: 'Booking Reference' }, { key: 'journeyType', label: 'Journey Type' },
{ key: 'passengerNames', label: 'Passenger Names' }, { key: 'contactPhone', label: 'Contact Phone' },
@@ -167,9 +150,38 @@ function BookingsPageContent() {
{
key: 'passengerNames', label: 'Names',
render: (booking: any) => {
const names: string[] = booking.passengerNames || [];
if (!names.length) return <span className="text-muted-foreground"></span>;
return <div className="flex flex-col gap-0.5">{names.map((n, i) => <span key={i} className="text-sm">{n}</span>)}</div>;
const passengers = booking.passengers || [];
if (!passengers.length) {
// Fallback to old logic if passengers array not available
const names: string[] = booking.passengerNames || [];
const adultCount = booking.adultCount || 0;
if (!names.length) return <span className="text-muted-foreground"></span>;
return (
<div className="flex flex-col gap-0.5">
{names.map((name, i) => {
const isAdult = i < adultCount;
const passengerType = isAdult ? 'A' : 'C';
return (
<span key={i} className="text-sm">
{name} ({passengerType})
</span>
);
})}
</div>
);
}
return (
<div className="flex flex-col gap-0.5">
{passengers.map((p: any, i: number) => {
const passengerType = p.category === 'ADULT' ? 'A' : 'C';
return (
<span key={i} className="text-sm">
{p.name} ({passengerType})
</span>
);
})}
</div>
);
},
},
{
@@ -206,10 +218,6 @@ function BookingsPageContent() {
const actions = [
{ label: 'View Details', onClick: (b: any) => setSelectedBooking(b), variant: 'secondary' as const, icon: Eye },
{
label: 'Cancel Booking', onClick: handleCancel, variant: 'danger' as const, icon: XCircle,
show: (b: any) => b.status !== 'CANCELLED' && b.status !== 'BOARDED',
},
{ label: 'Delete', onClick: (b: any) => { setDeleteError(null); setBookingToDelete(b); setDeleteConfirmOpen(true); }, variant: 'danger' as const, icon: Trash2 },
];

View File

@@ -144,8 +144,11 @@ export default function CoachesPage() {
const [activeTab, setActiveTab] = useState<Tab>('coaches');
const [search, setSearch] = useState('');
const [showModal, setShowModal] = useState(false);
const [showPreviewModal, setShowPreviewModal] = useState(false);
const [seatMapPreview, setSeatMapPreview] = useState<any>(null);
const [editingItem, setEditingItem] = useState<any>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; error?: string }>({ isOpen: false, item: null });
const [selectedCoachTypeId, setSelectedCoachTypeId] = useState<string>('');
const queryClient = useQueryClient();
// Coach Types Queries
@@ -212,6 +215,14 @@ export default function CoachesPage() {
},
});
const generateSeatMapMutation = useMutation({
mutationFn: fleetApi.generateSeatMap,
onSuccess: (data) => {
setSeatMapPreview(data);
setShowPreviewModal(true);
},
});
const handleCoachTypeSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
@@ -231,7 +242,8 @@ export default function CoachesPage() {
const handleCoachSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const data = {
const data: any = {
number: formData.get('number') as string,
coachTypeId: formData.get('coachTypeId') as string,
arrangement: formData.get('arrangement') as string,
@@ -240,6 +252,16 @@ export default function CoachesPage() {
status: formData.get('status') as string,
};
// Add bed-specific fields if bed coach is selected
const bedCategory = formData.get('bedCategory') as string;
if (bedCategory) {
data.bedCategory = bedCategory as 'ECONOMY_BED' | 'VIP_BED';
const bedsPerRoom = formData.get('bedsPerRoom') as string;
if (bedsPerRoom) {
data.bedsPerRoom = parseInt(bedsPerRoom);
}
}
if (editingItem?.isCoach) {
await updateCoachMutation.mutateAsync({ id: editingItem.id, data });
} else {
@@ -247,6 +269,27 @@ export default function CoachesPage() {
}
};
const handlePreviewSeatMap = async () => {
const form = document.querySelector('form') as HTMLFormElement;
const formData = new FormData(form);
const bedCategory = formData.get('bedCategory') as string;
const capacity = parseInt(formData.get('capacity') as string);
if (!bedCategory || !capacity) {
alert('Please select a bed category and enter capacity to preview seat map');
return;
}
const bedsPerRoom = bedCategory === 'VIP_BED' ? 4 : 6;
const roomsPerCoach = Math.ceil(capacity / bedsPerRoom);
await generateSeatMapMutation.mutateAsync({
coachCount: 1,
roomsPerCoach,
roomType: bedCategory,
});
};
const handleDelete = (item: any, isCoachType: boolean) => {
setDeleteConfirm({ isOpen: true, item: { ...item, isCoachType } });
};
@@ -298,7 +341,6 @@ export default function CoachesPage() {
const statusMap: Record<string, string> = {
ACTIVE: 'edr-badge-success',
MAINTENANCE: 'edr-badge-warning',
INACTIVE: 'edr-badge-danger',
};
@@ -373,10 +415,30 @@ export default function CoachesPage() {
},
{
key: 'arrangement',
label: 'Arrangement',
render: (coach: any) => (
<span className="text-sm font-mono">{coach.arrangement || 'N/A'}</span>
),
label: 'Type/Arrangement',
render: (coach: any) => {
// Check if this is a bed coach based on coach type name containing 'bed'
const coachTypeName = coach.coachType?.name?.toLowerCase() || '';
const isBedCoach = coachTypeName.includes('bed') || coachTypeName.includes('sleeper') || coachTypeName.includes('berth');
if (isBedCoach) {
// Determine if it's VIP or Economy based on coach type name
const isVIP = coachTypeName.includes('vip');
return (
<div className="flex items-center gap-2">
<Bed className="h-4 w-4 text-blue-600" />
<span className="text-sm font-mono">{coach.arrangement || 'N/A'}</span>
</div>
);
}
return (
<div className="flex items-center gap-2">
<Armchair className="h-4 w-4 text-green-600" />
<span className="text-sm font-mono">{coach.arrangement || 'N/A'}</span>
</div>
);
},
},
{
key: 'capacity',
@@ -422,6 +484,7 @@ export default function CoachesPage() {
label: 'Edit',
onClick: (item: any) => {
setEditingItem({ ...item, isCoach: true });
setSelectedCoachTypeId(item.coachTypeId || '');
setShowModal(true);
},
variant: 'secondary' as const,
@@ -446,6 +509,7 @@ export default function CoachesPage() {
icon={Plus}
onClick={() => {
setEditingItem(null);
setSelectedCoachTypeId('');
setSearch('');
setShowModal(true);
}}
@@ -556,6 +620,7 @@ export default function CoachesPage() {
onClose={() => {
setShowModal(false);
setEditingItem(null);
setSelectedCoachTypeId('');
}}
title={
activeTab === 'types'
@@ -636,12 +701,13 @@ export default function CoachesPage() {
name="coachTypeId"
className="input"
defaultValue={editingItem?.coachTypeId || ''}
onChange={(e) => setSelectedCoachTypeId(e.target.value)}
required
>
<option value="">Select Coach Type</option>
{coachTypesArray.map((ct: any) => (
<option key={ct.id} value={ct.id}>
{ct.code} - {ct.name} - {ct.type}
{ct.code} - {ct.name}
</option>
))}
</select>
@@ -659,17 +725,66 @@ export default function CoachesPage() {
/>
</div>
{/* Conditionally show bed fields only for Economy and Regular coach types */}
{(() => {
const selectedCoachType = coachTypesArray.find((ct: any) => ct.id === (selectedCoachTypeId || editingItem?.coachTypeId));
const isEconomyOrRegular = selectedCoachType &&
(selectedCoachType.name?.toLowerCase().includes('economy') ||
selectedCoachType.name?.toLowerCase().includes('regular') ||
selectedCoachType.type?.toLowerCase().includes('economy') ||
selectedCoachType.type?.toLowerCase().includes('regular'));
return isEconomyOrRegular ? (
<>
<div>
<label className="label">Bed Category</label>
<select
name="bedCategory"
className="input"
defaultValue={editingItem?.bedCategory || ''}
>
<option value="">Select bed category</option>
<option value="ECONOMY_BED">Economy Bed</option>
<option value="VIP_BED">VIP Bed</option>
</select>
<p className="text-xs text-muted-foreground mt-1">
Select if this is a bed coach
</p>
</div>
<div>
<label className="label">Beds Per Room</label>
<select
name="bedsPerRoom"
className="input"
defaultValue={editingItem?.bedsPerRoom || ''}
>
<option value="">Auto (VIP: 4, Economy: 6)</option>
<option value="2">2 beds per room</option>
<option value="4">4 beds per room</option>
<option value="6">6 beds per room</option>
</select>
<p className="text-xs text-muted-foreground mt-1">
Only applies to bed coaches
</p>
</div>
</>
) : null;
})()}
<div>
<label className="label">Arrangement *</label>
<input
type="text"
name="arrangement"
className="input"
defaultValue={editingItem?.arrangement || editingItem?.seatArrangement }
defaultValue={editingItem?.arrangement || editingItem?.seatArrangement || '2+2'}
required
placeholder="e.g., 3+2, 3+0, 2+0"
placeholder="e.g., 2+2, 3+2"
/>
<p className="text-xs text-muted-foreground mt-1">Format: separate columns with +</p>
<p className="text-xs text-muted-foreground mt-1">
For regular seats: columns separated by +
</p>
</div>
<div>
@@ -691,12 +806,14 @@ export default function CoachesPage() {
type="number"
name="sequence"
className="input"
defaultValue={editingItem?.sequence || 0}
min="0"
defaultValue={editingItem?.sequence || 1}
min="1"
required
placeholder="e.g., 1"
placeholder="1"
/>
<p className="text-xs text-muted-foreground mt-1">Used for ordering coaches in trains</p>
<p className="text-xs text-muted-foreground mt-1">
Position in train consist
</p>
</div>
<div>
@@ -708,19 +825,27 @@ export default function CoachesPage() {
required
>
<option value="ACTIVE">Active</option>
<option value="MAINTENANCE">Maintenance</option>
<option value="INACTIVE">Inactive</option>
</select>
</div>
</div>
<div className="flex justify-end gap-2 pt-4">
<ActionButton
type="button"
variant="secondary"
onClick={handlePreviewSeatMap}
loading={generateSeatMapMutation.isPending}
>
Preview Bed Layout
</ActionButton>
<ActionButton
type="button"
variant="secondary"
onClick={() => {
setShowModal(false);
setEditingItem(null);
setSelectedCoachTypeId('');
}}
>
Cancel
@@ -735,6 +860,58 @@ export default function CoachesPage() {
</form>
)}
</Modal>
{/* Seat Map Preview Modal */}
<Modal
isOpen={showPreviewModal}
onClose={() => {
setShowPreviewModal(false);
setSeatMapPreview(null);
}}
title="Bed Layout Preview"
size="lg"
>
{seatMapPreview && (
<div className="space-y-4">
<div className="bg-muted/50 p-4 rounded-lg">
<h4 className="font-semibold mb-2">Configuration</h4>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>Room Type: <span className="font-medium">{seatMapPreview.roomType}</span></div>
<div>Rooms per Coach: <span className="font-medium">{seatMapPreview.roomsPerCoach}</span></div>
<div>Beds per Room: <span className="font-medium">{seatMapPreview.bedsPerRoom}</span></div>
<div>Total Beds: <span className="font-medium">{seatMapPreview.totalBeds}</span></div>
</div>
</div>
<div className="space-y-2">
<h4 className="font-semibold">Bed Layout Sample (First Few Rooms)</h4>
<div className="bg-gray-50 p-4 rounded border max-h-64 overflow-y-auto">
{seatMapPreview.seats?.slice(0, 24).map((seat: any, idx: number) => (
<div key={idx} className="text-xs mb-1 font-mono">
{seat.seat_id} - Room: {seat.room_id} - {seat.position} {seat.bed_type}
</div>
))}
{seatMapPreview.seats?.length > 24 && (
<div className="text-xs text-muted-foreground mt-2">
... and {seatMapPreview.seats.length - 24} more beds
</div>
)}
</div>
</div>
<div className="flex justify-end">
<ActionButton
onClick={() => {
setShowPreviewModal(false);
setSeatMapPreview(null);
}}
>
Close
</ActionButton>
</div>
</div>
)}
</Modal>
</div>
);
}

View File

@@ -3,7 +3,7 @@
import { useQuery } from '@tanstack/react-query';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
import { Ticket, Users, DollarSign, Percent } from 'lucide-react';
import { Ticket, Users, DollarSign, Percent, AlertCircle, TrendingUp, Calendar } from 'lucide-react';
import StatCard from '@/components/dashboard/StatCard';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
@@ -13,43 +13,83 @@ import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, R
const COLORS = ['#2563eb', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6'];
// Mock data for fallback when API fails
const MOCK_STATS = {
totalBookings: 1247,
totalRevenue: 892450,
totalPassengers: 2156,
occupancyRate: 78
};
const MOCK_RECENT_BOOKINGS = [
{
id: '1',
bookingRef: 'BK-2024-001',
passenger: { fullName: 'John Doe' },
totalMinor: 125000,
currency: 'ETB',
status: 'CONFIRMED',
createdAt: new Date().toISOString()
},
{
id: '2',
bookingRef: 'BK-2024-002',
passenger: { fullName: 'Jane Smith' },
totalMinor: 85000,
currency: 'ETB',
status: 'PENDING',
createdAt: new Date().toISOString()
}
];
function DashboardPageContent() {
const { data: stats, isLoading: statsLoading } = useQuery({
const { data: stats, isLoading: statsLoading, error: statsError } = useQuery({
queryKey: ['dashboard-stats'],
queryFn: dashboardApi.getStats,
retry: 1,
staleTime: 60000, // 1 minute
});
const { data: revenueData, isLoading: revenueLoading } = useQuery({
queryKey: ['revenue-chart'],
queryFn: () => dashboardApi.getRevenueChart(30),
retry: 1,
});
const { data: recentBookingsData, isLoading: bookingsLoading } = useQuery<any[]>({
const { data: recentBookingsData, isLoading: bookingsLoading, error: bookingsError } = useQuery<any[]>({
queryKey: ['recent-bookings'],
queryFn: () => dashboardApi.getRecentBookings(10),
retry: 1,
});
const { data: topAgents, isLoading: agentsLoading } = useQuery({
queryKey: ['top-agents'],
queryFn: () => dashboardApi.getTopAgents(5),
retry: 1,
});
const { data: occupancyTrend, isLoading: occupancyLoading } = useQuery({
queryKey: ['occupancy-trend'],
queryFn: () => dashboardApi.getOccupancyTrend(7),
retry: 1,
});
const { data: upcomingTrips, isLoading: tripsLoading } = useQuery({
queryKey: ['upcoming-trips'],
queryFn: () => dashboardApi.getUpcomingTrips(5),
retry: 1,
});
const { data: paymentMethods } = useQuery({
queryKey: ['payment-methods'],
queryFn: dashboardApi.getPaymentMethods,
retry: 1,
});
const recentBookings = Array.isArray(recentBookingsData) ? recentBookingsData : [];
// Use actual data or fallback to mock/empty states
const displayStats = stats || (statsError ? MOCK_STATS : null);
const recentBookings = Array.isArray(recentBookingsData) ? recentBookingsData :
(bookingsError ? MOCK_RECENT_BOOKINGS : []);
const bookingColumns = [
{ key: 'reference', label: 'Reference', render: (item: any) => item.bookingRef || item.reference },
@@ -106,35 +146,52 @@ function DashboardPageContent() {
];
return (
<div className="space-y-6">
<div className="space-y-6 p-6">
<div>
<h1 className="text-3xl font-bold text-foreground">Dashboard</h1>
<p className="text-muted-foreground mt-1">Welcome back! Here&apos;s your operational summary.</p>
</div>
{/* Error Alert */}
{(statsError || bookingsError) && (
<div className="rounded-lg border border-orange-200 bg-orange-50 dark:border-orange-800 dark:bg-orange-950/30 p-4">
<div className="flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-orange-600 dark:text-orange-400" />
<div>
<h3 className="font-semibold text-orange-800 dark:text-orange-200">
Some data may be outdated
</h3>
<p className="text-sm text-orange-700 dark:text-orange-300">
Unable to fetch live data. Showing cached or sample information.
</p>
</div>
</div>
</div>
)}
{/* Primary Metrics */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
title="Total Bookings"
value={statsLoading ? '...' : (stats?.totalBookings || 0).toLocaleString()}
value={statsLoading ? '...' : (displayStats?.totalBookings || 0).toLocaleString()}
icon={Ticket}
color="blue"
/>
<StatCard
title="Total Revenue"
value={statsLoading ? '...' : formatCurrency(stats?.totalRevenue || 0, 'ETB')}
value={statsLoading ? '...' : formatCurrency(displayStats?.totalRevenue || 0, 'ETB')}
icon={DollarSign}
color="green"
/>
<StatCard
title="Total Passengers"
value={statsLoading ? '...' : (stats?.totalPassengers || 0).toLocaleString()}
value={statsLoading ? '...' : (displayStats?.totalPassengers || 0).toLocaleString()}
icon={Users}
color="purple"
/>
<StatCard
title="Occupancy Rate"
value={statsLoading ? '...' : `${stats?.occupancyRate || 0}%`}
value={statsLoading ? '...' : `${displayStats?.occupancyRate || 0}%`}
icon={Percent}
color="orange"
/>
@@ -143,9 +200,16 @@ function DashboardPageContent() {
{/* Charts Row */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* Revenue Trend */}
{!revenueLoading && revenueData && revenueData.length > 0 && (
<div className="card">
<h2 className="mb-4 text-lg font-semibold text-foreground">Revenue Trend (Last 30 Days)</h2>
<div className="card">
<h2 className="mb-4 text-lg font-semibold text-foreground flex items-center gap-2">
<TrendingUp className="h-5 w-5" />
Revenue Trend (Last 30 Days)
</h2>
{revenueLoading ? (
<div className="flex h-[300px] items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
) : revenueData && revenueData.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<LineChart data={revenueData}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
@@ -155,13 +219,27 @@ function DashboardPageContent() {
<Line type="monotone" dataKey="revenue" stroke="#2563eb" strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
</div>
)}
) : (
<div className="flex h-[300px] items-center justify-center text-muted-foreground">
<div className="text-center">
<TrendingUp className="h-12 w-12 mx-auto mb-2 opacity-50" />
<p>No revenue data available</p>
</div>
</div>
)}
</div>
{/* Occupancy Trend */}
{!occupancyLoading && occupancyTrend && occupancyTrend.length > 0 && (
<div className="card">
<h2 className="mb-4 text-lg font-semibold text-foreground">Occupancy Trend (Last 7 Days)</h2>
<div className="card">
<h2 className="mb-4 text-lg font-semibold text-foreground flex items-center gap-2">
<Percent className="h-5 w-5" />
Occupancy Trend (Last 7 Days)
</h2>
{occupancyLoading ? (
<div className="flex h-[300px] items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-green-600"></div>
</div>
) : occupancyTrend && occupancyTrend.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={occupancyTrend}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
@@ -171,8 +249,15 @@ function DashboardPageContent() {
<Bar dataKey="occupancyRate" fill="#10b981" radius={[8, 8, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
)}
) : (
<div className="flex h-[300px] items-center justify-center text-muted-foreground">
<div className="text-center">
<Percent className="h-12 w-12 mx-auto mb-2 opacity-50" />
<p>No occupancy data available</p>
</div>
</div>
)}
</div>
</div>
{/* Payment Methods Distribution */}
@@ -202,40 +287,45 @@ function DashboardPageContent() {
{/* Recent Bookings */}
<div className="card">
<h2 className="mb-4 text-lg font-semibold text-foreground">Recent Bookings</h2>
<h2 className="mb-4 text-lg font-semibold text-foreground flex items-center gap-2">
<Ticket className="h-5 w-5" />
Recent Bookings
</h2>
<DataTable
data={recentBookings}
columns={bookingColumns}
loading={bookingsLoading}
emptyMessage="No recent bookings"
emptyMessage="No recent bookings found"
/>
</div>
{/* Upcoming Trips */}
{upcomingTrips && upcomingTrips.length > 0 && (
<div className="card">
<h2 className="mb-4 text-lg font-semibold text-foreground">Upcoming Trips</h2>
<DataTable
data={upcomingTrips}
columns={tripColumns}
loading={tripsLoading}
emptyMessage="No upcoming trips"
/>
</div>
)}
<div className="card">
<h2 className="mb-4 text-lg font-semibold text-foreground flex items-center gap-2">
<Calendar className="h-5 w-5" />
Upcoming Trips
</h2>
<DataTable
data={upcomingTrips || []}
columns={tripColumns}
loading={tripsLoading}
emptyMessage="No upcoming trips scheduled"
/>
</div>
{/* Top Agents */}
{topAgents && topAgents.length > 0 && (
<div className="card">
<h2 className="mb-4 text-lg font-semibold text-foreground">Top Performing Agents</h2>
<DataTable
data={topAgents}
columns={agentColumns}
loading={agentsLoading}
emptyMessage="No agent data"
/>
</div>
)}
<div className="card">
<h2 className="mb-4 text-lg font-semibold text-foreground flex items-center gap-2">
<Users className="h-5 w-5" />
Top Performing Agents
</h2>
<DataTable
data={topAgents || []}
columns={agentColumns}
loading={agentsLoading}
emptyMessage="No agent performance data available"
/>
</div>
</div>
);
}
@@ -246,4 +336,4 @@ export default function DashboardPage() {
<DashboardPageContent />
</PermissionGuard>
);
}
}

View File

@@ -14,6 +14,7 @@ const DocPage = () => {
security: false,
analytics: false,
system: false,
enhanced: false,
});
const toggleSection = (section: string) => {
@@ -133,10 +134,32 @@ const DocPage = () => {
{ id: 'agents-how', label: '→ How-To' },
{ id: 'users', label: 'Users' },
{ id: 'users-how', label: '→ How-To' },
{ id: 'system-config', label: 'System Config' },
{ id: 'system-config-how', label: '→ How-To' },
{ id: 'settings', label: 'Settings' },
{ id: 'settings-how', label: '→ How-To' },
]
},
{
id: 'enhanced',
title: '✨ Enhanced Features',
items: [
{ id: 'excess-baggage', label: 'Excess Baggage' },
{ id: 'excess-baggage-how', label: '→ How-To' },
{ id: 'packages', label: 'Travel Packages' },
{ id: 'packages-how', label: '→ How-To' },
{ id: 'package-inquiries', label: 'Package Inquiries' },
{ id: 'package-inquiries-how', label: '→ How-To' },
{ id: 'health', label: 'Health Monitoring' },
{ id: 'health-how', label: '→ How-To' },
{ id: 'boarding', label: 'Boarding Management' },
{ id: 'boarding-how', label: '→ How-To' },
{ id: 'fare-config', label: 'Advanced Fare Config' },
{ id: 'fare-config-how', label: '→ How-To' },
{ id: 'payment-methods', label: 'Payment Methods' },
{ id: 'payment-methods-how', label: '→ How-To' },
]
},
];
const HowToStep = ({ number, title, children }: { number: number; title: string; children: React.ReactNode }) => (
@@ -203,7 +226,18 @@ const DocPage = () => {
<div id="about">
<h2 className="text-3xl font-bold text-slate-900 dark:text-white mb-4">Welcome to EDR Passenger Backoffice</h2>
<p className="text-slate-600 dark:text-slate-300">Comprehensive management system for the Ethio-Djibouti Railway passenger platform. This documentation provides complete guidance on all features, operations, and best practices.</p>
<p className="text-slate-600 dark:text-slate-300 mb-4">Comprehensive management system for the Ethio-Djibouti Railway passenger platform. This documentation provides complete guidance on all features, operations, and best practices.</p>
<div className="bg-emerald-50 dark:bg-emerald-900/20 p-6 rounded-lg border border-emerald-200 dark:border-emerald-800">
<h3 className="text-lg font-semibold text-emerald-900 dark:text-emerald-100 mb-2">🎆 Version 1.0.0 - Complete Platform Release</h3>
<ul className="text-emerald-800 dark:text-emerald-200 space-y-1">
<li> <strong>Excess Baggage:</strong> Complete baggage handling with agent tools and passenger self-pay</li>
<li> <strong>Travel Packages:</strong> Bundled offerings with tiered pricing and inquiry management</li>
<li> <strong>Health Monitoring:</strong> Comprehensive system status and performance tracking</li>
<li> <strong>Boarding Management:</strong> Gate operations and passenger processing workflows</li>
<li> <strong>Advanced Fare Config:</strong> Dynamic pricing with segment-based rules</li>
<li> <strong>Payment Methods:</strong> Multi-provider payment configuration and management</li>
</ul>
</div>
</div>
<div id="features" className="border-t pt-8">
@@ -222,7 +256,7 @@ const DocPage = () => {
<div className="space-y-4">
<HowToStep number={1} title="Access Bookings">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click {`"Bookings"`} in Operations section</li>
<li>Click "Bookings" in Operations section</li>
<li>View all bookings in table format</li>
</ol>
</HowToStep>
@@ -234,704 +268,278 @@ const DocPage = () => {
</HowToStep>
<HowToStep number={3} title="Manage Bookings">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click {`"View Details"`} for full information</li>
<li>Click {`"Cancel Booking"`} to process refunds</li>
<li>Click "View Details" for full information</li>
<li>Click "Cancel Booking" to process refunds</li>
</ol>
</HowToStep>
</div>
</div>
{/* PASSENGERS */}
<div id="passengers" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">👥 Passengers</h2>
<p className="text-slate-600 dark:text-slate-300">Manage passenger profiles, loyalty, and verification status.</p>
<div id="system-config" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4"> System Config</h2>
<p className="text-slate-600 dark:text-slate-300">Centralized system configuration management with feature flags and operational controls.</p>
</div>
<div id="passengers-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">👥 How-To: Manage Passengers</h3>
<div id="system-config-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2"> How-To: Manage System Configuration</h3>
<div className="space-y-4">
<HowToStep number={1} title="Access Passengers">
<HowToStep number={1} title="Access System Config">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click {`"Passengers"`} in Operations</li>
<li>View all profiles with pagination</li>
<li>Click "System Config" in System section</li>
<li>View all configuration categories</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Rate Limiting">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Adjust auth endpoints limit (default: 5 req/min)</li>
<li>Set strict endpoints limit (default: 20 req/min)</li>
<li>Configure default endpoints limit (default: 100 req/min)</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Seat Booking Settings">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Set seat hold duration (default: 5 minutes)</li>
<li>Configure hold cutoff before departure (default: 2 hours)</li>
<li>Click "Save Changes" to apply</li>
</ol>
</HowToStep>
</div>
</div>
{/* EXCESS BAGGAGE */}
<div id="excess-baggage" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">📦 Excess Baggage</h2>
<p className="text-slate-600 dark:text-slate-300">Manage excess baggage charges at boarding with agent tools and passenger self-pay options.</p>
</div>
<div id="excess-baggage-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">📦 How-To: Handle Excess Baggage</h3>
<div className="space-y-4">
<HowToStep number={1} title="Access Excess Baggage">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Excess Baggage" in Enhanced Features</li>
<li>View all baggage charges and their status</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Search & Filter">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Search by name, email, phone, ID</li>
<li>Filter by nationality, verification, loyalty tier</li>
<li>Search by booking reference</li>
<li>Filter by status: PENDING, PAID, CASH_COLLECTED, EXPIRED, WAIVED</li>
<li>Use date filters for specific periods</li>
</ol>
</HowToStep>
<HowToStep number={3} title="View Profile">
<HowToStep number={3} title="Manage Charges">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click passenger row to open modal</li>
<li>View account, loyalty, wallet, booking history</li>
<li>"Resend Link" for pending charges to passenger</li>
<li>"Waive" charges with reason (supervisor authority)</li>
<li>"Delete" expired or waived charges</li>
</ol>
</HowToStep>
</div>
</div>
{/* TICKETS */}
<div id="tickets" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">🎫 Tickets</h2>
<p className="text-slate-600 dark:text-slate-300">Manage ticket generation, tracking, and validation.</p>
{/* TRAVEL PACKAGES */}
<div id="packages" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">🎒 Travel Packages</h2>
<p className="text-slate-600 dark:text-slate-300">Manage pilgrimage and group travel packages with tiered pricing and capacity management.</p>
</div>
<div id="tickets-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">🎫 How-To: Manage Tickets</h3>
<div id="packages-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">🎒 How-To: Manage Travel Packages</h3>
<div className="space-y-4">
<HowToStep number={1} title="View Tickets">
<HowToStep number={1} title="Create Package">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click {`"Tickets"`} in Operations</li>
<li>View all issued tickets with status</li>
<li>Click "New Package" button</li>
<li>Fill package details: code, name, stations, schedules</li>
<li>Set capacity, validity period, and included services</li>
<li>Save package (starts in DRAFT status)</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Search Tickets">
<HowToStep number={2} title="Configure Price Tiers">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Search by booking reference or ticket number</li>
<li>Filter by validation status</li>
<li>Click "Tiers" on package to manage pricing</li>
<li>Add tiers: seat type, label, price, capacity</li>
<li>Edit existing tiers (limited if bookings exist)</li>
<li>Delete unused tiers</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Download PDF">
<HowToStep number={3} title="Activate & Manage">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click ticket to view details</li>
<li>Click {`"Download PDF"`} for printable version</li>
<li>"Activate" draft packages to make bookable</li>
<li>"Deactivate" active packages to stop new bookings</li>
<li>"Delete" packages with no bookings if needed</li>
</ol>
</HowToStep>
</div>
</div>
{/* STATIONS */}
<div id="stations" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">🏢 Stations</h2>
<p className="text-slate-600 dark:text-slate-300">Configure railway stations with locations and timezones.</p>
{/* PACKAGE INQUIRIES */}
<div id="package-inquiries" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">📝 Package Inquiries</h2>
<p className="text-slate-600 dark:text-slate-300">Manage incoming package booking inquiries and track lead conversion.</p>
</div>
<div id="stations-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">🏢 How-To: Manage Stations</h3>
<div id="package-inquiries-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">📝 How-To: Handle Package Inquiries</h3>
<div className="space-y-4">
<HowToStep number={1} title="Access Stations">
<HowToStep number={1} title="View Inquiries">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click {`"Stations"`} in Master Data</li>
<li>View all configured stations</li>
<li>Click "Package Inquiries" in Enhanced Features</li>
<li>Filter by package or inquiry status</li>
<li>View contact details, package interest, traveler count</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Create Station">
<HowToStep number={2} title="Update Status">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click {`"Add Station"`}</li>
<li>Enter code, name, city, timezone, coordinates</li>
<li>Use status dropdown: NEW CONTACTED CONVERTED/CLOSED</li>
<li>Mark as CONTACTED after first customer contact</li>
<li>Mark as CONVERTED when inquiry becomes booking</li>
<li>Mark as CLOSED if customer not interested</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Edit Station">
<HowToStep number={3} title="Lead Management">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click station to open details</li>
<li>Update information and save</li>
<li>Respond to NEW inquiries within 24 hours</li>
<li>Follow up on CONTACTED inquiries regularly</li>
<li>Delete spam or duplicate inquiries as needed</li>
</ol>
</HowToStep>
</div>
</div>
{/* TRAINS */}
<div id="trains" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">🚂 Trains</h2>
<p className="text-slate-600 dark:text-slate-300">Manage train fleet with coach assignments.</p>
{/* HEALTH MONITORING */}
<div id="health" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">🏥 Health Monitoring</h2>
<p className="text-slate-600 dark:text-slate-300">Monitor EDR Passenger API health with real-time system status and performance metrics.</p>
</div>
<div id="trains-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">🚂 How-To: Manage Trains</h3>
<div id="health-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">🏥 How-To: Monitor System Health</h3>
<div className="space-y-4">
<HowToStep number={1} title="Access Trains">
<HowToStep number={1} title="Access Health Dashboard">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click {`"Trains"`} in Master Data</li>
<li>View all trains and coaches</li>
<li>Click "Health Monitoring" in Enhanced Features</li>
<li>View overall system status banner</li>
<li>Check individual probe cards (auto-refreshing)</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Create Train">
<HowToStep number={2} title="Interpret Health Checks">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click {`"Add Train"`}</li>
<li>Enter code and select coaches</li>
<li>Liveness: API process alive (30s refresh)</li>
<li>Readiness: Database connectivity + latency (30s refresh)</li>
<li>App Info: Version, uptime, environment (60s refresh)</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Assign Coaches">
<HowToStep number={3} title="Troubleshoot Issues">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click train to edit</li>
<li>Add/remove coaches with position numbers</li>
<li>Red status: Check error details and system logs</li>
<li>High DB latency: Monitor database performance</li>
<li>Failed checks: Verify API server and connections</li>
<li>Use "Refresh" button for manual status update</li>
</ol>
</HowToStep>
</div>
</div>
{/* COACHES */}
<div id="coaches" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">🚃 Coaches</h2>
<p className="text-slate-600 dark:text-slate-300">Manage coach inventory with seat configurations.</p>
{/* BOARDING MANAGEMENT */}
<div id="boarding" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">🚆 Boarding Management</h2>
<p className="text-slate-600 dark:text-slate-300">Manage gate operations and passenger boarding processes with real-time tracking.</p>
</div>
<div id="coaches-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">🚃 How-To: Manage Coaches</h3>
<div id="boarding-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">🚆 How-To: Manage Boarding Operations</h3>
<div className="space-y-4">
<HowToStep number={1} title="Access Coaches">
<HowToStep number={1} title="Access Boarding Management">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Coaches" in Master Data</li>
<li>View all coaches and assignments</li>
<li>Click "Boarding" in Enhanced Features</li>
<li>Select active trip/schedule for boarding</li>
<li>View real-time boarding dashboard</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Create Coach">
<HowToStep number={2} title="Monitor Boarding Process">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Add Coach"</li>
<li>Enter code, select train, define seat layout</li>
<li>Track total passengers expected vs boarded</li>
<li>Monitor boarding progress percentage</li>
<li>View gate status and any alerts</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Configure Seats">
<HowToStep number={3} title="Handle Boarding Operations">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click coach to edit</li>
<li>Add seats and assign classes</li>
<li>Validate passenger tickets and documents</li>
<li>Resolve seat conflicts or issues</li>
<li>Process last-minute passengers and no-shows</li>
</ol>
</HowToStep>
</div>
</div>
{/* SEATS */}
<div id="seats" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">💺 Seats</h2>
<p className="text-slate-600 dark:text-slate-300">Manage seat inventory with visual maps.</p>
{/* ADVANCED FARE CONFIG */}
<div id="fare-config" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">📊 Advanced Fare Config</h2>
<p className="text-slate-600 dark:text-slate-300">Configure complex fare rules and dynamic pricing strategies with segment-based pricing.</p>
</div>
<div id="seats-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">💺 How-To: Manage Seats</h3>
<div id="fare-config-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">📊 How-To: Configure Advanced Fares</h3>
<div className="space-y-4">
<HowToStep number={1} title="View Seat Map">
<HowToStep number={1} title="Access Fare Configuration">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Go to "Seats" in Master Data</li>
<li>Select coach from dropdown</li>
<li>Visual map shows: Green=Available, Red=Blocked</li>
<li>Click "Advanced Fare Config" in Enhanced Features</li>
<li>Choose between Schedule Fares or Segment Fares</li>
<li>View existing fare rules and calculations</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Block Seat">
<HowToStep number={2} title="Create Fare Rules">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click available seat</li>
<li>Click "Block" and select reason</li>
<li>Set fare amounts for specific schedules or segments</li>
<li>Define passenger categories (ADULT/CHILD) and nationalities</li>
<li>Configure validity periods and seasonal adjustments</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Unblock Seat">
<HowToStep number={3} title="Manage Dynamic Pricing">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click blocked seat</li>
<li>Click "Unblock" to restore</li>
<li>Apply route segment-specific pricing</li>
<li>Set nationality-based rate variations</li>
<li>Monitor fare engine integration and real-time calculations</li>
</ol>
</HowToStep>
</div>
</div>
{/* SEAT CLASSES */}
<div id="classes" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">🎯 Seat Classes</h2>
<p className="text-slate-600 dark:text-slate-300">Define seat class types with pricing.</p>
{/* PAYMENT METHODS */}
<div id="payment-methods" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">💳 Payment Methods</h2>
<p className="text-slate-600 dark:text-slate-300">Configure and manage payment provider integrations with multi-provider support.</p>
</div>
<div id="classes-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">🎯 How-To: Manage Seat Classes</h3>
<div id="payment-methods-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">💳 How-To: Configure Payment Methods</h3>
<div className="space-y-4">
<HowToStep number={1} title="Access Classes">
<HowToStep number={1} title="Access Payment Configuration">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Seat Classes" in Master Data</li>
<li>View all class types</li>
<li>Click "Payment Methods" in Enhanced Features</li>
<li>View all configured payment providers</li>
<li>Check provider status and connectivity</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Create Class">
<HowToStep number={2} title="Configure Providers">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Add Class"</li>
<li>Enter name, base fare, premium, insurance</li>
<li>Set up API credentials (URLs, keys, merchant IDs)</li>
<li>Configure transaction fees and limits</li>
<li>Enable/disable specific payment methods</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Update Pricing">
<HowToStep number={3} title="Test & Validate">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click class to edit</li>
<li>Update fares and save</li>
<li>Run test transactions for each provider</li>
<li>Validate webhook endpoints and security</li>
<li>Monitor API connectivity and error logs</li>
</ol>
</HowToStep>
</div>
</div>
{/* ROUTES */}
<div id="routes" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">🛤 Routes</h2>
<p className="text-slate-600 dark:text-slate-300">Define railway routes with ordered stops.</p>
</div>
<div id="routes-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">🛤 How-To: Manage Routes</h3>
<div className="space-y-4">
<HowToStep number={1} title="Access Routes">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Routes" in Master Data</li>
<li>View all routes and stops</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Create Route">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Add Route"</li>
<li>Enter code and description</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Add Stops">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click route to edit</li>
<li>Click "Add Stop" and select station</li>
</ol>
</HowToStep>
</div>
</div>
{/* SCHEDULES */}
<div id="schedules" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">📅 Schedules</h2>
<p className="text-slate-600 dark:text-slate-300">Create and manage train schedules.</p>
</div>
<div id="schedules-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">📅 How-To: Create Schedules</h3>
<div className="space-y-4">
<HowToStep number={1} title="Create Single">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Go to "Schedules" in Master Data</li>
<li>Click "Create Schedule"</li>
<li>Fill train, route, departure/arrival times</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Bulk Generate">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Bulk Generate"</li>
<li>Set recurring parameters and generate</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Manage Status">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click schedule to edit</li>
<li>Update times and view fares</li>
</ol>
</HowToStep>
</div>
</div>
{/* PRICING */}
<div id="pricing" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">💰 Pricing & Fares</h2>
<p className="text-slate-600 dark:text-slate-300">Configure dynamic pricing with segments.</p>
</div>
<div id="pricing-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">💰 How-To: Configure Pricing</h3>
<div className="space-y-4">
<HowToStep number={1} title="Access Pricing">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Pricing & Fares" in Financial</li>
<li>Two tabs: Schedule Fares, Segment Fares</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Schedule Fares">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Add Fare Rule"</li>
<li>Fill schedule, seat class, fare, nationality</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Segment Fares">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Switch to "Segment Fares" tab</li>
<li>Select route and add origin/destination fare</li>
</ol>
</HowToStep>
</div>
</div>
{/* CURRENCIES */}
<div id="currencies" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">💵 Currencies</h2>
<p className="text-slate-600 dark:text-slate-300">Manage exchange rates for multiple currencies.</p>
</div>
<div id="currencies-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">💵 How-To: Manage Currencies</h3>
<div className="space-y-4">
<HowToStep number={1} title="View Rates">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Currencies" in Financial</li>
<li>View all configured rates</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Add Rate">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Add Rate"</li>
<li>Select currency and enter exchange rate</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Sync Rates">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click rate to edit</li>
<li>Click "Sync" to update from provider</li>
</ol>
</HowToStep>
</div>
</div>
{/* PAYMENTS */}
<div id="payments" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">💳 Payments</h2>
<p className="text-slate-600 dark:text-slate-300">Monitor and process transactions.</p>
</div>
<div id="payments-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">💳 How-To: Manage Payments</h3>
<div className="space-y-4">
<HowToStep number={1} title="View Transactions">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Payments" in Financial</li>
<li>View all transactions</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Search & Filter">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Search by booking or transaction ID</li>
<li>Filter by status and payment method</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Process Refunds">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click transaction</li>
<li>Click "Refund" if eligible</li>
</ol>
</HowToStep>
</div>
</div>
{/* PROMOS */}
<div id="promos" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">🎁 Promo Codes</h2>
<p className="text-slate-600 dark:text-slate-300">Create and manage promotional campaigns.</p>
</div>
<div id="promos-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">🎁 How-To: Manage Promo Codes</h3>
<div className="space-y-4">
<HowToStep number={1} title="Access Promos">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Promo Codes" in Financial</li>
<li>View all active codes</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Create Code">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Add Promo Code"</li>
<li>Enter code, discount type, validity dates</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Track Usage">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click code to view analytics</li>
<li>View usage count and savings</li>
</ol>
</HowToStep>
</div>
</div>
{/* LOYALTY */}
<div id="loyalty" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">🏆 Loyalty</h2>
<p className="text-slate-600 dark:text-slate-300">Manage loyalty program and rewards.</p>
</div>
<div id="loyalty-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">🏆 How-To: Manage Loyalty</h3>
<div className="space-y-4">
<HowToStep number={1} title="View Accounts">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Loyalty Program" in Services</li>
<li>View all loyalty accounts</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Adjust Points">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click account</li>
<li>Click "Adjust Points" and enter amount</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Award Rewards">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click account</li>
<li>Click "Grant Reward" and select reward</li>
</ol>
</HowToStep>
</div>
</div>
{/* SUPPORT */}
<div id="support" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">💬 Support</h2>
<p className="text-slate-600 dark:text-slate-300">Manage support tickets and conversations.</p>
</div>
<div id="support-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">💬 How-To: Manage Support</h3>
<div className="space-y-4">
<HowToStep number={1} title="View Tickets">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Support Center" in Services</li>
<li>View all support tickets</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Manage Ticket">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click ticket to open conversation</li>
<li>Add replies and update status</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Manage FAQ">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Go to FAQ management</li>
<li>Add or edit FAQ articles</li>
</ol>
</HowToStep>
</div>
</div>
{/* NOTIFICATIONS */}
<div id="notifications" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">🔔 Notifications</h2>
<p className="text-slate-600 dark:text-slate-300">Send notifications via multiple channels.</p>
</div>
<div id="notifications-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">🔔 How-To: Manage Notifications</h3>
<div className="space-y-4">
<HowToStep number={1} title="View Notifications">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Notifications" in Services</li>
<li>View notification history</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Send Notification">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Send Notification"</li>
<li>Select channel and message</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Manage Templates">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Go to Templates section</li>
<li>Create or edit templates with variables</li>
</ol>
</HowToStep>
</div>
</div>
{/* AUDIT */}
<div id="audit" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">📋 Audit Logs</h2>
<p className="text-slate-600 dark:text-slate-300">Monitor system activities and user actions.</p>
</div>
<div id="audit-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">📋 How-To: View Audit Logs</h3>
<div className="space-y-4">
<HowToStep number={1} title="Access Logs">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Audit Logs" in Security</li>
<li>View all recorded activities</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Filter Logs">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Filter by user, action, or date</li>
<li>Search by entity ID</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Export Logs">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click log entry for details</li>
<li>Click "Export" to download CSV</li>
</ol>
</HowToStep>
</div>
</div>
{/* FRAUD */}
<div id="fraud" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">🛡 Fraud Detection</h2>
<p className="text-slate-600 dark:text-slate-300">Monitor and manage fraud alerts.</p>
</div>
<div id="fraud-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">🛡 How-To: Manage Fraud Detection</h3>
<div className="space-y-4">
<HowToStep number={1} title="View Alerts">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Fraud Detection" in Security</li>
<li>View all fraud alerts</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Investigate">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click alert to view details</li>
<li>Review triggered rules and patterns</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Take Action">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Allow" or "Block" with notes</li>
<li>Update user status</li>
</ol>
</HowToStep>
</div>
</div>
{/* VERIFAYDA */}
<div id="verifayda" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4"> Verifayda</h2>
<p className="text-slate-600 dark:text-slate-300">Verify passenger identities against government database.</p>
</div>
<div id="verifayda-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2"> How-To: Manage Verifayda</h3>
<div className="space-y-4">
<HowToStep number={1} title="Access Verification">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Verifayda Integration" in Security</li>
<li>View verification history</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Verify Passenger">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Enter national ID or passport number</li>
<li>Click "Verify" to check database</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Review Results">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>View verified passenger data</li>
<li>Match with booking details</li>
</ol>
</HowToStep>
</div>
</div>
{/* REPORTS */}
<div id="reports" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">📊 Reports</h2>
<p className="text-slate-600 dark:text-slate-300">Generate business analytics and reports.</p>
</div>
<div id="reports-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">📊 How-To: Generate Reports</h3>
<div className="space-y-4">
<HowToStep number={1} title="Access Reports">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Reports" in Analytics</li>
<li>View available report types</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Generate Report">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click report type</li>
<li>Select date range and parameters</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Export Report">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>View report with charts</li>
<li>Click "Export" for PDF or CSV</li>
</ol>
</HowToStep>
</div>
</div>
{/* AGENTS */}
<div id="agents" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">👤 Agents</h2>
<p className="text-slate-600 dark:text-slate-300">Manage booking agents and commissions.</p>
</div>
<div id="agents-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">👤 How-To: Manage Agents</h3>
<div className="space-y-4">
<HowToStep number={1} title="View Agents">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Agents" in System</li>
<li>View all agents</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Create Agent">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Add Agent"</li>
<li>Enter name, email, commission rate</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Create Shift">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click agent to edit</li>
<li>Click "Create Shift" to assign schedule</li>
</ol>
</HowToStep>
</div>
</div>
{/* USERS */}
<div id="users" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">👥 Users</h2>
<p className="text-slate-600 dark:text-slate-300">Manage backoffice user accounts and permissions.</p>
</div>
<div id="users-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">👥 How-To: Manage Users</h3>
<div className="space-y-4">
<HowToStep number={1} title="View Users">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Users" in System</li>
<li>View all user accounts</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Create User">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Add User"</li>
<li>Enter email, name, select role</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Manage Permissions">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click user to edit</li>
<li>Adjust roles and permissions</li>
</ol>
</HowToStep>
</div>
</div>
{/* SETTINGS */}
<div id="settings" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4"> Settings</h2>
<p className="text-slate-600 dark:text-slate-300">Configure system-wide settings and integrations.</p>
</div>
<div id="settings-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2"> How-To: Configure Settings</h3>
<div className="space-y-4">
<HowToStep number={1} title="Access Settings">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Click "Settings" in System</li>
<li>View configuration options</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Configure Email">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Go to Email tab</li>
<li>Enter SendGrid API key and email</li>
</ol>
</HowToStep>
<HowToStep number={3} title="Configure API Keys">
<ol className="list-decimal pl-5 space-y-1 text-sm text-slate-700 dark:text-slate-300">
<li>Go to API tab</li>
<li>Add payment and Verifayda keys</li>
</ol>
</HowToStep>
</div>
</div>
</div>
</div>
</div>
@@ -939,11 +547,11 @@ const DocPage = () => {
<div className="mt-12 bg-slate-900 text-white py-8">
<div className="max-w-7xl mx-auto px-4 text-center text-slate-400">
<p>© 2026 Ethio-Djibouti Railway | Passenger Backoffice Documentation v1.0</p>
<p>© 2026 Ethio-Djibouti Railway | Passenger Backoffice Documentation v1.0.0</p>
</div>
</div>
</div>
);
};
export default DocPage;
export default DocPage;

View File

@@ -2,7 +2,7 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { RefreshCw, Send } from 'lucide-react';
import { RefreshCw, Send, Trash2 } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
@@ -54,6 +54,11 @@ export default function ExcessBaggagePage() {
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['excess-baggage'] }),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => excessBaggageApi.delete(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['excess-baggage'] }),
});
const columns = [
{
key: 'booking', label: 'Booking',
@@ -120,14 +125,25 @@ export default function ExcessBaggagePage() {
onClick: (c: any) => { setWaiveModal(c); setWaiveReason(''); setWaiveError(null); },
show: (c: any) => !['PAID', 'CASH_COLLECTED', 'WAIVED'].includes(c.status),
},
{
label: 'Delete',
icon: Trash2,
variant: 'danger' as const,
onClick: (c: any) => {
if (confirm('Are you sure you want to delete this charge?')) {
deleteMutation.mutate(c.id);
}
},
show: (c: any) => ['EXPIRED', 'WAIVED'].includes(c.status),
},
];
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Excess Baggage</h1>
<p className="text-muted-foreground">Track and manage excess baggage charges at boarding</p>
<h1 className="text-2xl font-bold text-foreground">Excess Lugagge</h1>
<p className="text-muted-foreground">Track and manage excess luggage charges at boarding</p>
</div>
</div>

View File

@@ -0,0 +1,54 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import Sidebar from '@/components/layout/Sidebar';
import Header from '@/components/layout/Header';
import { useAuthStore } from '@/lib/auth-store';
export default function FareManagementLayout({ children }: { children: React.ReactNode }) {
const router = useRouter();
const { isAuthenticated } = useAuthStore();
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const timer = setTimeout(() => {
setIsLoading(false);
}, 100);
return () => clearTimeout(timer);
}, []);
useEffect(() => {
if (!isLoading && !isAuthenticated) {
router.push('/login');
}
}, [isAuthenticated, router, isLoading]);
if (isLoading) {
return (
<div className="flex h-screen items-center justify-center bg-gray-50 dark:bg-slate-950">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-edr-green-600 mx-auto"></div>
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading...</p>
</div>
</div>
);
}
if (!isAuthenticated) {
return null;
}
return (
<div className="flex h-screen overflow-hidden bg-gray-50 dark:bg-slate-950">
<Sidebar />
<div className="flex flex-1 flex-col overflow-hidden">
<Header />
<main className="flex-1 overflow-y-auto bg-gray-50 dark:bg-slate-950 p-6">
{children}
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,535 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Settings, Play, Square, Trash2, TestTube, History, Download } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { apiClient } from '@/lib/api-client';
interface FareConfiguration {
id: string;
name: string;
description?: string;
effective_date: string;
expiry_date?: string;
is_active: boolean;
is_default: boolean;
created_by?: string;
approved_by?: string;
approved_at?: string;
created_at: string;
updated_at: string;
rate_rules_count: number;
components_count: number;
age_rules_count: number;
}
interface SystemStatus {
configurableFaresEnabled: boolean;
rolloutPercentage: number;
totalConfigurations: number;
activeConfiguration: string | null;
activeConfigurationName: string | null;
systemReady: boolean;
}
interface FareTestResult {
baseFareMinor: number;
componentsTotal: number;
finalTotalMinor: number;
breakdown?: Array<{
description: string;
runningTotal: number;
}>;
}
export default function ConfigurableFarePage() {
const [showCreateModal, setShowCreateModal] = useState(false);
const [showTestModal, setShowTestModal] = useState(false);
const [selectedConfig, setSelectedConfig] = useState<FareConfiguration | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; config: FareConfiguration | null }>({ isOpen: false, config: null });
const queryClient = useQueryClient();
// Queries
const { data: configurations = [], isLoading: configsLoading } = useQuery<FareConfiguration[]>({
queryKey: ['fare-configurations'],
queryFn: () => apiClient.get('/admin/fare-configurations'),
});
const { data: systemStatus } = useQuery<SystemStatus>({
queryKey: ['fare-system-status'],
queryFn: () => apiClient.get('/admin/fare-migration/status'),
});
// Mutations
const activateMutation = useMutation({
mutationFn: (id: string) => apiClient.post(`/admin/fare-configurations/${id}/activate`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['fare-configurations'] });
queryClient.invalidateQueries({ queryKey: ['fare-system-status'] });
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => apiClient.delete(`/admin/fare-configurations/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['fare-configurations'] });
setDeleteConfirm({ isOpen: false, config: null });
},
});
const toggleSystemMutation = useMutation({
mutationFn: (enabled: boolean) =>
enabled
? apiClient.post('/admin/fare-configurations/system/enable-configurable-fares', { rolloutPercentage: 100 })
: apiClient.post('/admin/fare-configurations/system/disable-configurable-fares'),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['fare-system-status'] });
},
});
const setupSystemMutation = useMutation({
mutationFn: () => apiClient.post('/admin/fare-migration/complete-setup', {
activateNewFormula: true,
enableFeature: true
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['fare-configurations'] });
queryClient.invalidateQueries({ queryKey: ['fare-system-status'] });
},
});
const handleActivate = async (config: FareConfiguration) => {
await activateMutation.mutateAsync(config.id);
};
const handleDelete = (config: FareConfiguration) => {
setDeleteConfirm({ isOpen: true, config });
};
const confirmDelete = async () => {
if (deleteConfirm.config) {
await deleteMutation.mutateAsync(deleteConfirm.config.id);
}
};
const handleTest = (config: FareConfiguration) => {
setSelectedConfig(config);
setShowTestModal(true);
};
const columns = [
{
key: 'name',
label: 'Configuration Name',
sortable: true,
render: (config: FareConfiguration) => (
<div>
<div className="font-medium">{config.name}</div>
{config.description && (
<div className="text-sm text-muted-foreground">{config.description}</div>
)}
</div>
),
},
{
key: 'status',
label: 'Status',
render: (config: FareConfiguration) => (
<div className="space-y-1">
<Badge variant="status" status={config.is_active ? 'CONFIRMED' : 'PENDING'}>
{config.is_active ? 'Active' : 'Inactive'}
</Badge>
{config.is_default && (
<Badge variant="status" status="INFO">Default</Badge>
)}
</div>
),
},
{
key: 'rules',
label: 'Rules Count',
render: (config: FareConfiguration) => (
<div className="text-sm">
<div>{config.rate_rules_count} rate rules</div>
<div>{config.components_count} components</div>
<div>{config.age_rules_count} age rules</div>
</div>
),
},
{
key: 'dates',
label: 'Validity Period',
render: (config: FareConfiguration) => (
<div className="text-sm">
<div>From: {new Date(config.effective_date).toLocaleDateString()}</div>
{config.expiry_date && (
<div>Until: {new Date(config.expiry_date).toLocaleDateString()}</div>
)}
</div>
),
},
{
key: 'created_at',
label: 'Created',
sortable: true,
render: (config: FareConfiguration) => (
<div className="text-sm">
<div>{new Date(config.created_at).toLocaleDateString()}</div>
{config.created_by && (
<div className="text-muted-foreground">by {config.created_by}</div>
)}
</div>
),
},
];
const actions = [
{
label: 'Activate',
onClick: handleActivate,
variant: 'secondary' as const,
icon: Play,
show: (config: FareConfiguration) => !config.is_active,
},
{
label: 'Test',
onClick: handleTest,
variant: 'secondary' as const,
icon: TestTube,
},
{
label: 'Delete',
onClick: handleDelete,
variant: 'danger' as const,
icon: Trash2,
show: (config: FareConfiguration) => !config.is_active,
},
];
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground">Configurable Fare Management</h1>
<p className="text-muted-foreground mt-1">
Manage dynamic fare configurations with flexible rules, components, and pricing
</p>
</div>
<div className="flex gap-2">
<ActionButton
icon={Settings}
variant="secondary"
onClick={() => setupSystemMutation.mutate()}
loading={setupSystemMutation.isPending}
disabled={systemStatus?.systemReady}
>
{systemStatus?.systemReady ? 'System Ready' : 'Setup System'}
</ActionButton>
<ActionButton
icon={Plus}
onClick={() => setShowCreateModal(true)}
>
New Configuration
</ActionButton>
</div>
</div>
{/* System Status */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="card">
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-muted-foreground">System Status</div>
<div className={`font-semibold ${systemStatus?.systemReady ? 'text-green-600' : 'text-yellow-600'}`}>
{systemStatus?.systemReady ? 'Ready' : 'Setup Required'}
</div>
</div>
<Badge variant="status" status={systemStatus?.configurableFaresEnabled ? 'CONFIRMED' : 'CANCELLED'}>
{systemStatus?.configurableFaresEnabled ? 'Enabled' : 'Disabled'}
</Badge>
</div>
</div>
<div className="card">
<div className="text-sm text-muted-foreground">Total Configurations</div>
<div className="text-2xl font-bold">{systemStatus?.totalConfigurations || 0}</div>
</div>
<div className="card">
<div className="text-sm text-muted-foreground">Rollout Percentage</div>
<div className="text-2xl font-bold">{systemStatus?.rolloutPercentage || 0}%</div>
</div>
<div className="card">
<div className="text-sm text-muted-foreground">Active Configuration</div>
<div className="font-medium">
{systemStatus?.activeConfigurationName || 'None'}
</div>
</div>
</div>
{/* System Controls */}
<div className="card">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold">System Control</h3>
<p className="text-sm text-muted-foreground mt-1">
Enable or disable the configurable fare system globally
</p>
</div>
<div className="flex items-center gap-4">
<span className="text-sm">
{systemStatus?.configurableFaresEnabled ? 'System Enabled' : 'Using Legacy System'}
</span>
<ActionButton
variant={systemStatus?.configurableFaresEnabled ? 'danger' : 'secondary'}
onClick={() => toggleSystemMutation.mutate(!systemStatus?.configurableFaresEnabled)}
loading={toggleSystemMutation.isPending}
icon={systemStatus?.configurableFaresEnabled ? Square : Play}
>
{systemStatus?.configurableFaresEnabled ? 'Disable' : 'Enable'}
</ActionButton>
</div>
</div>
</div>
{/* Configurations Table */}
<div className="card">
<div className="mb-6">
<h3 className="text-lg font-semibold">Fare Configurations</h3>
<p className="text-sm text-muted-foreground mt-1">
Manage fare calculation configurations with custom rates, components, and age-based pricing
</p>
</div>
<DataTable
data={configurations}
columns={columns}
actions={actions}
loading={configsLoading}
emptyMessage="No fare configurations found. Create your first configuration to get started."
/>
</div>
{/* Delete Confirmation */}
<ConfirmDialog
isOpen={deleteConfirm.isOpen}
onClose={() => setDeleteConfirm({ isOpen: false, config: null })}
onConfirm={confirmDelete}
title="Delete Configuration"
message={`Are you sure you want to delete "${deleteConfirm.config?.name}"? This action cannot be undone.`}
confirmText="Delete"
isDanger={true}
isLoading={deleteMutation.isPending}
warning="Active configurations cannot be deleted. Deactivate first if needed."
/>
{/* Test Modal */}
{showTestModal && selectedConfig && (
<FareTestModal
configuration={selectedConfig}
isOpen={showTestModal}
onClose={() => {
setShowTestModal(false);
setSelectedConfig(null);
}}
/>
)}
{/* Create/Edit Modal */}
{showCreateModal && (
<ConfigurationFormModal
isOpen={showCreateModal}
onClose={() => setShowCreateModal(false)}
onSuccess={() => {
setShowCreateModal(false);
queryClient.invalidateQueries({ queryKey: ['fare-configurations'] });
}}
/>
)}
</div>
);
}
// Test Modal Component
function FareTestModal({
configuration,
isOpen,
onClose
}: {
configuration: FareConfiguration;
isOpen: boolean;
onClose: () => void;
}) {
const [testData, setTestData] = useState({
distanceKm: 100,
nationality: 'Ethiopian',
coachType: 'REGULAR_SEAT',
bedPosition: '',
adultCount: 2,
childCount: 1,
});
const testMutation = useMutation<FareTestResult>({
mutationFn: () => apiClient.post(`/admin/fare-configurations/${configuration.id}/test`, testData),
});
const handleTest = () => {
testMutation.mutate();
};
return (
<Modal isOpen={isOpen} onClose={onClose} title={`Test Configuration: ${configuration.name}`} size="lg">
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Distance (km) *</label>
<input
type="number"
className="input"
value={testData.distanceKm}
onChange={(e) => setTestData({ ...testData, distanceKm: +e.target.value })}
/>
</div>
<div>
<label className="label">Nationality *</label>
<select
className="input"
value={testData.nationality}
onChange={(e) => setTestData({ ...testData, nationality: e.target.value })}
>
<option value="Ethiopian">Ethiopian</option>
<option value="Djiboutian">Djiboutian</option>
<option value="Other">International</option>
</select>
</div>
<div>
<label className="label">Coach Type *</label>
<select
className="input"
value={testData.coachType}
onChange={(e) => setTestData({ ...testData, coachType: e.target.value })}
>
<option value="REGULAR_SEAT">Regular Seat</option>
<option value="ECONOMY_BED">Economy Bed</option>
<option value="VIP_BED">VIP Bed</option>
</select>
</div>
{(testData.coachType === 'ECONOMY_BED' || testData.coachType === 'VIP_BED') && (
<div>
<label className="label">Bed Position</label>
<select
className="input"
value={testData.bedPosition}
onChange={(e) => setTestData({ ...testData, bedPosition: e.target.value })}
>
<option value="">Select position</option>
<option value="UPPER">Upper</option>
<option value="MIDDLE">Middle</option>
<option value="LOWER">Lower</option>
</select>
</div>
)}
<div>
<label className="label">Adults *</label>
<input
type="number"
min="1"
className="input"
value={testData.adultCount}
onChange={(e) => setTestData({ ...testData, adultCount: +e.target.value })}
/>
</div>
<div>
<label className="label">Children</label>
<input
type="number"
min="0"
className="input"
value={testData.childCount}
onChange={(e) => setTestData({ ...testData, childCount: +e.target.value })}
/>
</div>
</div>
<ActionButton
onClick={handleTest}
loading={testMutation.isPending}
className="w-full"
>
Calculate Fare
</ActionButton>
{testMutation.data && (
<div className="mt-6 p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
<h4 className="font-semibold text-green-900 dark:text-green-200 mb-3">Calculation Result</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span>Base Fare:</span>
<span className="font-mono">{(testMutation.data.baseFareMinor / 100).toFixed(2)} ETB</span>
</div>
<div className="flex justify-between">
<span>Components:</span>
<span className="font-mono">{(testMutation.data.componentsTotal / 100).toFixed(2)} ETB</span>
</div>
<div className="flex justify-between font-semibold border-t pt-2">
<span>Total:</span>
<span className="font-mono">{(testMutation.data.finalTotalMinor / 100).toFixed(2)} ETB</span>
</div>
</div>
{testMutation.data.breakdown && (
<div className="mt-4">
<h5 className="font-medium mb-2">Calculation Breakdown:</h5>
<div className="space-y-1 text-xs">
{testMutation.data.breakdown.map((step: any, index: number) => (
<div key={index} className="flex justify-between">
<span>{step.description}</span>
<span className="font-mono">{(step.runningTotal / 100).toFixed(2)} ETB</span>
</div>
))}
</div>
</div>
)}
</div>
)}
{testMutation.error && (
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg text-red-800 dark:text-red-200 text-sm">
{(testMutation.error as any)?.response?.data?.message || 'Test failed'}
</div>
)}
</div>
</Modal>
);
}
// Create Configuration Form Modal
function ConfigurationFormModal({
isOpen,
onClose,
onSuccess
}: {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
}) {
return (
<Modal isOpen={isOpen} onClose={onClose} title="Create Configuration" size="xl">
<div className="p-8 text-center">
<h3 className="text-lg font-semibold mb-2">Configuration Form</h3>
<p className="text-muted-foreground mb-4">
This would contain a comprehensive form for creating fare configurations with rate rules, components, and age pricing.
</p>
<ActionButton onClick={onSuccess} variant="secondary">
Close for Now
</ActionButton>
</div>
</Modal>
);
}

View File

@@ -143,17 +143,9 @@ export default function LoginPage() {
{/* Password field */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">
Password
</label>
<button
type="button"
className="text-xs text-[rgb(20,113,76)] hover:text-[rgb(16,90,61)] font-medium transition-colors"
>
Forgot password?
</button>
</div>
<label className="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider mb-2">
Password
</label>
<div className={`relative rounded-xl transition-all duration-200 ${
passwordFocused
? 'ring-2 ring-[rgb(20,113,76)] ring-offset-0'

View File

@@ -0,0 +1,63 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import Sidebar from '@/components/layout/Sidebar';
import Header from '@/components/layout/Header';
import { useAuthStore } from '@/lib/auth-store';
import { useTheme } from '@/lib/theme-store';
import type { Metadata } from 'next';
export default function PaymentMethodsLayout({
children,
}: {
children: React.ReactNode;
}) {
const router = useRouter();
const { isAuthenticated, user } = useAuthStore();
const { setTheme } = useTheme();
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// Auth is already initialized in root providers
// Just wait a tick for hydration
const timer = setTimeout(() => {
setIsLoading(false);
}, 100);
return () => clearTimeout(timer);
}, []);
useEffect(() => {
if (!isLoading && !isAuthenticated) {
router.push('/login');
}
}, [isAuthenticated, router, isLoading]);
if (isLoading) {
return (
<div className="flex h-screen items-center justify-center bg-gray-50 dark:bg-slate-950">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-edr-green-600 mx-auto"></div>
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading...</p>
</div>
</div>
);
}
if (!isAuthenticated) {
return null;
}
return (
<div className="flex h-screen overflow-hidden bg-gray-50 dark:bg-slate-950">
<Sidebar />
<div className="flex flex-1 flex-col overflow-hidden">
<Header />
<main className="flex-1 overflow-y-auto bg-gray-50 dark:bg-slate-950 p-6">
{children}
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,421 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Edit2, Trash2 } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { apiClient, paymentsApi } from '@/lib/api';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { usePermission } from '@/lib/use-permission';
import { PERMS } from '@/lib/permissions';
export default function PaymentMethodsPage() {
const canManagePayments = usePermission(PERMS.payments.manage);
const canManageAdmin = usePermission(PERMS.admin);
const canManage = canManagePayments || canManageAdmin;
const [createModalOpen, setCreateModalOpen] = useState(false);
const [editModalOpen, setEditModalOpen] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [selectedMethod, setSelectedMethod] = useState<any>(null);
const [successMessage, setSuccessMessage] = useState('');
const [formData, setFormData] = useState({
name: '',
type: 'TELEBIRR',
region: 'ETHIOPIA',
currency: 'ETB',
isEnabled: true,
displayOrder: 1,
description: '',
fees: '',
processingTime: ''
});
const queryClient = useQueryClient();
const { data, isLoading, error } = useQuery({
queryKey: ['payment-methods'],
queryFn: () => paymentsApi.getMethods(),
});
const createMutation = useMutation({
mutationFn: (data: any) => paymentsApi.addMethod(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['payment-methods'] });
setCreateModalOpen(false);
resetForm();
setSuccessMessage('Payment method added successfully');
setTimeout(() => setSuccessMessage(''), 3000);
},
});
const updateMutation = useMutation({
mutationFn: ({ id, ...data }: any) => paymentsApi.updateMethod(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['payment-methods'] });
queryClient.refetchQueries({ queryKey: ['payment-methods'] });
setEditModalOpen(false);
setSelectedMethod(null);
resetForm();
setSuccessMessage('Payment method updated successfully');
setTimeout(() => setSuccessMessage(''), 3000);
},
onError: (error) => {
console.error('Update failed:', error);
setSuccessMessage('Failed to update payment method');
setTimeout(() => setSuccessMessage(''), 3000);
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => paymentsApi.deleteMethod(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['payment-methods'] });
setDeleteConfirmOpen(false);
setSelectedMethod(null);
setSuccessMessage('Payment method deleted successfully');
setTimeout(() => setSuccessMessage(''), 3000);
},
});
const resetForm = () => {
setFormData({
name: '',
type: 'TELEBIRR',
region: 'ETHIOPIA',
currency: 'ETB',
isEnabled: true,
displayOrder: 1,
description: '',
fees: '',
processingTime: ''
});
};
const handleEdit = (method: any) => {
setSelectedMethod(method);
setFormData({
name: method.displayName || method.name || '',
type: method.type || 'TELEBIRR',
region: method.region || 'ETHIOPIA',
currency: method.currency || 'ETB',
isEnabled: method.enabled ?? method.isEnabled ?? true,
displayOrder: method.sortOrder ?? method.displayOrder ?? 1,
description: method.description || '',
fees: method.fees || '',
processingTime: method.processingTime || ''
});
setEditModalOpen(true);
};
const handleDelete = (method: any) => {
setSelectedMethod(method);
setDeleteConfirmOpen(true);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const submitData = {
displayName: formData.name,
type: formData.type,
region: formData.region,
currency: formData.currency,
enabled: formData.isEnabled,
sortOrder: formData.displayOrder,
// Additional fields that might be expected
description: formData.description,
fees: formData.fees,
processingTime: formData.processingTime,
};
console.log('Submitting data:', submitData);
if (selectedMethod) {
updateMutation.mutate({ id: selectedMethod.id, ...submitData });
} else {
createMutation.mutate(submitData);
}
};
const columns = [
{
key: 'displayName',
label: 'Name',
sortable: true,
render: (method: any) => (
<div>
<div className="font-semibold">{method.displayName || method.name}</div>
<div className="text-sm text-muted-foreground">{method.type}</div>
</div>
),
},
{
key: 'region',
label: 'Region',
render: (method: any) => (
<Badge>{method.region}</Badge>
),
},
{
key: 'currency',
label: 'Currency',
render: (method: any) => (
<span className="font-mono text-sm">{method.currency}</span>
),
},
{
key: 'enabled',
label: 'Status',
render: (method: any) => (
<Badge variant="status" status={(method.enabled ?? method.isEnabled) ? 'CONFIRMED' : 'CANCELLED'}>
{(method.enabled ?? method.isEnabled) ? 'Enabled' : 'Disabled'}
</Badge>
),
},
{
key: 'sortOrder',
label: 'Order',
render: (method: any) => (
<span className="text-sm">{method.sortOrder ?? method.displayOrder}</span>
),
},
];
const actions = [
{
label: 'Edit',
onClick: handleEdit,
variant: 'secondary' as const,
icon: Edit2,
show: () => canManage,
},
{
label: 'Delete',
onClick: handleDelete,
variant: 'danger' as const,
icon: Trash2,
show: () => canManage,
},
];
const paymentTypes = [
{ value: 'TELEBIRR', label: 'Telebirr' },
{ value: 'CBE_BIRR', label: 'CBE Birr' },
{ value: 'EBIRR', label: 'eBirr' },
{ value: 'WAAFI', label: 'Waafi' },
{ value: 'CARD', label: 'Card Payment' },
{ value: 'WALLET', label: 'Internal Wallet' },
];
const regions = [
{ value: 'ETHIOPIA', label: 'Ethiopia' },
{ value: 'DJIBOUTI', label: 'Djibouti' },
{ value: 'INTERNATIONAL', label: 'International' },
];
const currencies = [
{ value: 'ETB', label: 'Ethiopian Birr (ETB)' },
{ value: 'DJF', label: 'Djiboutian Franc (DJF)' },
{ value: 'USD', label: 'US Dollar (USD)' },
];
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Payment Methods</h1>
<p className="text-muted-foreground">Manage supported payment systems</p>
</div>
<PermissionGuard permission={PERMS.admin}>
<ActionButton icon={Plus} onClick={() => setCreateModalOpen(true)}>
Add Method
</ActionButton>
</PermissionGuard>
</div>
{successMessage && (
<div className="rounded-lg bg-green-50 dark:bg-green-900/20 p-4 text-sm text-green-800 dark:text-green-200">
{successMessage}
</div>
)}
{error && (
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 p-4 text-sm text-red-800 dark:text-red-200">
Error loading payment methods: {error instanceof Error ? error.message : 'Unknown error'}
</div>
)}
<DataTable
data={data || []}
columns={columns}
actions={actions}
loading={isLoading}
emptyMessage="No payment methods found"
/>
<Modal
isOpen={createModalOpen || editModalOpen}
onClose={() => {
setCreateModalOpen(false);
setEditModalOpen(false);
setSelectedMethod(null);
resetForm();
}}
title={selectedMethod ? 'Edit Payment Method' : 'Add Payment Method'}
size="md"
>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Name *</label>
<input
type="text"
className="input"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="e.g., Telebirr Mobile Money"
required
/>
</div>
<div>
<label className="label">Type *</label>
<select
className="input"
value={formData.type}
onChange={(e) => setFormData({ ...formData, type: e.target.value })}
required
>
{paymentTypes.map((type) => (
<option key={type.value} value={type.value}>{type.label}</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="label">Region *</label>
<select
className="input"
value={formData.region}
onChange={(e) => setFormData({ ...formData, region: e.target.value })}
required
>
{regions.map((region) => (
<option key={region.value} value={region.value}>{region.label}</option>
))}
</select>
</div>
<div>
<label className="label">Currency *</label>
<select
className="input"
value={formData.currency}
onChange={(e) => setFormData({ ...formData, currency: e.target.value })}
required
>
{currencies.map((currency) => (
<option key={currency.value} value={currency.value}>{currency.label}</option>
))}
</select>
</div>
<div>
<label className="label">Display Order</label>
<input
type="number"
className="input"
value={formData.displayOrder}
onChange={(e) => setFormData({ ...formData, displayOrder: parseInt(e.target.value) || 1 })}
min="1"
/>
</div>
</div>
<div>
<label className="label">Description</label>
<textarea
className="input"
rows={3}
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Brief description of the payment method..."
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Fees</label>
<input
type="text"
className="input"
value={formData.fees}
onChange={(e) => setFormData({ ...formData, fees: e.target.value })}
placeholder="e.g., 2.5% + 5 ETB"
/>
</div>
<div>
<label className="label">Processing Time</label>
<input
type="text"
className="input"
value={formData.processingTime}
onChange={(e) => setFormData({ ...formData, processingTime: e.target.value })}
placeholder="e.g., Instant, 1-3 business days"
/>
</div>
</div>
<div>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={formData.isEnabled}
onChange={(e) => setFormData({ ...formData, isEnabled: e.target.checked })}
className="w-4 h-4 rounded border-gray-300"
/>
<span className="text-sm">Enable this payment method</span>
</label>
</div>
<div className="flex justify-end gap-2 pt-4 border-t">
<ActionButton
variant="secondary"
onClick={() => {
setCreateModalOpen(false);
setEditModalOpen(false);
setSelectedMethod(null);
resetForm();
}}
>
Cancel
</ActionButton>
<ActionButton
type="submit"
loading={createMutation.isPending || updateMutation.isPending}
>
{selectedMethod ? 'Update' : 'Add'} Method
</ActionButton>
</div>
</form>
</Modal>
<ConfirmDialog
isOpen={deleteConfirmOpen}
onClose={() => {
setDeleteConfirmOpen(false);
setSelectedMethod(null);
}}
onConfirm={() => selectedMethod && deleteMutation.mutate(selectedMethod.id)}
title="Delete Payment Method"
message={`Are you sure you want to delete "${selectedMethod?.name}"? This action cannot be undone.`}
confirmText="Delete"
cancelText="Cancel"
isLoading={deleteMutation.isPending}
isDanger={true}
/>
</div>
);
}

View File

@@ -192,17 +192,18 @@ export default function SchedulesPage() {
if (!editingSchedule) return;
const depDate = new Date(editForm.departureAt);
const arrDate = new Date(editForm.arrivalAt);
if (arrDate <= depDate) {
// Convert local datetime-local values to UTC for API
const depLocal = new Date(editForm.departureAt);
const arrLocal = new Date(editForm.arrivalAt);
if (arrLocal <= depLocal) {
setError('Arrival time must be after departure time');
return;
}
const payload: any = {
departureAt: editForm.departureAt,
arrivalAt: editForm.arrivalAt,
departureAt: depLocal.toISOString(),
arrivalAt: arrLocal.toISOString(),
status: editForm.status,
coaches: editForm.coachIds.map((coachId: string, idx: number) => ({
coachId,
@@ -238,15 +239,22 @@ export default function SchedulesPage() {
const handleEditClick = (schedule: Schedule) => {
setEditingSchedule(schedule);
// Convert UTC dates to local time for datetime-local input
// datetime-local expects local time (no timezone info)
const dep = new Date(schedule.departureAt);
const arr = new Date(schedule.arrivalAt);
const depLocal = new Date(dep.getTime() - dep.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
const arrLocal = new Date(arr.getTime() - arr.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
// Convert to local time by adding the timezone offset
const depLocal = new Date(dep.getTime() + dep.getTimezoneOffset() * 60000);
const arrLocal = new Date(arr.getTime() + arr.getTimezoneOffset() * 60000);
// Format for datetime-local input (YYYY-MM-DDTHH:mm)
const depStr = depLocal.toISOString().slice(0, 16);
const arrStr = arrLocal.toISOString().slice(0, 16);
setEditForm({
departureAt: depLocal,
arrivalAt: arrLocal,
departureAt: depStr,
arrivalAt: arrStr,
status: schedule.status,
coachIds: schedule.coachAssignments?.map((ca: any) => ca.coachId) || [],
});
@@ -675,7 +683,7 @@ export default function SchedulesPage() {
className="rounded"
/>
<span className="text-sm">
Seq {coach.sequence || 'N/A'} - {coach.number || coach.coachNumber} - {coach.coachType?.name} (Cap: {coach.capacity})
{coach.sequence || 'N/A'} - {coach.number || coach.coachNumber} - {coach.coachType?.name} (Cap: {coach.capacity})
</span>
</label>
))
@@ -825,7 +833,7 @@ export default function SchedulesPage() {
className="rounded"
/>
<span className="text-sm">
Seq {coach.sequence || 'N/A'} - {coach.number || coach.coachNumber} - {coach.coachType?.name} (Cap: {coach.capacity})
{coach.sequence || 'N/A'} - {coach.number || coach.coachNumber} - {coach.coachType?.name} (Cap: {coach.capacity})
</span>
</label>
))

View File

@@ -9,16 +9,6 @@ import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { stationsApi } from '@/lib/api';
import { Station } from '@/types';
const TIMEZONES = [
'Africa/Addis_Ababa',
'Africa/Johannesburg',
'Africa/Cairo',
'Africa/Lagos',
'Asia/Kolkata',
'UTC',
];
export default function StationsPage() {
const [filters, setFilters] = useState({ search: '', country: '', operational: '' });
@@ -317,19 +307,6 @@ export default function StationsPage() {
<option value="DJ">Djibouti (DJ)</option>
</select>
</div>
<div>
<label className="label">Timezone *</label>
<select
name="timezone"
className="input"
defaultValue={editingStation?.timezone || 'Africa/Addis_Ababa'}
required
>
{TIMEZONES.map((tz) => (
<option key={tz} value={tz}>{tz}</option>
))}
</select>
</div>
<div>
<label className="label">Sequence Number *</label>
<input

View File

@@ -14,7 +14,7 @@ import { formatDateTime, formatCurrency, formatDateTimeShort } from '@/lib/utils
import { useAuthStore } from '@/lib/auth-store';
export default function TicketsPage() {
const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', arrivalDate: '', dateFrom: '', dateTo: '' });
const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', arrivalDate: '', dateFrom: '', dateTo: '', coachId: '' });
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [ticketToDelete, setTicketToDelete] = useState<any>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
@@ -80,6 +80,7 @@ export default function TicketsPage() {
arrivalDate: filters.arrivalDate || undefined,
dateFrom: filters.dateFrom || undefined,
dateTo: filters.dateTo || undefined,
coachId: filters.coachId || undefined,
skip: 0,
take: 50,
}),
@@ -90,6 +91,11 @@ export default function TicketsPage() {
queryFn: () => stationsApi.getAll(),
});
const { data: coachesData } = useQuery({
queryKey: ['coaches'],
queryFn: () => apiClient.get('/fleet/coaches'),
});
const boardMutation = useMutation({
mutationFn: ({ ticketId, leg }: { ticketId: string; leg?: 'outbound' | 'inbound' }) =>
ticketsApi.validate(ticketId, { status: 'USED', boardedAt: new Date().toISOString(), leg: leg === 'inbound' ? 'RETURN' : 'OUTBOUND' }),
@@ -283,7 +289,7 @@ export default function TicketsPage() {
switch (key) {
case 'ticketNumber': return ticket.ticketNumber || 'N/A';
case 'booking': return ticket.booking?.bookingRef || 'N/A';
case 'passenger': return ticket.booking?.passenger?.fullName || ticket.booking?.contactEmail || 'N/A';
case 'passenger': return ticket.passengerName || ticket.booking?.seats?.[0]?.passengerName || ticket.booking?.passenger?.fullName || ticket.booking?.contactEmail || 'Guest';
case 'trip': return (ticket.schedule?.originStation?.name || 'N/A') + ' - ' + (ticket.schedule?.destinationStation?.name || 'N/A');
case 'coach': return ticket.seat?.coach?.number || 'N/A';
case 'seat': return ticket.seat?.seatNumber || 'N/A';
@@ -322,26 +328,44 @@ export default function TicketsPage() {
key: 'ticketNumber',
label: 'Ticket Number',
sortable: true,
render: (ticket: any) => (
<div>
<div className="font-mono font-semibold">{ticket.ticketNumber || 'N/A'}</div>
<div className="text-sm text-muted-foreground">
{ticket.booking?.passenger?.fullName || 'N/A'}
render: (ticket: any) => {
const passengerName = ticket.passengerName ||
ticket.booking?.seats?.[0]?.passengerName ||
ticket.booking?.passenger?.fullName ||
ticket.booking?.contactEmail ||
'Guest';
return (
<div>
<div className="font-mono font-semibold">{ticket.ticketNumber || 'N/A'}</div>
<div className="text-sm text-muted-foreground">
{passengerName}
</div>
</div>
</div>
),
);
},
},
{
key: 'contact',
label: 'Contact',
render: (ticket: any) => (
<div>
<div className="font-medium">{ticket.booking?.contactPhone || ticket.booking?.passenger?.phone || 'N/A'}</div>
<div className="text-sm text-muted-foreground">
<div className="text-xs text-muted-foreground">{ticket.booking?.contactEmail || ticket.booking?.passenger?.email || 'N/A'}</div>
render: (ticket: any) => {
// Find the booking seat that matches this ticket's passenger
const matchingSeat = ticket.booking?.seats?.find((s: any) =>
s.passengerName === ticket.passengerName && s.leg === ticket.leg
);
// Try to get phone from BookingSeat first, then fall back to booking contact
const phone = matchingSeat?.phone || ticket.booking?.contactPhone || ticket.booking?.passenger?.phone || 'N/A';
const email = matchingSeat?.email || ticket.booking?.contactEmail || ticket.booking?.passenger?.email || 'N/A';
return (
<div>
<div className="font-medium">{phone}</div>
<div className="text-sm text-muted-foreground">
<div className="text-xs text-muted-foreground truncate" title={email}>{email}</div>
</div>
</div>
</div>
),
);
},
},
{
key: 'trip',
@@ -368,12 +392,35 @@ export default function TicketsPage() {
key: 'seat',
label: 'Seat/Bed',
sortable: true,
render: (ticket: any) => (
<div>
<div className="font-mono font-semibold">{ticket.seat?.coach?.number || 'N/A'}: {ticket.seat?.seatNumber || 'N/A'}</div>
<div className="text-xs text-muted-foreground">{ticket.seat?.coach?.coachType?.type || 'N/A'}</div>
</div>
),
render: (ticket: any) => {
const isRoundTrip = ticket.booking?.bookingType === 'ROUND_TRIP' || ticket.booking?.bookingType === 'ROUND_TRIP_TRANSIT';
if (isRoundTrip) {
// Find seats for THIS specific passenger based on passengerName
const passengerSeats = ticket.booking?.seats?.filter((s: any) => s.passengerName === ticket.passengerName) || [];
const outboundSeat = passengerSeats.find((s: any) => s.leg === 1);
const returnSeat = passengerSeats.find((s: any) => s.leg === 2);
return (
<div className="space-y-1">
<div className="font-mono font-semibold text-sm">
{outboundSeat?.seat?.coach?.number || 'N/A'}: {outboundSeat?.seat?.seatNumber || 'N/A'}
</div>
<div className="font-mono text-sm text-muted-foreground">
{returnSeat?.seat?.coach?.number || 'N/A'}: {returnSeat?.seat?.seatNumber || 'N/A'}
</div>
</div>
);
}
// For one-way, show the ticket's primary seat
return (
<div>
<div className="font-mono font-semibold">{ticket.seat?.coach?.number || 'N/A'}: {ticket.seat?.seatNumber || 'N/A'}</div>
<div className="text-xs text-muted-foreground">{ticket.seat?.coach?.coachType?.type || 'N/A'}</div>
</div>
);
},
},
{
key: 'arrivalDate',
@@ -386,8 +433,20 @@ export default function TicketsPage() {
key: 'boardingTimes',
label: 'Boarding Times',
render: (ticket: any) => {
const outbound = ticket.booking?.outboundBoardedAt;
const isRoundTrip = ticket.booking?.bookingType === 'ROUND_TRIP' || ticket.booking?.bookingType === 'ROUND_TRIP_TRANSIT';
const outbound = ticket.booking?.outboundBoardedAt || ticket.validatedAt;
const inbound = ticket.booking?.returnBoardedAt;
if (!isRoundTrip) {
// One-way tickets: only show outbound status
return (
<span className={`text-sm ${outbound ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground'}`}>
{outbound ? formatDateTimeShort(outbound) : 'Not boarded'}
</span>
);
}
// Round-trip tickets: show both legs
const hasAny = outbound || inbound;
if (!hasAny) return <span className="text-sm text-muted-foreground">Not boarded</span>;
return (
@@ -539,7 +598,7 @@ export default function TicketsPage() {
</div>
</div>
{showExtraFilters && (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mt-3">
<div className="grid grid-cols-1 sm:grid-cols-4 gap-3 mt-3">
<div>
<label className="label">Status</label>
<select
@@ -553,6 +612,19 @@ export default function TicketsPage() {
<option value="CANCELLED">Cancelled</option>
</select>
</div>
<div>
<label className="label">Coach</label>
<select
className="input"
value={filters.coachId}
onChange={(e) => setFilters({ ...filters, coachId: e.target.value })}
>
<option value="">All Coaches</option>
{(Array.isArray(coachesData) ? coachesData : (coachesData as any)?.data || []).map((coach: any) => (
<option key={coach.id} value={coach.id}>{coach.number}</option>
))}
</select>
</div>
<div>
<label className="label">Issued From</label>
<input type="date" className="input" value={filters.dateFrom}
@@ -663,7 +735,7 @@ export default function TicketsPage() {
const t = selectedTicket;
const b = t.booking;
const isRoundTrip = b?.bookingType === 'ROUND_TRIP' || b?.bookingType === 'ROUND_TRIP_TRANSIT';
const passengerName = b?.passenger?.fullName || b?.contactEmail || 'Guest';
const passengerName = b?.seats?.[0]?.passengerName || b?.passenger?.fullName || b?.contactEmail || 'Guest';
return (
<div>
{/* Gradient header */}

View File

@@ -40,7 +40,7 @@ export default function Header() {
</button>
{showNotifications && (
<div className="absolute right-0 mt-2 w-80 rounded-lg border border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-900 shadow-xl z-50">
<div className="fixed inset-x-2 top-20 z-50 rounded-lg border border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-900 shadow-xl sm:absolute sm:inset-x-auto sm:right-0 sm:top-full sm:mt-2 sm:w-80 md:w-96">
<div className="p-4 border-b border-gray-200 dark:border-slate-700">
<h3 className="font-semibold text-foreground">Notifications</h3>
</div>
@@ -96,7 +96,7 @@ export default function Header() {
</button>
{showUserMenu && (
<div className="absolute right-0 mt-2 w-56 rounded-lg border border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-900 shadow-xl z-50">
<div className="fixed inset-x-2 top-20 z-50 rounded-lg border border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-900 shadow-xl sm:absolute sm:inset-x-auto sm:right-0 sm:top-full sm:mt-2 sm:w-56">
<div className="p-3 border-b border-gray-200 dark:border-slate-700">
<p className="text-sm font-medium text-foreground">{user?.fullName || 'Full Name'}</p>
<p className="text-xs text-muted-foreground">{user?.email || 'user@email.com'}</p>

View File

@@ -2,7 +2,7 @@
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import {
LayoutDashboard,
Ticket,
@@ -13,6 +13,7 @@ import {
BarChart3,
Settings,
LogOut,
LogIn,
ChevronLeft,
ChevronRight,
Train,
@@ -61,6 +62,7 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
{ name: 'Bookings', href: '/bookings', icon: Ticket, permission: PERMS.bookings.view },
{ name: 'Passengers', href: '/passengers', icon: Users, permission: PERMS.passengers.view },
{ name: 'Tickets', href: '/tickets', icon: FileText, permission: PERMS.tickets.view },
{ name: 'Boarding', href: '/boarding', icon: LogIn, permission: PERMS.tickets.view },
{ name: 'Luggage', href: '/excess-baggage', icon: Banknote, permission: PERMS.bookings.view },
]
},
@@ -86,10 +88,11 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
{
title: 'Financial',
items: [
{ name: 'Pricing & Fares', href: '/pricing', icon: DollarSign, permission: PERMS.admin },
{ name: 'Currencies', href: '/currencies', icon: Banknote, permission: PERMS.currencies.manage },
{ name: 'Payments', href: '/payments', icon: CreditCard, permission: PERMS.payments.view },
{ name: 'Promo Codes', href: '/promos', icon: Gift, permission: PERMS.admin },
{ name: 'Pricing & Fares', href: '/pricing', icon: DollarSign, permission: PERMS.admin },
// { name: 'Configurable Fares', href: '/fare-management', icon: Settings, permission: PERMS.admin },
{ name: 'Currencies', href: '/currencies', icon: Banknote, permission: PERMS.currencies.manage },
{ name: 'Payment Methods', href: '/payment-methods', icon: CreditCard, permission: PERMS.payments.view },
{ name: 'Promo Codes', href: '/promos', icon: Gift, permission: PERMS.admin },
]
},
{
@@ -134,12 +137,40 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
}
];
// Hook to detect mobile devices
function useIsMobile() {
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const checkIsMobile = () => {
setIsMobile(window.innerWidth < 768); // md breakpoint
};
// Check on mount
checkIsMobile();
// Listen for resize events
window.addEventListener('resize', checkIsMobile);
return () => window.removeEventListener('resize', checkIsMobile);
}, []);
return isMobile;
}
export default function Sidebar() {
const pathname = usePathname();
const { user, logout, hasPermission } = useAuthStore();
const { isDark, toggleTheme } = useTheme();
const isMobile = useIsMobile();
// Initialize collapsed state based on mobile detection
const [isCollapsed, setIsCollapsed] = useState(false);
// Update collapsed state when mobile status changes
useEffect(() => {
setIsCollapsed(isMobile);
}, [isMobile]);
return (
<div className={cn(
'flex h-screen flex-col transition-all duration-300',

View File

@@ -0,0 +1,178 @@
import { apiClient } from '@/lib/api-client';
export interface FareConfiguration {
id: string;
name: string;
description?: string;
effective_date: string;
expiry_date?: string;
is_active: boolean;
is_default: boolean;
created_by?: string;
approved_by?: string;
approved_at?: string;
created_at: string;
updated_at: string;
rate_rules_count: number;
components_count: number;
age_rules_count: number;
rateRules?: any[];
components?: any[];
ageRules?: any[];
}
export interface CreateFareConfigurationRequest {
name: string;
description?: string;
effectiveDate: string;
expiryDate?: string;
rateRules: RateRule[];
components: FareComponent[];
ageRules: AgeRule[];
isDefault?: boolean;
}
export interface RateRule {
nationalityType: 'LOCAL' | 'INTERNATIONAL';
coachType: 'REGULAR_SEAT' | 'ECONOMY_BED' | 'VIP_BED';
bedPosition?: 'UPPER' | 'MIDDLE' | 'LOWER';
ratePerKmMinor: number;
isActive?: boolean;
}
export interface FareComponent {
componentType: 'INSURANCE' | 'PREMIUM' | 'SERVICE_CHARGE' | 'TAX' | 'DEMAND';
componentName: string;
calculationMethod: 'MULTIPLIER' | 'PERCENTAGE' | 'FIXED_AMOUNT';
valueMinor?: number;
percentageValue?: number;
appliesTo: 'BASE_FARE' | 'SUBTOTAL' | 'TOTAL';
applyOrder: number;
isActive?: boolean;
}
export interface AgeRule {
ruleName: string;
minAge: number;
maxAge?: number;
pricingType: 'FREE' | 'FULL_FARE' | 'DISCOUNTED';
discountPercentage?: number;
maxFreePassengers?: number;
appliesToComponents?: boolean;
isActive?: boolean;
}
export interface TestScenario {
distanceKm: number;
nationality: string;
coachType: 'REGULAR_SEAT' | 'ECONOMY_BED' | 'VIP_BED';
bedPosition?: 'UPPER' | 'MIDDLE' | 'LOWER';
adultCount: number;
childCount?: number;
promoCode?: string;
loyaltyPoints?: number;
}
export interface CalculationResult {
baseFareMinor: number;
componentsTotal: number;
totalBeforeDiscounts: number;
discountsTotal: number;
finalTotalMinor: number;
breakdown: Array<{
step: string;
description: string;
amount: number;
runningTotal: number;
}>;
currency: string;
calculationTimestamp: string;
}
export interface SystemStatus {
configurableFaresEnabled: boolean;
rolloutPercentage: number;
totalConfigurations: number;
activeConfiguration: string | null;
activeConfigurationName: string | null;
systemReady: boolean;
}
export const configurableFareApi = {
// Configuration Management
getAllConfigurations: (): Promise<FareConfiguration[]> =>
apiClient.get('/admin/fare-configurations'),
getConfigurationById: (id: string): Promise<FareConfiguration> =>
apiClient.get(`/admin/fare-configurations/${id}`),
createConfiguration: (data: CreateFareConfigurationRequest): Promise<FareConfiguration> =>
apiClient.post('/admin/fare-configurations', data),
updateConfiguration: (id: string, data: Partial<CreateFareConfigurationRequest>): Promise<FareConfiguration> =>
apiClient.put(`/admin/fare-configurations/${id}`, data),
activateConfiguration: (id: string): Promise<{ success: boolean; message: string }> =>
apiClient.post(`/admin/fare-configurations/${id}/activate`),
deleteConfiguration: (id: string): Promise<{ success: boolean; message: string }> =>
apiClient.delete(`/admin/fare-configurations/${id}`),
testConfiguration: (id: string, scenario: TestScenario): Promise<CalculationResult> =>
apiClient.post(`/admin/fare-configurations/${id}/test`, scenario),
getAuditTrail: (id: string): Promise<any[]> =>
apiClient.get(`/admin/fare-configurations/${id}/audit`),
// Migration & Setup
migrateLegacySystem: (dryRun: boolean = false): Promise<{
scheduleFareRules: number;
segmentFareRules: number;
configurationsCreated: number;
dryRun: boolean;
}> =>
apiClient.post('/admin/fare-migration/migrate-legacy', { dryRun }),
createNewFormulaConfiguration: (data: {
name: string;
description?: string;
activateImmediately?: boolean;
}): Promise<FareConfiguration> =>
apiClient.post('/admin/fare-migration/create-new-formula', data),
completeSetup: (options: {
activateNewFormula?: boolean;
enableFeature?: boolean;
} = {}): Promise<{
migration: any;
newConfiguration: FareConfiguration;
featureEnabled: boolean;
message: string;
}> =>
apiClient.post('/admin/fare-migration/complete-setup', options),
getSystemStatus: (): Promise<SystemStatus> =>
apiClient.get('/admin/fare-migration/status'),
// System Control
getFeatureStatus: (featureName: string = 'USE_CONFIGURABLE_FARES'): Promise<{
enabled: boolean;
config: Record<string, any>;
}> =>
apiClient.get(`/admin/fare-configurations/system/feature-status?feature=${featureName}`),
toggleFeature: (data: {
featureName: string;
enabled: boolean;
config?: Record<string, any>;
}): Promise<{ success: boolean; message: string }> =>
apiClient.post('/admin/fare-configurations/system/toggle-feature', data),
enableConfigurableFares: (rolloutPercentage: number = 100): Promise<{ success: boolean; message: string }> =>
apiClient.post('/admin/fare-configurations/system/enable-configurable-fares', { rolloutPercentage }),
disableConfigurableFares: (): Promise<{ success: boolean; message: string }> =>
apiClient.post('/admin/fare-configurations/system/disable-configurable-fares'),
};
export default configurableFareApi;

View File

@@ -113,6 +113,7 @@ export const fleetApi = {
createCoach: (data: any) => apiClient.post<any>('/fleet/coaches', data),
updateCoach: (id: string, data: any) => apiClient.patch<any>(`/fleet/coaches/${id}`, data),
deleteCoach: (id: string) => apiClient.delete(`/fleet/coaches/${id}`),
generateSeatMap: (data: any) => apiClient.post<any>('/fleet/seatmap/generate', data),
};
// Schedules API
@@ -165,6 +166,13 @@ export const paymentsApi = {
getById: (id: string) => apiClient.get<any>(`/payments/${id}`),
refund: (id: string, data: any) => apiClient.post<any>(`/payments/${id}/refund`, data),
getProviders: () => apiClient.get<any[]>('/payments/providers'),
getMethods: async () => {
const response = await apiClient.get('/payments/methods');
return (response as any)?.data || response || [];
},
addMethod: (data: any) => apiClient.post('/payments/methods', data),
updateMethod: (id: string, data: any) => apiClient.patch(`/payments/methods/${id}`, data),
deleteMethod: (id: string) => apiClient.delete(`/payments/methods/${id}`),
};
// Tickets API
@@ -182,6 +190,7 @@ export const ticketsApi = {
},
getById: (id: string) => apiClient.get<any>(`/tickets/${id}`),
validate: (ticketId: string, data: any) => apiClient.post<any>(`/tickets/${ticketId}/validate`, data),
scanAndBoard: (qrCodeOrRef: string, data: any) => apiClient.post<any>(`/tickets/scan-board/${encodeURIComponent(qrCodeOrRef)}`, data),
regenerate: (ticketId: string) => apiClient.post<any>(`/tickets/${ticketId}/regenerate`),
};
@@ -371,7 +380,7 @@ export const reportsApi = {
const query = reportType ? `?type=${reportType}` : '';
const response = await apiClient.get<any>(`/reports${query}`);
if (Array.isArray(response)) return { items: response };
return response?.data ? (Array.isArray(response.data) ? { items: response.data } : response) : { items: [] };
return (response as any)?.data ? (Array.isArray((response as any).data) ? { items: (response as any).data } : response) : { items: [] };
},
};
@@ -383,7 +392,7 @@ export const packagesApi = {
) as Record<string, string>;
const query = new URLSearchParams(cleanParams).toString();
const response = await apiClient.get<any>(`/packages/all${query ? `?${query}` : ''}`);
if (response?.data) return Array.isArray(response.data) ? { items: response.data } : response;
if ((response as any)?.data) return Array.isArray((response as any).data) ? { items: (response as any).data } : response;
return Array.isArray(response) ? { items: response } : response;
},
getById: (id: string) => apiClient.get<any>(`/packages/${id}`),
@@ -405,7 +414,7 @@ export const packageInquiriesApi = {
) as Record<string, string>;
const query = new URLSearchParams(cleanParams).toString();
const response = await apiClient.get<any>(`/packages/inquiries${query ? `?${query}` : ''}`);
if (response?.data) return Array.isArray(response.data) ? { items: response.data } : response;
if ((response as any)?.data) return Array.isArray((response as any).data) ? { items: (response as any).data } : response;
return Array.isArray(response) ? { items: response } : response;
},
create: (data: any) => apiClient.post<any>('/packages/inquiries', data),
@@ -423,11 +432,12 @@ export const excessBaggageApi = {
) as Record<string, string>;
const query = new URLSearchParams(cleanParams).toString();
const response = await apiClient.get<any>(`/agents/excess-baggage${query ? `?${query}` : ''}`);
if (response?.data) return Array.isArray(response.data) ? { items: response.data } : response;
if ((response as any)?.data) return Array.isArray((response as any).data) ? { items: (response as any).data } : response;
return Array.isArray(response) ? { items: response } : response;
},
resendLink: (id: string) => apiClient.post<any>(`/agents/excess-baggage/${id}/resend`, {}),
waive: (id: string, data: any) => apiClient.patch<any>(`/agents/excess-baggage/${id}/waive`, data),
delete: (id: string) => apiClient.delete(`/agents/excess-baggage/${id}`),
};
// System Config API