// ============================================================ // FINAFAMILY — Shared UI Components // ============================================================ const { useState, useEffect, useRef } = React; // ── Lucide icons via CDN (inline SVG paths) ──────────────── const icons = { Home: 'M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z M9 22V12h6v10', LayoutDashboard: 'M3 3h7v7H3zM14 3h7v7h-7zM14 14h7v7h-7zM3 14h7v7H3z', CreditCard: 'M1 4h22v16H1z M1 10h22', Wallet: 'M3 7a2 2 0 012-2h14a2 2 0 012 2v10a2 2 0 01-2 2H5a2 2 0 01-2-2V7z M16 12a1 1 0 110-2 1 1 0 010 2z', TrendingUp: 'M22 7l-8.5 8.5-5-5L2 17 M16 7h6v6', BarChart2: 'M18 20V10 M12 20V4 M6 20v-6', Settings: 'M12 15a3 3 0 100-6 3 3 0 000 6z M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 01-2-2 2 2 0 012-2h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z', Bell: 'M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9 M13.73 21a2 2 0 01-3.46 0', Sun: 'M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42 M12 17a5 5 0 100-10 5 5 0 000 10z', Moon: 'M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z', ChevronLeft: 'M15 18l-6-6 6-6', ChevronRight: 'M9 18l6-6-6-6', ChevronDown: 'M6 9l6 6 6-6', Plus: 'M12 5v14M5 12h14', X: 'M18 6L6 18M6 6l12 12', Lock: 'M19 11H5a2 2 0 00-2 2v7a2 2 0 002 2h14a2 2 0 002-2v-7a2 2 0 00-2-2z M7 11V7a5 5 0 0110 0v4', Eye: 'M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z M12 12a3 3 0 110-6 3 3 0 010 6z', EyeOff: 'M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19m-6.72-1.07a3 3 0 11-4.24-4.24 M1 1l22 22', Users: 'M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2 M23 21v-2a4 4 0 00-3-3.87 M16 3.13a4 4 0 010 7.75 M9 7a4 4 0 100 8 4 4 0 000-8z', ArrowUp: 'M12 19V5M5 12l7-7 7 7', ArrowDown: 'M12 5v14M5 12l7 7 7-7', DollarSign: 'M12 1v22M17 5H9.5a3.5 3.5 0 000 7h5a3.5 3.5 0 010 7H6', Repeat: 'M17 1l4 4-4 4 M3 11V9a4 4 0 014-4h14 M7 23l-4-4 4-4 M21 13v2a4 4 0 01-4 4H3', ShoppingBag: 'M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4z M3 6h18 M16 10a4 4 0 01-8 0', Car: 'M5 17H3a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v9a2 2 0 01-2 2h-2 M17 21a2 2 0 100-4 2 2 0 000 4z M7 21a2 2 0 100-4 2 2 0 000 4z', HeartPulse: 'M22 12h-4l-3 9L9 3l-3 9H2', GraduationCap: 'M22 10v6M2 10l10-5 10 5-10 5z M6 12v5c3 3 9 3 12 0v-5', Smile: 'M12 22a10 10 0 110-20 10 10 0 010 20z M8 14s1.5 2 4 2 4-2 4-2 M9 9h.01 M15 9h.01', UtensilsCrossed: 'M3 2v7c0 1.1.9 2 2 2s2-.9 2-2V2 M7 2v20 M21 15V2v0l-5 5 5 5v0 M21 22L12 2', Filter: 'M22 3H2l8 9.46V19l4 2v-8.54L22 3z', Download: 'M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4 M7 10l5 5 5-5 M12 15V3', Upload: 'M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4 M17 8l-5-5-5 5 M12 3v12', Share2: 'M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81a3 3 0 000-6 3 3 0 00-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9a3 3 0 000 6 3 3 0 002.96-2.56l7.23 4.22c-.04.2-.06.4-.06.61a3 3 0 106-6z', PieChart: 'M21.21 15.89A10 10 0 118 2.83 M22 12A10 10 0 0012 2v10z', Menu: 'M3 12h18M3 6h18M3 18h18', LogOut: 'M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4 M16 17l5-5-5-5 M21 12H9', CheckCircle: 'M22 11.08V12a10 10 0 11-5.93-9.14 M22 4L12 14.01l-3-3', AlertTriangle: 'M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z M12 9v4 M12 17h.01', Info: 'M12 22a10 10 0 110-20 10 10 0 010 20z M12 8h.01 M12 12v4', Search: 'M11 19a8 8 0 100-16 8 8 0 000 16z M21 21l-4.35-4.35', Percent: 'M19 5L5 19 M6.5 5a1.5 1.5 0 110 3 1.5 1.5 0 010-3z M17.5 16a1.5 1.5 0 110 3 1.5 1.5 0 010-3z', Activity: 'M22 12h-4l-3 9L9 3l-3 9H2', Layers: 'M12 2L2 7l10 5 10-5-10-5z M2 17l10 5 10-5 M2 12l10 5 10-5', ArrowRight: 'M5 12h14M12 5l7 7-7 7', Send: 'M22 2L11 13 M22 2l-7 20-4-9-9-4 20-7z', RotateCcw: 'M3 12a9 9 0 101.51-5M3 3v6h6', // ── UI action icons ──────────────────────────────────────── Pencil: 'M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7 M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z', Trash2: 'M3 6h18 M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6 M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2 M10 11v6 M14 11v6', Zap: 'M13 2L3 14h9l-1 8 10-12h-9l1-8z', ChevronUp: 'M18 15l-6-6-6 6', Tag: 'M20.59 13.41l-7.17 7.17a2 2 0 01-2.83 0L2 12V2h10l8.59 8.59a2 2 0 010 2.82z M7 7h.01', User: 'M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2 M12 11a4 4 0 100-8 4 4 0 000 8z', Check: 'M20 6L9 17l-5-5', LogIn: 'M15 3h4a2 2 0 012 2v14a2 2 0 01-2 2h-4 M10 17l5-5-5-5 M15 12H3', UserPlus: 'M16 21v-2a4 4 0 00-4-4H6a4 4 0 00-4 4v2 M12 11a4 4 0 100-8 4 4 0 000 8z M19 8v6 M22 11h-6', Settings2: 'M12 20a8 8 0 100-16 8 8 0 000 16z M12 14a2 2 0 100-4 2 2 0 000 4z M12 2v2 M12 20v2 M4.93 4.93l1.41 1.41 M17.66 17.66l1.41 1.41 M2 12h2 M20 12h2 M4.93 19.07l1.41-1.41 M17.66 6.34l1.41-1.41', CalendarClock: 'M21 7.5V6a2 2 0 00-2-2H5a2 2 0 00-2 2v14a2 2 0 002 2h3.5 M16 2v4 M8 2v4 M3 10h5 M17.5 17.5a5 5 0 100-10 5 5 0 000 10z M17.5 13.5v2l1.5 1', Building2: 'M6 22V4a2 2 0 012-2h8a2 2 0 012 2v18z M6 12H4a2 2 0 00-2 2v6a2 2 0 002 2h2 M18 9h2a2 2 0 012 2v9a2 2 0 01-2 2h-2 M10 6h4 M10 10h4 M10 14h4 M10 18h4', Banknote: 'M2 9a3 3 0 010 6v2a2 2 0 002 2h16a2 2 0 002-2V9a3 3 0 010-6H4a2 2 0 00-2 2v2 M12 12a3 3 0 100-6 3 3 0 000 6z', ShieldCheck: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z M9 12l2 2 4-4', Baby: 'M9 12h.01 M15 12h.01 M10 16c.67.67 3.33.67 4 0 M12 3a5 5 0 00-5 5v4a5 5 0 0010 0V8a5 5 0 00-5-5z', AlertCircle: 'M12 22a10 10 0 110-20 10 10 0 010 20z M12 8v4 M12 16h.01', Database: 'M12 2c5.52 0 10 1.79 10 4v12c0 2.21-4.48 4-10 4S2 20.21 2 18V6c0-2.21 4.48-4 10-4z M2 12c0 2.21 4.48 4 10 4s10-1.79 10-4 M2 6c0 2.21 4.48 4 10 4s10-1.79 10-4', KeyRound: 'M2 18v3c0 .6.4 1 1 1h4v-3h3v-3h2l1.4-1.4a6.5 6.5 0 102.3.3L2 18z M15 9h.01', // ── Category preset icons ────────────────────────────────── ShoppingCart: 'M9 22a1 1 0 100-2 1 1 0 000 2z M20 22a1 1 0 100-2 1 1 0 000 2z M1 1h4l2.68 13.39a2 2 0 001.95 1.61H19a2 2 0 001.94-1.61L23 6H6', Heart: 'M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z', Utensils: 'M3 2v7c0 1.1.9 2 2 2s2-.9 2-2V2 M7 2v20 M21 15V2l-5 5 5 5', Briefcase: 'M20 7H4a2 2 0 00-2 2v11a2 2 0 002 2h16a2 2 0 002-2V9a2 2 0 00-2-2z M16 7V5a2 2 0 00-2-2h-4a2 2 0 00-2 2v2', Plane: 'M17.8 19.2L16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.1-1.1.5l-.3.5c-.2.5-.1 1 .3 1.3L9 12l-2 3H4l-1 1 3 2 2 3 1-1v-3l3-2 3.5 3.5c.3.4.8.5 1.3.3l.5-.2c.4-.3.6-.7.5-1.2z', Music: 'M9 18V5l12-2v13 M5 18a2 2 0 100-4 2 2 0 000 4z M17 16a2 2 0 100-4 2 2 0 000 4z', Smartphone: 'M17 2H7a2 2 0 00-2 2v16a2 2 0 002 2h10a2 2 0 002-2V4a2 2 0 00-2-2z M12 17h.01', Shirt: 'M20.38 3.46L16 2l-4 3-4-3-4.38 1.46-1.5 3.73 3.88 1.28V21h12V8.47l3.88-1.28-1.5-3.73z', Gamepad2: 'M6 11h4 M8 9v4 M15 12h.01 M18 10h.01 M17.32 5H6.68a4 4 0 00-3.978 3.59L2 16a3 3 0 003 3c1 0 1.5-.5 2-1l1.41-1.41A2 2 0 019.83 16h4.34a2 2 0 011.41.59L17 18c.5.5 1 1 2 1a3 3 0 003-3l-.71-7.41A4 4 0 0017.32 5z', Coffee: 'M18 8h1a4 4 0 010 8h-1 M2 8h16v9a4 4 0 01-4 4H6a4 4 0 01-4-4V8z M6 1v3 M10 1v3 M14 1v3', Dumbbell: 'M2 12h4 M18 12h4 M6 8v8 M18 8v8 M4 8h4 M4 16h4 M16 8h4 M16 16h4', PawPrint: 'M11 4a2 2 0 114 0 2 2 0 01-4 0z M6.5 8a1.5 1.5 0 113 0 1.5 1.5 0 01-3 0z M14.5 8a1.5 1.5 0 113 0 1.5 1.5 0 01-3 0z M4.93 14.5c.3-2.46 2.2-4.5 4.57-5h5c2.37.5 4.27 2.54 4.57 5l.93 2.5a3 3 0 01-3 3H7a3 3 0 01-3-3l.93-2.5z', Book: 'M4 19.5A2.5 2.5 0 016.5 17H20 M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z', Gift: 'M20 12v10H4V12 M2 7h20v5H2z M12 22V7 M12 7H7.5a2.5 2.5 0 010-5C11 2 12 7 12 7z M12 7h4.5a2.5 2.5 0 000-5C13 2 12 7 12 7z', }; function Icon({ name, size = 16, className = '', strokeWidth = 1.75 }) { const d = icons[name]; if (!d) return ; const paths = d.split(' M ').map((p, i) => i === 0 ? p : 'M ' + p); return ( {paths.map((p, i) => )} ); } // Cores personalizadas continuam nos dados, anéis e gráficos. A legenda textual // mantém a tonalidade com contraste sobre as superfícies de cada tema. function ffReadableColor(color) { if (color && /^rgba?\(255,\s*255,\s*255[,)]/.test(color)) return '#ffffff'; if (!color || color.startsWith('var(') || ['white', '#fff', '#ffffff'].includes(color.toLowerCase())) return color; return `color-mix(in srgb, ${color} 25%, var(--text-1))`; } // ── Avatar ───────────────────────────────────────────────── function Avatar({ member, size = 32 }) { return (
{member.initials}
); } // ── StatusBadge ──────────────────────────────────────────── const STATUS_STYLES = { paid: { bg: 'var(--success-bg)', color: 'var(--success)', label: 'Paga' }, pending: { bg: 'var(--warning-bg)', color: 'var(--warning)', label: 'Pendente' }, overdue: { bg: 'var(--danger-bg)', color: 'var(--danger)', label: 'Vencida' }, installment: { bg: 'var(--info-bg)', color: 'var(--info)', label: 'Parcela' }, fixed: { bg: 'var(--neutral-bg)', color: 'var(--neutral)', label: 'Fixa' }, single: { bg: 'var(--violet-bg)', color: 'var(--violet)', label: 'Única' }, }; function StatusBadge({ status }) { const s = STATUS_STYLES[status] || STATUS_STYLES.single; return ( {s.label} ); } // ── TypeBadge ───────────────────────────────────────────── function TypeBadge({ type }) { const map = { fixed: { label: 'Fixa', color: 'var(--neutral)', bg: 'var(--neutral-bg)' }, installment: { label: 'Parcelada', color: 'var(--info)', bg: 'var(--info-bg)' }, single: { label: 'Única', color: 'var(--violet)', bg: 'var(--violet-bg)' }, }; const s = map[type] || map.single; return ( {s.label} ); } // ── ProgressBar ──────────────────────────────────────────── function ProgressBar({ pct, height = 6, showGradient = true, color: customColor }) { const color = customColor || (pct >= 90 ? '#EF4444' : pct >= 70 ? '#F59E0B' : '#0D9488'); return (
); } // ── Card ─────────────────────────────────────────────────── function Card({ children, className = '', style = {}, onClick, glass = false }) { return (
{children}
); } // ── KPI Card ─────────────────────────────────────────────── function KpiCard({ label, value, icon, color, trend, trendLabel, sub, invertTrend }) { // invertTrend: seta reflete a direção real, mas a cor é semânticamente invertida // Ex: despesas subiram (+) → seta ↑ + cor vermelha (ruim); caíram (-) → seta ↓ + cor verde (bom) const trendUp = trend >= 0; const trendColor = (invertTrend ? !trendUp : trendUp) ? 'var(--success)' : 'var(--danger)'; return (
{label}
{value}
{sub &&
{sub}
} {trend !== undefined && (
{Math.abs(trend).toFixed(1)}% {trendLabel && {trendLabel}}
)}
); } // ── Modal ────────────────────────────────────────────────── function Modal({ open, onClose, title, children, width = 520 }) { const innerRef = useRef(null); useEffect(() => { if (open) document.body.style.overflow = 'hidden'; else document.body.style.overflow = ''; return () => { document.body.style.overflow = ''; }; }, [open]); // No mobile o teclado virtual cobre o campo focado (o layout do modal // não "encolhe" sozinho em todos os navegadores). Ao focar um campo, // rola o próprio conteúdo do modal pra manter o campo visível acima do teclado. useEffect(() => { if (!open) return; function handleFocusIn(e) { const target = e.target; if (!target || !['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)) return; setTimeout(() => { target.scrollIntoView({ block: 'center', behavior: 'smooth' }); }, 300); // aguarda a animação de abertura do teclado } const node = innerRef.current; node?.addEventListener('focusin', handleFocusIn); return () => node?.removeEventListener('focusin', handleFocusIn); }, [open]); // Esc fecha, como qualquer diálogo — antes só dava para fechar clicando fora ou no X. useEffect(() => { if (!open) return; function onKey(e) { if (e.key === 'Escape') onClose?.(); } document.addEventListener('keydown', onKey); return () => document.removeEventListener('keydown', onKey); }, [open, onClose]); if (!open) return null; return (
{ if (e.target === e.currentTarget) onClose(); }}>
{/* Handle bar — só visível no mobile (bottom sheet) */}
); } // ── FormField ───────────────────────────────────────────── // O rótulo precisa estar ligado ao campo (htmlFor/id): sem isso leitores de tela // anunciavam só "caixa de texto" e tocar no rótulo não focava o campo. function FormField({ label, children, error, hint }) { const autoId = React.useId(); const errorId = `${autoId}-erro`; let fieldId = null; const linked = React.Children.map(children, child => { if (fieldId || !React.isValidElement(child)) return child; const isField = child.type === Input || child.type === Select || child.type === 'input' || child.type === 'select' || child.type === 'textarea'; if (!isField) return child; fieldId = child.props.id || autoId; return React.cloneElement(child, { id: fieldId, 'aria-invalid': error ? true : undefined, 'aria-describedby': error ? errorId : child.props['aria-describedby'], }); }); return (
{linked} {hint && !error && {hint}} {error && {error}}
); } // Tipos onde maiúsculas não fazem sentido (números, datas, credenciais...). const UPPERCASE_SKIP_TYPES = new Set([ 'number', 'date', 'email', 'password', 'search', 'tel', 'time', 'datetime-local', 'month', 'week', 'color', 'file', 'checkbox', 'radio', 'range', 'hidden', ]); const Input = React.forwardRef(function Input({ onChange, style, className = '', ...props }, ref) { const uppercase = !UPPERCASE_SKIP_TYPES.has(props.type || 'text'); function handleChange(e) { if (uppercase && e.target.value) { // Mantém a posição do cursor — reatribuir .value sem isso joga o cursor pro final. const pos = e.target.selectionStart; e.target.value = e.target.value.toUpperCase(); if (pos != null && e.target.setSelectionRange) e.target.setSelectionRange(pos, pos); } if (onChange) onChange(e); } return ( ); }); function Select({ children, className = '', ...props }) { return ( ); } // ── Toast ───────────────────────────────────────────────── function Toast({ message, type = 'success', onClose }) { useEffect(() => { const t = setTimeout(onClose, 3000); return () => clearTimeout(t); }, []); const color = type === 'success' ? '#10B981' : type === 'error' ? '#EF4444' : '#F59E0B'; const icon = type === 'success' ? 'CheckCircle' : type === 'error' ? 'X' : 'AlertTriangle'; return (
{message}
); } // ── EmptyState ──────────────────────────────────────────── function EmptyState({ icon = 'Layers', title = 'Nenhum item', desc = '' }) { return (
{title}
{desc &&
{desc}
}
); } // ── Sparkline ───────────────────────────────────────────── function Sparkline({ data, color = '#0D9488', height = 32, width = 80 }) { if (!data || data.length < 2) return null; const min = Math.min(...data), max = Math.max(...data); const range = max - min || 1; const pts = data.map((v, i) => { const x = (i / (data.length - 1)) * width; const y = height - ((v - min) / range) * (height - 4) - 2; return `${x},${y}`; }).join(' '); return ( ); } // ── Fmt helpers ──────────────────────────────────────────── function fmtBRL(v) { const n = typeof v === 'number' && isFinite(v) ? v : 0; return 'R$ ' + n.toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } function fmtMonth(m) { const names = ['Jan','Fev','Mar','Abr','Mai','Jun','Jul','Ago','Set','Out','Nov','Dez']; return `${names[m.month - 1]}/${m.year}`; } Object.assign(window, { ffReadableColor, Icon, Avatar, StatusBadge, TypeBadge, ProgressBar, Card, KpiCard, Modal, FormField, Input, Select, Toast, EmptyState, Sparkline, fmtBRL, fmtMonth, icons, });