Sistema de Gestão

Entre com seu e-mail e senha

← Voltar ao site da clínica

Acesso restrito à equipe da clínica. Dados sincronizados na nuvem (Firebase).

Clínica Jose Luiz de Oliveira
CNPJ 62.010.010/0001-65
`; abrirImpressao(html); } /* ================= CLIENTES ================= */ let buscaCli=""; function renderClientes(){ let listaCli=DB.clientes; if(buscaCli){ const q=buscaCli.toLowerCase(); listaCli=listaCli.filter(c=>(c.nome||"").toLowerCase().indexOf(q)!==-1 || soDigitos(c.cpf).indexOf(soDigitos(buscaCli))!==-1 || (c.telefone||"").toLowerCase().indexOf(q)!==-1); } const linhas=listaCli.map(c=>{ const i=idade(c.nascimento); const tag=(i!==null&&i<18)?' menor':''; const cidade=c.endereco?[c.endereco.cidade,c.endereco.uf].filter(Boolean).join("/"):""; return `${esc(c.nome)}${tag}${mascararCPF(c.cpf)}${esc(c.telefone||'—')}${esc(cidade||'—')} `; }).join(""); document.getElementById("conteudo").innerHTML=`

👥 Clientes

O cliente é da clínica. A alocação de profissional e sala é feita no agendamento. CPF e RG aparecem mascarados aqui (LGPD) e completos ao abrir o cadastro.

${linhas||``}
NomeCPFTelefoneCidade
Nenhum cliente encontrado.
`; const _b=document.getElementById("cli-busca"); if(_b&&buscaCli){ _b.focus(); const v=_b.value; _b.value=""; _b.value=v; } } function formCliente(id){ const c=id?DB.clientes.find(x=>x.id===id):{nome:"",cpf:"",rg:"",rgOrgao:"",nascimento:"",telefone:"",email:"",endereco:{},responsavel:{},obs:""}; const e=c.endereco||{}, r=c.responsavel||{}; abrirModal(id?"Editar cliente":"Novo cliente",`
Identificação
Endereço
Responsável (opcional — menores/representação legal)
`,()=>{ const nome=document.getElementById("fc-nome").value.trim(); if(!nome){ toast("Informe o nome."); return false; } const cpf=document.getElementById("fc-cpf").value.trim(), rcpf=document.getElementById("fc-rcpf").value.trim(); if(cpf && !cpfValido(cpf)){ toast("CPF inválido — informe os 11 dígitos corretos."); document.getElementById("fc-cpf").classList.add("invalido"); return false; } if(rcpf && !cpfValido(rcpf)){ toast("CPF do responsável inválido."); document.getElementById("fc-rcpf").classList.add("invalido"); return false; } const dados={nome,cpf:soDigitos(cpf),rg:document.getElementById("fc-rg").value.trim(),rgOrgao:document.getElementById("fc-rgorgao").value.trim(), nascimento:document.getElementById("fc-nasc").value,telefone:document.getElementById("fc-tel").value.trim(),email:document.getElementById("fc-email").value.trim(), endereco:{cep:document.getElementById("fc-cep").value.trim(),logradouro:document.getElementById("fc-log").value.trim(),numero:document.getElementById("fc-num").value.trim(),bairro:document.getElementById("fc-bairro").value.trim(),cidade:document.getElementById("fc-cidade").value.trim(),uf:document.getElementById("fc-uf").value}, responsavel:{nome:document.getElementById("fc-rnome").value.trim(),cpf:soDigitos(rcpf),parentesco:document.getElementById("fc-rpar").value.trim()}, obs:document.getElementById("fc-obs").value.trim(),ativo:true}; if(id) Object.assign(DB.clientes.find(x=>x.id===id),dados); else DB.clientes.push(Object.assign({id:uid()},dados)); salvarDB(); renderClientes(); toast("Cliente salvo."); }); } function excluirCliente(id){ if(!confirm("Excluir este cliente?")) return; DB.clientes=DB.clientes.filter(x=>x.id!==id); salvarDB(); renderClientes(); toast("Cliente excluído."); } /* ================= AGENDA ================= */ function dentroRecepcaoAgora(){ const c=DB.config.recepcao; const now=new Date(); const m=now.getHours()*60+now.getMinutes(); return (m>=hmMin(c.manhaInicio)&&m<=hmMin(c.manhaFim))||(m>=hmMin(c.tardeInicio)&&m<=hmMin(c.tardeFim)); } function dentroHorarioProf(prof,data,hora,dur){ if(!prof||!prof.horarios||!prof.horarios.length) return true; const dia=diaSemanaDe(data), ini=hmMin(hora), fim=ini+(dur||60); return prof.horarios.some(h=>h.dia===dia && hmMin(h.inicio)<=ini && fim<=hmMin(h.fim)); } function checarConflitos(atd,ignoreId){ const ini=hmMin(atd.hora), fim=ini+(atd.duracao||60); const sob=a=>{ const ai=hmMin(a.hora), af=ai+(a.duracao||60); return inia.id!==ignoreId && a.status!=="CANCELADO" && a.data===atd.data && a.hora && sob(a)); const cs=md.find(a=>a.salaId===atd.salaId), cp=md.find(a=>a.profissionalId===atd.profissionalId); const r={sala:!!cs,prof:!!cp,salaMsg:"",profMsg:""}; if(cs){ const c=DB.clientes.find(x=>x.id===cs.clienteId)||{}; r.salaMsg=cs.hora+" ("+(c.nome||"ocupada")+")"; } if(cp){ const c=DB.clientes.find(x=>x.id===cp.clienteId)||{}; r.profMsg=cp.hora+" ("+(c.nome||"ocupado")+")"; } return r; } function atdsDoPeriodo(){ const st=estadoMes.agenda; if(st.tipo==="mes"){ const c=compDe("agenda"); return DB.atendimentos.filter(a=>(a.data||"").slice(0,7)===c); } if(st.tipo==="semana"){ const {ini,fim}=semanaRange(st.data); return DB.atendimentos.filter(a=>a.data>=ini&&a.data<=fim); } return DB.atendimentos.filter(a=>a.data===st.data); } function rotuloPeriodoAgenda(){ const st=estadoMes.agenda; if(st.tipo==="mes") return MESES[st.mes-1]+"/"+st.ano; if(st.tipo==="semana"){ const {ini,fim}=semanaRange(st.data); return "semana "+dataBR(ini)+" a "+dataBR(fim); } return dataBR(st.data); } let buscaAg=""; function renderAgenda(){ const st=estadoMes.agenda; let lista=atdsDoPeriodo().slice().sort((a,b)=>(a.data+a.hora).localeCompare(b.data+b.hora)); if(buscaAg && st.tipo!=="dia"){ const q=buscaAg.toLowerCase(); lista=lista.filter(a=>{ const c=DB.clientes.find(x=>x.id===a.clienteId)||{}, p=DB.profissionais.find(x=>x.id===a.profissionalId)||{}; return (c.nome||"").toLowerCase().indexOf(q)!==-1||(p.nome||"").toLowerCase().indexOf(q)!==-1; }); } let blocos=""; if(st.tipo==="dia"){ let profs=DB.profissionais.filter(p=>p.ativo!==false); if(buscaAg){ const q=buscaAg.toLowerCase(); profs=profs.filter(p=>(p.nome||"").toLowerCase().indexOf(q)!==-1||(p.especialidade||"").toLowerCase().indexOf(q)!==-1); } const diaAll=atdsDoPeriodo(); blocos=profs.map(p=>blocoAgendaDia(p,st.data,diaAll.filter(a=>a.profissionalId===p.id).sort((a,b)=>(a.hora||"").localeCompare(b.hora||"")))).join(""); } else { const porProf={}; lista.forEach(a=>{ (porProf[a.profissionalId]=porProf[a.profissionalId]||[]).push(a); }); blocos=Object.keys(porProf).map(pid=>{ const p=DB.profissionais.find(x=>x.id===pid)||{nome:"(sem profissional)"}; const linhas=porProf[pid].map(a=>linhaAgenda(a)).join(""); return `
${esc(p.nome)} ${esc(p.especialidade||"")}${porProf[pid].length} atend.
${linhas}
DataHoraClienteTipoSalaValorStatus
`; }).join(""); } document.getElementById("conteudo").innerHTML=`

