First passenger and back office portal commit

This commit is contained in:
Stephanos A
2026-05-31 13:15:44 +03:00
parent a0b5eb1e92
commit 5059a1fe58
181 changed files with 14249 additions and 13949 deletions

View File

@@ -1,33 +0,0 @@
import {
useNavigate,
useLocation,
Routes,
Route,
Navigate,
} from "react-router-dom";
import { DashboardLayout, type SidebarItem } from "@edr/ui-common";
import DashboardPage from "./pages/dashboard/DashboardPage";
const sidebarItems: SidebarItem[] = [{ label: "Dashboard", href: "/" }];
const App = () => {
const navigate = useNavigate();
const location = useLocation();
return (
<DashboardLayout
title="EDR Passenger Backoffice"
sidebarItems={sidebarItems}
activeHref={location.pathname}
onNavigate={navigate}
>
<Routes>
<Route path="/" element={<DashboardPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</DashboardLayout>
);
};
export default App;

View File

@@ -0,0 +1,5 @@
import DashboardLayout from '../dashboard/layout';
export default function AgentsLayout({ children }: { children: React.ReactNode }) {
return <DashboardLayout>{children}</DashboardLayout>;
}

View File

@@ -0,0 +1,120 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Plus, Edit, DollarSign, Clock } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import ActionButton from '@/components/ui/ActionButton';
import Badge from '@/components/ui/Badge';
import { agentsApi } from '@/lib/api';
import { formatCurrency, formatDateTime } from '@/lib/utils';
export default function AgentsPage() {
const [filters, setFilters] = useState({ search: '', active: '' });
const { data, isLoading } = useQuery({
queryKey: ['agents', filters],
queryFn: () => agentsApi.getAll(filters),
});
const columns = [
{
key: 'agentCode',
label: 'Agent Code',
sortable: true,
render: (agent: any) => <span className="font-mono font-semibold">{agent.agentCode}</span>,
},
{
key: 'user',
label: 'Name',
render: (agent: any) => (
<div>
<div className="font-medium">{agent.user?.fullName || 'N/A'}</div>
<div className="text-sm text-gray-500">{agent.user?.email}</div>
</div>
),
},
{
key: 'commissionRate',
label: 'Commission',
render: (agent: any) => <span>{agent.commissionRate}%</span>,
},
{
key: 'active',
label: 'Status',
render: (agent: any) => (
<Badge variant="status" status={agent.active ? 'CONFIRMED' : 'CANCELLED'}>
{agent.active ? 'Active' : 'Inactive'}
</Badge>
),
},
];
const actions = [
{
label: 'View Shifts',
onClick: (agent: any) => window.location.href = `/agents/${agent.id}/shifts`,
variant: 'secondary' as const,
icon: Clock,
},
{
label: 'View Commissions',
onClick: (agent: any) => window.location.href = `/agents/${agent.id}/commissions`,
variant: 'secondary' as const,
icon: DollarSign,
},
{
label: 'Edit',
onClick: (agent: any) => console.log('Edit', agent),
variant: 'secondary' as const,
icon: Edit,
},
];
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Agent Operations</h1>
<p className="text-muted-foreground">Manage booking agents and their operations</p>
</div>
<ActionButton icon={Plus}>Add Agent</ActionButton>
</div>
<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 agents..."
className="input"
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
/>
</div>
<div>
<label className="label">Status</label>
<select
className="input"
value={filters.active}
onChange={(e) => setFilters({ ...filters, active: e.target.value })}
>
<option value="">All Status</option>
<option value="true">Active</option>
<option value="false">Inactive</option>
</select>
</div>
</div>
</div>
<DataTable
data={data?.items || []}
columns={columns}
actions={actions}
loading={isLoading}
emptyMessage="No agents found"
/>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import DashboardLayout from '../dashboard/layout';
export default function Layout({ children }: { children: React.ReactNode }) {
return <DashboardLayout>{children}</DashboardLayout>;
}

View File

@@ -0,0 +1,129 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Search, Eye } 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';
export default function AuditLogsPage() {
const [filters, setFilters] = useState({ search: '', action: '', entityType: '' });
const { data, isLoading } = useQuery({
queryKey: ['audit-logs', filters],
queryFn: () => auditApi.getLogs(filters),
});
const columns = [
{
key: 'action',
label: 'Action',
sortable: true,
render: (log: any) => (
<Badge>{log.action}</Badge>
),
},
{
key: 'user',
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>
),
},
{
key: 'entityType',
label: 'Entity Type',
render: (log: any) => log.entityType,
},
{
key: 'entityId',
label: 'Entity ID',
render: (log: any) => (
<span className="font-mono text-sm">{log.entityId?.substring(0, 8)}...</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}`,
variant: 'secondary' as const,
icon: Eye,
},
];
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>
</div>
<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 logs..."
className="input"
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
/>
</div>
<div>
<label className="label">Action</label>
<select
className="input"
value={filters.action}
onChange={(e) => setFilters({ ...filters, action: e.target.value })}
>
<option value="">All Actions</option>
<option value="CREATE">Create</option>
<option value="UPDATE">Update</option>
<option value="DELETE">Delete</option>
<option value="LOGIN">Login</option>
<option value="LOGOUT">Logout</option>
</select>
</div>
<div>
<label className="label">Entity Type</label>
<select
className="input"
value={filters.entityType}
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>
</select>
</div>
</div>
</div>
<DataTable
data={data?.items || []}
columns={columns}
actions={actions}
loading={isLoading}
emptyMessage="No audit logs found"
/>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import DashboardLayout from '../dashboard/layout';
export default function BookingsLayout({ children }: { children: React.ReactNode }) {
return <DashboardLayout>{children}</DashboardLayout>;
}

View File

@@ -0,0 +1,171 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Filter, Download, Eye, XCircle } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import Pagination from '@/components/ui/Pagination';
import ActionButton from '@/components/ui/ActionButton';
import { bookingsApi } from '@/lib/api';
import { formatCurrency, formatDateTime } from '@/lib/utils';
import { BookingFilters } from '@/types';
export default function BookingsPage() {
const [filters, setFilters] = useState<BookingFilters>({
page: 1,
pageSize: 20,
search: '',
status: '',
});
const queryClient = useQueryClient();
const { data, isLoading, error } = useQuery({
queryKey: ['bookings', filters],
queryFn: () => bookingsApi.getAll(filters),
});
if (error) {
console.error('Bookings API Error:', error);
}
const cancelMutation = useMutation({
mutationFn: ({ id, reason }: { id: string; reason?: string }) => bookingsApi.cancel(id, reason),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['bookings'] });
alert('Booking cancelled successfully');
},
});
const handleCancel = async (booking: any) => {
if (confirm(`Are you sure you want to cancel booking ${booking.bookingRef}?`)) {
await cancelMutation.mutateAsync({ id: booking.id, reason: 'Cancelled by admin' });
}
};
const columns = [
{
key: 'bookingRef',
label: 'Reference',
sortable: true,
render: (booking: any) => (
<span className="font-mono font-semibold">{booking.bookingRef}</span>
),
},
{
key: 'passenger',
label: 'Passenger',
render: (booking: any) => (
<div>
<div className="font-medium">{booking.passenger?.fullName || booking.contactEmail || 'Guest'}</div>
<div className="text-sm text-muted-foreground">{booking.contactPhone || booking.passenger?.phone}</div>
</div>
),
},
{
key: 'status',
label: 'Status',
render: (booking: any) => (
<Badge variant="status" status={booking.status}>{booking.status}</Badge>
),
},
{
key: 'totalMinor',
label: 'Amount',
sortable: true,
render: (booking: any) => formatCurrency(booking.totalMinor, booking.currency),
},
{
key: 'paymentStatus',
label: 'Payment',
render: (booking: any) => (
<Badge variant="status" status={booking.paymentIntent?.status || 'PENDING'}>
{booking.paymentIntent?.status || 'PENDING'}
</Badge>
),
},
{
key: 'createdAt',
label: 'Created',
sortable: true,
render: (booking: any) => formatDateTime(booking.createdAt),
},
];
const actions = [
// TODO: Create booking detail page
// {
// label: 'View Details',
// onClick: (booking: any) => window.location.href = `/bookings/${booking.id}`,
// variant: 'secondary' as const,
// icon: Eye,
// },
{
label: 'Cancel Booking',
onClick: handleCancel,
variant: 'danger' as const,
icon: XCircle,
show: (booking: any) => booking.status !== 'CANCELLED' && booking.status !== 'COMPLETED',
},
];
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<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>
</div>
<div className="card">
{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 loading bookings: {error instanceof Error ? error.message : 'Unknown error'}
</div>
)}
<div className="mb-4 flex flex-wrap gap-4">
<div className="flex-1">
<input
type="text"
placeholder="Search by reference, email, or phone..."
className="input"
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value, page: 1 })}
/>
</div>
<select
className="input w-48"
value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value || undefined, page: 1 })}
>
<option value="">All Status</option>
<option value="PENDING_PAYMENT">Pending Payment</option>
<option value="CONFIRMED">Confirmed</option>
<option value="CANCELLED">Cancelled</option>
<option value="COMPLETED">Completed</option>
</select>
<ActionButton variant="secondary" icon={Filter}>More Filters</ActionButton>
</div>
<DataTable
data={data?.items || []}
columns={columns}
actions={actions}
loading={isLoading}
emptyMessage="No bookings found"
/>
{data?.meta && (
<Pagination
currentPage={data.meta.page}
totalPages={data.meta.totalPages}
onPageChange={(page) => setFilters({ ...filters, page })}
/>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import DashboardLayout from '../dashboard/layout';
export default function CoachesLayout({ children }: { children: React.ReactNode }) {
return <DashboardLayout>{children}</DashboardLayout>;
}

View File

@@ -0,0 +1,308 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { fleetApi } from '@/lib/api';
import DataTable from '@/components/ui/DataTable';
import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
import { Plus, Search, Grid3x3, Train, Edit, Trash2 } from 'lucide-react';
export default function CoachesPage() {
const [search, setSearch] = useState('');
const [showModal, setShowModal] = useState(false);
const [editingCoach, setEditingCoach] = useState<any>(null);
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ['coaches', search],
queryFn: () => fleetApi.getCoaches({ search }),
});
const createMutation = useMutation({
mutationFn: fleetApi.createCoach,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['coaches'] });
setShowModal(false);
setEditingCoach(null);
},
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => fleetApi.updateCoach(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['coaches'] });
setShowModal(false);
setEditingCoach(null);
},
});
const deleteMutation = useMutation({
mutationFn: fleetApi.deleteCoach,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['coaches'] });
},
});
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const coachData = {
coachNumber: formData.get('coachNumber') as string,
label: formData.get('label') as string,
seatClassId: formData.get('seatClassId') as string,
coachType: formData.get('coachType') as string,
mode: formData.get('mode') as string,
seatArrangement: formData.get('seatArrangement') as string,
totalUnits: parseInt(formData.get('totalUnits') as string),
isActive: formData.get('isActive') === 'true',
};
if (editingCoach) {
await updateMutation.mutateAsync({ id: editingCoach.id, data: coachData });
} else {
await createMutation.mutateAsync(coachData);
}
};
const handleDelete = async (coach: any) => {
if (confirm(`Are you sure you want to delete coach ${coach.coachNumber}?`)) {
await deleteMutation.mutateAsync(coach.id);
}
};
const coaches = data?.items || data?.data || [];
const columns = [
{
key: 'coachNumber',
label: 'Coach Number',
sortable: true,
render: (coach: any) => (
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-[rgb(20,113,76)]">
<Grid3x3 className="h-4 w-4 text-white" />
</div>
<span className="font-medium">{coach.coachNumber}</span>
</div>
),
},
{
key: 'seatClass',
label: 'Seat Class',
render: (coach: any) => {
const seatClass = coach.seatClass?.name || coach.serviceClass || 'N/A';
const colorMap: Record<string, string> = {
'ECONOMY_REGULAR': 'edr-badge-info',
'ECONOMY_BED': 'edr-badge-warning',
'VIP_BED': 'edr-badge-success',
};
return (
<span className={`edr-badge ${colorMap[seatClass] || 'edr-badge-info'}`}>
{seatClass.replace(/_/g, ' ')}
</span>
);
},
},
{
key: 'totalSeats',
label: 'Total Seats',
render: (coach: any) => (
<span className="font-mono text-sm">{coach.totalSeats || coach.totalUnits || 0}</span>
),
},
{
key: 'layout',
label: 'Layout',
render: (coach: any) => (
<span className="text-sm text-muted-foreground">
{coach.layout || coach.seatLayout || coach.seatArrangement || 'N/A'}
</span>
),
},
{
key: 'status',
label: 'Status',
render: (coach: any) => {
const status = coach.isActive ? 'ACTIVE' : 'INACTIVE';
const statusMap: Record<string, string> = {
ACTIVE: 'edr-badge-success',
MAINTENANCE: 'edr-badge-warning',
INACTIVE: 'edr-badge-danger',
};
return (
<span className={`edr-badge ${statusMap[status] || 'edr-badge-info'}`}>
{status}
</span>
);
},
},
];
const actions = [
{
label: 'Edit',
onClick: (coach: any) => {
setEditingCoach(coach);
setShowModal(true);
},
variant: 'secondary' as const,
icon: Edit,
},
{
label: 'Delete',
onClick: handleDelete,
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">Coach Management</h1>
<p className="text-muted-foreground mt-1">Manage train coaches and configurations</p>
</div>
<ActionButton
icon={Plus}
onClick={() => {
setEditingCoach(null);
setShowModal(true);
}}
>
Add Coach
</ActionButton>
</div>
<div className="card">
<div className="flex items-center gap-4 mb-6">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search coaches..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="input pl-10"
/>
</div>
</div>
<DataTable
columns={columns}
data={coaches}
actions={actions}
isLoading={isLoading}
/>
</div>
{/* Add/Edit Modal */}
<Modal
isOpen={showModal}
onClose={() => {
setShowModal(false);
setEditingCoach(null);
}}
title={`${editingCoach ? 'Edit' : 'Add'} Coach`}
size="lg"
>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="label">Coach Number *</label>
<input
type="text"
name="coachNumber"
className="input"
defaultValue={editingCoach?.coachNumber}
required
placeholder="e.g., C001"
/>
</div>
<div>
<label className="label">Label *</label>
<input
type="text"
name="label"
className="input"
defaultValue={editingCoach?.label}
required
placeholder="e.g., Coach 1"
/>
</div>
<div>
<label className="label">Coach Type</label>
<select name="coachType" className="input" defaultValue={editingCoach?.coachType}>
<option value="passenger">Passenger</option>
<option value="sleeper">Sleeper</option>
<option value="dining">Dining</option>
<option value="baggage">Baggage</option>
</select>
</div>
<div>
<label className="label">Mode *</label>
<select name="mode" className="input" defaultValue={editingCoach?.mode || 'seat'}>
<option value="seat">Seat</option>
<option value="bed">Bed</option>
<option value="convertible">Convertible</option>
</select>
</div>
<div>
<label className="label">Seat Arrangement</label>
<input
type="text"
name="seatArrangement"
className="input"
defaultValue={editingCoach?.seatArrangement}
placeholder="e.g., 2+2"
/>
</div>
<div>
<label className="label">Total Units *</label>
<input
type="number"
name="totalUnits"
className="input"
defaultValue={editingCoach?.totalUnits}
required
min="1"
placeholder="60"
/>
</div>
<div>
<label className="label">Status</label>
<select
name="isActive"
className="input"
defaultValue={editingCoach?.isActive?.toString() || 'true'}
>
<option value="true">Active</option>
<option value="false">Inactive</option>
</select>
</div>
</div>
<div className="flex justify-end gap-2 pt-4">
<ActionButton
type="button"
variant="secondary"
onClick={() => {
setShowModal(false);
setEditingCoach(null);
}}
>
Cancel
</ActionButton>
<ActionButton
type="submit"
loading={createMutation.isPending || updateMutation.isPending}
>
{editingCoach ? 'Update' : 'Create'} Coach
</ActionButton>
</div>
</form>
</Modal>
</div>
);
}

View File

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

View File

@@ -0,0 +1,108 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { Ticket, Users, DollarSign, TrendingUp } 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';
export default function DashboardPage() {
const { data: stats, isLoading: statsLoading } = useQuery({
queryKey: ['dashboard-stats'],
queryFn: dashboardApi.getStats,
});
const { data: revenueData, isLoading: revenueLoading } = useQuery({
queryKey: ['revenue-chart'],
queryFn: () => dashboardApi.getRevenueChart(30),
});
const { data: recentBookingsData, isLoading: bookingsLoading } = useQuery({
queryKey: ['recent-bookings'],
queryFn: () => dashboardApi.getRecentBookings(10),
});
const recentBookings = Array.isArray(recentBookingsData)
? recentBookingsData
: recentBookingsData?.items || recentBookingsData?.data || [];
const columns = [
{ 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',
render: (item: any) => (
<Badge variant="status" status={item.status}>
{item.status}
</Badge>
)
},
{ key: 'createdAt', label: 'Created', render: (item: any) => formatDateTime(item.createdAt) },
];
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-foreground">Dashboard</h1>
<p className="text-muted-foreground mt-1">Welcome back! Here's what's happening today.</p>
</div>
<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"
/>
<StatCard
title="Total Revenue"
value={statsLoading ? '...' : formatCurrency(stats?.totalRevenue || 0, 'ETB')}
icon={DollarSign}
color="green"
/>
<StatCard
title="Total Passengers"
value={statsLoading ? '...' : (stats?.totalPassengers || 0).toLocaleString()}
icon={Users}
color="purple"
/>
<StatCard
title="Occupancy Rate"
value={statsLoading ? '...' : `${stats?.occupancyRate || 0}%`}
icon={TrendingUp}
color="green"
/>
</div>
{!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>
)}
<div className="card">
<h2 className="mb-4 text-lg font-semibold text-foreground">Recent Bookings</h2>
<DataTable
data={recentBookings}
columns={columns}
loading={bookingsLoading}
emptyMessage="No recent bookings"
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import DashboardLayout from '../dashboard/layout';
export default function Layout({ children }: { children: React.ReactNode }) {
return <DashboardLayout>{children}</DashboardLayout>;
}

View File

@@ -0,0 +1,67 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Download } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import { foodApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils';
export default function FoodPage() {
const [filters, setFilters] = useState({ search: '', status: '' });
const { data, isLoading } = useQuery({
queryKey: ['food', filters],
queryFn: () => foodApi.getOrders(filters),
});
const columns = [
{ key: 'orderNumber', label: 'Order #', render: (order: any) => <span className="font-mono">{order.orderNumber || order.id?.substring(0, 8)}</span> },
{ key: 'passenger', label: 'Passenger', render: (order: any) => order.passenger?.fullName || 'N/A' },
{ key: 'items', label: 'Items', render: (order: any) => order.items?.length || 0 },
{ key: 'totalMinor', label: 'Total', render: (order: any) => formatCurrency(order.totalMinor, 'ETB') },
{ key: 'status', label: 'Status', render: (order: any) => <Badge variant="status" status={order.status}>{order.status}</Badge> },
];
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Food & Dining</h1>
<p className="text-muted-foreground">Manage food orders and menu items</p>
</div>
<ActionButton icon={Download} variant="secondary">Export</ActionButton>
</div>
<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">Status</label>
<select className="input" value={filters.status} onChange={(e) => setFilters({ ...filters, status: e.target.value })}>
<option value="">All Status</option>
<option value="PENDING">Pending</option>
<option value="PREPARING">Preparing</option>
<option value="READY">Ready</option>
<option value="DELIVERED">Delivered</option>
</select>
</div>
</div>
</div>
<DataTable
data={data?.items || data || []}
columns={columns}
loading={isLoading}
emptyMessage="No food & dining found"
/>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import DashboardLayout from '../dashboard/layout';
export default function Layout({ children }: { children: React.ReactNode }) {
return <DashboardLayout>{children}</DashboardLayout>;
}

View File

@@ -0,0 +1,179 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { AlertTriangle, CheckCircle, Ban } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import { fraudApi } from '@/lib/api';
import { formatDateTime } from '@/lib/utils';
export default function FraudDetectionPage() {
const [filters, setFilters] = useState({ search: '', severity: '', status: '' });
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ['fraud-alerts', filters],
queryFn: () => fraudApi.getAlerts(filters),
});
const acknowledgeMutation = useMutation({
mutationFn: fraudApi.acknowledgeAlert,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['fraud-alerts'] });
alert('Alert acknowledged');
},
});
const blockUserMutation = useMutation({
mutationFn: ({ userId, reason }: any) => fraudApi.blockUser(userId, { reason }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['fraud-alerts'] });
alert('User blocked successfully');
},
});
const handleAcknowledge = async (alert: any) => {
await acknowledgeMutation.mutateAsync(alert.id);
};
const handleBlockUser = async (alert: any) => {
if (confirm(`Block user ${alert.user?.email}?`)) {
await blockUserMutation.mutateAsync({
userId: alert.userId,
reason: `Fraud alert: ${alert.ruleType}`,
});
}
};
const columns = [
{
key: 'severity',
label: 'Severity',
render: (alert: any) => (
<Badge variant="status" status={alert.severity === 'HIGH' ? 'CANCELLED' : alert.severity === 'MEDIUM' ? 'PENDING' : 'CONFIRMED'}>
{alert.severity}
</Badge>
),
},
{
key: 'ruleType',
label: 'Rule Type',
render: (alert: any) => (
<div className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-[rgb(20,113,76)]" />
<span>{alert.ruleType}</span>
</div>
),
},
{
key: 'user',
label: 'User',
render: (alert: any) => (
<div>
<div className="font-medium">{alert.user?.fullName || 'N/A'}</div>
<div className="text-sm text-muted-foreground">{alert.user?.email || 'N/A'}</div>
</div>
),
},
{
key: 'description',
label: 'Description',
render: (alert: any) => (
<span className="text-sm">{alert.description || alert.details}</span>
),
},
{
key: 'status',
label: 'Status',
render: (alert: any) => (
<Badge variant="status" status={alert.acknowledged ? 'CONFIRMED' : 'PENDING'}>
{alert.acknowledged ? 'Acknowledged' : 'Pending'}
</Badge>
),
},
{
key: 'createdAt',
label: 'Detected',
sortable: true,
render: (alert: any) => formatDateTime(alert.createdAt),
},
];
const actions = [
{
label: 'Acknowledge',
onClick: handleAcknowledge,
variant: 'primary' as const,
icon: CheckCircle,
show: (alert: any) => !alert.acknowledged,
},
{
label: 'Block User',
onClick: handleBlockUser,
variant: 'danger' as const,
icon: Ban,
},
];
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Fraud Detection</h1>
<p className="text-muted-foreground">Monitor and manage fraud alerts</p>
</div>
</div>
<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 alerts..."
className="input"
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
/>
</div>
<div>
<label className="label">Severity</label>
<select
className="input"
value={filters.severity}
onChange={(e) => setFilters({ ...filters, severity: e.target.value })}
>
<option value="">All Severities</option>
<option value="LOW">Low</option>
<option value="MEDIUM">Medium</option>
<option value="HIGH">High</option>
<option value="CRITICAL">Critical</option>
</select>
</div>
<div>
<label className="label">Status</label>
<select
className="input"
value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
>
<option value="">All Status</option>
<option value="pending">Pending</option>
<option value="acknowledged">Acknowledged</option>
</select>
</div>
</div>
</div>
<DataTable
data={data?.items || []}
columns={columns}
actions={actions}
loading={isLoading}
emptyMessage="No fraud alerts found"
/>
</div>
);
}

View File

@@ -0,0 +1,40 @@
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import '@/styles/globals.css';
import Providers from './providers';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: 'EDR Passenger Back-office',
description: 'Ethio-Djibouti Railway Passenger Back-office',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
try {
const stored = localStorage.getItem('edr-theme');
const theme = stored ? JSON.parse(stored).state.isDark : window.matchMedia('(prefers-color-scheme: dark)').matches;
if (theme) document.documentElement.classList.add('dark');
} catch (e) {}
})();
`,
}}
/>
</head>
<body className={inter.className}>
<Providers>{children}</Providers>
</body>
</html>
);
}

