mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 10:58:14 +00:00
First passenger and back office portal commit
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user