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:
+47
-6
@@ -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')`,
|
||||
|
||||
Reference in New Issue
Block a user