/* ============================================================
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 (
);
}
function CompaniesPanel({ companies, employeesOf, setSelCompany, setEditingCompany, setAddingForCompany, removeCompany, openNewAppt }) {
return (
{companies.length === 0 ?
: (
Empresa Contato Funcionários CNPJ
{companies.map(c => {
const employees = employeesOf(c.id);
return (
setSelCompany(c)}>
{c.name}
{[c.city, c.uf].filter(Boolean).join('/') || 'Empresa cadastrada'}
{c.phone || '—'}
{c.email || '—'}
{employees.length}
{c.cnpj || '—'}
e.stopPropagation()}>
setAddingForCompany(c)}>
{employees[0] &&
openNewAppt({ clientId: employees[0].id })}> }
}>
setEditingCompany(c)}> Editar empresa
removeCompany(c)}> Excluir
);
})}
)}
);
}
function CompanyDrawer({ company, employees, onClose, onEdit, onRemove, onAddEmployee, onEditEmployee, openNewAppt }) {
return (
{company.name} {employees.length} funcionários vinculados
}>
Editar empresa
Excluir
}
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'}
{ onClose(); openNewAppt({ clientId: e.id }); }}>
onEditEmployee(e)}>
))}
);
}
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
} />
{filtered.length === 0 ?
: (
Fornecedor Contato Documento Despesas Pago
{filtered.map((s) => {
const stats = statsOf(s.id);
return (
setSel(s)}>
{s.name}
{s.notes ? s.notes : 'Fornecedor cadastrado'}
{s.phone || '-'}
{s.email || '-'}
{s.document || '-'}
{stats.count}
{DB.money(stats.paid)}
e.stopPropagation()}>
setEditing(s)}>
remove(s)}>
);
})}
)}
{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
}>
Editar fornecedor
Excluir
}
footer={Editar fornecedor }>
{[
['Despesas', stats.count],
['Pago', window.DB.money(stats.paid)],
['Pendente', window.DB.money(stats.pending)],
].map(([label, value]) => (
))}
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}
}>
Editar dados
Exportar dados do titular
Anonimizar dados pessoais
{ctx.user && ctx.user.role !== 'pro' && futureAppts.length > 0 && setCancelFutureOpen(true)}> Cancelar {terms.appt.toLowerCase()}s futuros }
Excluir
}
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]) => (
))}
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 >}>
cancelFuture(0)}
style={{ padding: 14, textAlign: 'left', display: 'flex', alignItems: 'center', gap: 12, cursor: 'pointer', background: 'var(--danger-soft)', borderColor: 'color-mix(in srgb, var(--danger) 30%, var(--border))' }}>
Cancelar todos os {terms.appt.toLowerCase()}s futuros
{futureAppts.length} {terms.appt.toLowerCase()}{futureAppts.length === 1 ? '' : 's'} · todas as séries e ocorrências avulsas
{futureSeries.length > 0 &&
Ou selecione uma série }
{futureSeries.map((series) => (
cancelFuture(series.id)}
style={{ padding: 14, textAlign: 'left', display: 'flex', alignItems: 'center', gap: 12, cursor: 'pointer', background: 'var(--surface)' }}>
{series.service ? series.service.name : 'Série recorrente'}
{series.professional ? series.professional.name + ' · ' : ''}{series.count} futura{series.count === 1 ? '' : 's'} · próxima em {new Date(series.first.date + 'T12:00').toLocaleDateString('pt-BR')}
))}
}
{viewEmail && (
setViewEmail(null)} wide
footer={<>
setViewEmail(null)}>Fechar >}>
)}
>
);
}
function ClientModal({ terms, team, companies = [], services = [], categories = [], lockedCompany, initial, canManageBilling = false, onAddCategory, onDeleteCategory, onClose, onSave }) {
const DB = window.DB;
const [f, setF] = React.useState({
name: initial ? initial.name : '', phone: initial ? initial.phone : '', email: initial ? initial.email : '',
tag: initial ? initial.tag : (categories[0] || 'Novo'), convenio: initial ? initial.convenio : '', prontuario: initial ? initial.prontuario : '', notes: initial ? initial.notes : '',
cpf: initial ? (initial.cpf || '') : '', cep: initial ? (initial.cep || '') : '',
companyId: lockedCompany ? lockedCompany.id : (initial && initial.companyId ? initial.companyId : 0),
companyName: initial ? (initial.companyName || '') : '', companyRole: initial ? (initial.companyRole || '') : '',
companyCnpj: initial ? (initial.companyCnpj || '') : '', companyPhone: initial ? (initial.companyPhone || '') : '',
companyEmail: initial ? (initial.companyEmail || '') : '',
address: initial ? (initial.address || '') : '', addressNum: initial ? (initial.addressNum || '') : '',
district: initial ? (initial.district || '') : '', city: initial ? (initial.city || '') : '', uf: initial ? (initial.uf || '') : '',
proId: initial && initial.proId ? initial.proId : 0,
planId: initial && initial.planId ? initial.planId : 0,
planFrequency: initial && initial.planFrequency ? initial.planFrequency : '',
planPeriod: initial && initial.planPeriod ? initial.planPeriod : 'semana',
planDueDay: initial && initial.planDueDay ? initial.planDueDay : '',
planPrice: initial && typeof initial.planPrice === 'number' ? initial.planPrice : 0,
extraClassPrice: initial && typeof initial.extraClassPrice === 'number' ? initial.extraClassPrice : 0,
monthlyBillingEnabled: initial ? !!initial.monthlyBillingEnabled : false,
privacyConsentAt: initial && initial.privacyConsentAt ? String(initial.privacyConsentAt).slice(0, 10) : '',
privacyConsentSource: initial ? (initial.privacyConsentSource || '') : '',
privacyNotes: initial ? (initial.privacyNotes || '') : '',
});
const [saving, setSaving] = React.useState(false);
const [cep, setCep] = React.useState({ loading: false, found: null }); // found: true/false/null
const [categoryOpen, setCategoryOpen] = React.useState(false);
const [categoryName, setCategoryName] = React.useState('');
const [categoryBusy, setCategoryBusy] = React.useState(false);
const set = (k, num) => (e) => setF((p) => ({ ...p, [k]: num ? Number(e.target.value) : e.target.value }));
const setPlan = (e) => {
const pid = Number(e.target.value);
const s = services.find(x => x.id === pid);
setF(p => ({
...p,
planId: pid,
planFrequency: s && s.planFrequency ? s.planFrequency : (p.planFrequency || ''),
planPeriod: s && s.planPeriod ? s.planPeriod : (p.planPeriod || 'semana'),
planPrice: s && typeof s.price === 'number' ? s.price : (p.planPrice || 0),
extraClassPrice: p.extraClassPrice || 0,
planDueDay: p.planDueDay || ''
}));
};
const setCompany = (e) => {
const id = Number(e.target.value);
const c = companies.find(x => x.id === id);
setF((p) => ({
...p,
companyId: id,
companyName: c ? c.name : '',
companyCnpj: c ? c.cnpj : '',
companyPhone: c ? c.phone : '',
companyEmail: c ? c.email : '',
}));
};
const createCategory = async () => {
if (!categoryName.trim() || categoryBusy || !onAddCategory) return;
setCategoryBusy(true);
const created = await onAddCategory(categoryName);
if (created) {
setF((p) => ({ ...p, tag: created }));
setCategoryName('');
}
setCategoryBusy(false);
};
const removeCategory = async (category) => {
if (categoryBusy || !onDeleteCategory) return;
setCategoryBusy(true);
const removed = await onDeleteCategory(category);
if (removed && f.tag === category) {
setF((p) => ({ ...p, tag: categories.find((item) => item !== category) || '' }));
}
setCategoryBusy(false);
};
// CPF: estado de validação (null = vazio/incompleto)
const cpfDigits = DB.mask.digits(f.cpf);
const cpfValid = cpfDigits.length === 11 ? DB.validateCPF(f.cpf) : null;
// CEP: ao completar 8 dígitos, busca endereço e preenche o formulário
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 || cpfValid === false || billingInvalid) return;
setSaving(true);
const payload = { ...f };
if (!canManageBilling) delete payload.monthlyBillingEnabled;
await onSave(payload);
setSaving(false);
};
const billingInvalid = canManageBilling && f.monthlyBillingEnabled && (!(Number(f.planDueDay) >= 1 && Number(f.planDueDay) <= 31) || !(Number(f.planPrice) > 0));
return (
Cancelar
{saving ? 'Salvando…' : 'Salvar'} >}>
{cpfValid === true && CPF válido
}
{cpfValid === false && CPF inválido
}
{categories.map((category) => {category} )}
setCategoryOpen((open) => !open)} style={{ width: 40, height: 40, flex: 'none' }}>
{categoryOpen && (
setCategoryName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); createCategory(); } }} />
Adicionar
{categories.map((category) => (
{category}
removeCategory(category)} style={{ width: 28, height: 28, color: 'var(--danger)' }}>
))}
)}
{terms.extra.includes('convenio') &&
}
{terms.extra.includes('prontuario') &&
}
Sem vínculo — definir ao agendar
{team.filter((p) => !p.systemIdentity && !p.archived).map(p => {p.name}{p.specialty ? ' · ' + p.specialty : ''} )}
{terms.id === 'pilates' && (
Nenhum plano (avulso)
{services && services.map(s => (
{s.name} ({s.planFrequency ? `${s.planFrequency}x por ${s.planPeriod || 'semana'}` : 'Frequência livre'})
))}
)}
{terms.id === 'pilates' && f.planId > 0 && (
Por semana
Por mês / ciclo
setF((p) => ({ ...p, extraClassPrice: n }))} />
)}
{(canManageBilling || (terms.id === 'pilates' && f.planId > 0)) && <>
Mensalidade
{canManageBilling &&
Gerar cobrança mensal automaticamente
Cria um lançamento a receber em cada vencimento, sem precisar lançar manualmente.
setF((p) => ({ ...p, monthlyBillingEnabled: !p.monthlyBillingEnabled }))} />
}
{(f.monthlyBillingEnabled || (terms.id === 'pilates' && f.planId > 0)) && <>
setF((p) => ({ ...p, planPrice: n }))} />
{f.monthlyBillingEnabled &&
A primeira cobrança será criada no próximo vencimento e as seguintes serão geradas automaticamente. Se o dia não existir no mês, será usado o último dia.
}
{billingInvalid &&
Informe um dia entre 1 e 31 e um valor maior que zero.
}
>}
>}
Empresa
Sem empresa vinculada
{companies.map(c => {c.name} )}
{!f.companyId && <>
>}
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) }))} />
);
}
/* ---------------- SERVIÇOS ---------------- */
function commissionLabel(type, value, DB = window.DB) {
if (type === 'percentage') return `${Number(value) || 0}%`;
if (type === 'fixed') return DB.money(Number(value) || 0);
return 'Sem comissão definida';
}
function CommissionFields({ type, value, onChange, typeLabel = 'Tipo de comissão' }) {
const displayedValue = Number(value) === 0 ? '' : value;
return <>
onChange(e.target.value, e.target.value === 'none' ? 0 : (type === 'none' ? '' : value))}>
Sem comissão padrão
Porcentagem
Valor fixo
{type !== 'none' &&
e.target.select()} onChange={(e) => onChange(type, e.target.value === '' ? '' : Number(e.target.value))} />
Digite diretamente {type === 'percentage' ? 'a porcentagem, sem o símbolo %' : 'o valor em reais'}.
}
>;
}
function ServicesScreen(ctx) {
const { terms, services, team, toast } = ctx;
const DB = window.DB;
const [adding, setAdding] = React.useState(false);
const [editing, setEditing] = React.useState(null);
const cats = [...new Set(services.map(s => s.cat))];
const save = async (data) => {
try {
if (editing) { await ctx.updateService(editing.id, data); toast(terms.service + ' atualizado!'); }
else { await ctx.addService(data); toast('Salvo com sucesso!'); }
setAdding(false); setEditing(null);
} catch (e) { toast(e.message, 'x'); }
};
const remove = (s) => {
if (!window.confirm(`Excluir "${s.name}"?`)) return;
ctx.deleteService(s.id);
};
return (
);
}
function ServiceModal({ terms, team = [], initial, onClose, onSave }) {
let catOptions = terms.id === 'escritorio'
? ['Compromisso', 'Reunião', 'Análise', 'Documento', 'Assessoria', 'Pacote']
: ['Atendimento', 'Avaliação', 'Reunião', 'Pacote'];
if (terms.id === 'pilates') {
catOptions = ['Aula', 'Plano', 'Reposicao', 'Avaliacao', 'Experimental'];
}
const initialCategory = terms.id === 'pilates' && ['Plano mensal', 'Plano semanal'].includes(initial && initial.cat)
? 'Plano'
: (initial && initial.cat);
const [f, setF] = React.useState(initial
? { name: initial.name, cat: initialCategory, dur: initial.dur, price: initial.price, desc: initial.desc, planFrequency: initial.planFrequency || '', planPeriod: initial.planPeriod || 'semana', commissionType: initial.commissionType || 'none', commissionValue: initial.commissionValue || 0, commissionOverrides: initial.commissionOverrides || [] }
: { name: '', cat: catOptions[0], dur: 30, price: 0, desc: '', planFrequency: '', planPeriod: 'semana', commissionType: 'none', commissionValue: 0, commissionOverrides: [] });
const [saving, setSaving] = React.useState(false);
const [commissionOpen, setCommissionOpen] = React.useState(false);
const set = (k, num) => (e) => setF({ ...f, [k]: num ? Number(e.target.value) : e.target.value });
const save = async () => { if (!f.name || saving) return; setSaving(true); await onSave(f); setSaving(false); };
const setCommissionOverride = (proId, commissionType, commissionValue) => setF((prev) => {
const without = (prev.commissionOverrides || []).filter((item) => Number(item.proId) !== Number(proId));
return { ...prev, commissionOverrides: commissionType === 'none' ? without : [...without, { proId: Number(proId), commissionType, commissionValue: Number(commissionValue) || 0 }] };
});
return (
<>
Cancelar
{saving ? 'Salvando…' : 'Salvar'} >}>
{catOptions.map(cat => {cat} )}
setF((p) => ({ ...p, price: n }))} />
{terms.id === 'pilates' &&
setF((p) => ({ ...p, commissionType, commissionValue }))} typeLabel="Comissão padrão do serviço" />}
{terms.id === 'pilates' &&
setCommissionOpen(true)}>Ajustar comissão por instrutor{f.commissionOverrides.length ? ` (${f.commissionOverrides.length})` : ''}
Escolha porcentagem ou valor fixo e digite manualmente. O ajuste individual tem prioridade sobre a comissão padrão deste serviço e do instrutor.
}
{terms.id === 'pilates' && f.cat === 'Plano' && (
<>
Por semana
Por mês
>
)}
{commissionOpen && setCommissionOpen(false)}
footer={<>
setCommissionOpen(false)}>Concluir ajustes >}>
{team.filter((p) => !p.archived && !p.systemIdentity).map((p) => {
const override = (f.commissionOverrides || []).find((item) => Number(item.proId) === Number(p.id));
const type = override ? override.commissionType : 'none';
const value = override ? override.commissionValue : 0;
return
{p.name}
Padrão do instrutor: {commissionLabel(p.commissionType, p.commissionValue)}
setCommissionOverride(p.id, e.target.value, value)}>
Usar padrões Porcentagem própria Valor fixo próprio
{type === 'percentage' ?
e.target.select()} onChange={(e) => setCommissionOverride(p.id, type, e.target.value === '' ? '' : Number(e.target.value))} />
: type === 'fixed' ? e.target.select()} onChange={(e) => setCommissionOverride(p.id, type, e.target.value === '' ? '' : Number(e.target.value))} />
: {f.commissionType !== 'none' ? `Serviço: ${commissionLabel(f.commissionType, f.commissionValue)}` : 'Padrão do instrutor'} }
;
})}
{team.filter((p) => !p.archived && !p.systemIdentity).length === 0 &&
}
}
>
);
}
/* ---------------- EQUIPE ---------------- */
const TEAM_WEEK_DAYS = [
['0', 'Dom'],
['1', 'Seg'],
['2', 'Ter'],
['3', 'Qua'],
['4', 'Qui'],
['5', 'Sex'],
['6', 'Sáb'],
];
function defaultTeamAvailability(start = '08:00', end = '18:00', lunchStart = '', lunchEnd = '') {
return TEAM_WEEK_DAYS.reduce((acc, [key]) => {
acc[key] = { enabled: !['0', '6'].includes(key), start, end, lunchStart, lunchEnd };
return acc;
}, {});
}
function availabilitySummary(p) {
const availability = p.availability || {};
const active = TEAM_WEEK_DAYS.filter(([key]) => availability[key] && availability[key].enabled);
if (active.length === 0) return 'Sem dias configurados';
if (active.length === 5 && active.every(([key]) => ['1', '2', '3', '4', '5'].includes(key))) return 'Seg a Sex';
if (active.length === 7) return 'Todos os dias';
return active.map(([, label]) => label).join(', ');
}
function TeamScreen(ctx) {
const { terms, team, clients, appts, openNewAppt, toast } = ctx;
const regularTeam = team.filter((p) => !p.systemIdentity && !p.archived);
const ownAdminProfile = team.find((p) => p.systemIdentity && !p.archived && ctx.user && Number(p.id) === Number(ctx.user.proId));
const visibleTeam = ownAdminProfile ? [ownAdminProfile, ...regularTeam.filter((p) => Number(p.id) !== Number(ownAdminProfile.id))] : regularTeam;
const DB = window.DB;
const todayISO = DB.iso(DB.today);
const [adding, setAdding] = React.useState(false);
const [editing, setEditing] = React.useState(null);
const save = async (data) => {
try {
if (editing) { await ctx.updatePro(editing.id, data); toast('Perfil atualizado!'); }
else { await ctx.addPro(data); toast('Cadastro adicionado em ' + terms.pros + '!'); }
setAdding(false); setEditing(null);
} catch (e) { toast(e.message, 'x'); }
};
const remove = (p) => {
if (!window.confirm(`Remover ${p.name} de ${terms.pros}?`)) return;
ctx.deletePro(p.id);
};
const maxPros = (ctx.limits && ctx.limits.maxPros) || 0;
const maxUsers = (ctx.limits && ctx.limits.maxUsers) || 0;
const accessLimitReached = maxUsers > 0 && ((ctx.user && ctx.user.loginUserCount) || 1) >= maxUsers;
const atLimit = maxPros > 0 && regularTeam.length >= maxPros;
const canAddTeam = !!(ctx.user && ctx.user.hasTeam);
const usage = `${regularTeam.length}${maxPros > 0 ? '/' + maxPros : ''} cadastros · ${visibleTeam.filter(p => p.online).length} online agora`;
return (
setAdding(true)}>Adicionar
} />
{!canAddTeam && (
Seu plano nao inclui cadastro de equipe, mas o administrador pode ajustar o proprio horario de agenda.
)}
{atLimit && (
Limite do plano atingido. Sua empresa pode ter até {maxPros} {maxPros === 1 ? 'cadastro' : 'cadastros'} em {terms.pros.toLowerCase()}. Para adicionar mais, fale com o suporte.
)}
{visibleTeam.map(p => {
const count = appts.filter(a => a.proId === p.id && a.date === todayISO && a.status !== 'cancelado').length;
const linked = clients.filter(c => c.proId === p.id).length;
return (
}>
setEditing(p)}> Editar perfil
ctx.openCalendarFor(p.id)}> Ver agenda
ctx.openClientsFor(p.id)}> Ver {terms.clients.toLowerCase()}
{!p.systemIdentity && remove(p)}> Remover }
{p.specialty && {p.specialty} }
{availabilitySummary(p)}
{p.start} - {p.end}
{p.lunchStart && p.lunchEnd && {'Almo\u00e7o'} {p.lunchStart} - {p.lunchEnd} }
{terms.id === 'pilates' && p.commissionType !== 'none' && Comissão {commissionLabel(p.commissionType, p.commissionValue, DB)} }
{linked}
{terms.clients.toLowerCase()}
openNewAppt({ proId: p.id })}>Agendar
);
})}
{(adding || editing) && { setAdding(false); setEditing(null); }} onSave={save} />}
);
}
function TeamModal({ terms, team = [], initial, onClose, onSave, accessLimitReached = false, maxUsers = 0 }) {
const [f, setF] = React.useState(initial
? { name: initial.name, role: initial.role, specialty: initial.specialty, email: initial.email, phone: initial.phone, color: initial.color || '', start: initial.start, end: initial.end, lunchStart: initial.lunchStart || '', lunchEnd: initial.lunchEnd || '', availability: Object.keys(initial.availability || {}).length ? initial.availability : defaultTeamAvailability(initial.start, initial.end, initial.lunchStart || '', initial.lunchEnd || ''), commissionType: initial.commissionType || 'none', commissionValue: initial.commissionValue || 0, createAcesso: false, login: '', pass: '' }
: { name: '', role: '', specialty: '', email: '', phone: '', color: '', start: '08:00', end: '18:00', lunchStart: '', lunchEnd: '', availability: defaultTeamAvailability('08:00', '18:00'), commissionType: 'none', commissionValue: 0, createAcesso: false, login: '', pass: '' });
const [saving, setSaving] = React.useState(false);
const set = (k) => (e) => setF({ ...f, [k]: e.target.value });
const usedColors = (team || []).filter((p) => !p.archived && (!initial || Number(p.id) !== Number(initial.id))).map((p) => p.color);
const setAvail = (day, key, value) => setF(prev => ({
...prev,
availability: {
...prev.availability,
[day]: { ...(prev.availability[day] || { enabled: false, start: prev.start, end: prev.end, lunchStart: '', lunchEnd: '' }), [key]: value },
},
}));
const applyGeneralToWeek = () => setF(prev => ({
...prev,
availability: TEAM_WEEK_DAYS.reduce((acc, [day]) => {
const current = prev.availability[day] || {};
acc[day] = { ...current, start: prev.start, end: prev.end, lunchStart: prev.lunchStart || '', lunchEnd: prev.lunchEnd || '' };
return acc;
}, {}),
}));
const save = async () => { if (!f.name || saving) return; setSaving(true); await onSave(f); setSaving(false); };
return (
Cancelar
{saving ? 'Salvando…' : 'Salvar'} >}>
{terms.id === 'pilates' && setF((p) => ({ ...p, commissionType, commissionValue }))} typeLabel="Comissão padrão do instrutor" />}
setF((p) => ({ ...p, color }))} />
Dias e horários de agenda
Ajuste quando este {terms.pro.toLowerCase()} atende em cada dia.
Aplicar expediente
Dia Início Fim Almoço início Almoço fim
{TEAM_WEEK_DAYS.map(([day, label]) => {
const row = f.availability[day] || { enabled: false, start: f.start, end: f.end, lunchStart: '', lunchEnd: '' };
return (
setAvail(day, 'enabled', e.target.checked)} />
{label}
setAvail(day, 'start', e.target.value)} />
setAvail(day, 'end', e.target.value)} />
setAvail(day, 'lunchStart', e.target.value)} />
setAvail(day, 'lunchEnd', e.target.value)} />
);
})}
{initial && initial.userLogin ? (
Este membro já tem acesso ao sistema · login: {initial.userLogin}
Para redefinir a senha, use a tela de Usuários.
) : (
{accessLimitReached ? (
Limite de {maxUsers} usuários com login atingido. Este membro pode ser cadastrado normalmente, mas sem login e senha.
) :
setF({ ...f, createAcesso: e.target.checked })} style={{ width: 16, height: 16 }} />
Criar acesso ao sistema para este membro
}
{f.createAcesso && (
)}
)}
);
}
/* ---------- shared bits ---------- */
function Header({ title, sub, action }) {
return (
);
}
function SectionLabel({ children, style }) {
return {children}
;
}
function InfoRow({ icon, label, value }) {
return (
);
}
Object.assign(window, { ClientsScreen, SuppliersScreen, ServicesScreen, TeamScreen, ClientModal, ServiceModal, TeamModal, Header, SectionLabel, InfoRow });