feat(agenda): import como migracao (status importado) + confirmar/vincular paciente - Fase 2

- import entra como status 'importado' (provisorio): separa titulo NOME x PROCEDIMENTO, traz description do Google em observacoes, nao pontua
- 'importado' no CHECK de status
- falta bloqueada sem paciente vinculado / importado
- POST /agendamentos/:id/confirmar: vincula paciente, EXIGE cpf+telefone (enriquece cadastro), vira agendado
- pendencia 'a reagendar' so com paciente vinculado
- frontend: modal do importado mostra 'A CONFIRMAR' + Confirmar paciente (busca com desambiguacao homonimos: cpf/tel/nascimento + criar novo) + Remover; ScoreBadge

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-06-10 17:59:37 +02:00
parent 479a53e105
commit aae881fa6d
3 changed files with 193 additions and 7 deletions
+47 -6
View File
@@ -1970,7 +1970,7 @@ app.delete('/api/agendamentos/:id', authGuard, async (req, res) => {
clinicaId: a.clinica_id, entidade: 'agendamento', entidadeId: a.id, acao: 'cancelou',
actorId: actor, actorNome: actorNm, detalhes: { motivo: req.body?.motivo || null, start: a.start_time }
});
await abrirPendencia(pool, { clinicaId: a.clinica_id, pacienteId: a.pacienteid, pacienteNome: a.pacientenome, origemId: a.id, motivo: 'cancelado' });
if (a.pacienteid) await abrirPendencia(pool, { clinicaId: a.clinica_id, pacienteId: a.pacienteid, pacienteNome: a.pacientenome, origemId: a.id, motivo: 'cancelado' });
await notificarInteressesSlotLivre(a);
pushGoogleEvent('delete', a); // remove a cópia/notificação do Google
res.json({ success: true });
@@ -1985,6 +1985,10 @@ app.post('/api/agendamentos/:id/falta', authGuard, async (req, res) => {
const { rows: ag } = await pool.query('SELECT * FROM agendamentos WHERE id = $1', [req.params.id]);
if (!ag.length) return res.status(404).json({ success: false, message: 'Agendamento não encontrado.' });
const a = ag[0];
// Falta exige paciente vinculado (importado/provisório não pontua).
if (!a.pacienteid || String(a.status).toLowerCase() === 'importado') {
return res.status(400).json({ success: false, message: 'Vincule um paciente (confirme o agendamento) antes de marcar falta.' });
}
await pool.query(`UPDATE agendamentos SET status='falta', updated_by=$2, updated_by_nome=$3, updated_at=NOW(), version=COALESCE(version,1)+1 WHERE id=$1`, [req.params.id, actor, actorNm]);
await registrarAudit(pool, { clinicaId: a.clinica_id, entidade: 'agendamento', entidadeId: a.id, acao: 'faltou', actorId: actor, actorNome: actorNm, detalhes: { start: a.start_time } });
await abrirPendencia(pool, { clinicaId: a.clinica_id, pacienteId: a.pacienteid, pacienteNome: a.pacientenome, origemId: a.id, motivo: 'falta' });
@@ -2003,7 +2007,7 @@ app.post('/api/agendamentos/:id/remarcar', authGuard, async (req, res) => {
const a = ag[0];
await pool.query(`UPDATE agendamentos SET status='remarcar', updated_by=$2, updated_by_nome=$3, updated_at=NOW(), version=COALESCE(version,1)+1 WHERE id=$1`, [req.params.id, actor, actorNm]);
await registrarAudit(pool, { clinicaId: a.clinica_id, entidade: 'agendamento', entidadeId: a.id, acao: 'remarcou', actorId: actor, actorNome: actorNm, detalhes: { motivo: req.body?.motivo || null, start: a.start_time } });
await abrirPendencia(pool, { clinicaId: a.clinica_id, pacienteId: a.pacienteid, pacienteNome: a.pacientenome, origemId: a.id, motivo: 'remarcar' });
if (a.pacienteid) await abrirPendencia(pool, { clinicaId: a.clinica_id, pacienteId: a.pacienteid, pacienteNome: a.pacientenome, origemId: a.id, motivo: 'remarcar' });
await notificarInteressesSlotLivre(a);
pushGoogleEvent('delete', a);
res.json({ success: true });
@@ -2026,6 +2030,36 @@ app.post('/api/agendamentos/:id/restaurar', authGuard, async (req, res) => {
} catch (err) { res.status(500).json({ error: err.message }); }
});
// CONFIRMAR um agendamento IMPORTADO: vincula o paciente (exige CPF+telefone) e vira 'agendado'.
// Não escreve no Google (vínculo é interno). O paciente é enriquecido se faltava CPF/telefone.
app.post('/api/agendamentos/:id/confirmar', authGuard, async (req, res) => {
try {
const actor = req.authUser?.userId || null;
const actorNm = await actorNome(actor);
const { pacienteId, cpf, telefone, procedimento } = req.body || {};
if (!pacienteId) return res.status(400).json({ success: false, message: 'Selecione um paciente.' });
const { rows: ag } = await pool.query('SELECT * FROM agendamentos WHERE id = $1', [req.params.id]);
if (!ag.length) return res.status(404).json({ success: false, message: 'Agendamento não encontrado.' });
const a = ag[0];
const { rows: pr } = await pool.query('SELECT id, nome, cpf, telefone FROM pacientes WHERE id = $1', [pacienteId]);
if (!pr.length) return res.status(404).json({ success: false, message: 'Paciente não encontrado.' });
const pac = pr[0];
const novoCpf = String(cpf ?? pac.cpf ?? '').trim();
const novoTel = String(telefone ?? pac.telefone ?? '').trim();
if (!novoCpf || !novoTel) return res.status(400).json({ success: false, message: 'CPF e telefone são obrigatórios para confirmar.' });
// Enriquece o cadastro do paciente se faltava CPF/telefone.
if (novoCpf !== String(pac.cpf || '') || novoTel !== String(pac.telefone || '')) {
await pool.query('UPDATE pacientes SET cpf = $1, telefone = $2 WHERE id = $3', [novoCpf.slice(0, 20), novoTel.slice(0, 30), pacienteId]);
}
await pool.query(
`UPDATE agendamentos SET pacienteid=$1, pacientenome=$2, procedimento=COALESCE($3, procedimento), status='agendado',
updated_by=$4, updated_by_nome=$5, updated_at=NOW(), version=COALESCE(version,1)+1 WHERE id=$6`,
[pacienteId, pac.nome, procedimento || null, actor, actorNm, a.id]);
await registrarAudit(pool, { clinicaId: a.clinica_id, entidade: 'agendamento', entidadeId: a.id, acao: 'editou', actorId: actor, actorNome: actorNm, detalhes: { confirmou: true, paciente: pac.nome } });
res.json({ success: true });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
});
// Histórico (auditoria) de um agendamento.
app.get('/api/agendamentos/:id/historico', authGuard, async (req, res) => {
try {
@@ -2181,11 +2215,18 @@ app.post('/api/agenda/importar-google', authGuard, async (req, res) => {
if (ex.length) { pulados++; continue; }
const end = it.end?.dateTime || it.end?.date || start;
const cor = (it.colorId && GOOGLE_EVENT_COLORS[it.colorId]) || calColor || null;
// Separa "NOME - PROCEDIMENTO"; sem separador, o título inteiro vira o nome.
const titulo = String(it.summary || 'Importado do Google').trim();
const sep = titulo.indexOf(' - ');
const pacNome = sep > 0 ? titulo.slice(0, sep).trim() : titulo;
const proc = sep > 0 ? titulo.slice(sep + 3).trim() : null;
const obs = it.description ? String(it.description).slice(0, 2000) : null;
const id = `ag_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
// status 'importado' = PROVISÓRIO (não pontua, não conta falta até confirmar/vincular paciente).
await pool.query(
`INSERT INTO agendamentos (id, clinica_id, pacientenome, start_time, end_time, status, procedimento, google_event_id, cor, created_by, created_by_nome, version)
VALUES ($1,$2,$3,$4,$5,'agendado',$6,$7,$8,$9,$10,1)`,
[id, clinicaId, it.summary || 'Importado do Google', start, end, 'IMPORTADO (GOOGLE)', it.id, cor, actor, actorNm]);
`INSERT INTO agendamentos (id, clinica_id, pacientenome, start_time, end_time, status, procedimento, observacoes, google_event_id, cor, created_by, created_by_nome, version)
VALUES ($1,$2,$3,$4,$5,'importado',$6,$7,$8,$9,$10,$11,1)`,
[id, clinicaId, pacNome, start, end, proc, obs, it.id, cor, actor, actorNm]);
await registrarAudit(pool, { clinicaId, entidade: 'agendamento', entidadeId: id, acao: 'criou', actorId: actor, actorNome: actorNm, detalhes: { origem: 'google', summary: it.summary } });
criados++;
}
@@ -3444,7 +3485,7 @@ async function runMigrations() {
// ── Agenda Fase 1: autoria, soft-delete, faltas, auditoria, pendências ──
// Novo vocabulário de status (o CHECK antigo barrava 'agendado'/'cancelado').
`ALTER TABLE agendamentos DROP CONSTRAINT IF EXISTS agendamentos_status_check`,
`ALTER TABLE agendamentos ADD CONSTRAINT agendamentos_status_check CHECK (status IS NULL OR lower(status) IN ('agendado','confirmado','reagendado','remarcar','cancelado','falta','atendido','pendente','concluido','faltou'))`,
`ALTER TABLE agendamentos ADD CONSTRAINT agendamentos_status_check CHECK (status IS NULL OR lower(status) IN ('importado','agendado','confirmado','reagendado','remarcar','cancelado','falta','atendido','pendente','concluido','faltou'))`,
// Slot único só vale para status ATIVOS — assim cancelar/faltar libera o slot p/ reagendar.
`ALTER TABLE agendamentos DROP CONSTRAINT IF EXISTS unique_dentista_slot`,
`CREATE UNIQUE INDEX IF NOT EXISTS unique_dentista_slot_active ON agendamentos (dentistaid, start_time) WHERE lower(status) NOT IN ('cancelado','falta','remarcar')`,
+141 -1
View File
@@ -4,7 +4,8 @@ import { HybridBackend } from '../services/backend.ts';
import { useToast } from '../contexts/ToastContext.tsx';
import {
User, Stethoscope, Clock, CalendarRange, AlertCircle, CheckCircle2,
History, RotateCcw, CalendarClock, Ban, Users, Pencil, ClipboardCheck, ShieldAlert
History, RotateCcw, CalendarClock, Ban, Users, Pencil, ClipboardCheck, ShieldAlert,
Search, UserPlus, Link2, Trash2
} from 'lucide-react';
interface Props {
@@ -23,11 +24,13 @@ const _cache: Record<string, { faltas?: number; familia?: { grupo: any; membros:
const low = (s: any) => String(s || '').toLowerCase();
const STATUS_LABEL: Record<string, string> = {
importado: 'A CONFIRMAR · GOOGLE',
agendado: 'AGENDADO', confirmado: 'CONFIRMADO', reagendado: 'REAGENDADO',
remarcar: 'A REAGENDAR', cancelado: 'CANCELADO', falta: 'FALTOU', atendido: 'ATENDIDO',
pendente: 'PENDENTE', concluido: 'CONCLUÍDO', faltou: 'FALTOU',
};
const STATUS_COLOR: Record<string, string> = {
importado: 'bg-violet-50 text-violet-700',
cancelado: 'bg-red-50 text-red-600', falta: 'bg-red-50 text-red-600', faltou: 'bg-red-50 text-red-600',
remarcar: 'bg-amber-50 text-amber-600', confirmado: 'bg-emerald-50 text-emerald-600',
atendido: 'bg-emerald-50 text-emerald-600', concluido: 'bg-emerald-50 text-emerald-600',
@@ -143,11 +146,125 @@ const HistoryPage: React.FC<{ appointmentId: string }> = ({ appointmentId }) =>
);
};
// Página de CONFIRMAÇÃO de um importado: busca/desambigua paciente, exige CPF+telefone, vincula.
const ConfirmImportadoPage: React.FC<{ ag: any; onConfirmed: () => void }> = ({ ag, onConfirmed }) => {
const { close } = useStackModal();
const toast = useToast();
const [q, setQ] = useState('');
const [results, setResults] = useState<any[]>([]);
const [sel, setSel] = useState<any | null>(null);
const [novo, setNovo] = useState(false);
const [nome, setNome] = useState(ag.pacienteNome || ag.pacientenome || '');
const [cpf, setCpf] = useState('');
const [tel, setTel] = useState('');
const [proc, setProc] = useState(ag.procedimento || '');
const [saving, setSaving] = useState(false);
useEffect(() => {
let alive = true;
const t = setTimeout(() => {
HybridBackend.getPacientes(q, 'recentes').then(r => alive && setResults((r.data || []).slice(0, 8))).catch(() => { });
}, 300);
return () => { alive = false; clearTimeout(t); };
}, [q]);
const pick = (pac: any) => { setSel(pac); setNovo(false); setCpf(pac.cpf || ''); setTel(pac.telefone || ''); };
const iniciarNovo = () => { setNovo(true); setSel(null); setCpf(''); setTel(''); };
const confirmar = async () => {
if (!cpf.trim() || !tel.trim()) { toast.error('CPF E TELEFONE SÃO OBRIGATÓRIOS.'); return; }
setSaving(true);
try {
let pacienteId = sel?.id;
if (novo) {
if (!nome.trim()) { toast.error('INFORME O NOME.'); setSaving(false); return; }
pacienteId = `p_${Date.now()}`;
await HybridBackend.savePaciente({ id: pacienteId, nome, cpf, telefone: tel } as any);
}
if (!pacienteId) { toast.error('SELECIONE UM PACIENTE OU CRIE UM NOVO.'); setSaving(false); return; }
const res = await HybridBackend.confirmarAgendamento(ag.id, { pacienteId, cpf, telefone: tel, procedimento: proc || undefined });
if (!res.success) { toast.error(res.message || 'ERRO AO CONFIRMAR.'); return; }
toast.success('AGENDAMENTO CONFIRMADO!');
onConfirmed(); close();
} catch { toast.error('ERRO AO CONFIRMAR.'); } finally { setSaving(false); }
};
const faltaDados = !cpf.trim() || !tel.trim();
return (
<div className="space-y-3">
<p className="text-[11px] text-gray-500 leading-snug">Vincule o paciente do cadastro. Se houver homônimos, use CPF/telefone/nascimento para identificar. CPF e telefone são obrigatórios.</p>
{!novo && (
<>
<div className="relative">
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input autoFocus value={q} onChange={e => setQ(e.target.value)} placeholder="Buscar por nome, CPF ou telefone..."
className="w-full pl-9 pr-3 py-2.5 border border-gray-200 rounded-xl text-sm focus:outline-none focus:border-blue-400" />
</div>
<div className="max-h-52 overflow-y-auto custom-scrollbar space-y-1.5">
{results.map(pac => (
<button key={pac.id} onClick={() => pick(pac)}
className={`w-full text-left p-2.5 rounded-xl border transition-colors ${sel?.id === pac.id ? 'border-blue-400 bg-blue-50' : 'border-gray-100 hover:bg-gray-50'}`}>
<p className="text-xs font-black text-gray-800 uppercase truncate">{pac.nome}</p>
<p className="text-[10px] text-gray-400 font-bold">
{pac.cpf ? `CPF ${pac.cpf}` : 'sem CPF'} · {pac.telefone || 'sem tel'}{pac.datanascimento ? ` · ${pac.datanascimento}` : ''}
</p>
</button>
))}
{q && results.length === 0 && <p className="text-[11px] text-gray-400 text-center py-3 uppercase font-bold">Nenhum paciente encontrado.</p>}
</div>
<button onClick={iniciarNovo} className="w-full flex items-center justify-center gap-1.5 py-2.5 rounded-xl border-2 border-dashed border-gray-200 text-[11px] font-black uppercase text-gray-500 hover:border-blue-300 hover:text-blue-600 transition-colors">
<UserPlus size={15} /> Criar novo paciente
</button>
</>
)}
{novo && (
<div className="space-y-2">
<label className="text-[10px] font-black text-gray-500 uppercase">Novo paciente</label>
<input value={nome} onChange={e => setNome(e.target.value.toUpperCase())} placeholder="NOME COMPLETO"
className="w-full px-3 py-2.5 border border-gray-200 rounded-xl text-sm font-bold focus:outline-none focus:border-blue-400" />
<button onClick={() => setNovo(false)} className="text-[10px] font-black text-blue-600 uppercase"> voltar à busca</button>
</div>
)}
{(sel || novo) && (
<div className="bg-gray-50 rounded-xl p-3 space-y-2">
{sel && <p className="text-xs font-black text-gray-800 uppercase">{sel.nome}</p>}
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-[9px] font-black text-gray-400 uppercase">CPF *</label>
<input value={cpf} onChange={e => setCpf(e.target.value)} placeholder="000.000.000-00"
className={`w-full px-2.5 py-2 border rounded-lg text-xs font-bold focus:outline-none ${!cpf.trim() ? 'border-amber-300 bg-amber-50' : 'border-gray-200 focus:border-blue-400'}`} />
</div>
<div>
<label className="text-[9px] font-black text-gray-400 uppercase">Telefone *</label>
<input value={tel} onChange={e => setTel(e.target.value)} placeholder="(00) 00000-0000"
className={`w-full px-2.5 py-2 border rounded-lg text-xs font-bold focus:outline-none ${!tel.trim() ? 'border-amber-300 bg-amber-50' : 'border-gray-200 focus:border-blue-400'}`} />
</div>
</div>
<div>
<label className="text-[9px] font-black text-gray-400 uppercase">Procedimento</label>
<input value={proc} onChange={e => setProc(e.target.value.toUpperCase())} placeholder="EX: AVALIAÇÃO"
className="w-full px-2.5 py-2 border border-gray-200 rounded-lg text-xs font-bold focus:outline-none focus:border-blue-400" />
</div>
{faltaDados && <p className="text-[10px] text-amber-600 font-bold">Preencha CPF e telefone para confirmar.</p>}
<button onClick={confirmar} disabled={saving || faltaDados}
className="w-full py-2.5 bg-emerald-600 text-white rounded-xl text-[11px] font-black uppercase hover:bg-emerald-700 transition-all disabled:opacity-50 flex items-center justify-center gap-1.5">
<Link2 size={15} /> {saving ? 'CONFIRMANDO...' : 'Confirmar e vincular'}
</button>
</div>
)}
</div>
);
};
const DetailPage: React.FC<Props> = (p) => {
const { push, close } = useStackModal();
const toast = useToast();
const ag = p.appointment || {};
const status = low(ag.status);
const provisorio = status === 'importado';
const inativo = ['cancelado', 'falta', 'remarcar', 'faltou'].includes(status);
const pacienteId = ag.pacienteid || ag.pacienteId || '';
const dentNome = ag.dentistaNome || p.dentists?.find(d => d.id === (ag.dentistaId || ag.dentistaid))?.nome || '—';
@@ -210,6 +327,28 @@ const DetailPage: React.FC<Props> = (p) => {
{status === 'cancelado' && ag.canceled_by_nome && <p className="text-red-400">Cancelado por <b className="text-red-600">{ag.canceled_by_nome}</b></p>}
</div>
{/* Importado do Google (provisório): só confirmar/vincular ou remover. Não pontua. */}
{provisorio ? (
<div className="space-y-2">
<div className="bg-violet-50 border border-violet-100 rounded-xl p-3 text-[11px] text-violet-700 leading-snug">
Importado do Google. <b>Confirme o paciente</b> para virar um agendamento do sistema ( passa a contar faltas/remarcações).
</div>
<button onClick={() => push({ title: 'CONFIRMAR PACIENTE', render: () => <ConfirmImportadoPage ag={ag} onConfirmed={p.onChanged} /> })}
className="w-full py-3 bg-emerald-600 text-white rounded-xl text-[11px] font-black uppercase hover:bg-emerald-700 transition-all flex items-center justify-center gap-1.5">
<Link2 size={15} /> Confirmar paciente
</button>
<div className="flex gap-2">
<button onClick={() => push({ title: 'HISTÓRICO', render: () => <HistoryPage appointmentId={ag.id} /> })}
className="flex-1 py-2.5 rounded-xl bg-gray-50 hover:bg-gray-100 text-xs font-black uppercase text-gray-600 flex items-center justify-center gap-1.5">
<History size={15} /> Histórico
</button>
<button onClick={() => { if (confirm('REMOVER ESTE IMPORTADO DA AGENDA?')) run(() => HybridBackend.excluirAgendamento(ag.id), 'IMPORTADO REMOVIDO.'); }}
className="flex-1 py-2.5 rounded-xl border-2 border-gray-100 text-xs font-black uppercase text-gray-500 hover:border-red-200 hover:text-red-600 transition-all flex items-center justify-center gap-1.5">
<Trash2 size={15} /> Remover
</button>
</div>
</div>
) : (<>
{/* Ações de status */}
<div className="grid grid-cols-2 gap-2">
<button onClick={() => run(() => HybridBackend.atualizarAgendamento(ag.id, { status: 'confirmado' as any }), 'CONFIRMADO!')}
@@ -259,6 +398,7 @@ const DetailPage: React.FC<Props> = (p) => {
</button>
</div>
</div>
</>)}
</div>
);
};
+5
View File
@@ -785,6 +785,11 @@ export const HybridBackend = {
const res = await apiFetch(`${API_URL}/agendamentos/${id}/restaurar`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
return await res.json().catch(() => ({ success: res.ok }));
},
// Confirma um agendamento IMPORTADO: vincula paciente (exige CPF+telefone) → vira 'agendado'.
confirmarAgendamento: async (id: string, payload: { pacienteId: string; cpf?: string; telefone?: string; procedimento?: string }) => {
const res = await apiFetch(`${API_URL}/agendamentos/${id}/confirmar`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
return await res.json().catch(() => ({ success: res.ok }));
},
getHistoricoAgendamento: async (id: string): Promise<any[]> => {
const res = await apiFetch(`${API_URL}/agendamentos/${id}/historico`, {});
const j = await res.json().catch(() => ({} as any));