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')`,