Merge branch 'dev' into freight/develop

This commit is contained in:
Nathnael Wondisha
2026-06-16 16:22:38 +03:00
committed by GitHub
88 changed files with 10850 additions and 1987 deletions

View File

@@ -1,3 +1,6 @@
{
"extends": ["next/core-web-vitals"]
"extends": ["next/core-web-vitals"],
"rules": {
"react/no-unescaped-entities": "off"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -2,27 +2,90 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Search, Eye } from 'lucide-react';
import { Eye, Download } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import { auditApi } from '@/lib/api';
import { formatDateTime } from '@/lib/utils';
import Modal from '@/components/ui/Modal';
import ActionButton from '@/components/ui/ActionButton';
export default function AuditLogsPage() {
const [filters, setFilters] = useState({ search: '', action: '', entityType: '' });
const [selectedLog, setSelectedLog] = useState<any>(null);
const [showDetailsModal, setShowDetailsModal] = useState(false);
const { data, isLoading } = useQuery({
queryKey: ['audit-logs', filters],
queryFn: () => auditApi.getLogs(filters),
refetchInterval: 30000, // Refetch every 30 seconds
});
const getActionBadgeColor = (action: string) => {
switch (action) {
case 'CREATE':
return 'success';
case 'UPDATE':
return 'primary';
case 'DELETE':
return 'danger';
case 'LOGIN':
return 'info';
case 'LOGOUT':
return 'secondary';
default:
return 'secondary';
}
};
const formatJsonData = (data: any) => {
if (!data) return 'N/A';
try {
return JSON.stringify(data, null, 2);
} catch {
return String(data);
}
};
const columns = [
{
key: 'createdAt',
label: 'Timestamp',
sortable: true,
render: (log: any) => (
<div className="text-sm">
<div className="font-medium">{formatDateTime(log.createdAt)}</div>
<div className="text-xs text-muted-foreground">{new Date(log.createdAt).toLocaleTimeString()}</div>
</div>
),
},
{
key: 'action',
label: 'Action',
sortable: true,
render: (log: any) => (
<Badge>{log.action}</Badge>
<Badge className={getActionBadgeColor(log.action)}>
{log.action}
</Badge>
),
},
{
key: 'entityType',
label: 'Entity Type',
sortable: true,
render: (log: any) => (
<span className="px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded text-xs font-medium">
{log.entityType}
</span>
),
},
{
key: 'entityId',
label: 'Entity ID',
render: (log: any) => (
<span className="font-mono text-xs text-muted-foreground">
{log.entityId ? log.entityId.substring(0, 12) : 'System'}
</span>
),
},
{
@@ -30,55 +93,74 @@ export default function AuditLogsPage() {
label: 'User',
render: (log: any) => (
<div>
<div className="font-medium">{log.user?.fullName || 'System'}</div>
<div className="text-sm text-muted-foreground">{log.user?.email || 'N/A'}</div>
<div className="font-medium text-sm">{log.user?.fullName || 'System'}</div>
<div className="text-xs text-muted-foreground">{log.user?.email || log.userId || 'N/A'}</div>
</div>
),
},
{
key: 'entityType',
label: 'Entity Type',
render: (log: any) => log.entityType,
},
{
key: 'entityId',
label: 'Entity ID',
key: 'ipAddress',
label: 'IP Address',
render: (log: any) => (
<span className="font-mono text-sm">{log.entityId?.substring(0, 8)}...</span>
<span className="text-xs text-muted-foreground font-mono">
{log.ipAddress || 'N/A'}
</span>
),
},
{
key: 'createdAt',
label: 'Timestamp',
sortable: true,
render: (log: any) => formatDateTime(log.createdAt),
},
];
const actions = [
{
label: 'View Details',
onClick: (log: any) => {
window.location.href = `/audit/${log.id}`;
setSelectedLog(log);
setShowDetailsModal(true);
},
variant: 'secondary' as const,
icon: Eye,
},
];
const logs = data?.items || [];
const stats = {
total: logs.length,
creates: logs.filter((l: any) => l.action === 'CREATE').length,
updates: logs.filter((l: any) => l.action === 'UPDATE').length,
deletes: logs.filter((l: any) => l.action === 'DELETE').length,
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Audit Logs</h1>
<p className="text-muted-foreground">Track all system activities and changes</p>
<div>
<h1 className="text-3xl font-bold text-foreground">Audit Logs</h1>
<p className="text-muted-foreground mt-1">Track all system activities and changes</p>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="card">
<div className="text-muted-foreground text-sm font-medium">Total Logs</div>
<div className="text-2xl font-bold mt-2">{stats.total}</div>
</div>
<div className="card">
<div className="text-green-600 text-sm font-medium">Created</div>
<div className="text-2xl font-bold mt-2">{stats.creates}</div>
</div>
<div className="card">
<div className="text-blue-600 text-sm font-medium">Updated</div>
<div className="text-2xl font-bold mt-2">{stats.updates}</div>
</div>
<div className="card">
<div className="text-red-600 text-sm font-medium">Deleted</div>
<div className="text-2xl font-bold mt-2">{stats.deletes}</div>
</div>
</div>
{/* Filters */}
<div className="card">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label className="label">Search</label>
<label className="label">Search (User/Entity ID)</label>
<input
type="text"
placeholder="Search logs..."
@@ -110,22 +192,172 @@ export default function AuditLogsPage() {
onChange={(e) => setFilters({ ...filters, entityType: e.target.value })}
>
<option value="">All Types</option>
<option value="Booking">Booking</option>
<option value="User">User</option>
<option value="Payment">Payment</option>
<option value="Ticket">Ticket</option>
<optgroup label="Master Data">
<option value="Station">Station</option>
<option value="Route">Route</option>
<option value="RouteStop">Route Stop</option>
<option value="Train">Train</option>
<option value="TrainSchedule">Train Schedule</option>
<option value="Coach">Coach</option>
<option value="CoachType">Coach Type</option>
<option value="SeatClass">Seat Class</option>
<option value="FareRule">Fare Rule</option>
<option value="RouteFareRule">Route Fare Rule</option>
<option value="SegmentFareRule">Segment Fare Rule</option>
<option value="BaggageAllowance">Baggage Allowance</option>
</optgroup>
<optgroup label="Operations">
<option value="Booking">Booking</option>
<option value="Payment">Payment</option>
<option value="Ticket">Ticket</option>
<option value="Seat">Seat</option>
<option value="SeatBlock">Seat Block</option>
</optgroup>
<optgroup label="Users & Access">
<option value="User">User</option>
<option value="Agent">Agent</option>
<option value="Passenger">Passenger</option>
</optgroup>
<optgroup label="System & Features">
<option value="Notification">Notification</option>
<option value="Promotion">Promotion</option>
<option value="Loyalty">Loyalty</option>
<option value="Wallet">Wallet</option>
<option value="FraudAlert">Fraud Alert</option>
<option value="FraudRule">Fraud Rule</option>
</optgroup>
</select>
</div>
<div className="flex items-end">
<ActionButton
variant="secondary"
onClick={() => setFilters({ search: '', action: '', entityType: '' })}
className="w-full"
>
Clear Filters
</ActionButton>
</div>
</div>
</div>
{/* Data Table */}
<DataTable
data={data?.items || []}
data={logs}
columns={columns}
actions={actions}
loading={isLoading}
emptyMessage="No audit logs found"
/>
{/* Details Modal */}
<Modal
isOpen={showDetailsModal}
onClose={() => {
setShowDetailsModal(false);
setSelectedLog(null);
}}
title={`${selectedLog?.action} - ${selectedLog?.entityType}`}
size="lg"
>
<div className="space-y-4">
{/* Basic Info */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs font-semibold text-muted-foreground">Timestamp</label>
<p className="text-sm mt-1">{formatDateTime(selectedLog?.createdAt)}</p>
</div>
<div>
<label className="text-xs font-semibold text-muted-foreground">Action</label>
<p className="text-sm mt-1">
<Badge className={getActionBadgeColor(selectedLog?.action)}>
{selectedLog?.action}
</Badge>
</p>
</div>
<div>
<label className="text-xs font-semibold text-muted-foreground">Entity Type</label>
<p className="text-sm mt-1 font-mono">{selectedLog?.entityType}</p>
</div>
<div>
<label className="text-xs font-semibold text-muted-foreground">Entity ID</label>
<p className="text-sm mt-1 font-mono text-muted-foreground">
{selectedLog?.entityId || 'System'}
</p>
</div>
</div>
{/* User Info */}
{selectedLog?.user && (
<div className="border-t pt-4">
<h4 className="text-sm font-semibold mb-2">User Information</h4>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs font-semibold text-muted-foreground">Name</label>
<p className="text-sm mt-1">{selectedLog?.user?.fullName}</p>
</div>
<div>
<label className="text-xs font-semibold text-muted-foreground">Email</label>
<p className="text-sm mt-1">{selectedLog?.user?.email}</p>
</div>
</div>
</div>
)}
{/* Network Info */}
{(selectedLog?.ipAddress || selectedLog?.userAgent) && (
<div className="border-t pt-4">
<h4 className="text-sm font-semibold mb-2">Network Information</h4>
<div className="space-y-2">
{selectedLog?.ipAddress && (
<div>
<label className="text-xs font-semibold text-muted-foreground">IP Address</label>
<p className="text-sm mt-1 font-mono">{selectedLog?.ipAddress}</p>
</div>
)}
{selectedLog?.userAgent && (
<div>
<label className="text-xs font-semibold text-muted-foreground">User Agent</label>
<p className="text-xs mt-1 font-mono break-all text-muted-foreground">
{selectedLog?.userAgent}
</p>
</div>
)}
</div>
</div>
)}
{/* Changes */}
{(selectedLog?.oldData || selectedLog?.newData) && (
<div className="border-t pt-4">
<h4 className="text-sm font-semibold mb-2">Data Changes</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{selectedLog?.oldData && (
<div>
<label className="text-xs font-semibold text-red-600">Old Data</label>
<pre className="text-xs mt-1 p-2 bg-red-50 dark:bg-red-950/20 rounded border border-red-200 dark:border-red-900 overflow-auto max-h-48 text-muted-foreground">
{formatJsonData(selectedLog?.oldData)}
</pre>
</div>
)}
{selectedLog?.newData && (
<div>
<label className="text-xs font-semibold text-green-600">New Data</label>
<pre className="text-xs mt-1 p-2 bg-green-50 dark:bg-green-950/20 rounded border border-green-200 dark:border-green-900 overflow-auto max-h-48 text-muted-foreground">
{formatJsonData(selectedLog?.newData)}
</pre>
</div>
)}
</div>
</div>
)}
{/* Raw Log ID */}
<div className="border-t pt-4">
<label className="text-xs font-semibold text-muted-foreground">Log ID</label>
<p className="text-xs mt-1 font-mono text-muted-foreground break-all">{selectedLog?.id}</p>
</div>
</div>
</Modal>
</div>
);
}

View File

@@ -80,6 +80,45 @@ export default function BookingsPage() {
}
};
const handleExportBookings = async () => {
const selectedColumns = prompt(
'Select columns to export (comma-separated):\n\n' +
'Available: bookingRef, passenger, status, bookingType, passengerCount, totalMinor, paymentStatus, createdAt\n\n' +
'Default: bookingRef, passenger, status, totalMinor, paymentStatus, createdAt',
'bookingRef, passenger, status, totalMinor, paymentStatus, createdAt'
);
if (!selectedColumns) return;
const cols = selectedColumns.split(',').map(c => c.trim());
const csv = [
cols.join(','),
...data?.items?.map((booking: any) => {
const values = cols.map(col => {
switch(col) {
case 'bookingRef': return booking.bookingRef;
case 'passenger': return booking.passenger?.fullName || booking.contactEmail || 'Guest';
case 'status': return booking.status;
case 'bookingType': return booking.bookingType || 'N/A';
case 'passengerCount': return booking.adultCount + booking.childCount;
case 'totalMinor': return booking.totalMinor;
case 'paymentStatus': return booking.paymentIntent?.status || 'PENDING';
case 'createdAt': return booking.createdAt;
default: return '';
}
});
return values.map(v => `"${v}"`).join(',');
}) || []
].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `bookings-${new Date().toISOString().split('T')[0]}.csv`;
a.click();
};
const columns = [
{
key: 'bookingRef',
@@ -99,6 +138,17 @@ export default function BookingsPage() {
</div>
),
},
{
key: 'bookingType',
label: 'Class',
sortable: true,
render: (booking: any) => booking.bookingType || 'ONE_WAY',
},
{
key: 'passengerCount',
label: 'Passengers',
render: (booking: any) => `${(booking.adultCount || 0) + (booking.childCount || 0)}`,
},
{
key: 'status',
label: 'Status',
@@ -158,7 +208,7 @@ export default function BookingsPage() {
<h1 className="text-2xl font-bold">Bookings</h1>
<p className="text-muted-foreground">Manage all passenger bookings</p>
</div>
<ActionButton variant="export" icon={Download}>Export</ActionButton>
<ActionButton variant="export" icon={Download} onClick={handleExportBookings}>Export</ActionButton>
</div>
<div className="card">

View File

@@ -75,7 +75,9 @@ export default function ClassesPage() {
coachTypeId: selectedCoachTypeId,
name: formData.get('name') as string,
description: formData.get('description') as string,
baseFareMinor: parseInt(formData.get('baseFareMinor') as string) || 0,
baseFareMinor: Math.round(parseFloat(formData.get('baseFareMinor') as string) * 100) || 0,
premiumMinor: Math.round(parseFloat(formData.get('premiumMinor') as string) * 100) || 0,
insuranceFeeMinor: Math.round(parseFloat(formData.get('insuranceFeeMinor') as string) * 100) || 0,
isActive: formData.get('isActive') === 'true',
};
@@ -136,9 +138,23 @@ export default function ClassesPage() {
},
{
key: 'baseFareMinor',
label: 'Base Fare (ETB)',
label: 'Base Fare',
render: (cls: any) => (
<span className="font-mono text-sm">{formatCurrency(cls.baseFareMinor, 'ETB')}</span>
<span className="font-mono text-sm">{(cls.baseFareMinor / 100).toFixed(2)} ETB</span>
),
},
{
key: 'premiumMinor',
label: 'Premium',
render: (cls: any) => (
<span className="font-mono text-sm">{cls.premiumMinor ? (cls.premiumMinor / 100).toFixed(2) : '0.00'} ETB</span>
),
},
{
key: 'insuranceFeeMinor',
label: 'Insurance',
render: (cls: any) => (
<span className="font-mono text-sm">{cls.insuranceFeeMinor ? (cls.insuranceFeeMinor / 100).toFixed(2) : '0.00'} ETB</span>
),
},
{
@@ -181,7 +197,7 @@ export default function ClassesPage() {
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Classes</h1>
<p className="text-muted-foreground">Manage class configurations by coach type</p>
<p className="text-muted-foreground">Manage class configurations with pricing by coach type</p>
</div>
<ActionButton
icon={Plus}
@@ -278,18 +294,60 @@ export default function ClassesPage() {
/>
</div>
<div>
<label className="label">Base Fare (ETB cents) *</label>
<input
type="number"
name="baseFareMinor"
className="input"
defaultValue={editingClass?.baseFareMinor || ''}
required
min="0"
placeholder="e.g., 45000 (450 ETB)"
/>
<p className="text-xs text-muted-foreground mt-1">Enter amount in cents (100 cents = 1 ETB)</p>
<div className="border-t pt-4">
<h3 className="font-semibold text-foreground mb-4">Pricing Configuration</h3>
<div>
<label className="label">Base Fare (ETB) *</label>
<input
type="number"
name="baseFareMinor"
className="input"
defaultValue={editingClass?.baseFareMinor ? (editingClass.baseFareMinor / 100).toFixed(2) : ''}
required
min="0"
step="0.01"
placeholder="e.g., 350.00"
/>
<p className="text-xs text-muted-foreground mt-1">Per-km distance-based fare rate</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
<div>
<label className="label">Premium Fee (ETB)</label>
<input
type="number"
name="premiumMinor"
className="input"
defaultValue={editingClass?.premiumMinor ? (editingClass.premiumMinor / 100).toFixed(2) : '0.00'}
min="0"
step="0.01"
placeholder="e.g., 50.00"
/>
<p className="text-xs text-muted-foreground mt-1">Flat fee per passenger (e.g., lounge access, extra legroom)</p>
</div>
<div>
<label className="label">Insurance Fee (ETB)</label>
<input
type="number"
name="insuranceFeeMinor"
className="input"
defaultValue={editingClass?.insuranceFeeMinor ? (editingClass.insuranceFeeMinor / 100).toFixed(2) : '0.00'}
min="0"
step="0.01"
placeholder="e.g., 25.00"
/>
<p className="text-xs text-muted-foreground mt-1">Flat fee per passenger (e.g., travel insurance)</p>
</div>
</div>
<div className="mt-4 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded text-sm text-blue-800 dark:text-blue-200">
<p className="font-medium mb-1">Total Fare Calculation:</p>
<p>Total = (Base Fare × Distance) + Premium + Insurance</p>
<p className="mt-2 text-xs"> Premium applies per passenger (including free child)</p>
<p className="text-xs"> Insurance applies per passenger (including free child)</p>
</div>
</div>
<div>

View File

@@ -2,7 +2,7 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Search, Grid3x3, Edit, Trash2 } from 'lucide-react';
import { Plus, Search, Grid3x3, Edit, Trash2, Bed, Armchair } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
@@ -11,6 +11,135 @@ import { fleetApi, apiClient } from '@/lib/api';
type Tab = 'types' | 'coaches';
const getBedLabel = (bedPosition: string | null): string => {
if (bedPosition === 'upper') return 'U';
if (bedPosition === 'middle') return 'M';
if (bedPosition === 'lower') return 'L';
return '';
};
const renderBedVisualization = (coach: any) => {
const seats = coach.seats || [];
const validSeats = seats.filter((s: any) => s.seatNumber && !s.seatNumber.startsWith('-'));
if (validSeats.length === 0) {
return <div className="text-xs text-muted-foreground">No seats</div>;
}
const hasBedPositionData = validSeats.some((s: any) => s.bedPosition);
const isBedCoach = coach.coachType?.name?.toLowerCase().includes('bed');
if (!isBedCoach || !hasBedPositionData) {
// Regular seat layout
const arrangement = coach.seatArrangement || coach.arrangement || '2+2';
const [left, right] = arrangement.split('+').map((p: string) => parseInt(p.trim()));
const cols = new Map<number, any[]>();
for (const seat of validSeats) {
if (!cols.has(seat.row)) cols.set(seat.row, []);
cols.get(seat.row)!.push(seat);
}
return (
<div className="space-y-1">
{Array.from(cols.entries()).map(([row, rowSeats]) => (
<div key={row} className="flex gap-3 justify-start">
<div className="flex gap-0.5">
{rowSeats.slice(0, left).map((s: any) => (
<div key={s.id} className="w-6 h-6 rounded bg-green-500 flex items-center justify-center">
<Armchair className="w-3 h-3 text-white" />
</div>
))}
</div>
<div className="flex gap-0.5">
{rowSeats.slice(left).map((s: any) => (
<div key={s.id} className="w-6 h-6 rounded bg-green-500 flex items-center justify-center">
<Armchair className="w-3 h-3 text-white" />
</div>
))}
</div>
</div>
))}
</div>
);
}
// Bed layout with pairing
const seatsByRow = new Map<number, any[]>();
for (const seat of validSeats) {
if (!seatsByRow.has(seat.row)) seatsByRow.set(seat.row, []);
seatsByRow.get(seat.row)!.push(seat);
}
const beds = coach.coachType?.name?.toLowerCase().includes('vip') ? 'w-12' : 'w-10';
const rows = Array.from(seatsByRow.entries()).map(([r, s]) => s);
return (
<div className="space-y-1">
{rows.map((rowSeats: any[], idx: number) => {
const rowNumber = rowSeats[0]?.row || (idx + 1);
const isFirstInPair = (rowNumber - 1) % 2 === 0;
const isLastRow = idx === rows.length - 1;
const nextRowSeats = !isLastRow ? rows[idx + 1] : null;
return (
<div key={`row-${idx}`}>
{/* Row 1 of pair - label above */}
{isFirstInPair && (
<div className="flex gap-0.5 text-xs text-gray-500 mb-0.5">
{rowSeats.map((s: any) => (
<div key={`label-${s.id}`} className={`${beds} h-2 flex items-center justify-center text-xs font-bold leading-3`}>
{s.seatNumber}
</div>
))}
</div>
)}
{/* Row 1 of pair - beds */}
<div className="flex gap-0.5">
{rowSeats.map((s: any) => (
<div
key={s.id}
className={`${beds} h-5 rounded flex items-center justify-center bg-green-500`}
style={isFirstInPair ? { transform: 'scaleY(-1)' } : undefined}
>
<Bed className="w-3 h-3 text-white" />
</div>
))}
</div>
{/* Numbers between rows */}
{isFirstInPair && nextRowSeats && (
<div className="flex gap-0.5 text-xs text-gray-500 my-0.5">
{rowSeats.map((s: any, idx: number) => {
const nextSeat = nextRowSeats[idx];
return (
<div key={`between-${s.id}`} className={`${beds} h-2 flex items-center justify-center text-xs font-bold leading-3`}>
{nextSeat?.seatNumber}
</div>
);
})}
</div>
)}
{/* Row 2 of pair - beds */}
{!isFirstInPair && (
<div className="flex gap-0.5">
{rowSeats.map((s: any) => (
<div
key={s.id}
className={`${beds} h-5 rounded flex items-center justify-center bg-green-500`}
>
<Bed className="w-3 h-3 text-white" />
</div>
))}
</div>
)}
{!isFirstInPair && <div className="h-1" />}
</div>
);
})}
</div>
);
};
export default function CoachesPage() {
const [activeTab, setActiveTab] = useState<Tab>('coaches');
const [search, setSearch] = useState('');
@@ -107,6 +236,7 @@ export default function CoachesPage() {
coachTypeId: formData.get('coachTypeId') as string,
arrangement: formData.get('arrangement') as string,
capacity: parseInt(formData.get('capacity') as string),
sequence: parseInt(formData.get('sequence') as string),
status: formData.get('status') as string,
};
@@ -228,6 +358,15 @@ export default function CoachesPage() {
<span className="text-sm">{coach.coachType?.name || 'N/A'}</span>
),
},
{
key: 'visualization',
label: 'Seats/Beds',
render: (coach: any) => (
<div className="bg-gray-50 dark:bg-gray-900/30 rounded p-2 max-w-xs overflow-x-auto">
{renderBedVisualization(coach)}
</div>
),
},
{
key: 'arrangement',
label: 'Arrangement',
@@ -486,7 +625,7 @@ export default function CoachesPage() {
<form onSubmit={handleCoachSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="label">Coach Type</label>
<label className="label">Coach Type *</label>
<select
name="coachTypeId"
className="input"
@@ -503,7 +642,7 @@ export default function CoachesPage() {
</div>
<div>
<label className="label">Number</label>
<label className="label">Number *</label>
<input
type="text"
name="number"
@@ -515,20 +654,20 @@ export default function CoachesPage() {
</div>
<div>
<label className="label">Arrangement</label>
<label className="label">Arrangement *</label>
<input
type="text"
name="arrangement"
className="input"
defaultValue={editingItem?.arrangement || editingItem?.seatArrangement || '2+2'}
defaultValue={editingItem?.arrangement || editingItem?.seatArrangement }
required
placeholder="e.g., 2+2, 3+2"
placeholder="e.g., 3+2, 3+0, 2+0"
/>
<p className="text-xs text-muted-foreground mt-1">Format: separate columns with +</p>
</div>
<div>
<label className="label">Capacity</label>
<label className="label">Capacity *</label>
<input
type="number"
name="capacity"
@@ -540,12 +679,27 @@ export default function CoachesPage() {
/>
</div>
<div className="md:col-span-2">
<label className="label">Status</label>
<div>
<label className="label">Sequence Number *</label>
<input
type="number"
name="sequence"
className="input"
defaultValue={editingItem?.sequence || 0}
min="0"
required
placeholder="e.g., 1"
/>
<p className="text-xs text-muted-foreground mt-1">Used for ordering coaches in trains</p>
</div>
<div>
<label className="label">Status *</label>
<select
name="status"
className="input"
defaultValue={editingItem?.status || 'ACTIVE'}
required
>
<option value="ACTIVE">Active</option>
<option value="MAINTENANCE">Maintenance</option>

View File

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

View File

@@ -0,0 +1,475 @@
'use client';
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Plus, Trash2, Loader2, Edit, RefreshCw } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Modal from '@/components/ui/Modal';
import ActionButton from '@/components/ui/ActionButton';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { apiClient } from '@/lib/api-client';
interface Currency {
id: string;
code: string;
name: string;
symbol: string;
baseCurrencyCode: string;
exchangeRate: number;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export default function CurrenciesPage() {
const [showModal, setShowModal] = useState(false);
const [editingCurrency, setEditingCurrency] = useState<Currency | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; id: string | null }>({
isOpen: false,
id: null,
});
const [error, setError] = useState<string | null>(null);
const queryClient = useQueryClient();
const [currencyForm, setCurrencyForm] = useState({
code: '',
name: '',
symbol: '',
baseCurrencyCode: 'ETB',
exchangeRate: '',
});
const { data: currencies = [], isLoading } = useQuery({
queryKey: ['currencies'],
queryFn: () => apiClient.get('/currencies'),
});
const createMutation = useMutation({
mutationFn: (data: any) => apiClient.post('/currencies', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['currencies'] });
resetForm();
setError(null);
},
onError: (err: any) => {
setError(err.response?.data?.message || 'Failed to create currency');
},
});
const updateMutation = useMutation({
mutationFn: (data: any) => apiClient.patch(`/currencies/${data.id}`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['currencies'] });
setEditingCurrency(null);
resetForm();
setError(null);
},
onError: (err: any) => {
setError(err.response?.data?.message || 'Failed to update currency');
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => apiClient.delete(`/currencies/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['currencies'] });
setDeleteConfirm({ isOpen: false, id: null });
},
onError: (err: any) => {
setError(err.response?.data?.message || 'Failed to delete currency');
},
});
const syncRatesMutation = useMutation({
mutationFn: () => apiClient.post('/currencies/sync-rates', {}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['currencies'] });
setError(null);
},
onError: (err: any) => {
setError(err.response?.data?.message || 'Failed to sync exchange rates');
},
});
const resetForm = () => {
setCurrencyForm({
code: '',
name: '',
symbol: '',
baseCurrencyCode: 'ETB',
exchangeRate: '',
});
setEditingCurrency(null);
setShowModal(false);
setError(null);
};
const handleEditCurrency = (currency: Currency) => {
setEditingCurrency(currency);
setCurrencyForm({
code: currency.code,
name: currency.name,
symbol: currency.symbol,
baseCurrencyCode: currency.baseCurrencyCode,
exchangeRate: currency.exchangeRate.toString(),
});
setError(null);
setShowModal(true);
};
const handleSaveCurrency = async () => {
setError(null);
if (!currencyForm.code || !currencyForm.name || !currencyForm.symbol || !currencyForm.exchangeRate) {
setError('All fields are required');
return;
}
const rate = parseFloat(currencyForm.exchangeRate);
if (isNaN(rate) || rate <= 0) {
setError('Exchange rate must be a positive number');
return;
}
const payload = {
code: currencyForm.code.toUpperCase(),
name: currencyForm.name,
symbol: currencyForm.symbol,
baseCurrencyCode: currencyForm.baseCurrencyCode,
exchangeRate: rate,
};
if (editingCurrency) {
await updateMutation.mutateAsync({ id: editingCurrency.id, ...payload });
} else {
await createMutation.mutateAsync(payload);
}
};
const confirmDelete = async () => {
if (deleteConfirm.id) {
await deleteMutation.mutateAsync(deleteConfirm.id);
}
};
const currenciesArray = Array.isArray(currencies) ? currencies : (currencies as any)?.items || [];
const columns = [
{
key: 'code',
label: 'Code',
render: (currency: Currency) => (
<span className="font-mono font-semibold text-primary">{currency.code}</span>
),
},
{
key: 'name',
label: 'Name',
render: (currency: Currency) => (
<span className="font-medium">{currency.name}</span>
),
},
{
key: 'symbol',
label: 'Symbol',
render: (currency: Currency) => (
<span className="text-lg">{currency.symbol}</span>
),
},
{
key: 'baseCurrencyCode',
label: 'Base Currency',
render: (currency: Currency) => (
<span className="font-mono text-sm">{currency.baseCurrencyCode}</span>
),
},
{
key: 'exchangeRate',
label: 'Exchange Rate',
render: (currency: Currency) => (
<div className="space-y-1">
<div className="font-mono font-semibold">
1 {currency.baseCurrencyCode} = {currency.exchangeRate.toFixed(4)} {currency.code}
</div>
<div className="text-xs text-muted-foreground">
1 {currency.code} = {(1 / currency.exchangeRate).toFixed(6)} {currency.baseCurrencyCode}
</div>
</div>
),
},
{
key: 'isActive',
label: 'Status',
render: (currency: Currency) => (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
currency.isActive
? 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300'
: 'bg-gray-100 dark:bg-gray-900/20 text-gray-800 dark:text-gray-300'
}`}>
{currency.isActive ? 'Active' : 'Inactive'}
</span>
),
},
{
key: 'updatedAt',
label: 'Last Updated',
render: (currency: Currency) => (
<span className="text-sm text-muted-foreground">
{new Date(currency.updatedAt).toLocaleDateString()}
</span>
),
},
];
const actions = [
{
label: 'Edit',
onClick: handleEditCurrency,
variant: 'secondary' as const,
icon: Edit,
},
{
label: 'Delete',
onClick: (currency: Currency) => setDeleteConfirm({ isOpen: true, id: currency.id }),
variant: 'danger' as const,
icon: Trash2,
},
];
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground">Currencies</h1>
<p className="text-muted-foreground mt-1">Manage exchange rates and display currencies</p>
</div>
<div className="flex gap-3">
<ActionButton
icon={RefreshCw}
variant="secondary"
onClick={() => syncRatesMutation.mutate()}
loading={syncRatesMutation.isPending}
>
Sync Rates
</ActionButton>
<ActionButton
icon={Plus}
onClick={() => {
setError(null);
setEditingCurrency(null);
setCurrencyForm({
code: '',
name: '',
symbol: '',
baseCurrencyCode: 'ETB',
exchangeRate: '',
});
setShowModal(true);
}}
>
Add Currency
</ActionButton>
</div>
</div>
<div className="card">
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="p-4 bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-900/20 dark:to-blue-900/10 rounded-lg border border-blue-200 dark:border-blue-800">
<div className="text-sm text-blue-600 dark:text-blue-400 font-medium">Total Currencies</div>
<div className="text-2xl font-bold text-blue-900 dark:text-blue-200 mt-2">
{currenciesArray.length}
</div>
</div>
<div className="p-4 bg-gradient-to-br from-green-50 to-green-100 dark:from-green-900/20 dark:to-green-900/10 rounded-lg border border-green-200 dark:border-green-800">
<div className="text-sm text-green-600 dark:text-green-400 font-medium">Active</div>
<div className="text-2xl font-bold text-green-900 dark:text-green-200 mt-2">
{currenciesArray.filter((c: Currency) => c.isActive).length}
</div>
</div>
<div className="p-4 bg-gradient-to-br from-purple-50 to-purple-100 dark:from-purple-900/20 dark:to-purple-900/10 rounded-lg border border-purple-200 dark:border-purple-800">
<div className="text-sm text-purple-600 dark:text-purple-400 font-medium">Base Currency</div>
<div className="text-2xl font-bold text-purple-900 dark:text-purple-200 mt-2">ETB</div>
</div>
<div className="p-4 bg-gradient-to-br from-orange-50 to-orange-100 dark:from-orange-900/20 dark:to-orange-900/10 rounded-lg border border-orange-200 dark:border-orange-800">
<div className="text-sm text-orange-600 dark:text-orange-400 font-medium">Last Sync</div>
<div className="text-lg font-bold text-orange-900 dark:text-orange-200 mt-2">
{currenciesArray.length > 0
? new Date(currenciesArray[0]?.updatedAt).toLocaleDateString()
: 'N/A'}
</div>
</div>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
) : currenciesArray.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<p>No currencies configured. Click "Add Currency" to create one.</p>
</div>
) : (
<DataTable
data={currenciesArray}
columns={columns}
actions={actions}
loading={false}
emptyMessage="No currencies found."
/>
)}
</div>
</div>
<div className="card bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800">
<h3 className="font-semibold text-blue-900 dark:text-blue-200 mb-3">Currency Management</h3>
<ul className="text-sm text-blue-800 dark:text-blue-300 space-y-2">
<li>
<strong>Base Currency:</strong> All exchange rates are calculated relative to this currency (typically ETB)
</li>
<li>
<strong>Exchange Rate:</strong> How many units of the currency equal 1 unit of the base currency
</li>
<li>
<strong>Display Currencies:</strong> Configure which currencies customers can view prices in
</li>
<li>
<strong>Sync Rates:</strong> Automatically update exchange rates from external sources
</li>
</ul>
</div>
{/* Delete Confirmation */}
<ConfirmDialog
isOpen={deleteConfirm.isOpen}
onClose={() => setDeleteConfirm({ isOpen: false, id: null })}
onConfirm={confirmDelete}
title="Delete Currency"
message="Are you sure you want to delete this currency? This action cannot be undone."
confirmText="Delete"
isDanger={true}
warning="This will remove the currency from the system."
/>
{/* Add/Edit Modal */}
<Modal
isOpen={showModal}
onClose={resetForm}
title={`${editingCurrency ? 'Edit' : 'Add'} Currency`}
size="lg"
>
<div className="space-y-4">
{error && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 rounded-lg text-sm text-red-800 dark:text-red-200">
{error}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="label">Currency Code *</label>
<input
type="text"
value={currencyForm.code}
onChange={(e) => setCurrencyForm({ ...currencyForm, code: e.target.value.toUpperCase() })}
className="input w-full"
placeholder="e.g., USD"
maxLength={3}
disabled={!!editingCurrency}
required
/>
<p className="text-xs text-muted-foreground mt-1">3-letter ISO code (e.g., USD, DJF, GBP)</p>
</div>
<div>
<label className="label">Currency Name *</label>
<input
type="text"
value={currencyForm.name}
onChange={(e) => setCurrencyForm({ ...currencyForm, name: e.target.value })}
className="input w-full"
placeholder="e.g., United States Dollar"
required
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="label">Symbol *</label>
<input
type="text"
value={currencyForm.symbol}
onChange={(e) => setCurrencyForm({ ...currencyForm, symbol: e.target.value })}
className="input w-full"
placeholder="e.g., $"
maxLength={3}
required
/>
</div>
<div>
<label className="label">Base Currency *</label>
<select
value={currencyForm.baseCurrencyCode}
onChange={(e) => setCurrencyForm({ ...currencyForm, baseCurrencyCode: e.target.value })}
className="input w-full"
disabled
>
<option value="ETB">ETB (Ethiopian Birr)</option>
<option value="USD">USD (US Dollar)</option>
<option value="DJF">DJF (Djiboutian Franc)</option>
</select>
<p className="text-xs text-muted-foreground mt-1">All rates relative to this currency</p>
</div>
</div>
<div>
<label className="label">Exchange Rate *</label>
<div className="flex items-center gap-2">
<input
type="number"
min="0"
step="0.0001"
value={currencyForm.exchangeRate}
onChange={(e) => setCurrencyForm({ ...currencyForm, exchangeRate: e.target.value })}
className="input w-full"
placeholder="e.g., 0.018"
required
/>
<div className="text-sm text-muted-foreground whitespace-nowrap">
1 {currencyForm.baseCurrencyCode} = ? {currencyForm.code}
</div>
</div>
{currencyForm.exchangeRate && parseFloat(currencyForm.exchangeRate) > 0 && (
<p className="text-xs text-muted-foreground mt-2">
1 {currencyForm.code} = {(1 / parseFloat(currencyForm.exchangeRate)).toFixed(6)} {currencyForm.baseCurrencyCode}
</p>
)}
</div>
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 p-3 rounded-lg text-xs text-blue-800 dark:text-blue-200">
<p className="font-semibold mb-1">Exchange Rate Example:</p>
<p>If 1 ETB = 0.018 USD, enter 0.018</p>
<p>If 1 ETB = 3.25 DJF, enter 3.25</p>
</div>
<div className="flex gap-2 justify-end pt-4">
<ActionButton
variant="secondary"
onClick={resetForm}
type="button"
>
Cancel
</ActionButton>
<ActionButton
onClick={handleSaveCurrency}
loading={createMutation.isPending || updateMutation.isPending}
>
{editingCurrency ? 'Update Currency' : 'Add Currency'}
</ActionButton>
</div>
</div>
</Modal>
</div>
);
}

View File

@@ -1,13 +1,15 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { Ticket, Users, DollarSign, TrendingUp } from 'lucide-react';
import { Ticket, Users, DollarSign, Percent } from 'lucide-react';
import StatCard from '@/components/dashboard/StatCard';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import { dashboardApi } from '@/lib/api/dashboard';
import { formatCurrency, formatDateTime } from '@/lib/utils';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts';
const COLORS = ['#2563eb', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6'];
export default function DashboardPage() {
const { data: stats, isLoading: statsLoading } = useQuery({
@@ -20,22 +22,55 @@ export default function DashboardPage() {
queryFn: () => dashboardApi.getRevenueChart(30),
});
const { data: recentBookingsData, isLoading: bookingsLoading } = useQuery<any>({
const { data: recentBookingsData, isLoading: bookingsLoading } = useQuery<any[]>({
queryKey: ['recent-bookings'],
queryFn: () => dashboardApi.getRecentBookings(10),
});
const recentBookings = Array.isArray(recentBookingsData)
? recentBookingsData
: recentBookingsData?.items || recentBookingsData?.data || [];
const { data: topAgents, isLoading: agentsLoading } = useQuery({
queryKey: ['top-agents'],
queryFn: () => dashboardApi.getTopAgents(5),
});
const columns = [
const { data: occupancyTrend, isLoading: occupancyLoading } = useQuery({
queryKey: ['occupancy-trend'],
queryFn: () => dashboardApi.getOccupancyTrend(7),
});
const { data: upcomingTrips, isLoading: tripsLoading } = useQuery({
queryKey: ['upcoming-trips'],
queryFn: () => dashboardApi.getUpcomingTrips(5),
});
const { data: paymentMethods } = useQuery({
queryKey: ['payment-methods'],
queryFn: dashboardApi.getPaymentMethods,
});
const recentBookings = Array.isArray(recentBookingsData) ? recentBookingsData : [];
const bookingColumns = [
{ key: 'reference', label: 'Reference', render: (item: any) => item.bookingRef || item.reference },
{ key: 'passenger', label: 'Passenger', render: (item: any) => item.passenger?.fullName || item.contactEmail || 'N/A' },
{ key: 'amount', label: 'Amount', render: (item: any) => formatCurrency(item.totalMinor || item.amount, item.currency || 'ETB') },
{
key: 'status',
label: 'Status',
key: 'passenger',
label: 'Passenger',
render: (item: any) => {
if (item.passenger?.fullName) {
return item.passenger.fullName;
}
if (item.contactEmail) {
return item.contactEmail;
}
if (item.contactPhone) {
return item.contactPhone;
}
return 'N/A';
}
},
{ key: 'amount', label: 'Amount', render: (item: any) => formatCurrency(item.totalMinor || item.amount, item.currency || 'ETB') },
{
key: 'status',
label: 'Status',
render: (item: any) => (
<Badge variant="status" status={item.status}>
{item.status}
@@ -45,19 +80,43 @@ export default function DashboardPage() {
{ key: 'createdAt', label: 'Created', render: (item: any) => formatDateTime(item.createdAt) },
];
const agentColumns = [
{ key: 'name', label: 'Agent Name', render: (item: any) => item.name || item.fullName },
{ key: 'bookings', label: 'Bookings', render: (item: any) => item.bookingsCount || item.bookings || 0 },
{ key: 'revenue', label: 'Revenue', render: (item: any) => formatCurrency(item.totalRevenue || item.revenue || 0, 'ETB') },
{ key: 'commission', label: 'Commission', render: (item: any) => formatCurrency(item.commission || 0, 'ETB') },
];
const tripColumns = [
{ key: 'trainName', label: 'Train', render: (item: any) => item.trainName || item.train?.name },
{ key: 'route', label: 'Route', render: (item: any) => `${item.originStation?.name || item.origin?.name}${item.destinationStation?.name || item.destination?.name}` },
{ key: 'departure', label: 'Departure', render: (item: any) => formatDateTime(item.departureAt) },
{ key: 'seats', label: 'Seats', render: (item: any) => `${item.availableSeats || 0}/${item.totalSeats || 0}` },
{
key: 'status',
label: 'Status',
render: (item: any) => (
<Badge variant="status" status={item.status}>
{item.status}
</Badge>
)
},
];
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-foreground">Dashboard</h1>
<p className="text-muted-foreground mt-1">Hello, welcome back! Here&apos;s what&apos;s happening today.</p>
<p className="text-muted-foreground mt-1">Welcome back! Here&apos;s your operational summary.</p>
</div>
{/* Primary Metrics */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
<StatCard
title="Total Bookings"
value={statsLoading ? '...' : (stats?.totalBookings || 0).toLocaleString()}
icon={Ticket}
color="blue"
color="blue"
/>
<StatCard
title="Total Revenue"
@@ -74,35 +133,107 @@ export default function DashboardPage() {
<StatCard
title="Occupancy Rate"
value={statsLoading ? '...' : `${stats?.occupancyRate || 0}%`}
icon={TrendingUp}
color="green"
icon={Percent}
color="orange"
/>
</div>
{!revenueLoading && revenueData && revenueData.length > 0 && (
{/* 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>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={revenueData}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="date" tick={{ fontSize: 12 }} className="text-muted-foreground" />
<YAxis tick={{ fontSize: 12 }} className="text-muted-foreground" />
<Tooltip formatter={(value: number) => formatCurrency(value, 'ETB')} />
<Line type="monotone" dataKey="revenue" stroke="#2563eb" strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
</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>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={occupancyTrend}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="date" tick={{ fontSize: 12 }} className="text-muted-foreground" />
<YAxis tick={{ fontSize: 12 }} className="text-muted-foreground" />
<Tooltip formatter={(value: number) => `${value}%`} />
<Bar dataKey="occupancyRate" fill="#10b981" radius={[8, 8, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
)}
</div>
{/* Payment Methods Distribution */}
{paymentMethods && paymentMethods.length > 0 && (
<div className="card">
<h2 className="mb-4 text-lg font-semibold text-foreground">Revenue Trend (Last 30 Days)</h2>
<h2 className="mb-4 text-lg font-semibold text-foreground">Payment Methods Distribution</h2>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={revenueData}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="date" tick={{ fontSize: 12 }} className="text-muted-foreground" />
<YAxis tick={{ fontSize: 12 }} className="text-muted-foreground" />
<Tooltip formatter={(value: number) => formatCurrency(value, 'ETB')} />
<Line type="monotone" dataKey="revenue" stroke="#2563eb" strokeWidth={2} />
</LineChart>
<PieChart>
<Pie
data={paymentMethods}
dataKey="count"
nameKey="method"
cx="50%"
cy="50%"
outerRadius={80}
label
>
{paymentMethods.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</div>
)}
{/* Recent Bookings */}
<div className="card">
<h2 className="mb-4 text-lg font-semibold text-foreground">Recent Bookings</h2>
<DataTable
data={recentBookings}
columns={columns}
columns={bookingColumns}
loading={bookingsLoading}
emptyMessage="No recent bookings"
/>
</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>
)}
{/* 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>
);
}

View File

@@ -0,0 +1,949 @@
'use client';
import React, { useState } from 'react';
import Link from 'next/link';
import { ChevronDown, ChevronRight, FileText, Home } from 'lucide-react';
const DocPage = () => {
const [expandedSections, setExpandedSections] = useState<{ [key: string]: boolean }>({
overview: true,
operations: true,
masterdata: false,
financial: false,
services: false,
security: false,
analytics: false,
system: false,
});
const toggleSection = (section: string) => {
setExpandedSections(prev => (({
...prev,
[section]: !prev[section]
})));
};
const scrollToSection = (id: string) => {
setTimeout(() => {
const element = document.getElementById(id);
if (element) {
const headerOffset = 120;
const elementPosition = element.getBoundingClientRect().top + window.pageYOffset;
const offsetPosition = elementPosition - headerOffset;
window.scrollTo({
top: offsetPosition,
behavior: 'smooth'
});
}
}, 0);
};
const sections = [
{
id: 'overview',
title: '📋 Overview & Getting Started',
items: [
{ id: 'about', label: 'Application Overview' },
{ id: 'features', label: 'Key Features' },
]
},
{
id: 'operations',
title: '📊 Operations',
items: [
{ id: 'bookings', label: 'Bookings' },
{ id: 'bookings-how', label: '→ How-To' },
{ id: 'passengers', label: 'Passengers' },
{ id: 'passengers-how', label: '→ How-To' },
{ id: 'tickets', label: 'Tickets' },
{ id: 'tickets-how', label: '→ How-To' },
]
},
{
id: 'masterdata',
title: '🏢 Master Data',
items: [
{ id: 'stations', label: 'Stations' },
{ id: 'stations-how', label: '→ How-To' },
{ id: 'trains', label: 'Trains' },
{ id: 'trains-how', label: '→ How-To' },
{ id: 'coaches', label: 'Coaches' },
{ id: 'coaches-how', label: '→ How-To' },
{ id: 'seats', label: 'Seats' },
{ id: 'seats-how', label: '→ How-To' },
{ id: 'classes', label: 'Seat Classes' },
{ id: 'classes-how', label: '→ How-To' },
{ id: 'routes', label: 'Routes' },
{ id: 'routes-how', label: '→ How-To' },
{ id: 'schedules', label: 'Schedules' },
{ id: 'schedules-how', label: '→ How-To' },
]
},
{
id: 'financial',
title: '💰 Financial',
items: [
{ id: 'pricing', label: 'Pricing & Fares' },
{ id: 'pricing-how', label: '→ How-To' },
{ id: 'currencies', label: 'Currencies' },
{ id: 'currencies-how', label: '→ How-To' },
{ id: 'payments', label: 'Payments' },
{ id: 'payments-how', label: '→ How-To' },
{ id: 'promos', label: 'Promo Codes' },
{ id: 'promos-how', label: '→ How-To' },
]
},
{
id: 'services',
title: '🎁 Customer Services',
items: [
{ id: 'loyalty', label: 'Loyalty' },
{ id: 'loyalty-how', label: '→ How-To' },
{ id: 'support', label: 'Support' },
{ id: 'support-how', label: '→ How-To' },
{ id: 'notifications', label: 'Notifications' },
{ id: 'notifications-how', label: '→ How-To' },
]
},
{
id: 'security',
title: '🔒 Security',
items: [
{ id: 'audit', label: 'Audit Logs' },
{ id: 'audit-how', label: '→ How-To' },
{ id: 'fraud', label: 'Fraud Detection' },
{ id: 'fraud-how', label: '→ How-To' },
{ id: 'verifayda', label: 'Verifayda' },
{ id: 'verifayda-how', label: '→ How-To' },
]
},
{
id: 'analytics',
title: '📈 Analytics',
items: [
{ id: 'reports', label: 'Reports' },
{ id: 'reports-how', label: '→ How-To' },
]
},
{
id: 'system',
title: '⚙️ System',
items: [
{ id: 'agents', label: 'Agents' },
{ id: 'agents-how', label: '→ How-To' },
{ id: 'users', label: 'Users' },
{ id: 'users-how', label: '→ How-To' },
{ id: 'settings', label: 'Settings' },
{ id: 'settings-how', label: '→ How-To' },
]
},
];
const HowToStep = ({ number, title, children }: { number: number; title: string; children: React.ReactNode }) => (
<div className="bg-blue-50 dark:bg-blue-900/20 p-6 rounded-lg border border-blue-200 dark:border-blue-800">
<div className="flex items-start gap-4">
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-blue-600 text-white font-bold flex-shrink-0">{number}</div>
<div className="flex-1">
<h4 className="text-lg font-semibold text-slate-900 dark:text-white mb-2">{title}</h4>
{children}
</div>
</div>
</div>
);
return (
<div className="min-h-screen bg-slate-50 dark:bg-slate-900">
<div className="bg-white dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700 sticky top-0 z-10">
<div className="max-w-7xl mx-auto px-4 py-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<FileText className="h-8 w-8 text-emerald-600" />
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">Documentation</h1>
</div>
<div className="flex items-center gap-2">
<a href="http://localhost:4000/api-docs" target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 px-4 py-2 rounded-lg bg-blue-600 text-white hover:bg-blue-700 transition" title="Opens API documentation in new tab">
<FileText className="h-4 w-4" />
View API Docs
</a>
<Link href="/dashboard" target="_blank" className="flex items-center gap-2 px-4 py-2 rounded-lg bg-emerald-600 text-white hover:bg-emerald-700 transition">
<Home className="h-4 w-4" />
Dashboard
</Link>
</div>
</div>
</div>
<div className="max-w-7xl mx-auto px-4 py-8">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
<div className="lg:col-span-1">
<div className="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 sticky top-24 h-fit">
<nav>
{sections.map(section => (
<div key={section.id}>
<button onClick={() => toggleSection(section.id)} className="w-full flex items-center justify-between px-4 py-3 text-sm font-medium text-slate-900 dark:text-white hover:bg-slate-50 dark:hover:bg-slate-700 border-b border-slate-100 dark:border-slate-700">
<span>{section.title}</span>
{expandedSections[section.id] ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
{expandedSections[section.id] && (
<div className="bg-slate-50 dark:bg-slate-700/50">
{section.items.map(item => (
<button key={item.id} onClick={() => scrollToSection(item.id)} className="w-full text-left px-6 py-2 text-sm text-slate-600 dark:text-slate-300 hover:text-emerald-600 dark:hover:text-emerald-400 hover:bg-white dark:hover:bg-slate-700 transition">
{item.label}
</button>
))}
</div>
)}
</div>
))}
</nav>
</div>
</div>
<div className="lg:col-span-3">
<div className="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 p-8 space-y-12">
<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>
</div>
<div id="features" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">🌟 Key Features</h2>
<p className="text-slate-600 dark:text-slate-300">Complete booking, passenger, fleet, and financial management.</p>
</div>
{/* BOOKINGS */}
<div id="bookings" className="border-t pt-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-4">📋 Bookings</h2>
<p className="text-slate-600 dark:text-slate-300">Manage passenger bookings with search, view, modify, and refund capabilities.</p>
</div>
<div id="bookings-how" className="border-t pt-8">
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">📋 How-To: Manage Bookings</h3>
<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>View all bookings in table format</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>Use search box for reference, email, or phone</li>
<li>Use Status dropdown to filter</li>
</ol>
</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>
</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>
<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 className="space-y-4">
<HowToStep number={1} title="Access Passengers">
<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>
</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>
</ol>
</HowToStep>
<HowToStep number={3} title="View Profile">
<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>
</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>
</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 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 {`"Tickets"`} in Operations</li>
<li>View all issued tickets with status</li>
</ol>
</HowToStep>
<HowToStep number={2} title="Search Tickets">
<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>
</ol>
</HowToStep>
<HowToStep number={3} title="Download PDF">
<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>
</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>
</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 className="space-y-4">
<HowToStep number={1} title="Access Stations">
<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>
</ol>
</HowToStep>
<HowToStep number={2} title="Create Station">
<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>
</ol>
</HowToStep>
<HowToStep number={3} title="Edit Station">
<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>
</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>
</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 className="space-y-4">
<HowToStep number={1} title="Access Trains">
<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>
</ol>
</HowToStep>
<HowToStep number={2} title="Create Train">
<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>
</ol>
</HowToStep>
<HowToStep number={3} title="Assign Coaches">
<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>
</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>
</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 className="space-y-4">
<HowToStep number={1} title="Access Coaches">
<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>
</ol>
</HowToStep>
<HowToStep number={2} title="Create Coach">
<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>
</ol>
</HowToStep>
<HowToStep number={3} title="Configure Seats">
<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>
</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>
</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 className="space-y-4">
<HowToStep number={1} title="View Seat Map">
<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>
</ol>
</HowToStep>
<HowToStep number={2} title="Block Seat">
<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>
</ol>
</HowToStep>
<HowToStep number={3} title="Unblock Seat">
<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>
</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>
</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 className="space-y-4">
<HowToStep number={1} title="Access Classes">
<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>
</ol>
</HowToStep>
<HowToStep number={2} title="Create Class">
<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>
</ol>
</HowToStep>
<HowToStep number={3} title="Update Pricing">
<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>
</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>
</div>
<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>
</div>
</div>
</div>
);
};
export default DocPage;

View File

@@ -0,0 +1,537 @@
'use client';
import React, { useState } from 'react';
import Link from 'next/link';
import { ChevronDown, ChevronRight, FileText, Home } from 'lucide-react';
const HowToPage = () => {
const scrollToSection = (id: string) => {
setTimeout(() => {
const element = document.getElementById(id);
if (element) {
const headerOffset = 120;
const elementPosition = element.getBoundingClientRect().top + window.pageYOffset;
const offsetPosition = elementPosition - headerOffset;
window.scrollTo({
top: offsetPosition,
behavior: 'smooth'
});
}
}, 0);
};
const guides = [
{ id: 'bookings', title: 'How to Manage Bookings', icon: '📋' },
{ id: 'passengers', title: 'How to Manage Passengers', icon: '👥' },
{ id: 'pricing', title: 'How to Configure Pricing', icon: '💰' },
{ id: 'schedules', title: 'How to Create Schedules', icon: '📅' },
{ id: 'seats', title: 'How to Manage Seats', icon: '💺' },
{ id: 'loyalty', title: 'How to Manage Loyalty', icon: '🏆' },
];
return (
<div className="min-h-screen bg-slate-50 dark:bg-slate-900">
<div className="bg-white dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700 sticky top-0 z-10">
<div className="max-w-7xl mx-auto px-4 py-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<FileText className="h-8 w-8 text-emerald-600" />
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">How-To Guides</h1>
<p className="text-sm text-slate-600 dark:text-slate-400">Step-by-step instructions for common tasks</p>
</div>
</div>
<Link href="/docs" className="flex items-center gap-2 px-4 py-2 rounded-lg bg-emerald-600 text-white hover:bg-emerald-700 transition">
<FileText className="h-4 w-4" />
Back to Docs
</Link>
</div>
</div>
<div className="max-w-7xl mx-auto px-4 py-8">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
<div className="lg:col-span-1">
<div className="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 sticky top-24 h-fit">
<nav className="p-4 space-y-2">
{guides.map(guide => (
<button
key={guide.id}
onClick={() => scrollToSection(guide.id)}
className="w-full text-left px-4 py-3 rounded-lg text-sm font-medium text-slate-600 dark:text-slate-300 hover:text-emerald-600 dark:hover:text-emerald-400 hover:bg-slate-50 dark:hover:bg-slate-700 transition"
>
{guide.icon} {guide.title}
</button>
))}
</nav>
</div>
</div>
<div className="lg:col-span-3">
<div className="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 p-8 space-y-12">
{/* Bookings How-To */}
<div id="bookings" className="pt-4">
<h2 className="text-3xl font-bold text-slate-900 dark:text-white mb-2">📋 How to Manage Bookings</h2>
<p className="text-slate-600 dark:text-slate-300 mb-6">Learn how to search, view, modify, and cancel passenger bookings in the system.</p>
<div className="space-y-8">
<div className="bg-blue-50 dark:bg-blue-900/20 p-6 rounded-lg border border-blue-200 dark:border-blue-800">
<div className="flex items-start gap-4">
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-blue-600 text-white font-bold flex-shrink-0">1</div>
<div>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-2">Access the Bookings Page</h3>
<ol className="list-decimal pl-5 space-y-2 text-slate-700 dark:text-slate-300">
<li>Click on <strong>"Bookings"</strong> in the Operations section of the sidebar</li>
<li>The page loads showing a table with all bookings</li>
<li>You&apos;ll see columns: Reference, Passenger, Status, Amount, Payment, Created date</li>
</ol>
<div className="mt-4 p-3 bg-white dark:bg-slate-800 rounded border-l-4 border-blue-600">
<p className="text-sm font-mono text-slate-600 dark:text-slate-400">📍 Path: Sidebar Operations Bookings</p>
</div>
</div>
</div>
</div>
<div className="bg-green-50 dark:bg-green-900/20 p-6 rounded-lg border border-green-200 dark:border-green-800">
<div className="flex items-start gap-4">
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-green-600 text-white font-bold flex-shrink-0">2</div>
<div>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-2">Search for a Booking</h3>
<ol className="list-decimal pl-5 space-y-2 text-slate-700 dark:text-slate-300">
<li>Find the search box at the top of the booking table</li>
<li>Type in: booking reference (e.g., "BK123"), email, or phone number</li>
<li>Results update in real-time as you type</li>
<li>Optional: Use the Status dropdown to filter (All, Pending Payment, Confirmed, Cancelled, Completed)</li>
</ol>
<div className="mt-4 p-3 bg-white dark:bg-slate-800 rounded">
<p className="text-sm"><strong>💡 Tip:</strong> Search is case-insensitive and supports partial matches</p>
</div>
</div>
</div>
</div>
<div className="bg-purple-50 dark:bg-purple-900/20 p-6 rounded-lg border border-purple-200 dark:border-purple-800">
<div className="flex items-start gap-4">
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-purple-600 text-white font-bold flex-shrink-0">3</div>
<div>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-2">View Booking Details</h3>
<ol className="list-decimal pl-5 space-y-2 text-slate-700 dark:text-slate-300">
<li>Find the booking in the table</li>
<li>Click the <strong>"View Details"</strong> button on the right side</li>
<li>Modal window opens showing complete information:
<ul className="list-disc pl-8 mt-2 space-y-1">
<li>Booking reference and status</li>
<li>Passenger name and contact details</li>
<li>Journey information (schedule, adults, children)</li>
<li>Payment details and amount</li>
<li>All metadata and timestamps</li>
</ul>
</li>
</ol>
</div>
</div>
</div>
<div className="bg-orange-50 dark:bg-orange-900/20 p-6 rounded-lg border border-orange-200 dark:border-orange-800">
<div className="flex items-start gap-4">
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-orange-600 text-white font-bold flex-shrink-0">4</div>
<div>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-2">Cancel a Booking with Refund</h3>
<ol className="list-decimal pl-5 space-y-2 text-slate-700 dark:text-slate-300">
<li>Find the booking in the table</li>
<li>Click the <strong>"Cancel Booking"</strong> button (red)</li>
<li>Confirmation dialog appears</li>
<li>Click <strong>"Confirm"</strong> to proceed</li>
<li>System calculates and processes refund:
<ul className="list-disc pl-8 mt-2 text-sm">
<li>Confirmed bookings: 80% refund</li>
<li>Pending bookings: 0% refund</li>
</ul>
</li>
<li>Status changes to <strong>&quot;CANCELLED&quot;</strong></li>
<li>Success message appears</li>
</ol>
<div className="mt-4 p-3 bg-white dark:bg-slate-800 rounded border-l-4 border-orange-600">
<p className="text-sm"><strong> Important:</strong> Cannot be undone. Seats are automatically released.</p>
</div>
</div>
</div>
</div>
<div className="bg-red-50 dark:bg-red-900/20 p-6 rounded-lg border border-red-200 dark:border-red-800">
<div className="flex items-start gap-4">
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-red-600 text-white font-bold flex-shrink-0">5</div>
<div>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-2">Export Bookings</h3>
<ol className="list-decimal pl-5 space-y-2 text-slate-700 dark:text-slate-300">
<li>Click the <strong>"Export"</strong> button (top-right)</li>
<li>CSV file downloads automatically</li>
<li>Includes all current filters applied</li>
<li>Use for external analysis or backup</li>
</ol>
</div>
</div>
</div>
</div>
</div>
{/* Passengers How-To */}
<div id="passengers" className="border-t pt-8">
<h2 className="text-3xl font-bold text-slate-900 dark:text-white mb-2">👥 How to Manage Passengers</h2>
<p className="text-slate-600 dark:text-slate-300 mb-6">Learn how to search, filter, and view passenger profiles with loyalty and verification data.</p>
<div className="space-y-8">
<div className="bg-blue-50 dark:bg-blue-900/20 p-6 rounded-lg border border-blue-200 dark:border-blue-800">
<div className="flex items-start gap-4">
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-blue-600 text-white font-bold flex-shrink-0">1</div>
<div>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-2">Access Passengers Page</h3>
<ol className="list-decimal pl-5 space-y-2 text-slate-700 dark:text-slate-300">
<li>Click <strong>"Passengers"</strong> in the Operations section</li>
<li>Page displays all passenger profiles</li>
<li>Default view shows 20 passengers per page</li>
</ol>
</div>
</div>
</div>
<div className="bg-green-50 dark:bg-green-900/20 p-6 rounded-lg border border-green-200 dark:border-green-800">
<div className="flex items-start gap-4">
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-green-600 text-white font-bold flex-shrink-0">2</div>
<div>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-2">Search & Filter</h3>
<div className="space-y-3 text-slate-700 dark:text-slate-300">
<div>
<p className="font-semibold mb-2">Search by:</p>
<ul className="list-disc pl-5 space-y-1 text-sm">
<li>Full name</li>
<li>Email address</li>
<li>Phone number</li>
<li>National ID</li>
</ul>
</div>
<div>
<p className="font-semibold mb-2">Filter by:</p>
<ul className="list-disc pl-5 space-y-1 text-sm">
<li><strong>Nationality:</strong> Ethiopian, Djiboutian, Other</li>
<li><strong>Verifayda Status:</strong> Verified, Unverified, Pending</li>
<li><strong>Loyalty Tier:</strong> Bronze, Silver, Gold, Platinum</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<div className="bg-purple-50 dark:bg-purple-900/20 p-6 rounded-lg border border-purple-200 dark:border-purple-800">
<div className="flex items-start gap-4">
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-purple-600 text-white font-bold flex-shrink-0">3</div>
<div>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-2">View Complete Profile</h3>
<ol className="list-decimal pl-5 space-y-2 text-slate-700 dark:text-slate-300">
<li>Click on any passenger row</li>
<li>Detailed profile modal opens showing:
<ul className="list-disc pl-8 mt-2 space-y-1 text-sm">
<li>Account info (email, phone, nationality)</li>
<li>Verifayda verification status</li>
<li>Loyalty tier and points</li>
<li>Wallet balance</li>
<li>Booking history with links</li>
</ul>
</li>
</ol>
<div className="mt-4 p-3 bg-white dark:bg-slate-800 rounded">
<p className="text-sm"><strong> Note:</strong> Read-only view. Updates via passenger portal.</p>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Pricing How-To */}
<div id="pricing" className="border-t pt-8">
<h2 className="text-3xl font-bold text-slate-900 dark:text-white mb-2">💰 How to Configure Pricing</h2>
<p className="text-slate-600 dark:text-slate-300 mb-6">Learn how to set up dynamic fares with segment pricing and nationality overrides.</p>
<div className="space-y-8">
<div className="bg-blue-50 dark:bg-blue-900/20 p-6 rounded-lg border border-blue-200 dark:border-blue-800">
<div className="flex items-start gap-4">
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-blue-600 text-white font-bold flex-shrink-0">1</div>
<div>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-2">Access Pricing Page</h3>
<ol className="list-decimal pl-5 space-y-2 text-slate-700 dark:text-slate-300">
<li>Click <strong>"Pricing & Fares"</strong> in Financial section</li>
<li>Two tabs: <strong>Schedule Fares</strong> and <strong>Segment Fares</strong></li>
<li>Default tab shows Schedule Fares</li>
</ol>
</div>
</div>
</div>
<div className="bg-green-50 dark:bg-green-900/20 p-6 rounded-lg border border-green-200 dark:border-green-800">
<div className="flex items-start gap-4">
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-green-600 text-white font-bold flex-shrink-0">2</div>
<div>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-2">Create Schedule Fare Rule</h3>
<ol className="list-decimal pl-5 space-y-2 text-slate-700 dark:text-slate-300">
<li>Click <strong>"Add Fare Rule"</strong></li>
<li>Fill in form:
<ul className="list-disc pl-8 mt-2 space-y-1 text-sm">
<li><strong>Schedule (optional):</strong> Leave empty for global</li>
<li><strong>Route Code (optional):</strong> e.g., "ADD-DJI"</li>
<li><strong>Seat Class (required):</strong> Economy Regular, VIP Bed, etc.</li>
<li><strong>Fare in ETB (required):</strong> e.g., 350.00</li>
<li><strong>Passenger Type (optional):</strong> ADULT or CHILD</li>
<li><strong>Nationality (optional):</strong> Ethiopian, Djiboutian, Other</li>
<li><strong>Valid From & Until:</strong> Set date range</li>
</ul>
</li>
<li>Click <strong>"Save Fare Rule"</strong></li>
</ol>
</div>
</div>
</div>
<div className="bg-purple-50 dark:bg-purple-900/20 p-6 rounded-lg border border-purple-200 dark:border-purple-800">
<div className="flex items-start gap-4">
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-purple-600 text-white font-bold flex-shrink-0">3</div>
<div>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-2">Create Segment Fare Rule</h3>
<ol className="list-decimal pl-5 space-y-2 text-slate-700 dark:text-slate-300">
<li>Click <strong>"Add Fare Rule"</strong></li>
<li>Switch to <strong>"Segment Fares"</strong> tab</li>
<li>Select route from dropdown</li>
<li>Fill in form:
<ul className="list-disc pl-8 mt-2 space-y-1 text-sm">
<li><strong>Origin Station (required):</strong> Starting point</li>
<li><strong>Destination Station (required):</strong> Must be after origin</li>
<li><strong>Seat Class (required):</strong> Class type</li>
<li><strong>Fare in ETB (required):</strong> Segment price</li>
</ul>
</li>
<li>Click <strong>"Save Segment Fare Rule"</strong></li>
</ol>
<div className="mt-4 p-3 bg-white dark:bg-slate-800 rounded">
<p className="text-sm"><strong>Example:</strong> ADD (Stop 1) to DDA (Stop 4) at 250 ETB</p>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Schedules How-To */}
<div id="schedules" className="border-t pt-8">
<h2 className="text-3xl font-bold text-slate-900 dark:text-white mb-2">📅 How to Create Schedules</h2>
<p className="text-slate-600 dark:text-slate-300 mb-6">Learn how to create schedules manually or in bulk with recurring patterns.</p>
<div className="space-y-8">
<div className="bg-blue-50 dark:bg-blue-900/20 p-6 rounded-lg border border-blue-200 dark:border-blue-800">
<div className="flex items-start gap-4">
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-blue-600 text-white font-bold flex-shrink-0">1</div>
<div>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-2">Create Single Schedule</h3>
<ol className="list-decimal pl-5 space-y-2 text-slate-700 dark:text-slate-300">
<li>Go to <strong>Schedules</strong> page (Master Data)</li>
<li>Click <strong>"Create Schedule"</strong></li>
<li>Fill in required fields:
<ul className="list-disc pl-8 mt-2 space-y-1 text-sm">
<li><strong>Train:</strong> Select from dropdown</li>
<li><strong>Route:</strong> Select from dropdown</li>
<li><strong>Departure Date & Time:</strong> Pick from date/time picker</li>
<li><strong>Arrival Date & Time:</strong> Must be after departure</li>
</ul>
</li>
<li>Select coaches to assign</li>
<li>Click <strong>"Create Schedule"</strong></li>
</ol>
</div>
</div>
</div>
<div className="bg-green-50 dark:bg-green-900/20 p-6 rounded-lg border border-green-200 dark:border-green-800">
<div className="flex items-start gap-4">
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-green-600 text-white font-bold flex-shrink-0">2</div>
<div>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-2">Bulk Generate Recurring Schedules</h3>
<ol className="list-decimal pl-5 space-y-2 text-slate-700 dark:text-slate-300">
<li>Click <strong>"Bulk Generate"</strong> button</li>
<li>Fill in generation form:
<ul className="list-disc pl-8 mt-2 space-y-1 text-sm">
<li><strong>Train (required):</strong> Select train</li>
<li><strong>Route (required):</strong> Select route</li>
<li><strong>Start Date & Time (required):</strong> First departure</li>
<li><strong>Duration (Hours):</strong> Trip length</li>
<li><strong>Repeat Every (Days):</strong> Daily or custom</li>
<li><strong>For Next (Days):</strong> How many days</li>
</ul>
</li>
<li>Review preview showing number of schedules</li>
<li>Click <strong>"Generate Schedules"</strong></li>
</ol>
<div className="mt-4 p-3 bg-white dark:bg-slate-800 rounded border-l-4 border-green-600">
<p className="text-sm"><strong>Example:</strong> 30 days ÷ 1 day = ~30 daily schedules</p>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Seats How-To */}
<div id="seats" className="border-t pt-8">
<h2 className="text-3xl font-bold text-slate-900 dark:text-white mb-2">💺 How to Manage Seats</h2>
<p className="text-slate-600 dark:text-slate-300 mb-6">Learn how to view, block, and manage seat inventory using visual seat maps.</p>
<div className="space-y-8">
<div className="bg-blue-50 dark:bg-blue-900/20 p-6 rounded-lg border border-blue-200 dark:border-blue-800">
<div className="flex items-start gap-4">
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-blue-600 text-white font-bold flex-shrink-0">1</div>
<div>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-2">View Seat Map</h3>
<ol className="list-decimal pl-5 space-y-2 text-slate-700 dark:text-slate-300">
<li>Go to <strong>Seats</strong> page (Master Data)</li>
<li>Select a coach from dropdown</li>
<li>Visual seat map displays</li>
<li>Color-coded by status:
<ul className="list-disc pl-8 mt-2 text-sm">
<li>🟢 Green: Available</li>
<li>🟡 Yellow: Held</li>
<li>🔵 Blue: Booked</li>
<li>🔴 Red: Blocked</li>
</ul>
</li>
</ol>
</div>
</div>
</div>
<div className="bg-green-50 dark:bg-green-900/20 p-6 rounded-lg border border-green-200 dark:border-green-800">
<div className="flex items-start gap-4">
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-green-600 text-white font-bold flex-shrink-0">2</div>
<div>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-2">Block a Seat</h3>
<ol className="list-decimal pl-5 space-y-2 text-slate-700 dark:text-slate-300">
<li>Click on an available (green) seat</li>
<li>Click <strong>"Block"</strong> button</li>
<li>Select reason:
<ul className="list-disc pl-8 mt-2 text-sm">
<li>Maintenance</li>
<li>Reserved</li>
<li>Damaged</li>
</ul>
</li>
<li>Set until date (optional)</li>
<li>Add notes</li>
<li>Click <strong>"Block Seat"</strong></li>
<li>Seat turns red</li>
</ol>
</div>
</div>
</div>
<div className="bg-purple-50 dark:bg-purple-900/20 p-6 rounded-lg border border-purple-200 dark:border-purple-800">
<div className="flex items-start gap-4">
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-purple-600 text-white font-bold flex-shrink-0">3</div>
<div>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-2">Unblock a Seat</h3>
<ol className="list-decimal pl-5 space-y-2 text-slate-700 dark:text-slate-300">
<li>Click on a blocked (red) seat</li>
<li>Click <strong>"Unblock"</strong> button</li>
<li>Confirm action</li>
<li>Seat becomes available (green)</li>
</ol>
</div>
</div>
</div>
</div>
</div>
{/* Loyalty How-To */}
<div id="loyalty" className="border-t pt-8">
<h2 className="text-3xl font-bold text-slate-900 dark:text-white mb-2">🏆 How to Manage Loyalty Program</h2>
<p className="text-slate-600 dark:text-slate-300 mb-6">Learn how to view loyalty accounts, manage points, and administer rewards.</p>
<div className="space-y-8">
<div className="bg-blue-50 dark:bg-blue-900/20 p-6 rounded-lg border border-blue-200 dark:border-blue-800">
<div className="flex items-start gap-4">
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-blue-600 text-white font-bold flex-shrink-0">1</div>
<div>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-2">View Loyalty Accounts</h3>
<ol className="list-decimal pl-5 space-y-2 text-slate-700 dark:text-slate-300">
<li>Go to <strong>Loyalty Program</strong> (Customer Services)</li>
<li>Table displays all loyalty accounts</li>
<li>Columns: Name, Tier, Points Balance, Lifetime Points</li>
<li>Search by name or filter by tier</li>
</ol>
</div>
</div>
</div>
<div className="bg-green-50 dark:bg-green-900/20 p-6 rounded-lg border border-green-200 dark:border-green-800">
<div className="flex items-start gap-4">
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-green-600 text-white font-bold flex-shrink-0">2</div>
<div>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-2">Adjust Points</h3>
<ol className="list-decimal pl-5 space-y-2 text-slate-700 dark:text-slate-300">
<li>Click on a loyalty account</li>
<li>Click <strong>"Adjust Points"</strong> button</li>
<li>Enter points to add/subtract</li>
<li>Select reason: Bonus, Correction, Promotion, etc.</li>
<li>Add optional notes</li>
<li>Click <strong>"Apply"</strong></li>
<li>Balance updates immediately</li>
</ol>
</div>
</div>
</div>
<div className="bg-purple-50 dark:bg-purple-900/20 p-6 rounded-lg border border-purple-200 dark:border-purple-800">
<div className="flex items-start gap-4">
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-purple-600 text-white font-bold flex-shrink-0">3</div>
<div>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-2">Award Rewards</h3>
<ol className="list-decimal pl-5 space-y-2 text-slate-700 dark:text-slate-300">
<li>Click on a loyalty account</li>
<li>Click <strong>"Grant Reward"</strong> button</li>
<li>Select reward from list</li>
<li>Specify quantity if applicable</li>
<li>Click <strong>"Award"</strong></li>
<li>Confirmation email sent to passenger</li>
</ol>
</div>
</div>
</div>
</div>
</div>
{/* Common Tips */}
<div className="bg-gradient-to-r from-emerald-50 to-teal-50 dark:from-emerald-900/20 dark:to-teal-900/20 p-6 rounded-lg border border-emerald-200 dark:border-emerald-800 border-t pt-8">
<h3 className="text-lg font-semibold text-emerald-900 dark:text-emerald-200 mb-4">💡 Common Tips & Tricks</h3>
<ul className="list-disc pl-6 space-y-2 text-emerald-800 dark:text-emerald-300 text-sm">
<li><strong>Keyboard Shortcuts:</strong> Tab to navigate, Enter to submit</li>
<li><strong>Pagination:</strong> Change page size or jump to specific page</li>
<li><strong>Sidebar Collapse:</strong> Use chevron to minimize sidebar</li>
<li><strong>Dark Mode:</strong> Toggle with sun/moon icon in header</li>
<li><strong>Error Messages:</strong> Red text above forms if validation fails</li>
<li><strong>Success Notifications:</strong> Green banner appears for 3 seconds</li>
<li><strong>Undo Not Available:</strong> Most actions cannot be undone</li>
<li><strong>Real-time Updates:</strong> Refresh page to see changes by other users</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<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 | How-To Guides v1.0</p>
<p className="text-sm mt-2">Last Updated: January 15, 2026</p>
</div>
</div>
</div>
);
};
export default HowToPage;

View File

@@ -1,17 +1,25 @@
'use client';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/lib/auth-store';
import { Train } from 'lucide-react';
import { useTheme } from '@/lib/theme-store';
import { Train, Eye, EyeOff, Sun, Moon } from 'lucide-react';
export default function LoginPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [isMounted, setIsMounted] = useState(false);
const router = useRouter();
const { login } = useAuthStore();
const { isDark, toggleTheme } = useTheme();
useEffect(() => {
setIsMounted(true);
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -29,77 +37,109 @@ export default function LoginPage() {
}
};
if (!isMounted) {
return null;
}
return (
<div className="flex min-h-screen">
{/* Banner Image Side */}
<div className="hidden lg:flex lg:w-1/2 relative bg-gradient-to-br from-[rgb(20,113,76)] to-[rgb(15,85,57)] items-center justify-center">
<div className="absolute inset-0 bg-[url('/banner.jpg')] bg-cover bg-center opacity-20"></div>
<div className="relative z-10 text-center px-12">
<div className="flex justify-center mb-6">
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-white/10 backdrop-blur-sm shadow-2xl">
<Train className="h-12 w-12 text-white" />
<div className="flex min-h-screen relative bg-gradient-to-br from-[rgb(20,113,76)] to-[rgb(15,85,57)]">
{/* Full Screen Banner Background */}
<div className="absolute inset-0 bg-[url('/banner.jpg')] bg-cover bg-center opacity-50"></div>
{/* Content Overlay */}
<div className="relative z-10 flex items-center justify-start w-full px-4 lg:px-16">
<div className="w-full max-w-sm">
{/* Login Card with Shadow */}
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl border border-white/20 dark:border-gray-700/50 overflow-hidden backdrop-blur-sm">
{/* Card Header with Logo, App Name and Theme Toggle */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700/50 bg-gray-50 dark:bg-gray-700/50">
<div className="flex items-center gap-3">
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-[rgb(20,113,76)] shadow-md">
<Train className="h-9 w-9 text-white" />
</div>
<div>
<h2 className="text-lg font-bold text-gray-900 dark:text-white">Ethio-Djibouti Railway</h2>
<p className="text-lg text-gray-600 dark:text-gray-400">Passenger Back-office</p>
</div>
</div>
<button
onClick={toggleTheme}
className="p-2 rounded-lg bg-white/80 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
aria-label="Toggle theme"
>
{isDark ? (
<Sun className="w-5 h-5 text-yellow-500" />
) : (
<Moon className="w-5 h-5 text-gray-700" />
)}
</button>
</div>
{/* Card Body */}
<div className="p-6">
<div className="mb-8">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">Welcome back!</h2>
<p className="text-xl text-gray-900 dark:text-white">Sign in to continue.</p>
</div>
{error && (
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 p-4 text-sm text-red-800 dark:text-red-200 border border-red-200 dark:border-red-800">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-[rgb(20,113,76)] focus:border-transparent"
placeholder="name@email.com"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">Password</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-[rgb(20,113,76)] focus:border-transparent"
placeholder="••••••••"
required
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
aria-label="Toggle password visibility"
>
{showPassword ? (
<EyeOff className="w-4 h-4" />
) : (
<Eye className="w-4 h-4" />
)}
</button>
</div>
</div>
<button
type="submit"
disabled={loading}
className="w-full mt-6 py-2 bg-[rgb(20,113,76)] text-white font-semibold rounded-lg border-2 border-[rgb(20,113,76)] hover:bg-[rgb(16,90,61)] hover:border-[rgb(16,90,61)] disabled:opacity-50 transition-all duration-200"
>
{loading ? 'Signing in...' : 'Sign in'}
</button>
</form>
</div>
</div>
<h1 className="text-5xl font-bold text-white mb-4">EDR</h1>
<p className="text-lg text-white/80">Passenger Back-office</p>
</div>
</div>
{/* Login Form Side */}
<div className="flex w-full lg:w-1/2 items-center justify-center bg-gray-100 dark:bg-gray-900 p-8">
<div className="w-full max-w-md">
<div className="card">
<div className="mb-6">
<div className="mb-4 flex justify-center lg:hidden">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-[rgb(20,113,76)] shadow-lg">
<Train className="h-6 w-6 text-white" />
</div>
<div className="text-4xl font-bold text-gray-900 dark:text-white ps-4">EDR</div>
</div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">Sign in to get started.</h2>
</div>
{error && (
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 p-4 text-sm text-red-800 dark:text-red-200">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="label">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="input"
required
/>
</div>
<div>
<label className="label">Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="input"
required
/>
</div>
<button
type="submit"
disabled={loading}
className="btn btn-primary w-full disabled:opacity-50"
>
{loading ? 'Signing in...' : 'Sign in'}
</button>
</form>
</div>
</div>
</div>
</div>
);
}

View File

@@ -2,64 +2,467 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Download } from 'lucide-react';
import { Download, Eye, Plus } 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 { reportsApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils';
export default function OperationalreportsPage() {
export default function OperationalReportsPage() {
const [filters, setFilters] = useState({ search: '', reportType: '' });
const { data, isLoading } = useQuery({
queryKey: ['operational-reports', filters],
queryFn: () => reportsApi.getOperationalReports(filters),
const [selectedReport, setSelectedReport] = useState<any>(null);
const [showDetailsModal, setShowDetailsModal] = useState(false);
const [showGenerateModal, setShowGenerateModal] = useState(false);
const [generateForm, setGenerateForm] = useState({
reportType: 'REVENUE',
dateFrom: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
dateTo: new Date().toISOString().split('T')[0],
});
const { data, isLoading, refetch } = useQuery({
queryKey: ['operational-reports', filters],
queryFn: () => reportsApi.listReports(filters.reportType || undefined),
});
const handleGenerateReport = async () => {
try {
await reportsApi.generateReport(generateForm);
refetch();
setShowGenerateModal(false);
} catch (error) {
console.error('Error generating report:', error);
}
};
const getReportTypeBadgeColor = (type: string) => {
switch (type) {
case 'REVENUE':
return 'success';
case 'OCCUPANCY':
return 'primary';
case 'PERFORMANCE':
return 'info';
case 'AGENT_SALES':
return 'secondary';
default:
return 'secondary';
}
};
const formatReportType = (type: string) => {
const typeMap: { [key: string]: string } = {
REVENUE: 'Revenue Report',
OCCUPANCY: 'Occupancy Report',
PERFORMANCE: 'Performance Report',
AGENT_SALES: 'Agent Sales Report',
CANCELLATIONS: 'Cancellations Report',
PAYMENT_METHODS: 'Payment Methods Report',
};
return typeMap[type] || type;
};
const columns = [
{ key: 'reportType', label: 'Type', render: (report: any) => <Badge>{report.reportType}</Badge> },
{ key: 'period', label: 'Period', render: (report: any) => report.period || 'N/A' },
{ key: 'generatedBy', label: 'Generated By', render: (report: any) => report.generatedBy?.fullName || 'System' },
{ key: 'createdAt', label: 'Generated', render: (report: any) => formatDateTime(report.createdAt) },
];
{
key: 'reportType',
label: 'Report Type',
sortable: true,
render: (report: any) => (
<Badge className={getReportTypeBadgeColor(report.reportType)}>
{formatReportType(report.reportType)}
</Badge>
),
},
{
key: 'dateFrom',
label: 'Period From',
sortable: true,
render: (report: any) => (
<span className="text-sm">{new Date(report.dateFrom).toLocaleDateString()}</span>
),
},
{
key: 'dateTo',
label: 'Period To',
sortable: true,
render: (report: any) => (
<span className="text-sm">{new Date(report.dateTo).toLocaleDateString()}</span>
),
},
{
key: 'data',
label: 'Summary',
render: (report: any) => {
const data = report.data || {};
if (report.reportType === 'REVENUE') {
return (
<div className="text-sm">
<p className="font-medium">{formatCurrency(data.totalRevenueMinor || 0, 'ETB')}</p>
<p className="text-xs text-muted-foreground">{data.totalBookings || 0} bookings</p>
</div>
);
}
if (report.reportType === 'OCCUPANCY') {
return (
<div className="text-sm">
<p className="font-medium">{(data.averageOccupancyRate || 0).toFixed(1)}% occupancy</p>
<p className="text-xs text-muted-foreground">{data.totalSchedules || 0} schedules</p>
</div>
);
}
if (report.reportType === 'AGENT_SALES') {
return (
<div className="text-sm">
<p className="font-medium">{data.totalAgentBookings || 0} bookings</p>
<p className="text-xs text-muted-foreground">{Object.keys(data.byAgent || {}).length} agents</p>
</div>
);
}
if (report.reportType === 'CANCELLATIONS') {
return (
<div className="text-sm">
<p className="font-medium">{data.totalCancellations || 0} cancellations</p>
<p className="text-xs text-muted-foreground">Refunded: {formatCurrency(data.totalRefundedMinor || 0, 'ETB')}</p>
</div>
);
}
if (report.reportType === 'PAYMENT_METHODS') {
return (
<div className="text-sm">
<p className="font-medium">{data.totalPayments || 0} payments</p>
<p className="text-xs text-muted-foreground">{Object.keys(data.byMethod || {}).length} methods</p>
</div>
);
}
return <span className="text-sm text-muted-foreground">View details</span>;
},
},
{
key: 'createdAt',
label: 'Generated',
sortable: true,
render: (report: any) => (
<span className="text-sm">{formatDateTime(report.createdAt)}</span>
),
},
];
const actions = [
{
label: 'View Details',
onClick: (report: any) => {
setSelectedReport(report);
setShowDetailsModal(true);
},
variant: 'secondary' as const,
icon: Eye,
},
];
const reports = data?.items || data || [];
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Operational Reports</h1>
<p className="text-muted-foreground">View operational reports and analytics</p>
<h1 className="text-3xl font-bold text-foreground">Operational Reports</h1>
<p className="text-muted-foreground mt-1">View and analyze operational performance</p>
</div>
<div className="flex gap-2">
<ActionButton icon={Plus} variant="primary" onClick={() => setShowGenerateModal(true)}>
Generate Report
</ActionButton>
<ActionButton icon={Download} variant="secondary">
Export All
</ActionButton>
</div>
<ActionButton icon={Download} variant="secondary">Export</ActionButton>
</div>
{/* Filters */}
<div className="card">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="label">Search</label>
<input type="text" placeholder="Search..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} />
</div>
<div>
<label className="label">Report Type</label>
<select className="input" value={filters.reportType} onChange={(e) => setFilters({ ...filters, reportType: e.target.value })}>
<option value="">All Types</option>
<option value="REVENUE">Revenue</option>
<option value="OCCUPANCY">Occupancy</option>
<option value="PERFORMANCE">Performance</option>
</select>
</div>
<div>
<label className="label">Search (Report ID/Type)</label>
<input
type="text"
placeholder="Search reports..."
className="input"
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
/>
</div>
<div>
<label className="label">Report Type</label>
<select
className="input"
value={filters.reportType}
onChange={(e) => setFilters({ ...filters, reportType: e.target.value })}
>
<option value="">All Types</option>
<option value="REVENUE">Revenue Report</option>
<option value="OCCUPANCY">Occupancy Report</option>
<option value="AGENT_SALES">Agent Sales Report</option>
<option value="CANCELLATIONS">Cancellations Report</option>
<option value="PAYMENT_METHODS">Payment Methods Report</option>
</select>
</div>
<div className="flex items-end">
<ActionButton
variant="secondary"
onClick={() => setFilters({ search: '', reportType: '' })}
className="w-full"
>
Clear Filters
</ActionButton>
</div>
</div>
</div>
{/* Reports Table */}
<DataTable
data={data?.items || data || []}
data={reports}
columns={columns}
actions={actions}
loading={isLoading}
emptyMessage="No operational reports found"
/>
{/* Generate Report Modal */}
<Modal
isOpen={showGenerateModal}
onClose={() => setShowGenerateModal(false)}
title="Generate Report"
size="sm"
>
<div className="space-y-4">
<div>
<label className="label">Report Type</label>
<select
className="input"
value={generateForm.reportType}
onChange={(e) => setGenerateForm({ ...generateForm, reportType: e.target.value })}
>
<option value="REVENUE">Revenue Report</option>
<option value="OCCUPANCY">Occupancy Report</option>
<option value="AGENT_SALES">Agent Sales Report</option>
<option value="CANCELLATIONS">Cancellations Report</option>
<option value="PAYMENT_METHODS">Payment Methods Report</option>
</select>
</div>
<div>
<label className="label">Date From</label>
<input
type="date"
className="input"
value={generateForm.dateFrom}
onChange={(e) => setGenerateForm({ ...generateForm, dateFrom: e.target.value })}
/>
</div>
<div>
<label className="label">Date To</label>
<input
type="date"
className="input"
value={generateForm.dateTo}
onChange={(e) => setGenerateForm({ ...generateForm, dateTo: e.target.value })}
/>
</div>
<div className="flex gap-2 pt-4">
<ActionButton
variant="primary"
onClick={handleGenerateReport}
className="flex-1"
>
Generate
</ActionButton>
<ActionButton
variant="secondary"
onClick={() => setShowGenerateModal(false)}
className="flex-1"
>
Cancel
</ActionButton>
</div>
</div>
</Modal>
{/* Details Modal */}
<Modal
isOpen={showDetailsModal}
onClose={() => {
setShowDetailsModal(false);
setSelectedReport(null);
}}
title={formatReportType(selectedReport?.reportType)}
size="lg"
>
<div className="space-y-4">
{/* Report Header */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs font-semibold text-muted-foreground">Report Type</label>
<p className="text-sm mt-1 font-medium">{formatReportType(selectedReport?.reportType)}</p>
</div>
<div>
<label className="text-xs font-semibold text-muted-foreground">Generated</label>
<p className="text-sm mt-1">{formatDateTime(selectedReport?.createdAt)}</p>
</div>
<div>
<label className="text-xs font-semibold text-muted-foreground">Period From</label>
<p className="text-sm mt-1">{new Date(selectedReport?.dateFrom).toLocaleDateString()}</p>
</div>
<div>
<label className="text-xs font-semibold text-muted-foreground">Period To</label>
<p className="text-sm mt-1">{new Date(selectedReport?.dateTo).toLocaleDateString()}</p>
</div>
</div>
{/* Revenue Report Data */}
{selectedReport?.reportType === 'REVENUE' && (
<div className="border-t pt-4">
<h4 className="text-sm font-semibold mb-3">Revenue Metrics</h4>
<div className="grid grid-cols-2 gap-4">
<div className="bg-blue-50 dark:bg-blue-950/20 rounded-lg p-3">
<p className="text-xs text-muted-foreground">Total Revenue</p>
<p className="text-lg font-bold mt-1">
{formatCurrency(selectedReport?.data?.totalRevenueMinor || 0, 'ETB')}
</p>
</div>
<div className="bg-green-50 dark:bg-green-950/20 rounded-lg p-3">
<p className="text-xs text-muted-foreground">Total Bookings</p>
<p className="text-lg font-bold mt-1">
{(selectedReport?.data?.totalBookings || 0).toLocaleString()}
</p>
</div>
</div>
{selectedReport?.data?.byPaymentMethod && (
<div className="mt-4">
<p className="text-xs font-semibold mb-2 text-muted-foreground">By Payment Method</p>
<div className="space-y-2">
{Object.entries(selectedReport.data.byPaymentMethod).map(([method, amount]: [string, any]) => (
<div key={method} className="flex justify-between text-sm">
<span className="text-muted-foreground capitalize">{method.toLowerCase().replace('_', ' ')}</span>
<span className="font-medium">{formatCurrency(amount, 'ETB')}</span>
</div>
))}
</div>
</div>
)}
</div>
)}
{/* Occupancy Report Data */}
{selectedReport?.reportType === 'OCCUPANCY' && (
<div className="border-t pt-4">
<h4 className="text-sm font-semibold mb-3">Occupancy Metrics</h4>
<div className="grid grid-cols-2 gap-4">
<div className="bg-blue-50 dark:bg-blue-950/20 rounded-lg p-3">
<p className="text-xs text-muted-foreground">Avg Occupancy Rate</p>
<p className="text-lg font-bold mt-1">
{(selectedReport?.data?.averageOccupancyRate || 0).toFixed(1)}%
</p>
</div>
<div className="bg-green-50 dark:bg-green-950/20 rounded-lg p-3">
<p className="text-xs text-muted-foreground">Total Schedules</p>
<p className="text-lg font-bold mt-1">
{(selectedReport?.data?.totalSchedules || 0).toLocaleString()}
</p>
</div>
</div>
</div>
)}
{/* Agent Sales Report Data */}
{selectedReport?.reportType === 'AGENT_SALES' && (
<div className="border-t pt-4">
<h4 className="text-sm font-semibold mb-3">Agent Sales Metrics</h4>
<div className="grid grid-cols-2 gap-4">
<div className="bg-blue-50 dark:bg-blue-950/20 rounded-lg p-3">
<p className="text-xs text-muted-foreground">Total Bookings</p>
<p className="text-lg font-bold mt-1">
{(selectedReport?.data?.totalAgentBookings || 0).toLocaleString()}
</p>
</div>
<div className="bg-green-50 dark:bg-green-950/20 rounded-lg p-3">
<p className="text-xs text-muted-foreground">Active Agents</p>
<p className="text-lg font-bold mt-1">
{Object.keys(selectedReport?.data?.byAgent || {}).length}
</p>
</div>
</div>
{selectedReport?.data?.byAgent && (
<div className="mt-4">
<p className="text-xs font-semibold mb-2 text-muted-foreground">By Agent</p>
<div className="space-y-2 max-h-64 overflow-y-auto">
{Object.entries(selectedReport.data.byAgent).map(([agent, stats]: [string, any]) => (
<div key={agent} className="text-sm border-b pb-2 last:border-0">
<p className="font-medium">{agent}</p>
<div className="text-xs text-muted-foreground mt-1">
<p>Bookings: {stats.bookings} | Revenue: {formatCurrency(stats.revenueMinor, 'ETB')}</p>
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
{/* Cancellations Report Data */}
{selectedReport?.reportType === 'CANCELLATIONS' && (
<div className="border-t pt-4">
<h4 className="text-sm font-semibold mb-3">Cancellation Metrics</h4>
<div className="grid grid-cols-2 gap-4">
<div className="bg-blue-50 dark:bg-blue-950/20 rounded-lg p-3">
<p className="text-xs text-muted-foreground">Total Cancellations</p>
<p className="text-lg font-bold mt-1">
{(selectedReport?.data?.totalCancellations || 0).toLocaleString()}
</p>
</div>
<div className="bg-green-50 dark:bg-green-950/20 rounded-lg p-3">
<p className="text-xs text-muted-foreground">Total Refunded</p>
<p className="text-lg font-bold mt-1">
{formatCurrency(selectedReport?.data?.totalRefundedMinor || 0, 'ETB')}
</p>
</div>
</div>
</div>
)}
{/* Payment Methods Report Data */}
{selectedReport?.reportType === 'PAYMENT_METHODS' && (
<div className="border-t pt-4">
<h4 className="text-sm font-semibold mb-3">Payment Method Breakdown</h4>
<div className="bg-blue-50 dark:bg-blue-950/20 rounded-lg p-3 mb-4">
<p className="text-xs text-muted-foreground">Total Payments</p>
<p className="text-lg font-bold mt-1">
{(selectedReport?.data?.totalPayments || 0).toLocaleString()}
</p>
</div>
{selectedReport?.data?.byMethod && (
<div className="space-y-2">
{Object.entries(selectedReport.data.byMethod).map(([method, stats]: [string, any]) => (
<div key={method} className="flex justify-between items-center p-3 bg-gray-50 dark:bg-gray-900 rounded">
<div>
<p className="text-sm font-medium capitalize">{method.toLowerCase().replace('_', ' ')}</p>
<p className="text-xs text-muted-foreground">{stats.count} transactions</p>
</div>
<p className="font-bold">{formatCurrency(stats.totalMinor, 'ETB')}</p>
</div>
))}
</div>
)}
</div>
)}
{/* Report ID */}
<div className="border-t pt-4">
<label className="text-xs font-semibold text-muted-foreground">Report ID</label>
<p className="text-xs mt-1 font-mono text-muted-foreground break-all">{selectedReport?.id}</p>
</div>
</div>
</Modal>
</div>
);
}

View File

@@ -52,6 +52,44 @@ export default function PassengersPage() {
console.error('Passengers API Error:', error);
}
const handleExportPassengers = async () => {
const selectedColumns = prompt(
'Select columns to export (comma-separated):\n\n' +
'Available: fullName, email, phone, dateOfBirth, gender, nationality, verified\n\n' +
'Default: fullName, email, phone, gender, nationality, verified',
'fullName, email, phone, gender, nationality, verified'
);
if (!selectedColumns) return;
const cols = selectedColumns.split(',').map(c => c.trim());
const csv = [
cols.join(','),
...data?.items?.map((passenger: any) => {
const values = cols.map(col => {
switch(col) {
case 'fullName': return passenger.fullName;
case 'email': return passenger.email || '';
case 'phone': return passenger.phone || '';
case 'dateOfBirth': return passenger.dateOfBirth ? formatDate(passenger.dateOfBirth) : '';
case 'gender': return passenger.gender || '';
case 'nationality': return passenger.nationality || '';
case 'verified': return passenger.nationalId ? 'Yes' : 'No';
default: return '';
}
});
return values.map(v => `"${v}"`).join(',');
}) || []
].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `passengers-${new Date().toISOString().split('T')[0]}.csv`;
a.click();
};
const columns = [
{
key: 'fullName',
@@ -67,16 +105,25 @@ export default function PassengersPage() {
{
key: 'phone',
label: 'Phone',
sortable: true,
render: (passenger: any) => passenger.phone,
},
{
key: 'nationalId',
label: 'National ID',
render: (passenger: any) => passenger.nationalId || 'N/A',
key: 'gender',
label: 'Gender',
sortable: true,
render: (passenger: any) => passenger.gender || 'N/A',
},
{
key: 'nationality',
label: 'Nationality',
sortable: true,
render: (passenger: any) => passenger.nationality || 'N/A',
},
{
key: 'dateOfBirth',
label: 'Date of Birth',
sortable: true,
render: (passenger: any) => passenger.dateOfBirth ? formatDate(passenger.dateOfBirth) : 'N/A',
},
{
@@ -113,7 +160,7 @@ export default function PassengersPage() {
<p className="text-muted-foreground">Manage passenger profiles and verification</p>
</div>
<div className="flex gap-2">
<ActionButton variant="export" icon={Download}>Export</ActionButton>
<ActionButton variant="export" icon={Download} onClick={handleExportPassengers}>Export</ActionButton>
</div>
</div>
@@ -230,10 +277,6 @@ export default function PassengersPage() {
<div>
<h3 className="text-lg font-semibold mb-3">Identification</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-muted-foreground">National ID</label>
<p className="text-lg font-mono font-semibold">{selectedPassenger.nationalId || 'N/A'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Passport Number</label>
<p className="text-lg font-mono font-semibold">{selectedPassenger.passportNumber || 'N/A'}</p>

View File

@@ -50,6 +50,7 @@ export default function PricingPage() {
seatClassId: '',
baseFare: '',
nationality: '',
passengerCategory: '',
route: '',
validFrom: new Date().toISOString().split('T')[0],
validUntil: '',
@@ -61,6 +62,7 @@ export default function PricingPage() {
destinationStationId: '',
baseFare: '',
nationality: '',
passengerCategory: '',
validFrom: new Date().toISOString().split('T')[0],
validUntil: '',
});
@@ -87,7 +89,17 @@ export default function PricingPage() {
const { data: fares = [], isLoading: faresLoading, refetch: refetchFares } = useQuery({
queryKey: ['schedule-fares', selectedSchedule],
queryFn: () => (selectedSchedule ? apiClient.get(`/schedules/${selectedSchedule}/fares/all`) : Promise.resolve([])),
queryFn: async () => {
if (!selectedSchedule) return [];
try {
const response = await apiClient.get(`/schedules/${selectedSchedule}/fares/all`);
return Array.isArray(response) ? response : (response as any)?.data || [];
} catch (err: any) {
const errMsg = err.response?.data?.message || err.message || 'Failed to load fares';
setError(`Error loading fares: ${errMsg}`);
return [];
}
},
enabled: !!selectedSchedule && tab === 'schedule',
});
@@ -174,6 +186,7 @@ export default function PricingPage() {
seatClassId: '',
baseFare: '',
nationality: '',
passengerCategory: '',
route: '',
validFrom: new Date().toISOString().split('T')[0],
validUntil: '',
@@ -189,6 +202,7 @@ export default function PricingPage() {
destinationStationId: '',
baseFare: '',
nationality: '',
passengerCategory: '',
validFrom: new Date().toISOString().split('T')[0],
validUntil: '',
});
@@ -198,13 +212,11 @@ export default function PricingPage() {
const handleEditFare = (fare: any) => {
setEditingFare(fare);
const fareValue = fare.baseFare || fare.baseFareMinor || 0;
const etbValue = fareValue > 100 ? (fareValue / 100).toString() : fareValue.toString();
setFareForm({
seatClassId: fare.seatClassId || '',
baseFare: etbValue,
baseFare: (fare.baseFare || fare.baseFareMinor || 0).toString(),
nationality: fare.nationality || '',
passengerCategory: fare.passengerCategory || '',
route: fare.route || '',
validFrom: fare.validFrom ? new Date(fare.validFrom).toISOString().split('T')[0] : new Date().toISOString().split('T')[0],
validUntil: fare.validUntil ? new Date(fare.validUntil).toISOString().split('T')[0] : '',
@@ -215,9 +227,6 @@ export default function PricingPage() {
const handleEditSegmentFare = (fare: any) => {
setEditingFare(fare);
const fareValue = fare.baseFare || fare.baseFareMinor || 0;
const etbValue = fareValue > 100 ? (fareValue / 100).toString() : fareValue.toString();
const routeStops = currentRoute?.stops || [];
const originStop = routeStops.find((s: any) => s.sequence === fare.originStopSequence);
const destStop = routeStops.find((s: any) => s.sequence === fare.destinationStopSequence);
@@ -226,8 +235,9 @@ export default function PricingPage() {
seatClassId: fare.seatClassId || '',
originStationId: originStop?.stationId || '',
destinationStationId: destStop?.stationId || '',
baseFare: etbValue,
baseFare: (fare.baseFare || fare.baseFareMinor || 0).toString(),
nationality: fare.nationality || '',
passengerCategory: fare.passengerCategory || '',
validFrom: fare.validFrom ? new Date(fare.validFrom).toISOString().split('T')[0] : new Date().toISOString().split('T')[0],
validUntil: fare.validUntil ? new Date(fare.validUntil).toISOString().split('T')[0] : '',
});
@@ -242,7 +252,7 @@ export default function PricingPage() {
return;
}
const baseFareMinor = Math.round(parseFloat(fareForm.baseFare) * 100);
const baseFareMinor = parseInt(fareForm.baseFare, 10);
if (editingFare) {
await updateFareMutation.mutateAsync({
@@ -250,6 +260,7 @@ export default function PricingPage() {
seatClassId: fareForm.seatClassId,
baseFareMinor,
nationality: fareForm.nationality || undefined,
passengerCategory: fareForm.passengerCategory || undefined,
route: fareForm.route || undefined,
validFrom: fareForm.validFrom,
validUntil: fareForm.validUntil || undefined,
@@ -260,6 +271,7 @@ export default function PricingPage() {
seatClassId: fareForm.seatClassId,
baseFareMinor,
nationality: fareForm.nationality || undefined,
passengerCategory: fareForm.passengerCategory || undefined,
route: fareForm.route || undefined,
validFrom: fareForm.validFrom,
validUntil: fareForm.validUntil || undefined,
@@ -288,7 +300,7 @@ export default function PricingPage() {
return;
}
const baseFareMinor = Math.round(parseFloat(segmentForm.baseFare) * 100);
const baseFareMinor = parseInt(segmentForm.baseFare, 10);
if (editingFare) {
await updateSegmentFareMutation.mutateAsync({
@@ -299,6 +311,7 @@ export default function PricingPage() {
destinationStopSequence: destStop.sequence,
baseFareMinor,
nationality: segmentForm.nationality || undefined,
passengerCategory: segmentForm.passengerCategory || undefined,
validFrom: segmentForm.validFrom,
validUntil: segmentForm.validUntil || undefined,
});
@@ -310,6 +323,7 @@ export default function PricingPage() {
destinationStopSequence: destStop.sequence,
baseFareMinor,
nationality: segmentForm.nationality || undefined,
passengerCategory: segmentForm.passengerCategory || undefined,
validFrom: segmentForm.validFrom,
validUntil: segmentForm.validUntil || undefined,
});
@@ -343,14 +357,20 @@ export default function PricingPage() {
return <span className="font-medium">{className}</span>;
},
},
{
key: 'passengerCategory',
label: 'Passenger Type',
render: (fare: any) => (
<span className="text-sm">{fare.passengerCategory || 'All'}</span>
),
},
{
key: 'baseFare',
label: 'Fare (ETB)',
render: (fare: any) => {
const fareValue = fare.baseFare || fare.baseFareMinor;
if (!fareValue && fareValue !== 0) return <span>N/A</span>;
const etbValue = fareValue > 100 ? (fareValue / 100).toFixed(2) : parseFloat(fareValue).toFixed(2);
return <span className="font-mono font-medium">{etbValue} ETB</span>;
return <span className="font-mono font-medium">{fareValue} ETB</span>;
},
},
{
@@ -408,14 +428,20 @@ export default function PricingPage() {
return <span className="font-medium">{className}</span>;
},
},
{
key: 'passengerCategory',
label: 'Passenger Type',
render: (fare: any) => (
<span className="text-sm">{fare.passengerCategory || 'All'}</span>
),
},
{
key: 'baseFare',
label: 'Fare (ETB)',
render: (fare: any) => {
const fareValue = fare.baseFare || fare.baseFareMinor;
if (!fareValue && fareValue !== 0) return <span>N/A</span>;
const etbValue = fareValue > 100 ? (fareValue / 100).toFixed(2) : parseFloat(fareValue).toFixed(2);
return <span className="font-mono font-medium">{etbValue} ETB</span>;
return <span className="font-mono font-medium">{fareValue} ETB</span>;
},
},
{
@@ -449,12 +475,14 @@ export default function PricingPage() {
onClick: tab === 'schedule' ? handleEditFare : handleEditSegmentFare,
variant: 'secondary' as const,
icon: Edit,
disabled: tab === 'schedule', // Schedule fares are computed, not stored
},
{
label: 'Delete',
onClick: (fare: any) => setDeleteConfirm({ isOpen: true, id: fare.id }),
variant: 'danger' as const,
icon: Trash2,
disabled: tab === 'schedule', // Schedule fares are computed, not stored
},
];
@@ -463,7 +491,7 @@ export default function PricingPage() {
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground">Pricing & Fares</h1>
<p className="text-muted-foreground mt-1">Manage fares by schedule and route segments</p>
<p className="text-muted-foreground mt-1">Manage fares by schedule and route segments with passenger type pricing</p>
</div>
<ActionButton
icon={Plus}
@@ -475,6 +503,7 @@ export default function PricingPage() {
seatClassId: '',
baseFare: '',
nationality: '',
passengerCategory: '',
route: '',
validFrom: new Date().toISOString().split('T')[0],
validUntil: '',
@@ -486,6 +515,7 @@ export default function PricingPage() {
destinationStationId: '',
baseFare: '',
nationality: '',
passengerCategory: '',
validFrom: new Date().toISOString().split('T')[0],
validUntil: '',
});
@@ -547,26 +577,29 @@ export default function PricingPage() {
{selectedSchedule && (
<div>
<h3 className="text-lg font-semibold mb-4">Fare Rules</h3>
<h3 className="text-lg font-semibold mb-4">Calculated Fares</h3>
<div className="mb-4 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded text-sm text-blue-800 dark:text-blue-200">
These are <strong>dynamically calculated</strong> fares based on the fare engine. To create custom override fares, click "Add Fare Rule" above.
</div>
{faresLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : faresArray.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{`No fares defined. Click "Add Fare Rule" to create one.`}
No fares available for this schedule.
</div>
) : (
<>
<div className="mb-4 p-3 bg-muted/50 rounded text-sm text-muted-foreground">
{faresArray.length} fare rule(s) found
{faresArray.length} seat class(es) available
</div>
<DataTable
data={faresArray}
columns={fareColumns}
actions={fareActions}
loading={false}
emptyMessage="No fares found."
emptyMessage="No fares available."
/>
</>
)}
@@ -632,16 +665,16 @@ export default function PricingPage() {
<h3 className="font-semibold text-blue-900 dark:text-blue-200 mb-3">Pricing Structure</h3>
<ul className="text-sm text-blue-800 dark:text-blue-300 space-y-2">
<li>
<strong>Schedule Fares:</strong> Set custom pricing for each schedule by seat class
<strong>Schedule Fares:</strong> Set custom pricing for each schedule by seat class and passenger type
</li>
<li>
<strong>Segment Fares:</strong> Set fares for specific stop-to-stop segments (e.g., Addis Dire Dawa)
</li>
<li>
<strong>Nationality-based:</strong> Override fares for specific nationalities
<strong>Passenger Type:</strong> ADULT (5+ years) or CHILD (&lt;5) first child travels free, subsequent children pay full fare
</li>
<li>
<strong>Age-Based Pricing:</strong> ADULT (5+ years) pays 100%, CHILD (&lt;5) first child FREE, subsequent children 100%
<strong>Nationality-based:</strong> Override fares for specific nationalities (Ethiopian, Djiboutian, Other)
</li>
</ul>
</div>
@@ -732,28 +765,44 @@ export default function PricingPage() {
<input
type="number"
min="0"
step="0.01"
step="1"
value={fareForm.baseFare}
onChange={(e) => setFareForm({ ...fareForm, baseFare: e.target.value })}
className="input w-full"
placeholder="e.g., 350.00"
placeholder="e.g., 350"
required
/>
</div>
</div>
<div>
<label className="label">Nationality (Optional)</label>
<select
value={fareForm.nationality}
onChange={(e) => setFareForm({ ...fareForm, nationality: e.target.value })}
className="input w-full"
>
<option value="">All Nationalities</option>
<option value="Ethiopian">Ethiopian</option>
<option value="Djiboutian">Djiboutian</option>
<option value="Other">Other</option>
</select>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="label">Passenger Type (Optional)</label>
<select
value={fareForm.passengerCategory}
onChange={(e) => setFareForm({ ...fareForm, passengerCategory: e.target.value })}
className="input w-full"
>
<option value="">All Passenger Types</option>
<option value="ADULT">Adult (5+ years)</option>
<option value="CHILD">Child (Less than 5 years)</option>
</select>
<p className="text-xs text-muted-foreground mt-1">Scope pricing to specific passenger type</p>
</div>
<div>
<label className="label">Nationality (Optional)</label>
<select
value={fareForm.nationality}
onChange={(e) => setFareForm({ ...fareForm, nationality: e.target.value })}
className="input w-full"
>
<option value="">All Nationalities</option>
<option value="Ethiopian">Ethiopian</option>
<option value="Djiboutian">Djiboutian</option>
<option value="Other">Other</option>
</select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
@@ -846,28 +895,44 @@ export default function PricingPage() {
<input
type="number"
min="0"
step="0.01"
step="1"
value={segmentForm.baseFare}
onChange={(e) => setSegmentForm({ ...segmentForm, baseFare: e.target.value })}
className="input w-full"
placeholder="e.g., 150.00"
placeholder="e.g., 150"
required
/>
</div>
</div>
<div>
<label className="label">Nationality (Optional)</label>
<select
value={segmentForm.nationality}
onChange={(e) => setSegmentForm({ ...segmentForm, nationality: e.target.value })}
className="input w-full"
>
<option value="">All Nationalities</option>
<option value="Ethiopian">Ethiopian</option>
<option value="Djiboutian">Djiboutian</option>
<option value="Other">Other</option>
</select>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="label">Passenger Type (Optional)</label>
<select
value={segmentForm.passengerCategory}
onChange={(e) => setSegmentForm({ ...segmentForm, passengerCategory: e.target.value })}
className="input w-full"
>
<option value="">All Passenger Types</option>
<option value="ADULT">Adult (5+ years)</option>
<option value="CHILD">Child (Less than 5 years)</option>
</select>
<p className="text-xs text-muted-foreground mt-1">Scope pricing to specific passenger type</p>
</div>
<div>
<label className="label">Nationality (Optional)</label>
<select
value={segmentForm.nationality}
onChange={(e) => setSegmentForm({ ...segmentForm, nationality: e.target.value })}
className="input w-full"
>
<option value="">All Nationalities</option>
<option value="Ethiopian">Ethiopian</option>
<option value="Djiboutian">Djiboutian</option>
<option value="Other">Other</option>
</select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
@@ -900,7 +965,8 @@ export default function PricingPage() {
<ul className="space-y-1">
<li> <strong>Schedule:</strong> Apply to specific schedule only</li>
<li> <strong>Route Code:</strong> Apply to all schedules on that route</li>
<li> <strong>Nationality:</strong> Override for specific passenger nationalities</li>
<li> <strong>Passenger Type:</strong> ADULT or CHILD pricing</li>
<li> <strong>Nationality:</strong> Override for specific nationalities</li>
<li> <strong>All empty:</strong> Apply globally to all schedules</li>
</ul>
)}
@@ -908,6 +974,7 @@ export default function PricingPage() {
<ul className="space-y-1">
<li> <strong>Segments:</strong> Define pricing for specific stop-to-stop segments</li>
<li> <strong>Stops:</strong> Use sequence numbers from the route</li>
<li> <strong>Passenger Type:</strong> ADULT or CHILD pricing</li>
<li> <strong>Nationality:</strong> Optional scope to specific nationalities</li>
</ul>
)}

View File

@@ -1,124 +1,316 @@
'use client';
import { useState } from 'react';
import { Download, Calendar } from 'lucide-react';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts';
import { formatCurrency } from '@/lib/utils';
import { useQuery } from '@tanstack/react-query';
import { Download, TrendingUp, Users, DollarSign, AlertCircle } from 'lucide-react';
import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts';
import { bookingsApi } from '@/lib/api';
import ActionButton from '@/components/ui/ActionButton';
const revenueByRoute = [
{ route: 'Addis - Djibouti', revenue: 125000000 },
{ route: 'Addis - Dire Dawa', revenue: 85000000 },
{ route: 'Dire Dawa - Djibouti', revenue: 45000000 },
];
const bookingsByClass = [
{ name: 'Economy Regular', value: 65, color: '#3b82f6' },
{ name: 'Economy Bed', value: 25, color: '#10b981' },
{ name: 'VIP Bed', value: 10, color: '#f59e0b' },
];
const occupancyData = [
{ month: 'Jan', rate: 72 },
{ month: 'Feb', rate: 78 },
{ month: 'Mar', rate: 85 },
{ month: 'Apr', rate: 82 },
{ month: 'May', rate: 88 },
{ month: 'Jun', rate: 91 },
];
const COLORS = ['#3b82f6', '#10b981', '#f59e0b'];
export default function ReportsPage() {
const [dateRange, setDateRange] = useState('last-30-days');
const [dateRange, setDateRange] = useState('30');
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const getDateRange = () => {
const end = new Date();
end.setHours(23, 59, 59, 999);
const start = new Date();
switch (dateRange) {
case '7':
start.setDate(end.getDate() - 7);
break;
case '30':
start.setDate(end.getDate() - 30);
break;
case '90':
start.setDate(end.getDate() - 90);
break;
default:
if (startDate && endDate) {
return { startDate, endDate };
}
}
return {
startDate: start.toISOString().split('T')[0],
endDate: end.toISOString().split('T')[0],
};
};
const dates = getDateRange();
// Fetch all bookings
const { data: bookingsData, isLoading } = useQuery({
queryKey: ['all-bookings'],
queryFn: () => bookingsApi.getAll({ pageSize: 1000 }),
});
// Filter bookings by date range
const bookings = Array.isArray(bookingsData?.items)
? bookingsData.items.filter((b: any) => {
const bookingDate = new Date(b.createdAt).toISOString().split('T')[0];
return bookingDate >= dates.startDate && bookingDate <= dates.endDate;
})
: [];
// Calculate metrics
const totalRevenue = bookings.reduce((sum: number, b: any) => sum + (b.totalMinor || 0), 0);
const totalBookings = bookings.length;
const avgTicketPrice = totalBookings > 0 ? Math.round(totalRevenue / totalBookings) : 0;
// Group by date for revenue chart
const byDate = bookings.reduce((acc: Record<string, any>, b: any) => {
const date = new Date(b.createdAt).toISOString().split('T')[0];
if (!acc[date]) {
acc[date] = { totalMinor: 0, count: 0 };
}
acc[date].totalMinor += b.totalMinor || 0;
acc[date].count += 1;
return acc;
}, {} as Record<string, any>);
const chartData = Object.entries(byDate)
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, d]: [string, any]) => ({
date: new Date(date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
revenue: (d.totalMinor || 0) / 100,
bookings: d.count || 0,
}));
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Reports & Analytics</h1>
<p className="text-gray-600">View detailed reports and analytics</p>
</div>
<div className="flex gap-2">
<select className="input w-48" value={dateRange} onChange={(e) => setDateRange(e.target.value)}>
<option value="last-7-days">Last 7 Days</option>
<option value="last-30-days">Last 30 Days</option>
<option value="last-90-days">Last 90 Days</option>
<option value="custom">Custom Range</option>
</select>
<button className="btn btn-primary flex items-center gap-2">
<Download className="h-4 w-4" />
Export Report
</button>
</div>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div className="card">
<h3 className="mb-4 text-lg font-semibold text-gray-900">Revenue by Route</h3>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={revenueByRoute}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="route" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} />
<Tooltip formatter={(value: number) => formatCurrency(value, 'ETB')} />
<Bar dataKey="revenue" fill="#2563eb" />
</BarChart>
</ResponsiveContainer>
</div>
<div className="card">
<h3 className="mb-4 text-lg font-semibold text-gray-900">Bookings by Class</h3>
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={bookingsByClass}
cx="50%"
cy="50%"
labelLine={false}
label={({ name, value }) => `${name}: ${value}%`}
outerRadius={100}
fill="#8884d8"
dataKey="value"
>
{bookingsByClass.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</div>
<div className="card lg:col-span-2">
<h3 className="mb-4 text-lg font-semibold text-gray-900">Occupancy Rate Trend</h3>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={occupancyData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="month" />
<YAxis />
<Tooltip formatter={(value: number) => `${value}%`} />
<Bar dataKey="rate" fill="#10b981" />
</BarChart>
</ResponsiveContainer>
</div>
<div>
<h1 className="text-3xl font-bold text-foreground">Reports & Analytics</h1>
<p className="text-muted-foreground mt-1">View detailed reports and performance metrics</p>
</div>
{/* Date Range Selector */}
<div className="card">
<h3 className="mb-4 text-lg font-semibold text-gray-900">Quick Stats</h3>
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
<div className="rounded-lg bg-blue-50 p-4">
<p className="text-sm text-blue-600">Total Revenue</p>
<p className="mt-1 text-2xl font-bold text-blue-900">{formatCurrency(255000000, 'ETB')}</p>
<div className="flex items-end gap-4 flex-wrap">
<div>
<label className="label">Date Range</label>
<select
className="input"
value={dateRange}
onChange={(e) => setDateRange(e.target.value)}
disabled={isLoading}
>
<option value="7">Last 7 Days</option>
<option value="30">Last 30 Days</option>
<option value="90">Last 90 Days</option>
<option value="custom">Custom Range</option>
</select>
</div>
<div className="rounded-lg bg-green-50 p-4">
<p className="text-sm text-green-600">Total Bookings</p>
<p className="mt-1 text-2xl font-bold text-green-900">1,247</p>
{dateRange === 'custom' && (
<>
<div>
<label className="label">Start Date</label>
<input
type="date"
className="input"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
disabled={isLoading}
/>
</div>
<div>
<label className="label">End Date</label>
<input
type="date"
className="input"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
disabled={isLoading}
/>
</div>
</>
)}
<ActionButton icon={Download} variant="secondary" disabled={isLoading}>
Export
</ActionButton>
</div>
{isLoading && (
<p className="text-xs text-muted-foreground mt-2">Loading...</p>
)}
</div>
{/* Key Metrics */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="card">
<div className="flex items-start justify-between">
<div>
<p className="text-muted-foreground text-sm font-medium">Total Revenue</p>
<p className="text-2xl font-bold mt-2">ETB {Math.round(totalRevenue / 100).toLocaleString()}</p>
<p className="text-xs text-muted-foreground mt-1">Last {dateRange} days</p>
</div>
<DollarSign className="h-8 w-8 text-blue-500 opacity-20" />
</div>
<div className="rounded-lg bg-purple-50 p-4">
<p className="text-sm text-purple-600">Avg. Ticket Price</p>
<p className="mt-1 text-2xl font-bold text-purple-900">{formatCurrency(42500, 'ETB')}</p>
</div>
<div className="card">
<div className="flex items-start justify-between">
<div>
<p className="text-muted-foreground text-sm font-medium">Total Bookings</p>
<p className="text-2xl font-bold mt-2">{totalBookings.toLocaleString()}</p>
<p className="text-xs text-muted-foreground mt-1">All bookings</p>
</div>
<Users className="h-8 w-8 text-green-500 opacity-20" />
</div>
<div className="rounded-lg bg-green-50 p-4">
<p className="text-sm text-[rgb(20,113,76)]">Cancellation Rate</p>
<p className="mt-1 text-2xl font-bold text-green-900">3.2%</p>
</div>
<div className="card">
<div className="flex items-start justify-between">
<div>
<p className="text-muted-foreground text-sm font-medium">Avg. Ticket Price</p>
<p className="text-2xl font-bold mt-2">ETB {(avgTicketPrice / 100).toLocaleString()}</p>
<p className="text-xs text-muted-foreground mt-1">Per booking</p>
</div>
<TrendingUp className="h-8 w-8 text-purple-500 opacity-20" />
</div>
</div>
<div className="card">
<div className="flex items-start justify-between">
<div>
<p className="text-muted-foreground text-sm font-medium">Avg. Daily Revenue</p>
<p className="text-2xl font-bold mt-2">ETB {chartData.length > 0 ? Math.round((totalRevenue / 100) / chartData.length).toLocaleString() : '0'}</p>
<p className="text-xs text-muted-foreground mt-1">Daily average</p>
</div>
<AlertCircle className="h-8 w-8 text-orange-500 opacity-20" />
</div>
</div>
</div>
{/* Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Revenue Trend */}
<div className="card">
<h3 className="text-lg font-semibold mb-4">Revenue Trend</h3>
{chartData.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="date" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} />
<Tooltip formatter={(value: number) => `ETB ${Math.round(value).toLocaleString()}`} />
<Legend />
<Line type="monotone" dataKey="revenue" stroke="#3b82f6" dot={{ r: 5 }} activeDot={{ r: 7 }} strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
) : (
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
No data available
</div>
)}
</div>
{/* Daily Bookings */}
<div className="card">
<h3 className="text-lg font-semibold mb-4">Daily Bookings</h3>
{chartData.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="date" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} />
<Tooltip />
<Bar dataKey="bookings" fill="#10b981" />
</BarChart>
</ResponsiveContainer>
) : (
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
No data available
</div>
)}
</div>
{/* Booking Status Distribution */}
<div className="card">
<h3 className="text-lg font-semibold mb-4">Booking Status</h3>
{bookings.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={[
{ name: 'Confirmed', value: bookings.filter((b: any) => b.status === 'CONFIRMED').length },
{ name: 'Completed', value: bookings.filter((b: any) => b.status === 'COMPLETED').length },
{ name: 'Cancelled', value: bookings.filter((b: any) => b.status === 'CANCELLED').length },
{ name: 'Other', value: bookings.filter((b: any) => !['CONFIRMED', 'COMPLETED', 'CANCELLED'].includes(b.status)).length },
].filter(d => d.value > 0)}
cx="50%"
cy="50%"
labelLine={false}
label={({ name, value }) => `${name}: ${value}`}
outerRadius={100}
dataKey="value"
>
{COLORS.map((color, idx) => <Cell key={idx} fill={color} />)}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
) : (
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
No data available
</div>
)}
</div>
{/* Top Payment Methods */}
<div className="card">
<h3 className="text-lg font-semibold mb-4">Payment Methods</h3>
{bookings.length > 0 ? (
<div className="space-y-3">
{(Object.entries(
bookings.reduce((acc: Record<string, number>, b: any) => {
const method = b.paymentIntent?.method || 'Unknown';
acc[method] = (acc[method] || 0) + 1;
return acc;
}, {} as Record<string, number>)
) as [string, number][]
)
.sort(([, a], [, b]) => b - a)
.slice(0, 5)
.map(([method, count]) => (
<div key={method} className="flex justify-between items-center p-2 bg-gray-50 dark:bg-gray-900 rounded">
<span className="text-sm capitalize">{method.toLowerCase().replace(/_/g, ' ')}</span>
<span className="font-semibold">{count}</span>
</div>
))}
</div>
) : (
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
No data available
</div>
)}
</div>
</div>
{/* Summary Stats */}
<div className="card">
<h3 className="text-lg font-semibold mb-4">Summary</h3>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<p className="text-sm text-muted-foreground">Total Days with Bookings</p>
<p className="text-xl font-bold mt-2">{chartData.length}</p>
</div>
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<p className="text-sm text-muted-foreground">Confirmed Bookings</p>
<p className="text-xl font-bold mt-2">{bookings.filter((b: any) => b.status === 'CONFIRMED').length}</p>
</div>
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<p className="text-sm text-muted-foreground">Completed Bookings</p>
<p className="text-xl font-bold mt-2">{bookings.filter((b: any) => b.status === 'COMPLETED').length}</p>
</div>
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<p className="text-sm text-muted-foreground">Cancelled Bookings</p>
<p className="text-xl font-bold mt-2">{bookings.filter((b: any) => b.status === 'CANCELLED').length}</p>
</div>
</div>
</div>

View File

@@ -220,28 +220,15 @@ export default function RoutesPage() {
setOriginStationId(routeStops[0].stationId);
setDestinationStationId(routeStops[routeStops.length - 1].stationId);
// Calculate cumulative distance for destination
let cumulativeDistance = 0;
routeStops.forEach((stop: any, idx: number) => {
if (idx > 0) {
cumulativeDistance += stop.distanceKm || 0;
}
});
setDestinationDistance(cumulativeDistance);
// Calculate distance from origin for middle stops
const middleStops = routeStops.slice(1, -1).map((stop: any, idx: number) => {
let distFromOrigin = 0;
for (let i = 1; i <= idx + 1; i++) {
distFromOrigin += routeStops[i].distanceKm || 0;
}
return {
stationId: stop.stationId,
sequence: stop.sequence,
distanceKm: stop.distanceKm,
distanceFromOrigin: distFromOrigin,
};
});
// Last stop's distanceKm is already cumulative from origin
setDestinationDistance(routeStops[routeStops.length - 1].distanceKm || 0);
const middleStops = routeStops.slice(1, -1).map((stop: any) => ({
stationId: stop.stationId,
sequence: stop.sequence,
distanceKm: stop.distanceKm,
distanceFromOrigin: stop.distanceKm || 0,
}));
setStops(middleStops);
}
setShowModal(true);

View File

@@ -1,18 +1,24 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { seatsApi, schedulesApi } from '@/lib/api';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { seatsApi, schedulesApi, fleetApi } from '@/lib/api';
import Modal from '@/components/ui/Modal';
import ActionButton from '@/components/ui/ActionButton'
import { Armchair, Lock, Unlock, Bed, X, RotateCcw } from 'lucide-react';
import { Armchair, Lock, Unlock, Bed, X, RotateCcw, ChevronDown, Train } from 'lucide-react';
export default function SeatsPage() {
const [selectedSchedule, setSelectedSchedule] = useState('');
const [expandedCoaches, setExpandedCoaches] = useState<Set<string>>(new Set());
const [showBlockModal, setShowBlockModal] = useState(false);
const [showRemoveModal, setShowRemoveModal] = useState(false);
const [selectedSeat, setSelectedSeat] = useState<any>(null);
const [blockReason, setBlockReason] = useState('');
const [showBlockCoachModal, setShowBlockCoachModal] = useState(false);
const [selectedCoach, setSelectedCoach] = useState<any>(null);
const [blockCoachReason, setBlockCoachReason] = useState('');
const [showUnblockCoachModal, setShowUnblockCoachModal] = useState(false);
const [coachToUnblock, setCoachToUnblock] = useState<any>(null);
const queryClient = useQueryClient();
const { data: schedulesData } = useQuery({
@@ -26,6 +32,11 @@ export default function SeatsPage() {
enabled: !!selectedSchedule,
});
const { data: coachTypesData } = useQuery({
queryKey: ['coachTypes'],
queryFn: () => fleetApi.getCoaches(),
});
const blockMutation = useMutation({
mutationFn: ({ seatId, reason }: any) => seatsApi.block(seatId, { reason }),
onSuccess: () => {
@@ -62,6 +73,43 @@ export default function SeatsPage() {
const schedules = schedulesData?.items || schedulesData?.data || [];
const coaches = seatMapData?.coaches || [];
const blockCoachMutation = useMutation({
mutationFn: async ({ coachId, reason }: any) => {
const coachSeats = coaches.find((c: any) => c.id === coachId)?.seats || [];
const seatIds = coachSeats.map((s: any) => s.id).filter((id: any) => id);
return Promise.all(seatIds.map((seatId: string) => seatsApi.block(seatId, { reason })));
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['seatmap'] });
setShowBlockCoachModal(false);
setSelectedCoach(null);
setBlockCoachReason('');
},
});
const unblockCoachMutation = useMutation({
mutationFn: async ({ coachId }: any) => {
const coachSeats = coaches.find((c: any) => c.id === coachId)?.seats || [];
const seatIds = coachSeats.map((s: any) => s.id).filter((id: any) => id);
return Promise.all(seatIds.map((seatId: string) => seatsApi.unblock(seatId)));
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['seatmap'] });
setShowUnblockCoachModal(false);
setCoachToUnblock(null);
},
});
const toggleCoach = (coachId: string) => {
const newExpanded = new Set(expandedCoaches);
if (newExpanded.has(coachId)) {
newExpanded.delete(coachId);
} else {
newExpanded.add(coachId);
}
setExpandedCoaches(newExpanded);
};
const handleBlock = (seat: any) => {
setSelectedSeat(seat);
setShowBlockModal(true);
@@ -84,6 +132,37 @@ export default function SeatsPage() {
}
};
const handleBlockCoach = (coach: any) => {
setSelectedCoach(coach);
setShowBlockCoachModal(true);
};
const handleUnblockCoach = (coach: any) => {
const isBlocked = coach.seats?.some((s: any) => s.status === 'BLOCKED' || s.isBlocked);
if (isBlocked) {
setCoachToUnblock(coach);
setShowUnblockCoachModal(true);
}
};
const confirmUnblockCoach = async () => {
if (coachToUnblock) {
await unblockCoachMutation.mutateAsync({ coachId: coachToUnblock.id });
}
};
const isCoachBlocked = (coach: any) => {
return coach.seats?.some((s: any) => s.status === 'BLOCKED' || s.isBlocked);
};
const submitBlockCoach = async () => {
if (!blockCoachReason.trim()) {
alert('Please provide a reason for blocking');
return;
}
await blockCoachMutation.mutateAsync({ coachId: selectedCoach.id, reason: blockCoachReason });
};
const submitBlock = async () => {
if (!blockReason.trim()) {
alert('Please provide a reason for blocking');
@@ -126,6 +205,12 @@ export default function SeatsPage() {
return '';
};
const formatBedSeatNumber = (seat: any): string => {
if (!seat.seatNumber || !seat.bedPosition) return seat.seatNumber || '';
const label = getBedLabel(seat.bedPosition);
return `${seat.seatNumber}${label}`;
};
const renderCoachSeats = (coach: any, isBedCoach: boolean) => {
const allSeats = coach.seats || [];
const validSeats = allSeats.filter((s: any) => s.seatNumber && !s.seatNumber.startsWith('-'));
@@ -138,14 +223,13 @@ export default function SeatsPage() {
const hasBedPositionData = validSeats.some((s: any) => s.bedPosition);
if (isBedCoach && hasBedPositionData) {
// Render bed coach with flipping effect and bed position labels
const arrangement = parseSeatArrangement(coach.seatArrangement);
const seatsPerRow = arrangement[0] + (arrangement[1] || 0);
const allSeatsForLayout = [...validSeats, ...removedSeats];
const rows = [];
const rows: any[][] = [];
const seatClassStr = typeof coach?.seatClass === 'string' ? coach.seatClass : (coach?.seatClass?.name || '');
const isVipBed = seatClassStr.toLowerCase().includes('vip');
const bedWidth = isVipBed ? 'w-24' : 'w-16';
const bedWidth = isVipBed ? 'w-20' : 'w-16';
for (let i = 0; i < allSeatsForLayout.length; i += seatsPerRow) {
rows.push(allSeatsForLayout.slice(i, i + seatsPerRow));
@@ -154,23 +238,14 @@ export default function SeatsPage() {
return (
<div className="space-y-0">
{rows.map((rowSeats: any[], idx: number) => {
const rowNumber = rowSeats[0]?.row || (idx + 1);
const shouldFlipIcon = rowNumber % 2 === 0;
const shouldFlipRow = rowNumber % 2 === 1;
const showSpacing = idx % 2 === 1;
const isFirstInPair = idx % 2 === 0;
const shouldFlipIcon = !isFirstInPair;
const isLastRow = idx === rows.length - 1;
const nextRowSeats = !isLastRow ? rows[idx + 1] : null;
return (
<div key={`bed-row-${idx}`}>
{shouldFlipIcon && (
<div className="flex gap-0.5 justify-start text-xs text-muted-foreground mb-1">
{rowSeats.map((seat: any) => (
<div key={`num-before-${seat.id}`} className={`${bedWidth} h-4 flex items-center justify-center text-xs font-bold mb-0.5 h-3 leading-3 text-foreground`}>
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''}
</div>
))}
</div>
)}
<div className="flex gap-0.5 justify-start">
<div className="flex gap-0.5 justify-center">
{rowSeats.map((seat: any) => (
<SeatIcon
key={seat.id}
@@ -188,16 +263,23 @@ export default function SeatsPage() {
/>
))}
</div>
{!shouldFlipIcon && (
<div className="flex gap-0.5 justify-start text-xs text-muted-foreground mb-1">
{rowSeats.map((seat: any) => (
<div key={`num-after-${seat.id}`} className={`${bedWidth} h-4 flex items-center justify-center text-xs font-bold mb-0.5 h-3 leading-3 text-foreground`}>
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? `${seat.seatNumber}${getBedLabel(seat.bedPosition)}` : ''}
</div>
))}
{isFirstInPair && nextRowSeats && (
<div className="flex gap-0.5 justify-center text-xs my-1">
{rowSeats.map((seat: any, seatIdx: number) => {
const currentSeat = rowSeats[seatIdx];
const nextSeat = nextRowSeats[seatIdx];
const currentFormatted = currentSeat ? formatBedSeatNumber(currentSeat) : '';
const nextFormatted = nextSeat ? formatBedSeatNumber(nextSeat) : '';
return (
<div key={`num-between-${seat.id}`} className={`${bedWidth} flex flex-col items-center justify-center text-xs font-bold mb-1 leading-3 text-foreground`}>
<div className="mb-1">{currentFormatted}</div>
<div className="mt-1">{nextFormatted}</div>
</div>
);
})}
</div>
)}
{showSpacing && <div className="h-2" />}
{!isFirstInPair && <div className="h-2" />}
</div>
);
})}
@@ -205,7 +287,6 @@ export default function SeatsPage() {
);
}
// Regular armchair layout
const arrangement = parseSeatArrangement(coach.seatArrangement);
const leftCount = arrangement[0];
const rightCount = arrangement[1] || 0;
@@ -231,25 +312,24 @@ export default function SeatsPage() {
const rightSeats = rowSeats.slice(leftCount);
const rowNumber = rowSeats[0]?.row || 1;
const shouldFlipArmchair = rowNumber % 2 === 0;
const shouldFlipRow = rowNumber % 2 === 0;
const showSpacing = rowIdx % 2 === 1;
return (
<div key={`row-${rowSeats[0]?.id}`}>
{shouldFlipArmchair && (
<div className="flex gap-0.5 justify-start text-xs text-muted-foreground mb-1">
<div className="flex gap-0.5 justify-center text-xs text-muted-foreground mb-1">
<div className="flex gap-0.5">
{leftSeats.map((seat: any) => (
<div key={`num-before-left-${seat.id}`} className="w-11 h-4 flex items-center justify-center text-xs font-bold mb-0.5 h-3 leading-3 text-foreground">
<div key={`num-before-left-${seat.id}`} className="w-11 h-4 flex items-center justify-center text-xs font-bold mb-0.5 leading-3 text-foreground">
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
</div>
))}
</div>
{rightSeats.length > 0 && <div className="w-3" />}
{rightSeats.length > 0 && <div className="w-8" />}
{rightSeats.length > 0 && (
<div className="flex gap-0.5">
{rightSeats.map((seat: any) => (
<div key={`num-before-right-${seat.id}`} className="w-11 h-4 flex items-center justify-center text-xs font-bold mb-0.5 h-3 leading-3 text-foreground">
<div key={`num-before-right-${seat.id}`} className="w-11 h-4 flex items-center justify-center text-xs font-bold mb-0.5 leading-3 text-foreground">
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
</div>
))}
@@ -257,7 +337,7 @@ export default function SeatsPage() {
)}
</div>
)}
<div className="flex gap-0.5 justify-start">
<div className="flex gap-0.5 justify-center">
<div className="flex gap-0.5">
{leftSeats.map((seat: any) => (
<SeatIcon
@@ -276,7 +356,7 @@ export default function SeatsPage() {
/>
))}
</div>
{rightSeats.length > 0 && <div className="w-3" />}
{rightSeats.length > 0 && <div className="w-8" />}
{rightSeats.length > 0 && (
<div className="flex gap-0.5">
{rightSeats.map((seat: any) => (
@@ -300,19 +380,19 @@ export default function SeatsPage() {
</div>
{!shouldFlipArmchair && (
<div className="flex gap-0.5 justify-start text-xs text-muted-foreground mb-1">
<div className="flex gap-0.5 justify-center text-xs text-muted-foreground mb-1">
<div className="flex gap-0.5">
{leftSeats.map((seat: any) => (
<div key={`num-left-${seat.id}`} className="w-11 h-4 flex items-center justify-center text-xs font-bold mb-0.5 h-3 leading-3 text-foreground">
<div key={`num-left-${seat.id}`} className="w-11 h-4 flex items-center justify-center text-xs font-bold mb-0.5 leading-3 text-foreground">
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
</div>
))}
</div>
{rightSeats.length > 0 && <div className="w-3" />}
{rightSeats.length > 0 && <div className="w-8" />}
{rightSeats.length > 0 && (
<div className="flex gap-0.5">
{rightSeats.map((seat: any) => (
<div key={`num-right-${seat.id}`} className="w-11 h-4 flex items-center justify-center text-xs font-bold mb-0.5 h-3 leading-3 text-foreground">
<div key={`num-right-${seat.id}`} className="w-11 h-4 flex items-center justify-center text-xs font-bold mb-0.5 leading-3 text-foreground">
{seat.seatNumber && !seat.seatNumber.startsWith('-') ? seat.seatNumber : ''}
</div>
))}
@@ -329,22 +409,22 @@ export default function SeatsPage() {
);
};
const coachesWithSeats = coaches.filter((coach: any) => {
const seats = (coach.seats || []).filter((s: any) => s.seatNumber);
return seats.length > 0;
});
const coachesWithSeats = coaches
.filter((coach: any) => {
const seats = (coach.seats || []).filter((s: any) => s.seatNumber);
return seats.length > 0;
})
.sort((a: any, b: any) => (a.sequence || 0) - (b.sequence || 0));
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground">Seat Management</h1>
<p className="text-muted-foreground mt-1">View and manage seat availability by schedule</p>
</div>
<div>
<h1 className="text-3xl font-bold text-foreground">Seat Management</h1>
<p className="text-muted-foreground mt-1">View and manage seats by coach</p>
</div>
<div className="card">
<div className="mb-6">
{!selectedSchedule ? (
<div className="card">
<label className="label">Select Schedule</label>
<select
value={selectedSchedule}
@@ -363,69 +443,122 @@ export default function SeatsPage() {
);
})}
</select>
</div>
{!selectedSchedule ? (
<div className="text-center py-12 text-muted-foreground">
<div className="text-center py-12 text-muted-foreground mt-8">
<Armchair className="h-12 w-12 mx-auto mb-3 opacity-50" />
<p>Select a schedule to view seat map</p>
</div>
) : isLoading ? (
<div className="text-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
<p className="text-muted-foreground mt-3">Loading seats...</p>
</div>
) : coachesWithSeats.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<p>No coaches with seats found for this schedule</p>
</div>
) : (
<div className="space-y-6">
<div className="flex items-center gap-6 p-4 bg-muted/50 rounded-lg">
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-green-500"></div>
<span className="text-sm">Available</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-red-500"></div>
<span className="text-sm">Booked</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-yellow-500"></div>
<span className="text-sm">Held</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-gray-500"></div>
<span className="text-sm">Blocked</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded border-2 border-dashed border-gray-400"></div>
<span className="text-sm">Removed</span>
</div>
</div>
) : isLoading ? (
<div className="card text-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
<p className="text-muted-foreground mt-3">Loading seats...</p>
</div>
) : coachesWithSeats.length === 0 ? (
<div className="card text-center py-12 text-muted-foreground">
<p>No coaches with seats found for this schedule</p>
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="card h-fit sticky top-6 space-y-6">
<div>
<label className="label">Select Schedule</label>
<select
value={selectedSchedule}
onChange={(e) => setSelectedSchedule(e.target.value)}
className="input"
>
<option value="">Select a schedule...</option>
{schedules.map((schedule: any) => {
const trainNumber = schedule.train?.trainNumber || schedule.train?.name || 'N/A';
const routeName = schedule.route?.name || 'N/A';
const date = schedule.departureAt ? new Date(schedule.departureAt).toLocaleDateString() : 'N/A';
return (
<option key={schedule.id} value={schedule.id}>
{trainNumber} - {routeName} - {date}
</option>
);
})}
</select>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{coachesWithSeats.map((coach: any) => {
const isBedCoach = (coach.seatClass && coach.seatClass.toLowerCase().includes('bed')) ||
(coach.mode && coach.mode.toLowerCase().includes('bed'));
const seats = (coach.seats || []).filter((s: any) => s.seatNumber);
<div className="space-y-3 pt-4 border-t border-gray-200 dark:border-gray-700">
<h3 className="font-semibold text-sm text-foreground">Seat Status</h3>
<div className="space-y-2">
<div className="flex items-center gap-3">
<div className="w-5 h-5 rounded bg-green-500"></div>
<span className="text-sm text-muted-foreground">Available</span>
</div>
<div className="flex items-center gap-3">
<div className="w-5 h-5 rounded bg-red-500"></div>
<span className="text-sm text-muted-foreground">Booked</span>
</div>
<div className="flex items-center gap-3">
<div className="w-5 h-5 rounded bg-yellow-500"></div>
<span className="text-sm text-muted-foreground">Held</span>
</div>
<div className="flex items-center gap-3">
<div className="w-5 h-5 rounded bg-gray-500"></div>
<span className="text-sm text-muted-foreground">Blocked</span>
</div>
<div className="flex items-center gap-3">
<div className="w-5 h-5 rounded border-2 border-dashed border-gray-400"></div>
<span className="text-sm text-muted-foreground">Removed</span>
</div>
</div>
</div>
</div>
return (
<div key={coach.id} className="flex flex-col gap-4">
<div className="mb-3">
<h3 className="font-semibold text-sm">Coach {coach.coachNumber}</h3>
</div>
<div className="space-y-4 w-80">
<div className="bg-gradient-to-r from-[rgb(20,113,76)] to-[rgb(15,85,57)] rounded-lg border-2 border-[rgb(20,113,76)] flex items-center justify-center shadow-lg p-6 h-24">
<Train className="w-14 h-14 text-white" />
</div>
<div className="bg-gray-50 dark:bg-gray-900/30 rounded-lg w-64 border border-gray-200 dark:border-gray-700 p-2">
{renderCoachSeats(coach, isBedCoach)}
</div>
{coachesWithSeats.map((coach: any, index: number) => {
const coachData = coachTypesData?.items?.find((c: any) => c.id === coach.id) || coach;
const coachTypeName = coachData?.coachType?.type || 'Coach';
const isBedCoach = coachTypeName.toLowerCase().includes('bed');
const seats = (coach.seats || []).filter((s: any) => s.seatNumber);
const isExpanded = expandedCoaches.has(coach.id);
const seatOrBedLabel = isBedCoach ? 'beds' : 'seats';
return (
<div key={coach.id} className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden bg-white dark:bg-gray-800/50 shadow-md hover:shadow-lg transition-shadow">
<div className="px-4 py-3 bg-gradient-to-r from-[rgb(20,113,76)]/10 to-[rgb(20,113,76)]/5 dark:from-[rgb(20,113,76)]/20 dark:to-[rgb(20,113,76)]/10 border-b border-[rgb(20,113,76)]/20 dark:border-[rgb(20,113,76)]/30 flex items-center justify-between">
<button
onClick={() => toggleCoach(coach.id)}
className="flex-1 flex items-center gap-3 hover:opacity-75 transition-opacity"
>
<div className={`transform transition-transform ${isExpanded ? 'rotate-180' : ''}`}>
<ChevronDown className="w-5 h-5 text-[rgb(20,113,76)]" />
</div>
<div className="text-left">
<p className="font-semibold text-foreground">Coach {coach.coachNumber}</p>
<p className="text-xs text-muted-foreground">{coachTypeName} {seats.length} {seatOrBedLabel}</p>
</div>
</button>
<ActionButton
variant={isCoachBlocked(coach) ? 'danger' : 'secondary'}
size="sm"
onClick={() => isCoachBlocked(coach) ? handleUnblockCoach(coach) : handleBlockCoach(coach)}
className="ml-2"
>
{isCoachBlocked(coach) ? 'Unblock' : 'Block'}
</ActionButton>
</div>
);
})}
</div>
{isExpanded && (
<div className="px-4 py-4 bg-white dark:bg-gray-900/50 border-t border-gray-200 dark:border-gray-700">
<div className="bg-gray-50 dark:bg-gray-900/30 rounded-lg p-3 inline-block">
{renderCoachSeats(coach, isBedCoach)}
</div>
</div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
)}
<Modal
isOpen={showBlockModal}
@@ -439,8 +572,7 @@ export default function SeatsPage() {
>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Block seat <strong>{selectedSeat?.seatNumber}</strong> in Coach{' '}
<strong>{selectedSeat?.coach?.coachNumber}</strong>
Block seat <strong>{selectedSeat?.seatNumber}</strong> in Coach <strong>{selectedSeat?.coach?.coachNumber}</strong>
</p>
<div>
<label className="label">Reason for Blocking *</label>
@@ -485,8 +617,7 @@ export default function SeatsPage() {
>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Remove seat <strong>{selectedSeat?.seatNumber}</strong> from Coach{' '}
<strong>{selectedSeat?.coach?.coachNumber}</strong>
Remove seat <strong>{selectedSeat?.seatNumber}</strong> from Coach <strong>{selectedSeat?.coach?.coachNumber}</strong>
</p>
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
<p className="text-sm text-yellow-800">
@@ -514,6 +645,96 @@ export default function SeatsPage() {
</div>
</div>
</Modal>
<Modal
isOpen={showBlockCoachModal}
onClose={() => {
setShowBlockCoachModal(false);
setSelectedCoach(null);
setBlockCoachReason('');
}}
title="Block Coach"
size="md"
>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Block all seats in Coach <strong>{selectedCoach?.coachNumber}</strong>
</p>
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
<p className="text-sm text-red-800">
This will block all {selectedCoach?.seats?.length || 0} seats in this coach.
</p>
</div>
<div>
<label className="label">Reason for Blocking *</label>
<textarea
className="input"
rows={3}
value={blockCoachReason}
onChange={(e) => setBlockCoachReason(e.target.value)}
placeholder="e.g., Major maintenance, Safety inspection, Temporary withdrawal"
/>
</div>
<div className="flex justify-end gap-2">
<ActionButton
variant="secondary"
onClick={() => {
setShowBlockCoachModal(false);
setSelectedCoach(null);
setBlockCoachReason('');
}}
>
Cancel
</ActionButton>
<ActionButton
variant="danger"
onClick={submitBlockCoach}
loading={blockCoachMutation.isPending}
disabled={!blockCoachReason.trim()}
>
Block Coach
</ActionButton>
</div>
</div>
</Modal>
<Modal
isOpen={showUnblockCoachModal}
onClose={() => {
setShowUnblockCoachModal(false);
setCoachToUnblock(null);
}}
title="Unblock Coach"
size="md"
>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Unblock all seats in Coach <strong>{coachToUnblock?.coachNumber}</strong>
</p>
<div className="bg-green-50 border border-green-200 rounded-lg p-3">
<p className="text-sm text-green-800">
This will unblock all {coachToUnblock?.seats?.filter((s: any) => s.status === 'BLOCKED' || s.isBlocked).length || 0} blocked seats in this coach.
</p>
</div>
<div className="flex justify-end gap-2">
<ActionButton
variant="secondary"
onClick={() => {
setShowUnblockCoachModal(false);
setCoachToUnblock(null);
}}
>
Cancel
</ActionButton>
<ActionButton
onClick={confirmUnblockCoach}
loading={unblockCoachMutation.isPending}
>
Unblock Coach
</ActionButton>
</div>
</div>
</Modal>
</div>
);
}
@@ -581,7 +802,7 @@ function SeatIcon({
return (
<div className="relative group flex flex-col items-center">
{!hideNumber && (
<span className="text-xs font-bold mb-0.5 h-3 leading-3 text-foreground">
<span className="text-xs font-bold mb-0.5 leading-3 text-foreground">
{seat.seatNumber}
</span>
)}
@@ -590,7 +811,7 @@ function SeatIcon({
<div
className={`${width} h-11 rounded flex items-center justify-center cursor-pointer hover:opacity-80 transition-opacity ${color}`}
title={`${seat.seatNumber} - ${seat.bedPosition} - ${status}`}
style={seat.row % 2 === 1 ? { transform: 'scaleY(-1)' } : undefined}
style={!shouldFlipIcon ? { transform: 'scaleY(-1)' } : undefined}
>
<Bed className="w-7 h-7 text-white" />
</div>

View File

@@ -2,7 +2,7 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { MapPin, Globe, Plus, Edit, Trash2 } from 'lucide-react';
import { Globe, Plus, Edit, Trash2 } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
@@ -11,6 +11,15 @@ 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: '' });
const [showModal, setShowModal] = useState(false);
@@ -51,6 +60,13 @@ export default function StationsPage() {
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const sequence = parseInt(formData.get('sequence') as string);
if (isNaN(sequence)) {
alert('Sequence Number is required');
return;
}
const stationData = {
code: formData.get('code') as string,
name: formData.get('name') as string,
@@ -59,6 +75,8 @@ export default function StationsPage() {
lat: parseFloat(formData.get('lat') as string) || null,
lng: parseFloat(formData.get('lng') as string) || null,
timezone: formData.get('timezone') as string,
distance: parseFloat(formData.get('distance') as string) || 0,
sequence,
isOperational: formData.get('isOperational') === 'true',
};
@@ -81,6 +99,14 @@ export default function StationsPage() {
};
const columns = [
{
key: 'sequence',
label: 'Sequence',
sortable: true,
render: (station: any) => (
<span className="font-mono font-semibold text-sm">{station.sequence || 0}</span>
),
},
{
key: 'code',
label: 'Code',
@@ -111,24 +137,11 @@ export default function StationsPage() {
),
},
{
key: 'coordinates',
label: 'Coordinates',
render: (station: any) => {
const lat = station.lat ? parseFloat(station.lat) : null;
const lng = station.lng ? parseFloat(station.lng) : null;
return (
<div className="flex items-center gap-2">
<MapPin className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-mono">
{lat && lng && !isNaN(lat) && !isNaN(lng)
? `${lat.toFixed(4)}, ${lng.toFixed(4)}`
: 'N/A'
}
</span>
</div>
);
},
key: 'distance',
label: 'Distance (km)',
render: (station: any) => (
<span className="font-mono text-sm">{station.distance ? `${station.distance}` : '0'}</span>
),
},
{
key: 'isOperational',
@@ -139,13 +152,6 @@ export default function StationsPage() {
</Badge>
),
},
{
key: 'timezone',
label: 'Timezone',
render: (station: any) => (
<span className="text-sm">{station.timezone || 'N/A'}</span>
),
},
];
const actions = [
@@ -328,15 +334,43 @@ export default function StationsPage() {
/>
</div>
<div>
<label className="label">Timezone</label>
<input
type="text"
<label className="label">Timezone *</label>
<select
name="timezone"
className="input"
defaultValue={editingStation?.timezone || 'Africa/Addis_Ababa'}
placeholder="e.g., Africa/Addis_Ababa"
required
>
{TIMEZONES.map((tz) => (
<option key={tz} value={tz}>{tz}</option>
))}
</select>
</div>
<div>
<label className="label">Distance from Previous (km)</label>
<input
type="number"
name="distance"
className="input"
defaultValue={editingStation?.distance || 0}
min="0"
step="0.1"
placeholder="e.g., 150.5"
/>
</div>
<div>
<label className="label">Sequence Number *</label>
<input
type="number"
name="sequence"
className="input"
defaultValue={editingStation?.sequence || 0}
min="0"
required
placeholder="e.g., 1"
/>
<p className="text-xs text-muted-foreground mt-1">Used for ordering stations in routes</p>
</div>
<div>
<label className="label">Status</label>
<select

View File

@@ -2,19 +2,35 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Download, RefreshCw, CheckCircle, Trash2 } from 'lucide-react';
import { LogIn, Trash2 } from 'lucide-react';
import { Download } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { ticketsApi, apiClient } from '@/lib/api';
import { formatDateTime } from '@/lib/utils';
import Modal from '@/components/ui/Modal';
import { ticketsApi, apiClient, schedulesApi, stationsApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils';
export default function TicketsPage() {
const [filters, setFilters] = useState({ search: '', status: '' });
const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', tripDate: '' });
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [ticketToDelete, setTicketToDelete] = useState<any>(null);
const [boardConfirmOpen, setBoardConfirmOpen] = useState(false);
const [ticketToBoard, setTicketToBoard] = useState<any>(null);
const [successMessage, setSuccessMessage] = useState('');
const [detailsModalOpen, setDetailsModalOpen] = useState(false);
const [selectedTicket, setSelectedTicket] = useState<any>(null);
const [exportModalOpen, setExportModalOpen] = useState(false);
const [selectedColumns, setSelectedColumns] = useState<Record<string, boolean>>({
ticketNumber: true,
booking: true,
trip: true,
seat: true,
seatClass: true,
amount: true,
status: true,
});
const queryClient = useQueryClient();
const { data, isLoading, error } = useQuery({
@@ -22,27 +38,22 @@ export default function TicketsPage() {
queryFn: () => ticketsApi.getAll({ ...filters, skip: 0, take: 50 }),
});
const regenerateMutation = useMutation({
mutationFn: ticketsApi.regenerate,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tickets'] });
setSuccessMessage('Ticket regenerated successfully');
setTimeout(() => setSuccessMessage(''), 3000);
},
onError: (error: any) => {
alert(`Error: ${error.message || 'Failed to regenerate ticket'}`);
},
const { data: stationsData } = useQuery({
queryKey: ['stations'],
queryFn: () => stationsApi.getAll(),
});
const validateMutation = useMutation({
mutationFn: ({ ticketId, data }: any) => ticketsApi.validate(ticketId, data),
const boardMutation = useMutation({
mutationFn: ({ ticketId }: any) => ticketsApi.validate(ticketId, { status: 'USED', boardedAt: new Date().toISOString() }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tickets'] });
setSuccessMessage('Ticket validated successfully');
setBoardConfirmOpen(false);
setTicketToBoard(null);
setSuccessMessage('Ticket boarded successfully');
setTimeout(() => setSuccessMessage(''), 3000);
},
onError: (error: any) => {
alert(`Error: ${error.message || 'Failed to validate ticket'}`);
alert(`Error: ${error.message || 'Failed to board ticket'}`);
},
});
@@ -61,17 +72,15 @@ export default function TicketsPage() {
},
});
const handleRegenerate = async (ticket: any) => {
if (window.confirm(`Regenerate QR code for ticket ${ticket.ticketNumber}?`)) {
await regenerateMutation.mutateAsync(ticket.id);
}
const handleBoard = (ticket: any) => {
setTicketToBoard(ticket);
setBoardConfirmOpen(true);
};
const handleValidate = async (ticket: any) => {
await validateMutation.mutateAsync({
ticketId: ticket.id,
data: { validatedAt: new Date().toISOString() },
});
const handleConfirmBoard = async () => {
if (ticketToBoard) {
await boardMutation.mutateAsync({ ticketId: ticketToBoard.id });
}
};
const handleDeleteClick = (ticket: any) => {
@@ -85,6 +94,50 @@ export default function TicketsPage() {
}
};
const handleExportTickets = async () => {
setExportModalOpen(true);
};
const confirmExport = () => {
const cols = Object.entries(selectedColumns)
.filter(([, selected]) => selected)
.map(([col]) => col);
if (cols.length === 0) {
alert('Please select at least one column');
return;
}
const csv = [
cols.join(','),
...data?.items?.map((ticket: any) => {
const values = cols.map(col => {
switch(col) {
case 'ticketNumber': return ticket.ticketNumber || '';
case 'booking': return ticket.booking?.bookingRef || '';
case 'trip': return `${ticket.schedule?.originStation?.name || ''}-${ticket.schedule?.destinationStation?.name || ''}`;
case 'coach': return ticket.seat?.coach?.number || '';
case 'seat': return ticket.seat?.seatNumber || '';
case 'seatClass': return ticket.seat?.coach?.coachType?.name || '';
case 'amount': return formatCurrency((ticket.booking?.totalMinor || 0), ticket.booking?.currency || 'ETB');
case 'status': return ticket.status || '';
case 'boarded': return ticket.boardedAt ? 'Yes' : 'No';
default: return '';
}
});
return values.map(v => `"${v}"`).join(',');
}) || []
].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `tickets-${new Date().toISOString().split('T')[0]}.csv`;
a.click();
setExportModalOpen(false);
};
const columns = [
{
key: 'ticketNumber',
@@ -123,54 +176,60 @@ export default function TicketsPage() {
{
key: 'seat',
label: 'Seat',
sortable: true,
render: (ticket: any) => (
<span className="font-mono">{ticket.seat?.seatNumber || 'N/A'}</span>
<div>
<div className="font-mono font-semibold">Coach {ticket.seat?.coach?.number || 'N/A'} - Seat {ticket.seat?.seatNumber || 'N/A'}</div>
<div className="text-xs text-muted-foreground">{ticket.seat?.coach?.coachType?.name || 'N/A'}</div>
</div>
),
},
{
key: 'amount',
label: 'Amount',
sortable: true,
render: (ticket: any) => formatCurrency(ticket.booking?.totalMinor || 0, ticket.booking?.currency || 'ETB'),
},
{
key: 'status',
label: 'Status',
sortable: true,
render: (ticket: any) => (
<Badge variant="status" status={ticket.status || 'PENDING'}>
{ticket.status || 'PENDING'}
<Badge variant="status" status={ticket.status || 'ACTIVE'}>
{ticket.status || 'ACTIVE'}
</Badge>
),
},
{
key: 'validated',
label: 'Validated',
key: 'boarded',
label: 'Boarded',
render: (ticket: any) => (
ticket.validatedAt ? (
ticket.boardedAt ? (
<div className="flex items-center gap-1 text-green-600 dark:text-green-400">
<CheckCircle className="h-4 w-4" />
<span className="text-sm">{formatDateTime(ticket.validatedAt)}</span>
<span className="text-sm">{formatDateTime(ticket.boardedAt)}</span>
</div>
) : (
<span className="text-sm text-muted-foreground">Not validated</span>
<span className="text-sm text-muted-foreground">Not boarded</span>
)
),
},
{
key: 'createdAt',
label: 'Created',
sortable: true,
render: (ticket: any) => formatDateTime(ticket.createdAt),
},
];
const actions = [
{
label: 'Check-in',
onClick: handleValidate,
label: 'Board',
onClick: handleBoard,
variant: 'primary' as const,
icon: CheckCircle,
show: (ticket: any) => !ticket.validatedAt,
icon: LogIn,
show: (ticket: any) => ticket.status !== 'USED' && !ticket.boardedAt,
},
{
label: 'Regenerate',
onClick: handleRegenerate,
label: 'Details',
onClick: (ticket: any) => {
setSelectedTicket(ticket);
setDetailsModalOpen(true);
},
variant: 'secondary' as const,
icon: RefreshCw,
},
{
label: 'Delete',
@@ -180,6 +239,8 @@ export default function TicketsPage() {
},
];
const stations = stationsData?.items || [];
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
@@ -187,7 +248,7 @@ export default function TicketsPage() {
<h1 className="text-2xl font-bold text-foreground">Tickets</h1>
<p className="text-muted-foreground">Manage tickets and validations</p>
</div>
<ActionButton icon={Download} variant="secondary">Export</ActionButton>
<ActionButton icon={Download} variant="secondary" onClick={handleExportTickets}>Export</ActionButton>
</div>
{/* Filters */}
@@ -202,17 +263,52 @@ export default function TicketsPage() {
Error loading tickets: {error instanceof Error ? error.message : 'Unknown error'}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
<div>
<label className="label">Search</label>
<input
type="text"
placeholder="Search by ticket number or booking ref..."
placeholder="Search by ticket number..."
className="input"
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
/>
</div>
<div>
<label className="label">Origin</label>
<select
className="input"
value={filters.originStationId}
onChange={(e) => setFilters({ ...filters, originStationId: e.target.value })}
>
<option value="">All Origins</option>
{stations.map((station: any) => (
<option key={station.id} value={station.id}>{station.name}</option>
))}
</select>
</div>
<div>
<label className="label">Destination</label>
<select
className="input"
value={filters.destinationStationId}
onChange={(e) => setFilters({ ...filters, destinationStationId: e.target.value })}
>
<option value="">All Destinations</option>
{stations.map((station: any) => (
<option key={station.id} value={station.id}>{station.name}</option>
))}
</select>
</div>
<div>
<label className="label">Trip Date</label>
<input
type="date"
className="input"
value={filters.tripDate}
onChange={(e) => setFilters({ ...filters, tripDate: e.target.value })}
/>
</div>
<div>
<label className="label">Status</label>
<select
@@ -224,7 +320,6 @@ export default function TicketsPage() {
<option value="ACTIVE">Active</option>
<option value="USED">Used</option>
<option value="CANCELLED">Cancelled</option>
<option value="EXPIRED">Expired</option>
</select>
</div>
</div>
@@ -239,6 +334,21 @@ export default function TicketsPage() {
emptyMessage="No tickets found"
/>
{/* Board Confirmation Dialog */}
<ConfirmDialog
isOpen={boardConfirmOpen}
onClose={() => {
setBoardConfirmOpen(false);
setTicketToBoard(null);
}}
onConfirm={handleConfirmBoard}
title="Board Ticket"
message={`Are you sure you want to board ticket ${ticketToBoard?.ticketNumber}? This will mark the ticket as USED.`}
confirmText="Board"
cancelText="Cancel"
isLoading={boardMutation.isPending}
/>
{/* Delete Confirmation Dialog */}
<ConfirmDialog
isOpen={deleteConfirmOpen}
@@ -254,6 +364,154 @@ export default function TicketsPage() {
isLoading={deleteMutation.isPending}
isDanger={true}
/>
{/* Ticket Details Modal */}
<Modal
isOpen={detailsModalOpen}
onClose={() => {
setDetailsModalOpen(false);
setSelectedTicket(null);
}}
title="Ticket Details"
size="lg"
>
{selectedTicket && (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<p className="text-sm text-muted-foreground">Ticket Number</p>
<p className="font-mono font-semibold text-lg">{selectedTicket.ticketNumber}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Status</p>
<div className="mt-1">
<Badge variant="status" status={selectedTicket.status || 'ACTIVE'}>
{selectedTicket.status || 'ACTIVE'}
</Badge>
</div>
</div>
</div>
<div className="border-t pt-4">
<h3 className="font-semibold mb-3">Booking Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p className="text-sm text-muted-foreground">Booking Reference</p>
<p className="font-medium">{selectedTicket.booking?.bookingRef || 'N/A'}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Passenger</p>
<p className="font-medium">{selectedTicket.booking?.passenger?.fullName || selectedTicket.booking?.contactEmail || 'N/A'}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Amount</p>
<p className="font-medium">{formatCurrency(selectedTicket.booking?.totalMinor || 0, selectedTicket.booking?.currency || 'ETB')}</p>
</div>
</div>
</div>
<div className="border-t pt-4">
<h3 className="font-semibold mb-3">Trip Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p className="text-sm text-muted-foreground">Route</p>
<p className="font-medium">
{selectedTicket.schedule?.originStation?.name || 'N/A'} {selectedTicket.schedule?.destinationStation?.name || 'N/A'}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Departure</p>
<p className="font-medium">{selectedTicket.schedule?.departureAt ? formatDateTime(selectedTicket.schedule.departureAt) : 'N/A'}</p>
</div>
</div>
</div>
<div className="border-t pt-4">
<h3 className="font-semibold mb-3">Seat Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p className="text-sm text-muted-foreground">Coach</p>
<p className="font-mono font-semibold">{selectedTicket.seat?.coach?.number || 'N/A'}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Seat Number</p>
<p className="font-mono font-semibold">{selectedTicket.seat?.seatNumber || 'N/A'}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Class</p>
<p className="font-medium">{selectedTicket.seat?.coach?.coachType?.name || 'N/A'}</p>
</div>
</div>
</div>
{selectedTicket.boardedAt && (
<div className="border-t pt-4 bg-green-50 dark:bg-green-900/20 rounded-lg p-4">
<p className="text-sm text-muted-foreground">Boarded At</p>
<p className="font-medium text-green-700 dark:text-green-400">{formatDateTime(selectedTicket.boardedAt)}</p>
</div>
)}
<div className="flex justify-end gap-2 pt-4">
<ActionButton
variant="secondary"
onClick={() => {
setDetailsModalOpen(false);
setSelectedTicket(null);
}}
>
Close
</ActionButton>
</div>
</div>
)}
</Modal>
{/* Export Columns Modal */}
<Modal
isOpen={exportModalOpen}
onClose={() => setExportModalOpen(false)}
title="Export Tickets - Select Columns"
size="md"
>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">Select which columns to include in the export</p>
<div className="space-y-3 max-h-96 overflow-y-auto">
{[
{ key: 'ticketNumber', label: 'Ticket Number' },
{ key: 'booking', label: 'Booking Reference & Passenger' },
{ key: 'trip', label: 'Trip (Origin → Destination)' },
{ key: 'coach', label: 'Coach Number' },
{ key: 'seat', label: 'Seat Number' },
{ key: 'seatClass', label: 'Seat Class' },
{ key: 'amount', label: 'Amount' },
{ key: 'status', label: 'Status' },
{ key: 'boarded', label: 'Boarded Status' },
].map((col) => (
<label key={col.key} className="flex items-center gap-3 p-2 hover:bg-gray-50 dark:hover:bg-gray-900/50 rounded cursor-pointer">
<input
type="checkbox"
checked={selectedColumns[col.key] || false}
onChange={(e) =>
setSelectedColumns({ ...selectedColumns, [col.key]: e.target.checked })
}
className="w-4 h-4 rounded border-gray-300"
/>
<span className="text-sm font-medium">{col.label}</span>
</label>
))}
</div>
<div className="flex justify-end gap-2 pt-4 border-t">
<ActionButton variant="secondary" onClick={() => setExportModalOpen(false)}>
Cancel
</ActionButton>
<ActionButton onClick={confirmExport}>
Export CSV
</ActionButton>
</div>
</div>
</Modal>
</div>
);
}

View File

@@ -1,6 +1,6 @@
'use client';
import { Bell, LogOut, Moon, Sun, ChevronDown } from 'lucide-react';
import { Bell, LogOut, Moon, Sun, ChevronDown, HelpCircle } from 'lucide-react';
import { useAuthStore } from '@/lib/auth-store';
import { useTheme } from '@/lib/theme-store';
import { useState } from 'react';
@@ -19,6 +19,16 @@ export default function Header() {
</div>
<div className="flex items-center gap-3">
{/* Help Documentation */}
<Link
href="/docs"
className="rounded-lg p-2 hover:bg-gray-100 dark:hover:bg-slate-800 transition-colors"
title="View Documentation"
target="_blank"
>
<HelpCircle className="h-5 w-5 text-[rgb(20,113,76)] dark:text-slate-400" />
</Link>
{/* Notifications */}
<div className="relative">
<button

View File

@@ -32,7 +32,8 @@ import {
Moon,
Sun,
Armchair,
Grid3x3
Grid3x3,
Banknote
} from 'lucide-react';
import { useAuthStore } from '@/lib/auth-store';
import { cn } from '@/lib/utils';
@@ -69,6 +70,7 @@ const navigationSections = [
title: 'Financial',
items: [
{ name: 'Pricing & Fares', href: '/pricing', icon: DollarSign },
{ name: 'Currencies', href: '/currencies', icon: Banknote },
{ name: 'Payments', href: '/payments', icon: CreditCard },
{ name: 'Promo Codes', href: '/promos', icon: Gift },
]
@@ -78,16 +80,15 @@ const navigationSections = [
items: [
{ name: 'Loyalty Program', href: '/loyalty', icon: Gift },
{ name: 'Support Center', href: '/support', icon: MessageSquare },
{ name: 'Notifications', href: '/notifications', icon: Bell },
{ name: 'Food & Dining', href: '/food', icon: Utensils },
{ name: 'Notifications', href: '/notifications', icon: Bell },
]
},
{
title: 'Security & Compliance',
items: [
{ name: 'Audit Logs', href: '/audit', icon: AlertTriangle },
{ name: 'Fraud Detection', href: '/fraud', icon: Shield },
{ name: 'Verifayda Integration', href: '/verifayda', icon: UserCheck },
{ name: 'Audit Logs', href: '/audit', icon: AlertTriangle },
]
},
{

View File

@@ -10,6 +10,7 @@ interface Column<T> {
key: string;
label: string;
sortable?: boolean;
filterable?: boolean;
render?: (item: T) => ReactNode;
width?: string;
}

View File

@@ -2,15 +2,186 @@ import { apiClient } from '@/lib/api-client';
import { DashboardStats, RevenueData } from '@/types';
export const dashboardApi = {
getStats: () => {
return apiClient.get<DashboardStats>('/dashboard/stats');
getStats: async () => {
try {
// Fetch bookings and passengers data in parallel
const [bookingsRes, passengersRes] = await Promise.all([
apiClient.get<any>('/bookings?pageSize=1'),
apiClient.get<any>('/passengers?pageSize=1'),
]);
const bookingsTotal = bookingsRes?.meta?.total || 0;
const passengersTotal = passengersRes?.meta?.total || 0;
// Calculate revenue from bookings
const allBookingsRes = await apiClient.get<any>('/bookings?pageSize=100');
const allBookings = Array.isArray(allBookingsRes) ? allBookingsRes : allBookingsRes?.items || [];
const totalRevenue = allBookings.reduce((sum: number, b: any) => sum + (b.totalMinor || 0), 0);
// Calculate average occupancy (placeholder - would need dedicated endpoint)
const occupancyRate = Math.floor(Math.random() * 100); // Replace with actual data
return {
totalBookings: bookingsTotal,
totalRevenue: totalRevenue,
totalPassengers: passengersTotal,
occupancyRate: occupancyRate,
totalTripsToday: 0,
activeTrips: 0,
cancelledBookings: 0,
averageTicketPrice: allBookings.length > 0 ? totalRevenue / allBookings.length : 0,
};
} catch (error) {
console.error('Failed to fetch dashboard stats:', error);
return {
totalBookings: 0,
totalRevenue: 0,
totalPassengers: 0,
occupancyRate: 0,
totalTripsToday: 0,
activeTrips: 0,
cancelledBookings: 0,
averageTicketPrice: 0,
};
}
},
getRevenueChart: (days: number = 30) => {
return apiClient.get<RevenueData[]>(`/dashboard/revenue?days=${days}`);
getRevenueChart: async (days: number = 30) => {
try {
const response = await apiClient.get<RevenueData[]>(`/dashboard/revenue?days=${days}`);
return response;
} catch (error) {
console.error('Failed to fetch revenue chart:', error);
return [];
}
},
getRecentBookings: (limit: number = 10) => {
return apiClient.get<any[]>(`/dashboard/recent-bookings?limit=${limit}`);
getRecentBookings: async (limit: number = 10) => {
try {
const response = await apiClient.get<any>(`/bookings?pageSize=${limit}`);
// Extract items from paginated response
const bookings = Array.isArray(response) ? response : response?.items || [];
return bookings.map((booking: any) => ({
id: booking.id,
bookingRef: booking.bookingRef,
status: booking.status,
totalMinor: booking.totalMinor,
currency: booking.currency || 'ETB',
displayCurrency: booking.displayCurrency,
displayTotalMinor: booking.displayTotalMinor,
contactEmail: booking.contactEmail,
contactPhone: booking.contactPhone,
createdAt: booking.createdAt,
passenger: booking.passenger ? {
id: booking.passenger.id,
fullName: booking.passenger.fullName,
email: booking.passenger.email,
} : null,
schedule: booking.schedule,
paymentIntent: booking.paymentIntent,
}));
} catch (error) {
console.error('Failed to fetch recent bookings:', error);
return [];
}
},
getTopAgents: async (limit: number = 5) => {
try {
const response = await apiClient.get<any[]>(`/agents/top?limit=${limit}`);
return response || [];
} catch (error) {
console.error('Failed to fetch top agents:', error);
return [];
}
},
getOccupancyTrend: async (days: number = 7) => {
try {
const response = await apiClient.get<any[]>(`/dashboard/occupancy?days=${days}`);
return response || [];
} catch (error) {
console.error('Failed to fetch occupancy trend:', error);
return [];
}
},
getUpcomingTrips: async (limit: number = 5) => {
try {
const response = await apiClient.get<any[]>(`/schedules/upcoming?limit=${limit}`);
return response || [];
} catch (error) {
console.error('Failed to fetch upcoming trips:', error);
return [];
}
},
getPaymentMethods: async () => {
try {
const response = await apiClient.get<any[]>('/dashboard/payment-methods');
return response || [];
} catch (error) {
console.error('Failed to fetch payment methods:', error);
return [];
}
},
getPassengerStats: async () => {
try {
const response = await apiClient.get<any>('/dashboard/passenger-stats');
return response || {
totalPassengers: 0,
newPassengersToday: 0,
activePassengers: 0,
loyaltyPoints: 0,
};
} catch (error) {
console.error('Failed to fetch passenger stats:', error);
return {
totalPassengers: 0,
newPassengersToday: 0,
activePassengers: 0,
loyaltyPoints: 0,
};
}
},
getTransactionSummary: async (days: number = 30) => {
try {
const response = await apiClient.get<any>(`/dashboard/transactions?days=${days}`);
return response || {
totalTransactions: 0,
successfulTransactions: 0,
failedTransactions: 0,
totalAmount: 0,
};
} catch (error) {
console.error('Failed to fetch transaction summary:', error);
return {
totalTransactions: 0,
successfulTransactions: 0,
failedTransactions: 0,
totalAmount: 0,
};
}
},
getLiveMetrics: async () => {
try {
const response = await apiClient.get<any>('/dashboard/live-metrics');
return response || {
onlineUsers: 0,
activeBookings: 0,
activePayments: 0,
};
} catch (error) {
console.error('Failed to fetch live metrics:', error);
return {
onlineUsers: 0,
activeBookings: 0,
activePayments: 0,
};
}
},
};

View File

@@ -364,14 +364,12 @@ export const foodApi = {
// Reports API
export const reportsApi = {
getOperationalReports: async (params?: any) => {
const query = new URLSearchParams(params as Record<string, string>).toString();
const response = await apiClient.get<any>(`/reports/operational${query ? `?${query}` : ''}`);
if (response?.data) {
return Array.isArray(response.data) ? { items: response.data } : response;
}
return Array.isArray(response) ? { items: response } : response;
generateReport: (data: any) => apiClient.post<any>('/reports/generate', data),
getReport: (reportId: string) => apiClient.get<any>(`/reports/${reportId}`),
listReports: async (reportType?: string) => {
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: [] };
},
getRevenue: (params?: any) => apiClient.get<any>('/reports/revenue', { params }),
getOccupancy: (params?: any) => apiClient.get<any>('/reports/occupancy', { params }),
};