From 8b9776f0810a84c975ca481874c93c074378b5d0 Mon Sep 17 00:00:00 2001 From: VPS 4 Builder Date: Tue, 7 Jul 2026 20:40:54 +0200 Subject: [PATCH] =?UTF-8?q?feat(comunicacao-interna):=20gate=20progressivo?= =?UTF-8?q?=20+=20agenda=20s=C3=B3=20p/=20dentista?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Gate progressivo: modal ganha "Lembrar mais tarde" e reaparece a cada login; a partir da 4ª aparição vira bloqueio até preencher (contador em localStorage por clínica; ref evita contagem dupla no StrictMode). - Agenda passa a ser exigida/exibida só para DENTISTA (quem atende). Backend: comunicacaoCompleto(p, ehDentista) via vinculos.role; GET devolve `role`. Não-dentista (dono/funcionário) completa só com endereço + números. Verificado: dono completa sem agenda; dentista exige agenda; role no GET ok. Co-Authored-By: Claude Opus 4.8 --- backend/server.js | 15 ++++++--- frontend/App.tsx | 32 +++++++++++++------ .../components/ComunicacaoInternaModal.tsx | 19 ++++++++--- 3 files changed, 47 insertions(+), 19 deletions(-) diff --git a/backend/server.js b/backend/server.js index d349c0e..79d1e4b 100644 --- a/backend/server.js +++ b/backend/server.js @@ -7556,7 +7556,7 @@ async function resolveOrigem(req, cep, clinicaId) { // ─── 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 // janela de horário) + disponibilidade de agenda decidida. -function comunicacaoCompleto(p) { +function comunicacaoCompleto(p, ehDentista) { const e = p.endereco || {}; const nums = Array.isArray(p.numeros) ? p.numeros : []; const ag = p.agenda || {}; @@ -7564,9 +7564,14 @@ function comunicacaoCompleto(p) { const numerosOk = nums.length >= 1 && nums.every((n) => n && String(n.numero || '').replace(/\D/g, '').length >= 10 && String(n.descricao || '').trim() && Array.isArray(n.janelas) && n.janelas.length >= 1); - const agendaOk = ag.aberta === false || (ag.aberta === true && Array.isArray(ag.horarios) && ag.horarios.length >= 1); + // Agenda só é exigida de DENTISTA (quem atende); demais papéis não precisam. + const agendaOk = !ehDentista || ag.aberta === false || (ag.aberta === true && Array.isArray(ag.horarios) && ag.horarios.length >= 1); return enderecoOk && numerosOk && agendaOk; } +async function papelNaClinica(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 || ''; +} // Clínica do usuário: param explícito → primeiro vínculo. async function resolveClinicaUsuario(userId, clinicaIdParam) { if (clinicaIdParam) return String(clinicaIdParam); @@ -7594,7 +7599,8 @@ app.get('/api/comunicacao/:usuarioId', authGuard, async (req, res) => { const { rows: cr } = await pool.query('SELECT logradouro, numero, bairro, cidade, estado, cep FROM clinicas WHERE id=$1', [clinicaId]); if (cr[0] && (cr[0].cidade || cr[0].logradouro)) { p.endereco = { ...cr[0], ...(p.endereco || {}) }; enderecoDaClinica = true; } } - res.json({ usuario_id: alvo, clinica_id: clinicaId, ...p, endereco_da_clinica: enderecoDaClinica }); + const role = await papelNaClinica(alvo, clinicaId); + res.json({ usuario_id: alvo, clinica_id: clinicaId, ...p, endereco_da_clinica: enderecoDaClinica, role }); } catch (e) { res.status(500).json({ error: e.message }); } }); @@ -7608,7 +7614,8 @@ app.put('/api/comunicacao/:usuarioId', authGuard, async (req, res) => { const endereco = b.endereco && typeof b.endereco === 'object' ? b.endereco : {}; const numeros = Array.isArray(b.numeros) ? b.numeros : []; const agenda = b.agenda && typeof b.agenda === 'object' ? b.agenda : {}; - const completo = comunicacaoCompleto({ endereco, numeros, agenda }); + const ehDentista = (await papelNaClinica(alvo, clinicaId)) === 'dentista'; + const completo = comunicacaoCompleto({ endereco, numeros, agenda }, ehDentista); await pool.query( `INSERT INTO pessoa_comunicacao (usuario_id, clinica_id, endereco, numeros, agenda, completo, updated_at) VALUES ($1,$2,$3,$4,$5,$6,NOW()) diff --git a/frontend/App.tsx b/frontend/App.tsx index 311d74a..bcbcd5e 100644 --- a/frontend/App.tsx +++ b/frontend/App.tsx @@ -488,23 +488,33 @@ const App: React.FC = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // Gate de comunicação interna: todo NÃO-paciente com clínica ativa precisa preencher - // o perfil (endereço, números + janelas de horário, agenda) antes de usar o sistema. - // null = ainda não sei; true = ok/não-se-aplica; false = FORÇAR o modal em todas as telas. - const [comOk, setComOk] = useState(null); + // Gate de comunicação interna: todo NÃO-paciente com clínica ativa preenche o perfil + // (endereço + números com janelas de horário; agenda só p/ dentista). Gate PROGRESSIVO: + // reaparece a cada login com "Lembrar mais tarde"; a partir da 4ª aparição vira bloqueio + // até preencher. Conta as aparições em localStorage por clínica. + const [comGate, setComGate] = useState<{ show: boolean; forced: boolean; clinicaId?: string }>({ show: false, forced: false }); + const gateCountedFor = useRef(null); // evita contar 2x no mesmo mount (StrictMode) useEffect(() => { const ws: any = HybridBackend.getActiveWorkspace(); const role = HybridBackend.getCurrentRole(); const aplica = HybridBackend.isAuthenticated() && !HybridBackend.isPreviewMode() && role && role !== 'paciente' && role !== 'superadmin' && ws?.id && ws?.tipo !== 'sala' && ws?.tipo !== 'laboratorio'; - if (!aplica) { setComOk(true); return; } + if (!aplica) { setComGate({ show: false, forced: false }); return; } + const key = `comgate:${ws.id}`; let cancel = false; fetch(`${(window as any).__API_URL__ || ''}/api/comunicacao/me?clinicaId=${encodeURIComponent(ws.id)}`, { headers: { Authorization: `Bearer ${localStorage.getItem('SCOREODONTO_AUTH_TOKEN')}` } }) .then(r => (r.ok ? r.json() : { completo: true })) - .then(d => { if (!cancel) setComOk(d?.sem_clinica ? true : !!d?.completo); }) - .catch(() => { if (!cancel) setComOk(true); }); + .then(d => { + if (cancel) return; + if (d?.sem_clinica || d?.completo) { try { localStorage.removeItem(key); } catch { /* ignore */ } setComGate({ show: false, forced: false }); return; } + // Incompleto → conta a aparição 1x por carga da página. + let count = 0; try { count = parseInt(localStorage.getItem(key) || '0', 10) || 0; } catch { /* ignore */ } + if (gateCountedFor.current !== ws.id) { count += 1; try { localStorage.setItem(key, String(count)); } catch { /* ignore */ } gateCountedFor.current = ws.id; } + setComGate({ show: true, forced: count >= 4, clinicaId: ws.id }); + }) + .catch(() => { if (!cancel) setComGate({ show: false, forced: false }); }); return () => { cancel = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeWorkspace?.id, currentRole]); @@ -613,9 +623,11 @@ const App: React.FC = () => { {HybridBackend.isAuthenticated() && HybridBackend.mustChangePassword() && ( forcePwRerender(t => t + 1)} /> )} - {comOk === false && !HybridBackend.mustChangePassword() && ( - { if (c) setComOk(true); }} onClose={() => { /* forçado: sem fechar */ }} /> + {comGate.show && !HybridBackend.mustChangePassword() && ( + setComGate(s => ({ ...s, show: false }))} + onClose={() => setComGate(s => ({ ...s, show: false }))} + onSaved={(c) => { if (c) { try { localStorage.removeItem(`comgate:${comGate.clinicaId}`); } catch { /* ignore */ } setComGate({ show: false, forced: false }); } }} /> )}