From 86cf253161679d93e01e7e6e5b8bdd4695aaa724 Mon Sep 17 00:00:00 2001 From: VPS 4 Builder Date: Fri, 10 Jul 2026 06:07:20 +0200 Subject: [PATCH] =?UTF-8?q?feat(agenda):=20pedidos=20(dia/hor=C3=A1rio),?= =?UTF-8?q?=20check-in=20com=20confirma=C3=A7=C3=A3o=20de=20dados?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PedidosPendentes: ajuste de DIA (mini-calendário) além do horário; abrir chat. - ConfirmarDadosPaciente: modal no check-in (Atender) p/ pacientes cadastrados pela Secretária — confere nome/nascimento/CPF/telefone (número próprio) + grupo familiar, valida e marca confirmado. - AgendaView: dispara o modal no Atender; backend.ts getPacienteById/confirmar. Co-Authored-By: Claude Opus 4.8 --- .../components/ConfirmarDadosPaciente.tsx | 135 +++++++++++ frontend/components/PedidosPendentes.tsx | 214 ++++++++++++++++-- frontend/services/backend.ts | 22 +- frontend/views/AgendaView.tsx | 56 ++++- 4 files changed, 404 insertions(+), 23 deletions(-) create mode 100644 frontend/components/ConfirmarDadosPaciente.tsx diff --git a/frontend/components/ConfirmarDadosPaciente.tsx b/frontend/components/ConfirmarDadosPaciente.tsx new file mode 100644 index 0000000..3dac279 --- /dev/null +++ b/frontend/components/ConfirmarDadosPaciente.tsx @@ -0,0 +1,135 @@ +// Feature C — modal de confirmação de dados no CHECK-IN. Abre quando a recepção +// clica "Atender" e o paciente veio da Secretária (dados_confirmados=false). A recepção +// confere/ajusta Nome, Data de nascimento, CPF e Telefone (o número PRÓPRIO deste +// paciente — um dependente pode ter ganhado WhatsApp próprio) e vê o grupo familiar. +// Salva na base de Pacientes e marca dados_confirmados=true (o modal para de abrir). +import React, { useState, useEffect } from 'react'; +import { X, Loader2, Check, Users, ShieldCheck } from 'lucide-react'; +import { HybridBackend } from '../services/backend'; +import { useToast } from '../contexts/ToastContext.tsx'; + +// Normaliza p/ YYYY-MM-DD (aceita DD/MM/AAAA); senão devolve vazio p/ o . +const paraInputDate = (s?: string | null): string => { + const v = String(s || '').trim(); + if (/^\d{4}-\d{2}-\d{2}$/.test(v)) return v; + const m = v.match(/^(\d{1,2})[/-](\d{1,2})[/-](\d{4})$/); + if (m) return `${m[3]}-${m[2].padStart(2, '0')}-${m[1].padStart(2, '0')}`; + return ''; +}; + +interface Props { + pacienteId: string | null; + isOpen: boolean; + onClose: () => void; + onConfirmed: () => void; // segue o fluxo (ex.: prosseguir com o Atender) +} + +export const ConfirmarDadosPaciente: React.FC = ({ pacienteId, isOpen, onClose, onConfirmed }) => { + const toast = useToast(); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [nome, setNome] = useState(''); + const [nasc, setNasc] = useState(''); + const [cpf, setCpf] = useState(''); + const [tel, setTel] = useState(''); + const [familia, setFamilia] = useState<{ grupo: any; membros: any[] }>({ grupo: null, membros: [] }); + + useEffect(() => { + if (!isOpen || !pacienteId) return; + let alive = true; + setLoading(true); + Promise.all([ + HybridBackend.getPacienteById(pacienteId), + HybridBackend.getFamiliaPaciente(pacienteId).catch(() => ({ grupo: null, membros: [] })), + ]).then(([p, fam]) => { + if (!alive) return; + setNome(p?.nome || ''); setNasc(paraInputDate(p?.datanascimento)); setCpf(p?.cpf || ''); setTel(p?.telefone || ''); + setFamilia(fam as any); + }).catch(() => { if (alive) toast.error('Não foi possível carregar o paciente.'); }) + .finally(() => { if (alive) setLoading(false); }); + return () => { alive = false; }; + }, [isOpen, pacienteId]); + + if (!isOpen || !pacienteId) return null; + + const salvar = async () => { + if (!nome.trim()) { toast.error('Informe o nome.'); return; } + setSaving(true); + try { + const r = await HybridBackend.confirmarDadosPaciente(pacienteId, { + nome: nome.trim(), cpf: cpf.trim() || undefined, telefone: tel.trim() || undefined, data_nascimento: nasc || undefined, + }); + if (!r.ok) { toast.error(r.mensagem || 'Dados inválidos.'); setSaving(false); return; } + toast.success('Dados confirmados!'); + onConfirmed(); onClose(); + } catch { toast.error('Erro ao confirmar.'); } finally { setSaving(false); } + }; + + return ( +
+
e.stopPropagation()}> +
+
+
+
+

