Spaces:
Sleeping
Sleeping
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> | |
| ); | |
| } | |