🗓️ Agenda

${faixaMeses("agenda")}
${st.tipo!=='mes'?`
`:''}
${st.tipo==='dia'?'Modo Dia: clique no profissional para ver horários e vagas.':'Exibindo: '+rotuloPeriodoAgenda()+' · '+lista.length+' atendimento(s).'} Recepção: ${DB.config.recepcao.manhaInicio}–${DB.config.recepcao.manhaFim} e ${DB.config.recepcao.tardeInicio}–${DB.config.recepcao.tardeFim}.
${st.tipo==='dia'?`

${dataBR(st.data)} — profissionais

`:""} ${blocos||`
Nenhum agendamento em ${rotuloPeriodoAgenda()}.
`}`; const _b=document.getElementById("ag-busca"); if(_b&&buscaAg){ _b.focus(); const v=_b.value; _b.value=""; _b.value=v; } } function blocoAgendaDia(p,data,lista){ const ags=lista.filter(a=>a.status!=="CANCELADO" && a.status!=="FALTOU").length; const livres=horariosLivres(p,data,60,""); let livresHtml, nLiv=0; if(livres===null) livresHtml='Sem grade cadastrada — agenda livre. Use "+ Novo agendamento".'; else if(!livres.length) livresHtml='Sem horários livres neste dia.'; else { nLiv=livres.length; livresHtml=livres.map(h=>``).join(""); } const corpoTab = lista.length ? `${lista.map(a=>linhaAgenda(a)).join("")}
DataHoraClienteTipoSalaValorStatus
` : '
Nenhum agendamento neste dia.
'; return `
${esc(p.nome)} ${esc(p.especialidade||"")}${ags} agendado(s) · ${nLiv} livre(s)
${corpoTab}
Horários livres: ${livresHtml}
`; } function agendarEm(profId,data,hora){ estadoMes.agenda.data=data; formAgendamento(); const pe=document.getElementById("ag-prof"); if(pe){ pe.value=profId; agdProfMudou(); } const de=document.getElementById("ag-data"); if(de) de.value=data; const he=document.getElementById("ag-hora"); if(he) he.value=hora; atualizaLivres(); } function linhaAgenda(a){ const c=DB.clientes.find(x=>x.id===a.clienteId)||{}, s=DB.salas.find(x=>x.id===a.salaId)||{}; let acoes=""; if(a.status==="AGENDADO") acoes=` `; else if(a.status==="REALIZADO") acoes=``; else if(a.status==="CANCELADO"||a.status==="FALTOU") acoes=``; else acoes=`concluído`; const motivo=a.motivoCancelamento?`
✖ ${esc(a.motivoCancelamento)}`:""; return `${dataBR(a.data)}${a.hora||"—"}
${a.duracao||60}min ${esc(c.nome||"—")}${motivo}${esc(a.tipoAtendimentoNome||"")}${esc(s.nome||"—")} ${reais(a.valor)}${pillStatus(a.status)}${acoes}`; } function setTipoAgenda(t){ estadoMes.agenda.tipo=t; renderAgenda(); } function excluirAtendimento(id){ const a=DB.atendimentos.find(x=>x.id===id); if(a.status==="PAGO"){ alert("Este atendimento já foi concluído e pago — não pode ser excluído."); return; } if(jaRecebidoAtend(id)){ alert("A comissão deste atendimento já foi recebida. Estorne em Contas a Receber/Fluxo antes de excluir."); return; } if(!confirm("Excluir este agendamento? Esta ação não pode ser desfeita (use Cancelar para manter o registro).")) return; DB.atendimentos=DB.atendimentos.filter(x=>x.id!==id); salvarDB(); abrirModulo(moduloAtual); toast("Agendamento excluído."); } function cancelarAtendimento(id){ const a=DB.atendimentos.find(x=>x.id===id); const motivos=["Paciente avisou que não pode","Profissional não pôde comparecer","Remarcado para outra data","Erro de agendamento","Outro"]; abrirModal("Cancelar agendamento",`

Informe o motivo. O registro é mantido no histórico (não é apagado).

`,()=>{ let m=document.getElementById("cn-motivo").value; const o=document.getElementById("cn-obs").value.trim(); if(o) m+=" — "+o; a.status="CANCELADO"; a.motivoCancelamento=m; a.canceladoEm=new Date().toISOString(); salvarDB(); abrirModulo(moduloAtual); toast("Agendamento cancelado."); },"Confirmar cancelamento"); } function faltarAtendimento(id){ const a=DB.atendimentos.find(x=>x.id===id); const motivos=["Não avisou","Avisou mas não veio","Outro"]; abrirModal("Registrar falta",`

O cliente não compareceu. O registro é mantido no histórico como Falta.

