fix(nw): handoff é ação de inbox (não Secretária) + botão do dono p/ horário da clínica

1) 403 ao "desligar a IA no chat": guardAreaAccess tratava TODO /sec/* como config da
   Secretária (exige can_secretaria). Mas /sec/handoff é assumir/soltar o CHAT = INBOX.
   Reclassificado p/ can_inbox — a recep (can_inbox, sem can_secretaria) volta a assumir
   o chat. /sec/agent|nodes|slots e /secretaria/* seguem exigindo can_secretaria.
   (session-power global segue can_secretaria — o dono delega se quiser.)

2) Duplicação de horário (parte 2): o DONO agora tem, no modal, o card + botão p/
   configurar o "Horário da clínica" (clinicas_horarios) em Horários de Funcionamento
   (aba clínica). Completude do dono passa a exigir esse horário. Backend:
   comunicacaoCompleto(p, {ehDentista,jornadaOk,ehDono,horarioClinicaOk}); GET devolve
   horario_clinica_configurado. Verificado: dono da Consultt sem horário → gate exige.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-07-08 00:34:04 +02:00
parent e9f8a24a93
commit c8443a146a
3 changed files with 63 additions and 22 deletions
+5 -2
View File
@@ -152,8 +152,11 @@ export async function operatorSignature(pool, userId, clinicaId) {
// Gate de acesso a ÁREAS (Inbox / Secretária). Dono passa sempre; os demais só com
// delegação (can_inbox / can_secretaria) do dono em alguma sessão do workspace.
async function guardAreaAccess(pool, tail, userId, clinicaId) {
const isSecretaria = /^\/(secretaria|sec)(\/|$|\?)/.test(tail);
const isInbox = /^\/(inbox|conversations)(\/|$|\?)/.test(tail);
// /sec/handoff = assumir/soltar o CHAT (ação de INBOX), não config da Secretária.
// O resto de /sec/* e todo /secretaria/* é config (agents/nodes/slots/power) = Secretária.
const isHandoff = /^\/sec\/handoff(\/|$|\?)/.test(tail);
const isSecretaria = !isHandoff && /^\/(secretaria|sec)(\/|$|\?)/.test(tail);
const isInbox = isHandoff || /^\/(inbox|conversations)(\/|$|\?)/.test(tail);
if (!isSecretaria && !isInbox) return { ok: true };
if (!pool || !clinicaId) return { ok: true }; // sem workspace resolvido, não barra aqui
const ownerId = await resolveOwnerId(pool, clinicaId);
+22 -9
View File
@@ -7556,17 +7556,19 @@ 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, ehDentista, jornadaOk) {
function comunicacaoCompleto(p, opts) {
const { ehDentista, jornadaOk, ehDono, horarioClinicaOk } = opts || {};
const e = p.endereco || {};
const nums = Array.isArray(p.numeros) ? p.numeros : [];
const enderecoOk = !!(e.cidade && e.estado && (e.cep || e.logradouro));
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);
// A agenda saiu do modal (fonte única = dentistas_horarios). Do DENTISTA exige-se a
// jornada REAL configurada em "Horários de Funcionamento".
// Agenda = fonte única. DENTISTA exige jornada real (dentistas_horarios); DONO exige o
// horário de funcionamento da CLÍNICA (clinicas_horarios) — ambos em "Horários de Funcionamento".
const jornadaExigidaOk = !ehDentista || !!jornadaOk;
return enderecoOk && numerosOk && jornadaExigidaOk;
const clinicaExigidaOk = !ehDono || !!horarioClinicaOk;
return enderecoOk && numerosOk && jornadaExigidaOk && clinicaExigidaOk;
}
async function papelNaClinica(usuarioId, clinicaId) {
const { rows } = await pool.query('SELECT role FROM vinculos WHERE usuario_id=$1 AND clinica_id=$2', [usuarioId, clinicaId]);
@@ -7580,6 +7582,12 @@ async function jornadaConfigurada(usuarioId, clinicaId) {
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;
}
// Horário de FUNCIONAMENTO da clínica já configurado?
async function clinicaHorariosConfigurados(clinicaId) {
const { rows } = await pool.query('SELECT 1 FROM clinicas_horarios WHERE clinica_id=$1 AND COALESCE(ativo,1) <> 0 LIMIT 1', [clinicaId]);
return rows.length > 0;
}
const EH_DONO = (role) => ['donoclinica', 'donoconsultorio'].includes(role);
// Clínica do usuário: param explícito → primeiro vínculo.
async function resolveClinicaUsuario(userId, clinicaIdParam) {
if (clinicaIdParam) return String(clinicaIdParam);
@@ -7635,10 +7643,12 @@ app.get('/api/comunicacao/:usuarioId', authGuard, async (req, res) => {
}
const role = await papelNaClinica(alvo, clinicaId);
const ehDentista = role === 'dentista';
const ehDono = EH_DONO(role);
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 });
const horarioClinicaOk = ehDono ? await clinicaHorariosConfigurados(clinicaId) : true;
// `completo` recomputado AO VIVO (jornada/horário podem ter sido configurados fora do modal).
const completoLive = comunicacaoCompleto({ endereco: p.endereco, numeros: p.numeros }, { ehDentista, jornadaOk, ehDono, horarioClinicaOk });
res.json({ usuario_id: alvo, clinica_id: clinicaId, ...p, completo: completoLive, endereco_da_clinica: enderecoDaClinica, role, jornada_configurada: jornadaOk, horario_clinica_configurado: horarioClinicaOk });
} catch (e) { res.status(500).json({ error: e.message }); }
});
@@ -7652,9 +7662,12 @@ 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 ehDentista = (await papelNaClinica(alvo, clinicaId)) === 'dentista';
const role = await papelNaClinica(alvo, clinicaId);
const ehDentista = role === 'dentista';
const ehDono = EH_DONO(role);
const jornadaOk = ehDentista ? await jornadaConfigurada(alvo, clinicaId) : true;
const completo = comunicacaoCompleto({ endereco, numeros }, ehDentista, jornadaOk);
const horarioClinicaOk = ehDono ? await clinicaHorariosConfigurados(clinicaId) : true;
const completo = comunicacaoCompleto({ endereco, numeros }, { ehDentista, jornadaOk, ehDono, horarioClinicaOk });
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())
+36 -11
View File
@@ -78,9 +78,11 @@ export const ComunicacaoInternaModal: React.FC<{
const [endereco, setEndereco] = useState<Endereco>({});
const [numeros, setNumeros] = useState<Numero[]>([]);
const [enderecoDaClinica, setEnderecoDaClinica] = useState(false);
const [isDentista, setIsDentista] = useState(false); // 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 [isDentista, setIsDentista] = useState(false); // dentista precisa da jornada
const [isDono, setIsDono] = useState(false); // dono precisa do horário da clínica
const [jornadaOk, setJornadaOk] = useState(false); // jornada real (dentistas_horarios) configurada
const [horarioClinicaOk, setHorarioClinicaOk] = useState(false); // clinicas_horarios configurado
const [horariosTab, setHorariosTab] = useState<'dentistas' | 'clinica' | null>(null); // abre "Horários de Funcionamento"
const load = useCallback(async () => {
setLoading(true);
@@ -92,7 +94,9 @@ export const ComunicacaoInternaModal: React.FC<{
setNumeros(Array.isArray(d.numeros) ? d.numeros : []);
setEnderecoDaClinica(!!d.endereco_da_clinica);
setIsDentista((d.role || '') === 'dentista');
setIsDono(['donoclinica', 'donoconsultorio'].includes(d.role || ''));
setJornadaOk(!!d.jornada_configurada);
setHorarioClinicaOk(!!d.horario_clinica_configurado);
} catch { /* mantém vazio */ } finally { setLoading(false); }
}, [usuarioId, clinicaId]);
useEffect(() => { load(); }, [load]);
@@ -125,8 +129,9 @@ 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 jornadaFeita = !isDentista || jornadaOk; // dentista precisa da jornada real configurada
const completo = enderecoOk && numerosOk && jornadaFeita;
const jornadaFeita = !isDentista || jornadaOk; // dentista: jornada real configurada
const clinicaFeita = !isDono || horarioClinicaOk; // dono: horário da clínica configurado
const completo = enderecoOk && numerosOk && jornadaFeita && clinicaFeita;
const salvar = async () => {
setSaving(true);
@@ -265,12 +270,31 @@ export const ComunicacaoInternaModal: React.FC<{
{jornadaOk ? (
<div className="rounded-xl bg-green-50 border border-green-200 p-3.5 flex items-center justify-between">
<span className="flex items-center gap-2 text-xs font-black text-green-700 uppercase"><CheckCircle2 size={15} /> Jornada configurada</span>
<button type="button" onClick={() => setShowHorarios(true)} className="text-[10px] font-black uppercase text-teal-600 hover:text-teal-700">Editar</button>
<button type="button" onClick={() => setHorariosTab('dentistas')} className="text-[10px] font-black uppercase text-teal-600 hover:text-teal-700">Editar</button>
</div>
) : (
<div className="rounded-xl bg-amber-50 border border-amber-200 p-3.5">
<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>
<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>
<button type="button" onClick={() => setHorariosTab('dentistas')} 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>
</div>
)}
</section>
)}
{/* ── Horário de funcionamento da clínica — só para o DONO ── */}
{isDono && (
<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 }} /> Horário da clínica</h3>
<p className="text-[11px] text-gray-400 font-medium mb-3">É o horário de funcionamento que a Secretária usa para atender e marcar consultas. Fica em "Horários de Funcionamento".</p>
{horarioClinicaOk ? (
<div className="rounded-xl bg-green-50 border border-green-200 p-3.5 flex items-center justify-between">
<span className="flex items-center gap-2 text-xs font-black text-green-700 uppercase"><CheckCircle2 size={15} /> Horário da clínica configurado</span>
<button type="button" onClick={() => setHorariosTab('clinica')} className="text-[10px] font-black uppercase text-teal-600 hover:text-teal-700">Editar</button>
</div>
) : (
<div className="rounded-xl bg-amber-50 border border-amber-200 p-3.5">
<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" /> A clínica ainda não tem horário de funcionamento. Sem ele, a Secretária não consegue atender nem marcar consultas.</p>
<button type="button" onClick={() => setHorariosTab('clinica')} className="w-full py-2.5 rounded-xl text-[11px] font-black uppercase text-white hover:opacity-90" style={{ backgroundColor: COLOR }}>Configurar horário da clínica</button>
</div>
)}
</section>
@@ -285,6 +309,7 @@ export const ComunicacaoInternaModal: React.FC<{
<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>
{isDentista && <span className={jornadaFeita ? 'text-green-600' : 'text-gray-300'}>{jornadaFeita ? '✓' : '○'} Agenda</span>}
{isDono && <span className={clinicaFeita ? 'text-green-600' : 'text-gray-300'}>{clinicaFeita ? '✓' : '○'} Clínica</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">
@@ -302,10 +327,10 @@ export const ComunicacaoInternaModal: React.FC<{
)}
</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(); }} />
{/* "Horários de Funcionamento" por cima — fonte única (dentistas_horarios / clinicas_horarios). */}
{horariosTab && (
<HorariosConfig isOpen clinicaId={clinicaId} initialTab={horariosTab} soDentista={horariosTab === 'dentistas' && usuarioId === 'me'}
onClose={() => { setHorariosTab(null); load(); }} />
)}
</>
);