fix(comunicacao-interna): remove duplicação de agenda — fonte única (jornada real)

A seção "Agenda/Horários de atendimento" do modal era uma 3ª fonte de horário que a
Secretária NÃO lia (ela agenda por clinicas_horarios + dentistas_horarios). Removida:
- Modal cuida só de contato (endereço + números + janelas de envio). Para dentista,
  mostra o STATUS da jornada real e um botão que abre "Horários de Funcionamento"
  (HorariosConfig, aba dentistas) por cima; ao fechar, recarrega.
- Completude do DENTISTA passa a exigir a jornada REAL configurada (dentistas_horarios),
  não mais a agenda do modal. GET recomputa `completo` ao vivo + devolve jornada_configurada.
- Backend: comunicacaoCompleto(p, ehDentista, jornadaOk) + helper jornadaConfigurada.

Verificado: dentista com jornada → completo; query bate com dentistas_horarios real.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-07-08 00:14:32 +02:00
parent e8d1a5547e
commit e9f8a24a93
2 changed files with 53 additions and 32 deletions
+20 -7
View File
@@ -7556,22 +7556,30 @@ async function resolveOrigem(req, cep, clinicaId) {
// ─── Comunicação interna (perfil de contato de não-pacientes) ──────────────────── // ─── Comunicação interna (perfil de contato de não-pacientes) ────────────────────
// Regra de completude que destrava o gate: endereço + ≥1 número (com descrição e ≥1 // Regra de completude que destrava o gate: endereço + ≥1 número (com descrição e ≥1
// janela de horário) + disponibilidade de agenda decidida. // janela de horário) + disponibilidade de agenda decidida.
function comunicacaoCompleto(p, ehDentista) { function comunicacaoCompleto(p, ehDentista, jornadaOk) {
const e = p.endereco || {}; const e = p.endereco || {};
const nums = Array.isArray(p.numeros) ? p.numeros : []; const nums = Array.isArray(p.numeros) ? p.numeros : [];
const ag = p.agenda || {};
const enderecoOk = !!(e.cidade && e.estado && (e.cep || e.logradouro)); const enderecoOk = !!(e.cidade && e.estado && (e.cep || e.logradouro));
const numerosOk = nums.length >= 1 && nums.every((n) => const numerosOk = nums.length >= 1 && nums.every((n) =>
n && String(n.numero || '').replace(/\D/g, '').length >= 10 && String(n.descricao || '').trim() && n && String(n.numero || '').replace(/\D/g, '').length >= 10 && String(n.descricao || '').trim() &&
Array.isArray(n.janelas) && n.janelas.length >= 1); Array.isArray(n.janelas) && n.janelas.length >= 1);
// Agenda só é exigida de DENTISTA (quem atende); demais papéis não precisam. // A agenda saiu do modal (fonte única = dentistas_horarios). Do DENTISTA exige-se a
const agendaOk = !ehDentista || ag.aberta === false || (ag.aberta === true && Array.isArray(ag.horarios) && ag.horarios.length >= 1); // jornada REAL configurada em "Horários de Funcionamento".
return enderecoOk && numerosOk && agendaOk; const jornadaExigidaOk = !ehDentista || !!jornadaOk;
return enderecoOk && numerosOk && jornadaExigidaOk;
} }
async function papelNaClinica(usuarioId, clinicaId) { async function papelNaClinica(usuarioId, clinicaId) {
const { rows } = await pool.query('SELECT role FROM vinculos WHERE usuario_id=$1 AND clinica_id=$2', [usuarioId, clinicaId]); const { rows } = await pool.query('SELECT role FROM vinculos WHERE usuario_id=$1 AND clinica_id=$2', [usuarioId, clinicaId]);
return rows[0]?.role || ''; return rows[0]?.role || '';
} }
// Jornada de atendimento do dentista já configurada? (fonte real usada pela Secretária)
async function jornadaConfigurada(usuarioId, clinicaId) {
const { rows: dr } = await pool.query('SELECT id FROM dentistas WHERE usuario_id=$1 AND clinica_id=$2 LIMIT 1', [usuarioId, clinicaId]);
const dentId = dr[0]?.id;
if (!dentId) return false;
const { rows } = await pool.query('SELECT 1 FROM dentistas_horarios WHERE dentista_id=$1 AND COALESCE(ativo,1) <> 0 LIMIT 1', [dentId]);
return rows.length > 0;
}
// Clínica do usuário: param explícito → primeiro vínculo. // Clínica do usuário: param explícito → primeiro vínculo.
async function resolveClinicaUsuario(userId, clinicaIdParam) { async function resolveClinicaUsuario(userId, clinicaIdParam) {
if (clinicaIdParam) return String(clinicaIdParam); if (clinicaIdParam) return String(clinicaIdParam);
@@ -7626,7 +7634,11 @@ app.get('/api/comunicacao/:usuarioId', authGuard, async (req, res) => {
if (cr[0] && (cr[0].cidade || cr[0].logradouro)) { p.endereco = { ...cr[0], ...(p.endereco || {}) }; enderecoDaClinica = true; } if (cr[0] && (cr[0].cidade || cr[0].logradouro)) { p.endereco = { ...cr[0], ...(p.endereco || {}) }; enderecoDaClinica = true; }
} }
const role = await papelNaClinica(alvo, clinicaId); const role = await papelNaClinica(alvo, clinicaId);
res.json({ usuario_id: alvo, clinica_id: clinicaId, ...p, endereco_da_clinica: enderecoDaClinica, role }); const ehDentista = role === 'dentista';
const jornadaOk = ehDentista ? await jornadaConfigurada(alvo, clinicaId) : true;
// `completo` recomputado AO VIVO (a jornada pode ter sido configurada fora do modal).
const completoLive = comunicacaoCompleto({ endereco: p.endereco, numeros: p.numeros }, ehDentista, jornadaOk);
res.json({ usuario_id: alvo, clinica_id: clinicaId, ...p, completo: completoLive, endereco_da_clinica: enderecoDaClinica, role, jornada_configurada: jornadaOk });
} catch (e) { res.status(500).json({ error: e.message }); } } catch (e) { res.status(500).json({ error: e.message }); }
}); });
@@ -7641,7 +7653,8 @@ app.put('/api/comunicacao/:usuarioId', authGuard, async (req, res) => {
const numeros = Array.isArray(b.numeros) ? b.numeros : []; const numeros = Array.isArray(b.numeros) ? b.numeros : [];
const agenda = b.agenda && typeof b.agenda === 'object' ? b.agenda : {}; const agenda = b.agenda && typeof b.agenda === 'object' ? b.agenda : {};
const ehDentista = (await papelNaClinica(alvo, clinicaId)) === 'dentista'; const ehDentista = (await papelNaClinica(alvo, clinicaId)) === 'dentista';
const completo = comunicacaoCompleto({ endereco, numeros, agenda }, ehDentista); const jornadaOk = ehDentista ? await jornadaConfigurada(alvo, clinicaId) : true;
const completo = comunicacaoCompleto({ endereco, numeros }, ehDentista, jornadaOk);
await pool.query( await pool.query(
`INSERT INTO pessoa_comunicacao (usuario_id, clinica_id, endereco, numeros, agenda, completo, updated_at) `INSERT INTO pessoa_comunicacao (usuario_id, clinica_id, endereco, numeros, agenda, completo, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,NOW()) VALUES ($1,$2,$3,$4,$5,$6,NOW())
+33 -25
View File
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useCallback } from 'react';
import { X, Plus, Trash2, MapPin, Phone, Clock, CalendarClock, Save, Loader2, MessageSquare, AlertTriangle, CheckCircle2 } from 'lucide-react'; import { X, Plus, Trash2, MapPin, Phone, Clock, CalendarClock, Save, Loader2, MessageSquare, AlertTriangle, CheckCircle2 } from 'lucide-react';
import { useToast } from '../contexts/ToastContext.tsx'; import { useToast } from '../contexts/ToastContext.tsx';
import { buscarCep } from '../services/viacep.ts'; import { buscarCep } from '../services/viacep.ts';
import { HorariosConfig } from './HorariosConfig.tsx';
// Comunicação interna: perfil de contato de não-pacientes (dentistas/funcionários). // Comunicação interna: perfil de contato de não-pacientes (dentistas/funcionários).
// Números de WhatsApp múltiplos (cada um com descrição, situações e janelas de horário // Números de WhatsApp múltiplos (cada um com descrição, situações e janelas de horário
@@ -76,9 +77,10 @@ export const ComunicacaoInternaModal: React.FC<{
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [endereco, setEndereco] = useState<Endereco>({}); const [endereco, setEndereco] = useState<Endereco>({});
const [numeros, setNumeros] = useState<Numero[]>([]); const [numeros, setNumeros] = useState<Numero[]>([]);
const [agenda, setAgenda] = useState<Agenda>({});
const [enderecoDaClinica, setEnderecoDaClinica] = useState(false); const [enderecoDaClinica, setEnderecoDaClinica] = useState(false);
const [isDentista, setIsDentista] = useState(false); // só dentista precisa de agenda const [isDentista, setIsDentista] = useState(false); // só dentista precisa de jornada
const [jornadaOk, setJornadaOk] = useState(false); // jornada real (dentistas_horarios) já configurada
const [showHorarios, setShowHorarios] = useState(false); // abre "Horários de Funcionamento" por cima
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -88,9 +90,9 @@ export const ComunicacaoInternaModal: React.FC<{
const d = await r.json(); const d = await r.json();
setEndereco(d.endereco || {}); setEndereco(d.endereco || {});
setNumeros(Array.isArray(d.numeros) ? d.numeros : []); setNumeros(Array.isArray(d.numeros) ? d.numeros : []);
setAgenda(d.agenda || {});
setEnderecoDaClinica(!!d.endereco_da_clinica); setEnderecoDaClinica(!!d.endereco_da_clinica);
setIsDentista((d.role || '') === 'dentista'); setIsDentista((d.role || '') === 'dentista');
setJornadaOk(!!d.jornada_configurada);
} catch { /* mantém vazio */ } finally { setLoading(false); } } catch { /* mantém vazio */ } finally { setLoading(false); }
}, [usuarioId, clinicaId]); }, [usuarioId, clinicaId]);
useEffect(() => { load(); }, [load]); useEffect(() => { load(); }, [load]);
@@ -123,13 +125,13 @@ export const ComunicacaoInternaModal: React.FC<{
// Completude (espelha o backend) — orienta o preenchimento e destrava o gate. // Completude (espelha o backend) — orienta o preenchimento e destrava o gate.
const enderecoOk = !!(endereco.cidade && endereco.estado && (endereco.cep || endereco.logradouro)); const enderecoOk = !!(endereco.cidade && endereco.estado && (endereco.cep || endereco.logradouro));
const numerosOk = numeros.length >= 1 && numeros.every(n => (n.numero || '').replace(/\D/g, '').length >= 10 && (n.descricao || '').trim() && (n.janelas?.length ?? 0) >= 1); const numerosOk = numeros.length >= 1 && numeros.every(n => (n.numero || '').replace(/\D/g, '').length >= 10 && (n.descricao || '').trim() && (n.janelas?.length ?? 0) >= 1);
const agendaOk = !isDentista || agenda.aberta === false || (agenda.aberta === true && (agenda.horarios?.length ?? 0) >= 1); const jornadaFeita = !isDentista || jornadaOk; // dentista precisa da jornada real configurada
const completo = enderecoOk && numerosOk && agendaOk; const completo = enderecoOk && numerosOk && jornadaFeita;
const salvar = async () => { const salvar = async () => {
setSaving(true); setSaving(true);
try { try {
const r = await api(`/api/comunicacao/${usuarioId}`, { method: 'PUT', body: JSON.stringify({ clinicaId, endereco, numeros, agenda }) }); const r = await api(`/api/comunicacao/${usuarioId}`, { method: 'PUT', body: JSON.stringify({ clinicaId, endereco, numeros }) });
const d = await r.json(); const d = await r.json();
if (!r.ok) throw new Error(d.error || 'Erro ao salvar.'); if (!r.ok) throw new Error(d.error || 'Erro ao salvar.');
toast.success('DADOS SALVOS!'); toast.success('DADOS SALVOS!');
@@ -141,6 +143,7 @@ export const ComunicacaoInternaModal: React.FC<{
}; };
return ( return (
<>
<div className="fixed inset-0 z-[70] bg-white flex flex-col"> <div className="fixed inset-0 z-[70] bg-white flex flex-col">
{/* Header */} {/* Header */}
<div className="px-5 sm:px-8 py-4 border-b border-gray-100 flex items-center justify-between shrink-0"> <div className="px-5 sm:px-8 py-4 border-b border-gray-100 flex items-center justify-between shrink-0">
@@ -254,25 +257,23 @@ export const ComunicacaoInternaModal: React.FC<{
</div> </div>
</section> </section>
{/* ── Agenda (abrir/fechar) — só para dentista (quem atende) ── */} {/* ── Agenda de atendimento — fonte única: "Horários de Funcionamento" ── */}
{isDentista && ( {isDentista && (
<section> <section>
<h3 className="flex items-center gap-2 text-xs font-black text-gray-700 uppercase tracking-wide mb-1"><CalendarClock size={15} style={{ color: COLOR }} /> Agenda</h3> <h3 className="flex items-center gap-2 text-xs font-black text-gray-700 uppercase tracking-wide mb-1"><CalendarClock size={15} style={{ color: COLOR }} /> Agenda de atendimento</h3>
<p className="text-[11px] text-gray-400 font-medium mb-3">Sua agenda está aberta para a Secretária marcar atendimentos? Se sim, informe os horários em que ela pode agendar.</p> <p className="text-[11px] text-gray-400 font-medium mb-3">São os horários em que a Secretária pode marcar consultas para você. Ficam em "Horários de Funcionamento" (a mesma jornada que a agenda usa).</p>
<div className="flex gap-2 mb-3"> {jornadaOk ? (
{[{ v: true, l: 'Agenda aberta' }, { v: false, l: 'Agenda fechada' }].map(o => ( <div className="rounded-xl bg-green-50 border border-green-200 p-3.5 flex items-center justify-between">
<button key={String(o.v)} type="button" onClick={() => setAgenda(a => ({ ...a, aberta: o.v }))} <span className="flex items-center gap-2 text-xs font-black text-green-700 uppercase"><CheckCircle2 size={15} /> Jornada configurada</span>
className={`flex-1 py-2.5 rounded-xl text-[11px] font-black uppercase border transition-all ${agenda.aberta === o.v ? 'text-white border-transparent' : 'bg-gray-50 text-gray-500 border-gray-200 hover:bg-gray-100'}`} <button type="button" onClick={() => setShowHorarios(true)} className="text-[10px] font-black uppercase text-teal-600 hover:text-teal-700">Editar</button>
style={agenda.aberta === o.v ? { backgroundColor: o.v ? COLOR : '#6b7280' } : {}}>{o.l}</button> </div>
))} ) : (
</div> <div className="rounded-xl bg-amber-50 border border-amber-200 p-3.5">
{agenda.aberta === true && ( <p className="text-[11px] text-amber-700 font-medium mb-2 flex gap-2"><AlertTriangle size={14} className="shrink-0 mt-0.5 text-amber-500" /> Você ainda não configurou sua agenda de atendimento. Sem ela, a Secretária não consegue marcar consultas com você.</p>
<div> <button type="button" onClick={() => setShowHorarios(true)} className="w-full py-2.5 rounded-xl text-[11px] font-black uppercase text-white hover:opacity-90" style={{ backgroundColor: COLOR }}>Configurar minha agenda</button>
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest flex items-center gap-1.5 mb-1"><Clock size={12} /> Horários de atendimento</label> </div>
<JanelaEditor janelas={agenda.horarios || []} onChange={h => setAgenda(a => ({ ...a, horarios: h }))} addLabel="Adicionar horário de atendimento" /> )}
</div> </section>
)}
</section>
)} )}
</div> </div>
</div> </div>
@@ -283,7 +284,7 @@ export const ComunicacaoInternaModal: React.FC<{
<div className="flex-1 flex flex-wrap gap-x-3 gap-y-1 text-[10px] font-black uppercase tracking-wide"> <div className="flex-1 flex flex-wrap gap-x-3 gap-y-1 text-[10px] font-black uppercase tracking-wide">
<span className={enderecoOk ? 'text-green-600' : 'text-gray-300'}>{enderecoOk ? '✓' : '○'} Endereço</span> <span className={enderecoOk ? 'text-green-600' : 'text-gray-300'}>{enderecoOk ? '✓' : '○'} Endereço</span>
<span className={numerosOk ? 'text-green-600' : 'text-gray-300'}>{numerosOk ? '✓' : '○'} Números</span> <span className={numerosOk ? 'text-green-600' : 'text-gray-300'}>{numerosOk ? '✓' : '○'} Números</span>
{isDentista && <span className={agendaOk ? 'text-green-600' : 'text-gray-300'}>{agendaOk ? '✓' : '○'} Agenda</span>} {isDentista && <span className={jornadaFeita ? 'text-green-600' : 'text-gray-300'}>{jornadaFeita ? '✓' : '○'} Agenda</span>}
</div> </div>
{!forced && onSnooze && ( {!forced && onSnooze && (
<button onClick={onSnooze} className="px-4 py-3 rounded-xl text-xs font-black uppercase text-gray-500 border border-gray-200 hover:bg-gray-50"> <button onClick={onSnooze} className="px-4 py-3 rounded-xl text-xs font-black uppercase text-gray-500 border border-gray-200 hover:bg-gray-50">
@@ -300,6 +301,13 @@ export const ComunicacaoInternaModal: React.FC<{
</> </>
)} )}
</div> </div>
{/* "Horários de Funcionamento" por cima — mesma jornada real (dentistas_horarios). */}
{showHorarios && (
<HorariosConfig isOpen clinicaId={clinicaId} initialTab="dentistas" soDentista={usuarioId === 'me'}
onClose={() => { setShowHorarios(false); load(); }} />
)}
</>
); );
}; };