This commit is contained in:
isaac kauã 2025-08-16 07:56:07 -03:00
commit f44a0ce81c
4 changed files with 2152 additions and 0 deletions

564
Cadastro.html Normal file
View File

@ -0,0 +1,564 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Cadastro/Edição de Paciente</title>
<style>
:root{
--bg:#0b1221;--panel:#0f172a;--muted:#111827;--border:#263244;--text:#e5e7eb;--dim:#9ca3af;--accent:#22d3ee;--accent2:#60a5fa;--danger:#ef4444;--ok:#22c55e
}
*{box-sizing:border-box}
html,body{height:100%}
body{margin:0;background:linear-gradient(180deg,#08101e,var(--bg));color:var(--text);font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial}
.container{max-width:1100px;margin:24px auto;padding:0 16px}
header{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:16px}
.title{font-weight:800;font-size:clamp(18px,3.6vw,28px)}
.subtitle{color:var(--dim);font-size:14px}
.card{background:rgba(17,24,39,.7);border:1px solid var(--border);border-radius:16px;padding:16px;margin-bottom:12px;backdrop-filter:blur(6px)}
.row{display:grid;grid-template-columns:repeat(12,1fr);gap:12px}
.col-12{grid-column:span 12}.col-9{grid-column:span 9}.col-8{grid-column:span 8}.col-6{grid-column:span 6}.col-4{grid-column:span 4}.col-3{grid-column:span 3}.col-2{grid-column:span 2}
label{display:block;color:var(--dim);font-size:12px;margin:2px 0 6px}
input[type=text],input[type=date],input[type=datetime-local],input[type=number],input[type=email],input[type=tel],select,textarea{width:100%;border:1px solid var(--border);border-radius:12px;background:#0b1221;color:var(--text);padding:10px 12px}
textarea{resize:vertical}
.btn{border:none;border-radius:12px;padding:10px 14px;font-weight:700;cursor:pointer}
.btn.primary{background:linear-gradient(180deg,var(--accent),var(--accent2));color:#04121c;box-shadow:0 10px 24px rgba(34,211,238,.2)}
.btn.secondary{background:#0b1221;border:1px solid var(--border);color:var(--text)}
.btn.ghost{background:transparent;border:1px dashed var(--border);color:var(--text)}
.btn.danger{background:var(--danger);color:white}
.toolbar{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
.switch{display:inline-flex;align-items:center;gap:8px}
.hint{color:var(--dim);font-size:12px}
.thumb{width:96px;height:96px;border-radius:12px;border:1px dashed var(--border);display:flex;align-items:center;justify-content:center;overflow:hidden;background:#0b1221}
.thumb img{width:100%;height:100%;object-fit:cover}
.collapse{border-radius:16px;overflow:hidden;border:1px solid var(--border);margin-bottom:12px}
.collapse summary{list-style:none;cursor:pointer;padding:12px 16px;background:#0b1221;color:var(--text);display:flex;align-items:center;justify-content:space-between}
.collapse summary::-webkit-details-marker{display:none}
.collapse .content{padding:16px;background:rgba(17,24,39,.7)}
.tag{display:inline-flex;gap:6px;align-items:center;border:1px solid var(--border);border-radius:999px;padding:6px 10px;font-size:12px;color:var(--dim)}
.table{width:100%;border-collapse:collapse}
.table th,.table td{padding:10px;border-bottom:1px solid var(--border);text-align:left}
.right{display:flex;justify-content:flex-end;gap:8px}
.toast{position:fixed;right:16px;bottom:16px;background:#0b1221;border:1px solid var(--border);padding:12px 16px;border-radius:12px;box-shadow:0 12px 30px rgba(0,0,0,.35);display:none}
.error{color:#fecaca}
.ok{color:#bbf7d0}
@media(max-width:920px){.col-9{grid-column:span 12}.col-8{grid-column:span 12}.col-6{grid-column:span 12}.col-4{grid-column:span 12}.col-3{grid-column:span 12}.col-2{grid-column:span 12}}
</style>
</head>
<body>
<div class="container">
<header>
<div>
<div class="title">Formulário de Paciente</div>
<div class="subtitle">Cadastro e edição com validações, anexos, máscara de telefone e busca de CEP</div>
</div>
<div class="toolbar">
<button class="btn secondary" id="btnCancelar">Cancelar</button>
<button class="btn primary" id="btnSalvar">Salvar</button>
</div>
</header>
<!-- DADOS PESSOAIS -->
<section class="card" aria-labelledby="secDadosPessoais">
<h2 id="secDadosPessoais" style="margin:0 0 8px">1. Dados pessoais</h2>
<div class="row">
<div class="col-3">
<label>Foto</label>
<div class="row" style="align-items:end">
<div class="thumb" id="fotoPreview" aria-label="Miniatura da foto">📷</div>
<div class="col-12" style="margin-top:8px">
<input type="file" id="fotoInput" accept="image/*" />
<div class="hint">Clique em Carregar e selecione uma imagem</div>
</div>
</div>
</div>
<div class="col-9">
<div class="row">
<div class="col-8">
<label>Nome <span class="error">*</span></label>
<input type="text" id="nome" required />
</div>
<div class="col-4">
<label>Nome social</label>
<input type="text" id="nomeSocial" />
</div>
<div class="col-3">
<label>CPF</label>
<input type="text" id="cpf" inputmode="numeric" placeholder="000.000.000-00" />
</div>
<div class="col-3">
<label>RG</label>
<input type="text" id="rg" />
</div>
<div class="col-3">
<label>Outros documentos</label>
<select id="docTipo">
<option value="">Selecionar…</option>
<option>CNH</option>
<option>Passaporte</option>
<option>RNE</option>
<option>CRM</option>
</select>
</div>
<div class="col-3">
<label>Número do documento</label>
<input type="text" id="docNumero" placeholder="Preencha após escolher o tipo" />
</div>
<div class="col-4">
<label>Sexo</label>
<div class="switch">
<label><input type="radio" name="sexo" value="M"> Masculino</label>
<label><input type="radio" name="sexo" value="F"> Feminino</label>
<label><input type="radio" name="sexo" value="O"> Outro</label>
</div>
</div>
<div class="col-4">
<label>Data de nascimento</label>
<input type="date" id="nascimento" />
</div>
<div class="col-4">
<label>Estado civil</label>
<select id="estadoCivil">
<option value="">Selecionar…</option>
<option>Solteiro(a)</option>
<option>Casado(a)</option>
<option>Divorciado(a)</option>
<option>Viúvo(a)</option>
<option>União estável</option>
</select>
</div>
<div class="col-4">
<label>Raça/Cor (IBGE)</label>
<select id="racaIbge">
<option value="">Selecionar…</option>
<option>Branca</option>
<option>Preta</option>
<option>Parda</option>
<option>Amarela</option>
<option>Indígena</option>
<option>Não declarada</option>
</select>
</div>
<div class="col-4">
<label>Etnia (se indígena)</label>
<input type="text" id="etniaIbge" placeholder="Povo / Etnia" disabled />
</div>
<div class="col-4">
<label>Profissão</label>
<input type="text" id="profissao" />
</div>
<div class="col-4">
<label>Naturalidade (Cidade/UF)</label>
<input type="text" id="naturalidade" placeholder="Ex.: Maceió/AL" />
</div>
<div class="col-4">
<label>Nacionalidade</label>
<select id="nacionalidade">
<option>Brasil</option>
<option>Argentina</option>
<option>Portugal</option>
<option>Estados Unidos</option>
<option>Outra</option>
</select>
</div>
<div class="col-4">
<label>Nome da mãe</label>
<input type="text" id="mae" />
</div>
<div class="col-4">
<label>Profissão da mãe</label>
<input type="text" id="profMae" />
</div>
<div class="col-4">
<label>Nome do pai</label>
<input type="text" id="pai" />
</div>
<div class="col-4">
<label>Profissão do pai</label>
<input type="text" id="profPai" />
</div>
<div class="col-4">
<label>Nome do responsável</label>
<input type="text" id="responsavel" />
</div>
<div class="col-4">
<label>CPF do responsável</label>
<input type="text" id="cpfResponsavel" placeholder="000.000.000-00" />
</div>
<div class="col-4">
<label>Nome do(a) esposo(a) (opcional)</label>
<input type="text" id="conjuge" />
</div>
<div class="col-4">
<label>RN na Guia do convênio</label>
<label class="switch"><input type="checkbox" id="rnConvenio"/> <span>Sim</span></label>
</div>
<div class="col-4">
<label>Código legado</label>
<input type="text" id="codigoLegado" />
</div>
</div>
</div>
</div>
</section>
<!-- OBSERVAÇÕES E ANEXOS -->
<details class="collapse" open>
<summary>
<span>2. Observações e anexos</span>
<span class="hint">Clique para expandir/retrair</span>
</summary>
<div class="content">
<div class="row">
<div class="col-12">
<label>Observações</label>
<textarea id="observacoes" rows="4" placeholder="Alergias, restrições, uso de medicamentos, etc."></textarea>
</div>
<div class="col-12">
<div class="toolbar" style="justify-content:space-between">
<div class="tag">📎 Anexos do paciente</div>
<div>
<input type="file" id="anexoInput" multiple />
<button class="btn secondary" id="btnAddAnexo">Adicionar</button>
</div>
</div>
<div style="overflow:auto;border:1px solid var(--border);border-radius:12px;margin-top:8px">
<table class="table" id="anexosTable" aria-label="Lista de anexos">
<thead>
<tr><th>Nome</th><th>Tamanho</th><th>Data</th><th style="width:1%"></th></tr>
</thead>
<tbody id="anexosBody"></tbody>
</table>
</div>
<div class="hint">Arquivos ficam somente no navegador (localStorage) para este exemplo.</div>
</div>
</div>
</div>
</details>
<!-- CONTATO -->
<section class="card" aria-labelledby="secContato">
<h2 id="secContato" style="margin:0 0 8px">3. Contato</h2>
<div class="row">
<div class="col-6">
<label>E-mail</label>
<input type="email" id="email" placeholder="nome@dominio.com" />
</div>
<div class="col-6">
<label>Celular</label>
<input type="tel" id="celular" placeholder="+55 (82) 98888-8888" />
</div>
<div class="col-6">
<label>Telefone 1</label>
<input type="tel" id="tel1" placeholder="(82) 3333-3333" />
</div>
<div class="col-6">
<label>Telefone 2</label>
<input type="tel" id="tel2" placeholder="(82) 3444-4444" />
</div>
</div>
</section>
<!-- ENDEREÇO -->
<details class="collapse" open>
<summary>
<span>4. Endereço</span>
<span class="hint">Com busca de CEP automática</span>
</summary>
<div class="content">
<div class="row">
<div class="col-3">
<label>CEP</label>
<input type="text" id="cep" placeholder="00000-000" inputmode="numeric" />
</div>
<div class="col-7">
<label>Logradouro</label>
<input type="text" id="logradouro" />
</div>
<div class="col-2">
<label>Número</label>
<input type="text" id="numero" />
</div>
<div class="col-4">
<label>Complemento</label>
<input type="text" id="complemento" />
</div>
<div class="col-4">
<label>Bairro</label>
<input type="text" id="bairro" />
</div>
<div class="col-3">
<label>Cidade</label>
<input type="text" id="cidade" />
</div>
<div class="col-1">
<label>UF</label>
<input type="text" id="uf" maxlength="2" />
</div>
<div class="col-12">
<label>Referência</label>
<input type="text" id="referencia" placeholder="Ponto de referência (opcional)" />
</div>
</div>
</div>
</details>
<div class="right" style="margin-top:16px">
<button class="btn secondary" id="btnCancelar2">Cancelar</button>
<button class="btn primary" id="btnSalvar2">Salvar</button>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
// ===== Utilidades =====
const fmtBytes = (n)=>{
if (n<1024) return n+" B"; if (n<1024**2) return (n/1024).toFixed(1)+" KB"; if (n<1024**3) return (n/1024**2).toFixed(1)+" MB"; return (n/1024**3).toFixed(1)+" GB";
}
const showToast=(msg,type='ok')=>{ const t=document.getElementById('toast'); t.textContent=msg; t.style.display='block'; t.className = 'toast '+type; setTimeout(()=>t.style.display='none', 2600); }
// Máscaras simples
const onlyDigits = (s)=> (s||'').replace(/\D/g,'');
const maskCPF = (s)=>{
const d=onlyDigits(s).slice(0,11);
return d.replace(/(\d{3})(\d)/,'$1.$2').replace(/(\d{3})(\d)/,'$1.$2').replace(/(\d{3})(\d{1,2})$/,'$1-$2');
}
const maskCEP = s=>{
const d=onlyDigits(s).slice(0,8);
return d.replace(/(\d{5})(\d{0,3})/,'$1-$2');
}
const maskPhoneBR = s=>{
const d=onlyDigits(s).slice(0,13); // inclui 55
// +55 (82) 98888-8888 ou (82) 3333-3333
let out = d;
if (out.startsWith('55')) out = '+'+out.slice(0,2)+' '+out.slice(2);
out = out.replace(/^(\+55)\s?(\d{0,2})/, (m,p1,ddd)=> p1 + (ddd?` (${ddd}) `:' '));
const rest = onlyDigits(out.replace(/\+55|\(|\)|\s|-/g,''));
if (rest.length<=10) return out.split(' ').length>1? out.split(' ')[0]+" ("+rest.slice(0,2)+") "+rest.slice(2,6)+(rest.length>6?"-"+rest.slice(6):'') : out;
return `+55 (${rest.slice(0,2)}) ${rest.slice(2,7)}-${rest.slice(7,11)}`;
}
const maskPhoneLocal = s=>{
const d=onlyDigits(s).slice(0,11);
if (d.length<=10) return d.replace(/(\d{2})(\d{4})(\d{0,4})/, '($1) $2-$3');
return d.replace(/(\d{2})(\d{5})(\d{0,4})/, '($1) $2-$3');
}
// Validador de CPF
function isValidCPF(v){
const d = onlyDigits(v);
if (d.length!==11) return false;
if (/^(\d)\1{10}$/.test(d)) return false; // repetidos
let sum=0; for(let i=0;i<9;i++) sum += parseInt(d[i]) * (10-i);
let r = 11 - (sum % 11); const d1 = (r>=10)?0:r;
sum=0; for(let i=0;i<10;i++) sum += parseInt(d[i]) * (11-i);
r = 11 - (sum % 11); const d2 = (r>=10)?0:r;
return d1==d[9] && d2==d[10];
}
// Elementos
const fotoInput = document.getElementById('fotoInput');
const fotoPreview = document.getElementById('fotoPreview');
const nome = document.getElementById('nome');
const nomeSocial = document.getElementById('nomeSocial');
const cpf = document.getElementById('cpf');
const rg = document.getElementById('rg');
const docTipo = document.getElementById('docTipo');
const docNumero = document.getElementById('docNumero');
const nascimento = document.getElementById('nascimento');
const racaIbge = document.getElementById('racaIbge');
const etniaIbge = document.getElementById('etniaIbge');
const profissao = document.getElementById('profissao');
const naturalidade = document.getElementById('naturalidade');
const nacionalidade = document.getElementById('nacionalidade');
const mae = document.getElementById('mae');
const profMae = document.getElementById('profMae');
const pai = document.getElementById('pai');
const profPai = document.getElementById('profPai');
const responsavel = document.getElementById('responsavel');
const cpfResponsavel = document.getElementById('cpfResponsavel');
const conjuge = document.getElementById('conjuge');
const rnConvenio = document.getElementById('rnConvenio');
const codigoLegado = document.getElementById('codigoLegado');
const observacoes = document.getElementById('observacoes');
const anexoInput = document.getElementById('anexoInput');
const btnAddAnexo = document.getElementById('btnAddAnexo');
const anexosBody = document.getElementById('anexosBody');
const email = document.getElementById('email');
const celular = document.getElementById('celular');
const tel1 = document.getElementById('tel1');
const tel2 = document.getElementById('tel2');
const cep = document.getElementById('cep');
const logradouro = document.getElementById('logradouro');
const numero = document.getElementById('numero');
const complemento = document.getElementById('complemento');
const bairro = document.getElementById('bairro');
const cidade = document.getElementById('cidade');
const uf = document.getElementById('uf');
const referencia = document.getElementById('referencia');
const btnSalvar = document.getElementById('btnSalvar');
const btnSalvar2 = document.getElementById('btnSalvar2');
const btnCancelar = document.getElementById('btnCancelar');
const btnCancelar2 = document.getElementById('btnCancelar2');
// Estado de anexos (mantido no navegador)
let ANEXOS = []; // {name,size,ts,dataURL}
// Foto upload
fotoInput.addEventListener('change', async (e)=>{
const f = e.target.files?.[0];
if (!f) return;
const url = URL.createObjectURL(f);
fotoPreview.innerHTML = `<img alt="foto" src="${url}">`;
});
// Habilita etnia apenas quando raça=Indígena
racaIbge.addEventListener('change', ()=>{
if (racaIbge.value === 'Indígena'){ etniaIbge.disabled = false; etniaIbge.placeholder = 'Informe o povo/etnia'; }
else { etniaIbge.disabled = true; etniaIbge.value=''; etniaIbge.placeholder = 'Povo / Etnia'; }
});
// Doc. número habilita apenas se tipo escolhido
docTipo.addEventListener('change', ()=>{ docNumero.disabled = !docTipo.value; if (!docTipo.value) docNumero.value=''; });
// Máscaras
const applyMask = (el, fn) => el.addEventListener('input', ()=> { const pos=el.selectionStart; const before=el.value; el.value = fn(el.value); if (document.activeElement===el && before!==el.value) el.setSelectionRange(el.value.length, el.value.length); });
applyMask(cpf, maskCPF);
applyMask(cpfResponsavel, maskCPF);
applyMask(cep, maskCEP);
applyMask(celular, maskPhoneBR);
applyMask(tel1, maskPhoneLocal);
applyMask(tel2, maskPhoneLocal);
// CEP -> ViaCEP
async function fetchCEP(v){
const d = onlyDigits(v);
if (d.length!==8) return;
try{
const res = await fetch(`https://viacep.com.br/ws/${d}/json/`);
const j = await res.json();
if (j.erro){ showToast('CEP não encontrado','error'); return; }
logradouro.value = j.logradouro||''; bairro.value=j.bairro||''; cidade.value=j.localidade||''; uf.value=j.uf||'';
}catch(err){ console.error(err); showToast('Erro ao consultar CEP','error'); }
}
cep.addEventListener('change', ()=> fetchCEP(cep.value));
// Anexos: adicionar e listar
btnAddAnexo.addEventListener('click', async ()=>{
const files = Array.from(anexoInput.files||[]);
if (!files.length){ showToast('Selecione arquivos para anexar','error'); return; }
for (const f of files){
const dataURL = await new Promise(res=>{ const r=new FileReader(); r.onload=()=>res(r.result); r.readAsDataURL(f); });
ANEXOS.push({ name:f.name, size:f.size, ts:Date.now(), dataURL });
}
anexoInput.value='';
renderAnexos();
});
function renderAnexos(){
anexosBody.innerHTML='';
ANEXOS.sort((a,b)=>b.ts-a.ts).forEach((a,idx)=>{
const tr=document.createElement('tr');
tr.innerHTML = `<td><a download="${a.name}" href="${a.dataURL}">${a.name}</a></td><td>${fmtBytes(a.size)}</td><td>${new Date(a.ts).toLocaleString('pt-BR')}</td>`;
const td=document.createElement('td');
const del=document.createElement('button'); del.className='btn danger'; del.textContent='Excluir'; del.addEventListener('click',()=>{ ANEXOS.splice(idx,1); renderAnexos(); });
td.appendChild(del); tr.appendChild(td); anexosBody.appendChild(tr);
});
if (!ANEXOS.length){
const tr=document.createElement('tr'); tr.innerHTML = '<td colspan="4" class="hint">Nenhum anexo adicionado.</td>'; anexosBody.appendChild(tr);
}
}
renderAnexos();
// Validações básicas
function validate(){
const errors=[];
if (!nome.value.trim()) errors.push('Nome é obrigatório.');
if (cpf.value.trim() && !isValidCPF(cpf.value)) errors.push('CPF inválido.');
if (cpfResponsavel.value.trim() && !isValidCPF(cpfResponsavel.value)) errors.push('CPF do responsável inválido.');
if (email.value.trim() && !/^\S+@\S+\.\S+$/.test(email.value)) errors.push('E-mail em formato inválido.');
if (docTipo.value && !docNumero.value.trim()) errors.push('Informe o número do documento selecionado.');
return errors;
}
function getSexo(){ const r = document.querySelector('input[name="sexo"]:checked'); return r? r.value : ''; }
// Salvar no localStorage como exemplo
function collectData(){
return {
foto: fotoPreview.querySelector('img')?.src || null,
nome: nome.value.trim(),
nomeSocial: nomeSocial.value.trim(),
cpf: cpf.value.trim(),
rg: rg.value.trim(),
docTipo: docTipo.value,
docNumero: docNumero.value.trim(),
sexo: getSexo(),
nascimento: nascimento.value||null,
racaIbge: racaIbge.value,
etniaIbge: etniaIbge.value.trim(),
profissao: profissao.value.trim(),
naturalidade: naturalidade.value.trim(),
nacionalidade: nacionalidade.value,
mae: mae.value.trim(),
profMae: profMae.value.trim(),
pai: pai.value.trim(),
profPai: profPai.value.trim(),
responsavel: responsavel.value.trim(),
cpfResponsavel: cpfResponsavel.value.trim(),
conjuge: conjuge.value.trim(),
rnConvenio: rnConvenio.checked,
codigoLegado: codigoLegado.value.trim(),
observacoes: observacoes.value,
anexos: ANEXOS,
contato: { email: email.value.trim(), celular: celular.value.trim(), tel1: tel1.value.trim(), tel2: tel2.value.trim() },
endereco: { cep: cep.value.trim(), logradouro: logradouro.value.trim(), numero: numero.value.trim(), complemento: complemento.value.trim(), bairro: bairro.value.trim(), cidade: cidade.value.trim(), uf: uf.value.trim().toUpperCase(), referencia: referencia.value.trim() },
updatedAt: new Date().toISOString()
};
}
function save(){
const errs = validate();
if (errs.length){ alert('Corrija os seguintes erros:\n\n- '+errs.join('\n- ')); return; }
const data = collectData();
const listKey = 'lista_pacientes_data_v1';
const exists = JSON.parse(localStorage.getItem(listKey)||'[]');
// Se já existir um registro com mesmo CPF, atualiza; senão cria novo
const idx = data.cpf ? exists.findIndex(p=> (p.doc||'') === data.cpf) : -1;
const toSave = {
id: idx>=0 ? exists[idx].id : crypto.randomUUID(),
nome: data.nome || '(sem nome)',
doc: data.cpf || '',
vip: exists[idx]?.vip || false,
telefone: exists[idx]?.telefone || '',
cidade: exists[idx]?.cidade || '',
uf: exists[idx]?.uf || '',
convenio: exists[idx]?.convenio || 'Particular',
idade: exists[idx]?.idade || null,
nascimento: data.nascimento,
ultimo: exists[idx]?.ultimo || null,
proximo: exists[idx]?.proximo || null,
obs: data.observacoes || ''
};
if (idx>=0) exists[idx] = toSave; else exists.unshift(toSave);
localStorage.setItem(listKey, JSON.stringify(exists));
localStorage.setItem('paciente_detalhe', JSON.stringify(data)); // dump completo
showToast('Paciente salvo com sucesso','ok');
}
function cancel(){
if (confirm('Deseja cancelar? Alterações não salvas serão perdidas.')){
window.location.href = 'lista-pacientes.html'; // se existir; caso contrário, permanece
}
}
btnSalvar.addEventListener('click', save); btnSalvar2.addEventListener('click', save);
btnCancelar.addEventListener('click', cancel); btnCancelar2.addEventListener('click', cancel);
</script>
</body>
</html>

703
Lista de pacientes.html Normal file
View File

@ -0,0 +1,703 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Lista de Pacientes</title>
<style>
:root {
--bg: #0f172a; /* slate-900 */
--panel: #111827; /* gray-900 */
--muted: #1f2937; /* gray-800 */
--border: #374151; /* gray-700 */
--text: #e5e7eb; /* gray-200 */
--text-dim: #9ca3af; /* gray-400 */
--accent: #22d3ee; /* cyan-400 */
--accent-2: #60a5fa; /* blue-400 */
--danger: #ef4444;
--success: #22c55e;
--warning: #f59e0b;
}
* { box-sizing: border-box }
html, body { height: 100% }
body {
margin: 0;
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji";
background: linear-gradient(180deg, #0b1221, var(--bg));
color: var(--text);
}
.container { max-width: 1200px; margin: 24px auto; padding: 0 16px; }
header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 16px; }
.title { font-size: clamp(20px, 4vw, 28px); font-weight: 800; letter-spacing: .4px }
.actions { display: flex; gap: 8px; align-items: center; }
.btn {
background: linear-gradient(180deg, var(--accent), var(--accent-2));
border: none; color: #04121c; font-weight: 700; padding: 10px 14px; border-radius: 12px; cursor: pointer;
box-shadow: 0 8px 24px rgba(34,211,238,.2);
}
.btn.secondary { background: #111827; color: var(--text); border: 1px solid var(--border); box-shadow: none }
.btn.ghost { background: transparent; color: var(--text); border: 1px dashed var(--border) }
.btn.danger { background: var(--danger); color: white; box-shadow: 0 8px 24px rgba(239,68,68,.25) }
.toolbar {
display: grid; gap: 8px; grid-template-columns: 1fr repeat(5, max-content);
align-items: center; background: rgba(17,24,39,.6); border: 1px solid var(--border); border-radius: 16px; padding: 12px;
position: sticky; top: 12px; z-index: 5; backdrop-filter: blur(8px);
}
.search { position: relative; display: flex; align-items: center; }
.search input { width: 100%; padding: 12px 40px; border-radius: 12px; border: 1px solid var(--border); background: #0b1221; color: var(--text); }
.search .icon { position: absolute; left: 12px; font-size: 18px; color: var(--text-dim) }
.search .clear { position: absolute; right: 8px; background: transparent; border: none; color: var(--text-dim); cursor: pointer }
.chip, select, .date-input, .month-input {
background: #0b1221; color: var(--text); border: 1px solid var(--border); border-radius: 12px; padding: 10px 12px; cursor: pointer;
}
.toggle { display: inline-flex; gap: 8px; align-items: center; font-size: 14px; color: var(--text-dim) }
.table { margin-top: 12px; background: rgba(17,24,39,.6); border: 1px solid var(--border); border-radius: 16px; overflow: hidden; }
table { width: 100%; border-collapse: collapse; }
thead th {
text-align: left; font-size: 12px; text-transform: uppercase; letter-spacing: .08em; color: var(--text-dim); background: #0b1221;
padding: 12px; border-bottom: 1px solid var(--border);
position: sticky; top: 64px; z-index: 2; /* stays below toolbar */
}
tbody tr { border-bottom: 1px solid var(--border); }
tbody tr:hover { background: rgba(96,165,250,.06) }
td { padding: 12px; vertical-align: middle }
a.name-link { color: white; font-weight: 700; text-decoration: none }
a.name-link:hover { text-decoration: underline; color: var(--accent) }
.status.vip { background: rgba(250,204,21,.15); color: #facc15; padding: 4px 8px; border-radius: 999px; font-size: 12px; border: 1px solid rgba(250,204,21,.25) }
.status.no-apt { color: var(--text-dim); font-style: italic }
.row-actions { position: relative }
.menu-btn { background: #0b1221; border: 1px solid var(--border); color: var(--text); border-radius: 10px; padding: 8px 10px; cursor: pointer }
.dropdown { position: absolute; right: 0; top: 44px; background: #0b1221; border: 1px solid var(--border); border-radius: 12px; min-width: 220px; box-shadow: 0 8px 32px rgba(0,0,0,.4); display: none }
.dropdown.open { display: block }
.dropdown button { display: block; width: 100%; text-align: left; padding: 10px 12px; background: transparent; border: none; color: var(--text); cursor: pointer }
.dropdown button:hover { background: rgba(96,165,250,.12) }
.dropdown .danger { color: #fecaca }
.empty { text-align: center; padding: 40px; color: var(--text-dim) }
/* Modals */
dialog { border: 1px solid var(--border); border-radius: 16px; background: #0b1221; color: var(--text); padding: 0; width: min(720px, 92vw); }
dialog::backdrop { background: rgba(0,0,0,.6); backdrop-filter: blur(2px) }
.modal-header { display: flex; justify-content: space-between; align-items: center; padding: 16px; border-bottom: 1px solid var(--border) }
.modal-body { padding: 16px; max-height: 70vh; overflow: auto }
.modal-footer { display: flex; justify-content: flex-end; gap: 8px; padding: 16px; border-top: 1px solid var(--border) }
form.grid { display: grid; grid-template-columns: repeat(12, 1fr); gap: 12px }
.col-6 { grid-column: span 6 }
.col-4 { grid-column: span 4 }
.col-3 { grid-column: span 3 }
.col-12 { grid-column: span 12 }
label { display: block; font-size: 12px; color: var(--text-dim); margin-bottom: 6px }
input[type="text"], input[type="tel"], input[type="date"], input[type="datetime-local"], select, textarea, input[type="number"] { width: 100%; padding: 10px 12px; border-radius: 10px; border: 1px solid var(--border); background: #0b1221; color: var(--text); }
.switch { display: inline-flex; align-items: center; gap: 8px }
.pill { display: inline-flex; gap: 6px; align-items: center; border: 1px solid var(--border); background: #0b1221; padding: 6px 10px; border-radius: 999px; font-size: 12px; color: var(--text-dim) }
/* Infinite loader */
.loader { text-align: center; padding: 18px; color: var(--text-dim) }
@media (max-width: 860px) {
.toolbar { grid-template-columns: 1fr 1fr 1fr; }
thead { display: none }
table, tbody, tr, td { display: block; width: 100% }
tbody tr { border: 1px solid var(--border); margin: 10px; border-radius: 12px; padding: 8px }
td { padding: 6px 8px }
td[data-col]::before { content: attr(data-col) ": "; color: var(--text-dim); font-size: 12px; text-transform: uppercase; display: inline-block; width: 140px }
}
</style>
</head>
<body>
<div class="container">
<header>
<div class="title">Lista de Pacientes</div>
<div class="actions">
<button class="btn secondary" id="advancedFilterBtn" aria-haspopup="dialog">Filtro avançado</button>
<button class="btn" id="addBtn" aria-haspopup="dialog">Adicionar</button>
</div>
</header>
<section class="toolbar" aria-label="Barra de pesquisa e filtros">
<div class="search" style="grid-column: span 1">
<span class="icon">🔎</span>
<input id="searchInput" type="text" placeholder="Pesquisar por nome ou documento…" autocomplete="off" aria-label="Pesquisar por nome ou documento" />
<button class="clear" id="clearSearch" title="Limpar" aria-label="Limpar busca"></button>
</div>
<select id="convenioFilter" title="Convênio" aria-label="Filtrar por convênio">
<option value="">Convênio (todos)</option>
<option>Particular</option>
<option>Unimed</option>
<option>Amil</option>
<option>Bradesco</option>
<option>Hapvida</option>
</select>
<label class="toggle" title="VIP">
<input type="checkbox" id="vipFilter" /> VIP
</label>
<select id="aniversarioTipo" title="Aniversariantes: por mês ou data específica" aria-label="Escolher tipo de filtro de aniversariantes">
<option value="">Aniversariantes</option>
<option value="mes">Por mês</option>
<option value="data">Por data</option>
</select>
<input type="month" id="aniversarioMes" class="month-input" style="display:none" aria-label="Selecionar mês de aniversário" />
<input type="date" id="aniversarioData" class="date-input" style="display:none" aria-label="Selecionar data exata de aniversário" />
</section>
<section class="table" role="region" aria-labelledby="listaTitulo">
<table aria-describedby="listaDesc">
<caption id="listaDesc" class="sr-only" style="display:none">Lista de pacientes com pesquisa, filtros e ações por linha.</caption>
<thead>
<tr>
<th>Nome</th>
<th>Telefone</th>
<th>Cidade/UF</th>
<th>Último atendimento</th>
<th>Próximo atendimento</th>
<th style="width:1%"></th>
</tr>
</thead>
<tbody id="tbody"></tbody>
</table>
<div id="loader" class="loader">Carregando…</div>
<div id="empty" class="empty" style="display:none">Nenhum paciente encontrado.</div>
</section>
</div>
<!-- Modal: Filtro Avançado -->
<dialog id="advancedFilterModal" aria-labelledby="afTitle">
<div class="modal-header">
<h3 id="afTitle">Filtro avançado</h3>
<button class="btn ghost" id="afClose">Fechar</button>
</div>
<div class="modal-body">
<form class="grid" id="advancedForm" onsubmit="return false;">
<div class="col-6">
<label>Cidade</label>
<input type="text" id="afCidade" placeholder="Ex.: Maceió" />
</div>
<div class="col-6">
<label>Estado (UF)</label>
<select id="afUF">
<option value="">Todos</option>
<option>AL</option><option>PE</option><option>BA</option><option>SE</option><option>PB</option><option>RN</option><option>CE</option>
<option>SP</option><option>RJ</option><option>MG</option><option>PR</option><option>SC</option><option>RS</option>
</select>
</div>
<div class="col-6">
<label>Idade (mín.)</label>
<input type="number" id="afIdadeMin" min="0" max="120" />
</div>
<div class="col-6">
<label>Idade (máx.)</label>
<input type="number" id="afIdadeMax" min="0" max="120" />
</div>
<div class="col-6">
<label>Último atendimento (de)</label>
<input type="date" id="afUltimoDe" />
</div>
<div class="col-6">
<label>Último atendimento (até)</label>
<input type="date" id="afUltimoAte" />
</div>
</form>
</div>
<div class="modal-footer">
<button class="btn secondary" id="afLimpar">Limpar</button>
<button class="btn" id="afAplicar">Aplicar</button>
</div>
</dialog>
<!-- Modal: Ver/Editar/Adicionar Paciente -->
<dialog id="patientModal" aria-labelledby="pmTitle">
<div class="modal-header">
<h3 id="pmTitle">Paciente</h3>
<div>
<span id="pmVipBadge" class="pill" title="VIP" style="display:none">⭐ VIP</span>
<button class="btn ghost" id="pmClose">Fechar</button>
</div>
</div>
<div class="modal-body">
<form class="grid" id="patientForm" onsubmit="return false;">
<input type="hidden" id="pmId" />
<div class="col-6">
<label>Nome completo</label>
<input type="text" id="pmNome" required />
</div>
<div class="col-3">
<label>Documento (CPF)</label>
<input type="text" id="pmDoc" placeholder="000.000.000-00" />
</div>
<div class="col-3">
<label>VIP?</label>
<label class="switch"><input type="checkbox" id="pmVip" /> <span>Marcar como VIP</span></label>
</div>
<div class="col-4">
<label>Telefone</label>
<input type="tel" id="pmFone" placeholder="(00) 00000-0000" />
</div>
<div class="col-4">
<label>Convênio</label>
<select id="pmConvenio">
<option>Particular</option><option>Unimed</option><option>Amil</option><option>Bradesco</option><option>Hapvida</option>
</select>
</div>
<div class="col-4">
<label>Data de nascimento</label>
<input type="date" id="pmNascimento" />
</div>
<div class="col-6">
<label>Cidade</label>
<input type="text" id="pmCidade" />
</div>
<div class="col-3">
<label>Estado (UF)</label>
<select id="pmUF">
<option>AL</option><option>PE</option><option>BA</option><option>SE</option><option>PB</option><option>RN</option><option>CE</option>
<option>SP</option><option>RJ</option><option>MG</option><option>PR</option><option>SC</option><option>RS</option>
</select>
</div>
<div class="col-3">
<label>Idade</label>
<input type="number" id="pmIdade" min="0" max="120" />
</div>
<div class="col-12">
<label>Observações</label>
<textarea id="pmObs" rows="3"></textarea>
</div>
<div class="col-6">
<label>Último atendimento</label>
<input type="datetime-local" id="pmUltimo" />
</div>
<div class="col-6">
<label>Próximo atendimento</label>
<input type="datetime-local" id="pmProximo" />
</div>
</form>
</div>
<div class="modal-footer">
<button class="btn secondary" id="pmCancel">Cancelar</button>
<button class="btn" id="pmSalvar">Salvar</button>
</div>
</dialog>
<!-- Modal: Marcar Consulta -->
<dialog id="scheduleModal" aria-labelledby="scTitle">
<div class="modal-header">
<h3 id="scTitle">Marcar consulta</h3>
<button class="btn ghost" id="scClose">Fechar</button>
</div>
<div class="modal-body">
<div class="pill" id="scPaciente"></div>
<div style="margin-top:12px">
<label for="scDate">Data e hora</label>
<input type="datetime-local" id="scDate" />
</div>
</div>
<div class="modal-footer">
<button class="btn secondary" id="scCancel">Cancelar</button>
<button class="btn" id="scSalvar">Salvar</button>
</div>
</dialog>
<script>
// ==== Utilidades ====
const fmtDate = (d, withTime=true) => {
if (!d) return '';
const date = (d instanceof Date) ? d : new Date(d);
try {
return new Intl.DateTimeFormat('pt-BR', withTime ? { dateStyle: 'short', timeStyle: 'short' } : { dateStyle: 'short' }).format(date);
} catch(e) { return date.toLocaleString('pt-BR'); }
};
const fmtPhoneBR = (v) => {
if (!v) return '';
const digits = (''+v).replace(/\D/g, '').slice(0,11);
if (digits.length <= 10) return digits.replace(/(\d{2})(\d{4})(\d{0,4})/, '($1) $2-$3');
return digits.replace(/(\d{2})(\d{5})(\d{0,4})/, '($1) $2-$3');
};
const maskPhoneInput = (el) => { el.addEventListener('input', () => { const c = el.selectionStart; const raw = el.value; const masked = fmtPhoneBR(raw); el.value = masked; }); };
const debounce = (fn, ms=250) => { let t; return (...args)=>{ clearTimeout(t); t=setTimeout(()=>fn(...args), ms); } };
// ==== Dados simulados/persistidos ====
const LS_KEY = 'lista_pacientes_data_v1';
const firstNames = ['Ana','Bruno','Carla','Diego','Eduarda','Felipe','Giovana','Henrique','Isabela','João','Karen','Leonardo','Mariana','Nuno','Olívia','Paulo','Queila','Rafael','Sofia','Tiago','Úrsula','Viviane','William','Xavier','Yasmin','Zeca'];
const lastNames = ['Almeida','Barros','Cardoso','Dias','Esteves','Farias','Gomes','Hernandes','Ibiapina','Jesus','Klein','Lima','Moraes','Nascimento','Oliveira','Pereira','Queiroz','Ramos','Silva','Teixeira','Uchoa','Vieira','Wagner','Ximenes','Yamamoto','Zanetti'];
const cidades = [
{cidade:'Maceió', uf:'AL'}, {cidade:'Arapiraca', uf:'AL'}, {cidade:'Recife', uf:'PE'}, {cidade:'Petrolina', uf:'PE'},
{cidade:'Salvador', uf:'BA'}, {cidade:'Aracaju', uf:'SE'}, {cidade:'João Pessoa', uf:'PB'}, {cidade:'Natal', uf:'RN'},
{cidade:'Fortaleza', uf:'CE'}, {cidade:'São Paulo', uf:'SP'}, {cidade:'Rio de Janeiro', uf:'RJ'}, {cidade:'Belo Horizonte', uf:'MG'}
];
const convenios = ['Particular','Unimed','Amil','Bradesco','Hapvida'];
const rand = (n) => Math.floor(Math.random()*n);
const sample = (arr) => arr[rand(arr.length)];
function makePatients(n=200){
const out=[];
const now = new Date();
for (let i=1;i<=n;i++){
const nome = `${sample(firstNames)} ${sample(lastNames)}`;
const cidadeUF = sample(cidades);
const idade = 5 + rand(90);
const nascimento = new Date(now.getFullYear()-idade, rand(12), 1+rand(28));
const lastDays = rand(360);
const nextDelta = rand(5)===0 ? null : rand(120) - 60; // pode não ter próximo
const lastAt = new Date(now); lastAt.setDate(now.getDate() - lastDays); lastAt.setHours(rand(10)+8, [0,15,30,45][rand(4)]);
const nextAt = nextDelta==null ? null : new Date(now.getFullYear(), now.getMonth(), now.getDate()+nextDelta, rand(10)+8, [0,30][rand(2)]);
const ddd = sample(['11','21','31','41','51','61','71','81','82']);
const fone = `${ddd}${90000+rand(9999)}${1000+rand(8999)}`;
const cpf = `${100+rand(800)}.${100+rand(800)}.${100+rand(800)}-${String(10+rand(90)).padStart(2,'0')}`;
out.push({
id: crypto.randomUUID(),
nome, doc: cpf, vip: Math.random()<0.12, telefone: fone,
cidade: cidadeUF.cidade, uf: cidadeUF.uf,
convenio: sample(convenios), idade, nascimento: nascimento.toISOString().slice(0,10),
ultimo: lastAt.toISOString(), proximo: nextAt ? nextAt.toISOString() : null,
obs: '', atendimentosVinculados: rand(4) // usado para validar exclusão
})
}
return out;
}
function loadData(){
try { const raw = localStorage.getItem(LS_KEY); if (raw) return JSON.parse(raw); } catch(e){}
const data = makePatients();
try { localStorage.setItem(LS_KEY, JSON.stringify(data)); } catch(e){}
return data;
}
function saveData(){ try { localStorage.setItem(LS_KEY, JSON.stringify(DATA)); } catch(e){} }
let DATA = loadData();
// ==== Estado e filtros ====
const state = {
q: '', convenio: '', vip: false,
aniversTipo: '', aniversMes: '', aniversData: '',
afCidade: '', afUF: '', idadeMin: '', idadeMax: '', ultimoDe: '', ultimoAte: '',
pageSize: 30, loaded: 0, view: []
};
// ==== Elementos ====
const tbody = document.getElementById('tbody');
const loader = document.getElementById('loader');
const empty = document.getElementById('empty');
// Toolbar elements
const searchInput = document.getElementById('searchInput');
const clearSearch = document.getElementById('clearSearch');
const convenioFilter = document.getElementById('convenioFilter');
const vipFilter = document.getElementById('vipFilter');
const aniversarioTipo = document.getElementById('aniversarioTipo');
const aniversarioMes = document.getElementById('aniversarioMes');
const aniversarioData = document.getElementById('aniversarioData');
// Modals
const advancedFilterModal = document.getElementById('advancedFilterModal');
const advancedFilterBtn = document.getElementById('advancedFilterBtn');
const afClose = document.getElementById('afClose');
const afCidade = document.getElementById('afCidade');
const afUF = document.getElementById('afUF');
const afIdadeMin = document.getElementById('afIdadeMin');
const afIdadeMax = document.getElementById('afIdadeMax');
const afUltimoDe = document.getElementById('afUltimoDe');
const afUltimoAte = document.getElementById('afUltimoAte');
const afAplicar = document.getElementById('afAplicar');
const afLimpar = document.getElementById('afLimpar');
const addBtn = document.getElementById('addBtn');
const patientModal = document.getElementById('patientModal');
const pmClose = document.getElementById('pmClose');
const pmCancel = document.getElementById('pmCancel');
const pmId = document.getElementById('pmId');
const pmNome = document.getElementById('pmNome');
const pmDoc = document.getElementById('pmDoc');
const pmVip = document.getElementById('pmVip');
const pmVipBadge = document.getElementById('pmVipBadge');
const pmFone = document.getElementById('pmFone');
const pmConvenio = document.getElementById('pmConvenio');
const pmNascimento = document.getElementById('pmNascimento');
const pmCidade = document.getElementById('pmCidade');
const pmUF = document.getElementById('pmUF');
const pmIdade = document.getElementById('pmIdade');
const pmObs = document.getElementById('pmObs');
const pmUltimo = document.getElementById('pmUltimo');
const pmProximo = document.getElementById('pmProximo');
const pmSalvar = document.getElementById('pmSalvar');
const scheduleModal = document.getElementById('scheduleModal');
const scClose = document.getElementById('scClose');
const scCancel = document.getElementById('scCancel');
const scPaciente = document.getElementById('scPaciente');
const scDate = document.getElementById('scDate');
const scSalvar = document.getElementById('scSalvar');
// ==== Renderização ====
function applyFilters(){
const q = state.q.trim().toLowerCase();
const res = DATA.filter(p => {
// search
if (q) {
const hay = `${p.nome} ${p.doc}`.toLowerCase();
if (!hay.includes(q)) return false;
}
// convenio
if (state.convenio && p.convenio !== state.convenio) return false;
// vip
if (state.vip && !p.vip) return false;
// aniversariantes
if (state.aniversTipo==='mes' && state.aniversMes) {
const month = new Date(p.nascimento).toISOString().slice(0,7); // yyyy-mm
if (month !== state.aniversMes) return false;
}
if (state.aniversTipo==='data' && state.aniversData) {
const md = new Date(p.nascimento); const sel = new Date(state.aniversData);
if (md.getDate() !== sel.getDate() || md.getMonth() !== sel.getMonth()) return false;
}
// advanced filters
if (state.afCidade && !p.cidade.toLowerCase().includes(state.afCidade.toLowerCase())) return false;
if (state.afUF && p.uf !== state.afUF) return false;
if (state.idadeMin && p.idade < Number(state.idadeMin)) return false;
if (state.idadeMax && p.idade > Number(state.idadeMax)) return false;
if (state.ultimoDe) {
const d = new Date(state.ultimoDe);
if (new Date(p.ultimo) < d) return false;
}
if (state.ultimoAte) {
const d = new Date(state.ultimoAte);
if (new Date(p.ultimo) > new Date(d.getFullYear(), d.getMonth(), d.getDate(), 23,59,59)) return false;
}
return true;
});
state.view = res.sort((a,b)=> a.nome.localeCompare(b.nome,'pt-BR'));
state.loaded = 0;
tbody.innerHTML = '';
empty.style.display = res.length? 'none':'block';
loader.style.display = res.length? 'block':'none';
loadMore();
}
function loadMore(){
const start = state.loaded;
const end = Math.min(state.loaded + state.pageSize, state.view.length);
if (start >= end) { loader.style.display = 'none'; return; }
const frag = document.createDocumentFragment();
for (let i = start; i < end; i++){
frag.appendChild(renderRow(state.view[i]));
}
tbody.appendChild(frag);
state.loaded = end;
loader.style.display = (state.loaded < state.view.length) ? 'block' : 'none';
}
function renderRow(p){
const tr = document.createElement('tr');
const tdNome = document.createElement('td');
tdNome.dataset.col = 'Nome';
const link = document.createElement('a');
link.href = '#'; link.className = 'name-link'; link.textContent = p.nome;
link.addEventListener('click', (e)=>{ e.preventDefault(); openPatient(p, true); });
tdNome.appendChild(link);
if (p.vip){ const s=document.createElement('span'); s.className='status vip'; s.textContent='VIP'; s.style.marginLeft='8px'; tdNome.appendChild(s); }
const tdFone = document.createElement('td'); tdFone.dataset.col='Telefone';
tdFone.textContent = fmtPhoneBR(p.telefone);
const tdCidade = document.createElement('td'); tdCidade.dataset.col='Cidade/UF';
tdCidade.textContent = `${p.cidade}/${p.uf}`;
const tdUlt = document.createElement('td'); tdUlt.dataset.col='Último atendimento';
tdUlt.textContent = fmtDate(p.ultimo);
const tdProx = document.createElement('td'); tdProx.dataset.col='Próximo atendimento';
tdProx.textContent = p.proximo ? fmtDate(p.proximo) : 'Nenhum atendimento agendado';
if (!p.proximo) tdProx.classList.add('status','no-apt');
const tdActions = document.createElement('td');
tdActions.className = 'row-actions';
tdActions.appendChild(actionMenu(p));
tr.append(tdNome, tdFone, tdCidade, tdUlt, tdProx, tdActions);
return tr;
}
function actionMenu(p){
const wrap = document.createElement('div');
const btn = document.createElement('button');
btn.className='menu-btn'; btn.textContent='Ações ▾'; btn.setAttribute('aria-expanded','false');
const dd = document.createElement('div'); dd.className='dropdown';
const mkBtn = (label, cb, classes='')=>{ const b=document.createElement('button'); b.textContent=label; if(classes) b.className=classes; b.addEventListener('click', (e)=>{ e.stopPropagation(); e.preventDefault(); dd.classList.remove('open'); cb(); }); return b; };
dd.appendChild(mkBtn('Ver detalhes', ()=> openPatient(p, true)));
dd.appendChild(mkBtn('Editar', ()=> openPatient(p, false)));
dd.appendChild(mkBtn('Marcar consulta', ()=> openSchedule(p)));
dd.appendChild(document.createElement('hr'));
dd.appendChild(mkBtn('Excluir', ()=> removePatient(p), 'danger'));
btn.addEventListener('click', (e)=>{ e.stopPropagation(); const isOpen = dd.classList.toggle('open'); btn.setAttribute('aria-expanded', String(isOpen)); });
document.addEventListener('click', ()=> dd.classList.remove('open'));
wrap.append(btn, dd);
return wrap;
}
// ==== Modais & Ações ====
function openPatient(p, readOnly=false){
pmId.value = p.id;
pmNome.value = p.nome;
pmDoc.value = p.doc||'';
pmVip.checked = !!p.vip;
pmVipBadge.style.display = p.vip ? 'inline-flex' : 'none';
pmFone.value = fmtPhoneBR(p.telefone);
pmConvenio.value = p.convenio;
pmNascimento.value = p.nascimento || '';
pmCidade.value = p.cidade || '';
pmUF.value = p.uf || 'AL';
pmIdade.value = p.idade || '';
pmObs.value = p.obs || '';
pmUltimo.value = p.ultimo ? p.ultimo.slice(0,16) : '';
pmProximo.value = p.proximo ? p.proximo.slice(0,16) : '';
// Toggle read-only
[pmNome, pmDoc, pmVip, pmFone, pmConvenio, pmNascimento, pmCidade, pmUF, pmIdade, pmObs, pmUltimo, pmProximo].forEach(el=>{ el.disabled = readOnly; });
document.getElementById('pmTitle').textContent = readOnly ? 'Detalhes do paciente' : 'Editar paciente';
pmSalvar.style.display = readOnly ? 'none' : 'inline-flex';
patientModal.showModal();
}
function openNewPatient(){
pmId.value = '';
pmNome.value=''; pmDoc.value=''; pmVip.checked=false; pmVipBadge.style.display='none';
pmFone.value=''; pmConvenio.value='Particular'; pmNascimento.value=''; pmCidade.value=''; pmUF.value='AL'; pmIdade.value=''; pmObs.value=''; pmUltimo.value=''; pmProximo.value='';
[pmNome, pmDoc, pmVip, pmFone, pmConvenio, pmNascimento, pmCidade, pmUF, pmIdade, pmObs, pmUltimo, pmProximo].forEach(el=>{ el.disabled = false; });
document.getElementById('pmTitle').textContent = 'Adicionar paciente';
pmSalvar.style.display = 'inline-flex';
patientModal.showModal();
}
function savePatient(){
const isNew = !pmId.value;
const base = {
id: isNew ? crypto.randomUUID() : pmId.value,
nome: pmNome.value.trim(),
doc: pmDoc.value.trim(),
vip: pmVip.checked,
telefone: pmFone.value,
convenio: pmConvenio.value,
nascimento: pmNascimento.value || null,
cidade: pmCidade.value.trim(),
uf: pmUF.value,
idade: pmIdade.value ? Number(pmIdade.value) : null,
obs: pmObs.value,
ultimo: pmUltimo.value ? new Date(pmUltimo.value).toISOString() : null,
proximo: pmProximo.value ? new Date(pmProximo.value).toISOString() : null,
atendimentosVinculados: isNew ? 0 : (DATA.find(x=>x.id===pmId.value)?.atendimentosVinculados || 0)
};
if (!base.nome){ alert('Informe o nome do paciente.'); return; }
if (isNew) DATA.unshift(base); else { const i = DATA.findIndex(x=>x.id===base.id); if (i>=0) DATA[i]=base; }
saveData();
patientModal.close();
applyFilters();
}
function removePatient(p){
if (p.atendimentosVinculados > 0){
alert('Não é possível excluir: existem atendimentos vinculados a este paciente.');
return;
}
if (!confirm(`Confirmar exclusão de "${p.nome}"?`)) return;
DATA = DATA.filter(x=>x.id!==p.id);
saveData();
applyFilters();
}
let schedulePatient = null;
function openSchedule(p){
schedulePatient = p;
scPaciente.textContent = `Paciente: ${p.nome}`;
scDate.value = p.proximo ? p.proximo.slice(0,16) : '';
scheduleModal.showModal();
}
function saveSchedule(){
if (!schedulePatient) return;
const value = scDate.value ? new Date(scDate.value).toISOString() : null;
schedulePatient.proximo = value;
// Se marcou consulta, presumir que agora existe pelo menos 1 atendimento
if (value && schedulePatient.atendimentosVinculados===0) schedulePatient.atendimentosVinculados = 1;
saveData();
scheduleModal.close();
applyFilters();
}
// ==== Eventos ====
const onSearch = debounce(()=>{ state.q = searchInput.value; applyFilters(); }, 250);
searchInput.addEventListener('input', onSearch);
clearSearch.addEventListener('click', ()=>{ searchInput.value=''; state.q=''; applyFilters(); });
convenioFilter.addEventListener('change', ()=>{ state.convenio = convenioFilter.value; applyFilters(); });
vipFilter.addEventListener('change', ()=>{ state.vip = vipFilter.checked; applyFilters(); });
aniversarioTipo.addEventListener('change', ()=>{
state.aniversTipo = aniversarioTipo.value;
aniversarioMes.style.display = aniversarioTipo.value==='mes' ? '' : 'none';
aniversarioData.style.display = aniversarioTipo.value==='data' ? '' : 'none';
if (!aniversarioTipo.value){ state.aniversMes=''; state.aniversData=''; aniversarioMes.value=''; aniversarioData.value=''; }
applyFilters();
});
aniversarioMes.addEventListener('change', ()=>{ state.aniversMes = aniversarioMes.value; applyFilters(); });
aniversarioData.addEventListener('change', ()=>{ state.aniversData = aniversarioData.value; applyFilters(); });
advancedFilterBtn.addEventListener('click', ()=> advancedFilterModal.showModal());
afClose.addEventListener('click', ()=> advancedFilterModal.close());
afAplicar.addEventListener('click', ()=>{
state.afCidade = afCidade.value.trim();
state.afUF = afUF.value;
state.idadeMin = afIdadeMin.value;
state.idadeMax = afIdadeMax.value;
state.ultimoDe = afUltimoDe.value;
state.ultimoAte = afUltimoAte.value;
advancedFilterModal.close();
applyFilters();
});
afLimpar.addEventListener('click', ()=>{
afCidade.value=''; afUF.value=''; afIdadeMin.value=''; afIdadeMax.value=''; afUltimoDe.value=''; afUltimoAte.value='';
state.afCidade=''; state.afUF=''; state.idadeMin=''; state.idadeMax=''; state.ultimoDe=''; state.ultimoAte='';
applyFilters();
});
addBtn.addEventListener('click', openNewPatient);
pmClose.addEventListener('click', ()=> patientModal.close());
pmCancel.addEventListener('click', ()=> patientModal.close());
pmSalvar.addEventListener('click', savePatient);
scClose.addEventListener('click', ()=> scheduleModal.close());
scCancel.addEventListener('click', ()=> scheduleModal.close());
scSalvar.addEventListener('click', saveSchedule);
// Phone mask
maskPhoneInput(pmFone);
// Infinite scroll
window.addEventListener('scroll', ()=>{
if (loader.style.display==='none') return;
const nearBottom = window.innerHeight + window.scrollY >= document.body.offsetHeight - 200;
if (nearBottom) loadMore();
});
// Inicialização
applyFilters();
</script>
</body>
</html>

27
Página inicial.html Normal file
View File

@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="pt-br">
<head>
<meta charset="UTF-8">
<title>Página Inicial</title>
<style>
button {
padding: 10px 20px;
font-size: 16px;
cursor: pointer;
}
</style>
</head>
<body>
<h1>Bem-vindo à Clínica</h1>
<button onclick="abrirFormulario()">Adicionar Paciente</button>
<script>
function abrirFormulario() {
window.location.href = 'Cadastro.html';
}
</script>
</body>
</html>

858
Teste do site completo.html Normal file
View File

@ -0,0 +1,858 @@
<!DOCTYPE html>
<html lang="pt-br">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CRUD de Pacientes</title>
<style>
:root{
--bg:#0f172a; /* slate-900 */
--panel:#111827; /* gray-900 */
--muted:#1f2937; /* gray-800 */
--line:#2a3648; /* custom */
--text:#e5e7eb; /* gray-200 */
--text-dim:#a1a1aa; /* zinc-400 */
--brand:#22d3ee; /* cyan-400 */
--accent:#a78bfa; /* violet-400 */
--danger:#ef4444; /* red-500 */
--ok:#22c55e; /* green-500 */
--warn:#f59e0b; /* amber-500 */
--shadow: 0 10px 30px rgba(0,0,0,.35);
--radius:16px;
}
*{box-sizing:border-box}
html,body{height:100%}
body{margin:0;font-family:Inter,system-ui,Segoe UI,Roboto,Helvetica,Arial,sans-serif;background:linear-gradient(120deg,#0b1224,#0f172a 40%,#111827);color:var(--text)}
header{
position:sticky;top:0;z-index:20;background:rgba(17,24,39,.9);backdrop-filter:saturate(160%) blur(8px);
border-bottom:1px solid var(--line);
}
.container{max-width:1200px;margin:0 auto;padding:18px}
.toolbar{display:grid;grid-template-columns:1fr auto;gap:12px;align-items:center}
.searchbar{display:flex;gap:10px;align-items:center;background:var(--muted);border:1px solid var(--line);padding:10px 12px;border-radius:12px}
.searchbar input{flex:1;background:transparent;border:none;outline:none;color:var(--text);font-size:16px}
.chips{display:flex;flex-wrap:wrap;gap:8px}
.chip,button,.btn{border:1px solid var(--line);background:var(--muted);color:var(--text);padding:8px 12px;border-radius:12px;cursor:pointer;transition:.2s ease;box-shadow:none}
.chip[aria-pressed="true"], .btn.primary{background:linear-gradient(135deg,var(--brand),#60a5fa);color:#0b1224;border-color:transparent}
.btn.success{background:linear-gradient(135deg,var(--ok),#34d399);color:#03210e;border-color:transparent}
.btn.danger{background:linear-gradient(135deg,var(--danger),#fb7185);color:#300;border-color:transparent}
.btn.ghost{background:transparent}
.chip:hover,.btn:hover{transform:translateY(-1px);box-shadow:var(--shadow)}
/* grid + table */
.panel{background:linear-gradient(180deg,rgba(255,255,255,.02),rgba(255,255,255,.01));border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow)}
.list-head{display:flex;justify-content:space-between;align-items:center;padding:16px;border-bottom:1px solid var(--line)}
.list{max-height:calc(100vh - 220px);overflow:auto}
table{width:100%;border-collapse:separate;border-spacing:0}
thead th{position:sticky;top:0;background:rgba(17,24,39,.95);backdrop-filter:blur(6px);font-weight:600;text-align:left;padding:12px;border-bottom:1px solid var(--line)}
tbody td{padding:12px;border-bottom:1px solid var(--line);vertical-align:middle}
tbody tr:hover{background:rgba(255,255,255,.03)}
.tag{display:inline-flex;gap:6px;align-items:center;padding:3px 8px;border-radius:999px;border:1px solid var(--line);font-size:12px}
.tag.vip{border-color:var(--warn);color:#fde68a;background:rgba(245,158,11,.1)}
.muted{color:var(--text-dim)}
.avatar{width:36px;height:36px;border-radius:50%;object-fit:cover;border:1px solid var(--line)}
.row-actions{position:relative}
.menu{position:absolute;right:0;top:40px;background:var(--panel);border:1px solid var(--line);border-radius:12px;min-width:200px;display:none;box-shadow:var(--shadow)}
.menu.open{display:block}
.menu button{display:flex;width:100%;gap:10px;justify-content:flex-start;background:transparent;border:0;padding:10px 12px}
.menu button:hover{background:rgba(255,255,255,.04)}
/* forms */
.grid{display:grid;gap:12px}
.grid.col2{grid-template-columns:repeat(2,1fr)}
.grid.col3{grid-template-columns:repeat(3,1fr)}
.field{display:flex;flex-direction:column;gap:6px}
.field label{font-size:12px;color:var(--text-dim)}
.field input,.field select,.field textarea{background:var(--muted);border:1px solid var(--line);padding:10px 12px;color:var(--text);border-radius:10px;outline:none}
.field textarea{min-height:90px}
.switch{display:flex;align-items:center;gap:10px}
.footer-actions{display:flex;gap:12px;justify-content:flex-end;padding:12px}
/* modals */
dialog{border:none;border-radius:18px;background:var(--panel);color:var(--text);box-shadow:var(--shadow);width:min(900px,95vw)}
dialog::backdrop{background:rgba(0,0,0,.5)}
.modal-head{display:flex;justify-content:space-between;align-items:center;padding:14px 16px;border-bottom:1px solid var(--line)}
.modal-body{padding:16px}
.kpi{display:flex;gap:10px;align-items:center}
.kpi strong{font-size:20px}
.pill{border:1px dashed var(--line);padding:10px;border-radius:12px}
.files{display:flex;flex-direction:column;gap:10px}
/* toast */
.toast{position:fixed;right:16px;bottom:16px;display:flex;flex-direction:column;gap:8px;z-index:50}
.toast .msg{background:var(--panel);border:1px solid var(--line);padding:10px 12px;border-radius:12px;box-shadow:var(--shadow)}
@media (max-width:900px){
.grid.col3{grid-template-columns:1fr}
.grid.col2{grid-template-columns:1fr}
.list{max-height:unset}
}
</style>
</head>
<body>
<header>
<div class="container">
<div class="toolbar">
<div class="searchbar">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M21 21L16.65 16.65M19 11C19 15.4183 15.4183 19 11 19C6.58172 19 3 15.4183 3 11C3 6.58172 6.58172 3 11 3C15.4183 3 19 6.58172 19 11Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
<input id="q" type="search" placeholder="Pesquisar por nome, CPF ou RG..." />
<select id="filterConvenio" title="Convênio">
<option value="">Convênio (todos)</option>
</select>
<button class="chip" id="filterVip" aria-pressed="false">VIP</button>
<select id="filterMes" title="Aniversariantes">
<option value="">Aniversariantes (mês)</option>
<option value="1">Jan</option><option value="2">Fev</option><option value="3">Mar</option><option value="4">Abr</option>
<option value="5">Mai</option><option value="6">Jun</option><option value="7">Jul</option><option value="8">Ago</option>
<option value="9">Set</option><option value="10">Out</option><option value="11">Nov</option><option value="12">Dez</option>
</select>
<button class="chip" id="btnFiltroAvancado">Filtro avançado</button>
</div>
<div class="chips">
<button id="btnImport" class="btn">Importar CSV</button>
<button id="btnCampos" class="btn">Campos personalizados</button>
<button id="btnAdd" class="btn primary">+ Adicionar</button>
</div>
</div>
</div>
</header>
<main class="container">
<section class="panel">
<div class="list-head">
<div class="kpi"><span class="tag">Total: <strong id="kpiTotal">0</strong></span><span class="tag">Filtrados: <strong id="kpiFiltrados">0</strong></span></div>
<div class="muted">Scroll infinito ativo</div>
</div>
<div class="list" id="list">
<table>
<thead>
<tr>
<th>Paciente</th>
<th>Telefone</th>
<th>Cidade/UF</th>
<th>Último atendimento</th>
<th>Próximo atendimento</th>
<th style="width:60px">Ações</th>
</tr>
</thead>
<tbody id="tbody"></tbody>
</table>
<div id="sentinel" class="muted" style="text-align:center;padding:14px">Carregando...</div>
</div>
</section>
</main>
<!-- Modal Form Paciente -->
<dialog id="dlgForm">
<form method="dialog" id="formPaciente">
<div class="modal-head">
<h3 id="formTitle">Novo Paciente</h3>
<div class="chips">
<span class="tag" id="tagId">ID: —</span>
<span class="tag" id="tagVip" style="display:none">VIP</span>
<button type="button" class="btn ghost" onclick="dlgForm.close()"></button>
</div>
</div>
<div class="modal-body">
<!-- Dados pessoais -->
<h4>1. Dados pessoais</h4>
<div class="grid col3">
<div class="field">
<label>Foto</label>
<input type="file" id="foto" accept="image/*" />
<img id="fotoPreview" class="avatar" style="margin-top:8px;width:72px;height:72px" alt="Prévia"/>
</div>
<div class="field">
<label>Nome *</label>
<input required id="nome" />
</div>
<div class="field">
<label>Nome social</label>
<input id="nomeSocial" />
</div>
<div class="field">
<label>CPF</label>
<input id="cpf" maxlength="14" placeholder="___.___.___-__" />
</div>
<div class="field">
<label>RG</label>
<input id="rg" />
</div>
<div class="field">
<label>Outros documentos</label>
<div class="grid col2">
<select id="docTipo">
<option value=""></option>
<option>CNH</option>
<option>Passaporte</option>
<option>RNE</option>
</select>
<input id="docNumero" placeholder="Número" />
</div>
</div>
<div class="field">
<label>Sexo</label>
<select id="sexo"><option value=""></option><option>Masculino</option><option>Feminino</option><option>Outro</option></select>
</div>
<div class="field">
<label>Data de nascimento</label>
<input type="date" id="nascimento" />
</div>
<div class="field">
<label>Etnia (IBGE)</label>
<select id="etnia"><option value=""></option><option>Branca</option><option>Preta</option><option>Parda</option><option>Amarela</option><option>Indígena</option></select>
</div>
<div class="field">
<label>Raça/Cor (IBGE)</label>
<select id="raca"><option value=""></option><option>Branca</option><option>Preta</option><option>Parda</option><option>Amarela</option><option>Indígena</option></select>
</div>
<div class="field">
<label>Naturalidade</label>
<input id="naturalidade" placeholder="Cidade/UF" />
</div>
<div class="field">
<label>Nacionalidade</label>
<input id="nacionalidade" placeholder="País" />
</div>
<div class="field">
<label>Profissão</label>
<input id="profissao" />
</div>
<div class="field">
<label>Estado civil</label>
<select id="estadoCivil"><option value=""></option><option>Solteiro(a)</option><option>Casado(a)</option><option>Divorciado(a)</option><option>Viúvo(a)</option><option>União estável</option></select>
</div>
<div class="field">
<label>Nome da mãe</label>
<input id="mae" />
</div>
<div class="field">
<label>Profissão da mãe</label>
<input id="profMae" />
</div>
<div class="field">
<label>Nome do pai</label>
<input id="pai" />
</div>
<div class="field">
<label>Profissão do pai</label>
<input id="profPai" />
</div>
<div class="field">
<label>Nome do responsável</label>
<input id="responsavel" />
</div>
<div class="field">
<label>CPF do responsável</label>
<input id="cpfResponsavel" maxlength="14" placeholder="___.___.___-__" />
</div>
<div class="field">
<label>Nome do esposo(a)</label>
<input id="conjuge" />
</div>
<div class="field switch">
<input type="checkbox" id="rnGuia" />
<label for="rnGuia">RN na Guia do convênio</label>
</div>
<div class="field">
<label>Código legado</label>
<input id="codigoLegado" />
</div>
<div class="field">
<label>Convênio</label>
<input id="convenio" placeholder="Ex.: Unimed, Hapvida..." />
</div>
<div class="field switch">
<input type="checkbox" id="vip" />
<label for="vip">VIP</label>
</div>
</div>
<!-- Observações e anexos -->
<h4 style="margin-top:16px">2. Observações e anexos</h4>
<div class="grid col2">
<div class="field">
<label>Observações</label>
<textarea id="obs" placeholder="Alergias, restrições..."></textarea>
</div>
<div class="field">
<label>Anexos do paciente</label>
<div class="pill">
<input type="file" id="inputAnexo" />
<div class="files" id="listaAnexos"></div>
</div>
</div>
</div>
<!-- Contato -->
<h4 style="margin-top:16px">3. Contato</h4>
<div class="grid col3">
<div class="field"><label>E-mail</label><input id="email" type="email" placeholder="nome@dominio.com" /></div>
<div class="field"><label>Celular</label><input id="celular" placeholder="+55 (__) _____-____" maxlength="20"/></div>
<div class="field"><label>Telefone 1</label><input id="tel1" placeholder="(__) ____-_____" maxlength="20"/></div>
<div class="field"><label>Telefone 2</label><input id="tel2" placeholder="(__) ____-_____" maxlength="20"/></div>
</div>
<!-- Endereço -->
<h4 style="margin-top:16px">4. Endereço</h4>
<div class="grid col3">
<div class="field"><label>CEP</label><input id="cep" placeholder="________" maxlength="9"/></div>
<div class="field"><label>Logradouro</label><input id="logradouro"/></div>
<div class="field"><label>Número</label><input id="numero"/></div>
<div class="field"><label>Complemento</label><input id="complemento"/></div>
<div class="field"><label>Bairro</label><input id="bairro"/></div>
<div class="field"><label>Cidade</label><input id="cidade"/></div>
<div class="field"><label>Estado</label><input id="estado"/></div>
<div class="field"><label>Referência</label><input id="referencia"/></div>
</div>
<!-- Campos personalizados -->
<h4 style="margin-top:16px">5. Campos personalizados</h4>
<div id="customFields" class="grid col2"></div>
<div class="footer-actions">
<button type="button" class="btn danger" id="btnExcluir" style="margin-right:auto;display:none">Excluir</button>
<button type="button" class="btn" id="btnHistorico">Histórico de alterações</button>
<button type="button" class="btn" id="btnMarcarConsulta">Marcar consulta</button>
<button type="button" class="btn" onclick="dlgForm.close()">Cancelar</button>
<button type="submit" class="btn success" id="btnSalvar">Salvar</button>
</div>
</div>
</form>
</dialog>
<!-- Modal Filtro Avançado -->
<dialog id="dlgFiltro">
<div class="modal-head"><h3>Filtro avançado</h3><button class="btn ghost" onclick="dlgFiltro.close()"></button></div>
<div class="modal-body">
<div class="grid col3">
<div class="field"><label>Cidade</label><input id="fCidade"></div>
<div class="field"><label>Estado</label><input id="fEstado"></div>
<div class="field"><label>Idade mínima</label><input id="fIdadeMin" type="number" min="0"></div>
<div class="field"><label>Idade máxima</label><input id="fIdadeMax" type="number" min="0"></div>
<div class="field"><label>Último atendimento desde</label><input id="fUltimoDe" type="date"></div>
<div class="field"><label>Último atendimento até</label><input id="fUltimoAte" type="date"></div>
</div>
<div class="footer-actions"><button class="btn" onclick="resetAdvancedFilters()">Limpar</button><button class="btn primary" onclick="aplicarFiltrosAvancados()">Aplicar</button></div>
</div>
</dialog>
<!-- Modal Histórico -->
<dialog id="dlgHistorico">
<div class="modal-head"><h3>Histórico de alterações</h3><button class="btn ghost" onclick="dlgHistorico.close()"></button></div>
<div class="modal-body">
<div id="histBody" class="files"></div>
</div>
</dialog>
<!-- Modal Consulta -->
<dialog id="dlgConsulta">
<div class="modal-head"><h3>Marcar consulta</h3><button class="btn ghost" onclick="dlgConsulta.close()"></button></div>
<div class="modal-body">
<div class="grid col2">
<div class="field"><label>Data</label><input id="cData" type="date"></div>
<div class="field"><label>Hora</label><input id="cHora" type="time"></div>
<div class="field"><label>Observações</label><textarea id="cObs"></textarea></div>
</div>
<div class="footer-actions"><button class="btn" onclick="dlgConsulta.close()">Cancelar</button><button class="btn success" id="btnSalvarConsulta">Salvar</button></div>
</div>
</dialog>
<!-- Modal Importar CSV -->
<dialog id="dlgImport">
<div class="modal-head"><h3>Importação em massa (CSV)</h3><button class="btn ghost" onclick="dlgImport.close()"></button></div>
<div class="modal-body">
<p class="muted">Cabeçalhos aceitos: nome, cpf, rg, nascimento, email, celular, cidade, estado, convenio, vip</p>
<input type="file" id="csvFile" accept=".csv">
<div class="footer-actions"><button class="btn" onclick="dlgImport.close()">Fechar</button><button class="btn primary" id="btnImportCsv">Importar</button></div>
</div>
</dialog>
<!-- Modal Campos personalizados -->
<dialog id="dlgCampos">
<div class="modal-head"><h3>Campos personalizados</h3><button class="btn ghost" onclick="dlgCampos.close()"></button></div>
<div class="modal-body">
<div class="grid col3">
<div class="field"><label>Nome do campo</label><input id="cfNome" placeholder="Ex.: Plano odontológico"></div>
<div class="field"><label>Tipo</label><select id="cfTipo"><option>texto</option><option>numero</option><option>data</option></select></div>
<div class="field"><label>Chave (sem espaços)</label><input id="cfKey" placeholder="planoOdonto"></div>
</div>
<div class="footer-actions"><button class="btn" onclick="dlgCampos.close()">Fechar</button><button class="btn success" id="btnAddCampo">Adicionar</button></div>
<div id="cfLista" class="files" style="margin-top:10px"></div>
</div>
</dialog>
<div class="toast" id="toast"></div>
<script>
// ===== Util =====
const $ = sel => document.querySelector(sel);
const $$ = sel => Array.from(document.querySelectorAll(sel));
const uid = () => Math.random().toString(36).slice(2) + Date.now().toString(36);
const fmtDate = iso => iso ? new Date(iso).toLocaleString() : '—';
const fmtPhone = s => s ? s.replace(/\D/g,'').replace(/^(\d{2})(\d{5})(\d{4}).*/, '+55 ($1) $2-$3') : '';
const fmtTel = s => s ? s.replace(/\D/g,'').replace(/^(\d{2})(\d{4,5})(\d{4}).*/, '($1) $2-$3') : '';
const maskCPF = v => v.replace(/\D/g,'').replace(/(\d{3})(\d)/,'$1.$2').replace(/(\d{3})(\d)/,'$1.$2').replace(/(\d{3})(\d{1,2})$/,'$1-$2').slice(0,14);
const maskCEP = v => v.replace(/\D/g,'').replace(/(\d{5})(\d)/,'$1-$2').slice(0,9);
const toast = (msg, type='') => {
const el = document.createElement('div');
el.className = 'msg';
el.textContent = msg;
if(type==='ok') el.style.borderColor = 'var(--ok)';
if(type==='err') el.style.borderColor = 'var(--danger)';
$('#toast').appendChild(el);
setTimeout(()=>el.remove(), 3500);
}
const isCPF = cpf => {
cpf = (cpf||'').replace(/\D/g,'');
if(!cpf || cpf.length !== 11 || /^([0-9])\1+$/.test(cpf)) return false;
let s = 0; for(let i=0;i<9;i++) s += parseInt(cpf.charAt(i))*(10-i); let d1 = 11 - (s % 11); if(d1>9) d1=0;
s = 0; for(let i=0;i<10;i++) s += parseInt(cpf.charAt(i))*(11-i); let d2 = 11 - (s % 11); if(d2>9) d2=0;
return d1==cpf.charAt(9) && d2==cpf.charAt(10);
}
// ===== Storage =====
const DB_KEY = 'pacientesDB_v1';
const CF_KEY = 'customFields_v1';
const loadDB = () => JSON.parse(localStorage.getItem(DB_KEY)||'[]');
const saveDB = rows => localStorage.setItem(DB_KEY, JSON.stringify(rows));
const loadCF = () => JSON.parse(localStorage.getItem(CF_KEY)||'[]');
const saveCF = rows => localStorage.setItem(CF_KEY, JSON.stringify(rows));
// ===== Estado =====
let DB = loadDB();
let FILTERED = [];
let PAGE_SIZE = 20; let cursor = 0; // scroll infinito
let editingId = null; // id em edição
let viewOnly = false; // modo somente leitura
// ===== Mock inicial (se vazio) =====
if(DB.length===0){
const exemplos = Array.from({length:48}).map((_,i)=>({
id: uid(),
nome: `Paciente ${i+1}`,
nomeSocial:'',
cpf:'',
rg:'',
docTipo:'',docNumero:'',
sexo: i%2? 'Feminino':'Masculino',
nascimento: new Date(1980 + (i%30), (i%12), (i%28)+1).toISOString().slice(0,10),
etnia:'',raca:'',naturalidade:'',nacionalidade:'Brasil',
profissao:'',estadoCivil:'',
mae:'',profMae:'',pai:'',profPai:'',
responsavel:'',cpfResponsavel:'',conjuge:'',rnGuia:false,codigoLegado:'',
obs:'',
anexos:[],
email:'',celular:'',tel1:'',tel2:'',
endereco:{cep:'',logradouro:'',numero:'',complemento:'',bairro:'',cidade:'Maceió',estado:'AL',referencia:''},
foto:'',
convenio: ['Unimed','Hapvida','Particular'][i%3],
vip: i%10===0,
criadoEm: new Date().toISOString(),
atualizadoEm: new Date().toISOString(),
historico:[{quando:new Date().toISOString(), acao:'criado', por:'Usuário padrão'}],
atendimentos:{ultimo: new Date(2025, (i%12), (i%27)+1, 9, 0).toISOString(), proximo: i%4? null : new Date(2025, (i%12), (i%27)+2, 14, 30).toISOString()},
custom:{}
}));
DB = exemplos; saveDB(DB);
}
// ===== Render da lista =====
function uniq(arr){return [...new Set(arr.filter(Boolean))]}
function refreshConvenios(){
const opts = uniq(DB.map(r=>r.convenio)).sort();
const sel = $('#filterConvenio');
sel.innerHTML = '<option value="">Convênio (todos)</option>' + opts.map(v=>`<option>${v}</option>`).join('');
}
function applyFilters(){
const q = $('#q').value.trim().toLowerCase();
const conv = $('#filterConvenio').value;
const vip = $('#filterVip').getAttribute('aria-pressed')==='true';
const mes = $('#filterMes').value;
FILTERED = DB.filter(r=>{
const texto = [r.nome, r.cpf, r.rg].join(' ').toLowerCase();
if(q && !texto.includes(q)) return false;
if(conv && r.convenio !== conv) return false;
if(vip && !r.vip) return false;
if(mes){
const m = (r.nascimento||'').split('-')[1];
if(!m || parseInt(m) !== parseInt(mes)) return false;
}
// avançado em memória
if(ADV.cidade && (r.endereco?.cidade||'').toLowerCase() !== ADV.cidade.toLowerCase()) return false;
if(ADV.estado && (r.endereco?.estado||'').toLowerCase() !== ADV.estado.toLowerCase()) return false;
const idade = calcIdade(r.nascimento);
if(ADV.idMin!=null && idade!=null && idade < ADV.idMin) return false;
if(ADV.idMax!=null && idade!=null && idade > ADV.idMax) return false;
if(ADV.ultDe && r.atendimentos?.ultimo && new Date(r.atendimentos.ultimo) < new Date(ADV.ultDe)) return false;
if(ADV.ultAte && r.atendimentos?.ultimo && new Date(r.atendimentos.ultimo) > new Date(ADV.ultAte)) return false;
return true;
});
$('#kpiTotal').textContent = DB.length;
$('#kpiFiltrados').textContent = FILTERED.length;
cursor = 0;
$('#tbody').innerHTML = '';
loadMore();
}
function loadMore(){
const slice = FILTERED.slice(cursor, cursor + PAGE_SIZE);
const rows = slice.map(r=> rowHTML(r)).join('');
$('#tbody').insertAdjacentHTML('beforeend', rows);
cursor += PAGE_SIZE;
$('#sentinel').style.display = cursor < FILTERED.length ? 'block' : 'none';
}
function rowHTML(r){
const proximo = r.atendimentos?.proximo ? new Date(r.atendimentos.proximo) : null;
const proxTxt = proximo ? proximo.toLocaleString() : '<span class="muted">Nenhum atendimento agendado</span>';
return `<tr data-id="${r.id}">
<td>
<div style="display:flex;gap:10px;align-items:center">
<img class="avatar" src="${r.foto||'data:image/svg+xml;utf8,<svg xmlns=\'http://www.w3.org/2000/svg\' width=\'72\' height=\'72\'><rect width=\'100%\' height=\'100%\' fill=\'%23111827\'/><text x=\'50%\' y=\'55%\' dominant-baseline=\'middle\' text-anchor=\'middle\' fill=\'%23a1a1aa\' font-family=\'Arial\' font-size=\'14\'>Sem foto</text></svg>'}" alt="foto">
<div>
<div style="display:flex;gap:8px;align-items:center"><a href="#" class="linkNome" data-id="${r.id}"><strong>${r.nome}</strong></a>${r.vip?'<span class="tag vip">VIP</span>':''}</div>
<div class="muted">${r.convenio||'—'}</div>
</div>
</div>
</td>
<td>${fmtTel(r.celular||r.tel1||'')}</td>
<td>${(r.endereco?.cidade||'—')}/${(r.endereco?.estado||'—')}</td>
<td>${fmtDate(r.atendimentos?.ultimo)}</td>
<td>${proxTxt}</td>
<td class="row-actions">
<button class="btn" onclick="toggleMenu(this)">Ações ▾</button>
<div class="menu">
<button onclick="abrirProntuario('${r.id}', true)">Ver detalhes</button>
<button onclick="abrirProntuario('${r.id}', false)">Editar</button>
<button onclick="excluirPaciente('${r.id}')">Excluir</button>
<button onclick="abrirConsulta('${r.id}')">Marcar consulta</button>
</div>
</td>
</tr>`
}
function toggleMenu(btn){
const menu = btn.nextElementSibling; $$('.menu').forEach(m=>m.classList.remove('open')); menu.classList.add('open');
document.addEventListener('click', function doc(e){ if(!menu.contains(e.target) && e.target!==btn){ menu.classList.remove('open'); document.removeEventListener('click', doc); } }, {once:true});
}
// ===== Filtros avançados =====
const ADV = {cidade:'', estado:'', idMin:null, idMax:null, ultDe:'', ultAte:''};
function aplicarFiltrosAvancados(){
ADV.cidade = $('#fCidade').value.trim();
ADV.estado = $('#fEstado').value.trim();
ADV.idMin = $('#fIdadeMin').value? parseInt($('#fIdadeMin').value): null;
ADV.idMax = $('#fIdadeMax').value? parseInt($('#fIdadeMax').value): null;
ADV.ultDe = $('#fUltimoDe').value;
ADV.ultAte = $('#fUltimoAte').value;
dlgFiltro.close();
applyFilters();
}
function resetAdvancedFilters(){
$('#fCidade').value=''; $('#fEstado').value=''; $('#fIdadeMin').value=''; $('#fIdadeMax').value=''; $('#fUltimoDe').value=''; $('#fUltimoAte').value='';
ADV.cidade=''; ADV.estado=''; ADV.idMin=null; ADV.idMax=null; ADV.ultDe=''; ADV.ultAte='';
applyFilters();
}
// ===== Form =====
function abrirProntuario(id, somenteLeitura=false){
const r = DB.find(x=>x.id===id);
if(!r) return;
viewOnly = !!somenteLeitura; editingId = id;
$('#formTitle').textContent = (viewOnly? 'Prontuário — ':'Editar — ') + r.nome;
$('#tagId').textContent = 'ID: ' + r.id; $('#tagVip').style.display = r.vip? 'inline-flex':'none';
preencherForm(r);
$('#btnExcluir').style.display = viewOnly? 'none':'inline-flex';
$('#btnSalvar').style.display = viewOnly? 'none':'inline-flex';
disableForm(viewOnly);
dlgForm.showModal();
}
function novoPaciente(){
viewOnly = false; editingId = null;
$('#formTitle').textContent = 'Novo Paciente'; $('#tagId').textContent = 'ID: —'; $('#tagVip').style.display='none';
limparForm();
disableForm(false);
$('#btnExcluir').style.display = 'none';
$('#btnSalvar').style.display = 'inline-flex';
dlgForm.showModal();
}
function disableForm(dis){
$$('#formPaciente input, #formPaciente select, #formPaciente textarea').forEach(el=>{
if(['btnHistorico','btnMarcarConsulta'].includes(el.id)) return;
el.disabled = dis;
});
$('#inputAnexo').disabled = dis; $('#foto').disabled = dis;
}
function limparForm(){
$$('#formPaciente input, #formPaciente textarea').forEach(el=>{ if(el.type!=='checkbox' && el.type!=='file') el.value=''; if(el.type==='checkbox') el.checked=false; });
$('#fotoPreview').src='';
$('#listaAnexos').innerHTML='';
renderCustomFields({});
}
function preencherForm(r){
$('#fotoPreview').src = r.foto||'';
$('#nome').value = r.nome||''; $('#nomeSocial').value=r.nomeSocial||'';
$('#cpf').value = maskCPF(r.cpf||''); $('#rg').value = r.rg||'';
$('#docTipo').value = r.docTipo||''; $('#docNumero').value = r.docNumero||'';
$('#sexo').value = r.sexo||''; $('#nascimento').value = r.nascimento||'';
$('#etnia').value=r.etnia||''; $('#raca').value=r.raca||''; $('#naturalidade').value=r.naturalidade||''; $('#nacionalidade').value=r.nacionalidade||'';
$('#profissao').value=r.profissao||''; $('#estadoCivil').value=r.estadoCivil||'';
$('#mae').value=r.mae||''; $('#profMae').value=r.profMae||''; $('#pai').value=r.pai||''; $('#profPai').value=r.profPai||'';
$('#responsavel').value=r.responsavel||''; $('#cpfResponsavel').value=maskCPF(r.cpfResponsavel||''); $('#conjuge').value=r.conjuge||'';
$('#rnGuia').checked=!!r.rnGuia; $('#codigoLegado').value=r.codigoLegado||''; $('#convenio').value=r.convenio||''; $('#vip').checked=!!r.vip;
$('#obs').value=r.obs||'';
$('#email').value=r.email||''; $('#celular').value=r.celular||''; $('#tel1').value=r.tel1||''; $('#tel2').value=r.tel2||'';
const e = r.endereco||{}; $('#cep').value=e.cep||''; $('#logradouro').value=e.logradouro||''; $('#numero').value=e.numero||''; $('#complemento').value=e.complemento||''; $('#bairro').value=e.bairro||''; $('#cidade').value=e.cidade||''; $('#estado').value=e.estado||''; $('#referencia').value=e.referencia||'';
// anexos
$('#listaAnexos').innerHTML = (r.anexos||[]).map((a,idx)=>`<div class="tag" style="justify-content:space-between"><span>${a.nome}</span><button class="btn" onclick="removerAnexo(${idx})">Excluir</button></div>`).join('');
renderCustomFields(r.custom||{});
}
function coletarForm(){
const get = id=>$("#"+id).value;
const chk = id=>$("#"+id).checked;
const endereco = {cep:get('cep'),logradouro:get('logradouro'),numero:get('numero'),complemento:get('complemento'),bairro:get('bairro'),cidade:get('cidade'),estado:get('estado'),referencia:get('referencia')};
const custom = collectCustomFields();
const base = {
nome:get('nome').trim(), nomeSocial:get('nomeSocial'), cpf:get('cpf'), rg:get('rg'), docTipo:get('docTipo'), docNumero:get('docNumero'),
sexo:get('sexo'), nascimento:get('nascimento'), etnia:get('etnia'), raca:get('raca'), naturalidade:get('naturalidade'), nacionalidade:get('nacionalidade'),
profissao:get('profissao'), estadoCivil:get('estadoCivil'), mae:get('mae'), profMae:get('profMae'), pai:get('pai'), profPai:get('profPai'),
responsavel:get('responsavel'), cpfResponsavel:get('cpfResponsavel'), conjuge:get('conjuge'), rnGuia:chk('rnGuia'), codigoLegado:get('codigoLegado'),
convenio:get('convenio'), vip:chk('vip'), obs:get('obs'), email:get('email'), celular:get('celular'), tel1:get('tel1'), tel2:get('tel2'), endereco, custom
};
return base;
}
function validar(r){
const erros = [];
if(!r.nome) erros.push('Nome é obrigatório');
if(r.cpf){ if(!isCPF(r.cpf)) erros.push('CPF inválido'); }
if(r.email && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(r.email)) erros.push('E-mail inválido');
if(r.celular && r.celular.replace(/\D/g,'').length < 10) erros.push('Celular inválido');
// duplicidade
const normal = s => (s||'').replace(/\D/g,'');
const alvoCpf = normal(r.cpf);
const alvoNome = (r.nome||'').trim().toLowerCase();
const alvoNasc = r.nascimento||'';
const dup = DB.find(p => p.id!==editingId && ((alvoCpf && normal(p.cpf)===alvoCpf) || (alvoNome && p.nome.trim().toLowerCase()===alvoNome && (p.nascimento||'')===alvoNasc)));
if(dup) erros.push('Duplicidade detectada: já existe paciente com mesmo CPF ou (nome + data de nascimento).');
return erros;
}
function salvar(e){
e.preventDefault();
const base = coletarForm();
base.cpf = base.cpf.replace(/\D/g,'');
base.cpfResponsavel = base.cpfResponsavel.replace(/\D/g,'');
base.celular = fmtTel(base.celular); base.tel1 = fmtTel(base.tel1); base.tel2 = fmtTel(base.tel2);
const erros = validar(base);
if(erros.length){ toast('Erros: '+erros.join(' | '), 'err'); return; }
let r;
if(editingId){
r = DB.find(x=>x.id===editingId);
Object.assign(r, base);
r.atualizadoEm = new Date().toISOString();
(r.historico||[]).push({quando:new Date().toISOString(), acao:'atualizado', por:'Usuário padrão'});
} else {
r = {id:uid(), foto: $('#fotoPreview').src || '', anexos:[], criadoEm:new Date().toISOString(), atualizadoEm:new Date().toISOString(), historico:[{quando:new Date().toISOString(), acao:'criado', por:'Usuário padrão'}], atendimentos:{ultimo:null, proximo:null}, ...base};
DB.unshift(r);
}
saveDB(DB);
refreshConvenios();
applyFilters();
dlgForm.close();
toast('Paciente salvo com sucesso', 'ok');
}
function excluirPaciente(id){
const r = DB.find(x=>x.id===id);
if(!r) return;
// valida se há atendimentos futuros vinculados
if(r.atendimentos?.proximo){
toast('Não é possível excluir: há atendimento agendado. Cancele antes.', 'err');
return;
}
if(confirm('Confirma excluir este paciente?')){
DB = DB.filter(x=>x.id!==id); saveDB(DB); applyFilters(); toast('Excluído', 'ok');
}
}
// ===== CEP lookup (ViaCEP) =====
async function buscarCEP(){
const cep = $('#cep').value.replace(/\D/g,'');
if(cep.length!==8) return;
try{
const r = await fetch(`https://viacep.com.br/ws/${cep}/json/`);
const j = await r.json();
if(j.erro){ toast('CEP não encontrado','err'); return; }
$('#logradouro').value=j.logradouro||''; $('#bairro').value=j.bairro||''; $('#cidade').value=j.localidade||''; $('#estado').value=j.uf||'';
}catch(err){ console.error(err); toast('Falha ao consultar CEP','err'); }
}
// ===== Histórico =====
function abrirHistorico(){
const r = DB.find(x=>x.id===editingId); if(!r) return;
const body = $('#histBody'); body.innerHTML = (r.historico||[]).map(h=>`<div class="tag"><strong>${new Date(h.quando).toLocaleString()}</strong> — ${h.acao} por ${h.por}</div>`).join('');
dlgHistorico.showModal();
}
// ===== Consulta =====
function abrirConsulta(id){ editingId=id; $('#cData').value=''; $('#cHora').value=''; $('#cObs').value=''; dlgConsulta.showModal(); }
function salvarConsulta(){
const r = DB.find(x=>x.id===editingId); if(!r) return;
if(!$('#cData').value || !$('#cHora').value){ toast('Informe data e hora','err'); return; }
const when = new Date(`${$('#cData').value}T${$('#cHora').value}:00`);
r.atendimentos = r.atendimentos||{}; r.atendimentos.proximo = when.toISOString(); r.historico.push({quando:new Date().toISOString(), acao:'consulta agendada', por:'Usuário padrão'});
saveDB(DB); applyFilters(); dlgConsulta.close(); toast('Consulta marcada', 'ok');
}
// ===== Anexos =====
function removerAnexo(idx){
const r = DB.find(x=>x.id===editingId); if(!r) return; r.anexos.splice(idx,1); saveDB(DB); preencherForm(r);
}
// ===== CSV Import =====
async function importCSV(file){
const text = await file.text();
const lines = text.split(/\r?\n/).filter(Boolean);
const head = lines.shift().split(',').map(s=>s.trim().toLowerCase());
const rows = lines.map(l=>{
const cols = l.split(','); const rec = {};
head.forEach((h,i)=> rec[h] = (cols[i]||'').trim());
return rec;
});
let ok=0, fail=0;
rows.forEach(rec=>{
const novo = {
id: uid(), nome: rec.nome||'', nomeSocial:'', cpf:(rec.cpf||'').replace(/\D/g,''), rg:rec.rg||'', docTipo:'', docNumero:'',
sexo:'', nascimento: rec.nascimento||'', etnia:'', raca:'', naturalidade:'', nacionalidade:'Brasil',
profissao:'', estadoCivil:'', mae:'', profMae:'', pai:'', profPai:'', responsavel:'', cpfResponsavel:'', conjuge:'', rnGuia:false, codigoLegado:'',
convenio: rec.convenio||'', vip: /^true|1|sim$/i.test(rec.vip||''), obs:'', email: rec.email||'', celular: rec.celular||'', tel1:'', tel2:'',
endereco:{cep:'',logradouro:'',numero:'',complemento:'',bairro:'',cidade:rec.cidade||'',estado:rec.estado||'',referencia:''},
foto:'', anexos:[], criadoEm:new Date().toISOString(), atualizadoEm:new Date().toISOString(), historico:[{quando:new Date().toISOString(), acao:'importado CSV', por:'Usuário padrão'}], atendimentos:{ultimo:null, proximo:null}, custom:{}
};
const errs = validar(novo);
if(errs.length){ fail++; } else { DB.push(novo); ok++; }
});
saveDB(DB); refreshConvenios(); applyFilters();
toast(`Importação concluída. Sucesso: ${ok} | Ignorados: ${fail}`, ok? 'ok':'');
}
// ===== Campos personalizados =====
function renderCFAdmin(){
const list = loadCF();
$('#cfLista').innerHTML = list.length? list.map((c,idx)=>`<div class="tag">${c.nome} <span class="muted">(${c.tipo})</span><code>${c.key}</code> <button class="btn" onclick="remCF(${idx})">Remover</button></div>`).join('') : '<div class="muted">Nenhum campo adicionado.</div>'
}
function remCF(idx){ const list = loadCF(); list.splice(idx,1); saveCF(list); renderCFAdmin(); }
function addCF(){
const nome = $('#cfNome').value.trim(); const tipo=$('#cfTipo').value; const key=$('#cfKey').value.trim();
if(!nome||!key) return toast('Informe nome e chave','err');
const list = loadCF(); if(list.find(x=>x.key===key)) return toast('Chave já existe','err');
list.push({nome, tipo, key}); saveCF(list); $('#cfNome').value=''; $('#cfKey').value=''; renderCFAdmin(); toast('Campo adicionado','ok');
}
function renderCustomFields(values){
const defs = loadCF();
const host = $('#customFields'); host.innerHTML='';
defs.forEach(def=>{
const wrap = document.createElement('div'); wrap.className='field';
wrap.innerHTML = `<label>${def.nome}</label>${
def.tipo==='data'? `<input type="date" data-cf="${def.key}" value="${values[def.key]||''}">`:
def.tipo==='numero'? `<input type="number" data-cf="${def.key}" value="${values[def.key]||''}">`:
`<input data-cf="${def.key}" value="${values[def.key]||''}">`
}`;
host.appendChild(wrap);
})
}
function collectCustomFields(){
const values={}; $$('[data-cf]').forEach(el=> values[el.getAttribute('data-cf')] = el.value ); return values;
}
// ===== Helpers =====
function calcIdade(nasc){ if(!nasc) return null; const d=new Date(nasc); const t=new Date(); let a=t.getFullYear()-d.getFullYear(); const m=t.getMonth()-d.getMonth(); if(m<0||(m===0 && t.getDate()<d.getDate())) a--; return a; }
// ===== Eventos =====
const dlgForm = $('#dlgForm'); const dlgFiltro = $('#dlgFiltro'); const dlgHistorico = $('#dlgHistorico'); const dlgConsulta = $('#dlgConsulta'); const dlgImport = $('#dlgImport'); const dlgCampos = $('#dlgCampos');
// busca e filtros básicos
$('#q').addEventListener('input', applyFilters);
$('#filterConvenio').addEventListener('change', applyFilters);
$('#filterVip').addEventListener('click', e=>{ const on = e.currentTarget.getAttribute('aria-pressed')==='true'; e.currentTarget.setAttribute('aria-pressed', String(!on)); applyFilters(); })
$('#filterMes').addEventListener('change', applyFilters);
$('#btnFiltroAvancado').addEventListener('click', ()=> dlgFiltro.showModal());
// botões topo
$('#btnAdd').addEventListener('click', novoPaciente);
$('#btnImport').addEventListener('click', ()=> dlgImport.showModal());
$('#btnCampos').addEventListener('click', ()=>{ renderCFAdmin(); dlgCampos.showModal(); });
// form
$('#formPaciente').addEventListener('submit', salvar);
$('#btnExcluir').addEventListener('click', ()=>{ if(editingId) excluirPaciente(editingId); dlgForm.close(); });
$('#btnHistorico').addEventListener('click', abrirHistorico);
$('#btnMarcarConsulta').addEventListener('click', ()=> abrirConsulta(editingId));
// foto
$('#foto').addEventListener('change', e=>{
const f = e.target.files[0]; if(!f) return; const rd = new FileReader(); rd.onload = ev => $('#fotoPreview').src = ev.target.result; rd.readAsDataURL(f);
})
// anexos
$('#inputAnexo').addEventListener('change', e=>{
const f = e.target.files[0]; if(!f) return; const rd = new FileReader(); rd.onload = ev =>{
const r = DB.find(x=>x.id===editingId); if(!r){ toast('Salve o cadastro antes de anexar','err'); return; }
(r.anexos=r.anexos||[]).push({nome:f.name, data:ev.target.result, dataIso:new Date().toISOString()});
r.historico.push({quando:new Date().toISOString(), acao:`anexo adicionado (${f.name})`, por:'Usuário padrão'});
saveDB(DB); preencherForm(r); toast('Anexo adicionado','ok');
}; rd.readAsDataURL(f);
})
// mascaras
$('#cpf').addEventListener('input', e=> e.target.value = maskCPF(e.target.value));
$('#cpfResponsavel').addEventListener('input', e=> e.target.value = maskCPF(e.target.value));
$('#cep').addEventListener('input', e=> e.target.value = maskCEP(e.target.value));
$('#cep').addEventListener('blur', buscarCEP);
// nome clicável abre prontuário
document.addEventListener('click', e=>{ if(e.target.classList.contains('linkNome')){ e.preventDefault(); abrirProntuario(e.target.dataset.id, true); }});
// salvar consulta
$('#btnSalvarConsulta').addEventListener('click', salvarConsulta);
// importar csv
$('#btnImportCsv').addEventListener('click', ()=>{ const f = $('#csvFile').files[0]; if(!f) return toast('Selecione um arquivo CSV','err'); importCSV(f); dlgImport.close(); });
// campos personalizados
$('#btnAddCampo').addEventListener('click', addCF);
// scroll infinito
const io = new IntersectionObserver(entries=>{
entries.forEach(en=>{ if(en.isIntersecting) loadMore(); })
});
io.observe($('#sentinel'));
// init
refreshConvenios();
applyFilters();
</script>
</body>
</html>