diff --git a/backend/server.js b/backend/server.js index 7da11b1..d3f7b25 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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')`, diff --git a/frontend/components/AgendaDetailModal.tsx b/frontend/components/AgendaDetailModal.tsx index 857ca89..9b4473a 100644 --- a/frontend/components/AgendaDetailModal.tsx +++ b/frontend/components/AgendaDetailModal.tsx @@ -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(s || '').toLowerCase(); const STATUS_LABEL: Record = { + 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 = { + 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([]); + const [sel, setSel] = useState(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 ( +
+

Vincule o paciente do cadastro. Se houver homônimos, use CPF/telefone/nascimento para identificar. CPF e telefone são obrigatórios.

+ + {!novo && ( + <> +
+ + 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" /> +
+
+ {results.map(pac => ( + + ))} + {q && results.length === 0 &&

Nenhum paciente encontrado.

} +
+ + + )} + + {novo && ( +
+ + 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" /> + +
+ )} + + {(sel || novo) && ( +
+ {sel &&

{sel.nome}

} +
+
+ + 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'}`} /> +
+
+ + 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'}`} /> +
+
+
+ + 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" /> +
+ {faltaDados &&

Preencha CPF e telefone para confirmar.

} + +
+ )} +
+ ); +}; + const DetailPage: React.FC = (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 = (p) => { {status === 'cancelado' && ag.canceled_by_nome &&

Cancelado por {ag.canceled_by_nome}

} + {/* Importado do Google (provisório): só confirmar/vincular ou remover. Não pontua. */} + {provisorio ? ( +
+
+ Importado do Google. Confirme o paciente para virar um agendamento do sistema (aí passa a contar faltas/remarcações). +
+ +
+ + +
+
+ ) : (<> {/* Ações de status */}
+ )} ); }; diff --git a/frontend/services/backend.ts b/frontend/services/backend.ts index 0a5e662..fe9712e 100644 --- a/frontend/services/backend.ts +++ b/frontend/services/backend.ts @@ -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 => { const res = await apiFetch(`${API_URL}/agendamentos/${id}/historico`, {}); const j = await res.json().catch(() => ({} as any));