Files
edr-platform/apps/edr-passenger-web/backoffice/src/app/fraud/page.tsx

318 lines
13 KiB
TypeScript

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