`,()=>{ let m=document.getElementById("ft-motivo").value; const o=document.getElementById("ft-obs").value.trim(); if(o) m+=" — "+o; a.status="FALTOU"; a.motivoCancelamento=m; a.canceladoEm=new Date().toISOString(); salvarDB(); abrirModulo(moduloAtual); toast("Falta registrada."); },"Confirmar falta"); } function reabrirAtendimento(id){ const a=DB.atendimentos.find(x=>x.id===id); a.status="AGENDADO"; a.motivoCancelamento=""; salvarDB(); abrirModulo(moduloAtual); toast("Agendamento reaberto."); } function formAgendamento(id){ const profs=DB.profissionais.filter(p=>p.ativo!==false); if(!profs.length){ alert("Cadastre ao menos um profissional ativo antes de agendar."); return; } if(!DB.clientes.length){ alert("Cadastre ao menos um cliente antes de agendar."); return; } const a=id?JSON.parse(JSON.stringify(DB.atendimentos.find(x=>x.id===id))) :{data:estadoMes.agenda.data||hojeISO(),hora:"",duracao:60,profissionalId:"",clienteId:"",salaId:"",tipoAtendimentoId:"",tipoAtendimentoNome:"",valor:"",obs:"",status:"AGENDADO"}; if(a.status==="PAGO"){ alert("Atendimento concluído/pago não pode ser editado."); return; } const optProf=profs.map(p=>``).join(""); const optCli=DB.clientes.map(c=>``).join(""); const optSala=DB.salas.filter(s=>s.ativo!==false).map(s=>``).join(""); abrirModal(id?"Remarcar / editar agendamento":"Novo agendamento",`
`,()=>salvarAgendamento(id),"Salvar"); if(a.profissionalId) agdProfMudou(a.tipoAtendimentoId); atualizaLivres(); } function agdProfMudou(selTipoId){ const p=DB.profissionais.find(x=>x.id===document.getElementById("ag-prof").value); const sel=document.getElementById("ag-tipo"); const tipos=p?(p.tiposAtendimento||[]):[]; sel.innerHTML=''+tipos.map(t=>``).join(""); const g=document.getElementById("ag-grade"); if(p&&p.horarios&&p.horarios.length) g.textContent="Grade: "+p.horarios.map(h=>h.dia+" "+h.inicio+"-"+h.fim).join(" · "); else if(p) g.textContent="Sem grade cadastrada (agenda livre)."; else g.textContent=""; atualizaLivres(); if(selTipoId) agdTipoMudou(); } function agdTipoMudou(){ const sel=document.getElementById("ag-tipo"); const opt=sel.options[sel.selectedIndex]; if(opt&&opt.value&&opt.dataset&&opt.dataset.valor!==undefined) document.getElementById("ag-valor").value=opt.dataset.valor; } function salvarAgendamento(id){ const ex=id?DB.atendimentos.find(x=>x.id===id):null; const data=document.getElementById("ag-data").value, hora=document.getElementById("ag-hora").value, dur=parseInt(document.getElementById("ag-dur").value)||60; const pid=document.getElementById("ag-prof").value, cid=document.getElementById("ag-cli").value, sid=document.getElementById("ag-sala").value; if(!data||!hora){ toast("Informe data e hora."); return false; } if(!pid){ toast("Selecione o profissional."); return false; } if(!cid){ toast("Selecione o cliente."); return false; } if(!sid){ toast("Selecione a sala."); return false; } const tipoSel=document.getElementById("ag-tipo"); const tipoId=tipoSel.value; const tipoNome=tipoId?tipoSel.options[tipoSel.selectedIndex].textContent.split(" — ")[0]:""; const valor=parseFloat(document.getElementById("ag-valor").value)||0; const prof=DB.profissionais.find(x=>x.id===pid), sala=DB.salas.find(x=>x.id===sid); const atd={id:id||uid(),data,hora,duracao:dur,profissionalId:pid,clienteId:cid,salaId:sid,tipoAtendimentoId:tipoId,tipoAtendimentoNome:tipoNome,valor, formaPagamento:ex?(ex.formaPagamento||""):"",status:ex?ex.status:"AGENDADO",obs:document.getElementById("ag-obs").value.trim(),criadoEm:ex?ex.criadoEm:new Date().toISOString(),motivoCancelamento:ex?(ex.motivoCancelamento||""):"",criadoPor:ex?(ex.criadoPor||((usuarioAtual&&usuarioAtual.nome)||"")):((usuarioAtual&&usuarioAtual.nome)||"")}; if(!dentroRecepcaoAgora() && !confirm("Atenção: agora está fora do horário da recepção. Registrar mesmo assim?")) return false; if(!dentroHorarioProf(prof,data,hora,dur) && !confirm("O horário está fora da grade de "+prof.nome+". Agendar mesmo assim?")) return false; if(sala && sala.restricaoEsp && !salaPermiteProf(sala,prof) && !confirm(sala.nome+" é restrita a "+sala.restricaoEsp+" e "+prof.nome+" ("+(prof.especialidade||"?")+") não é dessa especialidade. Usar mesmo assim?")) return false; const conf=checarConflitos(atd,id); if(conf.sala){ alert("Conflito de SALA às "+conf.salaMsg+". Escolha outro horário ou sala."); return false; } if(conf.prof){ alert("Conflito de PROFISSIONAL às "+conf.profMsg+". Escolha outro horário."); return false; } if(id) Object.assign(ex,atd); else DB.atendimentos.push(atd); salvarDB(); abrirModulo(moduloAtual); toast("Agendamento salvo."); } function exportarAgenda(){ const lista=atdsDoPeriodo().slice().sort((a,b)=>(a.data+a.hora).localeCompare(b.data+b.hora)); if(!lista.length){ toast("Nada para exportar neste período."); return; } const linhas=[["Data","Hora","Duração(min)","Profissional","Especialidade","Cliente","Tipo","Sala","Valor","Forma pgto","Status","Motivo cancelamento","Registrado por"]]; lista.forEach(a=>{ const p=DB.profissionais.find(x=>x.id===a.profissionalId)||{},c=DB.clientes.find(x=>x.id===a.clienteId)||{},s=DB.salas.find(x=>x.id===a.salaId)||{}; linhas.push([dataBR(a.data),a.hora,a.duracao||60,p.nome||"",p.especialidade||"",c.nome||"",a.tipoAtendimentoNome||"",s.nome||"",Number(a.valor||0).toFixed(2),a.formaPagamento||"",a.status,a.motivoCancelamento||"",a.criadoPor||""]); }); baixarCSV("agenda-"+rotuloPeriodoAgenda().replace(/[^0-9a-zA-Z]/g,"_")+".csv",linhas); toast("Agenda exportada."); } /* ================= REGISTRO & CAIXA ================= */ function renderCaixa(){ const st=estadoMes.caixa; if(!st.tipo) st.tipo="mes"; let lista; if(st.tipo==="dia") lista=DB.atendimentos.filter(a=>a.data===st.data && a.status!=="CANCELADO" && a.status!=="FALTOU"); else { const comp=compDe("caixa"); lista=DB.atendimentos.filter(a=>(a.data||"").slice(0,7)===comp && a.status!=="CANCELADO" && a.status!=="FALTOU"); } lista=lista.slice().sort((a,b)=>(a.data+a.hora).localeCompare(b.data+b.hora)); const linhas=lista.map(a=>{ const p=DB.profissionais.find(x=>x.id===a.profissionalId)||{}, c=DB.clientes.find(x=>x.id===a.clienteId)||{}; let acoes=""; if(a.status==="AGENDADO") acoes=` `; else if(a.status==="REALIZADO") acoes=` `; else if(a.status==="PAGO") acoes=``; return `${dataBR(a.data)}${a.hora||"—"}${esc(c.nome||"—")} ${esc(p.nome||"—")}
${esc(a.tipoAtendimentoNome||"")} ${reais(a.valor)}${esc(a.formaPagamento||"—")}${pillStatus(a.status)}${acoes}`; }).join(""); const rot = st.tipo==="dia" ? dataBR(st.data) : (MESES[st.mes-1]+"/"+st.ano); document.getElementById("conteudo").innerHTML=`

💳 Registro & Caixa

${faixaMeses("caixa")}
${st.tipo==='dia'?`
`:''}
Exibindo ${rot} · ${lista.length} atendimento(s). O valor é recebido diretamente pelo profissional; a clínica registra para controle, comissões e relatórios. Fatura/Recibo internos, sem validade fiscal.
${linhas||``}
DataHoraClienteProfissional / TipoValorPagamentoStatus
Nenhum atendimento em ${rot}.
`; } function marcarRealizado(id){ DB.atendimentos.find(x=>x.id===id).status="REALIZADO"; salvarDB(); abrirModulo(moduloAtual); toast("Marcado como realizado."); } function registrarPagamento(id){ const a=DB.atendimentos.find(x=>x.id===id); abrirModal("Registrar pagamento",`

Confirme a forma de pagamento recebida pelo profissional.

`,()=>{ a.formaPagamento=document.getElementById("pg-forma").value; a.valor=parseFloat(document.getElementById("pg-valor").value)||a.valor; a.status="PAGO"; a.pagoEm=new Date().toISOString(); salvarDB(); renderCaixa(); toast("Pagamento registrado."); },"Confirmar pagamento"); } /* Fatura + Recibo na MESMA folha A4 (corta ao meio) */ function viaDocumento(a,c,p,cli,rotulo,via){ const reg=[p.registroTipo,p.registroNumero].filter(Boolean).join(" "); const logo=c.logoBase64?``:""; return `
${logo}

${esc(c.nomeFantasia)}

