mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 15:25:45 +00:00
Merge branch 'alpha' of https://github.com/Tria-plc/edr-platform into alpha
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user