feat(comunicacao-interna): gate progressivo + agenda só p/ dentista
- 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 <noreply@anthropic.com>
This commit is contained in:
+11
-4
@@ -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())
|
||||
|
||||
+22
-10
@@ -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<boolean | null>(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<string | null>(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() && (
|
||||
<ChangePasswordModal forced onDone={() => forcePwRerender(t => t + 1)} />
|
||||
)}
|
||||
{comOk === false && !HybridBackend.mustChangePassword() && (
|
||||
<ComunicacaoInternaModal forced clinicaId={(activeWorkspace as any)?.id}
|
||||
onSaved={(c) => { if (c) setComOk(true); }} onClose={() => { /* forçado: sem fechar */ }} />
|
||||
{comGate.show && !HybridBackend.mustChangePassword() && (
|
||||
<ComunicacaoInternaModal forced={comGate.forced} clinicaId={comGate.clinicaId}
|
||||
onSnooze={() => 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 }); } }} />
|
||||
)}
|
||||
<div className="flex h-screen bg-gray-50 text-gray-800 overflow-hidden relative">
|
||||
<style>{`
|
||||
|
||||
@@ -69,8 +69,8 @@ const JanelaEditor: React.FC<{ janelas: Janela[]; onChange: (j: Janela[]) => voi
|
||||
|
||||
export const ComunicacaoInternaModal: React.FC<{
|
||||
usuarioId?: string; clinicaId?: string; nome?: string; forced?: boolean;
|
||||
onClose: () => void; onSaved?: (completo: boolean) => void;
|
||||
}> = ({ usuarioId = 'me', clinicaId, nome, forced, onClose, onSaved }) => {
|
||||
onClose: () => void; onSaved?: (completo: boolean) => void; onSnooze?: () => void;
|
||||
}> = ({ usuarioId = 'me', clinicaId, nome, forced, onClose, onSaved, onSnooze }) => {
|
||||
const toast = useToast();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -78,6 +78,7 @@ export const ComunicacaoInternaModal: React.FC<{
|
||||
const [numeros, setNumeros] = useState<Numero[]>([]);
|
||||
const [agenda, setAgenda] = useState<Agenda>({});
|
||||
const [enderecoDaClinica, setEnderecoDaClinica] = useState(false);
|
||||
const [isDentista, setIsDentista] = useState(false); // só dentista precisa de agenda
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -89,6 +90,7 @@ export const ComunicacaoInternaModal: React.FC<{
|
||||
setNumeros(Array.isArray(d.numeros) ? d.numeros : []);
|
||||
setAgenda(d.agenda || {});
|
||||
setEnderecoDaClinica(!!d.endereco_da_clinica);
|
||||
setIsDentista((d.role || '') === 'dentista');
|
||||
} catch { /* mantém vazio */ } finally { setLoading(false); }
|
||||
}, [usuarioId, clinicaId]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
@@ -107,7 +109,7 @@ export const ComunicacaoInternaModal: React.FC<{
|
||||
// Completude (espelha o backend) — orienta o preenchimento e destrava o gate.
|
||||
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 agendaOk = agenda.aberta === false || (agenda.aberta === true && (agenda.horarios?.length ?? 0) >= 1);
|
||||
const agendaOk = !isDentista || agenda.aberta === false || (agenda.aberta === true && (agenda.horarios?.length ?? 0) >= 1);
|
||||
const completo = enderecoOk && numerosOk && agendaOk;
|
||||
|
||||
const salvar = async () => {
|
||||
@@ -232,7 +234,8 @@ export const ComunicacaoInternaModal: React.FC<{
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Agenda (abrir/fechar) ── */}
|
||||
{/* ── Agenda (abrir/fechar) — só para dentista (quem atende) ── */}
|
||||
{isDentista && (
|
||||
<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>
|
||||
<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>
|
||||
@@ -250,6 +253,7 @@ export const ComunicacaoInternaModal: React.FC<{
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -259,8 +263,13 @@ 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">
|
||||
<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={agendaOk ? 'text-green-600' : 'text-gray-300'}>{agendaOk ? '✓' : '○'} Agenda</span>
|
||||
{isDentista && <span className={agendaOk ? 'text-green-600' : 'text-gray-300'}>{agendaOk ? '✓' : '○'} Agenda</span>}
|
||||
</div>
|
||||
{!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">
|
||||
Lembrar mais tarde
|
||||
</button>
|
||||
)}
|
||||
<button onClick={salvar} disabled={saving}
|
||||
className="px-6 py-3 text-white rounded-xl text-xs font-black uppercase hover:opacity-90 disabled:opacity-50 flex items-center justify-center gap-2" style={{ backgroundColor: completo ? '#16a34a' : COLOR }}>
|
||||
{saving ? <Loader2 size={15} className="animate-spin" /> : completo ? <CheckCircle2 size={15} /> : <Save size={15} />}
|
||||
|
||||
Reference in New Issue
Block a user