mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 09:58:12 +00:00
Boarding, payment methods, journey direction on seat hold, and more updates
This commit is contained in:
452
apps/edr-passenger-web/backoffice/src/app/boarding/page.tsx
Normal file
452
apps/edr-passenger-web/backoffice/src/app/boarding/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user