${esc(c.razaoSocial)} · CNPJ ${esc(c.cnpj)} · ${esc(c.telefone)}
${rotulo}
${via}
Profissional responsável: ${esc(p.nome||"")}${p.especialidade?" — "+esc(p.especialidade):""}${reg?" · Reg. "+esc(reg):""}
${rotulo==="RECIBO"&&a.formaPagamento?``:""}
Cliente${esc(cli.nome||"")}${cli.cpf?" — CPF "+esc(formatarCPF(cli.cpf)):""}
Data${dataBR(a.data)} ${a.hora||""}
Atendimento${esc(a.tipoAtendimentoNome||"Atendimento")}
Pagamento${esc(a.formaPagamento)}
Valor${reais(a.valor)}
${rotulo==="RECIBO"?"Recebido o valor acima, pago diretamente ao profissional responsável.":"Valor a pagar pelo atendimento acima, pago diretamente ao profissional responsável."}
${esc(p.nome||"Profissional responsável")}
Documento de controle interno. Não possui validade fiscal. A ${esc(c.nomeFantasia)} disponibiliza o espaço; o serviço é prestado e o valor recebido pelo profissional.
`; } function gerarDocumento(id){ const a=DB.atendimentos.find(x=>x.id===id), c=DB.config; const p=DB.profissionais.find(x=>x.id===a.profissionalId)||{}, cli=DB.clientes.find(x=>x.id===a.clienteId)||{}; const html=`Fatura/Recibo — ${esc(cli.nome||"")}
${viaDocumento(a,c,p,cli,"FATURA","VIA DO CLIENTE")}
✂ corte aqui
${viaDocumento(a,c,p,cli,"RECIBO","VIA DA CLÍNICA")} `; abrirImpressao(html); } /* ================= CONTAS A RECEBER ================= */ function jaRecebido(comp,profId,tipo){ return DB.recebimentos.find(r=>r.competencia===comp&&r.profissionalId===profId&&r.tipo===tipo); } function jaRecebidoAtend(atdId){ return DB.recebimentos.find(r=>r.atendimentoId===atdId&&r.tipo==="COMISSAO"); } function contratoCobreMes(p,comp){ if(!p.contrato||!p.contrato.inicio) return false; const fim=p.contrato.fim||"9999-12"; return comp>=p.contrato.inicio&&comp<=fim; } function renderReceber(){ const comp=compDe("receber"); const alugueis=DB.profissionais.filter(p=>p.cobranca&&p.cobranca.tipo==="FIXO" && contratoCobreMes(p,comp)); const linAlug=alugueis.map(p=>{ const rec=jaRecebido(comp,p.id,"ALUGUEL"); const dv=String(p.contrato.diaVenc||5).padStart(2,"0"); const atrasado=!rec && hojeISO()>(comp+"-"+dv); const acao=rec?`Recebido ${dataBR(rec.data)}` :``; const selo=atrasado?` VENCIDO`:""; return `${esc(p.nome)}venc. dia ${p.contrato.diaVenc||"—"}${selo}${reais(p.cobranca.valor)}${acao}`; }).join(""); const comiss=[]; DB.profissionais.filter(p=>p.cobranca&&p.cobranca.tipo==="PERCENTUAL").forEach(p=>{ DB.atendimentos.filter(a=>a.profissionalId===p.id && (a.status==="REALIZADO"||a.status==="PAGO") && (a.data||"").slice(0,7)===comp) .forEach(a=>comiss.push({a,p,valor:(Number(a.valor)||0)*(Number(p.cobranca.valor)||0)/100})); }); const linComiss=comiss.sort((x,y)=>(x.a.data+x.a.hora).localeCompare(y.a.data+y.a.hora)).map(({a,p,valor})=>{ const cli=DB.clientes.find(x=>x.id===a.clienteId)||{}; const rec=jaRecebidoAtend(a.id); const atrasado=!rec && (a.data||"") < hojeISO(); const acao=rec?`Recebido` :``+(atrasado?` VENCIDO`:""); return `${dataBR(a.data)}${esc(p.nome)} (${p.cobranca.valor}%)${esc(cli.nome||"—")}${esc(a.tipoAtendimentoNome||"")}${reais(a.valor)}${reais(valor)}${acao}`; }).join(""); const prevAlug=alugueis.reduce((s,p)=>s+(Number(p.cobranca.valor)||0),0); const prevCom=comiss.reduce((s,x)=>s+x.valor,0); const previsto=prevAlug+prevCom; const recebido=DB.recebimentos.filter(r=>r.competencia===comp).reduce((s,r)=>s+(Number(r.valor)||0),0); document.getElementById("conteudo").innerHTML=`

📥 Contas a Receber

${faixaMeses("receber")} ${ehAdmin()?`
${reais(previsto)}
Previsto (aluguéis + comissões)
${reais(recebido)}
Já recebido
${reais(previsto-recebido)}
A receber
`:""}

Aluguéis fixos do mês

${linAlug||``}
ProfissionalVencimentoValor
Nenhum aluguel neste mês.

Comissões por atendimento (percentual)

${linComiss||``}
DataProfissionalClienteAtendimentoValor consultaComissão
Nenhuma comissão a receber neste mês.
`; } function receberAluguel(comp,profId){ if(jaRecebido(comp,profId,"ALUGUEL")) return; const p=DB.profissionais.find(x=>x.id===profId)||{}; DB.recebimentos.push({id:uid(),competencia:comp,data:hojeISO(),profissionalId:profId,tipo:"ALUGUEL",descricao:"Aluguel "+mesRotulo(comp)+" — "+(p.nome||""),valor:Number(p.cobranca.valor)||0,registradoPor:(usuarioAtual&&usuarioAtual.nome)||""}); salvarDB(); renderReceber(); toast("Aluguel recebido."); } function receberComissao(atdId){ if(jaRecebidoAtend(atdId)) return; const a=DB.atendimentos.find(x=>x.id===atdId); const p=DB.profissionais.find(x=>x.id===a.profissionalId)||{}; const valor=(Number(a.valor)||0)*(Number(p.cobranca.valor)||0)/100; const cli=DB.clientes.find(x=>x.id===a.clienteId)||{}; DB.recebimentos.push({id:uid(),competencia:(a.data||"").slice(0,7),data:hojeISO(),profissionalId:a.profissionalId,atendimentoId:atdId,tipo:"COMISSAO",descricao:"Comissão "+(p.cobranca.valor)+"% — "+(p.nome||"")+" ("+(cli.nome||"")+" "+dataBR(a.data)+")",valor,registradoPor:(usuarioAtual&&usuarioAtual.nome)||""}); salvarDB(); renderReceber(); toast("Comissão recebida."); } /* ================= CONTAS A PAGAR / DESPESAS ================= */ function renderPagar(){ const desp=DB.despesas.slice().sort((a,b)=>(b.data||"").localeCompare(a.data||"")); const totAberto=desp.filter(d=>!d.pago).reduce((s,d)=>s+(Number(d.valor)||0),0); const linhas=desp.map(d=>`${dataBR(d.data)}${esc(d.categoria||"—")}${esc(d.descricao||"")}${reais(d.valor)} ${d.pago?'Pago':'Em aberto'} `).join(""); document.getElementById("conteudo").innerHTML=`

📤 Contas a Pagar

${ehAdmin()?`
${reais(totAberto)}
Total em aberto
`:""} ${linhas||``}
DataCategoriaDescriçãoValorSituação
Nenhuma despesa cadastrada.
`; } function formDespesa(id){ const d=id?DB.despesas.find(x=>x.id===id):{data:hojeISO(),categoria:"Água",descricao:"",valor:"",pago:false}; const cats=["Água","Energia","Limpeza","Salário/Atendente","Aluguel do imóvel","Material","Internet/Telefone","Manutenção","Impostos","Outros"]; abrirModal(id?"Editar despesa":"Nova despesa",`
`,()=>{ const valor=parseFloat(document.getElementById("d-valor").value)||0, data=document.getElementById("d-data").value; if(!data){ toast("Informe a data."); return false; } if(!valor){ toast("Informe o valor."); return false; } const dados={data,categoria:document.getElementById("d-cat").value,descricao:document.getElementById("d-desc").value.trim(),valor,pago:document.getElementById("d-pago").value==="true"}; if(id) Object.assign(DB.despesas.find(x=>x.id===id),dados); else DB.despesas.push(Object.assign({id:uid()},dados)); salvarDB(); abrirModulo(moduloAtual); toast("Despesa salva."); }); } function toggleDespesa(id){ const d=DB.despesas.find(x=>x.id===id); d.pago=!d.pago; salvarDB(); renderPagar(); } function excluirDespesa(id){ if(!confirm("Excluir esta despesa?")) return; DB.despesas=DB.despesas.filter(x=>x.id!==id); salvarDB(); renderPagar(); toast("Despesa excluída."); } /* ================= FLUXO DE CAIXA (com editar/excluir) ================= */ function movimentosMes(comp){ const ent=DB.recebimentos.filter(r=>(r.data||"").slice(0,7)===comp).map(r=>({kind:"R",id:r.id,data:r.data,desc:r.descricao||("Recebimento"),tipo:"E",valor:Number(r.valor)||0})); const sai=DB.despesas.filter(d=>d.pago&&(d.data||"").slice(0,7)===comp).map(d=>({kind:"D",id:d.id,data:d.data,desc:"Despesa — "+(d.categoria||"")+(d.descricao?" ("+d.descricao+")":""),tipo:"S",valor:Number(d.valor)||0})); return ent.concat(sai).sort((a,b)=>(a.data||"").localeCompare(b.data||"")); } function renderFluxo(){ const comp=compDe("fluxo"); const mov=movimentosMes(comp); const totE=mov.filter(m=>m.tipo==="E").reduce((s,x)=>s+x.valor,0), totS=mov.filter(m=>m.tipo==="S").reduce((s,x)=>s+x.valor,0); const linhas=mov.map(m=>`${dataBR(m.data)}${esc(m.desc)} ${m.tipo==="E"?"Entrada":"Saída"} ${m.tipo==="E"?"+":"−"} ${reais(m.valor)} `).join(""); document.getElementById("conteudo").innerHTML=`

