// ============================================================ // FINAFAMILY — API Client + App Context // Backend: https://api.finafamily.com.br/api/v1 (production) // Backend: http://localhost:4000/api/v1 (development) // ============================================================ const { createContext, useContext, useState, useEffect, useCallback, useRef, useMemo } = React; // ── API Base ─────────────────────────────────────────────── const API_BASE = typeof window !== 'undefined' && ['localhost', '127.0.0.1', '::1'].includes(window.location.hostname) ? 'http://127.0.0.1:4000/api/v1' : 'https://api.finafamily.com.br/api/v1'; // ── Auth Storage ─────────────────────────────────────────── function getToken() { return localStorage.getItem('ff_token'); } function setTokens(token, refresh) { localStorage.setItem('ff_token', token); if (refresh) localStorage.setItem('ff_refresh', refresh); } function clearTokens() { localStorage.removeItem('ff_token'); localStorage.removeItem('ff_refresh'); } let refreshPromise = null; async function refreshAccessToken() { const refreshToken = localStorage.getItem('ff_refresh'); if (!refreshToken) throw new Error('Sessão expirada'); if (!refreshPromise) { refreshPromise = fetch(API_BASE + '/auth/refresh', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refreshToken }), }).then(async res => { if (!res.ok) throw new Error('Sessão expirada'); const tokens = await res.json(); setTokens(tokens.accessToken, tokens.refreshToken); return tokens.accessToken; }).finally(() => { refreshPromise = null; }); } return refreshPromise; } // ── HTTP Helper ──────────────────────────────────────────── async function apiFetch(path, options = {}) { const token = getToken(); let res = await fetch(API_BASE + path, { ...options, headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(options.headers || {}), }, body: options.body != null ? (typeof options.body === 'string' ? options.body : JSON.stringify(options.body)) : undefined, }); if (res.status === 401 && path !== '/auth/refresh' && localStorage.getItem('ff_refresh')) { try { const refreshedToken = await refreshAccessToken(); res = await fetch(API_BASE + path, { ...options, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${refreshedToken}`, ...(options.headers || {}), }, body: options.body != null ? (typeof options.body === 'string' ? options.body : JSON.stringify(options.body)) : undefined, }); } catch { clearTokens(); } } if (!res.ok) { const err = await res.json().catch(() => ({})); const e = new Error(err.message || `HTTP ${res.status}`); e.status = res.status; throw e; } if (res.status === 204) return null; return res.json(); } // ── Data Mappers ─────────────────────────────────────────── function roleLabel(role) { if (role === 'MASTER1') return 'Master 1'; if (role === 'MASTER2') return 'Master 2'; return 'Filho'; } function mapMember(m) { return { ...m, role: roleLabel(m.role), diaDiaLimit: parseFloat(m.diaDiaLimit) || 300, salary: parseFloat(m.salary) || 0, avatar: m.avatarUrl || null, }; } function mapExpenseShare(s) { return { memberId: s.memberId, mode: s.mode || 'percentage', pct: s.pct == null ? null : (parseFloat(s.pct) || 0), amountCents: Number(s.amountCents), amount: Number(s.amountCents) / 100, value: Number(s.amountCents) / 100, approvedAt: s.approvedAt || null, }; } function mapExpense(e) { const rawDate = e.date || ''; const value = parseFloat(e.value) || 0; return { ...e, pmId: e.paymentMethodId || '', value, valueCents: Number.isSafeInteger(e.valueCents) ? e.valueCents : Math.round(value * 100), responsibleMemberId: e.responsibleMemberId || null, seriesVersion: e.seriesVersion != null ? Number(e.seriesVersion) : (e.series?.currentVersion != null ? Number(e.series.currentVersion) : undefined), seriesTotal: e.seriesTotal != null ? Number(e.seriesTotal) : (e.series?.installmentTotalCents != null ? Number(e.series.installmentTotalCents) / 100 : undefined), date: typeof rawDate === 'string' ? rawDate.split('T')[0] : rawDate, // Despesas projetadas representam uma NOVA ocorrência mensal — sempre pendente, // independente do status do registro original (que pode ter sido pago em outro mês) status: e.isProjected ? 'pending' : (e.status || 'pending'), sharedWith: (e.expenseShares || []).map(mapExpenseShare), }; } function mapExpenseSeriesResponse(response) { const rawSeries = response?.series || null; const series = rawSeries ? { ...rawSeries, responsibilityShares: (rawSeries.responsibilityShares || []).map(mapExpenseShare), } : null; const rawOccurrences = Array.isArray(response) ? response : (response?.occurrences || (response?.id ? [response] : [])); const occurrences = rawOccurrences.map(item => ({ ...mapExpense(item), seriesVersion: Number(series?.currentVersion ?? item.seriesVersion ?? item.series?.currentVersion ?? 1), seriesTotal: series?.installmentTotalCents != null ? Number(series.installmentTotalCents) / 100 : item.seriesTotal, seriesResponsibilityShares: series?.responsibilityShares || item.seriesResponsibilityShares, })); return { series, occurrences }; } function mapIncome(i) { const rawDate = i.date || ''; return { ...i, value: parseFloat(i.value) || 0, date: typeof rawDate === 'string' ? rawDate.split('T')[0] : rawDate, }; } function mapPaymentMethod(pm) { // Build invoice history: API returns [{month:5,year:2026,value:"250.00",status:"paid"}] // Frontend expects [{month:'Mai/26', value: 250, status:'paid'}] const MONTHS_PT = ['Jan','Fev','Mar','Abr','Mai','Jun','Jul','Ago','Set','Out','Nov','Dez']; const invoices = (pm.invoices || []).map(inv => ({ ...inv, month: `${MONTHS_PT[(inv.month || 1) - 1]}/${String(inv.year || 2026).slice(-2)}`, value: parseFloat(inv.value) || 0, })); return { ...pm, limit: pm.limit != null ? parseFloat(pm.limit) : undefined, used: pm.used != null ? parseFloat(pm.used) : 0, balance: pm.balance != null ? parseFloat(pm.balance) : undefined, invoices, }; } function mapInvoicePreview(invoice) { const total = invoice.total != null ? parseFloat(invoice.total) || 0 : (Number(invoice.totalCents || 0) / 100); return { ...invoice, dueMonth: Number(invoice.dueMonth), dueYear: Number(invoice.dueYear), total, totalCents: invoice.totalCents != null ? Number(invoice.totalCents) : Math.round(total * 100), itemCount: Number(invoice.itemCount ?? invoice.items?.length ?? 0), items: (invoice.items || []).map(item => ({ ...item, value: item.total != null ? parseFloat(item.total) || 0 : item.amount != null ? parseFloat(item.amount) || 0 : Number(item.amountCents ?? item.valueCents ?? 0) / 100, })), }; } function mapInvestment(inv) { return { ...inv, value: parseFloat(inv.value) || 0, monthlyRate: parseFloat(inv.monthlyRate) || 0, visibility: inv.visibility || 'open', history: (inv.history || []).map(h => parseFloat(h.value) || 0), }; } // ── Customizable list defaults (persisted in localStorage) ─ const DEFAULT_INVESTMENT_TYPES = [ 'CDB', 'Tesouro Direto', 'LCI/LCA', 'Ações', 'FII', 'Poupança', 'Previdência', 'Criptomoedas', 'Outros', ]; const DEFAULT_INVESTMENT_PURPOSES = [ { key: 'emergency', label: 'Reserva de Emergência', color: '#EF4444' }, { key: 'retirement', label: 'Aposentadoria', color: '#8B5CF6' }, { key: 'passive', label: 'Renda Passiva', color: '#10B981' }, { key: 'travel', label: 'Viagem', color: '#3B82F6' }, { key: 'education', label: 'Educação Filhos', color: '#F59E0B' }, { key: 'other', label: 'Outros', color: '#94A3B8' }, ]; const DEFAULT_INCOME_SOURCES = [ { key: 'salary', label: 'Salário' }, { key: 'freelance', label: 'Freelance' }, { key: 'passive', label: 'Renda Passiva' }, { key: 'rent', label: 'Aluguel' }, { key: 'other', label: 'Outros' }, ]; function lsGet(key, fallback) { try { const v = localStorage.getItem(key); return v ? JSON.parse(v) : fallback; } catch { return fallback; } } function lsSet(key, val) { try { localStorage.setItem(key, JSON.stringify(val)); } catch {} } // Limpa entradas antigas de cache mensal — mantém só os últimos `keep` meses function lsEvictOldMonths(prefix, keep = 4) { try { const keys = Object.keys(localStorage).filter(k => k.startsWith(prefix + '_')); if (keys.length > keep) keys.sort().slice(0, keys.length - keep).forEach(k => localStorage.removeItem(k)); } catch {} } // ── Merge helpers: substitui dados de um mês sem apagar os outros ─ function mergeMonthExp(prev, newItems, year, month) { const kept = prev.filter(e => { const d = new Date((e.date || '').length === 10 ? e.date + 'T00:00:00' : e.date); return !(d.getFullYear() === year && d.getMonth() + 1 === month); }); return [...kept, ...newItems]; } function mergeMonthInc(prev, newItems, year, month) { const kept = prev.filter(i => { if (String(i.id).startsWith('temp_')) return true; // mantém otimistas const d = new Date((i.date || '').length === 10 ? i.date + 'T00:00:00' : i.date); return !(d.getFullYear() === year && d.getMonth() + 1 === month); }); return [...kept, ...newItems]; } // ── Lógica pura de cálculo ────────────────────────────────── // parseLocalDate, expenseInvolvesMember, monthExpensesTotal, calcBalancete e // buildPendingApprovalGroups vivem em lib/finance-calc.js (carregado como //