/* ============================================================
AGENDA — Financeiro, Tarefas, Histórico
============================================================ */
/* ---------------- FINANCEIRO ---------------- */
function FinanceScreen(ctx) {
const { terms, transactions, services, clients, suppliers = [], businessType, addClient, addSupplier, addTransaction, updateTransaction, payTransaction, deleteTransaction, toast } = ctx;
const DB = window.DB;
const [range, setRange] = React.useState('mes');
const [periodDate, setPeriodDate] = React.useState(() => new Date(DB.today));
const [filter, setFilter] = React.useState('Todas');
const [search, setSearch] = React.useState('');
const [typeFilter, setTypeFilter] = React.useState('');
const [statusFilter, setStatusFilter] = React.useState('');
const [clientFilter, setClientFilter] = React.useState('');
const [supplierFilter, setSupplierFilter] = React.useState('');
const [adding, setAdding] = React.useState(false);
const [editing, setEditing] = React.useState(null);
const [viewing, setViewing] = React.useState(null);
const [receiving, setReceiving] = React.useState(null);
const cleanMethod = (method) => String(method || 'Pix').replace(/\s+\d+\/\d+$/, '').trim() || 'Pix';
const txPaymentPlan = (t) => Array.isArray(t.paymentPlan)
? t.paymentPlan
.map((p) => ({ method: cleanMethod(p.method), value: Math.abs(Number(p.value) || 0), installments: Math.max(1, Number(p.installments) || 1) }))
.filter((p) => p.value > 0)
: [];
const paymentSummary = (t) => {
if (t.status === 'pendente') return 'A definir';
const plan = txPaymentPlan(t);
if (plan.length > 0) {
return plan.map((p) => p.method + ' ' + DB.money(p.value) + (p.installments > 1 ? ' (' + p.installments + 'x)' : '')).join(' + ');
}
return t.method || 'A definir';
};
const supplierName = (id) => {
const s = suppliers.find((x) => String(x.id) === String(id));
return s ? s.name : '';
};
const clientName = (id) => {
const c = clients.find((x) => String(x.id) === String(id));
return c ? c.name : '';
};
const norm = (v) => String(v || '').toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '');
const saveTx = async (data) => {
try { await addTransaction(data); toast('Lançamento registrado!'); setAdding(false); }
catch (e) { toast(e.message, 'x'); }
};
const saveEditTx = async (data) => {
if (!editing || !updateTransaction) return;
try {
await updateTransaction(editing.id, data);
toast('Lançamento atualizado!');
setEditing(null);
setViewing(null);
} catch (e) {
toast(e.message, 'x');
}
};
const saveReceive = async (data) => {
if (!receiving) return;
await payTransaction(receiving.id, data);
setReceiving(null);
};
const cloneDate = (d) => new Date(d.getFullYear(), d.getMonth(), d.getDate());
const startOfWeek = (d) => {
const out = cloneDate(d);
const wd = (out.getDay() + 6) % 7;
out.setDate(out.getDate() - wd);
return out;
};
const addPeriod = (dir) => {
setPeriodDate((prev) => {
const d = cloneDate(prev);
if (range === 'semana') d.setDate(d.getDate() + (dir * 7));
else if (range === 'mes') return new Date(d.getFullYear(), d.getMonth() + dir, 1);
else return new Date(d.getFullYear() + dir, d.getMonth(), 1);
return d;
});
};
const periodBounds = (() => {
const base = cloneDate(periodDate);
if (range === 'semana') {
const start = startOfWeek(base);
const end = new Date(start); end.setDate(start.getDate() + 6);
return { start, end };
}
if (range === 'mes') {
return { start: new Date(base.getFullYear(), base.getMonth(), 1), end: new Date(base.getFullYear(), base.getMonth() + 1, 0) };
}
return { start: new Date(base.getFullYear(), 0, 1), end: new Date(base.getFullYear(), 11, 31) };
})();
const periodTitle = (() => {
if (range === 'semana') {
const a = periodBounds.start.toLocaleDateString('pt-BR', { day: '2-digit', month: 'short' });
const b = periodBounds.end.toLocaleDateString('pt-BR', { day: '2-digit', month: 'short', year: 'numeric' });
return a + ' a ' + b;
}
if (range === 'mes') {
const label = periodBounds.start.toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' });
return label.charAt(0).toUpperCase() + label.slice(1);
}
return String(periodBounds.start.getFullYear());
})();
// recorte por período
const inRange = (t) => {
const d = new Date(t.date + 'T00:00');
return d >= periodBounds.start && d <= periodBounds.end;
};
const txs = transactions.filter(inRange);
const paidTxs = txs.filter(t => t.status === 'pago');
const receitas = paidTxs.filter(t => t.value > 0).reduce((s, t) => s + t.value, 0);
const despesas = paidTxs.filter(t => t.value < 0).reduce((s, t) => s + Math.abs(t.value), 0);
const saldo = receitas - despesas;
const pendentes = txs.filter(t => t.status === 'pendente' && t.value > 0);
const pendente = pendentes.reduce((s, t) => s + t.value, 0);
const aPagar = txs.filter(t => t.status === 'pendente' && t.value < 0).reduce((s, t) => s + Math.abs(t.value), 0);
const searchText = norm(search);
const filtered = txs.filter((t) => {
if (filter !== 'Todas') {
if (filter === 'Receitas' && t.value <= 0) return false;
if (filter === 'Despesas' && t.value >= 0) return false;
if (filter === 'Pendentes' && t.status !== 'pendente') return false;
}
if (typeFilter && t.cat !== typeFilter) return false;
if (statusFilter && t.status !== statusFilter) return false;
if (clientFilter && String(t.clientId || '') !== String(clientFilter)) return false;
if (supplierFilter && String(t.supplierId || '') !== String(supplierFilter)) return false;
if (searchText) {
const hay = norm([
t.desc,
t.cat,
t.status,
t.tag,
paymentSummary(t),
clientName(t.clientId),
supplierName(t.supplierId),
].filter(Boolean).join(' '));
if (!hay.includes(searchText)) return false;
}
return true;
});
// receita recebida no periodo selecionado
const paidIncomeOn = (start, end = start) => transactions
.filter((t) => {
const d = new Date(t.date + 'T00:00');
return t.status === 'pago' && t.value > 0 && d >= start && d <= end;
})
.reduce((s, t) => s + t.value, 0);
const chart = (() => {
if (range === 'semana') {
const days = ['Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sab', 'Dom'];
return days.map((lbl, i) => {
const d = new Date(periodBounds.start); d.setDate(periodBounds.start.getDate() + i);
return [lbl, paidIncomeOn(d)];
});
}
if (range === 'mes') {
const points = [];
for (let start = new Date(periodBounds.start); start <= periodBounds.end; start.setDate(start.getDate() + 7)) {
const end = new Date(start); end.setDate(start.getDate() + 6);
if (end > periodBounds.end) end.setTime(periodBounds.end.getTime());
points.push([String(start.getDate()).padStart(2, '0') + '-' + String(end.getDate()).padStart(2, '0'), paidIncomeOn(start, end)]);
}
return points;
}
return Array.from({ length: 12 }, (_, i) => {
const start = new Date(periodBounds.start.getFullYear(), i, 1);
const end = new Date(periodBounds.start.getFullYear(), i + 1, 0);
const label = start.toLocaleDateString('pt-BR', { month: 'short' }).replace('.', '');
return [label.charAt(0).toUpperCase() + label.slice(1), paidIncomeOn(start, end)];
});
})();
const maxC = Math.max(1, ...chart.map(c => c[1]));
const chartValueLabel = (v) => {
if (v <= 0) return '';
if (v < 1000) return DB.money0(v);
if (v < 1000000) {
const mil = v / 1000;
return 'R$ ' + mil.toLocaleString('pt-BR', { maximumFractionDigits: mil >= 10 ? 0 : 1 }) + ' mil';
}
const mi = v / 1000000;
return 'R$ ' + mi.toLocaleString('pt-BR', { maximumFractionDigits: mi >= 10 ? 0 : 1 }) + ' mi';
};
// formas de pagamento (% sobre receitas do período)
const recTx = paidTxs.filter(t => t.value > 0);
const totalRec = recTx.reduce((s, t) => s + t.value, 0) || 1;
const methodColors = ['var(--ev-teal)', 'var(--ev-blue)', 'var(--ev-amber)', 'var(--ev-cyan)', 'var(--ev-rose)', 'var(--ev-green)'];
const methodParts = recTx.map((t) => ({ method: cleanMethod(t.method), value: Math.abs(Number(t.value) || 0) }));
const methods = [...new Set(methodParts.map(p => p.method))]
.map(m => [m, Math.round(methodParts.filter(p => p.method === m).reduce((s, p) => s + p.value, 0) / totalRec * 100)])
.sort((a, b) => b[1] - a[1]).slice(0, 6);
const rangeLabel = periodTitle;
const chartTitle = range === 'semana' ? 'Receita por dia' : range === 'mes' ? 'Receita por semana' : 'Receita por mês';
const cards = [
{ lbl: 'Receitas', val: DB.money(receitas), color: 'var(--success)', bg: 'var(--success-soft)', ic: 'trendUp', delta: recTx.length + ' recebidos' },
{ lbl: 'Despesas', val: DB.money(despesas), color: 'var(--danger)', bg: 'var(--danger-soft)', ic: 'trendDown', delta: paidTxs.filter(t => t.value < 0).length + ' pagos' },
{ lbl: 'Saldo', val: DB.money(saldo), color: 'var(--primary)', bg: 'var(--primary-soft)', ic: 'wallet', delta: rangeLabel },
{ lbl: 'A receber', val: DB.money(pendente), color: 'var(--warning)', bg: 'var(--warning-soft)', ic: 'clock', delta: aPagar > 0 ? DB.money(aPagar) + ' a pagar' : pendentes.length + ' pendente' + (pendentes.length === 1 ? '' : 's') },
];
const exportar = () => {
window.exportCSV('financeiro-' + range + '.csv', [
['Data', 'Descrição', 'Categoria', terms.client || 'Cliente', 'Fornecedor', 'Forma', 'Status', 'Valor'],
...filtered.map(t => [t.date, t.desc, t.cat, clientName(t.clientId), supplierName(t.supplierId), paymentSummary(t), t.status, String(t.value).replace('.', ',')]),
]);
toast('Relatério exportado!', 'download');
};
return (
addPeriod(-1)}>
{periodTitle}
addPeriod(1)}>
setPeriodDate(new Date(DB.today))}>Hoje
Exportar
setAdding(true)}>Novo lançamento
} />
{cards.map((c, i) => (
))}
{chartTitle}
{periodTitle}
{DB.money(chart.reduce((s, c) => s + c[1], 0))}
{chart.map(([d, v]) => (
{chartValueLabel(v)}
0 ? 'var(--primary)' : 'var(--primary-soft-2)', borderRadius: '6px 6px 0 0', transition: 'height .4s' }} />
{d}
))}
Formas de pagamento
{rangeLabel}
{methods.length === 0 &&
Sem receitas no período.
}
{methods.map(([m, pct], i) => (
))}
Transações {filtered.length}
{['Todas', 'Receitas', 'Despesas', 'Pendentes'].map(f => setFilter(f)}>{f} )}
{filtered.length === 0 ?
: (
Descrição {terms.client || 'Cliente'} Fornecedor Data Forma Status Valor
{filtered.map(t => {
const cName = clientName(t.clientId);
const sName = supplierName(t.supplierId);
return (
setViewing(t)}>
0 ? 'var(--success-soft)' : 'var(--danger-soft)', color: t.value > 0 ? 'var(--success)' : 'var(--danger)', display: 'grid', placeItems: 'center', flex: 'none' }}>
0 ? 'trendUp' : 'trendDown'} size={15} />
{cName ? (
{cName}
) : - }
{sName ? (
{sName}
) : - }
{new Date(t.date + 'T00:00').toLocaleDateString('pt-BR', { day: '2-digit', month: 'short' })}
{paymentSummary(t)}
{t.status === 'pago' ? 'Pago' : 'Pendente'}
0 ? 'var(--success)' : 'var(--text)' }}>{DB.money(t.value)}
{t.status === 'pendente' &&
{ e.stopPropagation(); setReceiving(t); }}>{t.value > 0 ? 'Receber' : 'Pagar'} }
{false && t.status === 'pendente' && (
e.stopPropagation()}>{t.value > 0 ? 'Receber' : 'Pagar'}}>
Forma de pagamento
{['Pix', 'Cartão', 'Dinheiro', 'Transf.', 'Boleto'].map(m => (
{ e.stopPropagation(); payTransaction(t.id, m); }}>{m}
))}
)}
{ e.stopPropagation(); setEditing(t); }}>
{ e.stopPropagation(); if (window.confirm('Excluir este lançamento? Esta ação não pode ser desfeita.')) deleteTransaction(t.id); }}>
);
})}
)}
{adding &&
setAdding(false)} onSave={saveTx} />}
{editing && setEditing(null)} onSave={saveEditTx} />}
{viewing && setViewing(null)} onEdit={() => { setEditing(viewing); setViewing(null); }} />}
{receiving && setReceiving(null)} onSave={saveReceive} />}
);
}
function TransactionDetailsModal({ transaction, terms = {}, clients = [], suppliers = [], paymentSummary, onClose, onEdit }) {
const DB = window.DB;
const clientLabel = terms.client || 'Cliente';
const client = clients.find((c) => String(c.id) === String(transaction.clientId));
const supplier = suppliers.find((s) => String(s.id) === String(transaction.supplierId));
const isExpense = Number(transaction.value) < 0;
const dueText = transaction.dueDate
? new Date(transaction.dueDate + 'T00:00').toLocaleDateString('pt-BR')
: '-';
const dateText = transaction.date
? new Date(transaction.date + 'T00:00').toLocaleDateString('pt-BR')
: '-';
const partyText = client ? client.name : (supplier ? supplier.name : '-');
const Detail = ({ label, children }) => (
{label}
{children || '-'}
);
return (
Fechar Editar >}>
{transaction.desc}
{isExpense ? 'Despesa' : 'Receita'} #{transaction.id}
{DB.money(transaction.value)}
{transaction.status === 'pago' ? 'Pago' : 'Pendente'}
{dateText}
{dueText}
{transaction.tag || '-'}
{paymentSummary(transaction)}
Vínculos
{(client || supplier) && }
{partyText}
{client && {client.phone || client.email || '-'} }
{supplier && {supplier.phone || supplier.email || supplier.document || '-'} }
{transaction.cat || (isExpense ? 'Despesa' : 'Receita')}
);
}
/* ---------- NOVO LANÇAMENTO (entrada/saída avulsa) ---------- */
function TxModal({ transaction = null, terms, services = [], clients = [], suppliers = [], businessType, addClient, addSupplier, onClose, onSave }) {
const DB = window.DB;
const apptPlural = ((terms && terms.appt) || 'atendimento').toLowerCase() + 's';
const isEdit = !!transaction;
const cleanMethod = (method) => String(method || 'Pix').replace(/\s+\d+\/\d+$/, '').trim() || 'Pix';
const txPayments = isEdit && Array.isArray(transaction.paymentPlan)
? transaction.paymentPlan
.map((p) => ({ method: cleanMethod(p.method), value: Math.abs(Number(p.value) || 0), installments: Math.max(1, Number(p.installments) || 1) }))
.filter((p) => p.value > 0)
: [];
const initialForm = isEdit
? {
cat: Number(transaction.value) < 0 ? 'Despesa' : 'Receita',
desc: transaction.desc || '',
value: Math.abs(Number(transaction.value) || 0),
date: transaction.date || DB.iso(DB.today),
dueDate: transaction.dueDate || '',
tag: transaction.tag || '',
status: transaction.status || 'pendente',
weekendPolicy: 'keep',
clientId: transaction.clientId ? String(transaction.clientId) : '',
supplierId: transaction.supplierId ? String(transaction.supplierId) : '',
serviceId: '',
}
: { cat: 'Receita', desc: '', value: '', date: DB.iso(DB.today), dueDate: '', tag: '', status: 'pendente', weekendPolicy: 'keep', clientId: '', supplierId: '', serviceId: '' };
const [f, setF] = React.useState(initialForm);
const [localClients, setLocalClients] = React.useState(clients || []);
const [localSuppliers, setLocalSuppliers] = React.useState(suppliers || []);
const [clientQ, setClientQ] = React.useState('');
const [clientOpen, setClientOpen] = React.useState(false);
const [newClientOpen, setNewClientOpen] = React.useState(false);
const [newClient, setNewClient] = React.useState({ name: '', phone: '', email: '' });
const [clientSaving, setClientSaving] = React.useState(false);
const [clientErr, setClientErr] = React.useState('');
const [supplierQ, setSupplierQ] = React.useState('');
const [supplierOpen, setSupplierOpen] = React.useState(false);
const [newSupplierOpen, setNewSupplierOpen] = React.useState(false);
const [newSupplier, setNewSupplier] = React.useState({ name: '', phone: '', email: '', document: '' });
const [supplierSaving, setSupplierSaving] = React.useState(false);
const [supplierErr, setSupplierErr] = React.useState('');
const [fixo, setFixo] = React.useState(false);
const [payments, setPayments] = React.useState(txPayments.length > 0 ? txPayments : [{ method: cleanMethod(transaction?.method), value: isEdit ? Math.abs(Number(transaction.value) || 0) : '', installments: 1 }]);
const [saving, setSaving] = React.useState(false);
const clientComboRef = React.useRef(null);
const supplierComboRef = React.useRef(null);
const set = (k) => (e) => setF(prev => ({ ...prev, [k]: e.target.value }));
const pricedServices = (services || []).filter((s) => Number(s.price) > 0);
const selectClientLegacy = (e) => {
const cid = e.target.value;
const client = clients.find((c) => String(c.id) === String(cid));
if (!client) {
setF(prev => ({ ...prev, clientId: '' }));
return;
}
const planPrice = client.planPrice > 0 ? client.planPrice : (client.planId ? (pricedServices.find(s => s.id === client.planId)?.price || 0) : 0);
const planName = client.planId ? pricedServices.find(s => s.id === client.planId)?.name : '';
setF(prev => {
const v = planPrice > 0 ? Number(planPrice) : prev.value;
setPayments([{ method: 'Pix', value: v, installments: 1 }]);
return { ...prev, clientId: cid, cat: 'Receita', desc: planName ? `Mensalidade - ${planName}` : `Cobrança - ${client.name}`, value: v, status: 'pendente' };
});
};
React.useEffect(() => { setLocalClients(clients || []); }, [clients]);
React.useEffect(() => { setLocalSuppliers(suppliers || []); }, [suppliers]);
React.useEffect(() => {
if (!clientOpen) return;
const close = (e) => {
if (clientComboRef.current && !clientComboRef.current.contains(e.target)) setClientOpen(false);
};
document.addEventListener('mousedown', close);
return () => document.removeEventListener('mousedown', close);
}, [clientOpen]);
React.useEffect(() => {
if (!supplierOpen) return;
const close = (e) => {
if (supplierComboRef.current && !supplierComboRef.current.contains(e.target)) setSupplierOpen(false);
};
document.addEventListener('mousedown', close);
return () => document.removeEventListener('mousedown', close);
}, [supplierOpen]);
const clientLabel = terms.client || 'Cliente';
const selectedClient = localClients.find((c) => String(c.id) === String(f.clientId));
const selectedSupplier = localSuppliers.find((s) => String(s.id) === String(f.supplierId));
const norm = (v) => String(v || '').toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '');
const clientFilter = norm(clientQ);
const clientDigits = DB.mask.digits(clientQ);
const filteredClients = (localClients || []).filter((c) => {
if (!clientFilter && !clientDigits) return true;
return norm(c.name).includes(clientFilter) ||
norm(c.email).includes(clientFilter) ||
norm(c.phone).includes(clientFilter) ||
(clientDigits && DB.mask.digits(c.phone || '').includes(clientDigits)) ||
(clientDigits && DB.mask.digits(c.cpf || '').includes(clientDigits));
}).slice(0, 25);
const supplierFilter = norm(supplierQ);
const supplierDigits = DB.mask.digits(supplierQ);
const filteredSuppliers = (localSuppliers || []).filter((s) => {
if (!supplierFilter && !supplierDigits) return true;
return norm(s.name).includes(supplierFilter) ||
norm(s.email).includes(supplierFilter) ||
norm(s.phone).includes(supplierFilter) ||
norm(s.document).includes(supplierFilter) ||
(supplierDigits && DB.mask.digits(s.phone || '').includes(supplierDigits)) ||
(supplierDigits && DB.mask.digits(s.document || '').includes(supplierDigits));
}).slice(0, 25);
const applyClient = (client) => {
if (!client) {
setF(prev => ({ ...prev, clientId: '' }));
return;
}
const planPrice = client.planPrice > 0 ? client.planPrice : (client.planId ? (pricedServices.find(s => s.id === client.planId)?.price || 0) : 0);
const planName = client.planId ? pricedServices.find(s => s.id === client.planId)?.name : '';
setF(prev => {
if (isEdit) return { ...prev, clientId: String(client.id), cat: 'Receita' };
const v = planPrice > 0 ? Number(planPrice) : prev.value;
setPayments([{ method: 'Pix', value: v, installments: 1 }]);
return { ...prev, clientId: String(client.id), cat: 'Receita', desc: planName ? `Mensalidade - ${planName}` : `Cobranca - ${client.name}`, value: v, status: 'pendente' };
});
};
const selectClient = (client) => {
applyClient(client);
setClientQ('');
setClientOpen(false);
setNewClientOpen(false);
setClientErr('');
};
const clearClient = () => {
setF(prev => ({ ...prev, clientId: '' }));
setClientQ('');
setClientOpen(false);
};
const setNewClientField = (k) => (e) => setNewClient(prev => ({ ...prev, [k]: e.target.value }));
const createInlineClient = async () => {
const name = (newClient.name || clientQ || '').trim();
if (!name || clientSaving || !addClient) return;
setClientSaving(true);
setClientErr('');
try {
const created = await addClient({ ...newClient, name });
setLocalClients(prev => prev.some(c => c.id === created.id) ? prev : [created, ...prev]);
selectClient(created);
setNewClient({ name: '', phone: '', email: '' });
} catch (e) {
setClientErr(e.message || 'Nao foi possivel cadastrar.');
} finally {
setClientSaving(false);
}
};
const selectSupplier = (supplier) => {
if (!supplier) return;
setF(prev => ({ ...prev, supplierId: String(supplier.id), cat: 'Despesa' }));
setSupplierQ('');
setSupplierOpen(false);
setNewSupplierOpen(false);
setSupplierErr('');
};
const clearSupplier = () => {
setF(prev => ({ ...prev, supplierId: '' }));
setSupplierQ('');
setSupplierOpen(false);
};
const setNewSupplierField = (k) => (e) => setNewSupplier(prev => ({ ...prev, [k]: e.target.value }));
const createInlineSupplier = async () => {
const name = (newSupplier.name || supplierQ || '').trim();
if (!name || supplierSaving || !addSupplier) return;
setSupplierSaving(true);
setSupplierErr('');
try {
const created = await addSupplier({ ...newSupplier, name });
setLocalSuppliers(prev => prev.some(s => s.id === created.id) ? prev : [created, ...prev]);
selectSupplier(created);
setNewSupplier({ name: '', phone: '', email: '', document: '' });
} catch (e) {
setSupplierErr(e.message || 'Nao foi possivel cadastrar.');
} finally {
setSupplierSaving(false);
}
};
const selectService = (e) => {
const val = e.target.value;
const service = pricedServices.find((s) => String(s.id) === String(val));
if (!service) { setF(prev => ({ ...prev, serviceId: '' })); return; }
const v = Number(service.price) || '';
setPayments([{ method: 'Pix', value: v, installments: 1 }]);
setF(prev => ({ ...prev, serviceId: String(service.id), cat: 'Receita', desc: service.name, value: v, status: 'pendente' }));
};
const total = Math.abs(Number(f.value) || 0);
const sum = payments.reduce((acc, p) => acc + (Number(p.value) || 0), 0);
const remaining = Math.round((total - sum) * 100) / 100;
const updatePayment = (idx, key, value) => {
setPayments((items) => items.map((item, i) => i === idx ? { ...item, [key]: value } : item));
};
const addPayment = () => {
setPayments((items) => [...items, { method: 'Pix', value: Math.max(0, remaining), installments: 1 }]);
};
const removePayment = (idx) => {
setPayments((items) => items.length <= 1 ? items : items.filter((_, i) => i !== idx));
};
const changeStatus = (e) => {
const status = e.target.value;
setF(prev => ({ ...prev, status }));
if (status === 'pago') {
setPayments((items) => {
if (items.length > 0 && cleanMethod(items[0].method) !== 'A definir') return items;
return [{ method: 'Pix', value: total || Math.abs(Number(f.value) || 0), installments: 1 }];
});
}
};
const semValor = f.cat === 'Despesa' && total === 0;
const showPayments = f.status !== 'pendente';
const valid = f.desc.trim() !== '' &&
(semValor || (total > 0 &&
(!showPayments || (
payments.every((p) => Number(p.value) > 0 && Number(p.installments) >= 1) &&
Math.abs(remaining) <= 0.01
))));
const save = async () => {
if (!valid || saving) return;
setSaving(true);
let payload = { ...f, value: total };
if (!isEdit && fixo) {
payload.recurrence = '';
if (f.dueDate) payload.date = f.dueDate;
} else {
payload.recurrence = 1;
}
payload.clientId = f.cat === 'Receita' ? f.clientId : '';
payload.supplierId = f.cat === 'Despesa' ? f.supplierId : '';
payload.method = showPayments ? (payments[0]?.method || 'Pix') : 'A definir';
payload.payments = showPayments ? payments.map((p) => ({ method: p.method, value: Number(p.value) || 0, installments: Math.max(1, Number(p.installments) || 1) })) : [];
if (isEdit) payload.splitPayments = false;
try { await onSave(payload); } finally { setSaving(false); }
};
return (
Cancelar
{saving ? 'Salvando...' : 'Salvar'} >}>
setF({ ...f, cat: v })}
options={[{ value: 'Receita', label: 'Entrada (receita)' }, { value: 'Despesa', label: 'Saída (despesa)' }]} />
{f.cat === 'Receita' && (
{ setClientOpen(true); setClientQ(''); }}
onChange={(e) => { setClientQ(e.target.value); setClientOpen(true); setNewClient(prev => ({ ...prev, name: e.target.value })); }} />
{selectedClient && (
)}
{selectedClient && !clientOpen && (
{selectedClient.name}
{(selectedClient.phone || selectedClient.email) && {selectedClient.phone || selectedClient.email} }
)}
{clientOpen && (
{filteredClients.length === 0 &&
Nenhum cadastro encontrado.
}
{filteredClients.map((c) => (
selectClient(c)}>
{c.name}
{[c.phone, c.email].filter(Boolean).join(' - ') || 'Sem contato'}
))}
{!newClientOpen ? (
{ setNewClientOpen(true); setNewClient(prev => ({ ...prev, name: prev.name || clientQ })); }}>
Cadastrar {clientLabel.toLowerCase()}
) : (
{clientErr &&
{clientErr}
}
{ setNewClientOpen(false); setClientErr(''); }}>Cancelar
{clientSaving ? 'Salvando...' : 'Cadastrar'}
)}
)}
)}
{f.cat === 'Despesa' && (
{ setSupplierOpen(true); setSupplierQ(''); }}
onChange={(e) => { setSupplierQ(e.target.value); setSupplierOpen(true); setNewSupplier(prev => ({ ...prev, name: e.target.value })); }} />
{selectedSupplier && (
)}
{selectedSupplier && !supplierOpen && (
{selectedSupplier.name}
{(selectedSupplier.phone || selectedSupplier.email) && {selectedSupplier.phone || selectedSupplier.email} }
)}
{supplierOpen && (
{filteredSuppliers.length === 0 &&
Nenhum fornecedor encontrado.
}
{filteredSuppliers.map((s) => (
selectSupplier(s)}>
{s.name}
{[s.phone, s.email, s.document].filter(Boolean).join(' - ') || 'Sem contato'}
))}
{!newSupplierOpen ? (
{ setNewSupplierOpen(true); setNewSupplier(prev => ({ ...prev, name: prev.name || supplierQ })); }}>
Cadastrar fornecedor
) : (
{supplierErr &&
{supplierErr}
}
{ setNewSupplierOpen(false); setSupplierErr(''); }}>Cancelar
{supplierSaving ? 'Salvando...' : 'Cadastrar'}
)}
)}
)}
{f.cat === 'Receita' && businessType === 'pilates' && pricedServices.length > 0 && (
Sem plano/aula vinculado
{pricedServices.map((s) => {s.name} · {DB.money(s.price)} )}
)}
{
setF({ ...f, value: n });
if (payments.length === 1) updatePayment(0, 'value', n);
}} />
{f.cat === 'Despesa' && f.dueDate && !fixo && (
Manter a data mesmo no fim de semana
Antecipar para o dia útil anterior
Adiar para o próximo dia útil
)}
{f.cat === 'Despesa' ? 'Pago' : 'Recebido'}
{f.cat === 'Despesa' ? 'A pagar' : 'A receber'}
{showPayments &&
Pagamento
{payments.map((p, idx) => (
updatePayment(idx, 'method', e.target.value)}>
Pix Cartão Dinheiro Transf. Boleto
updatePayment(idx, 'value', n)} />
updatePayment(idx, 'installments', e.target.value)} />
removePayment(idx)} disabled={payments.length <= 1}>
))}
Adicionar forma
Soma: {DB.money(sum)}
}
{!isEdit && setFixo(!fixo)}>
setFixo(!fixo)} style={{ width: 18, height: 18, accentColor: 'var(--primary)' }} />
Fixo (repete todo mês)
{(() => {
const dia = f.dueDate ? Number(f.dueDate.split('-')[2]) : Number(f.date.split('-')[2]);
if (!dia) return 'Defina o vencimento para escolher o dia';
if (dia > 28) return `Todo dia ${dia} (ou Último dia do mês quando não existir)`;
return `Todo dia ${dia} de cada mês`;
})()}
}
{!isEdit && fixo && f.cat === 'Despesa' && (
Manter a data mesmo no fim de semana
Antecipar para o dia útil anterior
Adiar para o próximo dia útil
)}
);
}
function ReceiveTxModal({ transaction, clients = [], suppliers = [], terms = {}, onClose, onSave }) {
const DB = window.DB;
const total = Math.abs(Number(transaction.value) || 0);
const cleanMethod = (method) => String(method || 'Pix').replace(/\s+\d+\/\d+$/, '').trim() || 'Pix';
const plannedPayments = Array.isArray(transaction.paymentPlan)
? transaction.paymentPlan
.map((p) => ({ method: cleanMethod(p.method), value: Math.abs(Number(p.value) || 0), installments: Math.max(1, Number(p.installments) || 1) }))
.filter((p) => p.value > 0)
: [];
const [payments, setPayments] = React.useState(plannedPayments.length > 0 ? plannedPayments : [{ method: cleanMethod(transaction.method), value: total, installments: 1 }]);
const [saving, setSaving] = React.useState(false);
const methods = [...new Set(['Pix', 'Cartão', 'Dinheiro', 'Transf.', 'Boleto', ...payments.map((p) => cleanMethod(p.method))])];
const clientLabel = terms.client || 'Cliente';
const client = clients.find((c) => String(c.id) === String(transaction.clientId));
const clientText = client ? client.name : '';
const supplier = suppliers.find((s) => String(s.id) === String(transaction.supplierId));
const supplierText = supplier ? supplier.name : '';
const partyText = transaction.value < 0 ? supplierText : clientText;
const partyLabel = transaction.value < 0 ? 'Fornecedor' : clientLabel;
const partyIcon = transaction.value < 0 ? 'building' : 'user';
const partyDisplay = partyText || 'Não informado';
const modalSub = `${partyLabel}: ${partyDisplay} - ${transaction.desc} - ${DB.money(total)}`;
const sum = payments.reduce((acc, p) => acc + (Number(p.value) || 0), 0);
const remaining = Math.round((total - sum) * 100) / 100;
const update = (idx, key, value) => {
setPayments((items) => items.map((item, i) => i === idx ? { ...item, [key]: value } : item));
};
const addPayment = () => {
setPayments((items) => [...items, { method: 'Pix', value: Math.max(0, remaining), installments: 1 }]);
};
const removePayment = (idx) => {
setPayments((items) => items.length <= 1 ? items : items.filter((_, i) => i !== idx));
};
const valid = total > 0 && Math.abs(remaining) <= 0.01 && payments.every((p) => Number(p.value) > 0 && Number(p.installments) >= 1);
const save = async () => {
if (!valid || saving) return;
setSaving(true);
try {
await onSave({
desc: transaction.desc,
cat: transaction.value < 0 ? 'Despesa' : 'Receita',
value: total,
date: DB.iso(DB.today),
payments: payments.map((p) => ({
method: p.method,
value: Number(p.value) || 0,
installments: Math.max(1, Number(p.installments) || 1),
})),
});
} finally {
setSaving(false);
}
};
return (
Cancelar
{saving ? 'Salvando...' : 'Confirmar'} >}>
{partyLabel}: {partyDisplay}
{payments.map((p, idx) => (
update(idx, 'method', e.target.value)}>
{methods.map((m) => {m} )}
update(idx, 'value', n)} />
update(idx, 'installments', e.target.value)} />
removePayment(idx)} disabled={payments.length <= 1}>
))}
Adicionar forma
Soma: {DB.money(sum)}
);
}
/* ---------------- TAREFAS ---------------- */
function TasksScreen(ctx) {
const { tasks, toast } = ctx;
const [adding, setAdding] = React.useState(false);
const [text, setText] = React.useState('');
const cols = [
{ key: 'alta', label: 'Prioridade alta', cls: 'red' },
{ key: 'media', label: 'Prioridade média', cls: 'amber' },
{ key: 'baixa', label: 'Prioridade baixa', cls: 'blue' },
];
const done = tasks.filter(t => t.done).length;
const create = async () => {
if (!text.trim()) return;
await ctx.addTask(text.trim());
setText(''); setAdding(false); toast('Tarefa criada!');
};
return (
setAdding(true)}>Nova tarefa
} />
{adding && (
setText(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') create(); }} />
setAdding(false)}>Cancelar
)}
{cols.map(col => {
const list = tasks.filter(t => t.prio === col.key);
return (
{col.label}{list.length}
{list.length === 0 &&
Nada por aqui
}
{list.map(t => (
ctx.toggleTask(t.id, !t.done)} />
ctx.deleteTask(t.id)}>
))}
);
})}
);
}
/* ---------------- HISTÓRICO ---------------- */
function HistoryScreen(ctx) {
const { terms, appts, clients, services, team, statusMap, toast } = ctx;
const DB = window.DB;
const apptPlural = terms.appt.toLowerCase() + 's';
const initialClientId = ctx.historyClientId !== 'all' && clients.some((client) => Number(client.id) === Number(ctx.historyClientId))
? String(ctx.historyClientId)
: 'all';
const [clientId, setClientId] = React.useState(initialClientId);
const [serviceId, setServiceId] = React.useState('all');
const [proId, setProId] = React.useState('all');
const [status, setStatus] = React.useState('all');
const [dateFrom, setDateFrom] = React.useState('');
const [dateTo, setDateTo] = React.useState('');
React.useEffect(() => {
const requested = ctx.historyClientId !== 'all' && clients.some((client) => Number(client.id) === Number(ctx.historyClientId))
? String(ctx.historyClientId)
: 'all';
setClientId(requested);
}, [ctx.historyClientId]);
const todayMin = DB.iso(DB.today);
const past = appts.filter(a => a.date <= todayMin).sort((a, b) => (b.date + b.start).localeCompare(a.date + a.start));
const cli = (id) => clients.find(c => c.id === id);
const svc = (id) => services.find(s => s.id === id);
const pro = (id) => team.find(p => p.id === id);
const ini = (n) => String(n || '?').split(' ').map(x => x[0]).slice(0, 2).join('').toUpperCase();
const statusOptions = ['confirmado', 'pendente', 'concluido', 'faltou', 'cancelado'];
const shown = past.filter((a) => {
if (clientId !== 'all' && Number(a.clientId) !== Number(clientId)) return false;
if (serviceId === 'manual' && !a.manualServiceName) return false;
if (serviceId !== 'all' && serviceId !== 'manual' && Number(a.serviceId) !== Number(serviceId)) return false;
if (proId !== 'all' && Number(a.proId) !== Number(proId) && !(a.participants || []).some((part) => Number(part.proId) === Number(proId))) return false;
if (status !== 'all' && a.status !== status) return false;
if (dateFrom && a.date < dateFrom) return false;
if (dateTo && a.date > dateTo) return false;
return true;
});
const selectedClient = clientId === 'all' ? null : cli(Number(clientId));
const hasManualServices = past.some((a) => !!a.manualServiceName);
// agrupar por data
const groups = {};
shown.forEach(a => { (groups[a.date] = groups[a.date] || []).push(a); });
const fmtDate = (d) => {
const dt = new Date(d + 'T00:00');
if (d === todayMin) return 'Hoje';
if (d === DB.iso(DB.addDays(-1))) return 'Ontem';
return dt.toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'long' });
};
const exportar = () => {
const suffix = selectedClient ? '-' + String(selectedClient.name || selectedClient.id).toLowerCase().replace(/[^a-z0-9]+/g, '-') : '';
window.exportCSV('historico' + suffix + '.csv', [
['Data', 'Inicio', 'Fim', terms.client, terms.service, terms.pro, 'Valor', 'Status'],
...shown.map(a => {
const c = cli(a.clientId), s = a.manualServiceName ? { name: a.manualServiceName, price: a.manualServicePrice || 0, color: 'violet' } : svc(a.serviceId), p = pro(a.proId);
const st = statusMap[a.status] || { label: a.status };
return [a.date, a.start, window.apptEnd(a), c ? c.name : (a.clientLabel || `Sem ${terms.client.toLowerCase()}`), s ? s.name : '', p ? p.name : '', s ? String(s.price).replace('.', ',') : '', st.label];
}),
]);
toast('Histórico exportado!', 'download');
};
const clearFilters = () => {
setClientId('all');
setServiceId('all');
setProId('all');
setStatus('all');
setDateFrom('');
setDateTo('');
};
return (
Exportar
} />
setClientId(e.target.value)}>
Todos os {terms.clients.toLowerCase()}
{[...clients].sort((a, b) => String(a.name).localeCompare(String(b.name), 'pt-BR')).map((client) => {client.name} )}
setServiceId(e.target.value)}>
Todos os {terms.services.toLowerCase()}
{services.map((service) => {service.name} )}
{hasManualServices && Informado manualmente }
setProId(e.target.value)}>
Todos os {terms.pros.toLowerCase()}
{team.filter((member) => !member.systemIdentity).map((member) => {member.name} )}
setStatus(e.target.value)}>
Todos os status
{statusOptions.map((value) => {(statusMap[value] || { label: value }).label} )}
setDateFrom(e.target.value)} />
setDateTo(e.target.value)} />
{shown.length} registro{shown.length === 1 ? '' : 's'} encontrado{shown.length === 1 ? '' : 's'}
Limpar filtros
{Object.keys(groups).length === 0 && }
{Object.keys(groups).map(date => (
{fmtDate(date)}
{groups[date].length}
{groups[date].map((a, i) => {
const c = cli(a.clientId), 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, cls: 'plain' };
if (!s || !p) return null;
const displayClientName = c ? c.name : (a.clientLabel || p.name || `Sem ${terms.client.toLowerCase()}`);
return (
ctx.openAppt(a)}>
{a.start}–{window.apptEnd(a)}
{displayClientName}
{s.name} · {p.name.replace('Dra. ', '')}
{s.price > 0 ? DB.money0(s.price) : '—'}
{st.label}
);
})}
))}
);
}
function TasksScreenFull(ctx) {
const { tasks, clients, terms, toast } = ctx;
const DB = window.DB;
const blank = () => ({ title: '', dueDate: DB.iso(DB.today), dueTime: '09:00', clientId: 'none', prio: 'media' });
const [adding, setAdding] = React.useState(false);
const [form, setForm] = React.useState(blank);
const cols = [
{ key: 'alta', label: 'Prioridade alta', cls: 'red' },
{ key: 'media', label: 'Prioridade media', cls: 'amber' },
{ key: 'baixa', label: 'Prioridade baixa', cls: 'blue' },
];
const done = tasks.filter(t => t.done).length;
const set = (key) => (e) => setForm({ ...form, [key]: e.target.value });
const clientName = (id) => {
const c = clients.find(x => x.id === Number(id));
return c ? c.name : '';
};
const dueLabel = (t) => {
if (t.dueDate) {
const d = new Date(t.dueDate + 'T00:00').toLocaleDateString('pt-BR', { day: '2-digit', month: 'short' });
return t.dueTime ? d + ', ' + t.dueTime : d;
}
return t.due || 'Sem data';
};
const create = async () => {
if (!form.title.trim()) return;
const selectedClient = clientName(form.clientId);
await ctx.addTask({
title: form.title.trim(),
dueDate: form.dueDate,
dueTime: form.dueTime,
prio: form.prio,
clientId: form.clientId === 'none' ? null : Number(form.clientId),
tag: selectedClient || 'Geral',
});
setForm(blank());
setAdding(false);
toast('Tarefa criada!');
};
return (
setAdding(true)}>Nova tarefa
} />
{adding && (
{ if (e.key === 'Enter') create(); }} />
Sem {terms.client.toLowerCase()}
{clients.map(c => {c.name} )}
Alta
Media
Baixa
{ setAdding(false); setForm(blank()); }}>Cancelar
Salvar tarefa
)}
{cols.map(col => {
const list = tasks.filter(t => t.prio === col.key);
return (
{col.label}{list.length}
{list.length === 0 &&
Nada por aqui
}
{list.map(t => (
ctx.toggleTask(t.id, !t.done)} />
{t.text}
{dueLabel(t)}
{(t.clientId || t.tag) && {clientName(t.clientId) || t.tag} }
ctx.deleteTask(t.id)}>
))}
);
})}
);
}
/* ---------------- MENSALIDADE (cobrança do SaaS — visão do cliente/admin) ---------------- */
function BillingScreen(ctx) {
const DB = window.DB;
const { toast } = ctx;
const [data, setData] = React.useState(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState(null);
const [selPlanId, setSelPlanId] = React.useState(null);
const [selMonths, setSelMonths] = React.useState(1);
const [showCheckout, setShowCheckout] = React.useState(false);
const [showPlanFeatures, setShowPlanFeatures] = React.useState(false);
const [form, setForm] = React.useState({ name: '', doc: '', email: '' });
const [submitting, setSubmitting] = React.useState(false);
const [checking, setChecking] = React.useState(false);
const [cancelling, setCancelling] = React.useState(false);
const [pixData, setPixData] = React.useState(null);
const [pixLoading, setPixLoading] = React.useState(false);
const load = React.useCallback(async () => {
try {
const d = await window.API.billing.get();
setData(d);
setForm({ name: d.customer.name || '', doc: d.customer.doc || '', email: d.customer.email || '' });
setSelPlanId(d.currentPlanId || (d.plans && d.plans[0] && d.plans[0].id) || null);
setError(null);
} catch (e) {
setError(e.message || 'Erro ao carregar a mensalidade.');
} finally {
setLoading(false);
}
}, []);
React.useEffect(() => { load(); }, [load]);
// carrega o QR/copia-e-cola do PIX quando há uma cobrança em aberto
const openPayId = data && data.openPayment ? data.openPayment.id : null;
React.useEffect(() => {
if (!openPayId) { setPixData(null); return; }
let alive = true;
setPixLoading(true);
(async () => {
try {
const r = await window.API.billing.pix(openPayId);
if (alive) setPixData(r && r.payload ? r : null);
} catch { if (alive) setPixData(null); }
finally { if (alive) setPixLoading(false); }
})();
return () => { alive = false; };
}, [openPayId]);
const statusMeta = {
em_dia: { label: 'Em dia', cls: 'green', soft: 'success' },
pendente: { label: 'Pendente', cls: 'amber', soft: 'warning' },
atrasado: { label: 'Atrasado', cls: 'red', soft: 'danger' },
};
const typeLabel = { MANUAL: 'Manual', CREDIT: 'Crédito', PIX: 'PIX', BOLETO: 'Boleto', CREDIT_CARD: 'Cartão', UNDEFINED: 'Online' };
const payStatusLabel = { RECEIVED: 'Recebido', CONFIRMED: 'Confirmado', PENDING: 'Aguardando', OVERDUE: 'Vencido', REFUNDED: 'Estornado', CANCELLED: 'Cancelada' };
const fmtDate = (d) => d ? new Date(String(d).replace(' ', 'T')).toLocaleDateString('pt-BR', { day: '2-digit', month: 'short', year: 'numeric' }) : '—';
const monthLabel = (m) => m === 1 ? '1 mês' : m + ' meses';
if (loading) {
return (
);
}
if (error) {
return (
);
}
const plans = data.plans || [];
const payments = data.payments || [];
const durations = data.durations || [1];
const discounts = data.discounts || {};
const sm = statusMeta[data.status] || statusMeta.em_dia;
const currentPlan = plans.find(p => p.id === data.currentPlanId) || null;
const selPlan = plans.find(p => p.id === selPlanId) || plans[0] || null;
const selectedIsCurrentPlan = !!(currentPlan && selPlan && currentPlan.id === selPlan.id);
const totalPaid = payments.filter(p => p.status === 'RECEIVED' || p.status === 'CONFIRMED').reduce((s, p) => s + p.value, 0);
const accessUntil = [data.paidUntil, data.trialEndsAt].filter(Boolean).sort().pop() || null;
const priceFor = (p, m) => p ? Math.round(p.price * m * (1 - ((discounts[String(m)] || 0) / 100)) * 100) / 100 : 0;
const planLimits = (p) => !p ? '' : [
((p.maxUsers ?? p.maxAdmins) === 0 ? 'Acessos ilimitados' : (p.maxUsers ?? p.maxAdmins) + ((p.maxUsers ?? p.maxAdmins) === 1 ? ' acesso com login' : ' acessos com login')),
(p.hasTeam ? (p.maxPros === 0 ? 'Equipe ilimitada' : 'Até ' + p.maxPros + ' na equipe') : 'Sem equipe'),
].join(' · ');
const planComparisonRows = [
{ label: 'Agenda e calendário', value: () => true },
{ label: 'Cadastro de clientes / alunos', value: () => true },
{ label: 'Serviços, planos e aulas', value: () => true },
{ label: 'Tarefas e histórico', value: () => true },
{ label: 'Financeiro', value: (p) => p.hasFinance },
{ label: 'PIX, boleto e cartão', value: () => true },
{ label: 'Usuários com login e senha', value: (p) => (p.maxUsers ?? p.maxAdmins) === 0 ? 'Ilimitados' : String(p.maxUsers ?? p.maxAdmins) },
{ label: 'Equipe / profissionais', value: (p) => !p.hasTeam ? 'Não inclui' : (p.maxPros === 0 ? 'Ilimitada' : `Até ${p.maxPros}`) },
{ label: 'Gestáo das agendas da equipe', value: (p) => p.hasTeam },
{ label: 'Confirmações e lembretes por e-mail', value: (p) => p.hasAppointmentEmail },
{ label: 'Agendamento em grupo / reunião', value: (p) => p.hasGroupMeetings },
{ label: 'Chat interno', value: (p) => p.hasInternalChat ? 'Em breve' : false },
{ label: 'Sincronização com Google Drive', value: (p) => ['profissional', 'empresarial'].includes(p.slug) ? 'Em breve' : false },
{ label: 'Sincronização com Google Agenda', value: (p) => ['profissional', 'empresarial'].includes(p.slug) ? 'Em breve' : false },
{ label: 'Sincronização com Google Meet', value: (p) => p.slug === 'empresarial' ? 'Em breve' : false },
{ label: 'Suporte', value: (p) => p.hasSupport },
];
const selTotal = priceFor(selPlan, selMonths);
const openPay = data.openPayment || null;
// pró-rata: crédito dos dias restantes do plano atual ao TROCAR de plano
const lastPaid = payments.find(p => (p.status === 'RECEIVED' || p.status === 'CONFIRMED') && p.months > 0 && (p.planId === currentPlan?.id || p.planId == null));
const todayStart = new Date();
todayStart.setHours(0, 0, 0, 0);
const trialRemainingDays = !currentPlan && data.trialEndsAt ? Math.max(0, Math.ceil((new Date(data.trialEndsAt + 'T00:00:00') - todayStart) / 86400000)) : 0;
const accessRemainingDays = data.paidUntil ? Math.max(0, Math.ceil((new Date(data.paidUntil + 'T00:00:00') - todayStart) / 86400000)) : 0;
const paidDate = lastPaid?.date ? new Date(String(lastPaid.date).slice(0, 10) + 'T00:00:00') : null;
const usedPeriodDays = paidDate ? Math.max(0, Math.floor((todayStart - paidDate) / 86400000)) : 0;
const remainingDays = lastPaid ? Math.max(0, lastPaid.months * 30 - usedPeriodDays) : Math.min(30, accessRemainingDays);
const isPlanChange = currentPlan && selPlan && currentPlan.id !== selPlan.id && remainingDays > 0;
const dailyValue = lastPaid ? ((lastPaid.value + (lastPaid.creditApplied || 0)) / Math.max(1, lastPaid.months * 30)) : (currentPlan ? currentPlan.price / 30 : 0);
const periodCredit = isPlanChange ? Math.round(dailyValue * remainingDays * 100) / 100 : 0;
const availableCredit = Math.round(((data.creditBalance || 0) + periodCredit) * 100) / 100;
const credit = Math.min(availableCredit, selTotal);
const creditRemaining = Math.round((availableCredit - credit) * 100) / 100;
const netTotal = Math.round((selTotal - credit) * 100) / 100;
const checkoutActionLabel = !selPlan ? 'Selecione um plano' : (
selectedIsCurrentPlan ? 'Renovar ' + selPlan.name : (currentPlan ? 'Mudar para ' + selPlan.name : 'Assinar ' + selPlan.name)
) + ' · ' + monthLabel(selMonths) + ' · ' + DB.money(netTotal);
const usage = data.usage || {};
const loginUsers = Number(usage.loginUsers || 0);
const teamMembers = Number(usage.teamMembers || 0);
const maxPlanUsers = selPlan ? Number(selPlan.maxUsers ?? selPlan.maxAdmins ?? 0) : 0;
const maxPlanTeam = selPlan ? Number(selPlan.maxPros || 0) : 0;
const blockedUsers = maxPlanUsers > 0 ? Math.max(0, loginUsers - maxPlanUsers) : 0;
const archivedTeam = selPlan && !selPlan.hasTeam ? teamMembers : 0;
const excessTeam = selPlan && selPlan.hasTeam && maxPlanTeam > 0 ? Math.max(0, teamMembers - maxPlanTeam) : 0;
const firstAdminName = usage.firstAdmin && usage.firstAdmin.name ? usage.firstAdmin.name : 'o primeiro administrador cadastrado';
const planRank = { individual: 1, profissional: 2, empresarial: 3 };
const isPlanRegression = !!(currentPlan && selPlan && (planRank[selPlan.slug] || 0) < (planRank[currentPlan.slug] || 0));
const lostFeatures = !selPlan ? [] : [
(!currentPlan || currentPlan.hasFinance) && !selPlan.hasFinance ? 'Financeiro' : null,
(!currentPlan || currentPlan.hasTeam) && !selPlan.hasTeam ? 'Equipe' : null,
(!currentPlan || currentPlan.hasGroupMeetings) && !selPlan.hasGroupMeetings ? 'reuniões em grupo' : null,
(!currentPlan || currentPlan.hasInternalChat) && !selPlan.hasInternalChat ? 'chat interno' : null,
].filter(Boolean);
const hasPlanImpact = isPlanRegression && (blockedUsers > 0 || archivedTeam > 0 || excessTeam > 0 || lostFeatures.length > 0);
const startCheckout = () => {
if (!data.asaasReady) { toast('Pagamento online ainda não configurado. Fale com o suporte.', 'x'); return; }
if (!selPlan) { toast('Selecione um plano.', 'x'); return; }
setShowCheckout(true);
};
const confirmCheckout = async () => {
if (submitting || !selPlan) return;
setSubmitting(true);
try {
const r = await window.API.billing.checkout({ planId: selPlan.id, months: selMonths, name: form.name, doc: form.doc, email: form.email });
setShowCheckout(false);
toast(r.confirmed ? 'Plano ativado usando seu crédito!' : ((r.credit > 0 ? 'Cobrança gerada com ' + DB.money(r.credit) + ' de crédito abatido!' : 'Cobrança gerada!') + ' O PIX aparecerá nesta tela.'), 'check');
await load();
} catch (e) {
toast(e.message || 'Erro ao gerar a cobrança', 'x');
} finally {
setSubmitting(false);
}
};
const verifyPayment = async () => {
if (!openPay || checking) return;
setChecking(true);
try {
const r = await window.API.billing.check(openPay.id);
if (r.confirmed) {
toast('Pagamento confirmado! Seu acesso foi estendido.', 'check');
load();
} else if (r.cancelled) {
toast('Essa cobrança foi cancelada e não está mais disponível. Gere uma nova se quiser pagar.', 'info');
load();
} else {
toast('Pagamento ainda não identificado. Se já pagou, aguarde alguns instantes e tente de novo.', 'info');
}
} catch (e) {
toast(e.message || 'Erro ao verificar o pagamento', 'x');
} finally {
setChecking(false);
}
};
const cancelCharge = async () => {
if (!openPay || cancelling) return;
if (!window.confirm('Cancelar esta cobrança em aberto? Depois você poderá gerar outra.')) return;
setCancelling(true);
try {
await window.API.billing.cancel(openPay.id);
toast('Cobrança cancelada.', 'check');
load();
} catch (e) {
toast(e.message || 'Erro ao cancelar a cobrança', 'x');
} finally {
setCancelling(false);
}
};
return (
{currentPlan ? currentPlan.name : 'Período de teste'}
{currentPlan ? DB.money(currentPlan.price) + '/mês' : 'Escolha um plano abaixo'}
{sm.label}
Último pagamento: {fmtDate(data.lastPaymentAt)}
{accessUntil ? fmtDate(accessUntil) : '—'}
{totalPaid > 0 ? 'Total pago: ' + DB.money(totalPaid) : (currentPlan ? '' : 'Período de teste')}
{openPay && (
Cobrança em aberto — aguardando pagamento
{(openPay.months ? monthLabel(openPay.months) + ' · ' : '') + DB.money(openPay.value)}. Pague com PIX aqui (ou boleto/cartão pelo link) e confirme — o acesso é estendido na confirmação. Para assinar outro plano, cancele esta cobrança primeiro.
{pixData && pixData.payload && (
{pixData.encodedImage &&
}
Pague com PIX agora
Escaneie o QR Code ou copie o código abaixo no app do seu banco. A confirmação é automática.
e.target.select()} style={{ fontSize: 11, fontFamily: 'var(--font-mono)' }} />
{ if (navigator.clipboard) navigator.clipboard.writeText(pixData.payload); toast('Código PIX copiado!', 'check'); }}>Copiar
)}
{pixLoading && !pixData &&
Carregando PIX...
}
{openPay.invoiceUrl && window.open(openPay.invoiceUrl, '_blank')}>Boleto / cartão }
{checking ? 'Verificando...' : 'Já paguei / Verificar'}
{cancelling ? 'Cancelando...' : 'Cancelar cobrança'}
)}
{!openPay && (data.asaasReady ? (
{currentPlan ? 'Renovar ou alterar plano' : 'Escolha seu plano'}
{currentPlan ? 'A renovação soma tempo ao acesso pago atual. Para mudar de plano, selecione outra opção abaixo.' : `O período é somado ao seu acesso atual.${trialRemainingDays > 0 ? ` Você ainda tem ${trialRemainingDays} dia(s) de teste; eles serão mantidos e o plano começará depois do teste.` : (data.trialEndsAt ? ' Seu teste vai até ' + fmtDate(data.trialEndsAt) + '.' : '')}`}
{!currentPlan &&
Durante o teste, todos os recursos e limites ficam liberados. Ao contratar, passam a valer os limites do plano escolhido; nenhum dado será apagado.
}
{plans.map(p => {
const isSel = selPlan && p.id === selPlan.id;
return (
setSelPlanId(p.id)} aria-pressed={isSel}
style={{ display: 'block', width: '100%', textAlign: 'left', cursor: 'pointer', border: 0, background: 'transparent', color: 'inherit', padding: '13px 14px 9px' }}>
{p.name}
{DB.money(p.price)}/mês
{planLimits(p)}
{currentPlan && p.id === currentPlan.id && atual }
setShowPlanFeatures(true)}>Ver funcionalidades
);
})}
{hasPlanImpact &&
O que muda ao ativar o {selPlan && selPlan.name}
{maxPlanUsers === 1 &&
• Somente {firstAdminName} , o primeiro administrador criado, continuará com acesso.
}
{blockedUsers > 0 &&
• {blockedUsers} usuário(s) excedente(s) ficarão sem acesso, mas serão preservados.
}
{archivedTeam > 0 &&
• {archivedTeam} membro(s) da equipe ficarão arquivados enquanto este plano estiver ativo.
}
{excessTeam > 0 &&
• Sua equipe excede o limite em {excessTeam}; os dados serão preservados e novos cadastros ficarão bloqueados até a adequação.
}
{lostFeatures.length > 0 &&
• Recursos indisponíveis: {lostFeatures.join(', ')}.
}
• Agendamentos e históricos não serão apagados. Um upgrade restaura os recursos e acessos conforme o novo limite.
}
Por quanto tempo:
{durations.map(m => {
const isSel = m === selMonths; const disc = discounts[String(m)] || 0;
return (
setSelMonths(m)}
style={{ textAlign: 'left', cursor: 'pointer', background: isSel ? 'var(--primary-soft)' : 'var(--surface)', border: '1px solid ' + (isSel ? 'var(--primary)' : 'var(--border-strong)'), borderRadius: 'var(--r-sm)', padding: '9px 11px' }}>
{monthLabel(m)}
{DB.money(priceFor(selPlan, m))}
{disc > 0 ? {disc}% off
:
}
);
})}
{availableCredit > 0 && (
Crédito disponível: {DB.money(availableCredit)}{periodCredit > 0 ? ` (${remainingDays} dia(s) restantes do plano ${currentPlan.name})` : ''}. {DB.money(credit)} será abatido e você paga {DB.money(netTotal)}{creditRemaining > 0 ? `; o saldo de ${DB.money(creditRemaining)} fica para a próxima renovação.` : '.'}
)}
{checkoutActionLabel}
) : (
Pagamento online
Em breve você poderá assinar um plano direto por aqui (PIX, boleto e cartão).
))}
Histórico de pagamentos {payments.length}
{payments.length === 0 ?
: (
Data Período Forma Status Nota fiscal Valor
{payments.map(p => (
{fmtDate(p.date)}
{p.months > 0 ? monthLabel(p.months) : '—'}
{typeLabel[p.type] || p.type}
{payStatusLabel[p.status] || p.status}
{p.taxInvoiceUrl
? window.open(p.taxInvoiceUrl, '_blank')}>Baixar nota
: — }
{DB.money(p.value)}
))}
)}
{showPlanFeatures && (
setShowPlanFeatures(false)}
footer={<>
setShowPlanFeatures(false)}>Fechar >}>
Funcionalidade
{plans.map(p => {p.name}
{DB.money(p.price)}/mês
)}
{planComparisonRows.map(row => (
{row.label}
{plans.map(p => {
const value = row.value(p);
return
{typeof value === 'boolean'
?
: value}
;
})}
))}
)}
{showCheckout && (
setShowCheckout(false)}
footer={<>
setShowCheckout(false)}>Cancelar
{submitting ? 'Gerando...' : 'Gerar cobrança'} >}>
setForm({ ...form, name: e.target.value })} placeholder="Nome de quem paga" />
setForm({ ...form, doc: e.target.value })} placeholder="Somente números" />
setForm({ ...form, email: e.target.value })} placeholder="para o recibo (opcional)" />
{credit > 0 && {DB.money(credit)} de crédito abatido — total a pagar: {DB.money(netTotal)}.{creditRemaining > 0 ? ` Saldo de ${DB.money(creditRemaining)} guardado para a próxima renovação.` : ''}
}
{trialRemainingDays > 0 && Seus {trialRemainingDays} dia(s) restantes de teste serão preservados e somados ao período comprado.
}
{hasPlanImpact &&
Confirme que está ciente das limitações:
{maxPlanUsers === 1 &&
• Apenas {firstAdminName} continuará com acesso.
}
{blockedUsers > 0 &&
• {blockedUsers} usuário(s) ficarão bloqueados, sem exclusão dos dados.
}
{archivedTeam > 0 &&
• A equipe será arquivada e ficará disponível novamente após upgrade.
}
{excessTeam > 0 &&
• A equipe atual excede em {excessTeam} o limite deste plano.
}
{lostFeatures.length > 0 &&
• Você deixará de acessar: {lostFeatures.join(', ')}.
}
• Histórico e agendamentos serão preservados.
}
O QR Code PIX aparecerá nesta tela, sem redirecionamento. Se preferir boleto ou cartão, use o link de outras formas de pagamento. O acesso é estendido assim que o pagamento for confirmado.
)}
);
}
Object.assign(window, { FinanceScreen, TasksScreen: TasksScreenFull, HistoryScreen, BillingScreen });