📈 Fluxo de Caixa

${faixaMeses("fluxo")}
${reais(totE)}
Entradas
${reais(totS)}
Saídas
${reais(totE-totS)}
Saldo do mês
${linhas||``}
DataDescriçãoTipoValor
Sem movimentações no mês.
`; } function excluirLancamento(kind,id){ if(kind==="R"){ if(!confirm("Estornar/excluir este recebimento? Ele voltará a aparecer como 'a receber'.")) return; DB.recebimentos=DB.recebimentos.filter(x=>x.id!==id); } else { if(!confirm("Excluir esta despesa do caixa?")) return; DB.despesas=DB.despesas.filter(x=>x.id!==id); } salvarDB(); renderFluxo(); toast("Lançamento excluído."); } function editarLancamento(kind,id){ if(kind==="D"){ formDespesa(id); return; } const r=DB.recebimentos.find(x=>x.id===id); abrirModal("Editar recebimento",`
`,()=>{ r.data=document.getElementById("r-data").value; r.valor=parseFloat(document.getElementById("r-valor").value)||0; r.descricao=document.getElementById("r-desc").value.trim(); salvarDB(); renderFluxo(); toast("Recebimento atualizado."); }); } /* ================= RELATÓRIOS ================= */ let relProf=""; function semanaChave(dataStr){ if(!dataStr) return ""; const d=new Date(dataStr+"T00:00:00"); const day=(d.getDay()+6)%7; d.setDate(d.getDate()-day); return d.getFullYear()+"-"+String(d.getMonth()+1).padStart(2,"0")+"-"+String(d.getDate()).padStart(2,"0"); } function enquadraLabel(p){ return (p.cobranca&&p.cobranca.tipo==="FIXO")?("Mensalista "+reais(p.cobranca.valor)):("Percentual "+((p.cobranca&&p.cobranca.valor)||0)+"%"); } function devidoClinica(p,comp){ if(p.cobranca&&p.cobranca.tipo==="FIXO"){ return contratoCobreMes(p,comp)?(Number(p.cobranca.valor)||0):0; } const soma=DB.atendimentos.filter(a=>a.profissionalId===p.id&&(a.status==="REALIZADO"||a.status==="PAGO")&&(a.data||"").slice(0,7)===comp).reduce((s,a)=>s+(Number(a.valor)||0),0); return soma*((p.cobranca&&p.cobranca.valor)||0)/100; } let relTipo=""; function renderRelatorios(){ const comp=compDe("relatorios"); const atdsMesTodos=DB.atendimentos.filter(a=>a.status!=="CANCELADO" && a.status!=="FALTOU" && (a.data||"").slice(0,7)===comp); let atds=atdsMesTodos.slice(); if(relProf) atds=atds.filter(a=>a.profissionalId===relProf); if(relTipo) atds=atds.filter(a=>{ const p=DB.profissionais.find(x=>x.id===a.profissionalId); return p&&p.cobranca&&p.cobranca.tipo===relTipo; }); const porProf={}; atds.forEach(a=>{ const pid=a.profissionalId; if(!porProf[pid]) porProf[pid]={n:0,soma:0}; porProf[pid].n++; porProf[pid].soma+=Number(a.valor)||0; }); let previstoClinica=0,totDev=0,totRec=0,totAb=0; const linProf=Object.keys(porProf).map(pid=>{ const p=DB.profissionais.find(x=>x.id===pid)||{nome:"(desconhecido)",cobranca:{tipo:"",valor:0}}; const dev=devidoClinica(p,comp), rec=recebidoProfComp(pid,comp), ab=Math.max(0,dev-rec); previstoClinica+=dev; totDev+=dev; totRec+=rec; totAb+=ab; return {nome:p.nome,enq:enquadraLabel(p),n:porProf[pid].n,soma:porProf[pid].soma,dev,rec,ab}; }) .sort((a,b)=>a.nome.localeCompare(b.nome)) .map(r=>`${esc(r.nome)}${esc(r.enq)}${r.n}${reais(r.soma)}${reais(r.dev)}${reais(r.rec)}${reais(r.ab)}`).join(""); const totRecebido=DB.recebimentos.filter(r=>(r.data||"").slice(0,7)===comp).reduce((s,r)=>s+(Number(r.valor)||0),0); const totPago=DB.despesas.filter(d=>d.pago&&(d.data||"").slice(0,7)===comp).reduce((s,d)=>s+(Number(d.valor)||0),0); const semanas={}; DB.recebimentos.filter(r=>(r.data||"").slice(0,7)===comp).forEach(r=>{ const k=semanaChave(r.data); (semanas[k]=semanas[k]||{e:0,s:0}).e+=Number(r.valor)||0; }); DB.despesas.filter(d=>d.pago&&(d.data||"").slice(0,7)===comp).forEach(d=>{ const k=semanaChave(d.data); (semanas[k]=semanas[k]||{e:0,s:0}).s+=Number(d.valor)||0; }); const linSem=Object.keys(semanas).sort().map(k=>{ const w=semanas[k]; return `Semana de ${dataBR(k)}${reais(w.e)}${reais(w.s)}${reais(w.e-w.s)}`; }).join(""); let naoveio=DB.atendimentos.filter(a=>(a.status==="CANCELADO"||a.status==="FALTOU") && (a.data||"").slice(0,7)===comp); if(relProf) naoveio=naoveio.filter(a=>a.profissionalId===relProf); if(relTipo) naoveio=naoveio.filter(a=>{ const p=DB.profissionais.find(x=>x.id===a.profissionalId); return p&&p.cobranca&&p.cobranca.tipo===relTipo; }); const nFaltas=naoveio.filter(a=>a.status==="FALTOU").length, nCanc=naoveio.filter(a=>a.status==="CANCELADO").length; const linCancel=naoveio.sort((a,b)=>((a.data||"")+(a.hora||"")).localeCompare((b.data||"")+(b.hora||""))).map(a=>{ const cl=DB.clientes.find(x=>x.id===a.clienteId)||{}, pr=DB.profissionais.find(x=>x.id===a.profissionalId)||{}; const tp=a.status==="FALTOU"?'Falta':'Cancelamento'; return `${tp}${dataBR(a.data)} ${a.hora||""}${esc(cl.nome||"—")}${esc(pr.nome||"—")}${esc(a.motivoCancelamento||"—")}`; }).join(""); const ocup={}; atdsMesTodos.forEach(a=>{ const sid=a.salaId||"—"; if(!ocup[sid]) ocup[sid]={n:0,min:0}; ocup[sid].n++; ocup[sid].min+=(a.duracao||60); }); const linOcup=Object.keys(ocup).map(sid=>{ const sala=DB.salas.find(x=>x.id===sid)||{}; return {nome:sala.nome||sid,n:ocup[sid].n,h:ocup[sid].min/60}; }).sort((a,b)=>b.n-a.n).map(o=>`${esc(o.nome)}${o.n}${o.h.toFixed(1)} h`).join(""); const optProf=''+DB.profissionais.map(p=>``).join(""); const optTipo=''; document.getElementById("conteudo").innerHTML=`

