mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 05:25:41 +00:00
First passenger and back office portal commit
This commit is contained in:
@@ -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;
|
||||
@@ -0,0 +1,5 @@
|
||||
import DashboardLayout from '../dashboard/layout';
|
||||
|
||||
export default function AgentsLayout({ children }: { children: React.ReactNode }) {
|
||||
return <DashboardLayout>{children}</DashboardLayout>;
|
||||
}
|
||||
120
apps/edr-passenger-web/backoffice/src/app/agents/page.tsx
Normal file
120
apps/edr-passenger-web/backoffice/src/app/agents/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import DashboardLayout from '../dashboard/layout';
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return <DashboardLayout>{children}</DashboardLayout>;
|
||||
}
|
||||
129
apps/edr-passenger-web/backoffice/src/app/audit/page.tsx
Normal file
129
apps/edr-passenger-web/backoffice/src/app/audit/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import DashboardLayout from '../dashboard/layout';
|
||||
|
||||
export default function BookingsLayout({ children }: { children: React.ReactNode }) {
|
||||
return <DashboardLayout>{children}</DashboardLayout>;
|
||||
}
|
||||
171
apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx
Normal file
171
apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import DashboardLayout from '../dashboard/layout';
|
||||
|
||||
export default function CoachesLayout({ children }: { children: React.ReactNode }) {
|
||||
return <DashboardLayout>{children}</DashboardLayout>;
|
||||
}
|
||||
308
apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx
Normal file
308
apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
108
apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx
Normal file
108
apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import DashboardLayout from '../dashboard/layout';
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return <DashboardLayout>{children}</DashboardLayout>;
|
||||
}
|
||||
67
apps/edr-passenger-web/backoffice/src/app/food/page.tsx
Normal file
67
apps/edr-passenger-web/backoffice/src/app/food/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import DashboardLayout from '../dashboard/layout';
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return <DashboardLayout>{children}</DashboardLayout>;
|
||||
}
|
||||
179
apps/edr-passenger-web/backoffice/src/app/fraud/page.tsx
Normal file
179
apps/edr-passenger-web/backoffice/src/app/fraud/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
apps/edr-passenger-web/backoffice/src/app/layout.tsx
Normal file
40
apps/edr-passenger-web/backoffice/src/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import DashboardLayout from '../dashboard/layout';
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return <DashboardLayout>{children}</DashboardLayout>;
|
||||
}
|
||||
44
apps/edr-passenger-web/backoffice/src/app/live/page.tsx
Normal file
44
apps/edr-passenger-web/backoffice/src/app/live/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
105
apps/edr-passenger-web/backoffice/src/app/login/page.tsx
Normal file
105
apps/edr-passenger-web/backoffice/src/app/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import DashboardLayout from '../dashboard/layout';
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return <DashboardLayout>{children}</DashboardLayout>;
|
||||
}
|
||||
66
apps/edr-passenger-web/backoffice/src/app/loyalty/page.tsx
Normal file
66
apps/edr-passenger-web/backoffice/src/app/loyalty/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import DashboardLayout from '../dashboard/layout';
|
||||
|
||||
export default function NotificationsLayout({ children }: { children: React.ReactNode }) {
|
||||
return <DashboardLayout>{children}</DashboardLayout>;
|
||||
}
|
||||
135
apps/edr-passenger-web/backoffice/src/app/notifications/page.tsx
Normal file
135
apps/edr-passenger-web/backoffice/src/app/notifications/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import DashboardLayout from '../dashboard/layout';
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return <DashboardLayout>{children}</DashboardLayout>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
5
apps/edr-passenger-web/backoffice/src/app/page.tsx
Normal file
5
apps/edr-passenger-web/backoffice/src/app/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Home() {
|
||||
redirect('/login');
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import DashboardLayout from '../dashboard/layout';
|
||||
|
||||
export default function PassengersLayout({ children }: { children: React.ReactNode }) {
|
||||
return <DashboardLayout>{children}</DashboardLayout>;
|
||||
}
|
||||
135
apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx
Normal file
135
apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import DashboardLayout from '../dashboard/layout';
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return <DashboardLayout>{children}</DashboardLayout>;
|
||||
}
|
||||
67
apps/edr-passenger-web/backoffice/src/app/payments/page.tsx
Normal file
67
apps/edr-passenger-web/backoffice/src/app/payments/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import DashboardLayout from '../dashboard/layout';
|
||||
|
||||
export default function PricingLayout({ children }: { children: React.ReactNode }) {
|
||||
return <DashboardLayout>{children}</DashboardLayout>;
|
||||
}
|
||||
93
apps/edr-passenger-web/backoffice/src/app/pricing/page.tsx
Normal file
93
apps/edr-passenger-web/backoffice/src/app/pricing/page.tsx
Normal 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 (<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: ETB→DJF=3.25, ETB→USD=0.018</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
apps/edr-passenger-web/backoffice/src/app/providers.tsx
Normal file
45
apps/edr-passenger-web/backoffice/src/app/providers.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import DashboardLayout from '../dashboard/layout';
|
||||
|
||||
export default function ReportsLayout({ children }: { children: React.ReactNode }) {
|
||||
return <DashboardLayout>{children}</DashboardLayout>;
|
||||
}
|
||||
127
apps/edr-passenger-web/backoffice/src/app/reports/page.tsx
Normal file
127
apps/edr-passenger-web/backoffice/src/app/reports/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import DashboardLayout from '../dashboard/layout';
|
||||
|
||||
export default function RoutesLayout({ children }: { children: React.ReactNode }) {
|
||||
return <DashboardLayout>{children}</DashboardLayout>;
|
||||
}
|
||||
341
apps/edr-passenger-web/backoffice/src/app/routes/page.tsx
Normal file
341
apps/edr-passenger-web/backoffice/src/app/routes/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import DashboardLayout from '../dashboard/layout';
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return <DashboardLayout>{children}</DashboardLayout>;
|
||||
}
|
||||
442
apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx
Normal file
442
apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import DashboardLayout from '../dashboard/layout';
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return <DashboardLayout>{children}</DashboardLayout>;
|
||||
}
|
||||
217
apps/edr-passenger-web/backoffice/src/app/seat-classes/page.tsx
Normal file
217
apps/edr-passenger-web/backoffice/src/app/seat-classes/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import DashboardLayout from '../dashboard/layout';
|
||||
|
||||
export default function SeatsLayout({ children }: { children: React.ReactNode }) {
|
||||
return <DashboardLayout>{children}</DashboardLayout>;
|
||||
}
|
||||
173
apps/edr-passenger-web/backoffice/src/app/seats/page.tsx
Normal file
173
apps/edr-passenger-web/backoffice/src/app/seats/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import DashboardLayout from '../dashboard/layout';
|
||||
|
||||
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
|
||||
return <DashboardLayout>{children}</DashboardLayout>;
|
||||
}
|
||||
144
apps/edr-passenger-web/backoffice/src/app/settings/page.tsx
Normal file
144
apps/edr-passenger-web/backoffice/src/app/settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import DashboardLayout from '../dashboard/layout';
|
||||
|
||||
export default function StationsLayout({ children }: { children: React.ReactNode }) {
|
||||
return <DashboardLayout>{children}</DashboardLayout>;
|
||||
}
|
||||
350
apps/edr-passenger-web/backoffice/src/app/stations/page.tsx
Normal file
350
apps/edr-passenger-web/backoffice/src/app/stations/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import DashboardLayout from '../dashboard/layout';
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return <DashboardLayout>{children}</DashboardLayout>;
|
||||
}
|
||||
66
apps/edr-passenger-web/backoffice/src/app/support/page.tsx
Normal file
66
apps/edr-passenger-web/backoffice/src/app/support/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import DashboardLayout from '../dashboard/layout';
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return <DashboardLayout>{children}</DashboardLayout>;
|
||||
}
|
||||
196
apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx
Normal file
196
apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import DashboardLayout from '../dashboard/layout';
|
||||
|
||||
export default function TrainsLayout({ children }: { children: React.ReactNode }) {
|
||||
return <DashboardLayout>{children}</DashboardLayout>;
|
||||
}
|
||||
251
apps/edr-passenger-web/backoffice/src/app/trains/page.tsx
Normal file
251
apps/edr-passenger-web/backoffice/src/app/trains/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import DashboardLayout from '../dashboard/layout';
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return <DashboardLayout>{children}</DashboardLayout>;
|
||||
}
|
||||
64
apps/edr-passenger-web/backoffice/src/app/verifayda/page.tsx
Normal file
64
apps/edr-passenger-web/backoffice/src/app/verifayda/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import DashboardLayout from '../dashboard/layout';
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return <DashboardLayout>{children}</DashboardLayout>;
|
||||
}
|
||||
55
apps/edr-passenger-web/backoffice/src/app/wallet/page.tsx
Normal file
55
apps/edr-passenger-web/backoffice/src/app/wallet/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
64
apps/edr-passenger-web/backoffice/src/lib/api-client.ts
Normal file
64
apps/edr-passenger-web/backoffice/src/lib/api-client.ts
Normal 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();
|
||||
29
apps/edr-passenger-web/backoffice/src/lib/api/bookings.ts
Normal file
29
apps/edr-passenger-web/backoffice/src/lib/api/bookings.ts
Normal 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 });
|
||||
},
|
||||
};
|
||||
16
apps/edr-passenger-web/backoffice/src/lib/api/dashboard.ts
Normal file
16
apps/edr-passenger-web/backoffice/src/lib/api/dashboard.ts
Normal 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}`);
|
||||
},
|
||||
};
|
||||
338
apps/edr-passenger-web/backoffice/src/lib/api/index.ts
Normal file
338
apps/edr-passenger-web/backoffice/src/lib/api/index.ts
Normal 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 }),
|
||||
};
|
||||
23
apps/edr-passenger-web/backoffice/src/lib/api/passengers.ts
Normal file
23
apps/edr-passenger-web/backoffice/src/lib/api/passengers.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
64
apps/edr-passenger-web/backoffice/src/lib/api/routes.ts
Normal file
64
apps/edr-passenger-web/backoffice/src/lib/api/routes.ts
Normal 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 });
|
||||
},
|
||||
};
|
||||
86
apps/edr-passenger-web/backoffice/src/lib/auth-store.ts
Normal file
86
apps/edr-passenger-web/backoffice/src/lib/auth-store.ts
Normal 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 });
|
||||
},
|
||||
}));
|
||||
39
apps/edr-passenger-web/backoffice/src/lib/theme-store.ts
Normal file
39
apps/edr-passenger-web/backoffice/src/lib/theme-store.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
38
apps/edr-passenger-web/backoffice/src/lib/utils.ts
Normal file
38
apps/edr-passenger-web/backoffice/src/lib/utils.ts
Normal 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(' ');
|
||||
};
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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;
|
||||
118
apps/edr-passenger-web/backoffice/src/styles/globals.css
Normal file
118
apps/edr-passenger-web/backoffice/src/styles/globals.css
Normal 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;
|
||||
}
|
||||
}
|
||||
1
apps/edr-passenger-web/backoffice/src/types/edr.ts
Normal file
1
apps/edr-passenger-web/backoffice/src/types/edr.ts
Normal file
File diff suppressed because one or more lines are too long
101
apps/edr-passenger-web/backoffice/src/types/index.ts
Normal file
101
apps/edr-passenger-web/backoffice/src/types/index.ts
Normal 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';
|
||||
@@ -1 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user