View File

@@ -0,0 +1,5 @@
import DashboardLayout from '../dashboard/layout';
export default function Layout({ children }: { children: React.ReactNode }) {
return <DashboardLayout>{children}</DashboardLayout>;
}

View File

@@ -0,0 +1,44 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Plus, MapPin } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import ActionButton from '@/components/ui/ActionButton';
export default function Page() {
const [filters, setFilters] = useState({ search: '' });
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Live Tracking</h1>
<p className="text-muted-foreground">Real-time train tracking and status</p>
</div>
<ActionButton icon={Plus}>Add New</ActionButton>
</div>
<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>
</div>
<div className="card">
<p className="text-center text-muted-foreground py-12">
Live Tracking module - Connect to API endpoint
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,105 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/lib/auth-store';
import { Train } from 'lucide-react';
export default function LoginPage() {
const [email, setEmail] = useState('admin@edr-platform.com');
const [password, setPassword] = useState('admin123');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const router = useRouter();
const { login } = useAuthStore();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
try {
await login(email, password);
router.push('/dashboard');
} catch (err: any) {
const message = err.response?.data?.message || err.message || 'Login failed. Please check your credentials.';
setError(message);
} finally {
setLoading(false);
}
};
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>
</div>
<h1 className="text-5xl font-bold text-white mb-4">EDR</h1>
<p className="text-lg text-white/80">Passenger Back-office</p>
</div>
</div>
{/* Login Form Side */}
<div className="flex w-full lg:w-1/2 items-center justify-center bg-gray-100 dark:bg-gray-900 p-8">
<div className="w-full max-w-md">
<div className="card">
<div className="mb-6">
<div className="mb-4 flex justify-center lg:hidden">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-[rgb(20,113,76)] shadow-lg">
<Train className="h-6 w-6 text-white" />
</div>
<div className="text-4xl font-bold text-gray-900 dark:text-white ps-4">EDR</div>
</div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">Sign in to get started.</h2>
</div>
{error && (
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 p-4 text-sm text-red-800 dark:text-red-200">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="label">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="input"
required
/>
</div>
<div>
<label className="label">Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="input"
required
/>
</div>
<button
type="submit"
disabled={loading}
className="btn btn-primary w-full disabled:opacity-50"
>
{loading ? 'Signing in...' : 'Sign In'}
</button>
</form>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import DashboardLayout from '../dashboard/layout';
export default function Layout({ children }: { children: React.ReactNode }) {
return <DashboardLayout>{children}</DashboardLayout>;
}

View File

@@ -0,0 +1,66 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Download } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import { loyaltyApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils';
export default function LoyaltyPage() {
const [filters, setFilters] = useState({ search: '', tier: '' });
const { data, isLoading } = useQuery({
queryKey: ['loyalty', filters],
queryFn: () => loyaltyApi.getAccounts(filters),
});
const columns = [
{ key: 'passenger', label: 'Passenger', render: (account: any) => account.passenger?.fullName || 'N/A' },
{ key: 'tier', label: 'Tier', render: (account: any) => <Badge>{account.tier}</Badge> },
{ key: 'pointsBalance', label: 'Points', render: (account: any) => account.pointsBalance?.toLocaleString() || 0 },
{ key: 'lifetimePoints', label: 'Lifetime Points', render: (account: any) => account.lifetimePoints?.toLocaleString() || 0 },
];
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Loyalty Program</h1>
<p className="text-muted-foreground">Manage loyalty accounts and rewards</p>
</div>
<ActionButton icon={Download} variant="secondary">Export</ActionButton>
</div>
<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">Tier</label>
<select className="input" value={filters.tier} onChange={(e) => setFilters({ ...filters, tier: e.target.value })}>
<option value="">All Tiers</option>
<option value="BRONZE">Bronze</option>
<option value="SILVER">Silver</option>
<option value="GOLD">Gold</option>
<option value="PLATINUM">Platinum</option>
</select>
</div>
</div>
</div>
<DataTable
data={data?.items || data || []}
columns={columns}
loading={isLoading}
emptyMessage="No loyalty program found"
/>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import DashboardLayout from '../dashboard/layout';
export default function NotificationsLayout({ children }: { children: React.ReactNode }) {
return <DashboardLayout>{children}</DashboardLayout>;
}

View File

@@ -0,0 +1,135 @@
'use client';
import { useState } from 'react';
import { Plus, Send } from 'lucide-react';
import Table from '@/components/ui/Table';
import Badge from '@/components/ui/Badge';
import Modal from '@/components/ui/Modal';
const templates = [
{ id: '1', name: 'Booking Confirmation', channel: 'EMAIL', subject: 'Your booking is confirmed', active: true },
{ id: '2', name: 'Payment Receipt', channel: 'EMAIL', subject: 'Payment received', active: true },
{ id: '3', name: 'Trip Reminder', channel: 'SMS', body: 'Your trip is tomorrow', active: true },
{ id: '4', name: 'Cancellation Notice', channel: 'PUSH', body: 'Your booking has been cancelled', active: false },
];
export default function NotificationsPage() {
const [showModal, setShowModal] = useState(false);
const [activeTab, setActiveTab] = useState<'templates' | 'send'>('templates');
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Notifications</h1>
<p className="text-muted-foreground">Manage notification templates and send messages</p>
</div>
<button onClick={() => setShowModal(true)} className="btn btn-primary flex items-center gap-2">
<Plus className="h-4 w-4" />
New Template
</button>
</div>
<div className="flex gap-2 border-b border-border">
<button
onClick={() => setActiveTab('templates')}
className={`px-4 py-2 font-medium ${activeTab === 'templates' ? 'border-b-2 border-primary text-primary' : 'text-muted-foreground'}`}
>
Templates
</button>
<button
onClick={() => setActiveTab('send')}
className={`px-4 py-2 font-medium ${activeTab === 'send' ? 'border-b-2 border-primary text-primary' : 'text-muted-foreground'}`}
>
Send Notification
</button>
</div>
{activeTab === 'templates' ? (
<div className="card">
<Table
data={templates}
columns={[
{ key: 'name', label: 'Template Name' },
{ key: 'channel', label: 'Channel', render: (item) => (
<Badge>{item.channel}</Badge>
)},
{ key: 'subject', label: 'Subject/Body', render: (item) => item.subject || item.body },
{ key: 'active', label: 'Status', render: (item) => (
<Badge variant="status" status={item.active ? 'CONFIRMED' : 'CANCELLED'}>
{item.active ? 'Active' : 'Inactive'}
</Badge>
)},
]}
/>
</div>
) : (
<div className="card">
<form className="space-y-4">
<div>
<label className="label">Recipient Type</label>
<select className="input">
<option>All Passengers</option>
<option>Specific Passenger</option>
<option>Booking Reference</option>
</select>
</div>
<div>
<label className="label">Channel</label>
<select className="input">
<option>Email</option>
<option>SMS</option>
<option>Push Notification</option>
</select>
</div>
<div>
<label className="label">Subject</label>
<input type="text" className="input" placeholder="Enter subject" />
</div>
<div>
<label className="label">Message</label>
<textarea className="input" rows={6} placeholder="Enter message content"></textarea>
</div>
<button type="submit" className="btn btn-primary flex items-center gap-2">
<Send className="h-4 w-4" />
Send Notification
</button>
</form>
</div>
)}
<Modal isOpen={showModal} onClose={() => setShowModal(false)} title="Create Notification Template">
<form className="space-y-4">
<div>
<label className="label">Template Name</label>
<input type="text" className="input" placeholder="Enter template name" />
</div>
<div>
<label className="label">Channel</label>
<select className="input">
<option>Email</option>
<option>SMS</option>
<option>Push Notification</option>
</select>
</div>
<div>
<label className="label">Subject</label>
<input type="text" className="input" placeholder="Enter subject" />
</div>
<div>
<label className="label">Body</label>
<textarea className="input" rows={4} placeholder="Enter template body"></textarea>
</div>
<div className="flex justify-end gap-2">
<button type="button" onClick={() => setShowModal(false)} className="btn btn-secondary">
Cancel
</button>
<button type="submit" className="btn btn-primary">
Create Template
</button>
</div>
</form>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import DashboardLayout from '../dashboard/layout';
export default function Layout({ children }: { children: React.ReactNode }) {
return <DashboardLayout>{children}</DashboardLayout>;
}

View File

@@ -0,0 +1,65 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Download } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import { reportsApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils';
export default function OperationalreportsPage() {
const [filters, setFilters] = useState({ search: '', reportType: '' });
const { data, isLoading } = useQuery({
queryKey: ['operational-reports', filters],
queryFn: () => reportsApi.getOperationalReports(filters),
});
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) },
];
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>
</div>
<ActionButton icon={Download} variant="secondary">Export</ActionButton>
</div>
<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>
</div>
<DataTable
data={data?.items || data || []}
columns={columns}
loading={isLoading}
emptyMessage="No operational reports found"
/>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function Home() {
redirect('/login');
}

View File

@@ -0,0 +1,5 @@
import DashboardLayout from '../dashboard/layout';
export default function PassengersLayout({ children }: { children: React.ReactNode }) {
return <DashboardLayout>{children}</DashboardLayout>;
}

View File

@@ -0,0 +1,135 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { UserPlus, Download, Eye } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import Pagination from '@/components/ui/Pagination';
import ActionButton from '@/components/ui/ActionButton';
import { passengersApi } from '@/lib/api';
import { formatDate } from '@/lib/utils';
import { PassengerFilters } from '@/types';
export default function PassengersPage() {
const [filters, setFilters] = useState<PassengerFilters>({
page: 1,
pageSize: 20,
search: '',
});
const { data, isLoading, error } = useQuery({
queryKey: ['passengers', filters],
queryFn: () => passengersApi.getAll(filters),
});
if (error) {
console.error('Passengers API Error:', error);
}
const columns = [
{
key: 'fullName',
label: 'Name',
sortable: true,
render: (passenger: any) => (
<div>
<div className="font-medium">{passenger.fullName}</div>
<div className="text-sm text-muted-foreground">{passenger.email}</div>
</div>
),
},
{
key: 'phone',
label: 'Phone',
render: (passenger: any) => passenger.phone,
},
{
key: 'nationalId',
label: 'National ID',
render: (passenger: any) => passenger.nationalId || 'N/A',
},
{
key: 'dateOfBirth',
label: 'Date of Birth',
render: (passenger: any) => passenger.dateOfBirth ? formatDate(passenger.dateOfBirth) : 'N/A',
},
{
key: 'verified',
label: 'Status',
render: (passenger: any) => (
<Badge variant="status" status={passenger.nationalId ? 'CONFIRMED' : 'PENDING'}>
{passenger.nationalId ? 'Verified' : 'Unverified'}
</Badge>
),
},
];
const actions = [
// TODO: Create passenger detail page
// {
// label: 'View Details',
// onClick: (passenger: any) => window.location.href = `/passengers/${passenger.id}`,
// variant: 'secondary' as const,
// icon: Eye,
// },
];
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Passengers</h1>
<p className="text-muted-foreground">Manage passenger profiles and verification</p>
</div>
<div className="flex gap-2">
<ActionButton variant="export" icon={Download}>Export</ActionButton>
</div>
</div>
<div className="card">
{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 loading passengers: {error instanceof Error ? error.message : 'Unknown error'}
</div>
)}
<div className="mb-4 flex flex-wrap gap-4">
<div className="flex-1">
<input
type="text"
placeholder="Search by name, email, or phone..."
className="input"
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value, page: 1 })}
/>
</div>
<select
className="input w-48"
value={filters.verified?.toString() || ''}
onChange={(e) => setFilters({ ...filters, verified: e.target.value ? e.target.value === 'true' : undefined, page: 1 })}
>
<option value="">All Passengers</option>
<option value="true">Verified</option>
<option value="false">Unverified</option>
</select>
</div>
<DataTable
data={data?.items || []}
columns={columns}
actions={actions}
loading={isLoading}
emptyMessage="No passengers found"
/>
{data?.meta && (
<Pagination
currentPage={data.meta.page}
totalPages={data.meta.totalPages}
onPageChange={(page) => setFilters({ ...filters, page })}
/>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import DashboardLayout from '../dashboard/layout';
export default function Layout({ children }: { children: React.ReactNode }) {
return <DashboardLayout>{children}</DashboardLayout>;
}

View File

@@ -0,0 +1,67 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Download } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import { paymentsApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils';
export default function PaymentsPage() {
const [filters, setFilters] = useState({ search: '', status: '', method: '' });
const { data, isLoading } = useQuery({
queryKey: ['payments', filters],
queryFn: () => paymentsApi.getAll(filters),
});
const columns = [
{ key: 'reference', label: 'Reference', render: (payment: any) => <span className="font-mono">{payment.reference || payment.id?.substring(0, 8)}</span> },
{ key: 'booking', label: 'Booking', render: (payment: any) => payment.booking?.bookingRef || 'N/A' },
{ key: 'amount', label: 'Amount', render: (payment: any) => formatCurrency(payment.amountMinor, payment.currency) },
{ key: 'method', label: 'Method', render: (payment: any) => <Badge>{payment.method}</Badge> },
{ key: 'status', label: 'Status', render: (payment: any) => <Badge variant="status" status={payment.status}>{payment.status}</Badge> },
{ key: 'createdAt', label: 'Created', render: (payment: any) => formatDateTime(payment.createdAt) },
];
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Payments</h1>
<p className="text-muted-foreground">Manage payment transactions and refunds</p>
</div>
<ActionButton icon={Download} variant="secondary">Export</ActionButton>
</div>
<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">Status</label>
<select className="input" value={filters.status} onChange={(e) => setFilters({ ...filters, status: e.target.value })}>
<option value="">All Status</option>
<option value="PENDING">Pending</option>
<option value="COMPLETED">Completed</option>
<option value="FAILED">Failed</option>
</select>
</div>
</div>
</div>
<DataTable
data={data?.items || data || []}
columns={columns}
loading={isLoading}
emptyMessage="No payments found"
/>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import DashboardLayout from '../dashboard/layout';
export default function PricingLayout({ children }: { children: React.ReactNode }) {
return <DashboardLayout>{children}</DashboardLayout>;
}

View File

@@ -0,0 +1,93 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Save } from 'lucide-react';
import Table from '@/components/ui/Table';
import { routesApi } from '@/lib/api/routes';
import { formatCurrency } from '@/lib/utils';
export default function PricingPage() {
const [selectedRoute, setSelectedRoute] = useState<string>('1');
const { data: fareRules } = useQuery({
queryKey: ['fare-rules', selectedRoute],
queryFn: () => routesApi.getFareRules(selectedRoute),
initialData: [
{ id: '1', routeId: '1', passengerCategory: 'ADULT', serviceClass: 'ECONOMY_REGULAR', baseFare: 35000, currency: 'ETB' },
{ id: '2', routeId: '1', passengerCategory: 'CHILD', serviceClass: 'ECONOMY_REGULAR', baseFare: 0, currency: 'ETB' },
{ id: '3', routeId: '1', passengerCategory: 'ADULT', serviceClass: 'ECONOMY_BED', baseFare: 52500, currency: 'ETB' },
{ id: '4', routeId: '1', passengerCategory: 'ADULT', serviceClass: 'VIP_BED', baseFare: 70000, currency: 'ETB' },
],
});
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Pricing & Fare Rules</h1>
<p className="text-gray-600">Manage fare rules and pricing for different routes and classes</p>
</div>
<button className="btn btn-primary flex items-center gap-2">
<Save className="h-4 w-4" />
Save Changes
</button>
</div>
<div className="card">
<div className="mb-6">
<label className="label">Select Route</label>
<select
className="input w-full max-w-md"
value={selectedRoute}
onChange={(e) => setSelectedRoute(e.target.value)}
>
<option value="1">Addis Ababa - Djibouti</option>
</select>
</div>
<div className="mb-4">
<h3 className="text-lg font-semibold text-gray-900">Fare Rules</h3>
<p className="text-sm text-gray-600">Configure base fares for different passenger categories and service classes</p>
</div>
<Table
data={fareRules}
columns={[
{ key: 'passengerCategory', label: 'Passenger Category' },
{ key: 'serviceClass', label: 'Service Class' },
{ key: 'baseFare', label: 'Base Fare', render: (item) => (
<input
type="number"
defaultValue={item.baseFare}
className="input w-32"
/>
)},
{ key: 'currency', label: 'Currency' },
]}
/>
</div>
<div className="card">
<h3 className="mb-4 text-lg font-semibold text-gray-900">Pricing Rules</h3>
<div className="space-y-4">
<div className="rounded-lg bg-blue-50 p-4">
<h4 className="font-medium text-blue-900">Age-Based Pricing</h4>
<ul className="mt-2 space-y-1 text-sm text-blue-700">
<li> ADULT (5 years): Pay 100% of base fare</li>
<li> CHILD (&lt;5 years): First child travels FREE, subsequent children pay 100%</li>
</ul>
</div>
<div className="rounded-lg bg-green-50 p-4">
<h4 className="font-medium text-green-900">Multi-Currency Support</h4>
<ul className="mt-2 space-y-1 text-sm text-green-700">
<li> Transaction Currency: ETB (Ethiopian Birr)</li>
<li> Display Currencies: ETB, DJF, USD</li>
<li> Exchange rates: ETBDJF=3.25, ETBUSD=0.018</li>
</ul>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,45 @@
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useState, useEffect } from 'react';
import { useTheme } from '@/lib/theme-store';
import { useAuthStore } from '@/lib/auth-store';
function ThemeProvider({ children }: { children: React.ReactNode }) {
const { isDark, setTheme } = useTheme();
useEffect(() => {
document.documentElement.classList.toggle('dark', isDark);
}, [isDark]);
return <>{children}</>;
}
function AuthProvider({ children }: { children: React.ReactNode }) {
const initialize = useAuthStore((state) => state.initialize);
useEffect(() => {
initialize();
}, [initialize]);
return <>{children}</>;
}
export default function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
refetchOnWindowFocus: false,
},
},
}));
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>
<ThemeProvider>{children}</ThemeProvider>
</AuthProvider>
</QueryClientProvider>
);
}