📊 Relatórios

${faixaMeses("relatorios")}
Filtros combinam com o mês e valem para a exportação.
${atds.length}
Atendimentos no mês
${reais(previstoClinica)}
Devido à clínica (mês)
${reais(totRecebido)}
Recebido no mês
${reais(totPago)}
Pago (despesas)

Atendimentos por profissional

${linProf||``} ${linProf?``:""}
ProfissionalEnquadramentoValor movimentadoDevido no mêsJá recebidoEm aberto
Sem atendimentos no mês.
Totais${reais(totDev)}${reais(totRec)}${reais(totAb)}

"Devido no mês" = o que a clínica deveria receber (aluguel ou comissões). "Já recebido" = o que entrou. "Em aberto" = ainda a receber.

Faltas e cancelamentos — ${nFaltas} falta(s) · ${nCanc} cancelamento(s)

${linCancel||``}
TipoDataClienteProfissionalMotivo
Nenhuma falta ou cancelamento no mês.

Ocupação das salas

${linOcup||``}
SalaNº atend.Horas ocupadas
Sem atendimentos no mês.

Balancete semanal e mensal

${linSem||``}
SemanaEntradasSaídasSaldo
Sem movimentações no mês.
Total do mês${reais(totRecebido)}${reais(totPago)}${reais(totRecebido-totPago)}

Entradas/Saídas seguem a data do caixa (quando o dinheiro entrou/saiu), não a data da consulta.

`; } function linhasRelatorio(comp){ let atds=DB.atendimentos.filter(a=>a.status!=="CANCELADO" && a.status!=="FALTOU" && (a.data||"").slice(0,7)===comp); if(relProf) atds=atds.filter(a=>a.profissionalId===relProf); if(relTipo) atds=atds.filter(a=>{ const p=DB.profissionais.find(x=>x.id===a.profissionalId); return p&&p.cobranca&&p.cobranca.tipo===relTipo; }); atds.sort((a,b)=>(a.data+a.hora).localeCompare(b.data+b.hora)); const cab=["Data","Hora","Profissional","Especialidade","Enquadramento","Cliente","CPF","Atendimento","Valor consulta","Devido à clínica","Sala","Forma pgto","Status"]; const linhas=[cab]; atds.forEach(a=>{ const p=DB.profissionais.find(x=>x.id===a.profissionalId)||{}, c=DB.clientes.find(x=>x.id===a.clienteId)||{}, s=DB.salas.find(x=>x.id===a.salaId)||{}; const dev=(p.cobranca&&p.cobranca.tipo==="PERCENTUAL")?(Number(a.valor)||0)*(Number(p.cobranca.valor)||0)/100:0; linhas.push([dataBR(a.data),a.hora,p.nome||"",p.especialidade||"",enquadraLabel(p),c.nome||"",c.cpf?formatarCPF(c.cpf):"",a.tipoAtendimentoNome||"",Number(a.valor||0).toFixed(2),dev.toFixed(2),s.nome||"",a.formaPagamento||"",a.status]); }); return linhas; } function exportarRelatorio(){ const comp=compDe("relatorios"); const linhas=linhasRelatorio(comp); if(linhas.length<=1){ toast("Sem atendimentos para exportar."); return; } const suf=relProf?("-"+((DB.profissionais.find(p=>p.id===relProf)||{}).nome||"").replace(/[^0-9a-zA-Z]/g,"_")):"-geral"; baixarCSV("relatorio-"+comp+suf+".csv",linhas); toast("Relatório exportado."); } function imprimirRelatorio(){ const comp=compDe("relatorios"); const linhas=linhasRelatorio(comp); const c=DB.config; const th=linhas[0].map(h=>`${esc(h)}`).join(""); const tr=linhas.slice(1).map(r=>`${r.map(x=>`${esc(x)}`).join("")}`).join(""); const titProf=relProf?(" — "+esc((DB.profissionais.find(p=>p.id===relProf)||{}).nome)):" — geral"; const html=`Relatório ${comp}

${esc(c.nomeFantasia)}

Relatório de atendimentos — ${mesRotulo(comp)}${titProf} · CNPJ ${esc(c.cnpj)}
${th}${tr}
`; abrirImpressao(html); } /* ================= BACKUP ================= */ function renderBackup(){ const resumo=`${DB.salas.length} salas · ${DB.profissionais.length} profissionais · ${DB.clientes.length} clientes · ${DB.atendimentos.length} atendimentos`; const ub=DB.meta.ultimoBackup; const okHoje=ub===hojeISO(); document.getElementById("conteudo").innerHTML=`

💾 Backup

${okHoje?`
✅ Backup de hoje já feito (${dataBR(ub)}).
`:`
Backup de hoje ainda não foi feito. Último: ${ub?dataBR(ub):"nunca"}. Recomendado salvar na pasta do Google Drive para ir para a nuvem automaticamente.
`}

Exportar

Salva um arquivo .json com TODOS os dados. Ao salvar, escolha uma pasta dentro do Google Drive (ex.: G:/Meu Drive) — o Drive sobe para a nuvem sozinho.

Conteúdo atual: ${resumo}

Importar

Carrega um arquivo .json exportado antes. Substitui todos os dados atuais.

`; } async function exportarBackup(){ const ok=await salvarArquivo("backup-clinica-jlo-"+hojeISO()+".json", new Blob([JSON.stringify(DB,null,2)],{type:"application/json"})); if(ok!==false){ DB.meta.ultimoBackup=hojeISO(); salvarDB(); if(moduloAtual==="backup") renderBackup(); if(moduloAtual==="inicio") renderInicio(); } } function importarBackup(){ const arq=document.getElementById("arq-import").files[0]; if(!arq){ toast("Selecione um arquivo .json."); return; } const l=new FileReader(); l.onload=e=>{ try{ const dados=JSON.parse(e.target.result); if(!dados.salas||!dados.config) throw new Error("Arquivo não parece um backup válido."); if(!confirm("Importar irá SUBSTITUIR todos os dados atuais. Continuar?")) return; DB=normalizaDB(Object.assign(estruturaInicial(),dados)); salvarDB(); pintarTextos(); toast("Backup importado."); abrirModulo(moduloAtual); }catch(err){ alert("Falha ao importar: "+err.message); } }; l.readAsText(arq); } /* ================= CONFIGURAÇÃO ================= */ function renderConfig(){ const c=DB.config; document.getElementById("conteudo").innerHTML=`

⚙️ Configuração

Logomarca

${logoHTML()}
Aparece no cabeçalho e nos impressos.
${c.logoBase64?``:``}

Dados da clínica

Aparece só na tela do sistema. Não é usado em contratos, faturas nem recibos.

Horário da recepção

Usuários e acessos

Cada pessoa acessa com o próprio usuário. Atendentes não veem somatórios/totais, nem Fluxo de Caixa e Relatórios. Só o administrador vê tudo. (Separação de telas — não é segurança real.)

