// ============================================================ // JuFlow — Dashboard // ============================================================ const { useState: useStD, useMemo: useMemoD } = React; const UD = window.JFUtils; // ─── Native SVG Line Chart with area gradient ───────────── const LineAreaChart = ({ data, color = '#FF6B35', height = 200 }) => { const [hover, setHover] = useStD(null); const W = 760, H = height; const padL = 40, padR = 16, padT = 16, padB = 28; const innerW = W - padL - padR; const innerH = H - padT - padB; const values = data.map(d => d.count); const maxV = Math.max(1, ...values); const niceMax = Math.max(5, Math.ceil(maxV / 5) * 5); const stepX = innerW / Math.max(1, data.length - 1); const yScale = v => padT + innerH - (v / niceMax) * innerH; const points = data.map((d, i) => ({ x: padL + i * stepX, y: yScale(d.count), v: d.count, label: d.day })); const pathD = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' '); const areaD = pathD + ` L${points[points.length-1].x.toFixed(1)},${(padT + innerH).toFixed(1)} L${points[0].x.toFixed(1)},${(padT + innerH).toFixed(1)} Z`; const yTicks = [0, 0.25, 0.5, 0.75, 1].map(t => Math.round(niceMax * t)); const xLabels = data.length > 8 ? data.map((d, i) => i % Math.ceil(data.length / 6) === 0 || i === data.length - 1 ? d.day : null) : data.map(d => d.day); return (
setHover(null)} onMouseMove={e => { const rect = e.currentTarget.getBoundingClientRect(); const xRel = ((e.clientX - rect.left) / rect.width) * W - padL; const idx = Math.round(xRel / stepX); if (idx >= 0 && idx < data.length) setHover(idx); else setHover(null); }} > {yTicks.map(v => ( {v} ))} {points.map((p, i) => (xLabels[i] ? ( {xLabels[i]} ) : null))} {hover !== null && ( )} {hover !== null && (() => { const p = points[hover]; const leftPct = (p.x / W) * 100; return (
{p.label}
{p.v} atendimento{p.v === 1 ? '' : 's'}
); })()}
); }; // ─── Native SVG Donut Chart ─────────────────────────────── const DonutChart = ({ data, colors, size = 170 }) => { const [hover, setHover] = useStD(null); const total = data.reduce((s, d) => s + d.value, 0); const cx = size/2, cy = size/2, r = size/2 - 6, ir = r - 22; let acc = -Math.PI / 2; const arcs = data.map((d, i) => { const angle = total > 0 ? (d.value / total) * Math.PI * 2 : 0; const a0 = acc, a1 = acc + angle; acc = a1; const isHover = hover === i; const rOut = isHover ? r + 3 : r; const x0 = cx + rOut * Math.cos(a0), y0 = cy + rOut * Math.sin(a0); const x1 = cx + rOut * Math.cos(a1), y1 = cy + rOut * Math.sin(a1); const xi1 = cx + ir * Math.cos(a1), yi1 = cy + ir * Math.sin(a1); const xi0 = cx + ir * Math.cos(a0), yi0 = cy + ir * Math.sin(a0); const large = angle > Math.PI ? 1 : 0; const path = `M${x0.toFixed(2)},${y0.toFixed(2)} A${rOut},${rOut} 0 ${large} 1 ${x1.toFixed(2)},${y1.toFixed(2)} L${xi1.toFixed(2)},${yi1.toFixed(2)} A${ir},${ir} 0 ${large} 0 ${xi0.toFixed(2)},${yi0.toFixed(2)} Z`; return { path, color: colors[i % colors.length], i, name: d.name, value: d.value, pct: total ? Math.round(d.value/total*100) : 0 }; }); return (
{arcs.map(a => ( setHover(a.i)} onMouseLeave={() => setHover(null)} style={{ cursor:'pointer', transition:'opacity .15s', opacity: hover === null || hover === a.i ? 1 : .55 }} /> ))}
{hover !== null ? arcs[hover].pct + '%' : 'Total'}
{hover !== null ? arcs[hover].value : total}
{hover !== null ? 'sessões' : 'sessões'}
{arcs.map(a => (
setHover(a.i)} onMouseLeave={() => setHover(null)} style={{ display:'flex', alignItems:'center', gap:7, fontSize:11, padding:'3px 6px', borderRadius:6, cursor:'pointer', background: hover === a.i ? '#F9FAFB' : 'transparent', transition:'background .15s' }}>
{a.name} {a.value}
))}
); }; const Dashboard = ({ appointments, onNavigate, addToast, onNewAppointment }) => { // ── Metrics ──────────────────────────────────────────── const todayAppts = UD.todayAppointments(appointments); const weekAppts = UD.weekAppointments(appointments); const monthRevenue = UD.monthRevenue(appointments); const occupancy = UD.occupancyRate(appointments); // ── Today summary (next, completed, pending revenue) ── const now = window.JF.TODAY; const nextAppt = useMemoD(() => appointments .filter(a => ['confirmed','pending'].includes(a.status)) .sort((a,b) => new Date(a.datetime) - new Date(b.datetime)) .find(a => new Date(a.datetime) >= now) , [appointments]); const completedToday = todayAppts.filter(a => a.status === 'completed').length; const todayRevenue = todayAppts.filter(a => a.status === 'completed').reduce((s,a)=>s+a.price,0); const greeting = (() => { const h = new Date().getHours(); if (h < 12) return 'Bom dia'; if (h < 18) return 'Boa tarde'; return 'Boa noite'; })(); // ── Line chart: last 30 days ─────────────────────────── const lineData = useMemoD(() => { const today = window.JF.TODAY; return Array.from({ length: 30 }, (_, i) => { const d = UD.addDays(today, i - 29); const count = appointments.filter(a => UD.isSameDay(a.datetime, d) && a.status === 'completed').length; return { day: UD.formatDate(d,'dd/MM'), count }; }); }, [appointments]); const lineTotal = lineData.reduce((s,d)=>s+d.count,0); const lineFirstHalf = lineData.slice(0,15).reduce((s,d)=>s+d.count,0); const lineSecondHalf = lineData.slice(15).reduce((s,d)=>s+d.count,0); const lineTrend = lineFirstHalf > 0 ? ((lineSecondHalf - lineFirstHalf) / lineFirstHalf) * 100 : 0; // ── Pie chart: top services ──────────────────────────── const pieData = useMemoD(() => { const today = window.JF.TODAY; const map = {}; appointments .filter(a => { const d = new Date(a.datetime); return d.getMonth() === today.getMonth() && a.status === 'completed'; }) .forEach(a => { const svc = UD.getService(a.serviceId); if (!svc) return; map[svc.name] = (map[svc.name] || 0) + 1; }); return Object.entries(map) .sort((a,b) => b[1]-a[1]) .slice(0, 5) .map(([name, value]) => ({ name, value })); }, [appointments]); const PIE_COLORS = ['#FF6B35','#7C3AED','#0891B2','#059669','#D97706']; // ── Upcoming appointments ────────────────────────────── const upcoming = useMemoD(() => appointments .filter(a => ['confirmed','pending'].includes(a.status) && new Date(a.datetime) >= window.JF.TODAY) .sort((a,b) => new Date(a.datetime) - new Date(b.datetime)) .slice(0, 6) , [appointments]); // ── Top professional this month ──────────────────────── const topProf = useMemoD(() => { const today = window.JF.TODAY; const map = {}; appointments .filter(a => a.status === 'completed' && new Date(a.datetime).getMonth() === today.getMonth()) .forEach(a => { map[a.professionalId] = (map[a.professionalId] || 0) + 1; }); const top = Object.entries(map).sort((a,b) => b[1]-a[1])[0]; if (!top) return null; return { prof: UD.getProfessional(Number(top[0])), sessions: top[1] }; }, [appointments]); // ── Birthday clients this month ──────────────────────── const birthdays = window.JF.CLIENTS.filter(c => UD.isBirthdayThisMonth(c.birthdate)); return (
{/* Hero greeting */}
{UD.formatDateLong(window.JF.TODAY)}

{greeting}, Admin! 👋

{todayAppts.length === 0 ? 'Nenhum atendimento agendado para hoje. Aproveite para revisar a agenda da semana.' : <>Você tem {todayAppts.length} atendimento{todayAppts.length === 1 ? '' : 's'} hoje.{nextAppt && (() => { const c = UD.getClient(nextAppt.clientId); const p = UD.getProfessional(nextAppt.professionalId); const dStr = UD.isSameDay(nextAppt.datetime, window.JF.TODAY) ? 'hoje' : UD.formatDate(nextAppt.datetime, 'dd/MM'); return <> Próximo: {c?.name} {dStr} às {UD.formatTime(nextAppt.datetime)} com {p?.name}.; })()} }

{/* Metric cards with mini-context */}
{[ { title:'Atendimentos Hoje', value: todayAppts.length, icon:'Calendar', color:'#FF6B35', trend:12, trendLabel:'vs. ontem', sub: completedToday + ' realizados' }, { title:'Faturado Hoje', value: UD.formatCurrency(todayRevenue), icon:'DollarSign', color:'#059669', trend:9, trendLabel:'vs. ontem', sub: completedToday > 0 ? 'média ' + UD.formatCurrency(Math.round(todayRevenue/completedToday)) : 'sem fechamentos' }, { title:'Agendamentos da Semana', value: weekAppts.length, icon:'CalendarDays', color:'#7C3AED', trend:8, trendLabel:'vs. semana passada', sub:'sessões previstas' }, { title:'Receita do Mês', value: UD.formatCurrency(monthRevenue), icon:'TrendingUp', color:'#0891B2', trend:15, trendLabel:'vs. mês anterior', sub: occupancy + '% de ocupação' }, ].map(m => (
{ e.currentTarget.style.boxShadow='0 8px 20px rgba(10,31,68,.10)'; e.currentTarget.style.transform='translateY(-2px)'; }} onMouseLeave={e => { e.currentTarget.style.boxShadow='0 2px 8px rgba(10,31,68,.06)'; e.currentTarget.style.transform='translateY(0)'; }} >
{m.title}
{m.value}
= 0 ? '#15803D' : '#B91C1C', background: m.trend >= 0 ? '#F0FDF4' : '#FEF2F2', padding:'2px 7px', borderRadius:20 }}> = 0 ? 'TrendingUp' : 'TrendingDown'} size={11} /> {m.trend >= 0 ? '+' : ''}{m.trend}% {m.trendLabel}
{m.sub}
))}
{/* Charts Row */}
{/* Line chart */}

