mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'dev' into freight/develop
This commit is contained in:
@@ -1,3 +1,6 @@
|
||||
{
|
||||
"extends": ["next/core-web-vitals"]
|
||||
"extends": ["next/core-web-vitals"],
|
||||
"rules": {
|
||||
"react/no-unescaped-entities": "off"
|
||||
}
|
||||
}
|
||||
|
||||
2897
apps/edr-passenger-web/backoffice/public/docs.md
Normal file
2897
apps/edr-passenger-web/backoffice/public/docs.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
475
apps/edr-passenger-web/backoffice/src/app/currencies/page.tsx
Normal file
475
apps/edr-passenger-web/backoffice/src/app/currencies/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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's what's happening today.</p>
|
||||
<p className="text-muted-foreground mt-1">Welcome back! Here'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>
|
||||
);
|
||||
}
|
||||
|
||||
949
apps/edr-passenger-web/backoffice/src/app/docs/page.tsx
Normal file
949
apps/edr-passenger-web/backoffice/src/app/docs/page.tsx
Normal 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;
|
||||
537
apps/edr-passenger-web/backoffice/src/app/how-to/page.tsx
Normal file
537
apps/edr-passenger-web/backoffice/src/app/how-to/page.tsx
Normal 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'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>"CANCELLED"</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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (<5) — first child travels free, subsequent children pay full fare
|
||||
</li>
|
||||
<li>
|
||||
• <strong>Age-Based Pricing:</strong> ADULT (5+ years) pays 100%, CHILD (<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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -10,6 +10,7 @@ interface Column<T> {
|
||||
key: string;
|
||||
label: string;
|
||||
sortable?: boolean;
|
||||
filterable?: boolean;
|
||||
render?: (item: T) => ReactNode;
|
||||
width?: string;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user