Spaces:
Running
Running
| // packages/web/src/components/chat/ChatAssistant.tsx | |
| // | |
| // ICC Chat Assistant β conversational AI interface | |
| // Renders messages, inline transaction tables, stats, and scan progress | |
| // | |
| // Usage: | |
| // import ChatAssistant from '@/components/chat/ChatAssistant'; | |
| // <ChatAssistant onScanTriggered={() => refetchTransactions()} /> | |
| import { useState, useRef, useEffect, type KeyboardEvent } from 'react'; | |
| import { useChatAssistant, type ChatMessage } from '../../hooks/useChatAssistant'; | |
| interface ChatAssistantProps { | |
| onScanTriggered?: () => void; | |
| className?: string; | |
| } | |
| export default function ChatAssistant({ onScanTriggered, className = '' }: ChatAssistantProps) { | |
| const { messages, isLoading, sendMessage, clearHistory } = useChatAssistant(); | |
| const [input, setInput] = useState(''); | |
| const [isOpen, setIsOpen] = useState(false); | |
| const messagesEndRef = useRef<HTMLDivElement>(null); | |
| const inputRef = useRef<HTMLInputElement>(null); | |
| // Auto-scroll to bottom on new messages | |
| useEffect(() => { | |
| messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); | |
| }, [messages]); | |
| // Focus input when panel opens | |
| useEffect(() => { | |
| if (isOpen) inputRef.current?.focus(); | |
| }, [isOpen]); | |
| // Notify parent when scan is triggered | |
| useEffect(() => { | |
| const lastMsg = messages[messages.length - 1]; | |
| if (lastMsg?.type === 'scan_started' && onScanTriggered) { | |
| onScanTriggered(); | |
| } | |
| }, [messages, onScanTriggered]); | |
| const handleSend = () => { | |
| if (input.trim()) { | |
| sendMessage(input); | |
| setInput(''); | |
| } | |
| }; | |
| const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => { | |
| if (e.key === 'Enter' && !e.shiftKey) { | |
| e.preventDefault(); | |
| handleSend(); | |
| } | |
| }; | |
| // Quick action buttons | |
| const quickActions = [ | |
| { label: "Scanner aujourd'hui", command: "Scanne les courriels d'aujourd'hui" }, | |
| { label: 'Scanner 7 jours', command: 'Scanne les courriels des 7 derniers jours' }, | |
| { label: 'Transactions', command: 'Montre les transactions rΓ©centes' }, | |
| { label: 'Statistiques', command: 'Montre les statistiques' }, | |
| ]; | |
| return ( | |
| <> | |
| {/* βββ FLOATING BUTTON βββ */} | |
| <button | |
| onClick={() => setIsOpen(!isOpen)} | |
| className={`fixed bottom-6 right-6 z-50 flex items-center justify-center | |
| w-14 h-14 rounded-full shadow-lg transition-all duration-300 ease-out | |
| ${isOpen | |
| ? 'bg-slate-700 hover:bg-slate-800 rotate-0' | |
| : 'bg-gradient-to-br from-blue-500 to-indigo-600 hover:from-blue-600 hover:to-indigo-700 hover:scale-110' | |
| } | |
| text-white focus:outline-none focus:ring-4 focus:ring-blue-300/50`} | |
| title="Assistant ICC" | |
| > | |
| {isOpen ? ( | |
| /* X icon */ | |
| <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"> | |
| <path d="M18 6 6 18" /><path d="m6 6 12 12" /> | |
| </svg> | |
| ) : ( | |
| /* Chat sparkle icon */ | |
| <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> | |
| <path d="M12 3c.5 0 9 2 9 9s-2 9-9 9c-3 0-5.5-1-7-2l-4 1 1-4c-1-1.5-2-4-2-4 0-7 5-9 12-9z" /> | |
| <path d="M9 12h.01" /><path d="M12 12h.01" /><path d="M15 12h.01" /> | |
| </svg> | |
| )} | |
| {/* Notification dot */} | |
| {!isOpen && ( | |
| <span className="absolute -top-0.5 -right-0.5 w-4 h-4 bg-red-500 rounded-full border-2 border-white flex items-center justify-center"> | |
| <span className="text-[8px] font-bold text-white">AI</span> | |
| </span> | |
| )} | |
| </button> | |
| {/* βββ CHAT PANEL βββ */} | |
| <div | |
| className={`fixed bottom-24 right-6 z-50 w-[420px] max-w-[calc(100vw-2rem)] | |
| bg-white rounded-2xl shadow-2xl border border-slate-200/80 | |
| flex flex-col overflow-hidden | |
| transition-all duration-300 ease-out origin-bottom-right | |
| ${isOpen | |
| ? 'opacity-100 scale-100 translate-y-0 pointer-events-auto' | |
| : 'opacity-0 scale-95 translate-y-4 pointer-events-none' | |
| }`} | |
| style={{ height: 'min(600px, calc(100vh - 8rem))' }} | |
| > | |
| {/* βββ HEADER βββ */} | |
| <div className="flex items-center justify-between px-5 py-3.5 border-b border-slate-100 | |
| bg-gradient-to-r from-slate-50 to-blue-50/50"> | |
| <div className="flex items-center gap-3"> | |
| <div className="relative"> | |
| <div className="w-9 h-9 rounded-full bg-gradient-to-br from-blue-500 to-indigo-600 | |
| flex items-center justify-center shadow-sm"> | |
| <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2.5" strokeLinecap="round"> | |
| <path d="M12 2L15 8l6 1-4 4 1 6-6-3-6 3 1-6-4-4 6-1z" /> | |
| </svg> | |
| </div> | |
| <span className="absolute -bottom-0.5 -right-0.5 w-3 h-3 bg-green-400 rounded-full border-2 border-white" /> | |
| </div> | |
| <div> | |
| <h3 className="text-sm font-semibold text-slate-800">Assistant ICC</h3> | |
| <p className="text-[11px] text-slate-400">Gestion des virements Interac</p> | |
| </div> | |
| </div> | |
| <button | |
| onClick={clearHistory} | |
| className="text-slate-400 hover:text-slate-600 p-1.5 rounded-lg hover:bg-slate-100 transition-colors" | |
| title="Effacer l'historique" | |
| > | |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"> | |
| <path d="M3 6h18" /><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" /> | |
| <path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" /> | |
| </svg> | |
| </button> | |
| </div> | |
| {/* βββ MESSAGES βββ */} | |
| <div className="flex-1 overflow-y-auto px-4 py-3 space-y-3 scroll-smooth" | |
| style={{ scrollbarWidth: 'thin', scrollbarColor: '#cbd5e1 transparent' }}> | |
| {messages.map((msg) => ( | |
| <MessageBubble key={msg.id} message={msg} /> | |
| ))} | |
| {/* Typing indicator */} | |
| {isLoading && ( | |
| <div className="flex items-start gap-2"> | |
| <div className="w-7 h-7 rounded-full bg-gradient-to-br from-blue-500 to-indigo-600 flex-shrink-0 | |
| flex items-center justify-center"> | |
| <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="3" strokeLinecap="round"> | |
| <path d="M12 2L15 8l6 1-4 4 1 6-6-3-6 3 1-6-4-4 6-1z" /> | |
| </svg> | |
| </div> | |
| <div className="bg-slate-100 rounded-2xl rounded-tl-md px-4 py-2.5"> | |
| <div className="flex gap-1.5"> | |
| <span className="w-2 h-2 bg-slate-400 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} /> | |
| <span className="w-2 h-2 bg-slate-400 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} /> | |
| <span className="w-2 h-2 bg-slate-400 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} /> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| <div ref={messagesEndRef} /> | |
| </div> | |
| {/* βββ QUICK ACTIONS βββ */} | |
| <div className="px-4 py-2 border-t border-slate-100 bg-slate-50/50 flex gap-1.5 overflow-x-auto" | |
| style={{ scrollbarWidth: 'none' }}> | |
| {quickActions.map((action) => ( | |
| <button | |
| key={action.label} | |
| onClick={() => sendMessage(action.command)} | |
| disabled={isLoading} | |
| className="flex-shrink-0 px-3 py-1.5 text-[11px] font-medium rounded-full | |
| border border-slate-200 bg-white text-slate-600 | |
| hover:bg-blue-50 hover:border-blue-200 hover:text-blue-700 | |
| disabled:opacity-40 transition-colors" | |
| > | |
| {action.label} | |
| </button> | |
| ))} | |
| </div> | |
| {/* βββ INPUT βββ */} | |
| <div className="px-4 py-3 border-t border-slate-100 bg-white"> | |
| <div className="flex items-center gap-2 bg-slate-50 rounded-xl border border-slate-200 px-3 py-1.5 | |
| focus-within:border-blue-300 focus-within:ring-2 focus-within:ring-blue-100 transition-all"> | |
| <input | |
| ref={inputRef} | |
| type="text" | |
| value={input} | |
| onChange={(e) => setInput(e.target.value)} | |
| onKeyDown={handleKeyDown} | |
| placeholder="Γcris une commande..." | |
| disabled={isLoading} | |
| className="flex-1 bg-transparent text-sm text-slate-700 placeholder:text-slate-400 | |
| focus:outline-none py-1.5 disabled:opacity-50" | |
| /> | |
| <button | |
| onClick={handleSend} | |
| disabled={!input.trim() || isLoading} | |
| className="flex-shrink-0 w-8 h-8 rounded-lg bg-blue-500 text-white | |
| flex items-center justify-center | |
| hover:bg-blue-600 disabled:bg-slate-200 disabled:text-slate-400 | |
| transition-colors" | |
| > | |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"> | |
| <path d="m22 2-7 20-4-9-9-4z" /><path d="M22 2 11 13" /> | |
| </svg> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </> | |
| ); | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββ | |
| // MESSAGE BUBBLE β renders different message types | |
| // βββββββββββββββββββββββββββββββββββββββββββ | |
| function MessageBubble({ message }: { message: ChatMessage }) { | |
| const isUser = message.role === 'user'; | |
| return ( | |
| <div className={`flex items-start gap-2 ${isUser ? 'flex-row-reverse' : ''}`}> | |
| {/* Avatar */} | |
| {!isUser && ( | |
| <div className="w-7 h-7 rounded-full bg-gradient-to-br from-blue-500 to-indigo-600 flex-shrink-0 | |
| flex items-center justify-center shadow-sm"> | |
| <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="3" strokeLinecap="round"> | |
| <path d="M12 2L15 8l6 1-4 4 1 6-6-3-6 3 1-6-4-4 6-1z" /> | |
| </svg> | |
| </div> | |
| )} | |
| <div className={`max-w-[85%] ${isUser ? 'ml-auto' : ''}`}> | |
| {/* Bubble */} | |
| <div | |
| className={`rounded-2xl px-4 py-2.5 text-[13px] leading-relaxed | |
| ${isUser | |
| ? 'bg-blue-500 text-white rounded-tr-md' | |
| : message.type === 'error' | |
| ? 'bg-red-50 text-red-700 border border-red-200 rounded-tl-md' | |
| : message.type === 'scan_started' | |
| ? 'bg-blue-50 text-blue-800 border border-blue-200 rounded-tl-md' | |
| : 'bg-slate-100 text-slate-700 rounded-tl-md' | |
| }`} | |
| > | |
| <FormattedText text={message.content} /> | |
| </div> | |
| {/* INLINE DATA DISPLAYS */} | |
| {message.data && message.type === 'transactions' && ( | |
| <TransactionTable transactions={message.data.transactions} /> | |
| )} | |
| {message.data && message.type === 'search_results' && ( | |
| <TransactionTable transactions={message.data.transactions} /> | |
| )} | |
| {message.data && message.type === 'stats' && ( | |
| <StatsCard stats={message.data} /> | |
| )} | |
| {/* Timestamp */} | |
| <div className={`text-[10px] text-slate-400 mt-1 ${isUser ? 'text-right' : ''}`}> | |
| {message.timestamp.toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit' })} | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββ | |
| // FORMATTED TEXT β renders markdown-lite (bold, newlines) | |
| // βββββββββββββββββββββββββββββββββββββββββββ | |
| function FormattedText({ text }: { text: string }) { | |
| // Simple markdown: **bold**, newlines | |
| const parts = text.split('\n').map((line, i) => { | |
| const formatted = line.replace( | |
| /\*\*(.*?)\*\*/g, | |
| '<strong class="font-semibold">$1</strong>' | |
| ); | |
| return ( | |
| <span key={i}> | |
| {i > 0 && <br />} | |
| <span dangerouslySetInnerHTML={{ __html: formatted }} /> | |
| </span> | |
| ); | |
| }); | |
| return <>{parts}</>; | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββ | |
| // TRANSACTION TABLE β inline scrollable table | |
| // βββββββββββββββββββββββββββββββββββββββββββ | |
| function TransactionTable({ transactions }: { transactions: any[] }) { | |
| if (!transactions || transactions.length === 0) return null; | |
| return ( | |
| <div className="mt-2 rounded-xl border border-slate-200 overflow-hidden bg-white shadow-sm"> | |
| <div className="overflow-x-auto max-h-[250px] overflow-y-auto" | |
| style={{ scrollbarWidth: 'thin' }}> | |
| <table className="w-full text-[11px]"> | |
| <thead className="bg-slate-50 sticky top-0"> | |
| <tr className="border-b border-slate-200"> | |
| <th className="px-3 py-2 text-left font-semibold text-slate-600">Date</th> | |
| <th className="px-3 py-2 text-left font-semibold text-slate-600">ExpΓ©diteur</th> | |
| <th className="px-3 py-2 text-right font-semibold text-slate-600">Montant</th> | |
| <th className="px-3 py-2 text-left font-semibold text-slate-600">Succursale</th> | |
| <th className="px-3 py-2 text-center font-semibold text-slate-600">Statut</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {transactions.slice(0, 15).map((tx: any, i: number) => ( | |
| <tr key={tx.id || i} className="border-b border-slate-50 hover:bg-slate-50/50"> | |
| <td className="px-3 py-1.5 text-slate-500 whitespace-nowrap"> | |
| {tx.date ? new Date(tx.date).toLocaleDateString('fr-CA', { | |
| day: '2-digit', month: 'short', | |
| }) : 'β'} | |
| </td> | |
| <td className="px-3 py-1.5 text-slate-800 font-medium truncate max-w-[120px]"> | |
| {tx.sender || 'β'} | |
| </td> | |
| <td className="px-3 py-1.5 text-right font-semibold text-slate-800 whitespace-nowrap"> | |
| {tx.amount ? `${Number(tx.amount).toFixed(2)} $` : 'β'} | |
| </td> | |
| <td className="px-3 py-1.5 text-slate-600 truncate max-w-[100px]"> | |
| {tx.branch || 'β'} | |
| </td> | |
| <td className="px-3 py-1.5 text-center"> | |
| <StatusBadge status={tx.status} /> | |
| </td> | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| {transactions.length > 15 && ( | |
| <div className="px-3 py-1.5 text-[10px] text-slate-400 bg-slate-50 border-t border-slate-100 text-center"> | |
| + {transactions.length - 15} autres transactions | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββ | |
| // STATS CARD β compact stats display | |
| // βββββββββββββββββββββββββββββββββββββββββββ | |
| function StatsCard({ stats }: { stats: any }) { | |
| if (!stats) return null; | |
| return ( | |
| <div className="mt-2 rounded-xl border border-slate-200 overflow-hidden bg-white shadow-sm p-3 space-y-2"> | |
| {/* Top-level stats */} | |
| <div className="flex gap-3"> | |
| <div className="flex-1 bg-blue-50 rounded-lg p-2.5 text-center"> | |
| <div className="text-lg font-bold text-blue-700">{stats.totalTransactions}</div> | |
| <div className="text-[10px] text-blue-500 font-medium">Virements</div> | |
| </div> | |
| <div className="flex-1 bg-green-50 rounded-lg p-2.5 text-center"> | |
| <div className="text-lg font-bold text-green-700"> | |
| {Number(stats.totalAmount).toLocaleString('fr-CA', { minimumFractionDigits: 2 })} $ | |
| </div> | |
| <div className="text-[10px] text-green-500 font-medium">Total</div> | |
| </div> | |
| </div> | |
| {/* Top branches */} | |
| {stats.byBranch && stats.byBranch.length > 0 && ( | |
| <div> | |
| <div className="text-[10px] font-semibold text-slate-500 mb-1">Top succursales</div> | |
| <div className="space-y-1"> | |
| {stats.byBranch.slice(0, 5).map((b: any, i: number) => ( | |
| <div key={i} className="flex items-center justify-between text-[11px]"> | |
| <span className="text-slate-600 truncate">{b.branch}</span> | |
| <span className="text-slate-800 font-medium whitespace-nowrap ml-2"> | |
| {b.count} Β· {Number(b.total).toFixed(0)} $ | |
| </span> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββ | |
| // STATUS BADGE | |
| // βββββββββββββββββββββββββββββββββββββββββββ | |
| function StatusBadge({ status }: { status: string }) { | |
| const colors: Record<string, string> = { | |
| deposited: 'bg-green-100 text-green-700', | |
| pending: 'bg-amber-100 text-amber-700', | |
| expired: 'bg-slate-100 text-slate-500', | |
| cancelled: 'bg-red-100 text-red-600', | |
| }; | |
| return ( | |
| <span className={`inline-block px-2 py-0.5 rounded-full text-[10px] font-medium ${colors[status] || 'bg-slate-100 text-slate-500'}`}> | |
| {status || 'β'} | |
| </span> | |
| ); | |
| } | |