View File

@@ -0,0 +1,5 @@
import DashboardLayout from '../dashboard/layout';
export default function ReportsLayout({ children }: { children: React.ReactNode }) {
return <DashboardLayout>{children}</DashboardLayout>;
}

View File

@@ -0,0 +1,127 @@
'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';
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 },
];
export default function ReportsPage() {
const [dateRange, setDateRange] = useState('last-30-days');
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>
<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>
<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>
</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="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>
</div>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import DashboardLayout from '../dashboard/layout';
export default function RoutesLayout({ children }: { children: React.ReactNode }) {
return <DashboardLayout>{children}</DashboardLayout>;
}

View File

@@ -0,0 +1,341 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Edit, Trash2, X } 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 { routesApi } from '@/lib/api/routes';
import { stationsApi } from '@/lib/api';
interface RouteStop {
stationId: string;
sequence: number;
distanceKm?: number;
}
export default function RoutesPage() {
const [showModal, setShowModal] = useState(false);
const [editingRoute, setEditingRoute] = useState<any>(null);
const [stops, setStops] = useState<RouteStop[]>([]);
const queryClient = useQueryClient();
const { data: routes, isLoading: routesLoading } = useQuery({
queryKey: ['routes'],
queryFn: async () => {
const result = await routesApi.getAll();
console.log('Routes query result:', result);
return result;
},
});
const { data: stations } = useQuery({
queryKey: ['stations'],
queryFn: stationsApi.getAll,
});
const createMutation = useMutation({
mutationFn: routesApi.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['routes'] });
setShowModal(false);
setEditingRoute(null);
},
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => routesApi.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['routes'] });
setShowModal(false);
setEditingRoute(null);
},
});
const deleteMutation = useMutation({
mutationFn: routesApi.delete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['routes'] });
},
});
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
if (stops.length < 2) {
alert('Route must have at least 2 stops');
return;
}
const stopsArray = stops.map((stop, idx) => {
const stopData: any = {
stationId: stop.stationId,
sequence: idx + 1,
};
if (idx > 0 && stop.distanceKm) {
stopData.distanceKm = stop.distanceKm;
}
return stopData;
});
const routeData = {
code: formData.get('code') as string,
name: formData.get('name') as string,
description: formData.get('description') as string || undefined,
effectiveFrom: formData.get('effectiveFrom') as string,
effectiveUntil: formData.get('effectiveUntil') as string || undefined,
stops: stopsArray,
};
console.log('Submitting route data:', JSON.stringify(routeData, null, 2));
if (editingRoute) {
await updateMutation.mutateAsync({ id: editingRoute.id, data: routeData });
} else {
await createMutation.mutateAsync(routeData);
}
};
const addStop = () => {
setStops([...stops, { stationId: '', sequence: stops.length + 1 }]);
};
const removeStop = (index: number) => {
setStops(stops.filter((_, i) => i !== index));
};
const updateStop = (index: number, field: keyof RouteStop, value: any) => {
const updated = [...stops];
updated[index] = { ...updated[index], [field]: value };
setStops(updated);
};
const handleDelete = async (route: any) => {
if (confirm(`Are you sure you want to delete ${route.name}?`)) {
await deleteMutation.mutateAsync(route.id);
}
};
const routeColumns = [
{ key: 'code', label: 'Route Code', sortable: true },
{ key: 'name', label: 'Route Name', sortable: true },
{ key: 'description', label: 'Description', render: (route: any) => route.description || 'N/A' },
{
key: 'active',
label: 'Status',
render: (route: any) => (
<Badge variant="status" status={route.active ? 'CONFIRMED' : 'CANCELLED'}>
{route.active ? 'Active' : 'Inactive'}
</Badge>
),
},
];
const routeActions = [
{
label: 'Edit',
onClick: (route: any) => {
setEditingRoute(route);
setShowModal(true);
},
variant: 'secondary' as const,
icon: Edit,
},
{
label: 'Delete',
onClick: handleDelete,
variant: 'danger' as const,
icon: Trash2,
},
];
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Routes</h1>
<p className="text-muted-foreground">Manage railway routes</p>
</div>
<ActionButton
icon={Plus}
onClick={() => {
setEditingRoute(null);
setStops([]);
setShowModal(true);
}}
>
Add Route
</ActionButton>
</div>
<DataTable
data={routes?.items || routes || []}
columns={routeColumns}
actions={routeActions}
loading={routesLoading}
emptyMessage="No routes found"
/>
{/* Add/Edit Modal */}
<Modal
isOpen={showModal}
onClose={() => {
setShowModal(false);
setEditingRoute(null);
setStops([]);
}}
title={`${editingRoute ? 'Edit' : 'Add'} Route`}
size="lg"
>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Route Code *</label>
<input
type="text"
name="code"
className="input"
defaultValue={editingRoute?.code}
required
placeholder="e.g., ADD-DJI"
disabled={!!editingRoute}
/>
</div>
<div>
<label className="label">Route Name *</label>
<input
type="text"
name="name"
className="input"
defaultValue={editingRoute?.name}
required
placeholder="e.g., Addis Ababa Djibouti"
/>
</div>
</div>
<div>
<label className="label">Description</label>
<textarea
name="description"
className="input"
rows={2}
defaultValue={editingRoute?.description}
placeholder="Main corridor via Dire Dawa"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Effective From *</label>
<input
type="datetime-local"
name="effectiveFrom"
className="input"
defaultValue={editingRoute?.effectiveFrom ? new Date(editingRoute.effectiveFrom).toISOString().slice(0, 16) : new Date().toISOString().slice(0, 16)}
required
/>
</div>
<div>
<label className="label">Effective Until</label>
<input
type="datetime-local"
name="effectiveUntil"
className="input"
defaultValue={editingRoute?.effectiveUntil ? new Date(editingRoute.effectiveUntil).toISOString().slice(0, 16) : ''}
/>
</div>
</div>
<div className="border-t pt-4">
<div className="flex items-center justify-between mb-3">
<label className="label mb-0">Route Stops *</label>
<ActionButton
type="button"
variant="secondary"
size="sm"
icon={Plus}
onClick={addStop}
>
Add Stop
</ActionButton>
</div>
{stops.length === 0 && (
<p className="text-sm text-muted-foreground mb-3">No stops added. Click "Add Stop" to begin.</p>
)}
<div className="space-y-2 max-h-64 overflow-y-auto">
{stops.map((stop, index) => (
<div key={index} className="flex gap-2 items-start p-3 bg-muted/50 rounded">
<div className="flex-shrink-0 w-8 h-8 bg-primary text-primary-foreground rounded-full flex items-center justify-center text-sm font-medium">
{index + 1}
</div>
<div className="flex-1 grid grid-cols-2 gap-2">
<div>
<select
className="input input-sm"
value={stop.stationId}
onChange={(e) => updateStop(index, 'stationId', e.target.value)}
required
>
<option value="">Select Station</option>
{stations?.items?.map((station: any) => (
<option key={station.id} value={station.id}>
{station.name} ({station.code})
</option>
))}
</select>
</div>
<div>
<input
type="number"
className="input input-sm"
placeholder={index === 0 ? 'Origin (0 km)' : 'Distance from previous (km)'}
value={stop.distanceKm || ''}
onChange={(e) => updateStop(index, 'distanceKm', e.target.value ? parseFloat(e.target.value) : undefined)}
disabled={index === 0}
min="0"
step="0.1"
/>
</div>
</div>
<button
type="button"
onClick={() => removeStop(index)}
className="flex-shrink-0 p-1 text-destructive hover:bg-destructive/10 rounded"
>
<X className="h-4 w-4" />
</button>
</div>
))}
</div>
</div>
<div className="flex justify-end gap-2 pt-4">
<ActionButton
type="button"
variant="secondary"
onClick={() => {
setShowModal(false);
setEditingRoute(null);
setStops([]);
}}
>
Cancel
</ActionButton>
<ActionButton
type="submit"
loading={createMutation.isPending || updateMutation.isPending}
>
{editingRoute ? 'Update' : 'Create'} Route
</ActionButton>
</div>
</form>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import DashboardLayout from '../dashboard/layout';
export default function Layout({ children }: { children: React.ReactNode }) {
return <DashboardLayout>{children}</DashboardLayout>;
}

View File

@@ -0,0 +1,442 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Download, Plus, Edit, Trash2, Train as TrainIcon } 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 { schedulesApi, fleetApi } from '@/lib/api';
import { routesApi } from '@/lib/api/routes';
import { formatDateTime, formatCurrency } from '@/lib/utils';
export default function SchedulesPage() {
const [filters, setFilters] = useState({ search: '', status: '' });
const [showModal, setShowModal] = useState(false);
const [showCoachModal, setShowCoachModal] = useState(false);
const [editingSchedule, setEditingSchedule] = useState<any>(null);
const [selectedSchedule, setSelectedSchedule] = useState<any>(null);
const [selectedCoaches, setSelectedCoaches] = useState<Array<{ coachId: string; positionNumber: number }>>([]);
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ['schedules', filters],
queryFn: () => schedulesApi.getAll(filters),
});
const { data: trainsData } = useQuery({
queryKey: ['trains'],
queryFn: () => fleetApi.getTrains(),
});
const { data: routesData } = useQuery({
queryKey: ['routes'],
queryFn: () => routesApi.getAll(),
});
const { data: coachesData } = useQuery({
queryKey: ['coaches'],
queryFn: () => fleetApi.getCoaches(),
});
const createMutation = useMutation({
mutationFn: schedulesApi.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['schedules'] });
setShowModal(false);
setEditingSchedule(null);
},
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => schedulesApi.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['schedules'] });
setShowModal(false);
setEditingSchedule(null);
},
});
const deleteMutation = useMutation({
mutationFn: schedulesApi.delete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['schedules'] });
},
});
const assignCoachesMutation = useMutation({
mutationFn: ({ scheduleId, coaches }: { scheduleId: string; coaches: Array<{ coachId: string; positionNumber: number }> }) =>
schedulesApi.assignCoaches(scheduleId, coaches),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['schedules'] });
setShowCoachModal(false);
setSelectedSchedule(null);
setSelectedCoaches([]);
alert('Coaches assigned successfully');
},
});
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const departureAt = formData.get('departureAt') as string;
const arrivalAt = formData.get('arrivalAt') as string;
// Convert datetime-local to ISO 8601
const departureISO = new Date(departureAt).toISOString();
const arrivalISO = new Date(arrivalAt).toISOString();
const scheduleData = {
trainId: formData.get('trainId') as string,
routeId: formData.get('routeId') as string,
departureAt: departureISO,
arrivalAt: arrivalISO,
plannedTimes: [], // Will be auto-generated by backend based on route stops
};
if (editingSchedule) {
await updateMutation.mutateAsync({ id: editingSchedule.id, data: scheduleData });
} else {
await createMutation.mutateAsync(scheduleData);
}
};
const trains = trainsData?.items || trainsData?.data || [];
const routes = routesData?.items || routesData?.data || [];
const coaches = coachesData?.items || coachesData?.data || [];
const handleDelete = async (schedule: any) => {
if (confirm('Are you sure you want to delete this schedule?')) {
await deleteMutation.mutateAsync(schedule.id);
}
};
const handleAssignCoaches = (schedule: any) => {
setSelectedSchedule(schedule);
setSelectedCoaches([]);
setShowCoachModal(true);
};
const handleToggleCoach = (coachId: string) => {
setSelectedCoaches(prev => {
const exists = prev.find(c => c.coachId === coachId);
if (exists) {
return prev.filter(c => c.coachId !== coachId);
} else {
const maxPosition = prev.length > 0 ? Math.max(...prev.map(c => c.positionNumber)) : 0;
return [...prev, { coachId, positionNumber: maxPosition + 1 }];
}
});
};
const handleSubmitCoaches = async () => {
if (selectedCoaches.length === 0) {
alert('Please select at least one coach');
return;
}
await assignCoachesMutation.mutateAsync({
scheduleId: selectedSchedule.id,
coaches: selectedCoaches,
});
};
const columns = [
{ key: 'train', label: 'Train', render: (schedule: any) => schedule.train?.name || 'N/A' },
{ key: 'route', label: 'Route', render: (schedule: any) => schedule.route?.name || `${schedule.originStation?.name || 'N/A'}${schedule.destinationStation?.name || 'N/A'}` },
{ key: 'departureAt', label: 'Departure', render: (schedule: any) => formatDateTime(schedule.departureAt) },
{
key: 'coaches',
label: 'Coaches',
render: (schedule: any) => {
const coachCount = schedule._count?.coachAssignments || 0;
if (coachCount === 0) {
return <span className="text-sm text-muted-foreground">No coaches assigned</span>;
}
return (
<div className="flex flex-wrap gap-1">
{schedule.coachAssignments?.slice(0, 3).map((assignment: any) => (
<Badge key={assignment.id} variant="status" status="CONFIRMED">
{assignment.coach?.coachNumber || 'N/A'}
</Badge>
))}
{coachCount > 3 && (
<Badge variant="status" status="PENDING">
+{coachCount - 3}
</Badge>
)}
</div>
);
}
},
{ key: 'status', label: 'Status', render: (schedule: any) => <Badge variant="status" status={schedule.status}>{schedule.status}</Badge> },
];
const actions = [
{
label: 'Assign Coaches',
onClick: handleAssignCoaches,
variant: 'primary' as const,
icon: TrainIcon,
},
{
label: 'Edit',
onClick: (schedule: any) => {
setEditingSchedule(schedule);
setShowModal(true);
},
variant: 'secondary' as const,
icon: Edit,
},
{
label: 'Delete',
onClick: handleDelete,
variant: 'danger' as const,
icon: Trash2,
},
];
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Schedules</h1>
<p className="text-muted-foreground">Manage train schedules and trips</p>
</div>
<div className="flex gap-2">
<ActionButton icon={Download} variant="secondary">Export</ActionButton>
<ActionButton
icon={Plus}
onClick={() => {
setEditingSchedule(null);
setShowModal(true);
}}
>
Add Schedule
</ActionButton>
</div>
</div>
<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">Status</label>
<select className="input" value={filters.status} onChange={(e) => setFilters({ ...filters, status: e.target.value })}>
<option value="">All Status</option>
<option value="SCHEDULED">Scheduled</option>
<option value="ACTIVE">Active</option>
<option value="COMPLETED">Completed</option>
<option value="CANCELLED">Cancelled</option>
</select>
</div>
</div>
</div>
<DataTable
data={data?.items || data || []}
columns={columns}
actions={actions}
loading={isLoading}
emptyMessage="No schedules found"
/>
{/* Add/Edit Modal */}
<Modal
isOpen={showModal}
onClose={() => {
setShowModal(false);
setEditingSchedule(null);
}}
title={`${editingSchedule ? 'Edit' : 'Add'} Schedule`}
size="lg"
>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 gap-4">
<div>
<label className="label">Train *</label>
<select
name="trainId"
className="input"
defaultValue={editingSchedule?.trainId}
required
>
<option value="">Select Train</option>
{trains.map((train: any) => (
<option key={train.id} value={train.id}>
{train.trainNumber || train.name}
</option>
))}
</select>
</div>
<div>
<label className="label">Route *</label>
<select
name="routeId"
className="input"
defaultValue={editingSchedule?.routeId}
required
>
<option value="">Select Route</option>
{routes.map((route: any) => (
<option key={route.id} value={route.id}>
{route.code} - {route.name}
</option>
))}
</select>
</div>
<div>
<label className="label">Departure Time *</label>
<input
type="datetime-local"
name="departureAt"
className="input"
defaultValue={editingSchedule?.departureAt?.slice(0, 16)}
required
/>
</div>
<div>
<label className="label">Arrival Time *</label>
<input
type="datetime-local"
name="arrivalAt"
className="input"
defaultValue={editingSchedule?.arrivalAt?.slice(0, 16)}
required
/>
</div>
<div>
<label className="label">Status</label>
<select
name="status"
className="input"
defaultValue={editingSchedule?.status || 'SCHEDULED'}
>
<option value="SCHEDULED">Scheduled</option>
<option value="ACTIVE">Active</option>
<option value="COMPLETED">Completed</option>
<option value="CANCELLED">Cancelled</option>
</select>
</div>
</div>
<div className="flex justify-end gap-2 pt-4">
<ActionButton
type="button"
variant="secondary"
onClick={() => {
setShowModal(false);
setEditingSchedule(null);
}}
>
Cancel
</ActionButton>
<ActionButton
type="submit"
loading={createMutation.isPending || updateMutation.isPending}
>
{editingSchedule ? 'Update' : 'Create'} Schedule
</ActionButton>
</div>
</form>
</Modal>
{/* Coach Assignment Modal */}
<Modal
isOpen={showCoachModal}
onClose={() => {
setShowCoachModal(false);
setSelectedSchedule(null);
setSelectedCoaches([]);
}}
title="Assign Coaches to Schedule"
size="lg"
>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Select coaches to assign to this schedule. Coaches will be ordered by selection.
</p>
<div className="grid grid-cols-1 gap-3 max-h-96 overflow-y-auto">
{coaches.map((coach: any) => {
const isSelected = selectedCoaches.some(c => c.coachId === coach.id);
const position = selectedCoaches.find(c => c.coachId === coach.id)?.positionNumber;
return (
<div
key={coach.id}
onClick={() => handleToggleCoach(coach.id)}
className={`p-4 border rounded-lg cursor-pointer transition-colors ${
isSelected
? 'border-edr-green-600 bg-edr-green-50 dark:bg-edr-green-900/20'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
<div className="flex items-center justify-between">
<div>
<div className="font-medium">{coach.label}</div>
<div className="text-sm text-muted-foreground">
{coach.coachNumber} {coach.seatClass?.name || 'N/A'} {coach.totalUnits} seats
</div>
</div>
{isSelected && (
<div className="flex items-center gap-2">
<Badge variant="status" status="CONFIRMED">
Position {position}
</Badge>
</div>
)}
</div>
</div>
);
})}
</div>
{selectedCoaches.length > 0 && (
<div className="p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div className="text-sm font-medium mb-2">Selected Coaches ({selectedCoaches.length}):</div>
<div className="flex flex-wrap gap-2">
{selectedCoaches
.sort((a, b) => a.positionNumber - b.positionNumber)
.map(sc => {
const coach = coaches.find((c: any) => c.id === sc.coachId);
return (
<Badge key={sc.coachId} variant="status" status="CONFIRMED">
{sc.positionNumber}. {coach?.label || 'Unknown'}
</Badge>
);
})}
</div>
</div>
)}
<div className="flex justify-end gap-2 pt-4">
<ActionButton
type="button"
variant="secondary"
onClick={() => {
setShowCoachModal(false);
setSelectedSchedule(null);
setSelectedCoaches([]);
}}
>
Cancel
</ActionButton>
<ActionButton
type="button"
onClick={handleSubmitCoaches}
loading={assignCoachesMutation.isPending}
disabled={selectedCoaches.length === 0}
>
Assign {selectedCoaches.length} Coach{selectedCoaches.length !== 1 ? 'es' : ''}
</ActionButton>
</div>
</div>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import DashboardLayout from '../dashboard/layout';
export default function Layout({ children }: { children: React.ReactNode }) {
return <DashboardLayout>{children}</DashboardLayout>;
}

View File

@@ -0,0 +1,217 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Download, Plus, Edit, Trash2 } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
import { seatClassesApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils';
export default function SeatClassesPage() {
const [filters, setFilters] = useState({ search: '' });
const [showModal, setShowModal] = useState(false);
const [editingSeatClass, setEditingSeatClass] = useState<any>(null);
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ['seat-classes', filters],
queryFn: () => seatClassesApi.getAll(),
});
const createMutation = useMutation({
mutationFn: seatClassesApi.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['seat-classes'] });
setShowModal(false);
setEditingSeatClass(null);
},
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => seatClassesApi.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['seat-classes'] });
setShowModal(false);
setEditingSeatClass(null);
},
});
const deleteMutation = useMutation({
mutationFn: seatClassesApi.delete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['seat-classes'] });
},
});
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const seatClassData = {
name: formData.get('name') as string,
description: formData.get('description') as string,
basePrice: Math.round(parseFloat(formData.get('basePrice') as string) * 100), // Convert to minor units
isActive: formData.get('isActive') === 'true',
};
if (editingSeatClass) {
await updateMutation.mutateAsync({ id: editingSeatClass.id, data: seatClassData });
} else {
await createMutation.mutateAsync(seatClassData);
}
};
const handleDelete = async (seatClass: any) => {
if (confirm(`Are you sure you want to delete ${seatClass.name}?`)) {
await deleteMutation.mutateAsync(seatClass.id);
}
};
const columns = [
{ key: 'name', label: 'Name', render: (cls: any) => <span className="font-medium">{cls.name}</span> },
{ key: 'description', label: 'Description', render: (cls: any) => cls.description || 'N/A' },
{ key: 'basePrice', label: 'Base Price', render: (cls: any) => formatCurrency(cls.basePrice, 'ETB') },
{ key: 'isActive', label: 'Status', render: (cls: any) => <Badge variant="status" status={cls.isActive ? 'CONFIRMED' : 'CANCELLED'}>{cls.isActive ? 'Active' : 'Inactive'}</Badge> },
];
const actions = [
{
label: 'Edit',
onClick: (seatClass: any) => {
setEditingSeatClass(seatClass);
setShowModal(true);
},
variant: 'secondary' as const,
icon: Edit,
},
{
label: 'Delete',
onClick: handleDelete,
variant: 'danger' as const,
icon: Trash2,
},
];
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Seat Classes</h1>
<p className="text-muted-foreground">Manage seat class configurations</p>
</div>
<div className="flex gap-2">
<ActionButton icon={Download} variant="secondary">Export</ActionButton>
<ActionButton
icon={Plus}
onClick={() => {
setEditingSeatClass(null);
setShowModal(true);
}}
>
Add Seat Class
</ActionButton>
</div>
</div>
<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>
</div>
<DataTable
data={data?.items || data || []}
columns={columns}
actions={actions}
loading={isLoading}
emptyMessage="No seat classes found"
/>
{/* Add/Edit Modal */}
<Modal
isOpen={showModal}
onClose={() => {
setShowModal(false);
setEditingSeatClass(null);
}}
title={`${editingSeatClass ? 'Edit' : 'Add'} Seat Class`}
size="lg"
>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 gap-4">
<div>
<label className="label">Class Name *</label>
<input
type="text"
name="name"
className="input"
defaultValue={editingSeatClass?.name}
required
placeholder="e.g., Economy Regular"
/>
</div>
<div>
<label className="label">Description</label>
<textarea
name="description"
className="input"
rows={3}
defaultValue={editingSeatClass?.description}
placeholder="Describe the seat class..."
/>
</div>
<div>
<label className="label">Base Price (ETB) *</label>
<input
type="number"
name="basePrice"
className="input"
defaultValue={editingSeatClass?.basePrice ? (editingSeatClass.basePrice / 100).toFixed(2) : ''}
required
min="0"
step="0.01"
placeholder="e.g., 450.00"
/>
<p className="text-xs text-gray-500 mt-1">Enter amount in ETB (e.g., 450.00)</p>
</div>
<div>
<label className="label">Status</label>
<select
name="isActive"
className="input"
defaultValue={editingSeatClass?.isActive?.toString() || 'true'}
>
<option value="true">Active</option>
<option value="false">Inactive</option>
</select>
</div>
</div>
<div className="flex justify-end gap-2 pt-4">
<ActionButton
type="button"
variant="secondary"
onClick={() => {
setShowModal(false);
setEditingSeatClass(null);
}}
>
Cancel
</ActionButton>
<ActionButton
type="submit"
loading={createMutation.isPending || updateMutation.isPending}
>
{editingSeatClass ? 'Update' : 'Create'} Seat Class
</ActionButton>
</div>
</form>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import DashboardLayout from '../dashboard/layout';
export default function SeatsLayout({ children }: { children: React.ReactNode }) {
return <DashboardLayout>{children}</DashboardLayout>;
}

View File

@@ -0,0 +1,173 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { seatsApi, schedulesApi } from '@/lib/api';
import DataTable from '@/components/ui/DataTable';
import ActionButton from '@/components/ui/ActionButton';
import { Search, Armchair, Lock, Unlock } from 'lucide-react';
export default function SeatsPage() {
const [search, setSearch] = useState('');
const [selectedSchedule, setSelectedSchedule] = useState('');
const queryClient = useQueryClient();
const { data: schedulesData } = useQuery({
queryKey: ['schedules'],
queryFn: () => schedulesApi.getAll(),
});
const { data, isLoading } = useQuery({
queryKey: ['seats', selectedSchedule],
queryFn: () => selectedSchedule ? seatsApi.getBySchedule(selectedSchedule) : Promise.resolve([]),
enabled: !!selectedSchedule,
});
const blockMutation = useMutation({
mutationFn: ({ seatId, reason }: any) => seatsApi.block(seatId, { reason }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['seats'] }),
});
const unblockMutation = useMutation({
mutationFn: (seatId: string) => seatsApi.unblock(seatId),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['seats'] }),
});
const seats = Array.isArray(data) ? data : data?.items || data?.data || [];
const schedules = schedulesData?.items || schedulesData?.data || [];
const columns = [
{
key: 'seatNumber',
label: 'Seat Number',
render: (seat: any) => (
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-[rgb(20,113,76)]">
<Armchair className="h-4 w-4 text-white" />
</div>
<span className="font-medium">{seat.seatNumber}</span>
</div>
),
},
{
key: 'coach',
label: 'Coach',
render: (seat: any) => (
<span className="text-sm">{seat.coach?.coachNumber || 'N/A'}</span>
),
},
{
key: 'seatClass',
label: 'Class',
render: (seat: any) => {
const seatClass = seat.coach?.serviceClass || 'N/A';
const colorMap: Record<string, string> = {
'ECONOMY_REGULAR': 'edr-badge-info',
'ECONOMY_BED': 'edr-badge-warning',
'VIP_BED': 'edr-badge-success',
};
return (
<span className={`edr-badge ${colorMap[seatClass] || 'edr-badge-info'}`}>
{seatClass.replace(/_/g, ' ')}
</span>
);
},
},
{
key: 'position',
label: 'Position',
render: (seat: any) => (
<span className="text-sm text-muted-foreground">
{seat.position || seat.seatPosition || 'N/A'}
</span>
),
},
{
key: 'status',
label: 'Status',
render: (seat: any) => {
const isBlocked = seat.isBlocked || seat.status === 'BLOCKED';
const isBooked = seat.isBooked || seat.status === 'BOOKED';
if (isBlocked) return <span className="edr-badge edr-badge-danger">Blocked</span>;
if (isBooked) return <span className="edr-badge edr-badge-warning">Booked</span>;
return <span className="edr-badge edr-badge-success">Available</span>;
},
},
];
const actions = [
{
label: 'Block',
onClick: (seat: any) => blockMutation.mutate({ seatId: seat.id, reason: 'Manual block' }),
variant: 'secondary' as const,
icon: Lock,
show: (seat: any) => !seat.isBlocked && seat.status !== 'BLOCKED',
},
{
label: 'Unblock',
onClick: (seat: any) => unblockMutation.mutate(seat.id),
variant: 'secondary' as const,
icon: Unlock,
show: (seat: any) => seat.isBlocked || seat.status === 'BLOCKED',
},
];
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">Manage seat availability and blocking</p>
</div>
</div>
<div className="card">
<div className="flex items-center gap-4 mb-6">
<div className="flex-1">
<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 routeCode = schedule.route?.code || 'N/A';
const date = schedule.departureAt ? new Date(schedule.departureAt).toLocaleDateString() : 'N/A';
return (
<option key={schedule.id} value={schedule.id}>
{trainNumber} - {routeCode} - {date}
</option>
);
})}
</select>
</div>
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search seats..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="input pl-10"
/>
</div>
</div>
{selectedSchedule ? (
<DataTable
columns={columns}
data={seats}
actions={actions}
loading={isLoading}
/>
) : (
<div className="text-center py-12 text-muted-foreground">
Select a schedule to view seats
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import DashboardLayout from '../dashboard/layout';
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
return <DashboardLayout>{children}</DashboardLayout>;
}

View File

@@ -0,0 +1,144 @@
'use client';
import { useState } from 'react';
import { Save, Users } from 'lucide-react';
import Link from 'next/link';
export default function SettingsPage() {
const [activeTab, setActiveTab] = useState<'general' | 'payment' | 'integrations'>('general');
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Settings</h1>
<p className="text-muted-foreground">Manage system settings and configurations</p>
</div>
<button className="btn btn-primary flex items-center gap-2">
<Save className="h-4 w-4" />
Save Changes
</button>
</div>
<div className="flex gap-2 border-b border-border">
<button
onClick={() => setActiveTab('general')}
className={`px-4 py-2 font-medium ${activeTab === 'general' ? 'border-b-2 border-primary text-primary' : 'text-muted-foreground'}`}
>
General
</button>
<button
onClick={() => setActiveTab('payment')}
className={`px-4 py-2 font-medium ${activeTab === 'payment' ? 'border-b-2 border-primary text-primary' : 'text-muted-foreground'}`}
>
Payment
</button>
<button
onClick={() => setActiveTab('integrations')}
className={`px-4 py-2 font-medium ${activeTab === 'integrations' ? 'border-b-2 border-primary text-primary' : 'text-muted-foreground'}`}
>
Integrations
</button>
</div>
{activeTab === 'general' && (
<div className="card space-y-4">
<div>
<label className="label">Platform Name</label>
<input type="text" className="input" defaultValue="EDR Passenger Platform" />
</div>
<div>
<label className="label">Support Email</label>
<input type="email" className="input" defaultValue="support@edr-platform.com" />
</div>
<div>
<label className="label">Support Phone</label>
<input type="tel" className="input" defaultValue="+251911234567" />
</div>
<div>
<label className="label">Default Currency</label>
<select className="input">
<option>ETB - Ethiopian Birr</option>
<option>DJF - Djiboutian Franc</option>
<option>USD - US Dollar</option>
</select>
</div>
</div>
)}
{activeTab === 'payment' && (
<div className="space-y-6">
<div className="card">
<h3 className="mb-4 text-lg font-semibold text-foreground">Payment Providers</h3>
<div className="space-y-4">
<div className="flex items-center justify-between rounded-lg border border-border p-4">
<div>
<p className="font-medium text-foreground">Telebirr</p>
<p className="text-sm text-muted-foreground">Mobile payment provider</p>
</div>
<label className="relative inline-flex cursor-pointer items-center">
<input type="checkbox" className="peer sr-only" defaultChecked />
<div className="peer h-6 w-11 rounded-full bg-gray-200 dark:bg-gray-700 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:bg-primary peer-checked:after:translate-x-full peer-checked:after:border-white"></div>
</label>
</div>
<div className="flex items-center justify-between rounded-lg border border-border p-4">
<div>
<p className="font-medium text-foreground">CBE Birr</p>
<p className="text-sm text-muted-foreground">Bank payment provider</p>
</div>
<label className="relative inline-flex cursor-pointer items-center">
<input type="checkbox" className="peer sr-only" defaultChecked />
<div className="peer h-6 w-11 rounded-full bg-gray-200 dark:bg-gray-700 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:bg-primary peer-checked:after:translate-x-full peer-checked:after:border-white"></div>
</label>
</div>
</div>
</div>
</div>
)}
{activeTab === 'integrations' && (
<div className="space-y-6">
<div className="card">
<h3 className="mb-4 text-lg font-semibold text-foreground">Verifayda 2.0 Integration</h3>
<div className="space-y-4">
<div>
<label className="label">API URL</label>
<input type="text" className="input" defaultValue="https://api.verifayda.gov.et/v2" />
</div>
<div>
<label className="label">API Key</label>
<input type="password" className="input" defaultValue="••••••••••••" />
</div>
<div className="flex items-center gap-2">
<input type="checkbox" id="verifayda-enabled" defaultChecked />
<label htmlFor="verifayda-enabled" className="text-sm text-foreground">
Enable Verifayda verification
</label>
</div>
</div>
</div>
<div className="card">
<h3 className="mb-4 text-lg font-semibold text-foreground">Corporate IAM Integration</h3>
<div className="space-y-4">
<div>
<label className="label">IAM API URL</label>
<input type="text" className="input" defaultValue="https://iam.tria-plc.com/api" />
</div>
<div>
<label className="label">API Key</label>
<input type="password" className="input" defaultValue="••••••••••••" />
</div>
<div className="flex items-center gap-2">
<input type="checkbox" id="iam-enabled" />
<label htmlFor="iam-enabled" className="text-sm text-foreground">
Enable IAM authentication
</label>
</div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,58 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Search, Edit, Trash2 } from 'lucide-react';
import ActionButton from '@/components/ui/ActionButton';
export default function UserManagementPage() {
const [searchTerm, setSearchTerm] = useState('');
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground">User Management</h1>
<p className="text-muted-foreground mt-1">Manage system users and permissions</p>
</div>
</div>
<div className="card">
<div className="flex gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
type="text"
placeholder="Search users by name or email..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="input pl-10"
/>
</div>
</div>
</div>
<div className="card">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border">
<th className="px-4 py-3 text-left text-sm font-semibold text-foreground">Name</th>
<th className="px-4 py-3 text-left text-sm font-semibold text-foreground">Email</th>
<th className="px-4 py-3 text-left text-sm font-semibold text-foreground">Role</th>
<th className="px-4 py-3 text-left text-sm font-semibold text-foreground">Status</th>
</tr>
</thead>
<tbody>
<tr>
<td colSpan={4} className="px-4 py-8 text-center text-muted-foreground">
User management coming soon
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import DashboardLayout from '../dashboard/layout';
export default function StationsLayout({ children }: { children: React.ReactNode }) {
return <DashboardLayout>{children}</DashboardLayout>;
}

View File

@@ -0,0 +1,350 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { MapPin, 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';
import Modal from '@/components/ui/Modal';
import { stationsApi } from '@/lib/api';
import { Station } from '@/types';
export default function StationsPage() {
const [filters, setFilters] = useState({ search: '', country: '', operational: '' });
const [showModal, setShowModal] = useState(false);
const [editingStation, setEditingStation] = useState<any>(null);
const queryClient = useQueryClient();
const { data, isLoading, error } = useQuery({
queryKey: ['stations', filters],
queryFn: () => stationsApi.getAll(filters),
});
const createMutation = useMutation({
mutationFn: stationsApi.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['stations'] });
setShowModal(false);
setEditingStation(null);
},
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => stationsApi.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['stations'] });
setShowModal(false);
setEditingStation(null);
},
});
const deleteMutation = useMutation({
mutationFn: stationsApi.delete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['stations'] });
},
});
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const stationData = {
code: formData.get('code') as string,
name: formData.get('name') as string,
city: formData.get('city') as string,
countryCode: formData.get('countryCode') as string,
lat: parseFloat(formData.get('lat') as string) || null,
lng: parseFloat(formData.get('lng') as string) || null,
timezone: formData.get('timezone') as string,
isOperational: formData.get('isOperational') === 'true',
};
if (editingStation) {
await updateMutation.mutateAsync({ id: editingStation.id, data: stationData });
} else {
await createMutation.mutateAsync(stationData);
}
};
const handleDelete = async (station: any) => {
if (confirm(`Are you sure you want to delete ${station.name}?`)) {
await deleteMutation.mutateAsync(station.id);
}
};
const columns = [
{
key: 'code',
label: 'Code',
sortable: true,
render: (station: any) => (
<span className="font-mono font-semibold">{station.code || 'N/A'}</span>
),
},
{
key: 'name',
label: 'Station Name',
sortable: true,
render: (station: any) => (
<div>
<div className="font-medium">{station.name || 'N/A'}</div>
<div className="text-sm text-muted-foreground">{station.city || 'N/A'}</div>
</div>
),
},
{
key: 'countryCode',
label: 'Country',
render: (station: any) => (
<div className="flex items-center gap-2">
<Globe className="h-4 w-4 text-muted-foreground" />
<span>{station.countryCode || 'N/A'}</span>
</div>
),
},
{
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: 'isOperational',
label: 'Status',
render: (station: any) => (
<Badge variant="status" status={station.isOperational ? 'CONFIRMED' : 'CANCELLED'}>
{station.isOperational ? 'Operational' : 'Closed'}
</Badge>
),
},
{
key: 'timezone',
label: 'Timezone',
render: (station: any) => (
<span className="text-sm">{station.timezone || 'N/A'}</span>
),
},
];
const actions = [
{
label: 'Edit',
onClick: (station: any) => {
setEditingStation(station);
setShowModal(true);
},
variant: 'secondary' as const,
icon: Edit,
},
{
label: 'Delete',
onClick: handleDelete,
variant: 'danger' as const,
icon: Trash2,
},
];
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Stations</h1>
<p className="text-muted-foreground">Manage railway stations and their operational status</p>
</div>
<ActionButton
icon={Plus}
onClick={() => {
setEditingStation(null);
setShowModal(true);
}}
>
Add Station
</ActionButton>
</div>
{/* Filters */}
<div className="card">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<input
type="text"
placeholder="Search stations..."
className="input"
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
/>
</div>
<div>
<select
className="input"
value={filters.country}
onChange={(e) => setFilters({ ...filters, country: e.target.value })}
>
<option value="">All Countries</option>
<option value="ET">Ethiopia</option>
<option value="DJ">Djibouti</option>
</select>
</div>
<div>
<select
className="input"
value={filters.operational}
onChange={(e) => setFilters({ ...filters, operational: e.target.value })}
>
<option value="">All Status</option>
<option value="true">Operational</option>
<option value="false">Closed</option>
</select>
</div>
</div>
</div>
{/* Stations Table */}
<DataTable
data={data?.items || []}
columns={columns}
actions={actions}
loading={isLoading}
emptyMessage="No stations found"
/>
{/* Add/Edit Modal */}
<Modal
isOpen={showModal}
onClose={() => {
setShowModal(false);
setEditingStation(null);
}}
title={`${editingStation ? 'Edit' : 'Add'} Station`}
size="lg"
>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="label">Station Code *</label>
<input
type="text"
name="code"
className="input"
defaultValue={editingStation?.code}
required
placeholder="e.g., ADD"
/>
</div>
<div>
<label className="label">Station Name *</label>
<input
type="text"
name="name"
className="input"
defaultValue={editingStation?.name}
required
placeholder="e.g., Addis Ababa"
/>
</div>
<div>
<label className="label">City *</label>
<input
type="text"
name="city"
className="input"
defaultValue={editingStation?.city}
required
placeholder="e.g., Addis Ababa"
/>
</div>
<div>
<label className="label">Country Code *</label>
<select
name="countryCode"
className="input"
defaultValue={editingStation?.countryCode || 'ET'}
required
>
<option value="ET">Ethiopia (ET)</option>
<option value="DJ">Djibouti (DJ)</option>
</select>
</div>
<div>
<label className="label">Latitude</label>
<input
type="number"
name="lat"
className="input"
defaultValue={editingStation?.lat}
step="0.0001"
placeholder="e.g., 9.0320"
/>
</div>
<div>
<label className="label">Longitude</label>
<input
type="number"
name="lng"
className="input"
defaultValue={editingStation?.lng}
step="0.0001"
placeholder="e.g., 38.7469"
/>
</div>
<div>
<label className="label">Timezone</label>
<input
type="text"
name="timezone"
className="input"
defaultValue={editingStation?.timezone || 'Africa/Addis_Ababa'}
placeholder="e.g., Africa/Addis_Ababa"
/>
</div>
<div>
<label className="label">Status</label>
<select
name="isOperational"
className="input"
defaultValue={editingStation?.isOperational?.toString() || 'true'}
>
<option value="true">Operational</option>
<option value="false">Closed</option>
</select>
</div>
</div>
<div className="flex justify-end gap-2 pt-4">
<ActionButton
type="button"
variant="secondary"
onClick={() => {
setShowModal(false);
setEditingStation(null);
}}
>
Cancel
</ActionButton>
<ActionButton
type="submit"
loading={createMutation.isPending || updateMutation.isPending}
>
{editingStation ? 'Update' : 'Create'} Station
</ActionButton>
</div>
</form>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import DashboardLayout from '../dashboard/layout';
export default function Layout({ children }: { children: React.ReactNode }) {
return <DashboardLayout>{children}</DashboardLayout>;
}

View File

@@ -0,0 +1,66 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Download } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import { supportApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils';
export default function SupportPage() {
const [filters, setFilters] = useState({ search: '', status: '' });
const { data, isLoading } = useQuery({
queryKey: ['support', filters],
queryFn: () => supportApi.getConversations(filters),
});
const columns = [
{ key: 'subject', label: 'Subject', render: (conv: any) => conv.subject || 'No Subject' },
{ key: 'passenger', label: 'Passenger', render: (conv: any) => conv.passenger?.fullName || 'N/A' },
{ key: 'status', label: 'Status', render: (conv: any) => <Badge variant="status" status={conv.status}>{conv.status}</Badge> },
{ key: 'createdAt', label: 'Created', render: (conv: any) => formatDateTime(conv.createdAt) },
];
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Support Center</h1>
<p className="text-muted-foreground">Manage customer support conversations</p>
</div>
<ActionButton icon={Download} variant="secondary">Export</ActionButton>
</div>
<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">Status</label>
<select className="input" value={filters.status} onChange={(e) => setFilters({ ...filters, status: e.target.value })}>
<option value="">All Status</option>
<option value="OPEN">Open</option>
<option value="IN_PROGRESS">In Progress</option>
<option value="RESOLVED">Resolved</option>
<option value="CLOSED">Closed</option>
</select>
</div>
</div>
</div>
<DataTable
data={data?.items || data || []}
columns={columns}
loading={isLoading}
emptyMessage="No support center found"
/>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import DashboardLayout from '../dashboard/layout';
export default function Layout({ children }: { children: React.ReactNode }) {
return <DashboardLayout>{children}</DashboardLayout>;
}

View File

@@ -0,0 +1,196 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Download, Eye, RefreshCw, CheckCircle } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import { ticketsApi } from '@/lib/api';
import { formatDateTime } from '@/lib/utils';
export default function TicketsPage() {
const [filters, setFilters] = useState({ search: '', status: '' });
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ['tickets', filters],
queryFn: () => ticketsApi.getAll(filters),
});
const regenerateMutation = useMutation({
mutationFn: ticketsApi.regenerate,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tickets'] });
alert('Ticket regenerated successfully');
},
});
const validateMutation = useMutation({
mutationFn: ({ ticketId, data }: any) => ticketsApi.validate(ticketId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tickets'] });
alert('Ticket validated successfully');
},
});
const handleRegenerate = async (ticket: any) => {
if (confirm(`Regenerate ticket ${ticket.ticketNumber}?`)) {
await regenerateMutation.mutateAsync(ticket.id);
}
};
const handleValidate = async (ticket: any) => {
await validateMutation.mutateAsync({
ticketId: ticket.id,
data: { validatedAt: new Date().toISOString() },
});
};
const columns = [
{
key: 'ticketNumber',
label: 'Ticket Number',
sortable: true,
render: (ticket: any) => (
<span className="font-mono font-semibold">{ticket.ticketNumber || 'N/A'}</span>
),
},
{
key: 'booking',
label: 'Booking',
render: (ticket: any) => (
<div>
<div className="font-medium">{ticket.booking?.bookingRef || 'N/A'}</div>
<div className="text-sm text-muted-foreground">
{ticket.booking?.passenger?.fullName || ticket.booking?.contactEmail || 'N/A'}
</div>
</div>
),
},
{
key: 'trip',
label: 'Trip',
render: (ticket: any) => (
<div>
<div className="font-medium">
{ticket.schedule?.originStation?.name || 'N/A'} {ticket.schedule?.destinationStation?.name || 'N/A'}
</div>
<div className="text-sm text-muted-foreground">
{ticket.schedule?.departureAt ? formatDateTime(ticket.schedule.departureAt) : 'N/A'}
</div>
</div>
),
},
{
key: 'seat',
label: 'Seat',
render: (ticket: any) => (
<span className="font-mono">{ticket.seat?.seatNumber || 'N/A'}</span>
),
},
{
key: 'status',
label: 'Status',
render: (ticket: any) => (
<Badge variant="status" status={ticket.status || 'PENDING'}>
{ticket.status || 'PENDING'}
</Badge>
),
},
{
key: 'validated',
label: 'Validated',
render: (ticket: any) => (
ticket.validatedAt ? (
<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>
</div>
) : (
<span className="text-sm text-muted-foreground">Not validated</span>
)
),
},
{
key: 'createdAt',
label: 'Created',
sortable: true,
render: (ticket: any) => formatDateTime(ticket.createdAt),
},
];
const actions = [
// TODO: Create ticket detail page
// {
// label: 'View Details',
// onClick: (ticket: any) => window.location.href = `/tickets/${ticket.id}`,
// variant: 'secondary' as const,
// icon: Eye,
// },
{
label: 'Validate',
onClick: handleValidate,
variant: 'primary' as const,
icon: CheckCircle,
show: (ticket: any) => !ticket.validatedAt,
},
{
label: 'Regenerate',
onClick: handleRegenerate,
variant: 'secondary' as const,
icon: RefreshCw,
},
];
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<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>
</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 by ticket number or booking ref..."
className="input"
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
/>
</div>
<div>
<label className="label">Status</label>
<select
className="input"
value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
>
<option value="">All Status</option>
<option value="ACTIVE">Active</option>
<option value="USED">Used</option>
<option value="CANCELLED">Cancelled</option>
<option value="EXPIRED">Expired</option>
</select>
</div>
</div>
</div>
{/* Tickets Table */}
<DataTable
data={data?.items || []}
columns={columns}
actions={actions}
loading={isLoading}
emptyMessage="No tickets found"
/>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import DashboardLayout from '../dashboard/layout';
export default function TrainsLayout({ children }: { children: React.ReactNode }) {
return <DashboardLayout>{children}</DashboardLayout>;
}

View File

@@ -0,0 +1,251 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Edit, Trash2, Train } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
import Badge from '@/components/ui/Badge';
import { fleetApi } from '@/lib/api';
import { Train as TrainType } from '@/types';
import { formatDate } from '@/lib/utils';
export default function TrainsPage() {
const [showModal, setShowModal] = useState(false);
const [editingTrain, setEditingTrain] = useState<TrainType | null>(null);
const queryClient = useQueryClient();
const { data: trainsData, isLoading: trainsLoading } = useQuery({
queryKey: ['trains'],
queryFn: () => fleetApi.getTrains(),
});
const createTrainMutation = useMutation({
mutationFn: fleetApi.createTrain,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['trains'] });
setShowModal(false);
setEditingTrain(null);
},
});
const updateTrainMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => fleetApi.updateTrain(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['trains'] });
setShowModal(false);
setEditingTrain(null);
},
});
const handleSubmit = async (formData: FormData) => {
const trainData = {
number: formData.get('number') as string,
name: formData.get('name') as string,
operatorId: formData.get('operatorId') as string,
operatorName: formData.get('operatorName') as string,
description: formData.get('description') as string,
isActive: formData.get('isActive') === 'true',
};
if (editingTrain) {
await updateTrainMutation.mutateAsync({ id: editingTrain.id, data: trainData });
} else {
await createTrainMutation.mutateAsync(trainData);
}
};
const trainColumns = [
{
key: 'number',
label: 'Train Number',
sortable: true,
render: (train: TrainType) => (
<div className="flex items-center gap-2">
<Train className="h-4 w-4 text-[rgb(20,113,76)]" />
<span className="font-mono font-semibold">{train.number}</span>
</div>
),
},
{
key: 'name',
label: 'Train Name',
sortable: true,
render: (train: TrainType) => (
<div>
<div className="font-medium">{train.name}</div>
<div className="text-sm text-gray-500">{train.description}</div>
</div>
),
},
{
key: 'operatorName',
label: 'Operator',
render: (train: TrainType) => (
<span>{train.operatorName || train.operatorId}</span>
),
},
{
key: 'isActive',
label: 'Status',
render: (train: TrainType) => (
<Badge variant="status" status={train.isActive ? 'CONFIRMED' : 'CANCELLED'}>
{train.isActive ? 'Active' : 'Inactive'}
</Badge>
),
},
{
key: 'createdAt',
label: 'Created',
render: (train: TrainType) => formatDate(train.createdAt),
},
];
const actions = [
{
label: 'Edit',
onClick: (train: TrainType) => {
setEditingTrain(train);
setShowModal(true);
},
variant: 'secondary' as const,
icon: Edit,
},
];
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Train Management</h1>
<p className="text-gray-600 dark:text-gray-400">Manage trains in the system</p>
</div>
<ActionButton
onClick={() => {
setEditingTrain(null);
setShowModal(true);
}}
icon={Plus}
>
Add Train
</ActionButton>
</div>
{/* Trains Table */}
<DataTable
data={trainsData?.items || []}
columns={trainColumns}
actions={actions}
loading={trainsLoading}
emptyMessage="No trains found"
/>
{/* Add/Edit Modal */}
<Modal
isOpen={showModal}
onClose={() => {
setShowModal(false);
setEditingTrain(null);
}}
title={`${editingTrain ? 'Edit' : 'Add'} Train`}
size="lg"
>
<form
onSubmit={async (e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
await handleSubmit(formData);
}}
className="space-y-4"
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="label">Train Number *</label>
<input
type="text"
name="number"
className="input"
defaultValue={editingTrain?.number}
required
placeholder="e.g., EDR-001"
/>
</div>
<div>
<label className="label">Train Name *</label>
<input
type="text"
name="name"
className="input"
defaultValue={editingTrain?.name}
required
placeholder="e.g., Express Service"
/>
</div>
<div>
<label className="label">Operator ID</label>
<input
type="text"
name="operatorId"
className="input"
defaultValue={editingTrain?.operatorId || 'op_edr'}
placeholder="op_edr"
/>
</div>
<div>
<label className="label">Operator Name</label>
<input
type="text"
name="operatorName"
className="input"
defaultValue={editingTrain?.operatorName}
placeholder="Ethio-Djibouti Railway"
/>
</div>
<div className="md:col-span-2">
<label className="label">Description</label>
<textarea
name="description"
className="input"
rows={3}
defaultValue={editingTrain?.description}
placeholder="Train description..."
/>
</div>
<div>
<label className="label">Status</label>
<select
name="isActive"
className="input"
defaultValue={editingTrain?.isActive?.toString() || 'true'}
>
<option value="true">Active</option>
<option value="false">Inactive</option>
</select>
</div>
</div>
<div className="flex justify-end gap-2 pt-4">
<ActionButton
type="button"
variant="secondary"
onClick={() => {
setShowModal(false);
setEditingTrain(null);
}}
>
Cancel
</ActionButton>
<ActionButton
type="submit"
loading={createTrainMutation.isPending || updateTrainMutation.isPending}
>
{editingTrain ? 'Update' : 'Create'} Train
</ActionButton>
</div>
</form>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import DashboardLayout from '../dashboard/layout';
export default function Layout({ children }: { children: React.ReactNode }) {
return <DashboardLayout>{children}</DashboardLayout>;
}

View File

@@ -0,0 +1,64 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Download } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import { verifaydaApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils';
export default function VerifaydaPage() {
const [filters, setFilters] = useState({ search: '', verified: '' });
const { data, isLoading } = useQuery({
queryKey: ['verifayda', filters],
queryFn: () => verifaydaApi.getVerifications(filters),
});
const columns = [
{ key: 'nationalId', label: 'National ID', render: (ver: any) => <span className="font-mono">{ver.nationalId}</span> },
{ key: 'fullName', label: 'Name', render: (ver: any) => ver.fullName || 'N/A' },
{ key: 'verified', label: 'Status', render: (ver: any) => <Badge variant="status" status={ver.verified ? 'CONFIRMED' : 'CANCELLED'}>{ver.verified ? 'Verified' : 'Failed'}</Badge> },
{ key: 'createdAt', label: 'Verified At', render: (ver: any) => formatDateTime(ver.createdAt) },
];
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Verifayda Integration</h1>
<p className="text-muted-foreground">Ethiopian national ID verification logs</p>
</div>
<ActionButton icon={Download} variant="secondary">Export</ActionButton>
</div>
<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 by National ID..." className="input" value={filters.search} onChange={(e) => setFilters({ ...filters, search: e.target.value })} />
</div>
<div>
<label className="label">Status</label>
<select className="input" value={filters.verified} onChange={(e) => setFilters({ ...filters, verified: e.target.value })}>
<option value="">All</option>
<option value="true">Verified</option>
<option value="false">Failed</option>
</select>
</div>
</div>
</div>
<DataTable
data={data?.items || data || []}
columns={columns}
loading={isLoading}
emptyMessage="No verifayda integration found"
/>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import DashboardLayout from '../dashboard/layout';
export default function Layout({ children }: { children: React.ReactNode }) {
return <DashboardLayout>{children}</DashboardLayout>;
}

View File

@@ -0,0 +1,55 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Download } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import { walletApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils';
export default function WalletPage() {
const [filters, setFilters] = useState({ search: '' });
const { data, isLoading } = useQuery({
queryKey: ['wallet', filters],
queryFn: () => walletApi.getAccounts(filters),
});
const columns = [
{ key: 'passenger', label: 'Passenger', render: (account: any) => account.passenger?.fullName || 'N/A' },
{ key: 'balanceMinor', label: 'Balance', render: (account: any) => formatCurrency(account.balanceMinor, 'ETB') },
{ key: 'status', label: 'Status', render: (account: any) => <Badge variant="status" status={account.isActive ? 'CONFIRMED' : 'CANCELLED'}>{account.isActive ? 'Active' : 'Inactive'}</Badge> },
];
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Wallet Management</h1>
<p className="text-muted-foreground">Manage passenger wallet accounts</p>
</div>
<ActionButton icon={Download} variant="secondary">Export</ActionButton>
</div>
<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>
</div>
<DataTable
data={data?.items || data || []}
columns={columns}
loading={isLoading}
emptyMessage="No wallet management found"
/>
</div>
);
}

View File

@@ -0,0 +1,36 @@
import { LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
interface StatCardProps {
title: string;
value: string | number;
icon: LucideIcon;
trend?: {
value: number;
isPositive: boolean;
};
color?: 'blue' | 'green' | 'purple' | 'orange';
}
const colorClasses = {
blue: 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400',
green: 'bg-green-100 text-[rgb(20,113,76)] dark:bg-green-900/30 dark:text-green-400',
purple: 'bg-purple-100 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400',
orange: 'bg-green-100 text-[rgb(20,113,76)] dark:bg-green-900/30 dark:text-green-400',
};
export default function StatCard({ title, value, icon: Icon, color = 'blue' }: StatCardProps) {
return (
<div className="card">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">{title}</p>
<p className="mt-2 text-3xl font-bold text-foreground">{value}</p>
</div>
<div className={cn('rounded-full p-3', colorClasses[color])}>
<Icon className="h-6 w-6" />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,118 @@
'use client';
import { Bell, LogOut, Moon, Sun, ChevronDown } from 'lucide-react';
import { useAuthStore } from '@/lib/auth-store';
import { useTheme } from '@/lib/theme-store';
import { useState } from 'react';
import Link from 'next/link';
export default function Header() {
const { user, logout } = useAuthStore();
const { isDark, toggleTheme } = useTheme();
const [showUserMenu, setShowUserMenu] = useState(false);
const [showNotifications, setShowNotifications] = useState(false);
return (
<header className="flex h-16 items-center justify-between border-b border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-900 px-6 shadow-sm">
<div className="flex flex-1 items-center gap-4">
<h2 className="text-xl font-semibold text-[rgb(20,113,76)] dark:text-[rgb(20,113,76)]"></h2>
</div>
<div className="flex items-center gap-3">
{/* Notifications */}
<div className="relative">
<button
onClick={() => setShowNotifications(!showNotifications)}
className="relative rounded-lg p-2 hover:bg-gray-100 dark:hover:bg-slate-800 transition-colors"
>
<Bell className="h-5 w-5 text-[rgb(20,113,76)] dark:text-slate-400" />
<span className="absolute right-1 top-1 h-2 w-2 rounded-full bg-[rgb(20,113,76)]"></span>
</button>
{showNotifications && (
<div className="absolute right-0 mt-2 w-80 rounded-lg border border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-900 shadow-xl z-50">
<div className="p-4 border-b border-gray-200 dark:border-slate-700">
<h3 className="font-semibold text-foreground">Notifications</h3>
</div>
<div className="max-h-96 overflow-y-auto">
<Link href="/notifications" className="block p-4 hover:bg-gray-50 dark:hover:bg-slate-800 transition-colors border-b border-gray-200 dark:border-slate-700">
<p className="text-sm font-medium text-foreground">New booking received</p>
<p className="text-xs text-muted-foreground mt-1">Booking #BK-2024-001 created</p>
<p className="text-xs text-muted-foreground mt-1">2 minutes ago</p>
</Link>
<Link href="/notifications" className="block p-4 hover:bg-gray-50 dark:hover:bg-slate-800 transition-colors border-b border-gray-200 dark:border-slate-700">
<p className="text-sm font-medium text-foreground">Payment confirmed</p>
<p className="text-xs text-muted-foreground mt-1">Payment of 1,250 ETB received</p>
<p className="text-xs text-muted-foreground mt-1">15 minutes ago</p>
</Link>
<Link href="/notifications" className="block p-4 hover:bg-gray-50 dark:hover:bg-slate-800 transition-colors">
<p className="text-sm font-medium text-foreground">System update</p>
<p className="text-xs text-muted-foreground mt-1">New features available</p>
<p className="text-xs text-muted-foreground mt-1">1 hour ago</p>
</Link>
</div>
<div className="p-3 border-t border-gray-200 dark:border-slate-700">
<Link href="/notifications" className="text-sm text-primary hover:underline">
View all notifications
</Link>
</div>
</div>
)}
</div>
{/* Theme Toggle */}
<button
onClick={toggleTheme}
className="rounded-lg p-2 hover:bg-gray-100 dark:hover:bg-slate-800 transition-colors"
title={isDark ? 'Switch to Light Mode' : 'Switch to Dark Mode'}
>
{isDark ? <Sun className="h-5 w-5 text-[rgb(20,113,76)] dark:text-slate-400" /> : <Moon className="h-5 w-5 text-[rgb(20,113,76)] dark:text-slate-400" />}
</button>
{/* User Menu */}
<div className="relative">
<button
onClick={() => setShowUserMenu(!showUserMenu)}
className="flex items-center gap-3 rounded-lg p-2 hover:bg-gray-100 dark:hover:bg-slate-800 transition-colors"
>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-[rgb(20,113,76)] text-white font-semibold text-sm shadow-md">
{user?.fullName?.toUpperCase().charAt(0) || 'A'}
</div>
<div className="text-left hidden md:block">
<p className="text-sm font-medium text-foreground">{user?.fullName || 'Full Name'}</p>
<p className="text-xs text-muted-foreground">{user?.role || 'User Role'}</p>
</div>
<ChevronDown className="h-4 w-4 text-[rgb(20,113,76)] dark:text-slate-400" />
</button>
{showUserMenu && (
<div className="absolute right-0 mt-2 w-56 rounded-lg border border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-900 shadow-xl z-50">
<div className="p-3 border-b border-gray-200 dark:border-slate-700">
<p className="text-sm font-medium text-foreground">{user?.fullName || 'Full Name'}</p>
<p className="text-xs text-muted-foreground">{user?.email || 'user@email.com'}</p>
</div>
<div className="p-2">
<Link
href="/settings"
className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-foreground hover:bg-gray-100 dark:hover:bg-slate-800 transition-colors"
>
Settings
</Link>
<button
onClick={() => {
logout();
window.location.href = '/login';
}}
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-[rgb(20,113,76)] dark:text-green-400 hover:bg-green-50 dark:hover:bg-green-900/20 transition-colors"
>
<LogOut className="h-4 w-4" />
Logout
</button>
</div>
</div>
)}
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,192 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useState } from 'react';
import {
LayoutDashboard,
Ticket,
Users,
Route,
DollarSign,
Bell,
BarChart3,
Settings,
LogOut,
ChevronLeft,
ChevronRight,
Train,
MapPin,
CreditCard,
Shield,
UserCheck,
Wallet,
Gift,
MessageSquare,
AlertTriangle,
FileText,
Briefcase,
Calendar,
Utensils,
Package,
Moon,
Sun,
Armchair,
Grid3x3
} from 'lucide-react';
import { useAuthStore } from '@/lib/auth-store';
import { cn } from '@/lib/utils';
import { useTheme } from '@/lib/theme-store';
const navigationSections = [
{
title: 'Overview',
items: [
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
]
},
{
title: 'Operations',
items: [
{ name: 'Bookings', href: '/bookings', icon: Ticket },
{ name: 'Passengers', href: '/passengers', icon: Users },
{ name: 'Tickets', href: '/tickets', icon: FileText },
]
},
{
title: 'Master Data',
items: [
{ name: 'Stations', href: '/stations', icon: MapPin },
{ name: 'Routes', href: '/routes', icon: Route },
{ name: 'Trains', href: '/trains', icon: Train },
{ name: 'Coaches', href: '/coaches', icon: Grid3x3 },
{ name: 'Seats', href: '/seats', icon: Armchair },
{ name: 'Schedules', href: '/schedules', icon: Calendar },
{ name: 'Seat Classes', href: '/seat-classes', icon: Settings },
]
},
{
title: 'Financial',
items: [
{ name: 'Pricing & Fares', href: '/pricing', icon: DollarSign },
{ name: 'Payments', href: '/payments', icon: CreditCard },
{ name: 'Wallet Management', href: '/wallet', icon: Wallet },
{ name: 'Promotions', href: '/promotions', icon: Gift },
]
},
{
title: 'Customer Services',
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 },
]
},
{
title: 'Security & Compliance',
items: [
{ name: 'Fraud Detection', href: '/fraud', icon: Shield },
{ name: 'Verifayda Integration', href: '/verifayda', icon: UserCheck },
{ name: 'Audit Logs', href: '/audit', icon: AlertTriangle },
]
},
{
title: 'Analytics & Reports',
items: [
{ name: 'Reports', href: '/reports', icon: BarChart3 },
{ name: 'Operational Reports', href: '/operational-reports', icon: FileText },
]
},
{
title: 'System',
items: [
{ name: 'Agent Operations', href: '/agents', icon: Briefcase },
{ name: 'User Management', href: '/settings/users', icon: Users },
{ name: 'Settings', href: '/settings', icon: Settings },
]
}
];
export default function Sidebar() {
const pathname = usePathname();
const { user, logout } = useAuthStore();
const { isDark, toggleTheme } = useTheme();
const [isCollapsed, setIsCollapsed] = useState(false);
return (
<div className={cn(
'flex h-screen flex-col transition-all duration-300',
'bg-[rgb(20,113,76)] dark:bg-gradient-to-b dark:from-slate-900 dark:via-slate-800 dark:to-slate-900',
'border-r border-[rgb(16,90,61)] dark:border-slate-700/50',
isCollapsed ? 'w-16' : 'w-72'
)}>
{/* Header */}
<div className="flex h-16 items-center justify-between px-4 border-b border-[rgb(16,90,61)] dark:border-slate-700/50">
{!isCollapsed && (
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-white/10 shadow-lg">
<Train className="h-6 w-6 text-white" />
</div>
<div>
<h1 className="text-lg font-bold text-white">EDR</h1>
<p className="text-xs text-white/70">Back-office Passenger Portal</p>
</div>
</div>
)}
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className="rounded-lg p-1.5 text-white/70 hover:bg-white/10 hover:text-white dark:text-slate-400 dark:hover:bg-slate-700/50 dark:hover:text-white transition-colors"
>
{isCollapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
</button>
</div>
{/* Navigation */}
<nav className="flex-1 overflow-y-auto px-3 py-4 space-y-6">
{navigationSections.map((section) => (
<div key={section.title}>
{!isCollapsed && (
<h3 className="mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-white/60 dark:text-slate-400">
{section.title}
</h3>
)}
<div className="space-y-1">
{section.items.map((item) => {
// Special handling for Settings to avoid conflict with User Management
let isActive;
if (item.href === '/settings') {
// Settings is active only for exact match or non-users sub-routes
isActive = pathname === '/settings' ||
(pathname?.startsWith('/settings/') && !pathname.startsWith('/settings/users'));
} else {
// Standard matching for other items
isActive = pathname === item.href || pathname?.startsWith(item.href + '/');
}
return (
<Link
key={item.name}
href={item.href}
className={cn(
'flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-200',
'hover:bg-white/10 dark:hover:bg-slate-700/50',
isActive
? 'bg-white/20 text-white shadow-md hover:bg-white/25'
: 'text-white/80 hover:text-white dark:text-slate-300 dark:hover:text-white',
isCollapsed && 'justify-center'
)}
title={isCollapsed ? item.name : undefined}
>
<item.icon className="h-5 w-5 flex-shrink-0" />
{!isCollapsed && <span>{item.name}</span>}
</Link>
);
})}
</div>
</div>
))}
</nav>
</div>
);
}

View File

@@ -0,0 +1,84 @@
'use client';
import { ReactNode, useState } from 'react';
import { LucideIcon, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
interface ActionButtonProps {
children: ReactNode;
onClick?: () => void | Promise<void>;
variant?: 'primary' | 'secondary' | 'danger' | 'success' | 'export';
size?: 'sm' | 'md' | 'lg';
icon?: LucideIcon;
disabled?: boolean;
loading?: boolean;
className?: string;
type?: 'button' | 'submit' | 'reset';
}
const variants = {
primary: 'bg-[rgb(20,113,76)] text-white hover:bg-[rgb(16,90,61)] shadow-sm',
secondary: 'bg-gray-200 text-gray-700 hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600',
danger: 'bg-[rgb(20,113,76)] text-white hover:bg-[rgb(16,90,61)] shadow-sm',
success: 'bg-green-600 text-white hover:bg-green-700 shadow-sm',
export: 'bg-[rgb(20,113,76)] text-white hover:bg-[rgb(16,90,61)] shadow-sm',
};
const sizes = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-sm',
lg: 'px-6 py-3 text-base',
};
export default function ActionButton({
children,
onClick,
variant = 'primary',
size = 'md',
icon: Icon,
disabled = false,
loading = false,
className,
type = 'button',
}: ActionButtonProps) {
const [isLoading, setIsLoading] = useState(false);
const handleClick = async () => {
if (!onClick || disabled || loading || isLoading) return;
try {
setIsLoading(true);
await onClick();
} catch (error) {
console.error('Action failed:', error);
} finally {
setIsLoading(false);
}
};
const isDisabled = disabled || loading || isLoading;
const showLoading = loading || isLoading;
return (
<button
type={type}
onClick={handleClick}
disabled={isDisabled}
className={cn(
'inline-flex items-center justify-center gap-2 rounded-lg font-medium transition-all duration-200',
'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[rgb(20,113,76)]',
'disabled:opacity-50 disabled:cursor-not-allowed',
variants[variant],
sizes[size],
className
)}
>
{showLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
Icon && <Icon className="h-4 w-4" />
)}
{children}
</button>
);
}

View File

@@ -0,0 +1,26 @@
import { cn, getStatusColor } from '@/lib/utils';
interface BadgeProps {
children: React.ReactNode;
variant?: 'default' | 'status';
status?: string;
className?: string;
}
export default function Badge({ children, variant = 'default', status, className }: BadgeProps) {
const baseClasses = 'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium';
if (variant === 'status' && status) {
return (
<span className={cn(baseClasses, getStatusColor(status), className)}>
{children}
</span>
);
}
return (
<span className={cn(baseClasses, 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300', className)}>
{children}
</span>
);
}

View File

@@ -0,0 +1,40 @@
'use client';
import { ReactNode, ButtonHTMLAttributes } from 'react';
import { cn } from '@/lib/utils';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
children: ReactNode;
variant?: 'primary' | 'secondary' | 'outline' | 'danger';
size?: 'sm' | 'md' | 'lg';
}
export function Button({
children,
variant = 'primary',
size = 'md',
className,
...props
}: ButtonProps) {
const variants = {
primary: 'btn-primary',
secondary: 'btn-secondary',
outline: 'border border-input bg-background hover:bg-accent',
danger: 'btn-danger',
};
const sizes = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-sm',
lg: 'px-6 py-3 text-base',
};
return (
<button
className={cn('btn', variants[variant], sizes[size], className)}
{...props}
>
{children}
</button>
);
}

View File

@@ -0,0 +1,223 @@
'use client';
import { ReactNode, useState } from 'react';
import { ChevronUp, ChevronDown, MoreHorizontal } from 'lucide-react';
import { cn } from '@/lib/utils';
import ActionButton from './ActionButton';
interface Column<T> {
key: string;
label: string;
sortable?: boolean;
render?: (item: T) => ReactNode;
width?: string;
}
interface Action<T> {
label: string;
onClick: (item: T) => void | Promise<void>;
variant?: 'primary' | 'secondary' | 'danger';
icon?: any;
show?: (item: T) => boolean;
}
interface DataTableProps<T> {
data: T[];
columns: Column<T>[];
actions?: Action<T>[];
onRowClick?: (item: T) => void;
loading?: boolean;
emptyMessage?: string;
className?: string;
}
export default function DataTable<T extends Record<string, any>>({
data,
columns,
actions,
onRowClick,
loading = false,
emptyMessage = 'No data available',
className,
}: DataTableProps<T>) {
const [sortConfig, setSortConfig] = useState<{ key: string; direction: 'asc' | 'desc' } | null>(null);
const [expandedActions, setExpandedActions] = useState<string | null>(null);
// Ensure data is always an array
const safeData = Array.isArray(data) ? data : [];
const handleSort = (key: string) => {
let direction: 'asc' | 'desc' = 'asc';
if (sortConfig && sortConfig.key === key && sortConfig.direction === 'asc') {
direction = 'desc';
}
setSortConfig({ key, direction });
};
const sortedData = [...safeData].sort((a, b) => {
if (!sortConfig) return 0;
const aValue = a[sortConfig.key];
const bValue = b[sortConfig.key];
if (aValue < bValue) return sortConfig.direction === 'asc' ? -1 : 1;
if (aValue > bValue) return sortConfig.direction === 'asc' ? 1 : -1;
return 0;
});
if (loading) {
return (
<div className={cn('card', className)}>
<div className="animate-pulse">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded mb-4"></div>
<div className="space-y-3">
{[...Array(5)].map((_, i) => (
<div key={i} className="h-4 bg-gray-200 dark:bg-gray-700 rounded"></div>
))}
</div>
</div>
</div>
);
}
return (
<div className={cn('card p-0 overflow-visible', className)}>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-800">
<tr>
{columns.map((column) => (
<th
key={column.key}
className={cn(
'px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400',
column.sortable && 'cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700',
column.width && `w-${column.width}`
)}
onClick={() => column.sortable && handleSort(column.key)}
>
<div className="flex items-center gap-1">
{column.label}
{column.sortable && (
<div className="flex flex-col">
<ChevronUp
className={cn(
'h-3 w-3',
sortConfig?.key === column.key && sortConfig.direction === 'asc'
? 'text-edr-blue-600'
: 'text-gray-400'
)}
/>
<ChevronDown
className={cn(
'h-3 w-3 -mt-1',
sortConfig?.key === column.key && sortConfig.direction === 'desc'
? 'text-edr-blue-600'
: 'text-gray-400'
)}
/>
</div>
)}
</div>
</th>
))}
{actions && actions.length > 0 && (
<th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
Actions
</th>
)}
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
{sortedData.map((item, index) => (
<tr
key={item.id || index}
onClick={() => onRowClick?.(item)}
className={cn(
'transition-colors',
onRowClick && 'cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800'
)}
>
{columns.map((column) => (
<td key={column.key} className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{column.render ? column.render(item) : item[column.key]}
</td>
))}
{actions && actions.length > 0 && (
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="relative">
{(() => {
const visibleActions = actions.filter(action => !action.show || action.show(item));
if (visibleActions.length === 0) {
return null;
}
if (visibleActions.length === 1) {
const action = visibleActions[0];
return (
<ActionButton
onClick={() => action.onClick(item)}
variant={action.variant || 'secondary'}
size="sm"
icon={action.icon}
>
{action.label}
</ActionButton>
);
}
return (
<>
<button
onClick={(e) => {
e.stopPropagation();
setExpandedActions(expandedActions === item.id ? null : item.id);
}}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
<MoreHorizontal className="h-4 w-4" />
</button>
{expandedActions === item.id && (
<div className="absolute right-0 top-full mt-1 w-48 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-10">
<div className="py-1">
{visibleActions.map((action, actionIndex) => (
<button
key={actionIndex}
onClick={(e) => {
e.stopPropagation();
action.onClick(item);
setExpandedActions(null);
}}
className={cn(
'w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center gap-2',
action.variant === 'danger' && 'text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20'
)}
>
{action.icon && <action.icon className="h-4 w-4" />}
{action.label}
</button>
))}
</div>
</div>
)}
</>
);
})()}
</div>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
{sortedData.length === 0 && (
<div className="py-12 text-center text-gray-500 dark:text-gray-400">
{emptyMessage}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,20 @@
'use client';
import { InputHTMLAttributes, forwardRef } from 'react';
import { cn } from '@/lib/utils';
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ className, ...props }, ref) => {
return (
<input
ref={ref}
className={cn('input', className)}
{...props}
/>
);
}
);
Input.displayName = 'Input';

View File

@@ -0,0 +1,52 @@
'use client';
import { ReactNode, useEffect } from 'react';
import { X } from 'lucide-react';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: ReactNode;
size?: 'sm' | 'md' | 'lg' | 'xl';
}
const sizeClasses = {
sm: 'max-w-md',
md: 'max-w-lg',
lg: 'max-w-2xl',
xl: 'max-w-4xl',
};
export default function Modal({ isOpen, onClose, title, children, size = 'md' }: ModalProps) {
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = 'unset';
}
return () => {
document.body.style.overflow = 'unset';
};
}, [isOpen]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={onClose} />
<div className={`relative w-full ${sizeClasses[size]} rounded-lg bg-background p-6 shadow-xl`}>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-xl font-semibold text-foreground">{title}</h2>
<button
onClick={onClose}
className="rounded-lg p-1 hover:bg-muted"
>
<X className="h-5 w-5 text-muted-foreground" />
</button>
</div>
<div>{children}</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,54 @@
import { ChevronLeft, ChevronRight } from 'lucide-react';
interface PaginationProps {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
}
export default function Pagination({ currentPage, totalPages, onPageChange }: PaginationProps) {
return (
<div className="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6">
<div className="flex flex-1 justify-between sm:hidden">
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
className="btn btn-secondary disabled:opacity-50"
>
Previous
</button>
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className="btn btn-secondary disabled:opacity-50"
>
Next
</button>
</div>
<div className="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
<div>
<p className="text-sm text-gray-700">
Page <span className="font-medium">{currentPage}</span> of{' '}
<span className="font-medium">{totalPages}</span>
</p>
</div>
<div className="flex gap-2">
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
className="btn btn-secondary disabled:opacity-50"
>
<ChevronLeft className="h-4 w-4" />
</button>
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className="btn btn-secondary disabled:opacity-50"
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,52 @@
import { ReactNode } from 'react';
interface Column<T> {
key: string;
label: string;
render?: (item: T) => ReactNode;
}
interface TableProps<T> {
data: T[];
columns: Column<T>[];
onRowClick?: (item: T) => void;
}
export default function Table<T extends Record<string, any>>({ data, columns, onRowClick }: TableProps<T>) {
return (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-800">
<tr>
{columns.map((column) => (
<th
key={column.key}
className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400"
>
{column.label}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700 bg-white dark:bg-gray-900">
{data.map((item, index) => (
<tr
key={item.id || index}
onClick={() => onRowClick?.(item)}
className={onRowClick ? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800' : ''}
>
{columns.map((column) => (
<td key={column.key} className="whitespace-nowrap px-6 py-4 text-sm text-gray-900 dark:text-gray-100">
{column.render ? column.render(item) : item[column.key]}
</td>
))}
</tr>
))}
</tbody>
</table>
{data.length === 0 && (
<div className="py-12 text-center text-gray-500 dark:text-gray-400">No data available</div>
)}
</div>
);
}

View File

@@ -0,0 +1,64 @@
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
class ApiClient {
private client: AxiosInstance;
constructor() {
this.client = axios.create({
baseURL: API_URL,
headers: {
'Content-Type': 'application/json',
},
});
this.client.interceptors.request.use((config) => {
const token = typeof window !== 'undefined' ? localStorage.getItem('auth_token') : null;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
this.client.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
if (typeof window !== 'undefined') {
localStorage.removeItem('auth_token');
window.location.href = '/login';
}
}
return Promise.reject(error);
}
);
}
async get<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
const response = await this.client.get<{ success: boolean; data: T }>(url, config);
return response.data.data;
}
async post<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
const response = await this.client.post<{ success: boolean; data: T }>(url, data, config);
return response.data.data;
}
async put<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
const response = await this.client.put<{ success: boolean; data: T }>(url, data, config);
return response.data.data;
}
async patch<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
const response = await this.client.patch<{ success: boolean; data: T }>(url, data, config);
return response.data.data;
}
async delete<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
const response = await this.client.delete<{ success: boolean; data: T }>(url, config);
return response.data.data;
}
}
export const apiClient = new ApiClient();

View File

@@ -0,0 +1,29 @@
import { apiClient } from '@/lib/api-client';
import { Booking, BookingFilters } from '@/types';
import { PaginatedResponse } from '@edr/types';
export const bookingsApi = {
getAll: (filters?: BookingFilters) => {
const params = new URLSearchParams();
if (filters?.status) params.append('status', filters.status);
if (filters?.dateFrom) params.append('dateFrom', filters.dateFrom);
if (filters?.dateTo) params.append('dateTo', filters.dateTo);
if (filters?.search) params.append('search', filters.search);
if (filters?.page) params.append('page', filters.page.toString());
if (filters?.pageSize) params.append('pageSize', filters.pageSize.toString());
return apiClient.get<PaginatedResponse<Booking>>(`/bookings?${params.toString()}`);
},
getById: (id: string) => {
return apiClient.get<Booking>(`/bookings/${id}`);
},
updateStatus: (id: string, status: string) => {
return apiClient.patch<Booking>(`/bookings/${id}/status`, { status });
},
cancel: (id: string, reason?: string) => {
return apiClient.post<Booking>(`/bookings/${id}/cancel`, { reason });
},
};

View File

@@ -0,0 +1,16 @@
import { apiClient } from '@/lib/api-client';
import { DashboardStats, RevenueData } from '@/types';
export const dashboardApi = {
getStats: () => {
return apiClient.get<DashboardStats>('/dashboard/stats');
},
getRevenueChart: (days: number = 30) => {
return apiClient.get<RevenueData[]>(`/dashboard/revenue?days=${days}`);
},
getRecentBookings: (limit: number = 10) => {
return apiClient.get<any[]>(`/dashboard/recent-bookings?limit=${limit}`);
},
};

View File

@@ -0,0 +1,338 @@
import { apiClient } from '@/lib/api-client';
import { PaginatedResponse } from '@edr/types';
// Bookings API
export const bookingsApi = {
getAll: async (params?: any) => {
const cleanParams = Object.fromEntries(
Object.entries(params || {}).filter(([_, value]) => value !== '' && value !== undefined && value !== null)
);
const query = new URLSearchParams(cleanParams).toString();
const response = await apiClient.get<any>(`/bookings${query ? `?${query}` : ''}`);
if (response?.data) {
return Array.isArray(response.data) ? { items: response.data } : response;
}
return Array.isArray(response) ? { items: response } : response;
},
getById: (id: string) => apiClient.get<any>(`/bookings/${id}`),
cancel: (id: string, data?: any) => apiClient.post<any>(`/bookings/${id}/cancel`, data),
modify: (id: string, data: any) => apiClient.patch<any>(`/bookings/${id}`, data),
};
// Passengers API
export const passengersApi = {
getAll: async (params?: any) => {
const cleanParams = Object.fromEntries(
Object.entries(params || {}).filter(([_, value]) => value !== '' && value !== undefined && value !== null)
);
const query = new URLSearchParams(cleanParams).toString();
const response = await apiClient.get<any>(`/passengers${query ? `?${query}` : ''}`);
if (response?.data) {
return Array.isArray(response.data) ? { items: response.data } : response;
}
return Array.isArray(response) ? { items: response } : response;
},
getById: (id: string) => apiClient.get<any>(`/passengers/${id}`),
verify: (nationalId: string) => apiClient.post<any>('/passengers/verify-fayda', { nationalId }),
};
// Stations API
export const stationsApi = {
getAll: async (params?: any) => {
const cleanParams = Object.fromEntries(
Object.entries(params || {}).filter(([_, value]) => value !== '' && value !== undefined && value !== null)
);
const query = new URLSearchParams(cleanParams).toString();
const response = await apiClient.get<any>(`/stations${query ? `?${query}` : ''}`);
// Handle wrapped response: { success, data: [...], timestamp }
if (response?.data) {
return Array.isArray(response.data) ? { items: response.data } : response;
}
return Array.isArray(response) ? { items: response } : response;
},
getById: (id: string) => apiClient.get<any>(`/stations/${id}`),
create: (data: any) => apiClient.post<any>('/stations', data),
update: (id: string, data: any) => apiClient.patch<any>(`/stations/${id}`, data),
delete: (id: string) => apiClient.delete(`/stations/${id}`),
};
// Fleet API
export const fleetApi = {
getTrains: async (params?: any) => {
const cleanParams = Object.fromEntries(
Object.entries(params || {}).filter(([_, value]) => value !== '' && value !== undefined && value !== null)
);
const query = new URLSearchParams(cleanParams).toString();
const response = await apiClient.get<any>(`/fleet/trains${query ? `?${query}` : ''}`);
if (response?.data) {
return Array.isArray(response.data) ? { items: response.data } : response;
}
return Array.isArray(response) ? { items: response } : response;
},
getCoaches: async (params?: any) => {
const cleanParams = Object.fromEntries(
Object.entries(params || {}).filter(([_, value]) => value !== '' && value !== undefined && value !== null)
);
const query = new URLSearchParams(cleanParams).toString();
const response = await apiClient.get<any>(`/fleet/coaches${query ? `?${query}` : ''}`);
if (response?.data) {
return Array.isArray(response.data) ? { items: response.data } : response;
}
return Array.isArray(response) ? { items: response } : response;
},
createTrain: (data: any) => apiClient.post<any>('/fleet/trains', data),
updateTrain: (id: string, data: any) => apiClient.patch<any>(`/fleet/trains/${id}`, data),
deleteTrain: (id: string) => apiClient.delete(`/fleet/trains/${id}`),
createCoach: (data: any) => apiClient.post<any>('/fleet/coaches', data),
updateCoach: (id: string, data: any) => apiClient.patch<any>(`/fleet/coaches/${id}`, data),
deleteCoach: (id: string) => apiClient.delete(`/fleet/coaches/${id}`),
};
// Schedules API
export const schedulesApi = {
getAll: async (params?: any) => {
const query = new URLSearchParams(params).toString();
const response = await apiClient.get<any>(`/schedules${query ? `?${query}` : ''}`);
if (response?.data) {
return Array.isArray(response.data) ? { items: response.data } : response;
}
return Array.isArray(response) ? { items: response } : response;
},
getById: (id: string) => apiClient.get<any>(`/schedules/${id}`),
create: (data: any) => apiClient.post<any>('/schedules', data),
update: (id: string, data: any) => apiClient.patch<any>(`/schedules/${id}`, data),
delete: (id: string) => apiClient.delete(`/schedules/${id}`),
updateStatus: (id: string, status: string) => apiClient.patch<any>(`/schedules/${id}/status`, { status }),
assignCoaches: (scheduleId: string, coaches: Array<{ coachId: string; positionNumber: number }>) =>
apiClient.post<any>(`/schedules/${scheduleId}/coaches`, { coaches }),
getAssignedCoaches: (scheduleId: string) => apiClient.get<any>(`/schedules/${scheduleId}/coaches`),
removeCoachAssignment: (scheduleId: string, coachId: string) =>
apiClient.delete(`/schedules/${scheduleId}/coaches/${coachId}`),
};
// Seats API
export const seatsApi = {
getBySchedule: (scheduleId: string) => apiClient.get<any>(`/seats/schedule/${scheduleId}`),
hold: (data: any) => apiClient.post<any>('/seats/hold', data),
release: (holdId: string) => apiClient.delete(`/seats/hold/${holdId}`),
block: (seatId: string, data: any) => apiClient.post<any>(`/seats/${seatId}/block`, data),
unblock: (seatId: string) => apiClient.delete(`/seats/${seatId}/block`),
};
// Payments API
export const paymentsApi = {
getAll: async (params?: any) => {
const query = new URLSearchParams(params).toString();
const response = await apiClient.get<any>(`/payments${query ? `?${query}` : ''}`);
if (response?.data) {
return Array.isArray(response.data) ? { items: response.data } : response;
}
return Array.isArray(response) ? { items: response } : response;
},
getById: (id: string) => apiClient.get<any>(`/payments/${id}`),
refund: (id: string, data: any) => apiClient.post<any>(`/payments/${id}/refund`, data),
getProviders: () => apiClient.get<any[]>('/payments/providers'),
};
// Tickets API
export const ticketsApi = {
getAll: async (params?: any) => {
const query = new URLSearchParams(params).toString();
const response = await apiClient.get<any>(`/tickets${query ? `?${query}` : ''}`);
if (response?.data) {
return Array.isArray(response.data) ? { items: response.data } : response;
}
return Array.isArray(response) ? { items: response } : response;
},
getById: (id: string) => apiClient.get<any>(`/tickets/${id}`),
validate: (ticketId: string, data: any) => apiClient.post<any>(`/tickets/${ticketId}/validate`, data),
regenerate: (ticketId: string) => apiClient.post<any>(`/tickets/${ticketId}/regenerate`),
};
// Agents API
export const agentsApi = {
getAll: async (params?: any) => {
const query = new URLSearchParams(params).toString();
const response = await apiClient.get<any>(`/agents${query ? `?${query}` : ''}`);
if (response?.data) {
return Array.isArray(response.data) ? { items: response.data } : response;
}
return Array.isArray(response) ? { items: response } : response;
},
getById: (id: string) => apiClient.get<any>(`/agents/${id}`),
create: (data: any) => apiClient.post<any>('/agents', data),
update: (id: string, data: any) => apiClient.patch<any>(`/agents/${id}`, data),
getShifts: (agentId: string) => apiClient.get<any[]>(`/agents/${agentId}/shifts`),
openShift: (agentId: string, data: any) => apiClient.post<any>(`/agents/${agentId}/shifts/open`, data),
closeShift: (shiftId: string, data: any) => apiClient.post<any>(`/agents/shifts/${shiftId}/close`, data),
getCommissions: (agentId: string) => apiClient.get<any[]>(`/agents/${agentId}/commissions`),
};
// Loyalty API
export const loyaltyApi = {
getAccounts: async (params?: any) => {
const query = new URLSearchParams(params).toString();
const response = await apiClient.get<any>(`/loyalty/accounts${query ? `?${query}` : ''}`);
if (response?.data) {
return Array.isArray(response.data) ? { items: response.data } : response;
}
return Array.isArray(response) ? { items: response } : response;
},
getAccount: (passengerId: string) => apiClient.get<any>(`/loyalty/accounts/${passengerId}`),
adjustPoints: (accountId: string, data: any) => apiClient.post<any>(`/loyalty/accounts/${accountId}/adjust`, data),
getRewards: () => apiClient.get<any[]>('/loyalty/rewards'),
createReward: (data: any) => apiClient.post<any>('/loyalty/rewards', data),
};
// Wallet API
export const walletApi = {
getAccounts: async (params?: any) => {
const query = new URLSearchParams(params).toString();
const response = await apiClient.get<any>(`/wallet/accounts${query ? `?${query}` : ''}`);
if (response?.data) {
return Array.isArray(response.data) ? { items: response.data } : response;
}
return Array.isArray(response) ? { items: response } : response;
},
getAccount: (passengerId: string) => apiClient.get<any>(`/wallet/accounts/${passengerId}`),
adjustBalance: (accountId: string, data: any) => apiClient.post<any>(`/wallet/accounts/${accountId}/adjust`, data),
getLedger: (accountId: string) => apiClient.get<any[]>(`/wallet/accounts/${accountId}/ledger`),
};
// Promotions API
export const promotionsApi = {
getAll: async (params?: any) => {
const query = new URLSearchParams(params).toString();
const response = await apiClient.get<any>(`/promos${query ? `?${query}` : ''}`);
if (response?.data) {
return Array.isArray(response.data) ? { items: response.data } : response;
}
return Array.isArray(response) ? { items: response } : response;
},
getById: (id: string) => apiClient.get<any>(`/promos/${id}`),
create: (data: any) => apiClient.post<any>('/promos', data),
update: (id: string, data: any) => apiClient.patch<any>(`/promos/${id}`, data),
delete: (id: string) => apiClient.delete(`/promos/${id}`),
};
// Support API
export const supportApi = {
getConversations: async (params?: any) => {
const query = new URLSearchParams(params).toString();
const response = await apiClient.get<any>(`/support/conversations${query ? `?${query}` : ''}`);
if (response?.data) {
return Array.isArray(response.data) ? { items: response.data } : response;
}
return Array.isArray(response) ? { items: response } : response;
},
getConversation: (id: string) => apiClient.get<any>(`/support/conversations/${id}`),
updateStatus: (id: string, status: string) => apiClient.patch<any>(`/support/conversations/${id}/status`, { status }),
sendMessage: (conversationId: string, data: any) => apiClient.post<any>(`/support/conversations/${conversationId}/messages`, data),
getFaqs: () => apiClient.get<any[]>('/support/faqs'),
createFaq: (data: any) => apiClient.post<any>('/support/faqs', data),
};
// Notifications API
export const notificationsApi = {
getTemplates: () => apiClient.get<any[]>('/notifications/templates'),
createTemplate: (data: any) => apiClient.post<any>('/notifications/templates', data),
updateTemplate: (id: string, data: any) => apiClient.patch<any>(`/notifications/templates/${id}`, data),
send: (data: any) => apiClient.post<any>('/notifications/send', data),
getHistory: async (params?: any) => {
const query = new URLSearchParams(params).toString();
const response = await apiClient.get<any>(`/notifications/history${query ? `?${query}` : ''}`);
if (response?.data) {
return Array.isArray(response.data) ? { items: response.data } : response;
}
return Array.isArray(response) ? { items: response } : response;
},
};
// Fraud API
export const fraudApi = {
getAlerts: async (params?: any) => {
const query = new URLSearchParams(params).toString();
const response = await apiClient.get<any>(`/fraud/alerts${query ? `?${query}` : ''}`);
if (response?.data) {
return Array.isArray(response.data) ? { items: response.data } : response;
}
return Array.isArray(response) ? { items: response } : response;
},
acknowledgeAlert: (id: string) => apiClient.patch<any>(`/fraud/alerts/${id}/acknowledge`),
getRules: () => apiClient.get<any[]>('/fraud/rules'),
updateRule: (id: string, data: any) => apiClient.patch<any>(`/fraud/rules/${id}`, data),
blockUser: (userId: string, data: any) => apiClient.post<any>(`/fraud/users/${userId}/block`, data),
};
// Verifayda API
export const verifaydaApi = {
getVerifications: async (params?: any) => {
const query = new URLSearchParams(params).toString();
const response = await apiClient.get<any>(`/passengers/verifications${query ? `?${query}` : ''}`);
if (response?.data) {
return Array.isArray(response.data) ? { items: response.data } : response;
}
return Array.isArray(response) ? { items: response } : response;
},
verify: (nationalId: string) => apiClient.post<any>('/passengers/verify-fayda', { nationalId }),
getStats: () => apiClient.get<any>('/passengers/verification-stats'),
};
// Audit API
export const auditApi = {
getLogs: async (params?: any) => {
const query = new URLSearchParams(params).toString();
const response = await apiClient.get<any>(`/audit/logs${query ? `?${query}` : ''}`);
if (response?.data) {
return Array.isArray(response.data) ? { items: response.data } : response;
}
return Array.isArray(response) ? { items: response } : response;
},
getLog: (id: string) => apiClient.get<any>(`/audit/logs/${id}`),
};
// Live Tracking API
export const liveApi = {
getTrips: () => apiClient.get<any[]>('/live/trips'),
getTrip: (tripId: string) => apiClient.get<any>(`/live/trips/${tripId}`),
updateLocation: (tripId: string, data: any) => apiClient.post<any>(`/live/trips/${tripId}/location`, data),
getCrowdSignals: () => apiClient.get<any[]>('/live/crowd-signals'),
};
// Seat Classes API
export const seatClassesApi = {
getAll: () => apiClient.get<any[]>('/seat-classes'),
getById: (id: string) => apiClient.get<any>(`/seat-classes/${id}`),
create: (data: any) => apiClient.post<any>('/seat-classes', data),
update: (id: string, data: any) => apiClient.patch<any>(`/seat-classes/${id}`, data),
delete: (id: string) => apiClient.delete(`/seat-classes/${id}`),
};
// Food & Dining API
export const foodApi = {
getCategories: () => apiClient.get<any[]>('/food/categories'),
getMenuItems: (scheduleId: string) => apiClient.get<any[]>(`/food/menu/${scheduleId}`),
getOrders: async (params?: any) => {
const query = new URLSearchParams(params).toString();
const response = await apiClient.get<any>(`/food/orders${query ? `?${query}` : ''}`);
if (response?.data) {
return Array.isArray(response.data) ? { items: response.data } : response;
}
return Array.isArray(response) ? { items: response } : response;
},
updateOrderStatus: (orderId: string, status: string) => apiClient.patch<any>(`/food/orders/${orderId}/status`, { status }),
createMenuItem: (data: any) => apiClient.post<any>('/food/menu-items', data),
};
// Reports API
export const reportsApi = {
getOperationalReports: async (params?: any) => {
const query = new URLSearchParams(params).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;
},
getRevenue: (params?: any) => apiClient.get<any>('/reports/revenue', { params }),
getOccupancy: (params?: any) => apiClient.get<any>('/reports/occupancy', { params }),
};

View File

@@ -0,0 +1,23 @@
import { apiClient } from '@/lib/api-client';
import { PassengerFilters } from '@/types';
import { Passenger, PaginatedResponse } from '@edr/types';
export const passengersApi = {
getAll: (filters?: PassengerFilters) => {
const params = new URLSearchParams();
if (filters?.search) params.append('search', filters.search);
if (filters?.verified !== undefined) params.append('verified', filters.verified.toString());
if (filters?.page) params.append('page', filters.page.toString());
if (filters?.pageSize) params.append('pageSize', filters.pageSize.toString());
return apiClient.get<PaginatedResponse<Passenger.IPassenger>>(`/passengers?${params.toString()}`);
},
getById: (id: string) => {
return apiClient.get<Passenger.IPassenger>(`/passengers/${id}`);
},
update: (id: string, data: Partial<Passenger.IPassenger>) => {
return apiClient.patch<Passenger.IPassenger>(`/passengers/${id}`, data);
},
};

View File

@@ -0,0 +1,64 @@
import { apiClient } from '@/lib/api-client';
import { Route, Trip, FareRule } from '@/types';
import { PaginatedResponse } from '@edr/types';
export const routesApi = {
getAll: async () => {
const response = await apiClient.get<any>('/routes');
console.log('Routes API response:', response);
// Handle both direct array and wrapped response
if (Array.isArray(response)) {
return { items: response };
}
if (response?.data && Array.isArray(response.data)) {
return { items: response.data };
}
if (response?.items) {
return response;
}
return { items: response || [] };
},
getById: (id: string) => {
return apiClient.get<Route>(`/routes/${id}`);
},
create: (data: any) => {
console.log('Creating route with data:', JSON.stringify(data, null, 2));
return apiClient.post<Route>('/routes', data);
},
update: (id: string, data: Partial<Route>) => {
return apiClient.patch<Route>(`/routes/${id}`, data);
},
delete: (id: string) => {
return apiClient.delete(`/routes/${id}`);
},
getFareRules: (routeId: string) => {
return apiClient.get<FareRule[]>(`/routes/${routeId}/fare-rules`);
},
updateFareRule: (routeId: string, ruleId: string, data: Partial<FareRule>) => {
return apiClient.patch<FareRule>(`/routes/${routeId}/fare-rules/${ruleId}`, data);
},
};
export const tripsApi = {
getAll: (filters?: any) => {
const params = new URLSearchParams();
if (filters?.status) params.append('status', filters.status);
if (filters?.date) params.append('date', filters.date);
return apiClient.get<PaginatedResponse<Trip>>(`/schedules?${params.toString()}`);
},
getById: (id: string) => {
return apiClient.get<Trip>(`/schedules/${id}`);
},
updateStatus: (id: string, status: string) => {
return apiClient.patch<Trip>(`/schedules/${id}/status`, { status });
},
};

View File

@@ -0,0 +1,86 @@
import { create } from 'zustand';
import { AdminUser } from '@/types';
import axios from 'axios';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
interface AuthState {
user: AdminUser | null;
token: string | null;
isAuthenticated: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
setUser: (user: AdminUser, token: string) => void;
initialize: () => void;
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
token: null,
isAuthenticated: false,
initialize: () => {
if (typeof window === 'undefined') return;
const token = localStorage.getItem('auth_token');
const userStr = localStorage.getItem('auth_user');
if (token && userStr) {
try {
const user = JSON.parse(userStr);
set({ user, token, isAuthenticated: true });
} catch (e) {
localStorage.removeItem('auth_token');
localStorage.removeItem('auth_user');
}
}
},
login: async (email: string, password: string) => {
try {
console.log('Attempting login to:', `${API_URL}/auth/login`);
const response = await axios.post(`${API_URL}/auth/login`, { email, password });
console.log('Full response:', response.data);
// Backend wraps response in { success, data: { token, user }, timestamp }
const responseData = response.data.data || response.data;
if (!responseData || !responseData.token || !responseData.user) {
console.error('Invalid response structure:', response.data);
throw new Error('Invalid response from server');
}
const { token, user: apiUser } = responseData;
const user: AdminUser = {
id: apiUser.id,
email: apiUser.email,
fullName: apiUser.fullName,
role: apiUser.role,
active: true,
};
console.log('Login successful! User:', user);
localStorage.setItem('auth_token', token);
localStorage.setItem('auth_user', JSON.stringify(user));
set({ user, token, isAuthenticated: true });
} catch (error: any) {
console.error('Login error details:', {
message: error.message,
response: error.response?.data,
status: error.response?.status,
});
throw error;
}
},
logout: () => {
localStorage.removeItem('auth_token');
localStorage.removeItem('auth_user');
set({ user: null, token: null, isAuthenticated: false });
},
setUser: (user: AdminUser, token: string) => {
set({ user, token, isAuthenticated: true });
},
}));

View File

@@ -0,0 +1,39 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface ThemeState {
isDark: boolean;
toggleTheme: () => void;
setTheme: (isDark: boolean) => void;
}
export const useTheme = create<ThemeState>()(
persist(
(set) => ({
isDark: typeof window !== 'undefined' ? window.matchMedia('(prefers-color-scheme: dark)').matches : false,
toggleTheme: () => {
set((state) => {
const newIsDark = !state.isDark;
if (typeof window !== 'undefined') {
document.documentElement.classList.toggle('dark', newIsDark);
}
return { isDark: newIsDark };
});
},
setTheme: (isDark: boolean) => {
set({ isDark });
if (typeof window !== 'undefined') {
document.documentElement.classList.toggle('dark', isDark);
}
},
}),
{
name: 'edr-theme',
onRehydrateStorage: () => (state) => {
if (state && typeof window !== 'undefined') {
document.documentElement.classList.toggle('dark', state.isDark);
}
},
}
)
);

View File

@@ -0,0 +1,38 @@
import { format } from 'date-fns';
export const formatCurrency = (amount: number, currency: string = 'ETB'): string => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
minimumFractionDigits: 2,
}).format(amount / 100);
};
export const formatDate = (date: string | Date, formatStr: string = 'MMM dd, yyyy'): string => {
return format(new Date(date), formatStr);
};
export const formatDateTime = (date: string | Date): string => {
return format(new Date(date), 'MMM dd, yyyy HH:mm');
};
export const getStatusColor = (status: string): string => {
const colors: Record<string, string> = {
CONFIRMED: 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400',
PENDING: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400',
CANCELLED: 'bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400',
COMPLETED: 'bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400',
PAID: 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400',
FAILED: 'bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400',
REFUNDED: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300',
SCHEDULED: 'bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400',
DEPARTED: 'bg-purple-100 text-purple-800 dark:bg-purple-900/20 dark:text-purple-400',
ARRIVED: 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400',
DELAYED: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400',
};
return colors[status] || 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300';
};
export const cn = (...classes: (string | undefined | null | false)[]): string => {
return classes.filter(Boolean).join(' ');
};

View File

@@ -1,18 +0,0 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import App from "./App";
const queryClient = new QueryClient();
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</StrictMode>,
);

View File

@@ -1,10 +0,0 @@
const DashboardPage = () => {
return (
<div className="p-6">
<h1 className="text-2xl font-semibold">EDR Passenger Backoffice</h1>
<p className="mt-2 text-gray-600">Backoffice coming soon.</p>
</div>
);
};
export default DashboardPage;

View File

@@ -0,0 +1,118 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 152 74% 26%;
--primary-foreground: 0 0% 100%;
--secondary: 210 40% 96%;
--secondary-foreground: 222.2 84% 4.9%;
--muted: 210 40% 96%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96%;
--accent-foreground: 222.2 84% 4.9%;
--destructive: 152 74% 26%;
--destructive-foreground: 0 0% 100%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 152 74% 26%;
--radius: 0.5rem;
/* EDR Brand Colors */
--edr-green: 152 74% 26%;
--edr-orange: 24 95% 53%;
--edr-red: 152 74% 26%;
--edr-blue: 221 83% 53%;
}
.dark {
--background: 222.2 47% 11%;
--foreground: 210 40% 98%;
--card: 222.2 47% 11%;
--card-foreground: 210 40% 98%;
--popover: 222.2 47% 11%;
--popover-foreground: 210 40% 98%;
--primary: 152 74% 26%;
--primary-foreground: 0 0% 100%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 152 74% 26%;
--destructive-foreground: 0 0% 100%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 152 74% 26%;
}
body {
@apply bg-background text-foreground;
}
}
@layer components {
.card {
@apply bg-card text-card-foreground rounded-lg shadow-sm border border-border p-6;
}
.btn {
@apply px-4 py-2 rounded-lg font-medium transition-colors duration-200 inline-flex items-center justify-center;
}
.btn-primary {
@apply bg-[rgb(20,113,76)] text-white hover:bg-[rgb(16,90,61)] dark:bg-[rgb(20,113,76)] dark:hover:bg-[rgb(16,90,61)] shadow-md;
}
.btn-secondary {
@apply bg-secondary text-secondary-foreground hover:bg-secondary/80;
}
.btn-danger {
@apply bg-[rgb(20,113,76)] text-destructive-foreground hover:bg-[rgb(16,90,61)];
}
.input {
@apply w-full px-3 py-2 border border-input rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-[rgb(20,113,76)] focus:border-transparent;
}
.label {
@apply block text-sm font-medium text-foreground mb-1;
}
.gradient-edr {
@apply bg-[rgb(20,113,76)];
}
.gradient-edr-bg {
@apply bg-gradient-to-b from-slate-900 via-slate-800 to-slate-900;
}
.edr-badge {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
}
.edr-badge-success {
@apply bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400;
}
.edr-badge-warning {
@apply bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400;
}
.edr-badge-danger {
@apply bg-green-100 text-[rgb(20,113,76)] dark:bg-green-900/30 dark:text-green-400;
}
.edr-badge-info {
@apply bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400;
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,101 @@
import { Passenger } from '@edr/types';
export interface Booking {
id: string;
reference: string;
passengerId: string;
passenger?: Passenger.IPassenger;
tripId: string;
status: 'PENDING' | 'CONFIRMED' | 'CANCELLED' | 'COMPLETED';
totalAmount: number;
currency: string;
paymentStatus: Passenger.PaymentStatus;
createdAt: string;
updatedAt: string;
}
export interface Trip {
id: string;
trainCode: string;
routeId: string;
originStationId: string;
destinationStationId: string;
departureTime: string;
arrivalTime: string;
status: Passenger.ScheduleStatus;
basePrice: number;
availableSeats: number;
totalSeats: number;
}
export interface DashboardStats {
totalBookings: number;
totalRevenue: number;
totalPassengers: number;
activeTrips: number;
bookingsToday: number;
revenueToday: number;
occupancyRate: number;
}
export interface RevenueData {
date: string;
revenue: number;
bookings: number;
}
export interface BookingFilters {
status?: string;
dateFrom?: string;
dateTo?: string;
search?: string;
page?: number;
pageSize?: number;
}
export interface PassengerFilters {
search?: string;
verified?: boolean;
page?: number;
pageSize?: number;
}
export interface Route {
id: string;
name: string;
originStationId: string;
destinationStationId: string;
distance: number;
duration: number;
active: boolean;
createdAt: string;
}
export interface FareRule {
id: string;
routeId: string;
passengerCategory: 'ADULT' | 'CHILD';
serviceClass: string;
baseFare: number;
currency: string;
}
export interface NotificationTemplate {
id: string;
name: string;
channel: 'EMAIL' | 'SMS' | 'PUSH';
subject?: string;
body: string;
active: boolean;
}
export interface AdminUser {
id: string;
email: string;
fullName: string;
role: 'ADMIN' | 'AGENT' | 'SUPERVISOR';
active: boolean;
}
// Re-export EDR types
export * from './edr';

View File

@@ -1 +0,0 @@
/// <reference types="vite/client" />