This commit is contained in:
Roba Boru
2026-06-24 11:53:32 +03:00
89 changed files with 18718 additions and 10470 deletions

View File

@@ -2,15 +2,30 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Plus, Edit, DollarSign, Clock } from 'lucide-react';
import { Plus, Edit, DollarSign, Clock, Eye } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import ActionButton from '@/components/ui/ActionButton';
import Badge from '@/components/ui/Badge';
import Modal from '@/components/ui/Modal';
import { agentsApi } from '@/lib/api';
import { formatCurrency, formatDateTime } from '@/lib/utils';
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
<div className="bg-muted/40 rounded-lg p-3">
<p className="text-xs text-muted-foreground mb-1">{label}</p>
<p className={`text-sm font-semibold text-foreground${mono ? ' font-mono' : ''}${truncate ? ' truncate' : ''}`} title={value}>{value || '—'}</p>
</div>
);
const SectionHeader = ({ title }: { title: string }) => (
<h3 className="text-xs font-bold uppercase tracking-widest text-muted-foreground mb-3 flex items-center gap-2">
<span className="w-4 h-px bg-muted-foreground/40 inline-block" />{title}
</h3>
);
export default function AgentsPage() {
const [filters, setFilters] = useState({ search: '', active: '' });
const [selected, setSelected] = useState<any>(null);
const { data, isLoading } = useQuery({
queryKey: ['agents', filters],
@@ -30,7 +45,7 @@ export default function AgentsPage() {
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 className="text-sm text-muted-foreground">{agent.user?.email}</div>
</div>
),
},
@@ -51,19 +66,21 @@ export default function AgentsPage() {
];
const actions = [
{
label: 'View Details',
onClick: (agent: any) => setSelected(agent),
variant: 'secondary' as const,
icon: Eye,
},
{
label: 'View Shifts',
onClick: (agent: any) => {
window.location.href = `/agents/${agent.id}/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`;
},
onClick: (agent: any) => { window.location.href = `/agents/${agent.id}/commissions`; },
variant: 'secondary' as const,
icon: DollarSign,
},
@@ -119,6 +136,97 @@ export default function AgentsPage() {
loading={isLoading}
emptyMessage="No agents found"
/>
{/* Agent Details Modal */}
<Modal isOpen={!!selected} onClose={() => setSelected(null)} title="Agent Details" size="xl">
{selected && (() => {
const a = selected;
const initials = (a.user?.fullName || a.agentCode || '?').split(' ').map((w: string) => w[0]).join('').slice(0, 2).toUpperCase();
return (
<div>
<div className="from-emerald-600 to-emerald-700 -mx-6 -mt-4 mb-6 px-6 py-5 bg-gradient-to-r rounded-t-lg">
<div className="flex items-center gap-4">
<div className="w-14 h-14 rounded-full bg-white/20 flex items-center justify-center shrink-0">
<span className="text-white text-xl font-bold">{initials}</span>
</div>
<div className="flex-1 min-w-0">
<p className="text-white text-xl font-bold truncate">{a.user?.fullName || 'N/A'}</p>
<p className="text-emerald-200 text-sm font-mono">{a.agentCode}</p>
</div>
<div className="text-right shrink-0">
<Badge variant="status" status={a.active ? 'CONFIRMED' : 'CANCELLED'}>
{a.active ? 'Active' : 'Inactive'}
</Badge>
</div>
</div>
<div className="mt-4 grid grid-cols-3 gap-3">
{[
{ label: 'Agent Code', value: a.agentCode || '—' },
{ label: 'Commission Rate', value: `${a.commissionRate ?? 0}%` },
{ label: 'Total Bookings', value: (a.totalBookings ?? 0).toLocaleString() },
].map(({ label, value }) => (
<div key={label} className="bg-white/10 rounded-lg px-3 py-2">
<p className="text-emerald-200 text-xs">{label}</p>
<p className="text-white text-sm font-bold truncate">{value}</p>
</div>
))}
</div>
</div>
<div className="space-y-6">
<section>
<SectionHeader title="Agent Information" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Field label="Agent Code" value={a.agentCode} mono />
<Field label="Commission Rate" value={`${a.commissionRate ?? 0}%`} />
<Field label="Counter Location" value={a.counterLocation || a.location || 'N/A'} />
<div className="bg-muted/40 rounded-lg p-3">
<p className="text-xs text-muted-foreground mb-2">Status</p>
<Badge variant="status" status={a.active ? 'CONFIRMED' : 'CANCELLED'}>
{a.active ? 'Active' : 'Inactive'}
</Badge>
</div>
</div>
</section>
<section>
<SectionHeader title="User Account" />
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<Field label="Full Name" value={a.user?.fullName} />
<Field label="Email" value={a.user?.email} truncate />
<Field label="Phone" value={a.user?.phone} />
<Field label="Role" value={a.user?.role || 'AGENT'} />
<Field label="User ID" value={a.userId || a.user?.id} mono truncate />
</div>
</section>
<section>
<SectionHeader title="Performance" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Field label="Total Bookings" value={(a.totalBookings ?? 0).toLocaleString()} />
<Field label="Total Revenue" value={a.totalRevenue ? formatCurrency(a.totalRevenue, 'ETB') : 'N/A'} />
<Field label="Total Commission" value={a.totalCommission ? formatCurrency(a.totalCommission, 'ETB') : 'N/A'} />
<Field label="Pending Commission" value={a.pendingCommission ? formatCurrency(a.pendingCommission, 'ETB') : 'N/A'} />
</div>
</section>
<section>
<SectionHeader title="Timestamps & IDs" />
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<Field label="Agent Since" value={formatDateTime(a.createdAt)} />
<Field label="Last Updated" value={formatDateTime(a.updatedAt)} />
<Field label="Agent ID" value={a.id} mono truncate />
</div>
</section>
</div>
<div className="flex justify-end gap-2 pt-6 mt-2 border-t border-muted">
<ActionButton variant="secondary" onClick={() => setSelected(null)}>Close</ActionButton>
</div>
</div>
);
})()}
</Modal>
</div>
);
}

View File

@@ -252,111 +252,132 @@ export default function AuditLogsPage() {
{/* Details Modal */}
<Modal
isOpen={showDetailsModal}
onClose={() => {
setShowDetailsModal(false);
setSelectedLog(null);
}}
title={`${selectedLog?.action} - ${selectedLog?.entityType}`}
size="lg"
onClose={() => { setShowDetailsModal(false); setSelectedLog(null); }}
title="Audit Log Details"
size="xl"
>
<div className="space-y-4">
{/* Basic Info */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs font-semibold text-muted-foreground">Timestamp</label>
<p className="text-sm mt-1">{formatDateTime(selectedLog?.createdAt)}</p>
</div>
<div>
<label className="text-xs font-semibold text-muted-foreground">Action</label>
<p className="text-sm mt-1">
<Badge className={getActionBadgeColor(selectedLog?.action)}>
{selectedLog?.action}
</Badge>
</p>
</div>
<div>
<label className="text-xs font-semibold text-muted-foreground">Entity Type</label>
<p className="text-sm mt-1 font-mono">{selectedLog?.entityType}</p>
</div>
<div>
<label className="text-xs font-semibold text-muted-foreground">Entity ID</label>
<p className="text-sm mt-1 font-mono text-muted-foreground">
{selectedLog?.entityId || 'System'}
</p>
</div>
</div>
{selectedLog && (() => {
const l = selectedLog;
const actionColor: Record<string, string> = {
CREATE: 'from-emerald-600 to-emerald-700',
UPDATE: 'from-blue-600 to-blue-700',
DELETE: 'from-red-600 to-red-700',
LOGIN: 'from-violet-600 to-violet-700',
LOGOUT: 'from-gray-600 to-gray-700',
};
const gradient = actionColor[l.action] || 'from-gray-600 to-gray-700';
{/* User Info */}
{selectedLog?.user && (
<div className="border-t pt-4">
<h4 className="text-sm font-semibold mb-2">User Information</h4>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs font-semibold text-muted-foreground">Name</label>
<p className="text-sm mt-1">{selectedLog?.user?.fullName}</p>
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
<div className="bg-muted/40 rounded-lg p-3">
<p className="text-xs text-muted-foreground mb-1">{label}</p>
<p className={`text-sm font-semibold text-foreground${mono ? ' font-mono' : ''}${truncate ? ' truncate' : ''}`} title={value}>{value || '—'}</p>
</div>
);
const SectionHeader = ({ title }: { title: string }) => (
<h3 className="text-xs font-bold uppercase tracking-widest text-muted-foreground mb-3 flex items-center gap-2">
<span className="w-4 h-px bg-muted-foreground/40 inline-block" />{title}
</h3>
);
return (
<div>
<div className={`-mx-6 -mt-4 mb-6 px-6 py-5 bg-gradient-to-r ${gradient} rounded-t-lg`}>
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-white/70 text-xs font-semibold uppercase tracking-widest mb-1">Action</p>
<p className="text-white text-2xl font-bold">{l.action}</p>
</div>
<div className="text-right shrink-0">
<span className="inline-block bg-white/20 text-white text-xs font-mono px-3 py-1 rounded-full">{l.entityType}</span>
<p className="text-white/70 text-xs mt-2">{formatDateTime(l.createdAt)}</p>
</div>
</div>
<div>
<label className="text-xs font-semibold text-muted-foreground">Email</label>
<p className="text-sm mt-1">{selectedLog?.user?.email}</p>
<div className="mt-4 grid grid-cols-2 gap-3">
<div className="bg-white/10 rounded-lg px-3 py-2">
<p className="text-white/70 text-xs">User</p>
<p className="text-white text-sm font-bold truncate">{l.user?.fullName || 'System'}</p>
</div>
<div className="bg-white/10 rounded-lg px-3 py-2">
<p className="text-white/70 text-xs">IP Address</p>
<p className="text-white text-sm font-mono font-bold">{l.ipAddress || 'N/A'}</p>
</div>
</div>
</div>
</div>
)}
{/* Network Info */}
{(selectedLog?.ipAddress || selectedLog?.userAgent) && (
<div className="border-t pt-4">
<h4 className="text-sm font-semibold mb-2">Network Information</h4>
<div className="space-y-2">
{selectedLog?.ipAddress && (
<div>
<label className="text-xs font-semibold text-muted-foreground">IP Address</label>
<p className="text-sm mt-1 font-mono">{selectedLog?.ipAddress}</p>
<div className="space-y-6">
<section>
<SectionHeader title="Event Details" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Field label="Action" value={l.action} />
<Field label="Entity Type" value={l.entityType} mono />
<Field label="Entity ID" value={l.entityId || 'System'} mono truncate />
<Field label="Timestamp" value={formatDateTime(l.createdAt)} />
</div>
</section>
{l.user && (
<section>
<SectionHeader title="User Information" />
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<Field label="Full Name" value={l.user.fullName} />
<Field label="Email" value={l.user.email} truncate />
<Field label="User ID" value={l.userId} mono truncate />
</div>
</section>
)}
{selectedLog?.userAgent && (
<div>
<label className="text-xs font-semibold text-muted-foreground">User Agent</label>
<p className="text-xs mt-1 font-mono break-all text-muted-foreground">
{selectedLog?.userAgent}
</p>
{(l.ipAddress || l.userAgent) && (
<section>
<SectionHeader title="Network Information" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Field label="IP Address" value={l.ipAddress} mono />
<div className="bg-muted/40 rounded-lg p-3">
<p className="text-xs text-muted-foreground mb-1">User Agent</p>
<p className="text-xs font-mono text-foreground break-all leading-relaxed">{l.userAgent || '—'}</p>
</div>
</div>
</section>
)}
{(l.oldData || l.newData) && (
<section>
<SectionHeader title="Data Changes" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{l.oldData && (
<div>
<p className="text-xs font-bold text-red-600 dark:text-red-400 mb-2 uppercase tracking-wide"> Before</p>
<pre className="text-xs p-3 bg-red-50 dark:bg-red-950/20 rounded-lg border border-red-200 dark:border-red-900 overflow-auto max-h-52 text-muted-foreground leading-relaxed">
{formatJsonData(l.oldData)}
</pre>
</div>
)}
{l.newData && (
<div>
<p className="text-xs font-bold text-emerald-600 dark:text-emerald-400 mb-2 uppercase tracking-wide"> After</p>
<pre className="text-xs p-3 bg-emerald-50 dark:bg-emerald-950/20 rounded-lg border border-emerald-200 dark:border-emerald-900 overflow-auto max-h-52 text-muted-foreground leading-relaxed">
{formatJsonData(l.newData)}
</pre>
</div>
)}
</div>
</section>
)}
<section>
<SectionHeader title="System" />
<div className="grid grid-cols-1 gap-3">
<Field label="Log ID" value={l.id} mono truncate />
</div>
)}
</section>
</div>
<div className="flex justify-end gap-2 pt-6 mt-2 border-t border-muted">
<ActionButton variant="secondary" onClick={() => { setShowDetailsModal(false); setSelectedLog(null); }}>Close</ActionButton>
</div>
</div>
)}
{/* Changes */}
{(selectedLog?.oldData || selectedLog?.newData) && (
<div className="border-t pt-4">
<h4 className="text-sm font-semibold mb-2">Data Changes</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{selectedLog?.oldData && (
<div>
<label className="text-xs font-semibold text-red-600">Old Data</label>
<pre className="text-xs mt-1 p-2 bg-red-50 dark:bg-red-950/20 rounded border border-red-200 dark:border-red-900 overflow-auto max-h-48 text-muted-foreground">
{formatJsonData(selectedLog?.oldData)}
</pre>
</div>
)}
{selectedLog?.newData && (
<div>
<label className="text-xs font-semibold text-green-600">New Data</label>
<pre className="text-xs mt-1 p-2 bg-green-50 dark:bg-green-950/20 rounded border border-green-200 dark:border-green-900 overflow-auto max-h-48 text-muted-foreground">
{formatJsonData(selectedLog?.newData)}
</pre>
</div>
)}
</div>
</div>
)}
{/* Raw Log ID */}
<div className="border-t pt-4">
<label className="text-xs font-semibold text-muted-foreground">Log ID</label>
<p className="text-xs mt-1 font-mono text-muted-foreground break-all">{selectedLog?.id}</p>
</div>
</div>
);
})()}
</Modal>
</div>
);

View File

@@ -2,15 +2,37 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { AlertTriangle, CheckCircle, Ban } from 'lucide-react';
import { AlertTriangle, CheckCircle, Ban, Eye } 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 { fraudApi } from '@/lib/api';
import { formatDateTime } from '@/lib/utils';
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
<div className="bg-muted/40 rounded-lg p-3">
<p className="text-xs text-muted-foreground mb-1">{label}</p>
<p className={`text-sm font-semibold text-foreground${mono ? ' font-mono' : ''}${truncate ? ' truncate' : ''}`} title={value}>{value || '—'}</p>
</div>
);
const SectionHeader = ({ title }: { title: string }) => (
<h3 className="text-xs font-bold uppercase tracking-widest text-muted-foreground mb-3 flex items-center gap-2">
<span className="w-4 h-px bg-muted-foreground/40 inline-block" />{title}
</h3>
);
const SEVERITY_GRAD: Record<string, string> = {
CRITICAL: 'from-red-700 to-red-800',
HIGH: 'from-red-600 to-red-700',
MEDIUM: 'from-amber-500 to-amber-600',
LOW: 'from-blue-500 to-blue-600',
};
export default function FraudDetectionPage() {
const [filters, setFilters] = useState({ search: '', severity: '', status: '' });
const [selected, setSelected] = useState<any>(null);
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
@@ -40,10 +62,7 @@ export default function FraudDetectionPage() {
const handleBlockUser = async (alert: any) => {
if (confirm(`Block user ${alert.user?.email}?`)) {
await blockUserMutation.mutateAsync({
userId: alert.userId,
reason: `Fraud alert: ${alert.ruleType}`,
});
await blockUserMutation.mutateAsync({ userId: alert.userId, reason: `Fraud alert: ${alert.ruleType}` });
}
};
@@ -52,7 +71,7 @@ export default function FraudDetectionPage() {
key: 'severity',
label: 'Severity',
render: (alert: any) => (
<Badge variant="status" status={alert.severity === 'HIGH' ? 'CANCELLED' : alert.severity === 'MEDIUM' ? 'PENDING' : 'CONFIRMED'}>
<Badge variant="status" status={alert.severity === 'HIGH' || alert.severity === 'CRITICAL' ? 'CANCELLED' : alert.severity === 'MEDIUM' ? 'PENDING' : 'CONFIRMED'}>
{alert.severity}
</Badge>
),
@@ -62,7 +81,7 @@ export default function FraudDetectionPage() {
label: 'Rule Type',
render: (alert: any) => (
<div className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-[rgb(20,113,76)]" />
<AlertTriangle className="h-4 w-4 text-amber-500 shrink-0" />
<span>{alert.ruleType}</span>
</div>
),
@@ -80,9 +99,7 @@ export default function FraudDetectionPage() {
{
key: 'description',
label: 'Description',
render: (alert: any) => (
<span className="text-sm">{alert.description || alert.details}</span>
),
render: (alert: any) => <span className="text-sm">{alert.description || alert.details}</span>,
},
{
key: 'status',
@@ -102,6 +119,12 @@ export default function FraudDetectionPage() {
];
const actions = [
{
label: 'View Details',
onClick: (alert: any) => setSelected(alert),
variant: 'secondary' as const,
icon: Eye,
},
{
label: 'Acknowledge',
onClick: handleAcknowledge,
@@ -130,21 +153,11 @@ export default function FraudDetectionPage() {
<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 })}
/>
<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 })}
>
<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>
@@ -154,11 +167,7 @@ export default function FraudDetectionPage() {
</div>
<div>
<label className="label">Status</label>
<select
className="input"
value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
>
<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>
@@ -174,6 +183,121 @@ export default function FraudDetectionPage() {
loading={isLoading}
emptyMessage="No fraud alerts found"
/>
{/* Fraud Alert Details Modal */}
<Modal isOpen={!!selected} onClose={() => setSelected(null)} title="Fraud Alert Details" size="xl">
{selected && (() => {
const al = selected;
const grad = SEVERITY_GRAD[al.severity] || 'from-gray-600 to-gray-700';
return (
<div>
<div className={`-mx-6 -mt-4 mb-6 px-6 py-5 bg-gradient-to-r ${grad} rounded-t-lg`}>
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-white/70 text-xs font-semibold uppercase tracking-widest mb-1">Fraud Alert</p>
<p className="text-white text-xl font-bold">{al.ruleType}</p>
</div>
<div className="text-right shrink-0 space-y-1">
<Badge variant="status" status={al.severity === 'HIGH' || al.severity === 'CRITICAL' ? 'CANCELLED' : al.severity === 'MEDIUM' ? 'PENDING' : 'CONFIRMED'}>
{al.severity}
</Badge>
<div>
<Badge variant="status" status={al.acknowledged ? 'CONFIRMED' : 'PENDING'}>
{al.acknowledged ? 'Acknowledged' : 'Pending'}
</Badge>
</div>
<p className="text-white/70 text-xs">{formatDateTime(al.createdAt)}</p>
</div>
</div>
<div className="mt-4 grid grid-cols-3 gap-3">
{[
{ label: 'Severity', value: al.severity || '—' },
{ label: 'Rule Type', value: al.ruleType || '—' },
{ label: 'User', value: al.user?.fullName || al.user?.email || '—' },
].map(({ label, value }) => (
<div key={label} className="bg-white/10 rounded-lg px-3 py-2">
<p className="text-white/70 text-xs">{label}</p>
<p className="text-white text-sm font-bold truncate">{value}</p>
</div>
))}
</div>
</div>
<div className="space-y-6">
<section>
<SectionHeader title="Alert Details" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Field label="Rule Type" value={al.ruleType} />
<Field label="Severity" value={al.severity} />
<div className="bg-muted/40 rounded-lg p-3">
<p className="text-xs text-muted-foreground mb-2">Status</p>
<Badge variant="status" status={al.acknowledged ? 'CONFIRMED' : 'PENDING'}>
{al.acknowledged ? 'Acknowledged' : 'Pending'}
</Badge>
</div>
<Field label="Detected At" value={formatDateTime(al.createdAt)} />
<div className="col-span-2 md:col-span-4 bg-muted/40 rounded-lg p-3">
<p className="text-xs text-muted-foreground mb-1">Description</p>
<p className="text-sm font-medium">{al.description || al.details || '—'}</p>
</div>
</div>
</section>
<section>
<SectionHeader title="Flagged User" />
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<Field label="Full Name" value={al.user?.fullName} />
<Field label="Email" value={al.user?.email} truncate />
<Field label="Phone" value={al.user?.phone} />
<Field label="User ID" value={al.userId || al.user?.id} mono truncate />
<div className="bg-muted/40 rounded-lg p-3">
<p className="text-xs text-muted-foreground mb-2">Blocked</p>
<Badge variant="status" status={al.user?.isBlocked ? 'CANCELLED' : 'CONFIRMED'}>
{al.user?.isBlocked ? 'Blocked' : 'Not Blocked'}
</Badge>
</div>
</div>
</section>
{al.bookingId && (
<section>
<SectionHeader title="Related Booking" />
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<Field label="Booking ID" value={al.bookingId} mono truncate />
<Field label="Booking Ref" value={al.booking?.bookingRef} mono />
<Field label="Amount" value={al.booking?.totalMinor ? `ETB ${(al.booking.totalMinor / 100).toFixed(2)}` : 'N/A'} />
</div>
</section>
)}
{al.acknowledged && (
<section>
<SectionHeader title="Resolution" />
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<Field label="Acknowledged At" value={al.acknowledgedAt ? formatDateTime(al.acknowledgedAt) : '—'} />
<Field label="Acknowledged By" value={al.acknowledgedBy?.fullName || al.acknowledgedBy?.email || '—'} />
<Field label="Notes" value={al.resolutionNotes || '—'} truncate />
</div>
</section>
)}
<section>
<SectionHeader title="System" />
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<Field label="Alert ID" value={al.id} mono truncate />
<Field label="Created" value={formatDateTime(al.createdAt)} />
<Field label="Last Updated" value={formatDateTime(al.updatedAt)} />
</div>
</section>
</div>
<div className="flex justify-end gap-2 pt-6 mt-2 border-t border-muted">
<ActionButton variant="secondary" onClick={() => setSelected(null)}>Close</ActionButton>
</div>
</div>
);
})()}
</Modal>
</div>
);
}

View File

@@ -4,141 +4,295 @@ import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/lib/auth-store';
import { useTheme } from '@/lib/theme-store';
import { Train, Eye, EyeOff, Sun, Moon } from 'lucide-react';
import {
Eye, EyeOff, Sun, Moon, ArrowRight, Loader2,
TicketCheck, Users, TrendingUp, ShieldCheck,
} from 'lucide-react';
const EDR_GREEN = 'rgb(20, 113, 76)';
const features = [
{ icon: TicketCheck, label: 'Booking Management', desc: 'Full lifecycle booking operations' },
{ icon: Users, label: 'Passenger Services', desc: 'Profiles, loyalty & wallet' },
{ icon: TrendingUp, label: 'Revenue Analytics', desc: 'Real-time reports & insights' },
{ icon: ShieldCheck, label: 'Fraud Detection', desc: 'Automated risk monitoring' },
];
export default function LoginPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [isMounted, setIsMounted] = useState(false);
const router = useRouter();
const { login } = useAuthStore();
const [emailFocused, setEmailFocused] = useState(false);
const [passwordFocused, setPasswordFocused] = useState(false);
const router = useRouter();
const { login } = useAuthStore();
const { isDark, toggleTheme } = useTheme();
useEffect(() => {
setIsMounted(true);
}, []);
useEffect(() => { setIsMounted(true); }, []);
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);
setError(err.response?.data?.message || err.message || 'Invalid credentials. Please try again.');
} finally {
setLoading(false);
}
};
if (!isMounted) {
return null;
}
if (!isMounted) return null;
return (
<div className="flex min-h-screen relative bg-gradient-to-br from-[rgb(20,113,76)] to-[rgb(15,85,57)]">
{/* Full Screen Banner Background */}
<div className="absolute inset-0 bg-[url('/banner.jpg')] bg-cover bg-center opacity-50"></div>
<div className="flex min-h-screen bg-white dark:bg-gray-950">
{/* Content Overlay */}
<div className="relative z-10 flex items-center justify-start w-full px-4 lg:px-16">
<div className="w-full max-w-sm">
{/* Login Card with Shadow */}
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl border border-white/20 dark:border-gray-700/50 overflow-hidden backdrop-blur-sm">
{/* Card Header with Logo, App Name and Theme Toggle */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700/50 bg-gray-50 dark:bg-gray-700/50">
<div className="flex items-center gap-3">
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-[rgb(20,113,76)] shadow-md">
<Train className="h-9 w-9 text-white" />
</div>
<div>
<h2 className="text-lg font-bold text-gray-900 dark:text-white">Ethio-Djibouti Railway</h2>
<p className="text-lg text-gray-600 dark:text-gray-400">Passenger Back-office</p>
</div>
</div>
<button
onClick={toggleTheme}
className="p-2 rounded-lg bg-white/80 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
aria-label="Toggle theme"
>
{isDark ? (
<Sun className="w-5 h-5 text-yellow-500" />
) : (
<Moon className="w-5 h-5 text-gray-700" />
)}
</button>
{/* ── LEFT PANEL — form ── */}
<div className="flex-1 lg:flex-none lg:w-[42%] xl:w-[38%] flex flex-col min-h-screen bg-gray-50 dark:bg-gray-950 relative">
{/* Top bar */}
<div className="flex items-center justify-between px-8 py-4 lg:px-10">
{/* Logo — always visible on the form panel */}
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-lg bg-[rgb(20,113,76)] flex items-center justify-center shadow-md shadow-[rgb(20,113,76)]/30">
<svg className="w-4 h-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 2C8 2 5 5 5 8v8l2 2h10l2-2V8c0-3-3-6-7-6z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M8 17v2M16 17v2M5 12h14" />
<circle cx="9" cy="9" r="1" fill="currentColor" />
<circle cx="15" cy="9" r="1" fill="currentColor" />
</svg>
</div>
<div>
<div className="text-xs font-bold text-gray-900 dark:text-white tracking-wide leading-none">ETHIO-DJIBOUTI</div>
<div className="text-[12px] text-gray-400 dark:text-gray-500 tracking-widest uppercase leading-none mt-0.5">Railway</div>
</div>
</div>
<button
onClick={toggleTheme}
className="p-2 rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors text-gray-500 dark:text-gray-400"
aria-label="Toggle theme"
>
{isDark
? <Sun className="w-4 h-4 text-amber-400" />
: <Moon className="w-4 h-4" />
}
</button>
</div>
{/* Form area */}
<div className="flex-1 flex items-center justify-center px-8 py-10 lg:px-10 xl:px-14">
<div className="w-full max-w-xs">
{/* Heading */}
<div className="mb-8 animate-fade-up" style={{ animationDelay: '0ms' }}>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white tracking-tight">
Sign in to continue
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Enter your credentials to access the back-office.
</p>
</div>
{/* Card Body */}
<div className="p-6">
<div className="mb-8">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">Welcome back!</h2>
<p className="text-xl text-gray-900 dark:text-white">Sign in to continue.</p>
</div>
{error && (
<div className="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 p-4 text-sm text-red-800 dark:text-red-200 border border-red-200 dark:border-red-800">
{error}
{/* Error */}
{error && (<div className="animate-fade-up" style={{ animationDelay: '60ms' }}>
<div className="mb-5 flex items-start gap-3 rounded-xl bg-red-50 dark:bg-red-950/40 border border-red-200 dark:border-red-900/60 px-4 py-3">
<div className="flex-shrink-0 mt-0.5 w-4 h-4 rounded-full bg-red-500 flex items-center justify-center">
<span className="text-white text-[10px] font-bold">!</span>
</div>
)}
<p className="text-sm text-red-700 dark:text-red-300">{error}</p>
</div></div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">Email</label>
<form onSubmit={handleSubmit} className="space-y-4 animate-fade-up" style={{ animationDelay: '80ms' }}>
{/* Email field */}
<div>
<label className="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider mb-2">
Email address
</label>
<div className={`relative rounded-xl transition-all duration-200 ${
emailFocused
? 'ring-2 ring-[rgb(20,113,76)] ring-offset-0'
: 'ring-1 ring-gray-200 dark:ring-gray-800'
}`}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-[rgb(20,113,76)] focus:border-transparent"
placeholder="name@email.com"
onChange={(e) => { setEmail(e.target.value); setError(''); }}
onFocus={() => setEmailFocused(true)}
onBlur={() => setEmailFocused(false)}
className="w-full px-4 py-3 rounded-xl bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-600 text-sm focus:outline-none"
placeholder="name@edr.com"
required
autoComplete="email"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-2 text-gray-700 dark:text-gray-300">Password</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-[rgb(20,113,76)] focus:border-transparent"
placeholder="••••••••"
required
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
aria-label="Toggle password visibility"
>
{showPassword ? (
<EyeOff className="w-4 h-4" />
) : (
<Eye className="w-4 h-4" />
)}
</button>
</div>
{/* Password field */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">
Password
</label>
<button
type="button"
className="text-xs text-[rgb(20,113,76)] hover:text-[rgb(16,90,61)] font-medium transition-colors"
>
Forgot password?
</button>
</div>
<div className={`relative rounded-xl transition-all duration-200 ${
passwordFocused
? 'ring-2 ring-[rgb(20,113,76)] ring-offset-0'
: 'ring-1 ring-gray-200 dark:ring-gray-800'
}`}>
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => { setPassword(e.target.value); setError(''); }}
onFocus={() => setPasswordFocused(true)}
onBlur={() => setPasswordFocused(false)}
className="w-full px-4 py-3 pr-11 rounded-xl bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-600 text-sm focus:outline-none"
placeholder="••••••••••"
required
autoComplete="current-password"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 w-7 h-7 flex items-center justify-center rounded-lg text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-all"
aria-label="Toggle password visibility"
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
<button
type="submit"
disabled={loading}
className="w-full mt-6 py-2 bg-[rgb(20,113,76)] text-white font-semibold rounded-lg border-2 border-[rgb(20,113,76)] hover:bg-[rgb(16,90,61)] hover:border-[rgb(16,90,61)] disabled:opacity-50 transition-all duration-200"
>
{loading ? 'Signing in...' : 'Sign in'}
</button>
</form>
{/* Submit */}
<button
type="submit"
disabled={loading || !email || !password}
className="group w-full mt-2 flex items-center justify-center gap-2 py-3 px-4 rounded-xl font-semibold text-sm text-white transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
style={{ background: loading || !email || !password
? 'rgb(20,113,76)'
: `linear-gradient(135deg, rgb(20,113,76) 0%, rgb(16,143,96) 100%)`
}}
>
{loading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Signing in
</>
) : (
<>
Sign in
<ArrowRight className="w-4 h-4 transition-transform duration-200 group-hover:translate-x-0.5" />
</>
)}
</button>
</form>
{/* Divider */}
<div className="mt-8 pt-6 border-t border-gray-100 dark:border-gray-800/60 animate-fade-up" style={{ animationDelay: '160ms' }}>
<div className="flex items-center gap-3 p-3 rounded-xl bg-amber-50 dark:bg-amber-950/20 border border-amber-100 dark:border-amber-900/30">
<ShieldCheck className="w-4 h-4 text-amber-600 dark:text-amber-400 flex-shrink-0" />
<p className="text-xs text-amber-700 dark:text-amber-400 leading-relaxed">
Access is restricted to authorised EDR staff only. All sessions are logged and audited.
</p>
</div>
</div>
</div>
</div>
{/* Bottom bar */}
<div className="px-8 py-4 lg:px-10 flex items-center justify-between">
<span className="text-xs text-gray-400 dark:text-gray-600">
Back-office · v1.0
</span>
<span className="text-xs text-gray-400 dark:text-gray-600">
Need help? <a href="mailto:support@edr.com" className="text-[rgb(20,113,76)] hover:underline">support@edr.com</a>
</span>
</div>
</div>
{/* ── RIGHT PANEL — photo ── */}
<div className="hidden lg:flex flex-1 relative flex-col overflow-hidden">
{/* Layer 1 — base photo, desaturated */}
<div
className="absolute inset-0 bg-cover bg-center"
style={{
backgroundImage: "url('/banner.jpg')",
filter: isDark
? 'saturate(0.1) brightness(1)'
: 'saturate(0.15) brightness(1)',
}}
/>
{/* Layer 2 — brand green color wash */}
<div
className="absolute inset-0"
style={{
background: 'linear-gradient(145deg, rgb(5,46,30) 0%, rgb(20,113,76) 55%, rgb(4,120,67) 100%)',
mixBlendMode: 'multiply',
opacity: isDark ? 0.8 : 0.4,
}}
/>
{/* Content */}
<div className="relative z-10 flex flex-col h-full p-10 xl:p-14">
{/* Badge */}
<div className="flex justify-start">
<div className="inline-flex items-center gap-2 bg-white/10 backdrop-blur-sm border border-white/20 rounded-full px-3 py-1">
<div className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
<span className="text-white/80 text-xs font-medium tracking-wide">Back-office Portal v1.0</span>
</div>
</div>
{/* Hero text */}
<div className="mt-auto mb-auto">
<h1 className="text-4xl xl:text-5xl font-bold text-white leading-tight mb-4">
Passenger<br />
<span className="text-transparent bg-clip-text bg-gradient-to-r from-emerald-300 to-emerald-500">
Management
</span>
<br />System
</h1>
<p className="text-white/60 text-base leading-relaxed max-w-sm">
Unified platform for booking operations, passenger services, revenue analytics, and real-time train management.
</p>
</div>
{/* Feature grid */}
<div className="mt-auto grid grid-cols-2 gap-3">
{features.map(({ icon: Icon, label, desc }) => (
<div
key={label}
className="flex items-start gap-3 bg-white/5 hover:bg-white/10 backdrop-blur-sm border border-white/10 rounded-xl p-3.5 transition-colors duration-200"
>
<div className="flex-shrink-0 w-8 h-8 rounded-lg bg-[rgb(20,113,76)]/40 flex items-center justify-center">
<Icon className="w-4 h-4 text-emerald-300" />
</div>
<div>
<div className="text-white text-xs font-semibold">{label}</div>
<div className="text-white/40 text-xs mt-0.5">{desc}</div>
</div>
</div>
))}
</div>
{/* Bottom rule */}
<div className="mt-8 pt-6 border-t border-white/10">
<span className="text-white/30 text-xs block">© 2026 Ethio-Djibouti Railway S.C. Secure · Encrypted · Monitored</span>
</div>
</div>
</div>
</div>
);

View File

@@ -2,15 +2,44 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Download } from 'lucide-react';
import { Download, Eye, Star } 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 { loyaltyApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils';
import { formatDateTime } from '@/lib/utils';
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
<div className="bg-muted/40 rounded-lg p-3">
<p className="text-xs text-muted-foreground mb-1">{label}</p>
<p className={`text-sm font-semibold text-foreground${mono ? ' font-mono' : ''}${truncate ? ' truncate' : ''}`} title={value}>{value || '—'}</p>
</div>
);
const SectionHeader = ({ title }: { title: string }) => (
<h3 className="text-xs font-bold uppercase tracking-widest text-muted-foreground mb-3 flex items-center gap-2">
<span className="w-4 h-px bg-muted-foreground/40 inline-block" />{title}
</h3>
);
const TIER_COLORS: Record<string, string> = {
BRONZE: 'bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-400 border-orange-200 dark:border-orange-800',
SILVER: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-200 dark:border-gray-600',
GOLD: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400 border-yellow-200 dark:border-yellow-800',
PLATINUM: 'bg-indigo-100 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-400 border-indigo-200 dark:border-indigo-800',
};
const TIER_GRAD: Record<string, string> = {
BRONZE: 'from-orange-500 to-orange-600',
SILVER: 'from-gray-500 to-gray-600',
GOLD: 'from-yellow-500 to-yellow-600',
PLATINUM: 'from-indigo-600 to-indigo-700',
};
export default function LoyaltyPage() {
const [filters, setFilters] = useState({ search: '', tier: '' });
const [selected, setSelected] = useState<any>(null);
const { data, isLoading } = useQuery({
queryKey: ['loyalty', filters],
@@ -18,11 +47,24 @@ export default function LoyaltyPage() {
});
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 },
];
{ key: 'passenger', label: 'Passenger', render: (account: any) => (
<div>
<div className="font-medium">{account.passenger?.fullName || account.user?.fullName || 'N/A'}</div>
<div className="text-xs text-muted-foreground">{account.passenger?.email || account.user?.email || ''}</div>
</div>
)},
{ key: 'tier', label: 'Tier', render: (account: any) => (
<span className={`inline-flex items-center gap-1 text-xs font-bold px-2.5 py-0.5 rounded-full border ${TIER_COLORS[account.tier] || TIER_COLORS.BRONZE}`}>
<Star className="w-3 h-3" />{account.tier}
</span>
)},
{ key: 'pointsBalance', label: 'Points', render: (account: any) => (account.pointsBalance ?? 0).toLocaleString() },
{ key: 'lifetimePoints', label: 'Lifetime Points', render: (account: any) => (account.lifetimePoints ?? 0).toLocaleString() },
];
const actions = [
{ label: 'View Details', onClick: (a: any) => setSelected(a), variant: 'secondary' as const, icon: Eye },
];
return (
<div className="space-y-6">
@@ -36,31 +78,112 @@ export default function LoyaltyPage() {
<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>
<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={Array.isArray(data) ? data : (data?.items || [])}
columns={columns}
actions={actions}
loading={isLoading}
emptyMessage="No loyalty program found"
emptyMessage="No loyalty accounts found"
/>
{/* Loyalty Details Modal */}
<Modal isOpen={!!selected} onClose={() => setSelected(null)} title="Loyalty Account Details" size="xl">
{selected && (() => {
const a = selected;
const tier = a.tier || 'BRONZE';
const tierColor = TIER_COLORS[tier] || TIER_COLORS.BRONZE;
const grad = TIER_GRAD[tier] || 'from-gray-600 to-gray-700';
const passengerName = a.passenger?.fullName || a.user?.fullName || 'N/A';
return (
<div>
<div className={`-mx-6 -mt-4 mb-6 px-6 py-5 bg-gradient-to-r ${grad} rounded-t-lg`}>
<div className="flex items-center gap-4">
<div className="w-14 h-14 rounded-full bg-white/20 flex items-center justify-center shrink-0">
<Star className="w-7 h-7 text-white" />
</div>
<div className="flex-1 min-w-0">
<p className="text-white text-xl font-bold truncate">{passengerName}</p>
<p className="text-white/70 text-sm">{a.passenger?.email || a.user?.email || ''}</p>
</div>
<div className="text-right shrink-0">
<span className={`inline-flex items-center gap-1 text-xs font-bold px-3 py-1 rounded-full border ${tierColor}`}>
<Star className="w-3 h-3" />{tier}
</span>
</div>
</div>
<div className="mt-4 grid grid-cols-3 gap-3">
{[
{ label: 'Points Balance', value: (a.pointsBalance ?? 0).toLocaleString() },
{ label: 'Lifetime Points', value: (a.lifetimePoints ?? 0).toLocaleString() },
{ label: 'Points Redeemed', value: (a.pointsRedeemed ?? 0).toLocaleString() },
].map(({ label, value }) => (
<div key={label} className="bg-white/10 rounded-lg px-3 py-2">
<p className="text-white/70 text-xs">{label}</p>
<p className="text-white text-sm font-bold">{value}</p>
</div>
))}
</div>
</div>
<div className="space-y-6">
<section>
<SectionHeader title="Account Overview" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Field label="Current Tier" value={tier} />
<Field label="Points Balance" value={(a.pointsBalance ?? 0).toLocaleString()} />
<Field label="Lifetime Points" value={(a.lifetimePoints ?? 0).toLocaleString()} />
<Field label="Points Redeemed" value={(a.pointsRedeemed ?? 0).toLocaleString()} />
<Field label="Points Expiring" value={a.pointsExpiring ? a.pointsExpiring.toLocaleString() : 'N/A'} />
<Field label="Expiry Date" value={a.expiryDate ? formatDateTime(a.expiryDate) : 'N/A'} />
<Field label="Tier Since" value={a.tierAchievedAt ? formatDateTime(a.tierAchievedAt) : 'N/A'} />
<Field label="Next Tier" value={a.nextTier || 'N/A'} />
</div>
</section>
<section>
<SectionHeader title="Passenger" />
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<Field label="Full Name" value={a.passenger?.fullName || a.user?.fullName} />
<Field label="Email" value={a.passenger?.email || a.user?.email} truncate />
<Field label="Phone" value={a.passenger?.phone || a.user?.phone} />
<Field label="Passenger ID" value={a.passengerId || a.passenger?.id} mono truncate />
</div>
</section>
<section>
<SectionHeader title="Timestamps & IDs" />
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<Field label="Account Created" value={formatDateTime(a.createdAt)} />
<Field label="Last Updated" value={formatDateTime(a.updatedAt)} />
<Field label="Account ID" value={a.id} mono truncate />
</div>
</section>
</div>
<div className="flex justify-end gap-2 pt-6 mt-2 border-t border-muted">
<ActionButton variant="secondary" onClick={() => setSelected(null)}>Close</ActionButton>
</div>
</div>
);
})()}
</Modal>
</div>
);
}

View File

@@ -2,7 +2,7 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Download } from 'lucide-react';
import { Download, Eye } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
@@ -10,8 +10,22 @@ import Modal from '@/components/ui/Modal';
import { paymentsApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils';
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
<div className="bg-muted/40 rounded-lg p-3">
<p className="text-xs text-muted-foreground mb-1">{label}</p>
<p className={`text-sm font-semibold text-foreground${mono ? ' font-mono' : ''}${truncate ? ' truncate' : ''}`} title={value}>{value || '\u2014'}</p>
</div>
);
const SectionHeader = ({ title }: { title: string }) => (
<h3 className="text-xs font-bold uppercase tracking-widest text-muted-foreground mb-3 flex items-center gap-2">
<span className="w-4 h-px bg-muted-foreground/40 inline-block" />{title}
</h3>
);
export default function PaymentsPage() {
const [filters, setFilters] = useState({ search: '', status: '', method: '' });
const [selectedPayment, setSelectedPayment] = useState<any>(null);
const [exportModalOpen, setExportModalOpen] = useState(false);
const [exportDateFrom, setExportDateFrom] = useState('');
const [exportDateTo, setExportDateTo] = useState('');
@@ -86,6 +100,10 @@ export default function PaymentsPage() {
{ key: 'createdAt', label: 'Created', render: (payment: any) => formatDateTime(payment.createdAt) },
];
const paymentActions = [
{ label: 'View Details', onClick: (p: any) => setSelectedPayment(p), variant: 'secondary' as const, icon: Eye },
];
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
@@ -129,11 +147,103 @@ export default function PaymentsPage() {
<DataTable
data={(data as any)?.items || (Array.isArray(data) ? data : [])}
columns={columns}
actions={[]}
actions={paymentActions}
loading={isLoading}
emptyMessage="No payments found"
/>
{/* Payment Details Modal */}
<Modal isOpen={!!selectedPayment} onClose={() => setSelectedPayment(null)} title="Payment Details" size="xl">
{selectedPayment && (() => {
const p = selectedPayment;
const statusGrad: Record<string, string> = {
COMPLETED: 'from-emerald-600 to-emerald-700',
FAILED: 'from-red-600 to-red-700',
PENDING: 'from-amber-500 to-amber-600',
REFUNDED: 'from-blue-600 to-blue-700',
};
const grad = statusGrad[p.status] || 'from-gray-600 to-gray-700';
return (
<div>
<div className={`-mx-6 -mt-4 mb-6 px-6 py-5 bg-gradient-to-r ${grad} rounded-t-lg`}>
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-white/70 text-xs font-semibold uppercase tracking-widest mb-1">Payment Reference</p>
<p className="text-white text-2xl font-mono font-bold">{p.reference || p.id?.substring(0, 8)}</p>
</div>
<div className="text-right shrink-0">
<Badge variant="status" status={p.status}>{p.status}</Badge>
<p className="text-white/70 text-xs mt-1">{formatDateTime(p.createdAt)}</p>
</div>
</div>
<div className="mt-4 grid grid-cols-3 gap-3">
{[
{ label: 'Amount', value: formatCurrency(p.amountMinor, p.currency) },
{ label: 'Method', value: p.method || '—' },
{ label: 'Booking', value: p.booking?.bookingRef || '—' },
].map(({ label, value }) => (
<div key={label} className="bg-white/10 rounded-lg px-3 py-2">
<p className="text-white/70 text-xs">{label}</p>
<p className="text-white text-sm font-bold truncate">{value}</p>
</div>
))}
</div>
</div>
<div className="space-y-6">
<section>
<SectionHeader title="Transaction" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="bg-muted/40 rounded-lg p-3 col-span-2">
<p className="text-xs text-muted-foreground mb-1">Amount</p>
<p className="text-xl font-bold">{formatCurrency(p.amountMinor, p.currency || 'ETB')}</p>
</div>
<Field label="Method" value={p.method} />
<Field label="Status" value={p.status} />
<Field label="Reference" value={p.reference} mono truncate />
<Field label="Provider Ref" value={p.providerReference || p.externalReference} mono truncate />
<Field label="Created" value={formatDateTime(p.createdAt)} />
<Field label="Completed At" value={p.completedAt ? formatDateTime(p.completedAt) : 'N/A'} />
</div>
</section>
<section>
<SectionHeader title="Booking" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Field label="Booking Ref" value={p.booking?.bookingRef} mono />
<Field label="Booking Status" value={p.booking?.status} />
<Field label="Passenger" value={p.booking?.passenger?.fullName || p.booking?.contactEmail} truncate />
<Field label="Booking ID" value={p.bookingId} mono truncate />
</div>
</section>
{(p.failureReason || p.failureCode) && (
<section>
<SectionHeader title="Failure Information" />
<div className="grid grid-cols-2 gap-3">
<Field label="Failure Code" value={p.failureCode} mono />
<Field label="Failure Reason" value={p.failureReason} truncate />
</div>
</section>
)}
<section>
<SectionHeader title="IDs" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Field label="Payment ID" value={p.id} mono truncate />
<Field label="Last Updated" value={formatDateTime(p.updatedAt)} />
</div>
</section>
</div>
<div className="flex justify-end gap-2 pt-6 mt-2 border-t border-muted">
<ActionButton variant="secondary" onClick={() => setSelectedPayment(null)}>Close</ActionButton>
</div>
</div>
);
})()}
</Modal>
{/* Export Modal */}
<Modal isOpen={exportModalOpen} onClose={() => setExportModalOpen(false)} title="Export Payments" size="md">
<div className="space-y-4">

View File

@@ -1,11 +1,48 @@
'use client';
import { useState } from 'react';
import { Save, Users } from 'lucide-react';
import Link from 'next/link';
import { useState, useEffect } from 'react';
import { Save } from 'lucide-react';
import { systemConfigApi } from '@/lib/api';
type Tab = 'general' | 'payment' | 'integrations' | 'configurations';
export default function SettingsPage() {
const [activeTab, setActiveTab] = useState<'general' | 'payment' | 'integrations'>('general');
const [activeTab, setActiveTab] = useState<Tab>('general');
const [seatHoldMinutes, setSeatHoldMinutes] = useState('5');
const [configLoading, setConfigLoading] = useState(false);
const [configSaving, setConfigSaving] = useState(false);
const [configMessage, setConfigMessage] = useState('');
useEffect(() => {
if (activeTab !== 'configurations') return;
setConfigLoading(true);
systemConfigApi.getAll()
.then((data) => {
if (data?.seat_hold_duration_minutes) setSeatHoldMinutes(data.seat_hold_duration_minutes);
})
.catch(() => {})
.finally(() => setConfigLoading(false));
}, [activeTab]);
const saveConfigurations = async () => {
setConfigSaving(true);
setConfigMessage('');
try {
await systemConfigApi.update({ seat_hold_duration_minutes: seatHoldMinutes });
setConfigMessage('Saved successfully.');
} catch {
setConfigMessage('Failed to save.');
} finally {
setConfigSaving(false);
}
};
const tabs: { id: Tab; label: string }[] = [
{ id: 'general', label: 'General' },
{ id: 'payment', label: 'Payment' },
{ id: 'integrations', label: 'Integrations' },
{ id: 'configurations', label: 'Configurations' },
];
return (
<div className="space-y-6">
@@ -14,31 +51,24 @@ export default function SettingsPage() {
<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>
{activeTab !== 'configurations' && (
<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>
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2 font-medium ${activeTab === tab.id ? 'border-b-2 border-primary text-primary' : 'text-muted-foreground'}`}
>
{tab.label}
</button>
))}
</div>
{activeTab === 'general' && (
@@ -139,6 +169,46 @@ export default function SettingsPage() {
</div>
</div>
)}
{activeTab === 'configurations' && (
<div className="card space-y-6">
<h3 className="text-lg font-semibold text-foreground">Seat Booking</h3>
{configLoading ? (
<p className="text-sm text-muted-foreground">Loading...</p>
) : (
<div className="max-w-sm space-y-2">
<label className="label" htmlFor="hold-duration">
Seat Hold Duration (minutes)
</label>
<input
id="hold-duration"
type="number"
min="1"
max="60"
className="input"
value={seatHoldMinutes}
onChange={(e) => setSeatHoldMinutes(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
How long a seat hold remains active before it expires automatically. Default: 5 minutes.
</p>
</div>
)}
<div className="flex items-center gap-3">
<button
className="btn btn-primary flex items-center gap-2"
onClick={saveConfigurations}
disabled={configSaving || configLoading}
>
<Save className="h-4 w-4" />
{configSaving ? 'Saving...' : 'Save Changes'}
</button>
{configMessage && (
<span className="text-sm text-muted-foreground">{configMessage}</span>
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -23,6 +23,19 @@ export default function TicketsPage() {
const [successMessage, setSuccessMessage] = useState('');
const [detailsModalOpen, setDetailsModalOpen] = useState(false);
const [selectedTicket, setSelectedTicket] = useState<any>(null);
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
<div className="bg-muted/40 rounded-lg p-3">
<p className="text-xs text-muted-foreground mb-1">{label}</p>
<p className={`text-sm font-semibold text-foreground${mono ? ' font-mono' : ''}${truncate ? ' truncate' : ''}`} title={value}>{value || '—'}</p>
</div>
);
const SectionHeader = ({ title }: { title: string }) => (
<h3 className="text-xs font-bold uppercase tracking-widest text-muted-foreground mb-3 flex items-center gap-2">
<span className="w-4 h-px bg-muted-foreground/40 inline-block" />{title}
</h3>
);
const [exportModalOpen, setExportModalOpen] = useState(false);
const [exportDateFrom, setExportDateFrom] = useState('');
const [exportDateTo, setExportDateTo] = useState('');
@@ -534,111 +547,116 @@ export default function TicketsPage() {
isOpen={detailsModalOpen}
onClose={() => { setDetailsModalOpen(false); setSelectedTicket(null); }}
title="Ticket Details"
size="lg"
size="xl"
>
{selectedTicket && (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<p className="text-sm text-muted-foreground">Ticket Number</p>
<p className="font-mono font-semibold text-lg">{selectedTicket.ticketNumber}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Status</p>
<div className="mt-1">
<Badge variant="status" status={selectedTicket.status || 'ACTIVE'}>
{selectedTicket.status || 'ACTIVE'}
</Badge>
</div>
</div>
</div>
<div className="border-t pt-4">
<h3 className="font-semibold mb-3">Booking Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p className="text-sm text-muted-foreground">Booking Reference</p>
<p className="font-medium">{selectedTicket.booking?.bookingRef || 'N/A'}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Passenger</p>
<p className="font-medium">{selectedTicket.booking?.passenger?.fullName || selectedTicket.booking?.contactEmail || 'N/A'}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Amount</p>
<p className="font-medium">{formatCurrency(selectedTicket.booking?.totalMinor || 0, selectedTicket.booking?.currency || 'ETB')}</p>
</div>
</div>
</div>
<div className="border-t pt-4">
<h3 className="font-semibold mb-3">Trip Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p className="text-sm text-muted-foreground">Route</p>
<p className="font-medium">
{selectedTicket.schedule?.originStation?.name || 'N/A'} {selectedTicket.schedule?.destinationStation?.name || 'N/A'}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Departure</p>
<p className="font-medium">{selectedTicket.schedule?.departureAt ? formatDateTime(selectedTicket.schedule.departureAt) : 'N/A'}</p>
</div>
</div>
</div>
<div className="border-t pt-4">
<h3 className="font-semibold mb-3">Seat Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p className="text-sm text-muted-foreground">Coach</p>
<p className="font-mono font-semibold">{selectedTicket.seat?.coach?.number || 'N/A'}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Seat Number</p>
<p className="font-mono font-semibold">{selectedTicket.seat?.seatNumber || 'N/A'}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Class</p>
<p className="font-medium">{selectedTicket.seat?.coach?.coachType?.name || 'N/A'}</p>
</div>
</div>
</div>
{selectedTicket.validatedAt && (
<div className="border-t pt-4 bg-green-50 dark:bg-green-900/20 rounded-lg p-4">
<p className="text-sm text-muted-foreground">Validated At</p>
<p className="font-medium text-green-700 dark:text-green-400">{formatDateTime(selectedTicket.validatedAt)}</p>
</div>
)}
{selectedTicket.booking?.returnLegStatus && selectedTicket.booking.returnLegStatus !== 'NOT_APPLICABLE' && (
<div className="border-t pt-4">
<h3 className="font-semibold mb-3">Round-Trip Leg Status</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{selectedTicket && (() => {
const t = selectedTicket;
const b = t.booking;
const isRoundTrip = b?.bookingType === 'ROUND_TRIP' || b?.bookingType === 'ROUND_TRIP_TRANSIT';
const passengerName = b?.passenger?.fullName || b?.contactEmail || 'Guest';
return (
<div>
{/* Gradient header */}
<div className="-mx-6 -mt-4 mb-6 px-6 py-5 bg-gradient-to-r from-emerald-600 to-emerald-700 rounded-t-lg">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm text-muted-foreground">Leg Status</p>
<p className="font-medium">{selectedTicket.booking.returnLegStatus.replace(/_/g, ' ')}</p>
<p className="text-emerald-100 text-xs font-semibold uppercase tracking-widest mb-1">Ticket Number</p>
<p className="text-white text-3xl font-mono font-bold tracking-wider">{t.ticketNumber || '—'}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Outbound Boarded</p>
<p className="font-medium">{selectedTicket.booking.outboundBoardedAt ? formatDateTime(selectedTicket.booking.outboundBoardedAt) : '—'}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Return Boarded</p>
<p className="font-medium">{selectedTicket.booking.returnBoardedAt ? formatDateTime(selectedTicket.booking.returnBoardedAt) : '—'}</p>
<div className="text-right shrink-0">
<Badge variant="status" status={t.status || 'ACTIVE'}>{t.status || 'ACTIVE'}</Badge>
{t.validatedAt && <p className="text-emerald-200 text-xs mt-1">Validated {formatDateTime(t.validatedAt)}</p>}
</div>
</div>
<div className="mt-4 grid grid-cols-3 gap-3">
{[
{ label: 'Passenger', value: passengerName },
{ label: 'Route', value: `${t.schedule?.originStation?.name || '?'}${t.schedule?.destinationStation?.name || '?'}` },
{ label: 'Amount', value: formatCurrency(b?.totalMinor || 0, b?.currency || 'ETB') },
].map(({ label, value }) => (
<div key={label} className="bg-white/10 rounded-lg px-3 py-2">
<p className="text-emerald-200 text-xs">{label}</p>
<p className="text-white text-sm font-bold truncate">{value}</p>
</div>
))}
</div>
</div>
)}
<div className="flex justify-end gap-2 pt-4">
<ActionButton variant="secondary" onClick={() => { setDetailsModalOpen(false); setSelectedTicket(null); }}>
Close
</ActionButton>
<div className="space-y-6">
{/* Booking */}
<section>
<SectionHeader title="Booking Information" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Field label="Booking Ref" value={b?.bookingRef} mono />
<Field label="Booking Type" value={(b?.bookingType || 'ONE_WAY').replace(/_/g, ' ')} />
<Field label="Payment Status" value={b?.paymentIntent?.status || 'N/A'} />
<Field label="Contact Phone" value={b?.contactPhone || b?.passenger?.phone} />
<Field label="Contact Email" value={b?.contactEmail || b?.passenger?.email} truncate />
<Field label="Adults" value={String(b?.adultCount ?? 0)} />
<Field label="Children" value={String(b?.childCount ?? 0)} />
<Field label="Booking ID" value={b?.id} mono truncate />
</div>
</section>
{/* Trip */}
<section>
<SectionHeader title="Trip Information" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Field label="Origin" value={t.schedule?.originStation?.name} />
<Field label="Destination" value={t.schedule?.destinationStation?.name} />
<Field label="Departure" value={t.schedule?.departureAt ? formatDateTime(t.schedule.departureAt) : ''} />
<Field label="Arrival" value={t.schedule?.arrivalAt ? formatDateTime(t.schedule.arrivalAt) : ''} />
<Field label="Train" value={t.schedule?.train?.name || t.schedule?.train?.number} />
<Field label="Schedule ID" value={t.scheduleId} mono truncate />
</div>
</section>
{/* Seat */}
<section>
<SectionHeader title="Seat Information" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-100 dark:border-emerald-800 rounded-lg p-3 col-span-2 md:col-span-1 flex flex-col items-center justify-center">
<p className="text-xs text-emerald-700 dark:text-emerald-400 mb-1">Seat</p>
<p className="text-2xl font-mono font-bold text-emerald-800 dark:text-emerald-300">{t.seat?.seatNumber || '—'}</p>
</div>
<Field label="Coach" value={t.seat?.coach?.number} mono />
<Field label="Class" value={t.seat?.coach?.coachType?.name || t.seat?.coach?.coachType?.type} />
<Field label="Seat ID" value={t.seatId} mono truncate />
</div>
</section>
{/* Round-trip */}
{isRoundTrip && (
<section>
<SectionHeader title="Round-Trip Legs" />
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<Field label="Leg Status" value={(b?.returnLegStatus || '—').replace(/_/g, ' ')} />
<Field label="Outbound Boarded" value={b?.outboundBoardedAt ? formatDateTime(b.outboundBoardedAt) : 'Not yet'} />
<Field label="Return Boarded" value={b?.returnBoardedAt ? formatDateTime(b.returnBoardedAt) : 'Not yet'} />
</div>
</section>
)}
{/* Validation */}
<section>
<SectionHeader title="Validation & Timestamps" />
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<Field label="Validated At" value={t.validatedAt ? formatDateTime(t.validatedAt) : 'Not validated'} />
<Field label="Boarded At" value={t.boardedAt ? formatDateTime(t.boardedAt) : 'Not boarded'} />
<Field label="QR Code" value={t.qrCode ? 'Generated' : 'N/A'} />
<Field label="Created" value={formatDateTime(t.createdAt)} />
<Field label="Last Updated" value={formatDateTime(t.updatedAt)} />
<Field label="Ticket ID" value={t.id} mono truncate />
</div>
</section>
</div>
<div className="flex justify-end gap-2 pt-6 mt-2 border-t border-muted">
<ActionButton variant="secondary" onClick={() => { setDetailsModalOpen(false); setSelectedTicket(null); }}>Close</ActionButton>
</div>
</div>
</div>
)}
);
})()}
</Modal>
{/* Export Modal */}

View File

@@ -2,15 +2,30 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Download } from 'lucide-react';
import { Download, Eye, ShieldCheck, ShieldOff } 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 { verifaydaApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils';
import { formatDateTime } from '@/lib/utils';
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
<div className="bg-muted/40 rounded-lg p-3">
<p className="text-xs text-muted-foreground mb-1">{label}</p>
<p className={`text-sm font-semibold text-foreground${mono ? ' font-mono' : ''}${truncate ? ' truncate' : ''}`} title={value}>{value || '—'}</p>
</div>
);
const SectionHeader = ({ title }: { title: string }) => (
<h3 className="text-xs font-bold uppercase tracking-widest text-muted-foreground mb-3 flex items-center gap-2">
<span className="w-4 h-px bg-muted-foreground/40 inline-block" />{title}
</h3>
);
export default function VerifaydaPage() {
const [filters, setFilters] = useState({ search: '', verified: '' });
const [selected, setSelected] = useState<any>(null);
const { data, isLoading } = useQuery({
queryKey: ['verifayda', filters],
@@ -18,11 +33,19 @@ export default function VerifaydaPage() {
});
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) },
];
{ key: 'nationalId', label: 'National ID', render: (ver: any) => <span className="font-mono">{ver.nationalId}</span> },
{ key: 'fullName', label: 'Name', render: (ver: any) => ver.fullName || ver.returnedName || '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) },
];
const actions = [
{ label: 'View Details', onClick: (v: any) => setSelected(v), variant: 'secondary' as const, icon: Eye },
];
return (
<div className="space-y-6">
@@ -36,29 +59,129 @@ export default function VerifaydaPage() {
<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>
<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}
actions={actions}
loading={isLoading}
emptyMessage="No verifayda integration found"
emptyMessage="No verification records found"
/>
{/* Verifayda Details Modal */}
<Modal isOpen={!!selected} onClose={() => setSelected(null)} title="Verification Details" size="xl">
{selected && (() => {
const v = selected;
const isVerified = !!v.verified;
const grad = isVerified ? 'from-emerald-600 to-emerald-700' : 'from-red-600 to-red-700';
const name = v.fullName || v.returnedName || 'N/A';
return (
<div>
<div className={`-mx-6 -mt-4 mb-6 px-6 py-5 bg-gradient-to-r ${grad} rounded-t-lg`}>
<div className="flex items-center gap-4">
<div className="w-14 h-14 rounded-full bg-white/20 flex items-center justify-center shrink-0">
{isVerified
? <ShieldCheck className="w-7 h-7 text-white" />
: <ShieldOff className="w-7 h-7 text-white" />}
</div>
<div className="flex-1 min-w-0">
<p className="text-white text-xl font-bold truncate">{name}</p>
<p className="text-white/70 text-sm font-mono">{v.nationalId}</p>
</div>
<div className="text-right shrink-0">
<Badge variant="status" status={isVerified ? 'CONFIRMED' : 'CANCELLED'}>
{isVerified ? '✓ Verified' : '✗ Failed'}
</Badge>
<p className="text-white/70 text-xs mt-1">{formatDateTime(v.createdAt)}</p>
</div>
</div>
<div className="mt-4 grid grid-cols-3 gap-3">
{[
{ label: 'National ID', value: v.nationalId || '—' },
{ label: 'Date of Birth', value: v.dateOfBirth || v.returnedDob || '—' },
{ label: 'Nationality', value: v.nationality || 'Ethiopian' },
].map(({ label, value }) => (
<div key={label} className="bg-white/10 rounded-lg px-3 py-2">
<p className="text-white/70 text-xs">{label}</p>
<p className="text-white text-sm font-bold truncate">{value}</p>
</div>
))}
</div>
</div>
<div className="space-y-6">
<section>
<SectionHeader title="Verification Result" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="bg-muted/40 rounded-lg p-3">
<p className="text-xs text-muted-foreground mb-2">Status</p>
<div className="flex items-center gap-2">
{isVerified
? <ShieldCheck className="w-4 h-4 text-emerald-600 shrink-0" />
: <ShieldOff className="w-4 h-4 text-red-500 shrink-0" />}
<span className={`text-sm font-semibold ${isVerified ? 'text-emerald-700 dark:text-emerald-400' : 'text-red-600 dark:text-red-400'}`}>
{isVerified ? 'Verified' : 'Failed'}
</span>
</div>
</div>
<Field label="Verified At" value={formatDateTime(v.createdAt)} />
<Field label="Failure Reason" value={v.failureReason || (isVerified ? 'N/A' : 'Verification failed')} truncate />
<Field label="Response Code" value={v.responseCode || 'N/A'} mono />
</div>
</section>
<section>
<SectionHeader title="Identity Data (from Fayda)" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Field label="National ID" value={v.nationalId} mono />
<Field label="Full Name" value={v.fullName || v.returnedName} />
<Field label="Date of Birth" value={v.dateOfBirth || v.returnedDob} />
<Field label="Gender" value={v.gender || v.returnedGender} />
<Field label="Nationality" value={v.nationality || 'Ethiopian'} />
<Field label="Phone" value={v.phone || v.returnedPhone} />
</div>
</section>
<section>
<SectionHeader title="Linked Passenger" />
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<Field label="Passenger Name" value={v.passenger?.fullName || v.user?.fullName} />
<Field label="Email" value={v.passenger?.email || v.user?.email} truncate />
<Field label="Passenger ID" value={v.passengerId || v.passenger?.id} mono truncate />
</div>
</section>
<section>
<SectionHeader title="System" />
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<Field label="Record ID" value={v.id} mono truncate />
<Field label="Created" value={formatDateTime(v.createdAt)} />
<Field label="Last Updated" value={formatDateTime(v.updatedAt)} />
</div>
</section>
</div>
<div className="flex justify-end gap-2 pt-6 mt-2 border-t border-muted">
<ActionButton variant="secondary" onClick={() => setSelected(null)}>Close</ActionButton>
</div>
</div>
);
})()}
</Modal>
</div>
);
}

View File

@@ -2,15 +2,30 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Download } from 'lucide-react';
import { Download, Eye, Wallet } 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 { walletApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils';
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
<div className="bg-muted/40 rounded-lg p-3">
<p className="text-xs text-muted-foreground mb-1">{label}</p>
<p className={`text-sm font-semibold text-foreground${mono ? ' font-mono' : ''}${truncate ? ' truncate' : ''}`} title={value}>{value || '—'}</p>
</div>
);
const SectionHeader = ({ title }: { title: string }) => (
<h3 className="text-xs font-bold uppercase tracking-widest text-muted-foreground mb-3 flex items-center gap-2">
<span className="w-4 h-px bg-muted-foreground/40 inline-block" />{title}
</h3>
);
export default function WalletPage() {
const [filters, setFilters] = useState({ search: '' });
const [selected, setSelected] = useState<any>(null);
const { data, isLoading } = useQuery({
queryKey: ['wallet', filters],
@@ -18,10 +33,25 @@ export default function WalletPage() {
});
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> },
];
{ key: 'passenger', label: 'Passenger', render: (account: any) => (
<div>
<div className="font-medium">{account.passenger?.fullName || account.user?.fullName || 'N/A'}</div>
<div className="text-xs text-muted-foreground">{account.passenger?.email || account.user?.email || ''}</div>
</div>
)},
{ key: 'balanceMinor', label: 'Balance', render: (account: any) => (
<span className="font-semibold">{formatCurrency(account.balanceMinor, account.currency || 'ETB')}</span>
)},
{ key: 'status', label: 'Status', render: (account: any) => (
<Badge variant="status" status={account.isActive ? 'CONFIRMED' : 'CANCELLED'}>
{account.isActive ? 'Active' : 'Inactive'}
</Badge>
)},
];
const actions = [
{ label: 'View Details', onClick: (a: any) => setSelected(a), variant: 'secondary' as const, icon: Eye },
];
return (
<div className="space-y-6">
@@ -35,21 +65,113 @@ export default function WalletPage() {
<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">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 wallet management found"
emptyMessage="No wallet accounts found"
/>
{/* Wallet Details Modal */}
<Modal isOpen={!!selected} onClose={() => setSelected(null)} title="Wallet Account Details" size="xl">
{selected && (() => {
const w = selected;
const balance = w.balanceMinor ?? 0;
const passengerName = w.passenger?.fullName || w.user?.fullName || 'N/A';
return (
<div>
<div className="from-blue-600 to-blue-700 -mx-6 -mt-4 mb-6 px-6 py-5 bg-gradient-to-r rounded-t-lg">
<div className="flex items-center gap-4">
<div className="w-14 h-14 rounded-full bg-white/20 flex items-center justify-center shrink-0">
<Wallet className="w-7 h-7 text-white" />
</div>
<div className="flex-1 min-w-0">
<p className="text-white text-xl font-bold truncate">{passengerName}</p>
<p className="text-blue-200 text-sm">{w.passenger?.email || w.user?.email || ''}</p>
</div>
<div className="text-right shrink-0">
<Badge variant="status" status={w.isActive ? 'CONFIRMED' : 'CANCELLED'}>
{w.isActive ? 'Active' : 'Inactive'}
</Badge>
</div>
</div>
<div className="mt-4 grid grid-cols-3 gap-3">
{[
{ label: 'Current Balance', value: formatCurrency(balance, w.currency || 'ETB') },
{ label: 'Currency', value: w.currency || 'ETB' },
{ label: 'Total Topped Up', value: formatCurrency(w.totalTopUp ?? 0, w.currency || 'ETB') },
].map(({ label, value }) => (
<div key={label} className="bg-white/10 rounded-lg px-3 py-2">
<p className="text-blue-200 text-xs">{label}</p>
<p className="text-white text-sm font-bold truncate">{value}</p>
</div>
))}
</div>
</div>
<div className="space-y-6">
<section>
<SectionHeader title="Balance" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-100 dark:border-blue-800 rounded-lg p-3 col-span-2">
<p className="text-xs text-blue-700 dark:text-blue-400 mb-1">Current Balance</p>
<p className="text-xl font-bold text-blue-800 dark:text-blue-300">{formatCurrency(balance, w.currency || 'ETB')}</p>
</div>
<Field label="Total Topped Up" value={formatCurrency(w.totalTopUp ?? 0, w.currency || 'ETB')} />
<Field label="Total Spent" value={formatCurrency(w.totalSpent ?? 0, w.currency || 'ETB')} />
</div>
</section>
<section>
<SectionHeader title="Account Details" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Field label="Currency" value={w.currency || 'ETB'} />
<div className="bg-muted/40 rounded-lg p-3">
<p className="text-xs text-muted-foreground mb-2">Status</p>
<Badge variant="status" status={w.isActive ? 'CONFIRMED' : 'CANCELLED'}>
{w.isActive ? 'Active' : 'Inactive'}
</Badge>
</div>
<Field label="Locked" value={w.isLocked ? 'Yes' : 'No'} />
<Field label="Lock Reason" value={w.lockReason || 'N/A'} truncate />
</div>
</section>
<section>
<SectionHeader title="Passenger" />
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<Field label="Full Name" value={w.passenger?.fullName || w.user?.fullName} />
<Field label="Email" value={w.passenger?.email || w.user?.email} truncate />
<Field label="Phone" value={w.passenger?.phone || w.user?.phone} />
<Field label="Passenger ID" value={w.passengerId || w.passenger?.id} mono truncate />
</div>
</section>
<section>
<SectionHeader title="Timestamps & IDs" />
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<Field label="Created" value={formatDateTime(w.createdAt)} />
<Field label="Last Updated" value={formatDateTime(w.updatedAt)} />
<Field label="Account ID" value={w.id} mono truncate />
</div>
</section>
</div>
<div className="flex justify-end gap-2 pt-6 mt-2 border-t border-muted">
<ActionButton variant="secondary" onClick={() => setSelected(null)}>Close</ActionButton>
</div>
</div>
);
})()}
</Modal>
</div>
);
}

View File

@@ -373,3 +373,9 @@ export const reportsApi = {
return response?.data ? (Array.isArray(response.data) ? { items: response.data } : response) : { items: [] };
},
};
// System Config API
export const systemConfigApi = {
getAll: () => apiClient.get<Record<string, string>>('/system-config'),
update: (data: Record<string, string>) => apiClient.patch<Record<string, string>>('/system-config', data),
};

View File

@@ -4,9 +4,17 @@ import axios from 'axios';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
function mapIamRole(roles: { key?: string }[]): 'ADMIN' | 'AGENT' | 'SUPERVISOR' {
const keys = roles.map((r) => r.key ?? '');
if (keys.some((k) => k.includes('admin') || k === 'super_admin' || k === 'organization_admin')) return 'ADMIN';
if (keys.some((k) => k.includes('agent'))) return 'AGENT';
return 'SUPERVISOR';
}
interface AuthState {
user: AdminUser | null;
token: string | null;
refreshToken: string | null;
isAuthenticated: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
@@ -17,6 +25,7 @@ interface AuthState {
export const useAuthStore = create<AuthState>((set) => ({
user: null,
token: null,
refreshToken: null,
isAuthenticated: false,
initialize: () => {
@@ -27,57 +36,47 @@ export const useAuthStore = create<AuthState>((set) => ({
try {
const user = JSON.parse(userStr);
set({ user, token, isAuthenticated: true });
} catch (e) {
} catch {
localStorage.removeItem('auth_token');
localStorage.removeItem('auth_refresh_token');
localStorage.removeItem('auth_user');
}
}
},
login: async (email: string, password: string) => {
try {
console.log('Attempting login to:', `${API_URL}/auth/login`);
const response = await axios.post(`${API_URL}/auth/login`, { email, password });
console.log('Full response:', response.data);
// Backend wraps response in { success, data: { token, user }, timestamp }
const responseData = response.data.data || response.data;
if (!responseData || !responseData.token || !responseData.user) {
console.error('Invalid response structure:', response.data);
throw new Error('Invalid response from server');
}
const { token, user: apiUser } = responseData;
const user: AdminUser = {
id: apiUser.id,
email: apiUser.email,
fullName: apiUser.fullName,
role: apiUser.role,
active: true,
};
console.log('Login successful! User:', user);
localStorage.setItem('auth_token', token);
localStorage.setItem('auth_user', JSON.stringify(user));
set({ user, token, isAuthenticated: true });
} catch (error: any) {
console.error('Login error details:', {
message: error.message,
response: error.response?.data,
status: error.response?.status,
});
throw error;
}
// Step 1: IAM login — returns token + refreshToken only
const loginRes = await axios.post(`${API_URL}/v1/auth/login`, { email, password });
const loginData = loginRes.data?.data ?? loginRes.data;
const { token, refreshToken } = loginData;
if (!token) throw new Error('No token received from server');
// Step 2: fetch full user info with the token
const meRes = await axios.get(`${API_URL}/v1/auth/me`, {
headers: { Authorization: `Bearer ${token}` },
});
const iamUser = meRes.data?.data ?? meRes.data;
const user: AdminUser = {
id: iamUser.id,
email: iamUser.email,
fullName: iamUser.name?.en ?? iamUser.name?.am ?? iamUser.email,
role: mapIamRole(iamUser.roles ?? []),
active: true,
};
localStorage.setItem('auth_token', token);
localStorage.setItem('auth_user', JSON.stringify(user));
if (refreshToken) localStorage.setItem('auth_refresh_token', refreshToken);
set({ user, token, refreshToken: refreshToken ?? null, isAuthenticated: true });
},
logout: () => {
localStorage.removeItem('auth_token');
localStorage.removeItem('auth_refresh_token');
localStorage.removeItem('auth_user');
set({ user: null, token: null, isAuthenticated: false });
set({ user: null, token: null, refreshToken: null, isAuthenticated: false });
},
setUser: (user: AdminUser, token: string) => {

View File

@@ -60,6 +60,16 @@
}
}
@layer utilities {
@keyframes fade-up {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-fade-up {
animation: fade-up 0.4s cubic-bezier(0.22, 1, 0.36, 1) both;
}
}
@layer components {
.card {
background-color: hsl(var(--card));