MichaelEdou
feat: add Email Senders page, replace Reports with Coming Soon, remove AI references
efc415a
import { useState, useEffect, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { ScanLine, StopCircle, CheckCircle2, AlertTriangle } from 'lucide-react';
import { useEmailScan } from '@/hooks/useEmailScan';
import { useScanEvents } from '@/hooks/useRealtime';
import type { ScanPreset } from '@icc/shared';
interface LogEntry {
id: string;
time: string;
type: 'found' | 'duplicate' | 'info' | 'error';
sender?: string;
amount?: string;
ref?: string;
branch?: string;
message?: string;
}
interface ScanModalProps {
onClose: () => void;
onMinimize: () => void;
preset?: ScanPreset;
forceRescan?: boolean;
startDate?: string;
endDate?: string;
}
function formatTime(date?: Date): string {
const d = date ?? new Date();
return d.toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
export default function ScanModal({ onClose, onMinimize, preset = 'today', forceRescan = false, startDate, endDate }: ScanModalProps) {
const { t } = useTranslation();
const { startScan, activeJobId, scanStatus, scanError, isStarting } = useEmailScan();
const [logEntries, setLogEntries] = useState<LogEntry[]>([]);
const [stats, setStats] = useState({ processed: 0, skipped: 0, errored: 0, total: 0 });
const [status, setStatus] = useState<'starting' | 'scanning' | 'completed' | 'failed'>('starting');
const [dateRange, setDateRange] = useState<{ startDate?: string; endDate?: string }>({});
const logContainerRef = useRef<HTMLDivElement>(null);
const scanStarted = useRef(false);
// Start scan on mount
useEffect(() => {
if (scanStarted.current) return;
scanStarted.current = true;
startScan(preset, { forceRescan, startDate, endDate });
}, [preset, forceRescan, startScan]);
// Add log entry helper
const addLog = useCallback((entry: Omit<LogEntry, 'id'>) => {
setLogEntries(prev => {
const newEntries = [{ ...entry, id: `${Date.now()}_${Math.random().toString(36).slice(2)}` }, ...prev];
return newEntries.slice(0, 50);
});
}, []);
// Listen to WebSocket events
useScanEvents({
onStarted: useCallback((data: any) => {
setStatus('scanning');
setStats(prev => ({ ...prev, total: data.newEmails ?? 0, skipped: data.skipped ?? 0 }));
setDateRange({ startDate: data.dateRange?.startDate, endDate: data.dateRange?.endDate });
addLog({
time: formatTime(),
type: 'info',
message: `${data.totalEmails ?? 0} ${t('scanModal.logEmailsFound')}, ${data.newEmails ?? 0} ${t('scanModal.logNewToProcess')}`,
});
if (data.skipped > 0) {
addLog({
time: formatTime(),
type: 'duplicate',
message: `${data.skipped} ${t('scanModal.logAlreadyProcessed')}`,
});
}
}, [addLog, t]),
onProgress: useCallback((data: any) => {
setStatus('scanning');
setStats({
processed: data.processed ?? 0,
total: data.total ?? 0,
skipped: data.skipped ?? 0,
errored: data.errored ?? 0,
});
if (data.latest) {
const tx = data.latest;
addLog({
time: formatTime(),
type: 'found',
sender: tx.sender || 'Inconnu',
amount: tx.amount ? `${Number(tx.amount).toFixed(2)}$` : undefined,
ref: tx.reference,
branch: tx.branch,
});
}
if (data.error) {
addLog({
time: formatTime(),
type: 'error',
message: data.error.message || 'Erreur de traitement',
});
}
}, [addLog]),
onCompleted: useCallback((data: any) => {
setStatus('completed');
addLog({
time: formatTime(),
type: 'info',
message: `${t('scanModal.logScanComplete')} (${data.duration ?? ''}) — ${data.summary?.parsed ?? 0} ${t('scanModal.logTransactionsExtracted')}`,
});
}, [addLog, t]),
onError: useCallback((data: any) => {
setStatus('failed');
addLog({
time: formatTime(),
type: 'error',
message: data.message || t('scanModal.logScanError'),
});
}, [addLog, t]),
});
// Also update from polling status (fallback)
useEffect(() => {
if (scanStatus) {
if (scanStatus.status === 'completed') setStatus('completed');
if (scanStatus.status === 'failed') setStatus('failed');
if (scanStatus.status === 'scanning' || scanStatus.status === 'parsing') setStatus('scanning');
}
}, [scanStatus]);
// Track error from mutation
useEffect(() => {
if (scanError) {
setStatus('failed');
addLog({ time: formatTime(), type: 'error', message: scanError.message });
}
}, [scanError, addLog]);
const emailsDone = stats.processed + stats.errored;
const progressPercent = stats.total > 0
? Math.round((emailsDone / stats.total) * 100)
: 0;
const totalProcessed = emailsDone + stats.skipped;
const grandTotal = stats.total + stats.skipped;
const totalPercent = grandTotal > 0
? Math.round((totalProcessed / grandTotal) * 100)
: 0;
const isActive = status === 'starting' || status === 'scanning';
return (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-slate-900/40 backdrop-blur-sm p-4">
<div className="w-full max-w-2xl transform overflow-hidden rounded-2xl bg-white p-8 text-left shadow-2xl border border-white/20">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div className="flex items-center gap-4">
<div className="relative flex h-12 w-12 items-center justify-center rounded-xl bg-blue-50 text-primary">
{status === 'completed' ? (
<CheckCircle2 className="h-7 w-7 text-emerald-600" />
) : status === 'failed' ? (
<AlertTriangle className="h-7 w-7 text-red-600" />
) : (
<ScanLine className="h-7 w-7 animate-pulse" />
)}
{isActive && (
<div className="absolute -right-1 -top-1 h-3 w-3 rounded-full bg-green-500 ring-2 ring-white" />
)}
</div>
<div>
<h3 className="text-xl font-bold text-slate-900">
{status === 'completed' ? t('scanModal.titleComplete') : status === 'failed' ? t('scanModal.titleError') : t('scanModal.title')}
</h3>
<p className="text-sm text-slate-500 flex items-center gap-1.5">
{isActive && <span className="h-1.5 w-1.5 rounded-full bg-green-500 animate-pulse" />}
{t('scanModal.engine', 'Scan Engine active')}
</p>
</div>
</div>
{activeJobId && (
<div className="rounded-lg bg-slate-50 px-3 py-1.5 text-xs font-medium text-slate-600 border border-slate-100">
{t('scanModal.sessionId')}: #{activeJobId.slice(-8).toUpperCase()}
</div>
)}
</div>
{/* Progress section */}
<div className="mb-8 space-y-6">
<div className="flex justify-between items-end text-sm mb-2">
<span className="font-semibold text-slate-900">
{isStarting ? t('scanModal.starting') : `${emailsDone} / ${stats.total} ${t('scanModal.emailsLabel')}`}
</span>
{dateRange.startDate && dateRange.endDate && (
<span className="text-slate-500">
{t('scanModal.processing')} {dateRange.startDate.slice(0, 10)} {t('scanModal.toDate')} {dateRange.endDate.slice(0, 10)}
</span>
)}
</div>
<div className="space-y-4">
{/* Current progress */}
<div>
<div className="flex justify-between text-xs mb-1.5">
<span className="text-slate-600 font-medium">{t('scanModal.batchProgress')}</span>
<span className="text-primary font-bold">{progressPercent}%</span>
</div>
<div className="h-2.5 w-full rounded-full bg-slate-100 overflow-hidden">
<div
className="h-full rounded-full bg-primary transition-all duration-500 ease-out relative overflow-hidden"
style={{ width: `${progressPercent}%` }}
>
{isActive && <div className="absolute inset-0 bg-white/20 animate-pulse" />}
</div>
</div>
</div>
{/* Total progress */}
<div>
<div className="flex justify-between text-xs mb-1.5">
<span className="text-slate-500">{t('scanModal.totalProgress')}</span>
<span className="text-slate-700 font-semibold">{totalPercent}%</span>
</div>
<div className="h-1.5 w-full rounded-full bg-slate-100">
<div
className={`h-full rounded-full transition-all duration-500 ${
status === 'completed' ? 'bg-emerald-500' : status === 'failed' ? 'bg-red-400' : 'bg-slate-400/60'
}`}
style={{ width: `${totalPercent}%` }}
/>
</div>
</div>
</div>
</div>
{/* Stats grid */}
<div className="grid grid-cols-3 gap-4 mb-8">
<div className="rounded-xl bg-slate-50 p-4 border border-slate-100">
<div className="text-slate-500 text-xs font-medium mb-1 uppercase tracking-wide">{t('scanModal.emailsAnalyzed')}</div>
<div className="text-2xl font-bold text-slate-900">{stats.processed}</div>
</div>
<div className="rounded-xl bg-slate-50 p-4 border border-slate-100">
<div className="text-slate-500 text-xs font-medium mb-1 uppercase tracking-wide">{t('scanModal.duplicatesSkipped')}</div>
<div className="text-2xl font-bold text-slate-700">{stats.skipped}</div>
</div>
<div className={`rounded-xl p-4 border ${stats.errored > 0 ? 'bg-red-50 border-red-100' : 'bg-slate-50 border-slate-100'}`}>
<div className={`text-xs font-medium mb-1 uppercase tracking-wide ${stats.errored > 0 ? 'text-red-600' : 'text-slate-500'}`}>
{t('scanModal.errors')}
</div>
<div className={`text-2xl font-bold ${stats.errored > 0 ? 'text-red-700' : 'text-slate-700'}`}>{stats.errored}</div>
</div>
</div>
{/* Extraction log */}
<div className="mb-8 rounded-xl border border-slate-200 bg-slate-50 overflow-hidden">
<div className="border-b border-slate-200 bg-slate-100/50 px-4 py-2 text-xs font-semibold text-slate-500 uppercase tracking-wider flex justify-between items-center">
<span>{t('scanModal.extractionLog', 'Extraction Log')}</span>
{isActive && <span className="h-1.5 w-1.5 rounded-full bg-green-500 animate-pulse" />}
</div>
<div ref={logContainerRef} className="h-40 overflow-y-auto p-4 space-y-2 font-mono text-sm">
{logEntries.length === 0 ? (
<div className="flex items-center justify-center h-full text-slate-400 text-xs">
{isStarting ? t('scanModal.logWaiting') : t('scanModal.logEmpty')}
</div>
) : (
logEntries.map((entry, index) => (
<div key={entry.id} className="flex items-start gap-3" style={{ opacity: Math.max(0.4, 1 - index * 0.08) }}>
<span className="text-slate-400 text-xs mt-0.5">{entry.time}</span>
<div className="flex-1">
{entry.type === 'found' && (
<>
<span className="text-emerald-600 font-semibold">{t('scanModal.logFound')}:</span>{' '}
<span className="text-slate-800">{entry.sender}</span>
{entry.amount && (
<>
<span className="mx-2 text-slate-300">|</span>
<span className="text-slate-600">{t('scanModal.logAmount')}:</span>{' '}
<span className="text-slate-900 font-bold">{entry.amount}</span>
</>
)}
{entry.ref && (
<>
<span className="mx-2 text-slate-300">|</span>
<span className="text-slate-500 text-xs">Ref: {entry.ref}</span>
</>
)}
</>
)}
{entry.type === 'duplicate' && (
<>
<span className="text-amber-600 font-semibold">{t('scanModal.logDuplicate')}:</span>{' '}
<span className="text-slate-600">{entry.message}</span>
</>
)}
{entry.type === 'info' && (
<span className="text-slate-500">{entry.message}</span>
)}
{entry.type === 'error' && (
<>
<span className="text-red-600 font-semibold">{t('scanModal.logError')}:</span>{' '}
<span className="text-red-700">{entry.message}</span>
</>
)}
</div>
</div>
))
)}
</div>
</div>
{/* Action buttons */}
<div className="flex justify-end gap-3">
<button
onClick={onMinimize}
className="rounded-lg bg-white px-5 py-2.5 text-sm font-medium text-slate-600 hover:bg-slate-50 hover:text-slate-800 border border-slate-200 shadow-sm transition-colors"
>
{t('scan.minimize')}
</button>
{status === 'completed' || status === 'failed' ? (
<button
onClick={onClose}
className="group flex items-center gap-2 rounded-lg bg-primary px-6 py-2.5 text-sm font-semibold text-white hover:bg-primary/90 shadow-sm transition-all"
>
{t('common.close')}
</button>
) : (
<button
onClick={onClose}
className="group flex items-center gap-2 rounded-lg bg-red-50 px-6 py-2.5 text-sm font-semibold text-red-600 hover:bg-red-100 hover:shadow-sm border border-red-100 transition-all"
>
<StopCircle className="h-[18px] w-[18px] group-hover:scale-110 transition-transform" />
{t('scanModal.stop')}
</button>
)}
</div>
</div>
</div>
);
}