riseup-squad19/Teste do site completo.html
2025-08-16 07:56:07 -03:00

859 lines
43 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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>