// AI Assistant — real chat connected to Supabase workspace data. (() => { const SUGGESTIONS = [ 'Summarize this week', 'What should I prioritize today?', 'Which projects are at risk?', 'Write a client update email', 'Draft meeting notes template', 'Generate a status report', 'Write an SOP for onboarding a client', 'How\'s our revenue this month?', ]; // Home/reporting currency is PKR — same conversion the Finance screen uses, // so the AI reasons in one consistent currency no matter what a client paid in. function toPKR(amount, currency, rates) { const n = Number(amount); if (!n) return 0; if (!currency || currency === 'PKR') return n; if (!rates) return null; const usd = currency === 'USD' ? n : (rates[currency] ? n / rates[currency] : null); if (usd === null) return null; return rates.PKR ? usd * rates.PKR : null; } /* ── Build a compact, real snapshot of what this signed-in user can see ── Every window.API call below is the same RLS-scoped Supabase client the rest of the app uses, so a "member" role only ever hands the AI the same data they're allowed to see themselves — the AI can't see more than the user already can. */ async function buildWorkspaceSnapshot() { const api = window.API; if (!api) return null; // Supabase's query builder is a "thenable", not a real Promise — it has // .then() but not .catch(), so wrap each call in Promise.resolve() first. const safe = (call) => Promise.resolve(call()).catch(() => ({ data: [] })); const [tr, pr, cr, ir, er, teamR, adR, fx] = await Promise.all([ safe(() => api.getTasks()), safe(() => api.getProjects()), safe(() => api.getClients()), safe(() => api.getInvoices()), api.getExpenses ? safe(() => api.getExpenses()) : Promise.resolve({ data: [] }), safe(() => api.getTeam()), api.getAdCampaigns ? safe(() => api.getAdCampaigns()) : Promise.resolve({ data: [] }), api.getFxRates ? api.getFxRates().catch(() => null) : Promise.resolve(null), ]); const rates = fx && fx.rates; const tasks = tr.data || [], projects = pr.data || [], clients = cr.data || [], invoices = ir.data || [], expenses = er.data || [], team = teamR.data || [], campaigns = adR.data || []; // Compact snapshot — kept deliberately small to keep AI token usage low. // Only OPEN tasks / unpaid invoices matter for most questions; summarise the rest. const openTasks = tasks.filter(t => t.status !== 'done'); const unpaidInv = invoices.filter(i => i.status && i.status !== 'paid'); return { today: new Date().toISOString().slice(0, 10), home_currency: 'PKR', counts: { tasks: tasks.length, open_tasks: openTasks.length, projects: projects.length, clients: clients.length, invoices: invoices.length, unpaid_invoices: unpaidInv.length }, open_tasks: openTasks.slice(0, 25).map(t => ({ title: t.title, status: t.status, priority: t.priority, due: t.due_date, who: t.team_members ? t.team_members.name : null })), projects: projects.slice(0, 25).map(p => ({ name: p.name, status: p.status, progress: p.progress })), clients: clients.slice(0, 25).map(c => (c.company || c.name)), unpaid_invoices: unpaidInv.slice(0, 20).map(i => ({ client: i.clients ? i.clients.name : null, status: i.status, due: i.due_date, pkr: toPKR(i.amount, i.currency, rates) })), team: team.map(m => ({ name: m.name, role: m.role })), }; } /* ── AI Insights — proactive alerts computed locally from the same data the user can see. No LLM call; instant, deterministic "what needs attention". */ async function computeInsights() { const api = window.API; if (!api) return []; const safe = (call) => Promise.resolve(call()).catch(() => ({ data: [] })); const [tr, pr, cr, ir, adR] = await Promise.all([ safe(() => api.getTasks()), safe(() => api.getProjects()), safe(() => api.getClients()), safe(() => api.getInvoices()), api.getAdCampaigns ? safe(() => api.getAdCampaigns()) : Promise.resolve({ data: [] }), ]); const tasks = tr.data || [], projects = pr.data || [], clients = cr.data || [], invoices = ir.data || [], campaigns = adR.data || []; const now = new Date(); now.setHours(0, 0, 0, 0); const out = []; // 1) Overdue tasks const overdue = tasks.filter(t => t.status !== 'done' && t.due_date && new Date(t.due_date) < now); if (overdue.length) out.push({ tone: 'danger', icon: 'alert-triangle', text: `${overdue.length} task${overdue.length>1?'s are':' is'} overdue.`, ask: 'Which overdue tasks are most urgent and who should handle them?' }); // 2) Projects at risk — past due date and not completed const lateProjects = projects.filter(p => p.status !== 'completed' && p.due_date && new Date(p.due_date) < now); if (lateProjects.length) out.push({ tone: 'danger', icon: 'folder-kanban', text: `${lateProjects.length} project${lateProjects.length>1?'s are':' is'} past its due date.`, ask: 'Which projects are behind schedule and what should we do?' }); // 3) Overloaded member — most open tasks assigned const load = {}; tasks.filter(t => t.status !== 'done' && t.team_members).forEach(t => { const n = t.team_members.name; load[n] = (load[n]||0)+1; }); const top = Object.entries(load).sort((a,b)=>b[1]-a[1])[0]; if (top && top[1] >= 6) out.push({ tone: 'warning', icon: 'user', text: `${top[0]} has ${top[1]} open tasks — possibly overloaded.`, ask: `Is ${top[0]} overloaded, and who has free capacity to help?` }); // 4) Unpaid / overdue invoices const unpaid = invoices.filter(i => i.status && i.status !== 'paid'); const overdueInv = unpaid.filter(i => i.due_date && new Date(i.due_date) < now); if (overdueInv.length) out.push({ tone: 'warning', icon: 'receipt', text: `${overdueInv.length} invoice${overdueInv.length>1?'s are':' is'} overdue for payment.`, ask: 'Which overdue invoices should we chase first?' }); // 5) Ad budget nearly used campaigns.forEach(c => { const spent = Number(c.spent||0), budget = Number(c.budget||0); if (budget > 0 && spent / budget >= 0.8) out.push({ tone: 'warning', icon: 'megaphone', text: `"${c.name}" ad budget is ${Math.round(spent/budget*100)}% used.`, ask: `Should we adjust the budget for the "${c.name}" campaign?` }); }); if (!out.length) out.push({ tone: 'success', icon: 'check-circle', text: 'All clear — nothing urgent needs your attention right now.', ask: 'What should I focus on to move things forward?' }); return out.slice(0, 6); } /* ── AI response — calls the real LLM backend (Vercel AI Gateway) ────── */ async function getResponse(msg) { if (!window.API) return { text: 'No API connection — running in demo mode.' }; const context = await buildWorkspaceSnapshot(); const res = await fetch((window.__LARAVEL_API_URL||'https://api.techyfuel.com/api')+'/ai-chat', { method: 'POST', headers: { 'Content-Type': 'application/json', ...(localStorage.getItem('tf_auth_token') ? { Authorization: `Bearer ${localStorage.getItem('tf_auth_token')}` } : {}) }, body: JSON.stringify({ message: msg, context, screen: (window.TFActiveScreen || null) }), }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.error || 'AI request failed'); } const data = await res.json(); return { text: data.reply || 'Sorry, I got an empty response. Please try again.' }; } /* ── UI ─────────────────────────────────────────────────────────── */ function AIPanel({ open, onClose }) { useLucide(); const [messages, setMessages] = React.useState([]); const [input, setInput] = React.useState(''); const [loading, setLoading] = React.useState(false); const [insights, setInsights] = React.useState(null); const bottomRef = React.useRef(null); React.useEffect(() => { if (bottomRef.current) bottomRef.current.scrollIntoView({ behavior: 'smooth' }); }, [messages, loading]); // Load proactive insights the first time the panel opens. React.useEffect(() => { if (open && insights === null) { computeInsights().then(setInsights).catch(() => setInsights([])); } }, [open]); async function send(text) { const msg = (text || input).trim(); if (!msg || loading) return; setInput(''); setMessages(prev => [...prev, { role: 'user', text: msg }]); setLoading(true); try { const res = await getResponse(msg); setMessages(prev => [...prev, { role: 'ai', ...res }]); } catch { setMessages(prev => [...prev, { role: 'ai', text: 'Something went wrong. Please try again.' }]); } finally { setLoading(false); } } function onKey(e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } } // Voice input via on-device Web Speech API — no server call, no tokens. const recogRef = React.useRef(null); const [listening, setListening] = React.useState(false); function dictate() { const SR = window.SpeechRecognition || window.webkitSpeechRecognition; if (!SR) { alert('Voice input is not supported in this browser. Try Chrome.'); return; } if (listening) { try { recogRef.current && recogRef.current.stop(); } catch {} return; } const r = new SR(); r.lang = 'en-US'; r.interimResults = false; r.maxAlternatives = 1; r.onresult = (e) => { const t = e.results[0][0].transcript; setInput(prev => (prev ? prev + ' ' : '') + t); }; r.onerror = () => setListening(false); r.onend = () => setListening(false); recogRef.current = r; try { r.start(); setListening(true); } catch {} } return ( <>
> ); } Object.assign(window, { AIPanel }); })();