${DB.usuarios.map(u=>``).join("")}
NomePapel
${esc(u.nome)}${u.papel==='admin'?'Administrador':'Atendente'}
`; document.getElementById("cfg-logo").addEventListener("change",function(){ const f=this.files[0]; if(!f) return; const r=new FileReader(); r.onload=e=>{ DB.config.logoBase64=e.target.result; salvarDB(); pintarTextos(); renderConfig(); toast("Logo atualizada."); }; r.readAsDataURL(f); }); } function removerLogo(){ DB.config.logoBase64=""; salvarDB(); pintarTextos(); renderConfig(); toast("Logo removida."); } function salvarConfig(){ const c=DB.config; c.tituloApp=document.getElementById("cfg-titapp").value.trim(); c.nomeFantasia=document.getElementById("cfg-nome").value.trim(); c.razaoSocial=document.getElementById("cfg-razao").value.trim(); c.cnpj=document.getElementById("cfg-cnpj").value.trim(); c.atividade=document.getElementById("cfg-ativ").value.trim(); c.organizacao=document.getElementById("cfg-org").value.trim(); c.titular=document.getElementById("cfg-tit").value.trim(); c.endereco=document.getElementById("cfg-end").value.trim(); c.telefone=document.getElementById("cfg-tel").value.trim(); c.slogan=document.getElementById("cfg-slogan").value.trim(); c.recepcao={manhaInicio:document.getElementById("cfg-mi").value,manhaFim:document.getElementById("cfg-mf").value,tardeInicio:document.getElementById("cfg-ti").value,tardeFim:document.getElementById("cfg-tf").value}; salvarDB(); pintarTextos(); toast("Configuração salva."); } /* ================= EXTRAS (pendências, WhatsApp, horários livres, avisos) ================= */ function recebidoProfComp(profId,comp){ return DB.recebimentos.filter(r=>r.profissionalId===profId && r.competencia===comp).reduce((s,r)=>s+(Number(r.valor)||0),0); } function mesesEntre(ini,fim){ const out=[]; if(!ini) return out; let a=ini.split("-").map(Number),y=a[0],m=a[1]; const fa=(fim||ini).split("-").map(Number),fy=fa[0],fm=fa[1]; while(y12){m=1;y++;} if(out.length>72) break; } return out; } function diasAtraso(vencISO){ if(!vencISO) return 0; return Math.floor((new Date(hojeISO())-new Date(vencISO))/86400000); } function precisaBackup(){ return DB.meta.ultimoBackup!==hojeISO(); } function contratosVencendo(){ const m=mesAtualISO(); const a=m.split("-").map(Number); let ny=a[0],nm=a[1]+1; if(nm>12){nm=1;ny++;} const prox=ny+"-"+String(nm).padStart(2,"0"); return DB.profissionais.filter(p=>p.cobranca&&p.cobranca.tipo==="FIXO"&&p.contrato&&p.contrato.fim&&(p.contrato.fim===m||p.contrato.fim===prox)); } /* ---- PENDÊNCIAS DE REPASSE (admin) ---- */ function renderPendencias(){ const mesAtual=mesAtualISO(); const grupos=[]; DB.profissionais.forEach(p=>{ const itens=[]; if(p.cobranca && p.cobranca.tipo==="FIXO" && p.contrato && p.contrato.inicio){ const fimC=(p.contrato.fim && p.contrato.fim{ if(!jaRecebido(comp,p.id,"ALUGUEL")){ const dv=String(p.contrato.diaVenc||5).padStart(2,"0"); const venc=comp+"-"+dv; itens.push({tipo:"Aluguel",ref:mesRotulo(comp),valor:Number(p.cobranca.valor)||0,venc,atraso:diasAtraso(venc)}); } }); } else if(p.cobranca && p.cobranca.tipo==="PERCENTUAL"){ DB.atendimentos.filter(a=>a.profissionalId===p.id && (a.status==="REALIZADO"||a.status==="PAGO") && !jaRecebidoAtend(a.id)).forEach(a=>{ const cl=DB.clientes.find(x=>x.id===a.clienteId)||{}; itens.push({tipo:"Comissão "+p.cobranca.valor+"%",ref:(cl.nome||"")+" — "+dataBR(a.data),valor:(Number(a.valor)||0)*(Number(p.cobranca.valor)||0)/100,venc:a.data,atraso:diasAtraso(a.data)}); }); } if(itens.length){ grupos.push({p,itens,total:itens.reduce((s,i)=>s+i.valor,0)}); } }); grupos.sort((x,y)=>y.total-x.total); const totalGeral=grupos.reduce((s,g)=>s+g.total,0); const blocos=grupos.map(g=>`
${esc(g.p.nome)} ${esc(g.p.especialidade||"")}${reais(g.total)} · ${g.itens.length} item(ns)
${g.itens.sort((a,b)=>b.atraso-a.atraso).map(i=>``).join("")}
TipoReferênciaVencimentoAtrasoValor
${esc(i.tipo)}${esc(i.ref)}${dataBR(i.venc)}${i.atraso>0?`${i.atraso} dia(s)`:'em dia'}${reais(i.valor)}
`).join(""); document.getElementById("conteudo").innerHTML=`

⏰ Pendências de repasse

${reais(totalGeral)}
Total pendente (todos os meses)
${grupos.length}
Profissionais com pendência

Tudo que ainda não foi recebido pela clínica (aluguéis e comissões), somando todos os meses. Para dar baixa, use o botão Receber em Contas a Receber.

${blocos||`
Nenhuma pendência — tudo em dia! 🎉
`}`; } /* ---- WHATSAPP ---- */ function fmtWpp(t){ const d=soDigitos(t); if(!d) return ""; return d.length<=11?("55"+d):d; } function msgWpp(a,modelo){ const c=DB.clientes.find(x=>x.id===a.clienteId)||{}, p=DB.profissionais.find(x=>x.id===a.profissionalId)||{}, cfg=DB.config; const nome=(c.nome||"").split(" ")[0]; if(modelo==="hoje") return "Bom dia, "+nome+"! Hoje é seu atendimento com "+(p.nome||"")+" às "+a.hora+", na "+cfg.nomeFantasia+" ("+cfg.endereco+"). Estamos te esperando! Qualquer imprevisto, é só avisar por aqui. 😊"; return "Olá, "+nome+"! 👋 Aqui é da "+cfg.nomeFantasia+". Passando para lembrar do seu atendimento com "+(p.nome||"")+(p.especialidade?" ("+p.especialidade+")":"")+" no dia "+dataBR(a.data)+" às "+a.hora+".\n📍 "+cfg.endereco+"\nPodemos confirmar sua presença? Se precisar remarcar, é só responder por aqui. Até breve! 🙂"; } function abrirWpp(id,modelo){ const a=DB.atendimentos.find(x=>x.id===id); const c=DB.clientes.find(x=>x.id===a.clienteId)||{}; const fone=fmtWpp(c.telefone); window.open("https://wa.me/"+(fone||"")+"?text="+encodeURIComponent(msgWpp(a,modelo)),"_blank"); } function whatsappAtendimento(id){ const a=DB.atendimentos.find(x=>x.id===id); const c=DB.clientes.find(x=>x.id===a.clienteId)||{}; abrirModal("Enviar lembrete por WhatsApp",`

Cliente: ${esc(c.nome||"—")}${c.telefone?" · "+esc(c.telefone):' · sem telefone no cadastro'}