Confirme os dados

+

Cadastro veio da Secretária IA · confira no check-in

+
+
+ +
+ + {loading ? ( +
+ ) : ( +
+ setNome(e.target.value)} className={inpCls} /> +
+ setNasc(e.target.value)} className={inpCls} /> + setCpf(e.target.value)} placeholder="000.000.000-00" inputMode="numeric" className={inpCls} /> +
+ + setTel(e.target.value)} placeholder="(67) 90000-0000" inputMode="tel" className={inpCls} /> +

Se for dependente com WhatsApp próprio, ajuste o número dele aqui.

+
+ + {/* Grupo familiar (contexto) */} + {(familia.grupo || familia.membros.length > 0) && ( +
+

+ Grupo familiar{familia.grupo?.nome ? ` · ${familia.grupo.nome}` : ''} +

+
+ {familia.membros.map((m: any) => ( +
+ + {m.nome} + {String(m.relacao || m.grupofamiliarrelacao || '').toLowerCase() === 'titular' && titular} +
+ ))} + {familia.membros.length === 0 &&

Sem outros membros vinculados.

} +
+
+ )} +
+ )} + +
+ + +
+
+
+ ); +}; + +const inpCls = 'w-full bg-gray-50 border-2 border-transparent focus:border-teal-500 rounded-xl px-3 py-2 text-sm outline-none'; +const Campo: React.FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => ( +
+ + {children} +
+); diff --git a/frontend/components/PedidosPendentes.tsx b/frontend/components/PedidosPendentes.tsx index 247b37c..5ccb691 100644 --- a/frontend/components/PedidosPendentes.tsx +++ b/frontend/components/PedidosPendentes.tsx @@ -2,7 +2,7 @@ // registrou ("vou confirmar e retorno"). A humana confirma (vira agendamento) ou // recusa. Consome /api/nw/agenda-config/clinica/:id/pedidos*. import React, { useState, useEffect, useCallback } from 'react'; -import { X, CalendarClock, Check, Ban, Loader2, Phone, User, HelpCircle } from 'lucide-react'; +import { X, CalendarClock, Check, Ban, Loader2, Phone, User, HelpCircle, MessageCircle, ChevronDown, ChevronLeft, ChevronRight, Clock, Sparkles, CalendarDays } from 'lucide-react'; const API = (import.meta as any).env?.VITE_API_URL || '/api'; const BASE = `${API}/nw/agenda-config`; @@ -17,10 +17,85 @@ async function req(path: string, opts: RequestInit = {}) { const dm = (s: string) => s ? s.split('-').reverse().join('/') : s; -export const PedidosPendentes: React.FC<{ isOpen: boolean; onClose: () => void; clinicaId?: string; onChange?: () => void }> = ({ isOpen, onClose, clinicaId, onChange }) => { +// Grade de horários (07:00–20:00, passo 30min) para AJUSTAR o horário na confirmação. +// Garante que o horário sugerido pela IA esteja sempre presente (mesmo fora do passo). +function gerarSlots(sugerido?: string): string[] { + const set = new Set(); + for (let m = 7 * 60; m <= 20 * 60; m += 30) { + set.add(`${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`); + } + const s = (sugerido || '').slice(0, 5); + if (/^\d{2}:\d{2}$/.test(s)) set.add(s); + return [...set].sort(); +} + +// ── Mini-calendário (troca de DIA na confirmação) ──────────────────────────── +const WD = ['D', 'S', 'T', 'Q', 'Q', 'S', 'S']; +const MESES = ['Janeiro', 'Fevereiro', 'Março', 'Abril', 'Maio', 'Junho', 'Julho', 'Agosto', 'Setembro', 'Outubro', 'Novembro', 'Dezembro']; +const toISO = (y: number, m: number, d: number) => `${y}-${String(m + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`; +const hojeISO = () => { const n = new Date(); return toISO(n.getFullYear(), n.getMonth(), n.getDate()); }; + +// Calendário mensal compacto, sem libs. Dia sugerido pela IA marcado (bolinha âmbar), +// selecionado em verde, hoje com anel, passado desabilitado. Navega por mês. +const MiniCalendar: React.FC<{ value: string; suggested: string; onSelect: (iso: string) => void }> = ({ value, suggested, onSelect }) => { + const base = (value || suggested || hojeISO()).slice(0, 10); + const [view, setView] = useState<{ y: number; m: number }>({ y: Number(base.slice(0, 4)), m: Number(base.slice(5, 7)) - 1 }); + const hoje = hojeISO(); + const firstDow = new Date(view.y, view.m, 1).getDay(); + const nDias = new Date(view.y, view.m + 1, 0).getDate(); + const cells: (number | null)[] = [...Array(firstDow).fill(null), ...Array.from({ length: nDias }, (_, i) => i + 1)]; + const passo = (delta: number) => setView(v => { const m = v.m + delta; return m < 0 ? { y: v.y - 1, m: 11 } : m > 11 ? { y: v.y + 1, m: 0 } : { y: v.y, m }; }); + return ( +
+
+ + {MESES[view.m]} {view.y} + +
+
+ {WD.map((w, i) => {w})} +
+
+ {cells.map((d, i) => { + if (d === null) return ; + const iso = toISO(view.y, view.m, d); + const past = iso < hoje; + const on = iso === value; + const ia = iso === suggested; + const isHoje = iso === hoje; + return ( + + ); + })} +
+
+ ); +}; + +export const PedidosPendentes: React.FC<{ + isOpen: boolean; + onClose: () => void; + clinicaId?: string; + onChange?: () => void; + // Abre o chat do WhatsApp do solicitante (drawer da agenda) — telefone direto, + // funciona mesmo para lead sem cadastro. + onOpenChat?: (t: { pacienteId?: string; pacienteNome?: string; phone?: string }) => void; +}> = ({ isOpen, onClose, clinicaId, onChange, onOpenChat }) => { const [pedidos, setPedidos] = useState([]); const [dentistas, setDentistas] = useState([]); const [sel, setSel] = useState>({}); // pedido_id → dentista_id escolhido + const [horaSel, setHoraSel] = useState>({}); // pedido_id → HH:MM ajustado + const [horaOpen, setHoraOpen] = useState>({}); // pedido_id → grade aberta + const [dataSel, setDataSel] = useState>({}); // pedido_id → YYYY-MM-DD ajustado + const [dataOpen, setDataOpen] = useState>({}); // pedido_id → calendário aberto const [busy, setBusy] = useState(null); const [erro, setErro] = useState(null); const [loading, setLoading] = useState(false); @@ -41,8 +116,11 @@ export const PedidosPendentes: React.FC<{ isOpen: boolean; onClose: () => void; const confirmar = async (p: any) => { const dentistaId = p.dentista_id || sel[p.id]; if (!dentistaId) { setErro('Escolha o dentista para confirmar.'); return; } + // Horário e DIA: os ajustados pela humana (se mexeu) ou os que a IA fixou. + const hora = horaSel[p.id] || String(p.hora_inicio || '').slice(0, 5); + const data = dataSel[p.id] || String(p.data || '').slice(0, 10); setBusy(p.id); setErro(null); - try { await req(`/clinica/${clinicaId}/pedidos/${p.id}/confirmar`, { method: 'POST', body: JSON.stringify({ dentista_id: dentistaId }) }); await carregar(); onChange?.(); } + try { await req(`/clinica/${clinicaId}/pedidos/${p.id}/confirmar`, { method: 'POST', body: JSON.stringify({ dentista_id: dentistaId, hora_inicio: hora, data }) }); await carregar(); onChange?.(); } catch (e: any) { setErro(e.message); } finally { setBusy(null); } }; const recusar = async (p: any) => { @@ -88,6 +166,12 @@ export const PedidosPendentes: React.FC<{ isOpen: boolean; onClose: () => void; {p.paciente_telefone && {p.paciente_telefone}}
+ {onOpenChat && p.paciente_telefone && ( + + )} + ); + })()} + {/* Horário AJUSTÁVEL: a IA fixou p.hora_inicio; a humana troca pelo combinado com o paciente. */} + {(() => { + const sugerida = String(p.hora_inicio).slice(0, 5); + const atual = horaSel[p.id] || sugerida; + const mudou = atual !== sugerida; + return ( + + ); + })()}
{p.paciente_nome || 'Cliente'} {p.paciente_telefone && {p.paciente_telefone}}
{p.procedimento &&
{p.procedimento}
} @@ -114,15 +235,26 @@ export const PedidosPendentes: React.FC<{ isOpen: boolean; onClose: () => void; {p.dentista_id ? ( {p.dentista_nome} ) : ( - +
+ + {!sel[p.id] && Escolha o dentista} +
)}
- + )} +
+ + {/* Calendário — escondido por padrão, abre pelo chevron do dia. Troca o dia + combinado com o paciente; o sugerido pela IA vem marcado (bolinha âmbar). */} + {dataOpen[p.id] && (() => { + const sugerido = String(p.data || '').slice(0, 10); + const atual = dataSel[p.id] || sugerido; + return ( +
+
+ Dia combinado com o paciente +
+ setDataSel({ ...dataSel, [p.id]: iso })} /> + {dataSel[p.id] && dataSel[p.id] !== sugerido && ( + + )} +
+ ); + })()} + + {/* Grade de horários — escondida por padrão, abre pelo chevron. Escolha rápida + do horário decidido com o paciente; o sugerido pela IA vem marcado. */} + {horaOpen[p.id] && (() => { + const sugerida = String(p.hora_inicio).slice(0, 5); + const atual = horaSel[p.id] || sugerida; + return ( +
+
+ Horário combinado com o paciente +
+
+ {gerarSlots(sugerida).map((h) => { + const on = h === atual; + const ia = h === sugerida; + return ( + + ); + })} +
+ {horaSel[p.id] && horaSel[p.id] !== sugerida && ( + + )} +
+ ); + })()} ))} diff --git a/frontend/services/backend.ts b/frontend/services/backend.ts index cef1387..4b91dff 100644 --- a/frontend/services/backend.ts +++ b/frontend/services/backend.ts @@ -1496,6 +1496,18 @@ export const HybridBackend = { return { grupo: j.grupo || null, membros: j.membros || [] }; }, + // Paciente único (modal de confirmação no check-in — Feature C). + getPacienteById: async (id: string): Promise => { + const res = await apiFetch(`${API_URL}/pacientes/${id}`, {}); + const j = await res.json().catch(() => ({} as any)); + return j.paciente || null; + }, + // Confirma os dados no check-in: valida server-side; { ok:false, mensagem } se inválido. + confirmarDadosPaciente: async (id: string, payload: { nome?: string; cpf?: string; telefone?: string; data_nascimento?: string }): Promise<{ ok: boolean; mensagem?: string; campo?: string }> => { + const res = await apiFetch(`${API_URL}/pacientes/${id}/confirmar-dados`, { method: 'POST', body: JSON.stringify(payload) }); + return await res.json().catch(() => ({ ok: false, mensagem: 'Falha ao confirmar.' })); + }, + // ── NewWhats: ownership & autorizações de sessão de WhatsApp ────────────── // Dono do workspace tem poder total; delega re-scan/exclusão por sessão a outras contas. getWaSessionAuthz: async (instanceId: string): Promise<{ @@ -1523,13 +1535,15 @@ export const HybridBackend = { // Fail-open: se o endpoint estiver indisponível, não esconde nada (backend é a autoridade). getWaAccess: async (): Promise<{ isOwner: boolean; inbox: boolean; secretaria: boolean; delete_msg: boolean; signature?: string | null }> => { const clinicaId = HybridBackend.getActiveWorkspace()?.id || ''; - const open = { isOwner: false, inbox: true, secretaria: true, delete_msg: true, signature: null }; - if (!clinicaId) return open; + // Fail-CLOSED: sem clínica resolvida ou em erro, NEGA acesso — nunca vaza as áreas + // do WhatsApp (Inbox/Secretária) por falha transitória. Só libera com resposta OK. + const closed = { isOwner: false, inbox: false, secretaria: false, delete_msg: false, signature: null }; + if (!clinicaId) return closed; try { const res = await apiFetch(`${API_URL}/nw/access?clinicaId=${encodeURIComponent(clinicaId)}`, {}); - if (!res.ok) return open; + if (!res.ok) return closed; return await res.json(); - } catch { return open; } + } catch { return closed; } }, setWaSessionAuthz: async (instanceId: string, usuarioId: string, perms: WaPerms): Promise<{ ok: boolean; error?: string }> => { diff --git a/frontend/views/AgendaView.tsx b/frontend/views/AgendaView.tsx index 13ca5cd..f39afba 100644 --- a/frontend/views/AgendaView.tsx +++ b/frontend/views/AgendaView.tsx @@ -3,12 +3,13 @@ import FullCalendar from '@fullcalendar/react'; import dayGridPlugin from '@fullcalendar/daygrid'; import timeGridPlugin from '@fullcalendar/timegrid'; import interactionPlugin from '@fullcalendar/interaction'; -import { fmtDataHora } from '../utils/appTime.ts'; +import { fmtDataHora, toCalendarNaive } from '../utils/appTime.ts'; import { Calendar as CalendarIcon, Plus, X, Loader2, GripVertical, Settings as GearIcon, Edit, Trash2, CalendarRange, Clock, User, Stethoscope, AlertCircle, CheckCircle2, Bell, CalendarClock, History, Download, PanelLeftClose, PanelLeftOpen } from 'lucide-react'; import { AgendaSettingsModal } from '../components/AgendaSettingsModal.tsx'; import { HorariosConfig } from '../components/HorariosConfig.tsx'; import { PedidosPendentes } from '../components/PedidosPendentes.tsx'; import { AgendaDetailModal, ScoreBadge, FamiliaChips } from '../components/AgendaDetailModal.tsx'; +import { ConfirmarDadosPaciente } from '../components/ConfirmarDadosPaciente.tsx'; import { WhatsChatDrawer } from './newwhats/WhatsChatDrawer.tsx'; import { AgendaPresence } from '../components/AgendaPresence.tsx'; import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd'; @@ -376,7 +377,9 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebar const [selectedDateInfo, setSelectedDateInfo] = useState(null); const [selectedAppointment, setSelectedAppointment] = useState(null); const [showDetails, setShowDetails] = useState(false); - const [whatsTarget, setWhatsTarget] = useState<{ pacienteId: string; pacienteNome: string } | null>(null); + const [whatsTarget, setWhatsTarget] = useState<{ pacienteId?: string; pacienteNome?: string; phone?: string } | null>(null); + const [confirmarDadosId, setConfirmarDadosId] = useState(null); // Feature C: paciente a confirmar no check-in + const [pendingAtender, setPendingAtender] = useState(false); // segue o Atender após confirmar const [interesses, setInteresses] = useState([]); const [showInteresses, setShowInteresses] = useState(false); const [pendencias, setPendencias] = useState([]); @@ -485,8 +488,11 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebar return { id: ag.id, title: `${nomePac} - ${ag.procedimento}`, - start: ag.start, - end: ag.end, + // Wall-clock naive no fuso canônico: o FullCalendar (sem plugin luxon) + // ignora o prop timeZone nomeado e usaria o fuso do navegador — passando + // naive, exibe o horário literal (mesmo que o card) em qualquer navegador. + start: toCalendarNaive(ag.start), + end: toCalendarNaive(ag.end), backgroundColor: cor, borderColor: cor, extendedProps: { ...ag, pacienteNome: nomePac, isGoogle: false, dentistaNome: dentist?.nome } @@ -598,7 +604,9 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebar const currentUserEmail = HybridBackend.getCurrentUser(); const gtoStore = useGTOStore(); - const handleAtender = async () => { + // Prossegue o atendimento (Lançar GTO). Separado p/ ser chamado após a confirmação + // de dados (Feature C), quando ela é necessária. + const prosseguirAtender = async () => { if (!selectedAppointment || !onNavigate) return; try { const result = await HybridBackend.getPacientes(selectedAppointment.pacienteNome); @@ -615,6 +623,24 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebar onNavigate('lancar-gto'); }; + // Feature C: ao Atender (check-in), se o paciente veio da Secretária e ainda NÃO + // teve os dados confirmados, abre o modal de confirmação ANTES de prosseguir. + const handleAtender = async () => { + if (!selectedAppointment || !onNavigate) return; + const pid = (selectedAppointment as any).pacienteid || (selectedAppointment as any).pacienteId; + if (pid) { + try { + const pac = await HybridBackend.getPacienteById(pid); + if (pac && pac.dados_confirmados === false) { + setConfirmarDadosId(pid); + setPendingAtender(true); // após confirmar, segue o atender + return; + } + } catch { /* falha ao checar → segue normal */ } + } + await prosseguirAtender(); + }; + const filteredDentists = dentists?.filter(d => { if (currentRole === 'admin' || currentRole === 'donoclinica' || currentRole === 'funcionario') return true; if (currentRole === 'dentista') return d.email === currentUserEmail; @@ -750,7 +776,11 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebar void; sidebar onClose={() => setWhatsTarget(null)} pacienteId={whatsTarget?.pacienteId} pacienteNome={whatsTarget?.pacienteNome} + phone={whatsTarget?.phone} onNavigate={onNavigate} /> + {/* Feature C: confirmação de dados no check-in (paciente cadastrado pela Secretária) */} + { setConfirmarDadosId(null); setPendingAtender(false); }} + onConfirmed={() => { + setConfirmarDadosId(null); + if (pendingAtender) { setPendingAtender(false); prosseguirAtender(); } + refresh(); + }} + /> + {/* Lista "A REAGENDAR" — pendências de falta/cancelamento/remarcação */} {showPendencias && (
setShowPendencias(false)}> @@ -926,6 +969,7 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebar setIsSettingsOpen(false)} dentists={dentists} /> setShowMeusHorarios(false)} clinicaId={(HybridBackend.getActiveWorkspace() as any)?.id} initialTab="dentistas" soDentista /> setShowPedidos(false)} clinicaId={(HybridBackend.getActiveWorkspace() as any)?.id} + onOpenChat={(t) => setWhatsTarget(t)} onChange={() => { const cid = (HybridBackend.getActiveWorkspace() as any)?.id; const token = localStorage.getItem('SCOREODONTO_AUTH_TOKEN'); if (cid) fetch(`/api/nw/agenda-config/clinica/${cid}/pedidos?status=pendente`, { headers: token ? { Authorization: `Bearer ${token}` } : {} }).then(r => r.ok ? r.json() : { total: 0 }).then(j => setPedidosCount(j.total || 0)).catch(() => {}); }} /> {showInteresses && (