Atendimentos — Últimos 30 dias

Sessões realizadas por dia

Total no período
{lineTotal} sessões
=0 ? '#F0FDF4' : '#FEF2F2', color: lineTrend>=0 ? '#15803D' : '#B91C1C' }}> =0 ? 'TrendingUp' : 'TrendingDown'} size={11} /> {lineTrend>=0?'+':''}{lineTrend.toFixed(0)}% vs primeira quinzena
{/* Pie chart */}

Serviços do Mês

Top 5 mais realizados

{pieData.length > 0 ? ( ) : ( )}
{/* Bottom Row */}
{/* Upcoming appointments */}

Próximos Atendimentos

{upcoming.length === 0 ? ( ) : (
{upcoming.map(a => { const client = UD.getClient(a.clientId); const service = UD.getService(a.serviceId); const prof = UD.getProfessional(a.professionalId); return (
e.currentTarget.style.background='#F0F4F8'} onMouseLeave={e => e.currentTarget.style.background='#F9FAFB'} >
{UD.formatTime(a.datetime).split(':')[0]}
{UD.formatTime(a.datetime).split(':')[1]}
{client?.name}
{service?.name} · {prof?.name}
{UD.formatDate(a.datetime, 'dd/MM')}
); })}
)} {/* Top professional ribbon */} {topProf && (
Destaque do mês
{topProf.prof?.name} · {topProf.sessions} atendimentos
)}
{/* Birthdays */}
🎂

Aniversariantes

Este mês · {birthdays.length} {birthdays.length === 1 ? 'cliente' : 'clientes'}

{birthdays.length > 0 && ( )}
{birthdays.length === 0 ? (

Nenhum aniversariante este mês

) : (
{birthdays.slice(0,4).map(c => (
{c.name}
{UD.formatDate(c.birthdate, 'dd/MM')} · {UD.age(c.birthdate)} anos
))} {birthdays.length > 4 && (
+{birthdays.length - 4} aniversariantes
)}
)} {/* Quick stats */}
Destaques do mês
{[ { label:'Clientes inativos', value: window.JF.CLIENTS.filter(c => c.lastVisit && UD.daysBetween(c.lastVisit, window.JF.TODAY) > 60).length, color:'#EF4444' }, { label:'Novos clientes', value: 4, color:'#22C55E' }, { label:'Taxa de retorno', value: '78%', color:'#0891B2' }, { label:'Avaliação média', value: '4.8 ★', color:'#D97706' }, ].map(item => (
{item.label} {item.value}
))}
); }; Object.assign(window, { Dashboard });