feat(newwhats): agenda inteligente — especialidades, situações, dúvidas, checklist do dono e pendências

Satélite da Secretária IA:
- /contexto (dentistas+especialidades+situações+cautelas) e /duvida (encaminhar
  ao humano sem agendar); agenda_pedidos ganha tipo=duvida + resumo.
- Situações da clínica: tabela agenda_situacoes + config (dono) + aba na UI.
- Checklist do dono (checklist-defs.js + agenda_checklist): perguntas Sim/Não
  conferidas contra o banco (dentistas/pacientes) → pendências de config +
  cautelas p/ a IA não chutar.
- Área de PENDÊNCIAS DO DONO (PendenciasDono.tsx): engloba config + fila
  operacional (horários/dúvidas); botão+badge no /wa-secretaria (só o dono).
- Fila humana (PedidosPendentes): cards de dúvida + resolver; /resolver.
- Botão DESLIGAR global da Secretária no ThinNav (secPowerApi, visível a todos).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-07-06 15:40:51 +02:00
parent f4c2c6861a
commit 40bed96515
9 changed files with 597 additions and 13 deletions
+52
View File
@@ -10,6 +10,7 @@
// POST /book — cria agendamento real (anti-overbooking + vínculo de paciente) // POST /book — cria agendamento real (anti-overbooking + vínculo de paciente)
import express from 'express'; import express from 'express';
import { getConfig } from './config.js'; import { getConfig } from './config.js';
import { cautelasParaIA } from './checklist-defs.js';
import { resolverFeriado, feriadoNacionalNome } from './feriados.js'; import { resolverFeriado, feriadoNacionalNome } from './feriados.js';
const SLOT_MIN = 60; // duração/espaçamento padrão do slot (1h entre atendimentos) const SLOT_MIN = 60; // duração/espaçamento padrão do slot (1h entre atendimentos)
@@ -344,6 +345,57 @@ export function createAgendaBridge(pool) {
} catch (e) { res.status(500).json({ error: e.message }); } } catch (e) { res.status(500).json({ error: e.message }); }
}); });
// ── POST /duvida ──────────────────────────────────────────────────────────────
// Encaminha uma DÚVIDA/caso incerto à secretária humana (sem agendar). Cria um
// agenda_pedidos tipo='duvida' com o resumo; entra na mesma fila de pendentes.
router.post('/duvida', async (req, res) => {
const b = req.body || {};
if (!b.clinica_id || !b.resumo) return res.status(400).json({ error: 'clinica_id e resumo obrigatórios' });
try {
const id = `ped_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
await pool.query(
`INSERT INTO agenda_pedidos (id, clinica_id, tipo, resumo, paciente_nome, paciente_telefone, status)
VALUES ($1,$2,'duvida',$3,$4,$5,'pendente')`,
[id, b.clinica_id, String(b.resumo).slice(0, 2000),
b.nome_cliente || null, (String(b.telefone || '').replace(/\D/g, '')) || null]);
res.json({ ok: true, pedido_id: id });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ── GET /contexto ─────────────────────────────────────────────────────────────
// Contexto clínico p/ a Secretária: dentistas + especialidades + "situações"
// (regras da clínica). Query: clinica_id.
router.get('/contexto', async (req, res) => {
const clinicaId = String(req.query.clinica_id || '');
if (!clinicaId) return res.status(400).json({ error: 'clinica_id obrigatório' });
try {
const { rows: dentistas } = await pool.query(
`SELECT nome, especialidade, especialidades FROM dentistas WHERE clinica_id = $1 AND ativo IS DISTINCT FROM 0 ORDER BY nome`, [clinicaId]);
let situacoes = [];
try {
const { rows } = await pool.query(`SELECT texto FROM agenda_situacoes WHERE clinica_id = $1 AND ativo = 1 ORDER BY ordem, created_at`, [clinicaId]);
situacoes = rows.map((r) => r.texto);
} catch { /* tabela pode não existir ainda */ }
// Cautelas do checklist do dono (declaração × dados reais) → a IA não chuta.
let cautelas = [];
try {
const respostas = {};
const { rows } = await pool.query(`SELECT chave, resposta FROM agenda_checklist WHERE clinica_id = $1`, [clinicaId]);
for (const r of rows) respostas[r.chave] = r.resposta == null ? null : Number(r.resposta);
const nPac = await pool.query(`SELECT COUNT(*) n FROM pacientes WHERE clinica_id = $1`, [clinicaId]).then((x) => Number(x.rows[0]?.n || 0)).catch(() => 0);
cautelas = cautelasParaIA(respostas, { dentistas: dentistas.length, pacientes: nPac });
} catch { /* checklist novo/indisponível */ }
res.json({
dentistas: dentistas.map((d) => ({
nome: d.nome,
especialidades: Array.isArray(d.especialidades) ? d.especialidades : (d.especialidade ? [d.especialidade] : []),
})),
situacoes,
cautelas,
});
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ── GET /dentista-contato ───────────────────────────────────────────────────── // ── GET /dentista-contato ─────────────────────────────────────────────────────
// Resolve o WhatsApp de um dentista da clínica (Parte A: a IA aciona o Dr). // Resolve o WhatsApp de um dentista da clínica (Parte A: a IA aciona o Dr).
// Query: clinica_id (obrig.), nome? OU dentista_id? // Query: clinica_id (obrig.), nome? OU dentista_id?
+108 -1
View File
@@ -7,6 +7,7 @@
import express from 'express'; import express from 'express';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { feriadosNacionais } from './feriados.js'; import { feriadosNacionais } from './feriados.js';
import { CHECKLIST_QUESTOES, pendenciasDeConfig } from './checklist-defs.js';
function authFromReq(req) { function authFromReq(req) {
try { try {
@@ -216,6 +217,100 @@ export function createAgendaConfig(pool) {
} catch (e) { res.status(500).json({ error: e.message }); } } catch (e) { res.status(500).json({ error: e.message }); }
}); });
// ── Situações / regras da clínica (dono) — a "área de situações" da Secretária ─
router.get('/clinica/:clinicaId/situacoes', async (req, res) => {
try {
const { rows } = await pool.query(`SELECT id, texto, ativo, ordem FROM agenda_situacoes WHERE clinica_id = $1 ORDER BY ordem, created_at`, [req.params.clinicaId]);
res.json({ situacoes: rows });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Substitui toda a lista. Body: { situacoes: [texto] }
router.put('/clinica/:clinicaId/situacoes', async (req, res) => {
const clinicaId = req.params.clinicaId;
if (!(await isDono(pool, clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Apenas o dono pode configurar as situações.' });
const linhas = Array.isArray(req.body?.situacoes) ? req.body.situacoes : [];
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query('DELETE FROM agenda_situacoes WHERE clinica_id = $1', [clinicaId]);
let i = 0;
for (const s of linhas) {
const t = String(s || '').trim(); if (!t) continue;
await client.query(`INSERT INTO agenda_situacoes (id, clinica_id, texto, ativo, ordem) VALUES ($1,$2,$3,1,$4)`, [uid('sit'), clinicaId, t, i++]);
}
await client.query('COMMIT'); res.json({ ok: true });
} catch (e) { try { await client.query('ROLLBACK'); } catch {} res.status(500).json({ error: e.message }); }
finally { client.release(); }
});
// ── Checklist do DONO + pendências (só o dono do workspace) ───────────────────
// Conta dentistas/pacientes reais da clínica p/ conferir contra as respostas.
async function contagens(clinicaId) {
const q = async (sql) => { try { const { rows } = await pool.query(sql, [clinicaId]); return Number(rows[0]?.n || 0); } catch { return 0; } };
const dentistas = await q(`SELECT COUNT(*) n FROM dentistas WHERE clinica_id = $1 AND ativo IS DISTINCT FROM 0`);
const pacientes = await q(`SELECT COUNT(*) n FROM pacientes WHERE clinica_id = $1`);
return { dentistas, pacientes };
}
async function respostasChecklist(clinicaId) {
const map = {};
try { const { rows } = await pool.query('SELECT chave, resposta FROM agenda_checklist WHERE clinica_id = $1', [clinicaId]); for (const r of rows) map[r.chave] = r.resposta == null ? null : Number(r.resposta); } catch { /* tabela nova */ }
return map;
}
router.get('/clinica/:clinicaId/checklist', async (req, res) => {
const clinicaId = req.params.clinicaId;
if (!(await isDono(pool, clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Apenas o dono acessa o checklist.' });
try {
const map = await respostasChecklist(clinicaId);
res.json({ questoes: CHECKLIST_QUESTOES.map((q) => ({ ...q, resposta: map[q.chave] ?? null })) });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Salva respostas. Body: { respostas: { chave: 1|0|null } }
router.put('/clinica/:clinicaId/checklist', async (req, res) => {
const clinicaId = req.params.clinicaId;
if (!(await isDono(pool, clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Apenas o dono pode configurar o checklist.' });
const respostas = req.body?.respostas || {};
const client = await pool.connect();
try {
await client.query('BEGIN');
for (const q of CHECKLIST_QUESTOES) {
if (!(q.chave in respostas)) continue;
const v = respostas[q.chave];
const r = v === 1 || v === true ? 1 : v === 0 || v === false ? 0 : null;
await client.query(
`INSERT INTO agenda_checklist (clinica_id, chave, resposta, updated_at) VALUES ($1,$2,$3,now())
ON CONFLICT (clinica_id, chave) DO UPDATE SET resposta = EXCLUDED.resposta, updated_at = now()`,
[clinicaId, q.chave, r]);
}
await client.query('COMMIT'); res.json({ ok: true });
} catch (e) { try { await client.query('ROLLBACK'); } catch {} res.status(500).json({ error: e.message }); }
finally { client.release(); }
});
// Área de pendências do DONO — engloba TUDO: pendências de config (checklist ×
// banco) + a fila operacional (pedidos de horário + dúvidas aguardando humano).
router.get('/clinica/:clinicaId/pendencias', async (req, res) => {
const clinicaId = req.params.clinicaId;
if (!(await isDono(pool, clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Apenas o dono acessa as pendências.' });
try {
const counts = await contagens(clinicaId);
const respostas = await respostasChecklist(clinicaId);
const config = pendenciasDeConfig(respostas, counts);
let horarios = 0, duvidas = 0;
try {
const { rows } = await pool.query(
`SELECT COALESCE(tipo,'horario') tipo, COUNT(*) n FROM agenda_pedidos WHERE clinica_id = $1 AND status = 'pendente' GROUP BY 1`, [clinicaId]);
for (const r of rows) { if (r.tipo === 'duvida') duvidas = Number(r.n); else horarios = Number(r.n); }
} catch { /* tabela nova */ }
res.json({
contagens: counts,
config,
operacionais: { horarios, duvidas, total: horarios + duvidas },
total: config.length + horarios + duvidas,
});
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ── Pedidos pendentes (zona cinza) — a secretária humana confirma/recusa ────── // ── Pedidos pendentes (zona cinza) — a secretária humana confirma/recusa ──────
async function ehMembro(clinicaId, userId) { async function ehMembro(clinicaId, userId) {
if (await isDono(pool, clinicaId, userId)) return true; if (await isDono(pool, clinicaId, userId)) return true;
@@ -228,7 +323,8 @@ export function createAgendaConfig(pool) {
const status = req.query.status ? String(req.query.status) : 'pendente'; const status = req.query.status ? String(req.query.status) : 'pendente';
try { try {
const { rows } = await pool.query( const { rows } = await pool.query(
`SELECT p.id, to_char(p.data,'YYYY-MM-DD') AS data, to_char(p.hora_inicio,'HH24:MI') AS hora_inicio, `SELECT p.id, COALESCE(p.tipo,'horario') AS tipo, p.resumo,
to_char(p.data,'YYYY-MM-DD') AS data, to_char(p.hora_inicio,'HH24:MI') AS hora_inicio,
to_char(p.hora_fim,'HH24:MI') AS hora_fim, p.paciente_nome, p.paciente_telefone, p.procedimento, to_char(p.hora_fim,'HH24:MI') AS hora_fim, p.paciente_nome, p.paciente_telefone, p.procedimento,
p.dentista_id, d.nome AS dentista_nome, p.status, p.created_at p.dentista_id, d.nome AS dentista_nome, p.status, p.created_at
FROM agenda_pedidos p LEFT JOIN dentistas d ON d.id = p.dentista_id FROM agenda_pedidos p LEFT JOIN dentistas d ON d.id = p.dentista_id
@@ -273,6 +369,17 @@ export function createAgendaConfig(pool) {
finally { client.release(); } finally { client.release(); }
}); });
// Resolve uma DÚVIDA (tipo='duvida') sem criar agendamento — a humana já
// decidiu/atendeu (ou vai agendar manualmente uma avaliação).
router.post('/clinica/:clinicaId/pedidos/:id/resolver', async (req, res) => {
if (!(await ehMembro(req.params.clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Sem acesso.' });
try {
await pool.query(`UPDATE agenda_pedidos SET status='resolvido', resolved_at=NOW(), resolved_by=$1 WHERE id=$2 AND clinica_id=$3 AND status='pendente'`,
[req.nwUser.userId, req.params.id, req.params.clinicaId]);
res.json({ ok: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
router.post('/clinica/:clinicaId/pedidos/:id/recusar', async (req, res) => { router.post('/clinica/:clinicaId/pedidos/:id/recusar', async (req, res) => {
if (!(await ehMembro(req.params.clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Sem acesso.' }); if (!(await ehMembro(req.params.clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Sem acesso.' });
try { try {
+30
View File
@@ -79,6 +79,36 @@ export async function ensureAgendaSchema(pool) {
resolved_by varchar resolved_by varchar
)`); )`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_agenda_pedidos_clinica_status ON agenda_pedidos (clinica_id, status, created_at)`); await pool.query(`CREATE INDEX IF NOT EXISTS idx_agenda_pedidos_clinica_status ON agenda_pedidos (clinica_id, status, created_at)`);
// Pedido também serve para DÚVIDAS (sem horário): tipo + resumo; data/hora nullable.
await pool.query(`ALTER TABLE agenda_pedidos ADD COLUMN IF NOT EXISTS tipo varchar DEFAULT 'horario'`); // horario | duvida
await pool.query(`ALTER TABLE agenda_pedidos ADD COLUMN IF NOT EXISTS resumo text`);
await pool.query(`ALTER TABLE agenda_pedidos ALTER COLUMN data DROP NOT NULL`).catch(() => {});
await pool.query(`ALTER TABLE agenda_pedidos ALTER COLUMN hora_inicio DROP NOT NULL`).catch(() => {});
// ── agenda_situacoes — "área de situações": regras da clínica que a Secretária
// IA deve seguir (ex.: "plano X não cobre canal posterior — ofereça avaliação").
await pool.query(`
CREATE TABLE IF NOT EXISTS agenda_situacoes (
id varchar PRIMARY KEY,
clinica_id varchar NOT NULL,
texto text NOT NULL,
ativo smallint DEFAULT 1,
ordem integer DEFAULT 0,
created_at timestamptz DEFAULT now()
)`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_agenda_situacoes_clinica ON agenda_situacoes (clinica_id)`);
// ── agenda_checklist — respostas Sim/Não do DONO sobre a clínica (ver
// checklist-defs.js). Conferidas contra o banco → geram pendências de config
// e deixam a Secretária cautelosa. resposta: 1=sim, 0=não, NULL=não respondido.
await pool.query(`
CREATE TABLE IF NOT EXISTS agenda_checklist (
clinica_id varchar NOT NULL,
chave varchar NOT NULL,
resposta smallint,
updated_at timestamptz DEFAULT now(),
PRIMARY KEY (clinica_id, chave)
)`);
return true; return true;
} catch (e) { } catch (e) {
// Não derruba o boot do satélite se o banco estiver indisponível no momento. // Não derruba o boot do satélite se o banco estiver indisponível no momento.
+78
View File
@@ -0,0 +1,78 @@
// Checklist do DONO do workspace: perguntas Sim/Não sobre a clínica que a
// Secretária IA usa para saber quando PODE agendar sozinha e quando deve ter
// cautela. As respostas são conferidas contra o banco (dentistas/pacientes):
// quando a declaração do dono não bate com os dados, geramos uma PENDÊNCIA
// para ele configurar/cadastrar — e a IA fica cautelosa (encaminha ao humano).
//
// Fonte única: config (UI), consistência (pendências) e injeção no cérebro
// (cautelas) consomem esta mesma definição para nunca divergirem.
export const CHECKLIST_QUESTOES = [
{
chave: 'multi_dentista',
pergunta: 'A clínica tem mais de um dentista?',
ajuda: 'Se sim, a Secretária não pode assumir um único dentista nem "clínico geral" por padrão — precisa saber quem atende cada paciente.',
},
{
chave: 'pacientes_cadastrados',
pergunta: 'Os pacientes já estão cadastrados no sistema (/pacientes)?',
ajuda: 'Sem os pacientes cadastrados, a Secretária não reconhece quem já é paciente e encaminha os retornos para a secretária humana confirmar.',
},
{
chave: 'dentista_fixo',
pergunta: 'Cada paciente tem um dentista de referência (fixo)?',
ajuda: 'Se sim, um paciente de retorno deve voltar para o MESMO dentista — a IA não deve agendá-lo no clínico geral.',
},
];
const sim = (r, k) => r[k] === 1;
const nao = (r, k) => r[k] === 0;
// Pendências de CONFIG do dono: a declaração não bate com o banco.
// counts = { dentistas, pacientes }.
export function pendenciasDeConfig(respostas, counts) {
const r = respostas || {};
const dentistas = Number(counts?.dentistas || 0);
const pacientes = Number(counts?.pacientes || 0);
const P = [];
if (sim(r, 'multi_dentista') && dentistas <= 1)
P.push({ chave: 'multi_dentista', severidade: 'alta', titulo: 'Cadastre os demais dentistas',
detalhe: `Você indicou que a clínica tem mais de um dentista, mas há apenas ${dentistas} cadastrado. A Secretária não consegue direcionar cada paciente ao dentista certo — cadastre os outros dentistas.` });
if (nao(r, 'multi_dentista') && dentistas > 1)
P.push({ chave: 'multi_dentista', severidade: 'media', titulo: 'Confirme quantos dentistas atendem',
detalhe: `Você indicou um único dentista, mas há ${dentistas} cadastrados no sistema. Ajuste a resposta ou os cadastros.` });
if (sim(r, 'pacientes_cadastrados') && pacientes === 0)
P.push({ chave: 'pacientes_cadastrados', severidade: 'alta', titulo: 'Cadastre os pacientes',
detalhe: 'Você indicou que os pacientes estão cadastrados, mas não há nenhum paciente no sistema. A Secretária não reconhece quem já é paciente.' });
if (r['pacientes_cadastrados'] == null || nao(r, 'pacientes_cadastrados'))
P.push({ chave: 'pacientes_cadastrados', severidade: 'media', titulo: 'Complete o cadastro de pacientes',
detalhe: 'Enquanto os pacientes não estiverem cadastrados, a Secretária vai encaminhar todo retorno para a secretária humana confirmar (não agenda sozinha).' });
if (sim(r, 'dentista_fixo') && dentistas <= 1 && sim(r, 'multi_dentista'))
P.push({ chave: 'dentista_fixo', severidade: 'media', titulo: 'Cadastre os dentistas de referência',
detalhe: 'Você indicou que cada paciente tem um dentista fixo, mas o sistema ainda não tem os dentistas todos cadastrados para fazer esse vínculo.' });
return P;
}
// Cautelas para injetar no cérebro da IA (texto direto no system prompt).
export function cautelasParaIA(respostas, counts) {
const r = respostas || {};
const dentistas = Number(counts?.dentistas || 0);
const pacientes = Number(counts?.pacientes || 0);
const C = [];
const multi = sim(r, 'multi_dentista') || dentistas > 1;
if (multi)
C.push('A clínica tem MAIS DE UM dentista: NUNCA assuma um dentista nem "clínico geral" por padrão para quem já é paciente. Descubra/pergunte qual dentista atende. Sem certeza → encaminhe ao humano.');
if (sim(r, 'multi_dentista') && dentistas <= 1)
C.push('ATENÇÃO: o sistema ainda não tem todos os dentistas cadastrados. Para pacientes de retorno ou que pedem um dentista específico, encaminhe ao humano — NÃO agende no clínico geral.');
if (sim(r, 'dentista_fixo'))
C.push('Cada paciente tem um dentista fixo: um retorno deve voltar ao MESMO dentista. Se você não sabe qual é (falta cadastro), encaminhe ao humano.');
if (pacientes === 0 || nao(r, 'pacientes_cadastrados'))
C.push('O cadastro de pacientes está incompleto: você NÃO consegue confirmar histórico nem o dentista de quem diz já ser paciente. Nesses casos, encaminhe ao humano em vez de agendar.');
return C;
}
+45 -7
View File
@@ -2,7 +2,7 @@
// horários dos dentistas. Consome /api/nw/agenda-config (o backend garante o // horários dos dentistas. Consome /api/nw/agenda-config (o backend garante o
// ownership: horário/feriado da clínica = dono; horário do dentista = ele ou dono). // ownership: horário/feriado da clínica = dono; horário do dentista = ele ou dono).
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { X, Clock, CalendarOff, Plus, Trash2, Save, Loader2, User, Copy } from 'lucide-react'; import { X, Clock, CalendarOff, Plus, Trash2, Save, Loader2, User, Copy, AlertTriangle } from 'lucide-react';
const API = (import.meta as any).env?.VITE_API_URL || '/api'; const API = (import.meta as any).env?.VITE_API_URL || '/api';
const BASE = `${API}/nw/agenda-config`; const BASE = `${API}/nw/agenda-config`;
@@ -88,8 +88,8 @@ const GradeSemanal: React.FC<{ faixas: Faixa[]; onChange: (f: Faixa[]) => void;
); );
}; };
export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; clinicaId?: string; initialTab?: 'clinica' | 'dentistas'; soDentista?: boolean }> = ({ isOpen, onClose, clinicaId, initialTab, soDentista }) => { export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; clinicaId?: string; initialTab?: 'clinica' | 'dentistas' | 'situacoes'; soDentista?: boolean }> = ({ isOpen, onClose, clinicaId, initialTab, soDentista }) => {
const [aba, setAba] = useState<'clinica' | 'dentistas'>(initialTab || 'clinica'); const [aba, setAba] = useState<'clinica' | 'dentistas' | 'situacoes'>(initialTab || 'clinica');
useEffect(() => { if (isOpen && initialTab) setAba(initialTab); }, [isOpen, initialTab]); useEffect(() => { if (isOpen && initialTab) setAba(initialTab); }, [isOpen, initialTab]);
const [erro, setErro] = useState<string | null>(null); const [erro, setErro] = useState<string | null>(null);
const [ok, setOk] = useState<string | null>(null); const [ok, setOk] = useState<string | null>(null);
@@ -107,6 +107,9 @@ export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; cl
const [folgas, setFolgas] = useState<any[]>([]); const [folgas, setFolgas] = useState<any[]>([]);
const [novaFolga, setNovaFolga] = useState({ data_inicio: '', data_fim: '', motivo: '' }); const [novaFolga, setNovaFolga] = useState({ data_inicio: '', data_fim: '', motivo: '' });
// Situações (regras que a Secretária IA segue)
const [situacoes, setSituacoes] = useState<string[]>([]);
const flash = (setter: (v: string | null) => void, msg: string) => { setter(msg); setTimeout(() => setter(null), 3000); }; const flash = (setter: (v: string | null) => void, msg: string) => { setter(msg); setTimeout(() => setter(null), 3000); };
const carregarClinica = useCallback(async () => { const carregarClinica = useCallback(async () => {
@@ -131,7 +134,13 @@ export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; cl
} catch (e: any) { flash(setErro, e.message); } } catch (e: any) { flash(setErro, e.message); }
}, [clinicaId, soDentista]); }, [clinicaId, soDentista]);
useEffect(() => { if (isOpen) { carregarClinica(); carregarDentistas(); } }, [isOpen, carregarClinica, carregarDentistas]); const carregarSituacoes = useCallback(async () => {
if (!clinicaId) return;
try { const s = await req(`/clinica/${clinicaId}/situacoes`); setSituacoes((s.situacoes || []).map((r: any) => r.texto)); }
catch (e: any) { flash(setErro, e.message); }
}, [clinicaId]);
useEffect(() => { if (isOpen) { carregarClinica(); carregarDentistas(); if (!soDentista) carregarSituacoes(); } }, [isOpen, carregarClinica, carregarDentistas, carregarSituacoes, soDentista]);
const carregarFolgas = useCallback((id: string) => { const carregarFolgas = useCallback((id: string) => {
req(`/dentista/${id}/folgas`).then((f) => setFolgas(f.folgas || [])).catch(() => setFolgas([])); req(`/dentista/${id}/folgas`).then((f) => setFolgas(f.folgas || [])).catch(() => setFolgas([]));
}, []); }, []);
@@ -171,6 +180,11 @@ export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; cl
try { await req(`/clinica/${clinicaId}/feriados/${id}`, { method: 'DELETE' }); carregarClinica(); } try { await req(`/clinica/${clinicaId}/feriados/${id}`, { method: 'DELETE' }); carregarClinica(); }
catch (e: any) { flash(setErro, e.message); } catch (e: any) { flash(setErro, e.message); }
}; };
const salvarSituacoes = async () => {
setSaving(true); setErro(null);
try { await req(`/clinica/${clinicaId}/situacoes`, { method: 'PUT', body: JSON.stringify({ situacoes: situacoes.map((s) => s.trim()).filter(Boolean) }) }); flash(setOk, 'Situações salvas!'); }
catch (e: any) { flash(setErro, e.message); } finally { setSaving(false); }
};
if (!isOpen) return null; if (!isOpen) return null;
@@ -191,10 +205,10 @@ export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; cl
{/* Tabs */} {/* Tabs */}
<div className="flex gap-1 px-8 pt-4 border-b border-gray-50"> <div className="flex gap-1 px-8 pt-4 border-b border-gray-50">
{(soDentista ? (['dentistas'] as const) : (['clinica', 'dentistas'] as const)).map((t) => ( {(soDentista ? (['dentistas'] as const) : (['clinica', 'dentistas', 'situacoes'] as const)).map((t) => (
<button key={t} onClick={() => setAba(t)} <button key={t} onClick={() => setAba(t)}
className={`px-4 py-2.5 text-xs font-black uppercase tracking-wide rounded-t-xl transition-colors ${aba === t ? 'bg-teal-600 text-white' : 'text-gray-400 hover:text-gray-600'}`}> className={`px-4 py-2.5 text-xs font-black uppercase tracking-wide rounded-t-xl transition-colors ${aba === t ? 'bg-teal-600 text-white' : 'text-gray-400 hover:text-gray-600'}`}>
{t === 'clinica' ? 'Clínica' : 'Dentistas'} {t === 'clinica' ? 'Clínica' : t === 'dentistas' ? 'Dentistas' : 'Situações'}
</button> </button>
))} ))}
</div> </div>
@@ -244,7 +258,7 @@ export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; cl
</details> </details>
</section> </section>
</div> </div>
) : ( ) : aba === 'dentistas' ? (
<div className="space-y-5"> <div className="space-y-5">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -287,6 +301,30 @@ export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; cl
</section> </section>
)} )}
</div> </div>
) : (
<div className="space-y-5">
<div className="flex items-center justify-between">
<h3 className="text-sm font-black text-gray-800 uppercase tracking-tight flex items-center gap-2"><AlertTriangle size={16} className="text-teal-600" /> Situações e regras da Secretária IA</h3>
<button onClick={salvarSituacoes} disabled={saving} className="bg-teal-600 hover:bg-teal-700 text-white text-xs font-black uppercase tracking-wide px-4 py-2 rounded-xl flex items-center gap-2 disabled:opacity-50">
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />} Salvar
</button>
</div>
<p className="text-[11px] text-gray-500 leading-relaxed bg-amber-50 rounded-2xl p-4">
Escreva regras que a Secretária IA deve seguir ao atender uma por caixa. Ex.: <span className="italic">"Pelo plano X não temos profissional para canal em dente posterior; não agende direto — ofereça uma avaliação para o dentista examinar melhor o dente."</span> Quando uma situação exigir decisão humana, a IA encaminha o caso e pede para o paciente aguardar (aparece em Pedidos pendentes).
</p>
<div className="space-y-2">
{situacoes.map((s, i) => (
<div key={i} className="flex items-start gap-2">
<textarea value={s} rows={2} onChange={(e) => setSituacoes(situacoes.map((x, j) => j === i ? e.target.value : x))}
placeholder="Descreva a situação e o que a IA deve fazer…"
className="flex-1 bg-gray-50 border-2 border-transparent focus:border-teal-600 rounded-xl px-3 py-2 text-sm font-medium text-gray-800 outline-none resize-y" />
<button onClick={() => setSituacoes(situacoes.filter((_, j) => j !== i))} className="mt-2 text-gray-300 hover:text-red-600"><Trash2 size={16} /></button>
</div>
))}
<button onClick={() => setSituacoes([...situacoes, ''])} className="bg-gray-900 hover:bg-black text-white text-xs font-black uppercase px-4 py-2.5 rounded-xl flex items-center gap-1"><Plus size={14} /> Nova situação</button>
</div>
{situacoes.length === 0 && <p className="text-[11px] text-gray-400 italic">Nenhuma situação cadastrada. A IA seguirá apenas o roteiro padrão.</p>}
</div>
)} )}
</div> </div>
</div> </div>
+28 -2
View File
@@ -2,7 +2,7 @@
// registrou ("vou confirmar e retorno"). A humana confirma (vira agendamento) ou // registrou ("vou confirmar e retorno"). A humana confirma (vira agendamento) ou
// recusa. Consome /api/nw/agenda-config/clinica/:id/pedidos*. // recusa. Consome /api/nw/agenda-config/clinica/:id/pedidos*.
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { X, CalendarClock, Check, Ban, Loader2, Phone, User } from 'lucide-react'; import { X, CalendarClock, Check, Ban, Loader2, Phone, User, HelpCircle } from 'lucide-react';
const API = (import.meta as any).env?.VITE_API_URL || '/api'; const API = (import.meta as any).env?.VITE_API_URL || '/api';
const BASE = `${API}/nw/agenda-config`; const BASE = `${API}/nw/agenda-config`;
@@ -50,6 +50,11 @@ export const PedidosPendentes: React.FC<{ isOpen: boolean; onClose: () => void;
try { await req(`/clinica/${clinicaId}/pedidos/${p.id}/recusar`, { method: 'POST' }); await carregar(); onChange?.(); } try { await req(`/clinica/${clinicaId}/pedidos/${p.id}/recusar`, { method: 'POST' }); await carregar(); onChange?.(); }
catch (e: any) { setErro(e.message); } finally { setBusy(null); } catch (e: any) { setErro(e.message); } finally { setBusy(null); }
}; };
const resolver = async (p: any) => {
setBusy(p.id); setErro(null);
try { await req(`/clinica/${clinicaId}/pedidos/${p.id}/resolver`, { method: 'POST' }); await carregar(); onChange?.(); }
catch (e: any) { setErro(e.message); } finally { setBusy(null); }
};
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
@@ -73,7 +78,28 @@ export const PedidosPendentes: React.FC<{ isOpen: boolean; onClose: () => void;
<div className="flex items-center justify-center py-16"><Loader2 size={22} className="animate-spin text-gray-400" /></div> <div className="flex items-center justify-center py-16"><Loader2 size={22} className="animate-spin text-gray-400" /></div>
) : pedidos.length === 0 ? ( ) : pedidos.length === 0 ? (
<p className="text-center text-sm text-gray-400 py-16">Nenhum pedido pendente. 🎉</p> <p className="text-center text-sm text-gray-400 py-16">Nenhum pedido pendente. 🎉</p>
) : pedidos.map((p) => ( ) : pedidos.map((p) => p.tipo === 'duvida' ? (
<div key={p.id} className="border-2 border-indigo-100 rounded-2xl p-4 bg-indigo-50/40">
<div className="flex items-start justify-between gap-3 flex-wrap">
<div className="space-y-1.5 max-w-[75%]">
<div className="flex items-center gap-1.5 text-[10px] font-black text-indigo-600 uppercase tracking-widest"><HelpCircle size={13} /> Dúvida p/ decidir</div>
<div className="text-sm font-bold text-gray-800 leading-snug whitespace-pre-wrap">{p.resumo}</div>
<div className="flex items-center gap-2 text-xs text-gray-600 font-bold"><User size={13} className="text-gray-400" /> {p.paciente_nome || 'Cliente'}
{p.paciente_telefone && <span className="flex items-center gap-1 text-gray-400"><Phone size={12} /> {p.paciente_telefone}</span>}</div>
</div>
<div className="flex gap-2">
<button onClick={() => resolver(p)} disabled={busy === p.id}
className="bg-indigo-600 hover:bg-indigo-700 text-white text-[11px] font-black uppercase px-3 py-1.5 rounded-xl flex items-center gap-1 disabled:opacity-50">
{busy === p.id ? <Loader2 size={13} className="animate-spin" /> : <Check size={13} />} Resolvida
</button>
<button onClick={() => recusar(p)} disabled={busy === p.id}
className="bg-gray-100 hover:bg-red-50 text-gray-500 hover:text-red-600 text-[11px] font-black uppercase px-3 py-1.5 rounded-xl flex items-center gap-1">
<Ban size={13} /> Descartar
</button>
</div>
</div>
</div>
) : (
<div key={p.id} className="border border-gray-200 rounded-2xl p-4 bg-white"> <div key={p.id} className="border border-gray-200 rounded-2xl p-4 bg-white">
<div className="flex items-start justify-between gap-3 flex-wrap"> <div className="flex items-start justify-between gap-3 flex-wrap">
<div className="space-y-1"> <div className="space-y-1">
+165
View File
@@ -0,0 +1,165 @@
// Área de PENDÊNCIAS DO DONO do workspace (só o dono vê). Engloba tudo:
// 1) Checklist da clínica — perguntas Sim/Não que o dono responde;
// 2) Pendências de configuração — quando a resposta não bate com o banco
// (ex.: marcou "mais de um dentista" mas só há 1 cadastrado);
// 3) Fila operacional — resumo dos pedidos/dúvidas aguardando a secretária humana.
// Consome /api/nw/agenda-config/clinica/:id/{checklist,pendencias}.
import React, { useState, useEffect, useCallback } from 'react';
import { X, ClipboardCheck, AlertTriangle, Check, Ban, Loader2, Save, CalendarClock, HelpCircle, ChevronRight } from 'lucide-react';
const API = (import.meta as any).env?.VITE_API_URL || '/api';
const BASE = `${API}/nw/agenda-config`;
async function req(path: string, opts: RequestInit = {}) {
const token = localStorage.getItem('SCOREODONTO_AUTH_TOKEN');
const res = await fetch(`${BASE}${path}`, { ...opts, headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(opts.headers || {}) } });
const t = await res.text(); const d = t ? JSON.parse(t) : {};
if (!res.ok) throw new Error(d.error || `Erro ${res.status}`);
return d;
}
type Questao = { chave: string; pergunta: string; ajuda?: string; resposta: number | null };
type PendConfig = { chave: string; severidade: string; titulo: string; detalhe: string };
export const PendenciasDono: React.FC<{ isOpen: boolean; onClose: () => void; clinicaId?: string; onOpenFila?: () => void }> = ({ isOpen, onClose, clinicaId, onOpenFila }) => {
const [questoes, setQuestoes] = useState<Questao[]>([]);
const [config, setConfig] = useState<PendConfig[]>([]);
const [oper, setOper] = useState<{ horarios: number; duvidas: number; total: number }>({ horarios: 0, duvidas: 0, total: 0 });
const [contagens, setContagens] = useState<{ dentistas: number; pacientes: number }>({ dentistas: 0, pacientes: 0 });
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [erro, setErro] = useState<string | null>(null);
const [ok, setOk] = useState<string | null>(null);
const flash = (setter: (v: string | null) => void, m: string) => { setter(m); setTimeout(() => setter(null), 3000); };
const carregar = useCallback(async () => {
if (!clinicaId) return;
setLoading(true); setErro(null);
try {
const c = await req(`/clinica/${clinicaId}/checklist`);
setQuestoes(c.questoes || []);
const p = await req(`/clinica/${clinicaId}/pendencias`);
setConfig(p.config || []);
setOper(p.operacionais || { horarios: 0, duvidas: 0, total: 0 });
setContagens(p.contagens || { dentistas: 0, pacientes: 0 });
} catch (e: any) { setErro(e.message); } finally { setLoading(false); }
}, [clinicaId]);
useEffect(() => { if (isOpen) carregar(); }, [isOpen, carregar]);
const setResposta = (chave: string, resposta: number | null) =>
setQuestoes((qs) => qs.map((q) => q.chave === chave ? { ...q, resposta } : q));
const salvar = async () => {
setSaving(true); setErro(null);
try {
const respostas: Record<string, number | null> = {};
questoes.forEach((q) => { respostas[q.chave] = q.resposta; });
await req(`/clinica/${clinicaId}/checklist`, { method: 'PUT', body: JSON.stringify({ respostas }) });
flash(setOk, 'Checklist salvo!');
await carregar();
} catch (e: any) { setErro(e.message); } finally { setSaving(false); }
};
if (!isOpen) return null;
const sevColor = (s: string) => s === 'alta' ? 'border-red-200 bg-red-50' : 'border-amber-200 bg-amber-50';
const sevText = (s: string) => s === 'alta' ? 'text-red-700' : 'text-amber-700';
return (
<div className="fixed inset-0 bg-black/60 z-[70] flex items-center justify-center backdrop-blur-md p-0">
<div className="bg-white shadow-2xl w-full h-dvh md:w-[92vw] md:max-w-3xl md:h-[92vh] rounded-none md:rounded-[2rem] flex flex-col overflow-hidden border border-gray-100">
<div className="px-8 py-6 border-b border-gray-50 flex justify-between items-center bg-gradient-to-br from-gray-50 to-white">
<div className="flex items-center gap-3">
<div className="p-2.5 bg-indigo-600 rounded-2xl shadow-lg shadow-indigo-200"><ClipboardCheck size={22} className="text-white" /></div>
<div>
<h2 className="text-lg font-black text-gray-900 uppercase tracking-tighter">Pendências do workspace</h2>
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mt-0.5">Config da clínica + o que a Secretária precisa que você resolva</p>
</div>
</div>
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-xl text-gray-400"><X size={22} /></button>
</div>
{(erro || ok) && <div className={`px-8 py-2 text-xs font-bold ${erro ? 'bg-red-50 text-red-600' : 'bg-green-50 text-green-600'}`}>{erro || ok}</div>}
<div className="flex-1 overflow-y-auto p-6 space-y-8">
{loading ? (
<div className="flex items-center justify-center py-16"><Loader2 size={22} className="animate-spin text-gray-400" /></div>
) : (
<>
{/* Fila operacional */}
<section>
<h3 className="text-xs font-black text-gray-500 uppercase tracking-widest mb-3">Aguardando a secretária humana</h3>
<button onClick={() => onOpenFila?.()} disabled={!onOpenFila}
className="w-full flex items-center justify-between gap-3 border-2 border-amber-100 bg-amber-50/50 rounded-2xl px-4 py-3.5 text-left hover:bg-amber-50 disabled:cursor-default disabled:hover:bg-amber-50/50">
<div className="flex items-center gap-3">
<CalendarClock size={20} className="text-amber-600" />
<div>
<div className="text-sm font-black text-gray-800">{oper.total} pedido(s) na fila</div>
<div className="text-[11px] text-gray-500 font-bold">{oper.horarios} horário(s) · {oper.duvidas} dúvida(s)</div>
</div>
</div>
{onOpenFila && <ChevronRight size={18} className="text-amber-400" />}
</button>
</section>
{/* Pendências de configuração */}
<section>
<h3 className="text-xs font-black text-gray-500 uppercase tracking-widest mb-3">Configuração a resolver</h3>
{config.length === 0 ? (
<p className="text-sm text-gray-400 flex items-center gap-2 py-2"><Check size={16} className="text-emerald-500" /> Nenhuma pendência de configuração. 🎉</p>
) : (
<div className="space-y-2">
{config.map((p, i) => (
<div key={i} className={`border-2 rounded-2xl p-4 ${sevColor(p.severidade)}`}>
<div className={`flex items-center gap-1.5 text-[10px] font-black uppercase tracking-widest ${sevText(p.severidade)}`}><AlertTriangle size={13} /> {p.severidade === 'alta' ? 'Importante' : 'Atenção'}</div>
<div className="text-sm font-black text-gray-800 mt-1">{p.titulo}</div>
<div className="text-[12px] text-gray-600 mt-0.5 leading-snug">{p.detalhe}</div>
</div>
))}
</div>
)}
<p className="text-[10px] text-gray-400 mt-2">No sistema: {contagens.dentistas} dentista(s) · {contagens.pacientes} paciente(s) cadastrado(s).</p>
</section>
{/* Checklist */}
<section>
<div className="flex items-center justify-between mb-3">
<h3 className="text-xs font-black text-gray-500 uppercase tracking-widest">Checklist da clínica</h3>
<button onClick={salvar} disabled={saving} className="bg-indigo-600 hover:bg-indigo-700 text-white text-[11px] font-black uppercase tracking-wide px-4 py-2 rounded-xl flex items-center gap-2 disabled:opacity-50">
{saving ? <Loader2 size={13} className="animate-spin" /> : <Save size={13} />} Salvar
</button>
</div>
<p className="text-[11px] text-gray-500 bg-gray-50 rounded-2xl p-3 mb-3 leading-relaxed">
Responda sobre a clínica. A Secretária usa isto para saber quando pode agendar sozinha quando algo não bate com o cadastro, ela evita agendar e encaminha ao humano, e aparece aqui em cima como pendência.
</p>
<div className="space-y-2">
{questoes.map((q) => (
<div key={q.chave} className="border border-gray-200 rounded-2xl p-4">
<div className="flex items-start justify-between gap-3 flex-wrap">
<div className="flex-1 min-w-[200px]">
<div className="text-sm font-black text-gray-800 flex items-center gap-1.5"><HelpCircle size={14} className="text-gray-300" /> {q.pergunta}</div>
{q.ajuda && <div className="text-[11px] text-gray-400 mt-1 leading-snug">{q.ajuda}</div>}
</div>
<div className="flex gap-1.5">
<button onClick={() => setResposta(q.chave, q.resposta === 1 ? null : 1)}
className={`text-[11px] font-black uppercase px-3 py-1.5 rounded-xl flex items-center gap-1 transition-colors ${q.resposta === 1 ? 'bg-emerald-600 text-white' : 'bg-gray-100 text-gray-500 hover:bg-emerald-50'}`}>
<Check size={13} /> Sim
</button>
<button onClick={() => setResposta(q.chave, q.resposta === 0 ? null : 0)}
className={`text-[11px] font-black uppercase px-3 py-1.5 rounded-xl flex items-center gap-1 transition-colors ${q.resposta === 0 ? 'bg-red-600 text-white' : 'bg-gray-100 text-gray-500 hover:bg-red-50'}`}>
<Ban size={13} /> Não
</button>
</div>
</div>
</div>
))}
</div>
</section>
</>
)}
</div>
</div>
</div>
);
};
+84 -3
View File
@@ -8,10 +8,14 @@ import {
Sparkles, BookOpen, ShieldCheck, Calendar, AlertTriangle, Sparkles, BookOpen, ShieldCheck, Calendar, AlertTriangle,
CheckCircle, Clock, User, RefreshCw, MoreVertical, Power, Cpu, CheckCircle, Clock, User, RefreshCw, MoreVertical, Power, Cpu,
Phone, Shield, Star, Repeat, UserCheck, Building2, Stethoscope, Phone, Shield, Star, Repeat, UserCheck, Building2, Stethoscope,
Maximize2, Save, HelpCircle, ArrowLeft, Maximize2, Save, HelpCircle, ArrowLeft, ClipboardCheck,
} from 'lucide-react' } from 'lucide-react'
import { useSecretariaStore } from './store/secretariaStore' import { useSecretariaStore } from './store/secretariaStore'
import type { SecAgent, BrainNode, SecConversation, BrainNodeType, CalendarSlot, SecNumber, NumberRole } from './services/secretariaApi' import type { SecAgent, BrainNode, SecConversation, BrainNodeType, CalendarSlot, SecNumber, NumberRole } from './services/secretariaApi'
import { HybridBackend } from '../../services/backend.ts'
import { PendenciasDono } from '../../components/PendenciasDono.tsx'
import { PedidosPendentes } from '../../components/PedidosPendentes.tsx'
import { secPowerApi } from './services/secretariaApi'
// ─── Thin Nav ───────────────────────────────────────────────────────────────── // ─── Thin Nav ─────────────────────────────────────────────────────────────────
@@ -25,7 +29,7 @@ const NAV_ITEMS: { id: NavTab; icon: React.ElementType; label: string }[] = [
{ id: 'numbers', icon: Phone, label: 'Números' }, { id: 'numbers', icon: Phone, label: 'Números' },
] ]
function ThinNav({ active, onChange, onHelp, onBack }: { active: NavTab; onChange: (t: NavTab) => void; onHelp: () => void; onBack?: () => void }) { function ThinNav({ active, onChange, onHelp, onBack, onPendencias, pendenciasBadge, secOn, onTogglePower }: { active: NavTab; onChange: (t: NavTab) => void; onHelp: () => void; onBack?: () => void; onPendencias?: () => void; pendenciasBadge?: number; secOn?: boolean; onTogglePower?: () => void }) {
return ( return (
<div className="w-[60px] h-full bg-white border-r border-gray-100 flex flex-col items-center py-4 shrink-0 z-30 gap-1"> <div className="w-[60px] h-full bg-white border-r border-gray-100 flex flex-col items-center py-4 shrink-0 z-30 gap-1">
{onBack && ( {onBack && (
@@ -54,6 +58,34 @@ function ThinNav({ active, onChange, onHelp, onBack }: { active: NavTab; onChang
<div className="flex-1" /> <div className="flex-1" />
{/* Desligar/ligar a Secretária — GLOBAL, todos os chats, sem timer de retorno.
Botão de segurança enquanto a IA amadurece; visível a todos. */}
{onTogglePower && (
<button
title={secOn ? 'Desligar a Secretária (todos os chats)' : 'Secretária DESLIGADA — clique para ligar'}
onClick={onTogglePower}
className={`relative w-10 h-10 rounded-xl flex items-center justify-center transition-all mb-1 ${
secOn ? 'text-emerald-600 hover:bg-emerald-50' : 'text-white bg-red-600 hover:bg-red-700 animate-pulse'
}`}
>
<Power size={20} />
</button>
)}
{/* Pendências do dono — checklist + config + fila (só o dono do workspace) */}
{onPendencias && (
<button
title="Pendências do workspace"
onClick={onPendencias}
className="relative w-10 h-10 rounded-xl flex items-center justify-center text-gray-500 hover:text-indigo-600 hover:bg-gray-50 transition-all mb-1"
>
<ClipboardCheck size={20} />
{!!pendenciasBadge && pendenciasBadge > 0 && (
<span className="absolute -top-0.5 -right-0.5 min-w-[16px] h-4 px-1 bg-indigo-600 text-white text-[9px] font-black rounded-full flex items-center justify-center">{pendenciasBadge > 99 ? '99+' : pendenciasBadge}</span>
)}
</button>
)}
{/* Ajuda — modelos de como usar e configurar a secretária */} {/* Ajuda — modelos de como usar e configurar a secretária */}
<button <button
title="Ajuda — como usar" title="Ajuda — como usar"
@@ -1578,13 +1610,52 @@ export function SecretariaView(props: { onNavigate?: (view: string) => void } =
const [openNode, setOpenNode] = useState<BrainNode | null>(null) const [openNode, setOpenNode] = useState<BrainNode | null>(null)
const [showHelp, setShowHelp] = useState(false) const [showHelp, setShowHelp] = useState(false)
// Pendências do dono (checklist + config + fila) — só o dono do workspace
const clinicaId = (HybridBackend.getActiveWorkspace() as any)?.id as string | undefined
const ehDono = ['admin', 'donoclinica', 'donoconsultorio'].includes(HybridBackend.getCurrentRole?.() || '')
const [showPendencias, setShowPendencias] = useState(false)
const [showFila, setShowFila] = useState(false)
const [pendCount, setPendCount] = useState(0)
// Liga/desliga GLOBAL da Secretária (kill-switch auto_reply) — todos, sem timer
const [secOn, setSecOn] = useState(true)
const [powerBusy, setPowerBusy] = useState(false)
// Boot // Boot
useEffect(() => { useEffect(() => {
store.loadAgents() store.loadAgents()
store.loadCalendar() store.loadCalendar()
store.loadNumbers() store.loadNumbers()
secPowerApi.get().then((r) => setSecOn(r.enabled)).catch(() => {})
}, []) }, [])
const handleTogglePower = useCallback(async () => {
if (powerBusy) return
const proximo = !secOn
// Ao DESLIGAR (para todos os chats, sem retorno automático), confirma.
if (!proximo && !window.confirm('Desligar a Secretária para TODOS os chats? Ela para de responder e só volta quando você ligar de novo.')) return
setPowerBusy(true)
try { const r = await secPowerApi.set(proximo); setSecOn(r.enabled) }
catch { /* mantém estado atual */ } finally { setPowerBusy(false) }
}, [secOn, powerBusy])
// Contagem de pendências para o badge (dono) — atualiza ao abrir e a cada 5 min.
useEffect(() => {
if (!ehDono || !clinicaId) return
let vivo = true
const carregar = async () => {
try {
const token = localStorage.getItem('SCOREODONTO_AUTH_TOKEN')
const API = (import.meta as any).env?.VITE_API_URL || '/api'
const r = await fetch(`${API}/nw/agenda-config/clinica/${clinicaId}/pendencias`, { headers: token ? { Authorization: `Bearer ${token}` } : {} })
if (r.ok && vivo) { const d = await r.json(); setPendCount(d.total || 0) }
} catch { /* silencioso */ }
}
carregar()
const t = setInterval(carregar, 5 * 60 * 1000)
return () => { vivo = false; clearInterval(t) }
}, [ehDono, clinicaId, showPendencias, showFila])
// Sincroniza openNode quando o store atualiza o nó (ex: toggle active, node_model) // Sincroniza openNode quando o store atualiza o nó (ex: toggle active, node_model)
useEffect(() => { useEffect(() => {
if (!openNode) return if (!openNode) return
@@ -1637,7 +1708,17 @@ export function SecretariaView(props: { onNavigate?: (view: string) => void } =
<div className="flex h-full w-full bg-gray-50 overflow-hidden font-sans"> <div className="flex h-full w-full bg-gray-50 overflow-hidden font-sans">
{/* Thin Nav */} {/* Thin Nav */}
<ThinNav active={activeTab} onChange={handleTabChange} onHelp={() => setShowHelp(true)} onBack={() => props.onNavigate?.('dashboard')} /> <ThinNav active={activeTab} onChange={handleTabChange} onHelp={() => setShowHelp(true)} onBack={() => props.onNavigate?.('dashboard')}
onPendencias={ehDono ? () => setShowPendencias(true) : undefined} pendenciasBadge={pendCount}
secOn={secOn} onTogglePower={handleTogglePower} />
{ehDono && (
<>
<PendenciasDono isOpen={showPendencias} onClose={() => setShowPendencias(false)} clinicaId={clinicaId}
onOpenFila={() => { setShowPendencias(false); setShowFila(true) }} />
<PedidosPendentes isOpen={showFila} onClose={() => setShowFila(false)} clinicaId={clinicaId} />
</>
)}
{showHelp && ( {showHelp && (
<HelpModal <HelpModal
@@ -83,6 +83,13 @@ export interface CalendarSlot {
created_at: string created_at: string
} }
// ─── Power (liga/desliga global — kill-switch auto_reply) ──────────────────────
export const secPowerApi = {
get: () => api.get<{ enabled: boolean }>(`${BASE}/power`).then((r) => r.data),
set: (enabled: boolean) => api.post<{ enabled: boolean }>(`${BASE}/power`, { enabled }).then((r) => r.data),
}
// ─── Agents ─────────────────────────────────────────────────────────────────── // ─── Agents ───────────────────────────────────────────────────────────────────
export const secAgentApi = { export const secAgentApi = {