Lembrete da véspera
${esc(msgWpp(a,"vespera"))}
Lembrete no dia
${esc(msgWpp(a,"hoje"))}
`,()=>true,"Fechar"); } /* ---- HORÁRIOS LIVRES (sugestão no agendamento) ---- */ function horariosLivres(prof,data,dur,salaId){ if(!prof||!prof.horarios||!prof.horarios.length) return null; const dia=diaSemanaDe(data), passo=dur||60, livres=[]; prof.horarios.filter(h=>h.dia===dia).forEach(h=>{ let t=hmMin(h.inicio); const fj=hmMin(h.fim); while(t+passo<=fj){ const hora=String(Math.floor(t/60)).padStart(2,"0")+":"+String(t%60).padStart(2,"0"); const conf=checarConflitos({data,hora,duracao:passo,salaId:salaId||"",profissionalId:prof.id},null); if(!conf.prof && (!salaId||!conf.sala)) livres.push(hora); t+=passo; } }); return livres; } function atualizaLivres(){ const box=document.getElementById("ag-livres"); if(!box) return; const prof=DB.profissionais.find(x=>x.id===document.getElementById("ag-prof").value); const data=document.getElementById("ag-data").value, dur=parseInt(document.getElementById("ag-dur").value)||60, sid=document.getElementById("ag-sala").value; if(!prof||!data){ box.innerHTML=""; return; } const l=horariosLivres(prof,data,dur,sid); if(l===null){ box.innerHTML='Sem grade cadastrada — horário livre.'; return; } if(!l.length){ box.innerHTML='Sem horários livres nesse dia para este profissional/sala.'; return; } box.innerHTML="Horários livres: "+l.map(h=>``).join(""); } /* ================= LOGIN / USUÁRIOS ================= */ let usuarioAtual=null; function ehAdmin(){ return !!usuarioAtual && usuarioAtual.papel==="admin"; } const RESTRITO_ATENDENTE=["fluxo","pendencias","relatorios","config"]; function moduloPermitido(id){ if(ehAdmin()) return true; return RESTRITO_ATENDENTE.indexOf(id)===-1; } function salaPermiteProf(sala,prof){ if(!sala||!sala.restricaoEsp) return true; const a=(sala.restricaoEsp||"").toLowerCase().trim(), b=((prof&&prof.especialidade)||"").toLowerCase().trim(); return !!b && (b.indexOf(a)!==-1 || a.indexOf(b)!==-1); } let _unsub=null, _sessaoIniciada=false; function traduzErroAuth(c){ return ({"auth/invalid-email":"E-mail inválido.","auth/user-not-found":"Usuário não encontrado.","auth/wrong-password":"Senha incorreta.","auth/invalid-credential":"E-mail ou senha incorretos.","auth/missing-password":"Digite a senha.","auth/too-many-requests":"Muitas tentativas. Aguarde e tente de novo.","auth/network-request-failed":"Sem conexão com a internet."})[c]||("Não foi possível entrar."+(c?" ("+c+")":"")); } function toggleSenha(){var i=document.getElementById("login-senha"),b=document.getElementById("btn-olho");if(!i)return;if(i.type==="password"){i.type="text";if(b){b.textContent="🙈";b.setAttribute("aria-label","Ocultar senha");b.title="Ocultar senha";}}else{i.type="password";if(b){b.textContent="👁️";b.setAttribute("aria-label","Mostrar senha");b.title="Mostrar senha";}}} function entrar(){ const email=document.getElementById("login-email").value.trim(), senha=document.getElementById("login-senha").value, erro=document.getElementById("login-erro"); if(!email||!senha){ erro.textContent="Informe e-mail e senha."; return; } erro.textContent="Entrando..."; fbAuth.signInWithEmailAndPassword(email,senha).then(function(){ erro.textContent=""; document.getElementById("login-senha").value=""; }).catch(function(e){ erro.textContent=traduzErroAuth(e&&e.code); }); } function sair(){ fbAuth.signOut(); } function mostrarLogin(){ if(_unsub){ _unsub(); _unsub=null; } usuarioAtual=null; _sessaoIniciada=false; moduloAtual=null; document.getElementById("app").style.display="none"; document.getElementById("tela-login").style.display="flex"; document.getElementById("login-senha").value=""; document.getElementById("login-erro").textContent=""; pintarTextos(); } function definirUsuario(user){ const email=(user.email||"").toLowerCase(); if(!Array.isArray(DB.usuarios)) DB.usuarios=[]; let u=DB.usuarios.find(x=>(x.email||"").toLowerCase()===email); if(!u){ const temAdmin=DB.usuarios.some(x=>x.papel==="admin"); u={id:uid(),email:user.email,nome:(user.email||"").split("@")[0],papel:temAdmin?"atendente":"admin"}; DB.usuarios.push(u); salvarDB(); } usuarioAtual={uid:user.uid,email:user.email,nome:u.nome,papel:u.papel}; } function iniciarSessao(user){ _unsub=DOC.onSnapshot(function(snap){ if(!snap.exists){ DB=estruturaInicial(); DOC.set({payload:JSON.stringify(DB)}).catch(function(){}); return; } try{ DB=normalizaDB(JSON.parse((snap.data()&&snap.data().payload)||"{}")); }catch(e){ DB=estruturaInicial(); } definirUsuario(user); if(!_sessaoIniciada){ _sessaoIniciada=true; document.getElementById("tela-login").style.display="none"; document.getElementById("app").style.display="flex"; document.getElementById("topo-user").textContent=(usuarioAtual.nome||user.email)+(ehAdmin()?" · Admin":" · Atendente"); pintarTextos(); montarMenu(); abrirModulo("inicio"); } else if(!snap.metadata.hasPendingWrites){ pintarTextos(); montarMenu(); abrirModulo(moduloAtual||"inicio"); } }, function(err){ const erro=document.getElementById("login-erro"); if(erro) erro.textContent="Erro ao acessar os dados: "+(err&&err.code||""); }); } function onAuthMudou(user){ if(user) iniciarSessao(user); else mostrarLogin(); } function appSecundario(){ try{ return firebase.app("sec"); }catch(e){ return firebase.initializeApp(firebaseConfig,"sec"); } } function formUsuario(id){ const u=id?DB.usuarios.find(x=>x.id===id):{nome:"",email:"",papel:"atendente"}; const novo=!id; abrirModal(id?"Editar usuário":"Novo usuário / criar login",`
${novo?`
`:''}
${novo?"Isto cria o login (e-mail + senha) na hora e já liga ao papel — a Cyda continua conectada. O e-mail pode ser fictício (não precisa ser caixa real), só serve para o login.":"Para trocar a senha de alguém, recrie o login ou use o Firebase. Aqui você ajusta nome e papel."}
`,()=>{ const nome=document.getElementById("u-nome").value.trim(), papel=document.getElementById("u-papel").value; if(!nome){ toast("Informe o nome."); return false; } if(id){ if(DB.usuarios.find(x=>x.id===id).papel==="admin" && papel!=="admin" && DB.usuarios.filter(u=>u.papel==="admin").length<=1){ toast("Deve haver ao menos um administrador."); return false; } Object.assign(DB.usuarios.find(x=>x.id===id),{nome,papel}); salvarDB(); renderConfig(); toast("Usuário salvo."); return true; } const email=document.getElementById("u-email").value.trim().toLowerCase(), senha=document.getElementById("u-senha").value; if(!email){ toast("Informe o e-mail."); return false; } if(!senha||senha.length<6){ toast("Senha de no mínimo 6 caracteres."); return false; } if(DB.usuarios.some(x=>(x.email||"").toLowerCase()===email)){ toast("Esse e-mail já está cadastrado."); return false; } toast("Criando login..."); appSecundario().auth().createUserWithEmailAndPassword(email,senha).then(function(){ try{ appSecundario().auth().signOut(); }catch(e){} DB.usuarios.push({id:uid(),nome,email,papel}); salvarDB(); renderConfig(); toast("Login criado com sucesso."); fecharModal(); }).catch(function(e){ toast(traduzErroAuth(e&&e.code)); }); return false; }, id?"Salvar":"Criar login"); } function excluirUsuario(id){ const alvo=DB.usuarios.find(u=>u.id===id); if(alvo&&alvo.papel==="admin"&&DB.usuarios.filter(u=>u.papel==="admin").length<=1){ alert("Deve haver ao menos um administrador."); return; } if(DB.usuarios.length<=1){ alert("Deve haver ao menos um usuário."); return; } if(!confirm("Excluir este usuário?")) return; DB.usuarios=DB.usuarios.filter(x=>x.id!==id); salvarDB(); renderConfig(); toast("Usuário excluído."); } /* ================= INICIALIZAÇÃO ================= */ pintarTextos(); fbAuth.onAuthStateChanged(onAuthMudou);