Spaces:
Running
Running
| // packages/web/src/components/scan/ScanControls.tsx | |
| // | |
| // THIS IS THE KEY COMPONENT that makes "Scanner maintenant" actually work. | |
| // It connects the date range selection + scan button to the backend API. | |
| // | |
| // Usage in your DashboardPage or ScanPage: | |
| // import ScanControls from '@/components/scan/ScanControls'; | |
| // <ScanControls onScanComplete={(result) => refetchTransactions()} /> | |
| import { useState, useCallback } from 'react'; | |
| import { useEmailScan, type ScanPreset } from '../../hooks/useEmailScan'; | |
| // Import your UI components (adjust paths to your project) | |
| // import { Button } from '@/components/ui/button'; | |
| // import { ScanLine, Loader2 } from 'lucide-react'; | |
| interface ScanControlsProps { | |
| onScanComplete?: (result: { found: number; parsed: number; skipped: number; errors: number }) => void; | |
| onNewTransaction?: (transaction: any) => void; | |
| } | |
| export default function ScanControls({ onScanComplete, onNewTransaction }: ScanControlsProps) { | |
| // ═══ STATE ═══ | |
| const [selectedPreset, setSelectedPreset] = useState<ScanPreset>('last7days'); | |
| const [customStartDate, setCustomStartDate] = useState(''); | |
| const [customEndDate, setCustomEndDate] = useState(''); | |
| const [forceRescan, setForceRescan] = useState(false); | |
| // ═══ SCAN HOOK — connects to API + WebSocket ═══ | |
| const { | |
| isScanning, | |
| progress, | |
| result, | |
| error, | |
| startScan, | |
| progressPercent, | |
| } = useEmailScan(); | |
| // Notify parent when scan completes | |
| if (result && onScanComplete) { | |
| onScanComplete(result); | |
| } | |
| // ═══ HANDLERS ═══ | |
| const handleScanClick = useCallback(() => { | |
| startScan({ | |
| preset: selectedPreset, | |
| startDate: selectedPreset === 'custom' ? customStartDate : undefined, | |
| endDate: selectedPreset === 'custom' ? customEndDate : undefined, | |
| forceRescan, | |
| }); | |
| }, [selectedPreset, customStartDate, customEndDate, forceRescan, startScan]); | |
| const presetButtons: { value: ScanPreset; label: string }[] = [ | |
| { value: 'today', label: "Aujourd'hui" }, | |
| { value: 'last7days', label: '7 derniers jours' }, | |
| { value: 'custom', label: 'Période personnalisée' }, | |
| ]; | |
| // ═══ DATE DISPLAY ═══ | |
| const getDateRangeDisplay = () => { | |
| const now = new Date(); | |
| const fmt = (d: Date) => d.toLocaleDateString('fr-CA', { day: 'numeric', month: 'short' }); | |
| switch (selectedPreset) { | |
| case 'today': | |
| return fmt(now); | |
| case 'last7days': { | |
| const start = new Date(now); | |
| start.setDate(start.getDate() - 7); | |
| return `${fmt(start)} → ${fmt(now)}`; | |
| } | |
| case 'custom': | |
| if (customStartDate && customEndDate) { | |
| return `${fmt(new Date(customStartDate))} → ${fmt(new Date(customEndDate))}`; | |
| } | |
| return 'Sélectionnez les dates'; | |
| } | |
| }; | |
| // ═══ RENDER ═══ | |
| return ( | |
| <div className="flex flex-col gap-4"> | |
| {/* PRESET BUTTONS + SCAN BUTTON ROW */} | |
| <div className="flex items-center gap-4 flex-wrap"> | |
| {/* Date preset toggle group */} | |
| <div className="flex items-center gap-2 bg-card p-1 rounded-lg border border-border shadow-sm"> | |
| {presetButtons.map((btn) => ( | |
| <button | |
| key={btn.value} | |
| onClick={() => setSelectedPreset(btn.value)} | |
| className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${ | |
| selectedPreset === btn.value | |
| ? 'bg-slate-100 text-slate-900 hover:bg-slate-200' | |
| : 'text-slate-500 hover:bg-slate-50 hover:text-slate-900' | |
| }`} | |
| > | |
| {btn.label} | |
| </button> | |
| ))} | |
| </div> | |
| {/* Date range display */} | |
| <div className="hidden sm:flex items-center gap-2 bg-card px-3 py-1.5 rounded-lg border border-border shadow-sm text-sm text-slate-600"> | |
| <span className="font-medium text-slate-900">{getDateRangeDisplay()}</span> | |
| </div> | |
| {/* Separator */} | |
| <div className="h-8 w-px bg-slate-200 mx-2" /> | |
| {/* ════════════════════════════════════════════════ */} | |
| {/* 🔥 THE SCAN BUTTON — this is what triggers it! */} | |
| {/* ════════════════════════════════════════════════ */} | |
| <button | |
| onClick={handleScanClick} | |
| disabled={isScanning || (selectedPreset === 'custom' && (!customStartDate || !customEndDate))} | |
| className="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium | |
| bg-primary text-primary-foreground hover:bg-primary/90 | |
| h-9 px-4 py-2 gap-2 | |
| shadow-md shadow-blue-500/20 hover:shadow-blue-500/30 | |
| active:scale-95 transition-all | |
| disabled:pointer-events-none disabled:opacity-50" | |
| > | |
| {isScanning ? ( | |
| <> | |
| {/* Spinning loader */} | |
| <svg className="animate-spin h-[18px] w-[18px]" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> | |
| <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /> | |
| <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /> | |
| </svg> | |
| Scan en cours... | |
| </> | |
| ) : ( | |
| <> | |
| {/* Scan icon */} | |
| <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" | |
| stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> | |
| <path d="M3 7V5a2 2 0 0 1 2-2h2" /> | |
| <path d="M17 3h2a2 2 0 0 1 2 2v2" /> | |
| <path d="M21 17v2a2 2 0 0 1-2 2h-2" /> | |
| <path d="M7 21H5a2 2 0 0 1-2-2v-2" /> | |
| <path d="M7 12h10" /> | |
| </svg> | |
| Scanner maintenant | |
| </> | |
| )} | |
| </button> | |
| </div> | |
| {/* CUSTOM DATE PICKERS (only when custom is selected) */} | |
| {selectedPreset === 'custom' && ( | |
| <div className="flex items-center gap-3"> | |
| <div> | |
| <label className="text-xs text-slate-500 block mb-1">Date de début</label> | |
| <input | |
| type="date" | |
| value={customStartDate} | |
| onChange={(e) => setCustomStartDate(e.target.value)} | |
| className="px-3 py-1.5 text-sm border border-border rounded-md bg-card shadow-sm" | |
| /> | |
| </div> | |
| <div> | |
| <label className="text-xs text-slate-500 block mb-1">Date de fin</label> | |
| <input | |
| type="date" | |
| value={customEndDate} | |
| onChange={(e) => setCustomEndDate(e.target.value)} | |
| max={new Date().toISOString().split('T')[0]} | |
| className="px-3 py-1.5 text-sm border border-border rounded-md bg-card shadow-sm" | |
| /> | |
| </div> | |
| </div> | |
| )} | |
| {/* FORCE RESCAN CHECKBOX */} | |
| <label className="flex items-center gap-2 text-sm text-slate-600 cursor-pointer"> | |
| <input | |
| type="checkbox" | |
| checked={forceRescan} | |
| onChange={(e) => setForceRescan(e.target.checked)} | |
| className="rounded border-slate-300" | |
| /> | |
| Forcer le re-scan (re-traiter les courriels déjà importés) | |
| </label> | |
| {/* ═══ PROGRESS BAR (during scan) ═══ */} | |
| {isScanning && progress && ( | |
| <div className="bg-card rounded-lg border border-border p-4 shadow-sm"> | |
| {/* Progress bar */} | |
| <div className="w-full bg-slate-100 rounded-full h-3 mb-2"> | |
| <div | |
| className="bg-blue-500 h-3 rounded-full transition-all duration-300 ease-out" | |
| style={{ width: `${progressPercent}%` }} | |
| /> | |
| </div> | |
| <div className="flex justify-between text-sm text-slate-600"> | |
| <span> | |
| {progress.processed}/{progress.total} courriels traités | |
| </span> | |
| <span>{progressPercent}%</span> | |
| </div> | |
| {progress.latest && ( | |
| <div className="mt-2 text-xs text-slate-500"> | |
| Dernier traité: {progress.latest.sender} — {progress.latest.amount?.toFixed(2)} $ → {progress.latest.branch} | |
| </div> | |
| )} | |
| {progress.currentProvider && ( | |
| <div className="mt-1 text-xs text-slate-400"> | |
| Fournisseur IA: {progress.currentProvider} | |
| </div> | |
| )} | |
| {progress.errored > 0 && ( | |
| <div className="mt-1 text-xs text-red-500"> | |
| {progress.errored} erreur(s) de traitement | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| {/* ═══ SCAN RESULT (after completion) ═══ */} | |
| {result && !isScanning && ( | |
| <div className="bg-green-50 border border-green-200 rounded-lg p-4 text-sm"> | |
| <div className="font-medium text-green-800 mb-1">Scan terminé ✓</div> | |
| <div className="text-green-700"> | |
| <span className="font-semibold">{result.parsed}</span> nouveaux virements importés | |
| {result.skipped > 0 && ( | |
| <> · {result.skipped} déjà en base</> | |
| )} | |
| {result.errors > 0 && ( | |
| <> · <span className="text-red-600">{result.errors} erreurs</span></> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| {/* ═══ ERROR MESSAGE ═══ */} | |
| {error && ( | |
| <div className="bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-700"> | |
| <div className="font-medium mb-1">Erreur de scan</div> | |
| {error} | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |