/* ============================================================ AGENDA — Clientes, Serviços, Equipe (CRUD via API) ============================================================ */ /* ---------------- CLIENTES ---------------- */ function ClientsScreen(ctx) { const { terms, clients, companies, team, appts, openNewAppt, toast, services, settings = {} } = ctx; const DB = window.DB; const [mode, setMode] = React.useState('clientes'); const [q, setQ] = React.useState(ctx.clientQ || ''); const [tag, setTag] = React.useState('Todos'); const [proF, setProF] = React.useState(ctx.clientPro || 'all'); const [page, setPage] = React.useState(1); const [sel, setSel] = React.useState(null); const [selCompany, setSelCompany] = React.useState(null); const [editing, setEditing] = React.useState(null); // cliente em edição const [adding, setAdding] = React.useState(false); const [editingCompany, setEditingCompany] = React.useState(null); const [addingCompany, setAddingCompany] = React.useState(false); const [addingForCompany, setAddingForCompany] = React.useState(null); const f = { privacyConsentAt: '', privacyConsentSource: '', privacyNotes: '', notes: '' }; const set = () => () => {}; const defaultCategories = ['Novo', 'Ativo', 'VIP', 'Corporativo', 'Inativo']; const savedCategories = (() => { try { const parsed = JSON.parse(settings.client_categories || '[]'); return Array.isArray(parsed) ? parsed.map((item) => String(item || '').trim()).filter(Boolean) : []; } catch { return []; } })(); const categories = [...new Set([...(savedCategories.length ? savedCategories : defaultCategories), ...clients.map((c) => String(c.tag || '').trim()).filter(Boolean)])]; const tags = ['Todos', ...categories]; const tagCls = { VIP: 'violet', Ativo: 'green', Novo: 'blue', Corporativo: 'amber', Inativo: 'red' }; const qText = q.toLowerCase(); const qDigits = DB.mask.digits(q); const filtered = clients.filter(c => (tag === 'Todos' || c.tag === tag) && (proF === 'all' || c.proId === proF) && (c.name.toLowerCase().includes(qText) || c.email.toLowerCase().includes(qText) || c.phone.includes(q) || (c.companyName || '').toLowerCase().includes(qText) || (c.companyEmail || '').toLowerCase().includes(qText) || (c.companyCnpj || '').includes(q) || (qDigits && DB.mask.digits(c.companyCnpj || '').includes(qDigits))) ); const pageSize = 20; const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize)); const currentPage = Math.min(page, totalPages); const pageStart = (currentPage - 1) * pageSize; const paginatedClients = filtered.slice(pageStart, pageStart + pageSize); const pageItems = (() => { if (totalPages <= 7) return Array.from({ length: totalPages }, (_, index) => index + 1); if (currentPage <= 4) return [1, 2, 3, 4, 5, 'end-gap', totalPages]; if (currentPage >= totalPages - 3) return [1, 'start-gap', ...Array.from({ length: 5 }, (_, index) => totalPages - 4 + index)]; return [1, 'start-gap', currentPage - 1, currentPage, currentPage + 1, 'end-gap', totalPages]; })(); React.useEffect(() => setPage(1), [q, tag, proF, mode]); React.useEffect(() => { if (page > totalPages) setPage(totalPages); }, [page, totalPages]); const filteredCompanies = companies.filter(c => c.name.toLowerCase().includes(qText) || (c.email || '').toLowerCase().includes(qText) || (c.phone || '').includes(q) || (c.cnpj || '').includes(q) || (qDigits && DB.mask.digits(c.cnpj || '').includes(qDigits)) ); const proOf = (id) => team.find(p => p.id === id); const ini = (n) => n.split(' ').map(x => x[0]).slice(0, 2).join('').toUpperCase(); const colorFor = (id) => DB.colors[id % DB.colors.length]; const employeesOf = (companyId) => clients.filter(c => c.companyId === companyId).sort((a, b) => a.name.localeCompare(b.name)); const clientAppts = (id) => appts.filter(a => a.clientId === id).sort((a, b) => b.date.localeCompare(a.date)); const saveClient = async (data) => { try { const payload = addingForCompany ? { ...data, companyId: addingForCompany.id } : data; if (editing) { await ctx.updateClient(editing.id, payload); toast('Dados atualizados!'); } else { await ctx.addClient(payload); toast(terms.client + ' cadastrado!'); } setAdding(false); setEditing(null); setAddingForCompany(null); } catch (e) { toast(e.message, 'x'); } }; const addClientCategory = async (value) => { const name = String(value || '').trim().replace(/\s+/g, ' '); if (!name) return null; const existing = categories.find((item) => item.toLocaleLowerCase('pt-BR') === name.toLocaleLowerCase('pt-BR')); if (existing) { toast('Essa categoria já está cadastrada.', 'info'); return existing; } try { await ctx.saveClientCategories([...categories, name]); toast('Categoria cadastrada!'); return name; } catch (e) { toast(e.message || 'Não foi possível cadastrar a categoria.', 'x'); return null; } }; const deleteClientCategory = async (name) => { const linked = clients.filter((client) => client.tag === name).length; if (linked > 0) { toast(`A categoria "${name}" possui ${linked} aluno${linked === 1 ? '' : 's'} vinculado${linked === 1 ? '' : 's'}.`, 'x'); return false; } if (categories.length <= 1) { toast('Mantenha ao menos uma categoria.', 'x'); return false; } if (!window.confirm(`Excluir a categoria "${name}"?`)) return false; try { await ctx.saveClientCategories(categories.filter((item) => item !== name)); if (tag === name) setTag('Todos'); toast('Categoria excluída!'); return true; } catch (e) { toast(e.message || 'Não foi possível excluir a categoria.', 'x'); return false; } }; const saveCompany = async (data) => { try { if (editingCompany) { await ctx.updateCompany(editingCompany.id, data); toast('Empresa atualizada!'); } else { await ctx.addCompany(data); toast('Empresa cadastrada!'); } setAddingCompany(false); setEditingCompany(null); } catch (e) { toast(e.message, 'x'); } }; const removeClient = (c) => { if (!window.confirm(`Excluir ${c.name}? Os ${terms.appt.toLowerCase()}s vinculados ficarão sem ${terms.client.toLowerCase()}.`)) return; setSel(null); ctx.deleteClient(c.id); }; const removeCompany = (c) => { if (!window.confirm(`Excluir ${c.name}?`)) return; setSelCompany(null); ctx.deleteCompany(c.id); }; return (
setAddingCompany(true)}>Nova empresa : setAdding(true)}>Novo {terms.client.toLowerCase()} } />
{false && LGPD} {false && } {false && } LGPD LGPD LGPD
{mode === 'clientes' &&
{tags.map(t => )}
setProF(e.target.value === 'all' ? 'all' : Number(e.target.value))} style={{ minWidth: 170 }}> {team.filter((p) => !p.systemIdentity && !p.archived).map(p => )}
setQ(e.target.value)} />
{filtered.length === 0 ? : (
{paginatedClients.map(c => ( setSel(c)}> ))}
{terms.client}Contato{terms.pro}CategoriaVisitasTotal gasto
{c.name}
{c.companyName ? c.companyName + (c.companyRole ? ' · ' + c.companyRole : '') : terms.client + ' desde ' + c.since}
{c.phone}
{c.email}
{(() => { const p = proOf(c.proId); return p ? (
{p.name.replace('Dra. ', '')}
) : ; })()}
{c.tag} {c.visits} {DB.money0(c.spent)} e.stopPropagation()}>
)} {filtered.length > 0 &&
Mostrando {pageStart + 1}–{Math.min(pageStart + pageSize, filtered.length)} de {filtered.length}
{pageItems.map((item, index) => typeof item === 'number' ? : )}
}
} {mode === 'empresas' && } {sel && x.id === sel.id) || sel} appts={clientAppts(sel.id)} onClose={() => setSel(null)} color={colorFor(sel.id)} ini={ini} onEdit={() => setEditing(clients.find(x => x.id === sel.id) || sel)} onRemove={() => removeClient(sel)} />} {selCompany && x.id === selCompany.id) || selCompany} employees={employeesOf(selCompany.id)} onClose={() => setSelCompany(null)} onEdit={() => setEditingCompany(companies.find(x => x.id === selCompany.id) || selCompany)} onRemove={() => removeCompany(selCompany)} onAddEmployee={() => setAddingForCompany(companies.find(x => x.id === selCompany.id) || selCompany)} onEditEmployee={(c) => setEditing(c)} openNewAppt={openNewAppt} />} {(adding || editing || addingForCompany) && { setAdding(false); setEditing(null); setAddingForCompany(null); }} onSave={saveClient} />} {(addingCompany || editingCompany) && { setAddingCompany(false); setEditingCompany(null); }} onSave={saveCompany} />}
); } function CompaniesPanel({ companies, employeesOf, setSelCompany, setEditingCompany, setAddingForCompany, removeCompany, openNewAppt }) { return (
{companies.length === 0 ? : (
{companies.map(c => { const employees = employeesOf(c.id); return ( setSelCompany(c)}> ); })}
EmpresaContatoFuncionáriosCNPJ
{c.name}
{[c.city, c.uf].filter(Boolean).join('/') || 'Empresa cadastrada'}
{c.phone || '—'}
{c.email || '—'}
{employees.length} {c.cnpj || '—'} e.stopPropagation()}>
{employees[0] && } }>
)}
); } function CompanyDrawer({ company, employees, onClose, onEdit, onRemove, onAddEmployee, onEditEmployee, openNewAppt }) { return (

{company.name}

{employees.length} funcionários vinculados
}>
} footer={Adicionar funcionário}> Dados da empresa {company.cnpj && } {(company.address || company.city || company.cep) && } {company.notes && <>Observações
{company.notes}
} Funcionários
{employees.length === 0 &&
Nenhum funcionário cadastrado nesta empresa.
} {employees.map(e => (
x[0]).slice(0, 2).join('').toUpperCase()} color="blue" size={32} />
{e.name}
{e.companyRole || 'Sem cargo'} · {e.phone || e.email || 'sem contato'}
))}
); } function CompanyModal({ initial, onClose, onSave }) { const DB = window.DB; const [f, setF] = React.useState(initial ? { name: initial.name, cnpj: initial.cnpj, phone: initial.phone, email: initial.email, cep: initial.cep, address: initial.address, addressNum: initial.addressNum, district: initial.district, city: initial.city, uf: initial.uf, notes: initial.notes } : { name: '', cnpj: '', phone: '', email: '', cep: '', address: '', addressNum: '', district: '', city: '', uf: '', notes: '' }); const [saving, setSaving] = React.useState(false); const [cep, setCep] = React.useState({ loading: false, found: null }); const set = (k) => (e) => setF((p) => ({ ...p, [k]: e.target.value })); const onCep = async (e) => { const masked = e.target.value; setF((p) => ({ ...p, cep: masked })); setCep({ loading: false, found: null }); if (DB.mask.digits(masked).length === 8) { setCep({ loading: true, found: null }); const addr = await window.viaCEP(masked); if (addr) { setF((p) => ({ ...p, address: addr.address, district: addr.district, city: addr.city, uf: addr.uf })); setCep({ loading: false, found: true }); } else { setCep({ loading: false, found: false }); } } }; const save = async () => { if (!f.name || saving) return; setSaving(true); await onSave(f); setSaving(false); }; return (
Cancelar {saving ? 'Salvando…' : 'Salvar'}}>
Endereço {cep.loading &&
Buscando endereço…
} {cep.found === true &&
Endereço preenchido
} {cep.found === false &&
CEP não encontrado
}
setF((p) => ({ ...p, uf: e.target.value.toUpperCase().slice(0, 2) }))} />
); } /* ---------------- FORNECEDORES ---------------- */ function SuppliersScreen(ctx) { const { suppliers = [], transactions = [], toast } = ctx; const DB = window.DB; const [q, setQ] = React.useState(''); const [sel, setSel] = React.useState(null); const [adding, setAdding] = React.useState(false); const [editing, setEditing] = React.useState(null); const qText = String(q || '').toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, ''); const qDigits = DB.mask.digits(q); const filtered = suppliers.filter((s) => { const hay = [ s.name, s.email, s.phone, s.document, s.notes, ].join(' ').toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, ''); const digitsHay = DB.mask.digits([s.phone, s.document].join(' ')); return hay.includes(qText) || (qDigits && digitsHay.includes(qDigits)); }); const statsOf = (supplierId) => { const linked = transactions.filter((t) => Number(t.supplierId || 0) === Number(supplierId) && t.value < 0); const paid = linked.filter((t) => t.status === 'pago').reduce((sum, t) => sum + Math.abs(Number(t.value) || 0), 0); const pending = linked.filter((t) => t.status === 'pendente').reduce((sum, t) => sum + Math.abs(Number(t.value) || 0), 0); return { count: linked.length, paid, pending }; }; const save = async (data) => { try { if (editing) { await ctx.updateSupplier(editing.id, data); toast('Fornecedor atualizado!'); } else { await ctx.addSupplier(data); toast('Fornecedor cadastrado!'); } setAdding(false); setEditing(null); } catch (e) { toast(e.message, 'x'); } }; const remove = async (supplier) => { if (!window.confirm(`Excluir ${supplier.name}?`)) return; try { await ctx.deleteSupplier(supplier.id); toast('Fornecedor removido!'); setSel(null); } catch (e) { toast(e.message, 'x'); } }; return (
setAdding(true)}>Novo fornecedor } />

Lista de fornecedores

{filtered.length}
setQ(e.target.value)} />
{filtered.length === 0 ? : (
{filtered.map((s) => { const stats = statsOf(s.id); return ( setSel(s)}> ); })}
FornecedorContatoDocumentoDespesasPago
{s.name}
{s.notes ? s.notes : 'Fornecedor cadastrado'}
{s.phone || '-'}
{s.email || '-'}
{s.document || '-'} {stats.count} {DB.money(stats.paid)} e.stopPropagation()}>
)}
{sel && x.id === sel.id) || sel} stats={statsOf(sel.id)} onClose={() => setSel(null)} onEdit={() => setEditing(suppliers.find((x) => x.id === sel.id) || sel)} onRemove={() => remove(sel)} />} {(adding || editing) && { setAdding(false); setEditing(null); }} onSave={save} />}
); } function SupplierDrawer({ supplier, stats, onClose, onEdit, onRemove }) { return (

{supplier.name}

{stats.count} despesas vinculadas
}>
} footer={Editar fornecedor}>
{[ ['Despesas', stats.count], ['Pago', window.DB.money(stats.paid)], ['Pendente', window.DB.money(stats.pending)], ].map(([label, value]) => (
{value}
{label}
))}
Contato {supplier.notes && <>Observacoes
{supplier.notes}
}
); } function SupplierModal({ initial, onClose, onSave }) { const [f, setF] = React.useState(initial ? { name: initial.name, phone: initial.phone, email: initial.email, document: initial.document, notes: initial.notes } : { name: '', phone: '', email: '', document: '', notes: '' }); const [saving, setSaving] = React.useState(false); const set = (k) => (e) => setF((prev) => ({ ...prev, [k]: e.target.value })); const save = async () => { if (!f.name.trim() || saving) return; setSaving(true); try { await onSave(f); } finally { setSaving(false); } }; return (
Cancelar {saving ? 'Salvando...' : 'Salvar'}}>
); } function ClientDrawer({ ctx, c, appts, onClose, color, ini, onEdit, onRemove }) { const DB = window.DB; const { terms, services, team, statusMap, openNewAppt } = ctx; const [cancelFutureOpen, setCancelFutureOpen] = React.useState(false); const [cancellingFuture, setCancellingFuture] = React.useState(false); const [emails, setEmails] = React.useState(null); const [viewEmail, setViewEmail] = React.useState(null); const [privacyBusy, setPrivacyBusy] = React.useState(false); React.useEffect(() => { window.API.clients.emails(c.id).then(res => setEmails(res.emails || [])).catch(() => setEmails([])); }, [c.id]); const svc = (id) => services.find(s => s.id === id); const pro = (id) => team.find(p => p.id === id); const extras = terms.extra; const hasCompany = c.companyName || c.companyRole || c.companyCnpj || c.companyPhone || c.companyEmail; const todayISO = DB.iso(DB.today); const historyAppts = appts .filter((a) => a.date <= todayISO) .sort((a, b) => (String(b.date) + String(b.start)).localeCompare(String(a.date) + String(a.start))); const recentHistoryAppts = historyAppts.slice(0, 5); const futureAppts = appts.filter((a) => a.date >= todayISO && ['pendente', 'confirmado'].includes(a.status)); const futureSeries = [...new Set(futureAppts.map((a) => a.seriesId).filter(Boolean))].map((seriesId) => { const items = futureAppts.filter((a) => a.seriesId === seriesId).sort((a, b) => a.date.localeCompare(b.date)); const first = items[0]; return { id: seriesId, count: items.length, first, service: svc(first.serviceId), professional: pro(first.proId) }; }); const currentPlanUsage = (() => { const plan = svc(c.planId); const limit = Math.max(0, Number(c.planFrequency || (plan && plan.planFrequency)) || 0); if (!plan || limit <= 0) return null; const target = new Date(DB.iso(DB.today) + 'T12:00'); const period = c.planPeriod || plan.planPeriod || 'semana'; let start; let end; if (period === 'mes') { const dueDay = Math.max(1, Math.min(31, Number(c.planDueDay) || 1)); const anchor = (year, month) => new Date(year, month, Math.min(dueDay, new Date(year, month + 1, 0).getDate()), 12); start = anchor(target.getFullYear(), target.getMonth()); if (target < start) start = anchor(target.getFullYear(), target.getMonth() - 1); const next = anchor(start.getFullYear(), start.getMonth() + 1); end = new Date(next); end.setDate(end.getDate() - 1); } else { start = new Date(target); start.setDate(target.getDate() - ((target.getDay() + 6) % 7)); end = new Date(start); end.setDate(start.getDate() + 6); } const startISO = DB.iso(start); const endISO = DB.iso(end); const used = appts.filter((a) => { if (a.status === 'cancelado' || a.quotaAction === 'extra' || a.quotaAction === 'replacement' || a.date < startISO || a.date > endISO) return false; const service = svc(a.serviceId); return service && (Number(service.planFrequency) > 0 || Number(service.id) === Number(c.planId)); }).length; return { used, limit, startISO, endISO }; })(); const cancelFuture = async (seriesId = 0) => { const target = seriesId ? 'esta série' : `todos os ${terms.appt.toLowerCase()}s futuros`; if (!window.confirm(`Cancelar ${target} de ${c.name}? O histórico anterior será preservado.`)) return; setCancellingFuture(true); try { const res = await ctx.cancelClientFuture(c.id, seriesId); ctx.toast(`${res.cancelled} ${res.cancelled === 1 ? terms.appt.toLowerCase() : terms.appt.toLowerCase() + 's'} cancelado${res.cancelled === 1 ? '' : 's'}.`); setCancelFutureOpen(false); } catch (e) { ctx.toast(e.message, 'x'); } setCancellingFuture(false); }; const exportPersonalData = async () => { try { const res = await window.API.clients.export(c.id); const stamp = new Date().toISOString().slice(0, 10); window.downloadJSON(`titular-${c.id}-${stamp}.json`, res.export || {}); ctx.toast('Pacote de dados exportado.'); } catch (e) { ctx.toast(e.message || 'Não foi possível exportar os dados.', 'x'); } }; const anonymizePersonalData = async () => { if (privacyBusy) return; if (!window.confirm(`Anonimizar os dados pessoais de ${c.name}? O histórico operacional será mantido, mas contato, documento e anotações pessoais serão removidos.`)) return; setPrivacyBusy(true); try { await ctx.anonymizeClient(c.id); ctx.toast('Dados pessoais anonimizados.'); onClose(); } catch (e) { ctx.toast(e.message || 'Não foi possível anonimizar este cadastro.', 'x'); } setPrivacyBusy(false); }; return ( <> cancelFutureOpen ? setCancelFutureOpen(false) : onClose()} head={

{c.name}

{terms.client} · desde {c.since}
}> {ctx.user && ctx.user.role !== 'pro' && futureAppts.length > 0 && }
} footer={<> { onClose(); openNewAppt({ clientId: c.id }); }}>{['Aula', 'Consulta', 'Sessão'].includes(terms.appt) ? 'Nova' : 'Novo'} {terms.appt.toLowerCase()} window.waOpen(c.phone)}>WhatsApp }>
{[['Visitas', c.visits], ['Total gasto', DB.money0(c.spent)], ['Ticket médio', DB.money0(Math.round(c.spent / Math.max(1, c.visits)))]].map(([l, v]) => (
{v}
{l}
))}
Contato {c.cpf && } {(c.address || c.city || c.cep) && } {terms.id === 'pilates' && <> Plano e Aulas {c.planId > 0 && <> {currentPlanUsage && } {c.planFrequency > 0 && } {c.planPeriod && } {c.planDueDay > 0 && } {c.planPrice > 0 && } {c.extraClassPrice > 0 && } {c.monthlyBillingEnabled && } } } {(terms.id !== 'pilates' || !c.planId) && c.monthlyBillingEnabled && <> Mensalidade } {extras.includes('convenio') && } {extras.includes('prontuario') && } LGPD {c.anonymizedAt && } {c.privacyNotes &&
{c.privacyNotes}
} {hasCompany && <> Empresa {c.companyRole && } {c.companyCnpj && } {c.companyPhone && } {c.companyEmail && } } {c.notes && <>Observações
{c.notes}
} {emails && emails.length > 0 && ( <> E-mails Enviados ({emails.length})
{emails.map(em => (
setViewEmail(em)}>
{em.subject}
{new Date(em.sent_at.replace(' ', 'T')).toLocaleString('pt-BR', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' })} · Para: {em.to_email}
))}
)} Histórico ({historyAppts.length})
{historyAppts.length === 0 &&
Sem {terms.appt.toLowerCase()}s registrados.
} {recentHistoryAppts.map(a => { const s = a.manualServiceName ? { name: a.manualServiceName, price: a.manualServicePrice || 0, color: 'violet' } : svc(a.serviceId), p = pro(a.proId), st = statusMap[a.status] || { label: a.status || 'Sem status', cls: 'plain' }; if (!s || !p) return null; return (
{s.name}
{new Date(a.date + 'T00:00').toLocaleDateString('pt-BR', { day: '2-digit', month: 'short' })} · {a.start}–{window.apptEnd(a)} · {p.name.replace('Dra. ', '')}
{a.quotaAction === 'extra' && Aula extra {Number(a.extraClassPrice) > 0 ? `· ${DB.money(a.extraClassPrice)}` : ''}} {a.quotaAction === 'replacement' && Crédito de reposição usado} {a.replacementCreditGranted && Crédito gerado no cancelamento}
{st.label}
); })} {historyAppts.length > 5 && { onClose(); ctx.openClientHistory(c.id); }}> Ver histórico completo ({historyAppts.length}) }
{cancelFutureOpen && setCancelFutureOpen(false)} footer={<>
setCancelFutureOpen(false)}>Voltar}>
{futureSeries.length > 0 && Ou selecione uma série} {futureSeries.map((series) => ( ))}
} {viewEmail && ( setViewEmail(null)} wide footer={<>
setViewEmail(null)}>Fechar}>