Compare commits

...

11 Commits

Author SHA1 Message Date
ruicesar 8cac06e37c Merge pull request 'feat(superadmin): página Secretária IA (motivos de liga/desliga por sessão)' (#5) from feat/newwhats-satelite into main
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 1m0s
2026-07-06 20:14:08 +02:00
VPS 4 Builder b67fa15c55 feat(superadmin): página "Secretária IA" com motivos de liga/desliga por sessão
Nova aba no super admin (/superadmin/secretaria): tabela com quem ligou/desligou a
Secretária de cada número, quando e o MOTIVO do desligamento. Endpoint
/api/superadmin/secretaria-desligamentos (superadminGuard) busca o log global no
motor (resolve um e-mail de dono pareado; log é global). Item no Sidebar + rotas.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 20:02:39 +02:00
ruicesar 1e16edf2be Merge pull request 'feat/fix: dashboard scope + agenda UI + newwhats (encaixe, session-power, papel)' (#4) from feat/newwhats-satelite into main
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 1m0s
2026-07-06 19:42:57 +02:00
VPS 4 Builder cfeac891fb feat(wa-inbox): botão liga/desliga da Secretária por sessão na thin sidebar
Botão Power na thin sidebar do /wa-inbox age SÓ na sessão/número aberto (verde=on,
vermelho pulsante=off). Abre modal com textarea de motivo (obrigatório p/ desligar),
histórico de quem ligou/desligou e aviso de rate-limit (1x/hora). secSessionPowerApi
+ SessaoSecretariaPower.tsx.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 19:39:41 +02:00
VPS 4 Builder f30b0ab732 feat(agenda): linha da meia-hora dividindo cada hora em 2 (estilo Google)
Grade de 30min com rótulo só na hora (slotDuration 30min + slotLabelInterval 1h);
a linha de :30 fica tracejada/fraca (fc-timegrid-slot-minor), distinta da linha
cheia da hora — como na agenda do Google.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 19:34:02 +02:00
VPS 4 Builder ddcc639ab6 feat(agenda): coluna "Agenda Clínica" no desktop com a estrutura do mobile
As ações da coluna de controles no desktop eram só uma fileira de ícones sem
rótulo (difícil saber o que era cada um). Agora o desktop herda a estrutura do
mobile: botões empilhados com ícone + nome (Atividade da equipe, Pedidos pendentes,
Configurações, Meus Horários, etc.), badges reposicionados junto ao ícone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 19:22:04 +02:00
VPS 4 Builder 882af16458 fix(dashboard): escopo por papel — dentista não vê agenda da clínica nem financeiro
/api/dashboard/stats devolvia a agenda inteira da clínica (nomes de pacientes de
todos os dentistas) + faturamento/despesas/growth no payload para QUALQUER membro,
inclusive dentista. Pior: cache por clínica (dashboard:stats:<clinica>) servia o
mesmo JSON a todos os papéis. Agora: dentista vê só a própria agenda; financeiro
só p/ admin/donoclinica/donoconsultorio/funcionario; cache por escopo (papel/dentista).
Verificado: dentista recém-cadastrado (murilo) → agenda vazia + financeiro 0; dono → tudo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 19:11:46 +02:00
VPS 4 Builder 4dea9ab28a fix(newwhats): papel do número no /wa-inbox p/ usuário sem conta no motor
Os papéis (sec_numbers) são resolvidos no motor pela conta (x-nw-email). Um
usuário que só existe no scoreodonto (ex.: recepcionista) não tem conta no motor,
então buscar os papéis com o e-mail DELE retorna "conta não encontrada" → todos os
números liberados apareciam como "Sem papel definido". Agora o papel de cada sessão
liberada é buscado com o e-mail do DONO da sessão (cache por e-mail). Verificado:
recep passa a ver o número como role=clinic ("Clínica / Recepção · Rui Cesar").

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 17:24:16 +02:00
VPS 4 Builder 26f39a60f3 feat(newwhats): endpoints de encaixe (analisar + mover) p/ adiantar consulta
- GET /encaixe/analisar: ocupação do horário desejado (quem está nele) + horário
  livre mais cedo (flex-inclusivo) p/ oferecer/encaixar.
- POST /encaixe/mover: remarca um agendamento (o A aceitou adiantar), com checagem
  de conflito no destino (findConflict ganha excludeId p/ ignorar o próprio).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 17:08:49 +02:00
ruicesar 7a5f5cf0fa Merge pull request 'fix(newwhats): proxy.js/server.js do auto-provisionamento (corrige 502 em prod)' (#3) from fix/newwhats-proxy-exports into main
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 54s
2026-07-06 16:35:08 +02:00
VPS 4 Builder f806835ae3 fix(newwhats): commita proxy.js/server.js do auto-provisionamento (main inconsistente)
O index.js (já no main) importa createAccountsRoute/provisionNewwhatsAccount do
proxy.js, mas esses exports nunca foram commitados — só existiam no working tree.
Isso quebrava o boot do backend na imagem de produção (SyntaxError: proxy.js does
not provide an export named 'createAccountsRoute') → 502. Commita proxy.js (exports
createAccountsRoute/provisionNewwhatsAccount/operatorSignature/createWsTunnel) e
server.js (usa operatorSignature + parser 12mb em /api/nw/v1), deixando o main
consistente com o backend que já roda em dev.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 16:34:07 +02:00
10 changed files with 648 additions and 74 deletions
+113 -2
View File
@@ -99,7 +99,7 @@ async function janelasClinicaNoDia(pool, clinicaId, dateStr, dow, clinicHours) {
// Overbooking: MESMA regra do server.js (agrupa o dentista por usuario_id/email, // Overbooking: MESMA regra do server.js (agrupa o dentista por usuario_id/email,
// para o mesmo profissional atuando em clínicas diferentes). Cópia da lógica — // para o mesmo profissional atuando em clínicas diferentes). Cópia da lógica —
// se a regra do scoreodonto mudar, revisar aqui também. // se a regra do scoreodonto mudar, revisar aqui também.
async function findConflict(db, dentistaId, start, end) { async function findConflict(db, dentistaId, start, end, excludeId = null) {
if (!dentistaId || !start) return null; if (!dentistaId || !start) return null;
const { rows: dr } = await db.query('SELECT usuario_id, email FROM dentistas WHERE id = $1', [dentistaId]); const { rows: dr } = await db.query('SELECT usuario_id, email FROM dentistas WHERE id = $1', [dentistaId]);
let ids = [dentistaId]; let ids = [dentistaId];
@@ -117,8 +117,9 @@ async function findConflict(db, dentistaId, start, end) {
WHERE dentistaid = ANY($1) WHERE dentistaid = ANY($1)
AND (status IS NULL OR lower(status) NOT IN ('cancelado','falta','remarcar')) AND (status IS NULL OR lower(status) NOT IN ('cancelado','falta','remarcar'))
AND start_time < $3 AND COALESCE(end_time, start_time) > $2 AND start_time < $3 AND COALESCE(end_time, start_time) > $2
AND ($4::text IS NULL OR id <> $4)
LIMIT 1`, LIMIT 1`,
[ids, start, end || start]); [ids, start, end || start, excludeId]);
return rows[0] || null; return rows[0] || null;
} }
@@ -473,6 +474,116 @@ export function createAgendaBridge(pool) {
} }
}); });
// ── GET /encaixe/analisar ─────────────────────────────────────────────────────
// Fluxo de "adiantar consulta": dado um dia (e horário desejado), diz se o horário
// está OCUPADO (por quem) e qual o horário LIVRE mais cedo que dá para oferecer/
// encaixar — incluindo a janela FLEX (zona cinza), pois na contenção a Secretária
// pode usá-la. Query: clinica_id, dentista_id?, data (YYYY-MM-DD), hora? (HH:MM).
router.get('/encaixe/analisar', async (req, res) => {
const clinicaId = String(req.query.clinica_id || '');
const data = String(req.query.data || '');
if (!clinicaId || !data) return res.status(400).json({ error: 'clinica_id e data obrigatórios' });
const dentistaId = req.query.dentista_id ? String(req.query.dentista_id) : null;
const horaDesejada = req.query.hora ? String(req.query.hora).slice(0, 5) : null;
try {
const day = new Date(`${data}T00:00:00`);
const dow = day.getDay();
const dParams = [clinicaId]; let dWhere = 'clinica_id = $1 AND ativo IS DISTINCT FROM 0';
if (dentistaId) { dParams.push(dentistaId); dWhere += ` AND id = $${dParams.length}`; }
const { rows: dentistas } = await pool.query(`SELECT id, nome FROM dentistas WHERE ${dWhere}`, dParams);
if (!dentistas.length) return res.json({ mais_cedo: null, contestado: null });
const clinicHours = await carregarHorariosClinica(pool, clinicaId);
const { janelas: clinicWins, fechado } = await janelasClinicaNoDia(pool, clinicaId, data, dow, clinicHours);
if (fechado || !clinicWins.length) return res.json({ mais_cedo: null, contestado: null, motivo: 'fechado' });
// Jornada FLEX-inclusiva (usa hora_*_flex quando houver — a zona cinza entra no encaixe).
const { rows: horarios } = await pool.query(
`SELECT dentista_id,
COALESCE(hora_inicio_flex, hora_inicio) AS hora_inicio,
COALESCE(hora_fim_flex, hora_fim) AS hora_fim
FROM dentistas_horarios WHERE clinica_id = $1 AND ativo = 1 AND dia_semana = $2`, [clinicaId, dow]);
const hoursBy = new Map();
for (const h of horarios) { const a = hoursBy.get(h.dentista_id) || []; a.push([hm(h.hora_inicio), hm(h.hora_fim)]); hoursBy.set(h.dentista_id, a); }
const start0 = new Date(day); start0.setHours(0, 0, 0, 0);
const end0 = new Date(start0); end0.setDate(end0.getDate() + 1);
const { rows: busy } = await pool.query(
`SELECT id, dentistaid, pacientenome, pacientecelular, start_time, end_time FROM agendamentos
WHERE clinica_id=$1 AND start_time>=$2 AND start_time<$3
AND (status IS NULL OR lower(status) NOT IN ('cancelado','falta','remarcar'))`,
[clinicaId, start0.toISOString(), end0.toISOString()]);
const busyBy = new Map();
for (const bz of busy) { const a = busyBy.get(bz.dentistaid) || []; a.push(bz); busyBy.set(bz.dentistaid, a); }
// Quem ocupa o horário desejado (direto, sem depender do grid).
let contestado = null;
if (horaDesejada) {
const alvo = new Date(`${data}T${horaDesejada}:00`);
const occ = busy.find((b) => new Date(b.start_time) <= alvo && new Date(b.end_time || b.start_time) > alvo)
|| busy.find((b) => fmt(new Date(b.start_time)).slice(11, 16) === horaDesejada);
if (occ) {
const dn = dentistas.find((d) => d.id === occ.dentistaid);
contestado = { agendamento_id: occ.id, dentista_id: occ.dentistaid, dentista_nome: dn?.nome || null,
paciente_nome: occ.pacientenome || null, paciente_celular: occ.pacientecelular || null,
start: fmt(new Date(occ.start_time)), hora: fmt(new Date(occ.start_time)).slice(11, 16) };
}
}
// Slots LIVRES do dia (flex-inclusivo).
const now = new Date(); const dur = SLOT_MIN; const livres = [];
for (const dent of dentistas) {
const dw = (hoursBy.get(dent.id) && hoursBy.get(dent.id).length) ? hoursBy.get(dent.id) : clinicWins;
const windows = interseccao(clinicWins, dw);
const bs = busyBy.get(dent.id) || [];
for (const [ini, fim] of windows) {
const [hi, mi] = String(ini).split(':').map(Number);
const [hf, mf] = String(fim).split(':').map(Number);
let t = new Date(day); t.setHours(hi, mi || 0, 0, 0);
const limit = new Date(day); limit.setHours(hf, mf || 0, 0, 0);
while (t.getTime() + dur * 60000 <= limit.getTime()) {
const s = new Date(t), e = new Date(t.getTime() + dur * 60000); t = e;
if (s <= now) continue;
if (bs.some((b) => new Date(b.start_time) < e && new Date(b.end_time || b.start_time) > s)) continue;
livres.push({ dentista_id: dent.id, dentista_nome: dent.nome, start: fmt(s), end: fmt(e), hora: fmt(s).slice(11, 16) });
}
}
}
livres.sort((a, b) => a.start.localeCompare(b.start));
// mais_cedo = livre imediatamente ANTES do desejado (o mais perto por baixo); senão o 1º livre.
let mais_cedo = null;
if (horaDesejada) {
const antes = livres.filter((s) => s.hora < horaDesejada);
mais_cedo = antes.length ? antes[antes.length - 1] : (livres[0] || null);
} else {
mais_cedo = livres[0] || null;
}
res.json({ mais_cedo, contestado, total_livres: livres.length });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ── POST /encaixe/mover ───────────────────────────────────────────────────────
// Move um agendamento existente para um novo horário (o A aceitou adiantar).
// Body: { clinica_id, agendamento_id, start, end? }.
router.post('/encaixe/mover', async (req, res) => {
const b = req.body || {};
if (!b.clinica_id || !b.agendamento_id || !b.start) return res.status(400).json({ error: 'clinica_id, agendamento_id e start obrigatórios' });
const end = b.end || new Date(new Date(b.start).getTime() + SLOT_MIN * 60000).toISOString();
const client = await pool.connect();
try {
await client.query('BEGIN');
const { rows } = await client.query('SELECT id, dentistaid FROM agendamentos WHERE id=$1 AND clinica_id=$2 FOR UPDATE', [b.agendamento_id, b.clinica_id]);
const ag = rows[0];
if (!ag) { await client.query('ROLLBACK'); return res.status(404).json({ error: 'Agendamento não encontrado.' }); }
const conflict = await findConflict(client, ag.dentistaid, b.start, end, b.agendamento_id);
if (conflict) { await client.query('ROLLBACK'); return res.status(409).json({ error: 'Horário de destino já ocupado.' }); }
await client.query('UPDATE agendamentos SET start_time=$1, end_time=$2, updated_at=NOW() WHERE id=$3', [b.start, end, b.agendamento_id]);
await client.query('COMMIT');
res.json({ ok: true });
} catch (e) { try { await client.query('ROLLBACK'); } catch { /* ignore */ } res.status(500).json({ error: e.message }); }
finally { client.release(); }
});
// ════════ Cadastro de pacientes (Secretária super inteligente) ════════════════ // ════════ Cadastro de pacientes (Secretária super inteligente) ════════════════
const soDigitos = (v) => String(v || '').replace(/\D/g, ''); const soDigitos = (v) => String(v || '').replace(/\D/g, '');
function idadeDe(dob) { function idadeDe(dob) {
+171 -18
View File
@@ -39,6 +39,18 @@ async function resolveOwnerId(pool, clinicaId) {
} catch { return null; } } catch { return null; }
} }
// Resolve o dono do workspace COM e-mail. Retorna { ownerId, email } ou null.
// Usado para a inbox compartilhada: a caixa da clínica vive na conta (tenant) do
// dono no motor, então falamos com o motor usando o e-mail DELE.
async function resolveOwner(pool, clinicaId) {
const ownerId = await resolveOwnerId(pool, clinicaId);
if (!ownerId) return null;
try {
const { rows } = await pool.query('SELECT lower(email) AS email FROM usuarios WHERE id = $1', [ownerId]);
return { ownerId, email: rows[0]?.email || null };
} catch { return { ownerId, email: null }; }
}
// Autoriza (ou nega 403) uma ação sensível de sessão de WhatsApp. // Autoriza (ou nega 403) uma ação sensível de sessão de WhatsApp.
// Mapeamento das ações do motor: // Mapeamento das ações do motor:
// POST /sessions → scan INICIAL (criar) → só o dono // POST /sessions → scan INICIAL (criar) → só o dono
@@ -115,7 +127,7 @@ export async function provisionNewwhatsAccount(pool, cfg, email) {
// Monta a assinatura do operador para prefixar mensagens: primeiro nome; se houver // Monta a assinatura do operador para prefixar mensagens: primeiro nome; se houver
// outro membro do workspace com o MESMO primeiro nome, desambigua com "Nome I." (inicial // outro membro do workspace com o MESMO primeiro nome, desambigua com "Nome I." (inicial
// do primeiro sobrenome). Retorna null se não houver nome. // do primeiro sobrenome). Retorna null se não houver nome.
async function operatorSignature(pool, userId, clinicaId) { export async function operatorSignature(pool, userId, clinicaId) {
if (!pool || !userId) return null; if (!pool || !userId) return null;
try { try {
const { rows } = await pool.query('SELECT nome FROM usuarios WHERE id = $1', [userId]); const { rows } = await pool.query('SELECT nome FROM usuarios WHERE id = $1', [userId]);
@@ -160,8 +172,9 @@ async function guardAreaAccess(pool, tail, userId, clinicaId) {
// Só o dono ou quem tiver can_delete_msg. (Complementa o gate de acesso ao inbox.) // Só o dono ou quem tiver can_delete_msg. (Complementa o gate de acesso ao inbox.)
async function guardInboxDestructive(pool, method, tail, userId, clinicaId) { async function guardInboxDestructive(pool, method, tail, userId, clinicaId) {
const isDeleteChat = method === 'DELETE' && /^\/inbox\/[^/]+\/?(\?|$)/.test(tail); const isDeleteChat = method === 'DELETE' && /^\/inbox\/[^/]+\/?(\?|$)/.test(tail);
const isClearMsgs = method === 'DELETE' && /^\/inbox\/[^/]+\/messages\/?(\?|$)/.test(tail); // limpar conversa (todas as msgs)
const isDeleteMsg = method === 'DELETE' && /^\/inbox\/[^/]+\/messages\/[^/]+/.test(tail); const isDeleteMsg = method === 'DELETE' && /^\/inbox\/[^/]+\/messages\/[^/]+/.test(tail);
if (!isDeleteChat && !isDeleteMsg) return { ok: true }; if (!isDeleteChat && !isClearMsgs && !isDeleteMsg) return { ok: true };
if (!pool || !clinicaId) return { ok: true }; if (!pool || !clinicaId) return { ok: true };
const ownerId = await resolveOwnerId(pool, clinicaId); const ownerId = await resolveOwnerId(pool, clinicaId);
if (ownerId && ownerId === userId) return { ok: true }; if (ownerId && ownerId === userId) return { ok: true };
@@ -199,23 +212,47 @@ export function createRestProxy(pool) {
const destructive = await guardInboxDestructive(pool, req.method, tail, userId, clinicaId); const destructive = await guardInboxDestructive(pool, req.method, tail, userId, clinicaId);
if (!destructive.ok) return res.status(destructive.status).json({ error: destructive.error }); if (!destructive.ok) return res.status(destructive.status).json({ error: destructive.error });
// Assinatura do operador: prefixa o texto enviado com "*Nome*\n" (identifica // Assinatura do operador: prefixa o texto enviado com "*Nome:*\n" (identifica quem
// quem respondeu numa caixa compartilhada). Só no envio de texto por humano. // respondeu numa caixa compartilhada). O ":" fica DENTRO do negrito para que TODOS
// os dispositivos (contato/outros operadores) e jids vejam "Nome:" em negrito
// consistente — não só a nossa UI. Só no envio de texto por humano.
if (req.method === 'POST' && /^\/inbox\/[^/]+\/send$/.test(tail) && req.body && typeof req.body.text === 'string' && req.body.text.trim()) { if (req.method === 'POST' && /^\/inbox\/[^/]+\/send$/.test(tail) && req.body && typeof req.body.text === 'string' && req.body.text.trim()) {
const sig = await operatorSignature(pool, userId, clinicaId); const sig = await operatorSignature(pool, userId, clinicaId);
if (sig) req.body.text = `*${sig}*\n${req.body.text}`; if (sig) req.body.text = `*${sig}:*\n${req.body.text}`;
}
// Conta-alvo no motor. Por padrão é a do próprio usuário (e-mail do JWT). Mas a
// inbox da clínica vive na conta (tenant) do DONO — então, nas rotas de inbox,
// se o usuário NÃO é dono do workspace e chegou até aqui (guardAreaAccess já
// exigiu can_inbox), falamos com o motor como o dono. Assim, logado como ele
// mesmo, o usuário vê a caixa do número da clínica no seletor — sem trocar de
// login. Só a inbox comuta a conta; demais rotas seguem com o e-mail próprio.
let targetEmail = email;
if (/^\/(inbox|conversations)(\/|$|\?)/.test(tail) && clinicaId) {
const owner = await resolveOwner(pool, clinicaId);
if (owner && owner.ownerId !== userId && owner.email) targetEmail = owner.email;
} }
const url = `${cfg.motorUrl}/api/ext/v1${tail}`; const url = `${cfg.motorUrl}/api/ext/v1${tail}`;
// Upload de arquivo grande: o corpo é multipart e NÃO passa pelo body-parser JSON
// (o stream chega intacto). Encaminhamos o stream cru ao motor, preservando o
// Content-Type (com boundary) — sem base64, sem limite de JSON.
const isMultipart = /multipart\/form-data/i.test(req.headers['content-type'] || '');
const opts = { method: req.method, headers: { const opts = { method: req.method, headers: {
'Content-Type': 'application/json',
'x-nw-key': cfg.integrationKey, 'x-nw-key': cfg.integrationKey,
'x-nw-email': email, 'x-nw-email': targetEmail,
// clínica do workspace ativo (modelo de canal) — repassa ao motor se veio. // clínica do workspace ativo (modelo de canal) — repassa ao motor se veio.
...(req.headers['x-nw-clinica'] ? { 'x-nw-clinica': String(req.headers['x-nw-clinica']) } : {}), ...(req.headers['x-nw-clinica'] ? { 'x-nw-clinica': String(req.headers['x-nw-clinica']) } : {}),
...(isMultipart
? { 'content-type': req.headers['content-type'] }
: { 'Content-Type': 'application/json' }),
} }; } };
if (!['GET', 'HEAD'].includes(req.method) && req.body && Object.keys(req.body).length) { if (isMultipart) {
opts.body = req; // stream cru do upload → motor
opts.duplex = 'half'; // exigido pelo fetch (undici) ao enviar stream
} else if (!['GET', 'HEAD'].includes(req.method) && req.body && Object.keys(req.body).length) {
opts.body = JSON.stringify(req.body); opts.body = JSON.stringify(req.body);
} }
@@ -224,8 +261,8 @@ export function createRestProxy(pool) {
let text = await r.text(); let text = await r.text();
// Self-heal: a conta ainda não foi espelhada no motor. Provisiona (idempotente) // Self-heal: a conta ainda não foi espelhada no motor. Provisiona (idempotente)
// e refaz o request UMA vez — assim "quando a pessoa for mexer, tudo funciona". // e refaz o request UMA vez — assim "quando a pessoa for mexer, tudo funciona".
if (r.status === 404 && /conta n[ãa]o encontrada/i.test(text) && email) { if (r.status === 404 && /conta n[ãa]o encontrada/i.test(text) && targetEmail) {
const ok = await provisionNewwhatsAccount(pool, cfg, email); const ok = await provisionNewwhatsAccount(pool, cfg, targetEmail);
if (ok) { r = await fetch(url, opts); text = await r.text(); } if (ok) { r = await fetch(url, opts); text = await r.text(); }
} }
res.status(r.status); res.status(r.status);
@@ -236,6 +273,99 @@ export function createRestProxy(pool) {
}; };
} }
// ── GET /api/nw/accounts — contas de WhatsApp que o usuário logado pode operar ──
//
// Alimenta o seletor de número da inbox (multi-conta, SEM trocar de login) e o
// ícone (i) de papéis. Junta: (1) as sessões da PRÓPRIA conta do usuário no motor
// e (2) as sessões LIBERADAS pelo dono (can_inbox por sessão em wa_session_authz),
// buscadas no motor com o e-mail do dono. Cada item traz o papel (sec_numbers).
export function createAccountsRoute(pool) {
return async function accountsRoute(req, res) {
const cfg = getConfig();
if (!cfg.motorUrl || !cfg.integrationKey) {
return res.status(503).json({ error: 'Integração NewWhats não configurada.' });
}
const auth = authFromReq(req);
if (!auth) return res.status(401).json({ error: 'Sessão inválida (faça login no scoreodonto).' });
const { email, userId } = auth;
const motorGet = async (path, asEmail) => {
try {
const r = await fetch(`${cfg.motorUrl}/api/ext/v1${path}`, {
headers: { 'x-nw-key': cfg.integrationKey, 'x-nw-email': asEmail },
});
if (!r.ok) return [];
const j = await r.json();
return Array.isArray(j) ? j : [];
} catch { return []; }
};
try {
// Papéis (sec_numbers) são resolvidos no motor pela CONTA (x-nw-email). Um
// usuário sem conta no motor (ex.: recepcionista que só existe no scoreodonto)
// não tem números próprios — então o papel de cada número LIBERADO precisa ser
// buscado com o e-mail do DONO da sessão, não do usuário logado. Cache por e-mail.
const numbersCache = new Map();
const numbersFor = async (asEmail) => {
if (!numbersCache.has(asEmail)) {
const nums = await motorGet('/secretaria/numbers', asEmail);
numbersCache.set(asEmail, new Map(nums.map((n) => [n.instance_id, n])));
}
return numbersCache.get(asEmail);
};
const metaFrom = (roleMap, instanceId, fallbackName) => {
const n = roleMap.get(instanceId) || {};
return { role: n.role || null, label: n.label || fallbackName || null, area: n.area || null, notes: n.notes || null };
};
const out = [];
const seen = new Set();
const pushWith = (s, roleMap, extra) => {
if (!s || seen.has(s.id)) return;
seen.add(s.id);
out.push({
instanceId: s.id, phone: s.phone || null, name: s.name || null,
status: s.status || null, avatar: s.avatar || null,
...metaFrom(roleMap, s.id, s.name), ...extra,
});
};
// 1) Sessões PRÓPRIAS (conta do próprio usuário no motor) — papel da própria conta.
const ownRoles = await numbersFor(email);
for (const s of await motorGet('/sessions', email)) {
pushWith(s, ownRoles, { clinicaId: null, ownerEmail: email, ownerNome: null, own: true, canInbox: true });
}
// 2) Sessões LIBERADAS por outros donos (can_inbox por sessão).
const { rows: grants } = await pool.query(
`SELECT DISTINCT clinica_id, instance_id FROM wa_session_authz
WHERE usuario_id = $1 AND can_inbox = true`, [userId]);
const byClinica = new Map();
for (const g of grants) {
if (!byClinica.has(g.clinica_id)) byClinica.set(g.clinica_id, new Set());
byClinica.get(g.clinica_id).add(g.instance_id);
}
for (const [clinicaId, allowed] of byClinica) {
const owner = await resolveOwner(pool, clinicaId);
if (!owner || !owner.email || owner.ownerId === userId) continue; // dono próprio já veio em (1)
let ownerNome = null;
try {
const { rows } = await pool.query('SELECT nome FROM usuarios WHERE id = $1', [owner.ownerId]);
ownerNome = rows[0]?.nome || null;
} catch { /* opcional */ }
const ownerRoles = await numbersFor(owner.email); // papéis dos números vêm da conta do DONO
for (const s of await motorGet('/sessions', owner.email)) {
if (allowed.has(s.id)) pushWith(s, ownerRoles, { clinicaId, ownerEmail: owner.email, ownerNome, own: false, canInbox: true });
}
}
res.json(out);
} catch (e) {
res.status(502).json({ error: `Falha ao montar contas: ${e.message}` });
}
};
}
// ── Túnel WebSocket: /api/nw/v1/stream (browser) → motor /api/ext/v1/stream ── // ── Túnel WebSocket: /api/nw/v1/stream (browser) → motor /api/ext/v1/stream ──
// //
// O inbox em tempo real do motor (QR, status da sessão, mensagens novas) roda // O inbox em tempo real do motor (QR, status da sessão, mensagens novas) roda
@@ -249,24 +379,47 @@ export function createRestProxy(pool) {
// Motor: autentica o /api/ext/v1/stream pela x-nw-key + x-nw-email (a chave é a // Motor: autentica o /api/ext/v1/stream pela x-nw-key + x-nw-email (a chave é a
// mestra; o tenant é resolvido pelo email do usuário) e exige o path EXATO, sem // mestra; o tenant é resolvido pelo email do usuário) e exige o path EXATO, sem
// query — por isso reescrevemos um request-line limpo. O email vem do JWT. // query — por isso reescrevemos um request-line limpo. O email vem do JWT.
export function createWsTunnel() { //
return function wsTunnel(req, clientSocket, head) { // Conta-alvo do stream: por padrão a própria. Se o browser mandar ?clinica= (a
// conta ativa do seletor) e o usuário não for o dono mas tiver can_inbox nela,
// o stream passa a ser o do DONO — assim a caixa compartilhada recebe mensagens
// novas em tempo real. Mesma regra do proxy REST (reescrita de x-nw-email).
export function createWsTunnel(pool) {
return async function wsTunnel(req, clientSocket, head) {
const fail = (statusLine) => { const fail = (statusLine) => {
try { clientSocket.write(`HTTP/1.1 ${statusLine}\r\n\r\n`); } catch { /* socket já morto */ } try { clientSocket.write(`HTTP/1.1 ${statusLine}\r\n\r\n`); } catch { /* socket já morto */ }
clientSocket.destroy(); clientSocket.destroy();
}; };
// Auth: JWT do scoreodonto no query string (?token=). // Auth: JWT do scoreodonto no query string (?token=); conta ativa em ?clinica=.
let token; let token, clinica;
try { token = new URL(req.url, 'http://localhost').searchParams.get('token'); } try {
catch { return fail('400 Bad Request'); } const u = new URL(req.url, 'http://localhost');
let email; token = u.searchParams.get('token');
clinica = u.searchParams.get('clinica');
} catch { return fail('400 Bad Request'); }
let email, userId;
try { try {
const p = jwt.verify(token, process.env.JWT_SECRET); const p = jwt.verify(token, process.env.JWT_SECRET);
email = (p.email || '').toLowerCase(); email = (p.email || '').toLowerCase();
userId = p.userId || null;
} catch { return fail('401 Unauthorized'); } } catch { return fail('401 Unauthorized'); }
if (!email) return fail('401 Unauthorized'); if (!email) return fail('401 Unauthorized');
// Reescrita da conta-alvo p/ a caixa compartilhada (só leitura via can_inbox).
let targetEmail = email;
if (clinica && pool && userId) {
try {
const owner = await resolveOwner(pool, clinica);
if (owner && owner.email && owner.ownerId !== userId) {
const { rows } = await pool.query(
'SELECT 1 FROM wa_session_authz WHERE clinica_id = $1 AND usuario_id = $2 AND can_inbox = true LIMIT 1',
[clinica, userId]);
if (rows.length) targetEmail = owner.email;
}
} catch { /* mantém a própria conta */ }
}
const cfg = getConfig(); const cfg = getConfig();
if (!cfg.motorUrl || !cfg.integrationKey) return fail('503 Service Unavailable'); if (!cfg.motorUrl || !cfg.integrationKey) return fail('503 Service Unavailable');
@@ -289,7 +442,7 @@ export function createWsTunnel() {
`${req.method} /api/ext/v1/stream HTTP/1.1\r\n` + `${req.method} /api/ext/v1/stream HTTP/1.1\r\n` +
`Host: ${host}\r\n` + `Host: ${host}\r\n` +
`x-nw-key: ${cfg.integrationKey}\r\n` + `x-nw-key: ${cfg.integrationKey}\r\n` +
`x-nw-email: ${email}\r\n` + `x-nw-email: ${targetEmail}\r\n` +
`${headers}\r\n\r\n` `${headers}\r\n\r\n`
); );
if (head && head.length) proxySocket.write(head); if (head && head.length) proxySocket.write(head);
+88 -29
View File
@@ -12,6 +12,7 @@ import path from 'path';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import crypto from 'crypto'; import crypto from 'crypto';
import { registerNewwhats, createWsTunnel, provisionNewwhatsAccountEager } from './newwhats/index.js'; import { registerNewwhats, createWsTunnel, provisionNewwhatsAccountEager } from './newwhats/index.js';
import { operatorSignature } from './newwhats/proxy.js';
const { Pool } = pg; const { Pool } = pg;
const JWT_SECRET = process.env.JWT_SECRET || 'scoreodonto_secret_key_2026'; const JWT_SECRET = process.env.JWT_SECRET || 'scoreodonto_secret_key_2026';
@@ -165,6 +166,9 @@ try {
// EXPRESS // EXPRESS
// ============================================================================= // =============================================================================
const app = express(); const app = express();
// Envio de mídia pelo plugin WhatsApp vai em base64 (JSON) — precisa de mais folga
// que o limite global. Este parser roda ANTES do global (2mb) só para /api/nw/v1.
app.use('/api/nw/v1', express.json({ limit: '12mb' }));
app.use(express.json({ limit: '2mb' })); app.use(express.json({ limit: '2mb' }));
app.use(cors({ app.use(cors({
origin: process.env.CORS_ORIGIN || '*', origin: process.env.CORS_ORIGIN || '*',
@@ -839,9 +843,12 @@ app.get('/api/nw/access', tenantGuard, async (req, res) => {
const clinicaId = req.clinicaId; const clinicaId = req.clinicaId;
const meId = req.authUser.userId; const meId = req.authUser.userId;
if (!clinicaId) return res.status(400).json({ error: 'clinicaId é obrigatório.' }); if (!clinicaId) return res.status(400).json({ error: 'clinicaId é obrigatório.' });
// Assinatura do operador (mesma do proxy) — o frontend usa para exibir "Nome:" na
// mensagem otimista NA HORA, sem esperar o eco do servidor (evita o flicker).
const signature = await operatorSignature(pool, meId, clinicaId);
const owner = await resolveWorkspaceOwner(clinicaId); const owner = await resolveWorkspaceOwner(clinicaId);
if (owner && owner.ownerId === meId) { if (owner && owner.ownerId === meId) {
return res.json({ isOwner: true, inbox: true, secretaria: true, delete_msg: true }); return res.json({ isOwner: true, inbox: true, secretaria: true, delete_msg: true, signature });
} }
const { rows } = await pool.query( const { rows } = await pool.query(
`SELECT bool_or(can_inbox) AS inbox, bool_or(can_secretaria) AS secretaria, bool_or(can_delete_msg) AS delete_msg `SELECT bool_or(can_inbox) AS inbox, bool_or(can_secretaria) AS secretaria, bool_or(can_delete_msg) AS delete_msg
@@ -851,6 +858,7 @@ app.get('/api/nw/access', tenantGuard, async (req, res) => {
inbox: !!rows[0]?.inbox, inbox: !!rows[0]?.inbox,
secretaria: !!rows[0]?.secretaria, secretaria: !!rows[0]?.secretaria,
delete_msg: !!rows[0]?.delete_msg, delete_msg: !!rows[0]?.delete_msg,
signature,
}); });
} catch (err) { res.status(500).json({ error: err.message }); } } catch (err) { res.status(500).json({ error: err.message }); }
}); });
@@ -1919,7 +1927,25 @@ app.get('/api/dashboard/stats', tenantGuard, async (req, res) => {
try { try {
const clinicaId = req.clinicaId; const clinicaId = req.clinicaId;
if (!clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); if (!clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
const cacheKey = `dashboard:stats:${clinicaId}`; // Escopo por PAPEL: um dentista vê só a PRÓPRIA agenda; o financeiro só é
// devolvido a dono/admin/funcionário. Sem isso, um dentista recém-cadastrado
// via a agenda inteira da clínica + os financeiros no payload (mesmo com o
// front escondendo os cards). O cache passa a ser por ESCOPO (não só clínica).
let role = null, dentistaIds = [];
try {
const { rows } = await pool.query('SELECT role FROM vinculos WHERE usuario_id = $1 AND clinica_id = $2 LIMIT 1', [req.authUser.userId, clinicaId]);
role = rows[0]?.role || null;
} catch { /* segue */ }
const isDentista = role === 'dentista';
const verFinanceiro = ['admin', 'donoclinica', 'donoconsultorio', 'funcionario'].includes(role);
if (isDentista) {
try {
const { rows } = await pool.query('SELECT id FROM dentistas WHERE usuario_id = $1 AND clinica_id = $2', [req.authUser.userId, clinicaId]);
dentistaIds = rows.map((r) => r.id);
} catch { /* segue */ }
}
const scope = isDentista ? `dent:${dentistaIds.join('_') || 'none'}` : (verFinanceiro ? 'full' : 'basic');
const cacheKey = `dashboard:stats:${clinicaId}:${scope}`;
if (redis) { if (redis) {
const cachedStats = await redis.get(cacheKey); const cachedStats = await redis.get(cacheKey);
if (cachedStats) { if (cachedStats) {
@@ -1929,43 +1955,55 @@ app.get('/api/dashboard/stats', tenantGuard, async (req, res) => {
} }
const { rows: pacientes } = await pool.query('SELECT COUNT(*) as count FROM pacientes WHERE clinica_id = $1', [clinicaId]); const { rows: pacientes } = await pool.query('SELECT COUNT(*) as count FROM pacientes WHERE clinica_id = $1', [clinicaId]);
const { rows: agendamentos } = await pool.query('SELECT COUNT(*) as count FROM agendamentos WHERE clinica_id = $1', [clinicaId]);
const { rows: leads } = await pool.query('SELECT COUNT(*) as count FROM leads WHERE clinica_id = $1', [clinicaId]); const { rows: leads } = await pool.query('SELECT COUNT(*) as count FROM leads WHERE clinica_id = $1', [clinicaId]);
const { rows: faturamento } = await pool.query("SELECT COALESCE(SUM(valor), 0) as total FROM financeiro WHERE (tipo = 'RECEITA' OR tipo IS NULL) AND clinica_id = $1", [clinicaId]);
const { rows: pendente } = await pool.query("SELECT COALESCE(SUM(valor), 0) as total FROM financeiro WHERE tipo = 'DESPESA' AND clinica_id = $1", [clinicaId]);
// Growth data (last 6 months) // Filtro de agenda: dentista → só os próprios agendamentos.
const { rows: growth } = await pool.query(` const agFilter = (col, params) => {
SELECT let w = `${col === 'a' ? 'a.' : ''}clinica_id = $1`;
TO_CHAR(datavencimento, 'YYYY-MM') as mes, if (isDentista) {
SUM(CASE WHEN tipo = 'RECEITA' OR tipo IS NULL THEN valor ELSE 0 END) as receita, if (!dentistaIds.length) return { where: w + ' AND false', params };
SUM(CASE WHEN tipo = 'DESPESA' THEN valor ELSE 0 END) as despesa params.push(dentistaIds);
FROM financeiro w += ` AND ${col === 'a' ? 'a.' : ''}dentistaid = ANY($${params.length})`;
WHERE datavencimento >= CURRENT_DATE - INTERVAL '6 months' AND clinica_id = $1 }
GROUP BY TO_CHAR(datavencimento, 'YYYY-MM') return { where: w, params };
ORDER BY mes ASC };
`, [clinicaId]); const agp = agFilter('', [clinicaId]);
const { rows: agendamentos } = await pool.query(`SELECT COUNT(*) as count FROM agendamentos WHERE ${agp.where}`, agp.params);
// Recent appointments with patient and dentist names // Financeiro só p/ dono/admin/funcionário.
let faturamentoTotal = 0, despesasTotal = 0, growth = [];
if (verFinanceiro) {
const { rows: faturamento } = await pool.query("SELECT COALESCE(SUM(valor), 0) as total FROM financeiro WHERE (tipo = 'RECEITA' OR tipo IS NULL) AND clinica_id = $1", [clinicaId]);
const { rows: pendente } = await pool.query("SELECT COALESCE(SUM(valor), 0) as total FROM financeiro WHERE tipo = 'DESPESA' AND clinica_id = $1", [clinicaId]);
const { rows: g } = await pool.query(`
SELECT TO_CHAR(datavencimento, 'YYYY-MM') as mes,
SUM(CASE WHEN tipo = 'RECEITA' OR tipo IS NULL THEN valor ELSE 0 END) as receita,
SUM(CASE WHEN tipo = 'DESPESA' THEN valor ELSE 0 END) as despesa
FROM financeiro
WHERE datavencimento >= CURRENT_DATE - INTERVAL '6 months' AND clinica_id = $1
GROUP BY TO_CHAR(datavencimento, 'YYYY-MM') ORDER BY mes ASC`, [clinicaId]);
faturamentoTotal = parseFloat(faturamento[0].total);
despesasTotal = parseFloat(pendente[0].total);
growth = g;
}
// Agenda recente com os mesmos limites por papel.
const rec = agFilter('a', [clinicaId]);
const { rows: recent } = await pool.query(` const { rows: recent } = await pool.query(`
SELECT a.id, a.pacientenome, a.start_time, a.status, SELECT a.id, a.pacientenome, a.start_time, a.status, d.nome as "dentistaNome"
d.nome as "dentistaNome" FROM agendamentos a LEFT JOIN dentistas d ON a.dentistaid = d.id
FROM agendamentos a WHERE ${rec.where}
LEFT JOIN dentistas d ON a.dentistaid = d.id ORDER BY a.start_time DESC LIMIT 5`, rec.params);
WHERE a.clinica_id = $1
ORDER BY a.start_time DESC
LIMIT 5
`, [clinicaId]);
const responseData = { const responseData = {
stats: { stats: {
totalPacientes: parseInt(pacientes[0].count), totalPacientes: parseInt(pacientes[0].count),
totalAgendamentos: parseInt(agendamentos[0].count), totalAgendamentos: parseInt(agendamentos[0].count),
totalLeads: parseInt(leads[0].count), totalLeads: parseInt(leads[0].count),
faturamentoTotal: parseFloat(faturamento[0].total), faturamentoTotal,
despesasTotal: parseFloat(pendente[0].total) despesasTotal
}, },
growth: growth, growth,
recentAgendamentos: recent recentAgendamentos: recent
}; };
@@ -9400,6 +9438,27 @@ app.get('/api/superadmin/login-log', superadminGuard, async (req, res) => {
} catch (err) { res.status(500).json({ error: err.message }); } } catch (err) { res.status(500).json({ error: err.message }); }
}); });
// Super admin: motivos de LIGAR/DESLIGAR a Secretária por sessão (histórico global,
// vem do motor — sec_session_power_log). Requer integração newwhats configurada.
app.get('/api/superadmin/secretaria-desligamentos', superadminGuard, async (req, res) => {
try {
const { getConfig } = await import('./newwhats/config.js');
const cfg = getConfig();
if (!cfg.motorUrl || !cfg.integrationKey) return res.json({ configurado: false, log: [] });
// O log é GLOBAL no motor; qualquer conta de dono pareada serve para autenticar.
const { rows } = await pool.query(`SELECT u.email FROM usuarios u JOIN vinculos v ON v.usuario_id = u.id WHERE v.role IN ('donoclinica','donoconsultorio') ORDER BY u.email LIMIT 1`);
const email = rows[0]?.email;
if (!email) return res.json({ configurado: true, log: [] });
const limit = Math.min(500, Math.max(1, parseInt(String(req.query.limit || '150'), 10)));
const r = await fetch(`${cfg.motorUrl}/api/ext/v1/secretaria/session-power/log?limit=${limit}`, {
headers: { 'x-nw-key': cfg.integrationKey, 'x-nw-email': email },
});
if (!r.ok) return res.status(502).json({ error: 'Motor indisponível', status: r.status });
const log = await r.json();
res.json({ configurado: true, log: Array.isArray(log) ? log : [] });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// Defaults mesclados sob o JSON salvo: chaves novas (biomedico, add-ons de plugin) // Defaults mesclados sob o JSON salvo: chaves novas (biomedico, add-ons de plugin)
// aparecem na tabela mesmo p/ configs gravadas antes delas existirem. // aparecem na tabela mesmo p/ configs gravadas antes delas existirem.
const PRICING_DEFAULTS = { const PRICING_DEFAULTS = {
@@ -10125,7 +10184,7 @@ const wss = new WebSocketServer({ noServer: true });
// Dispatcher único de upgrade WS: roteia por path. O `ws` em modo {server,path} // Dispatcher único de upgrade WS: roteia por path. O `ws` em modo {server,path}
// aborta com 400 qualquer upgrade de outro path, então centralizamos aqui para o // aborta com 400 qualquer upgrade de outro path, então centralizamos aqui para o
// /api/ws (app) coexistir com o túnel do NewWhats (/api/nw/v1/stream → motor). // /api/ws (app) coexistir com o túnel do NewWhats (/api/nw/v1/stream → motor).
const nwWsTunnel = createWsTunnel(); const nwWsTunnel = createWsTunnel(pool);
httpServer.on('upgrade', (req, socket, head) => { httpServer.on('upgrade', (req, socket, head) => {
let pathname; let pathname;
try { pathname = new URL(req.url, 'http://localhost').pathname; } try { pathname = new URL(req.url, 'http://localhost').pathname; }
+25 -3
View File
@@ -121,6 +121,7 @@ export type ViewKey =
| 'superadmin-logs' | 'superadmin-logs'
| 'superadmin-financeiro' | 'superadmin-financeiro'
| 'superadmin-notificacoes' | 'superadmin-notificacoes'
| 'superadmin-secretaria'
| 'diretorio-profissionais'; | 'diretorio-profissionais';
// ------- URL <-> VIEW mapping ------- // ------- URL <-> VIEW mapping -------
@@ -181,6 +182,7 @@ const ROUTE_MAP: Record<string, ViewKey> = {
'/superadmin/logs': 'superadmin-logs', '/superadmin/logs': 'superadmin-logs',
'/superadmin/financeiro': 'superadmin-financeiro', '/superadmin/financeiro': 'superadmin-financeiro',
'/superadmin/notificacoes': 'superadmin-notificacoes', '/superadmin/notificacoes': 'superadmin-notificacoes',
'/superadmin/secretaria': 'superadmin-secretaria',
'/aguardando-convite': 'aguardando-convite', '/aguardando-convite': 'aguardando-convite',
'/diretorio-profissionais': 'diretorio-profissionais', '/diretorio-profissionais': 'diretorio-profissionais',
'/proteses': 'proteses', '/proteses': 'proteses',
@@ -248,6 +250,7 @@ const VIEW_TO_ROUTE: Record<ViewKey, string> = {
'superadmin-logs': '/superadmin/logs', 'superadmin-logs': '/superadmin/logs',
'superadmin-financeiro': '/superadmin/financeiro', 'superadmin-financeiro': '/superadmin/financeiro',
'superadmin-notificacoes': '/superadmin/notificacoes', 'superadmin-notificacoes': '/superadmin/notificacoes',
'superadmin-secretaria': '/superadmin/secretaria',
'diretorio-profissionais': '/diretorio-profissionais', 'diretorio-profissionais': '/diretorio-profissionais',
proteses: '/proteses', proteses: '/proteses',
}; };
@@ -315,6 +318,13 @@ const MobileBottomNav: React.FC<{
const App: React.FC = () => { const App: React.FC = () => {
const ver = useAppVersion(); const ver = useAppVersion();
const [isSidebarOpen, setSidebarOpen] = useState(false); const [isSidebarOpen, setSidebarOpen] = useState(false);
// Evita que a sidebar ANIME (slide-in) a cada reload no desktop: a transição só liga
// após o 1º paint, então no load ela já nasce posicionada; toggles do usuário animam.
const [uiMounted, setUiMounted] = useState(false);
useEffect(() => { setUiMounted(true); }, []);
// Gaveta de controles da Agenda no MOBILE (título/AGENDAR/ações) — comporta-se como
// a sidebar: some por padrão p/ dar o máximo de espaço ao calendário; abre pelo ⋯.
const [agendaControlsOpen, setAgendaControlsOpen] = useState(false);
// Recolher a sidebar no DESKTOP (persistente) — dá largura ao conteúdo (ex.: agenda). // Recolher a sidebar no DESKTOP (persistente) — dá largura ao conteúdo (ex.: agenda).
const [isSidebarCollapsed, setSidebarCollapsed] = useState<boolean>(() => { const [isSidebarCollapsed, setSidebarCollapsed] = useState<boolean>(() => {
try { return localStorage.getItem('scoreodonto:sidebar-collapsed') === '1'; } catch { return false; } try { return localStorage.getItem('scoreodonto:sidebar-collapsed') === '1'; } catch { return false; }
@@ -508,7 +518,7 @@ const App: React.FC = () => {
case 'leads': return <LeadsView />; case 'leads': return <LeadsView />;
case 'public': return <PublicContactForm />; case 'public': return <PublicContactForm />;
case 'pacientes': return <PatientsView />; case 'pacientes': return <PatientsView />;
case 'agenda': return <AgendaView onNavigate={handleNavigate} sidebarCollapsed={isSidebarCollapsed} onToggleSidebar={() => toggleSidebarCollapsed(!isSidebarCollapsed)} />; case 'agenda': return <AgendaView onNavigate={handleNavigate} sidebarCollapsed={isSidebarCollapsed} onToggleSidebar={() => toggleSidebarCollapsed(!isSidebarCollapsed)} controlsOpen={agendaControlsOpen} onCloseControls={() => setAgendaControlsOpen(false)} />;
case 'ortodontia': return <OrthoView />; case 'ortodontia': return <OrthoView />;
case 'financeiro': return <FinanceiroView />; case 'financeiro': return <FinanceiroView />;
case 'dentistas': return <DentistasView />; case 'dentistas': return <DentistasView />;
@@ -561,6 +571,7 @@ const App: React.FC = () => {
case 'superadmin-logs': return <SuperAdminView tab="logs" />; case 'superadmin-logs': return <SuperAdminView tab="logs" />;
case 'superadmin-financeiro': return <SuperAdminView tab="financeiro" />; case 'superadmin-financeiro': return <SuperAdminView tab="financeiro" />;
case 'superadmin-notificacoes': return <SuperAdminView tab="notificacoes" />; case 'superadmin-notificacoes': return <SuperAdminView tab="notificacoes" />;
case 'superadmin-secretaria': return <SuperAdminView tab="secretaria" />;
// Plugin Profissionais ativo absorve o diretório antigo (mesma rota, view nova). // Plugin Profissionais ativo absorve o diretório antigo (mesma rota, view nova).
case 'diretorio-profissionais': return isPluginActive('profissionais-marketplace') ? <ProfissionaisPlugin /> : <DiretorioProfissionaisView />; case 'diretorio-profissionais': return isPluginActive('profissionais-marketplace') ? <ProfissionaisPlugin /> : <DiretorioProfissionaisView />;
case 'login': case 'login':
@@ -599,7 +610,7 @@ const App: React.FC = () => {
></div> ></div>
)} )}
<div className={`fixed inset-y-0 left-0 z-30 transform transition-transform duration-300 ${isSidebarCollapsed ? 'md:hidden' : 'md:relative md:translate-x-0'} ${isSidebarOpen ? 'translate-x-0' : '-translate-x-full'}`}> <div className={`fixed inset-y-0 left-0 z-30 transform ${uiMounted ? 'transition-transform duration-300' : ''} ${isSidebarCollapsed ? 'md:hidden' : 'md:relative md:translate-x-0'} ${isSidebarOpen ? 'translate-x-0' : '-translate-x-full'}`}>
<Sidebar <Sidebar
activeTab={currentView} activeTab={currentView}
setActiveTab={(t) => handleNavigate(t as ViewKey)} setActiveTab={(t) => handleNavigate(t as ViewKey)}
@@ -652,7 +663,18 @@ const App: React.FC = () => {
</div> </div>
<span className="font-bold text-gray-800 text-sm tracking-tight uppercase">SCOREODONTO</span> <span className="font-bold text-gray-800 text-sm tracking-tight uppercase">SCOREODONTO</span>
</div> </div>
<div className="w-8"></div> {/* ⋯ controles da Agenda (só nesta view) — abre a gaveta; senão, espaçador */}
{currentView === 'agenda' ? (
<button
onClick={() => setAgendaControlsOpen(true)}
className="p-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
title="Controles da agenda"
>
<MoreHorizontal size={24} />
</button>
) : (
<div className="w-8"></div>
)}
</div> </div>
</> </>
)} )}
+2 -1
View File
@@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
import { useConfirm } from '../contexts/ConfirmContext.tsx'; import { useConfirm } from '../contexts/ConfirmContext.tsx';
import { import {
Users, Calendar as CalendarIcon, LayoutDashboard, Users, Calendar as CalendarIcon, LayoutDashboard,
DollarSign, RefreshCw, Activity, BookOpen, Award, Megaphone, LogOut, ClipboardList, Bell, Building2, User, BarChart3, Settings, FileText, Puzzle, ScanLine, Monitor, UsersRound, GraduationCap, UserSearch, Briefcase, TrendingUp, Eye, FlaskConical, DoorOpen, Sparkles, FileSignature, Percent, ShieldAlert, ChevronDown, MessageCircle, Bot, Smartphone DollarSign, RefreshCw, Activity, BookOpen, Award, Megaphone, LogOut, ClipboardList, Bell, Building2, User, BarChart3, Settings, FileText, Puzzle, ScanLine, Monitor, UsersRound, GraduationCap, UserSearch, Briefcase, TrendingUp, Eye, FlaskConical, DoorOpen, Sparkles, FileSignature, Percent, ShieldAlert, ChevronDown, MessageCircle, Bot, Smartphone, Power
} from 'lucide-react'; } from 'lucide-react';
import { ToothIcon } from './ToothIcon.tsx'; import { ToothIcon } from './ToothIcon.tsx';
import { HybridBackend } from '../services/backend.ts'; import { HybridBackend } from '../services/backend.ts';
@@ -83,6 +83,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
{ id: 'superadmin-logs', label: 'ACESSOS', icon: Activity }, { id: 'superadmin-logs', label: 'ACESSOS', icon: Activity },
{ id: 'superadmin-financeiro', label: 'FINANCEIRO', icon: DollarSign }, { id: 'superadmin-financeiro', label: 'FINANCEIRO', icon: DollarSign },
{ id: 'superadmin-notificacoes', label: 'NOTIFICAÇÕES', icon: Bell }, { id: 'superadmin-notificacoes', label: 'NOTIFICAÇÕES', icon: Bell },
{ id: 'superadmin-secretaria', label: 'SECRETÁRIA IA', icon: Power },
{ id: 'sala-home', label: 'PAINEL DA SALA', icon: DoorOpen }, { id: 'sala-home', label: 'PAINEL DA SALA', icon: DoorOpen },
{ id: 'lab-home', label: 'PAINEL DO LAB', icon: LayoutDashboard }, { id: 'lab-home', label: 'PAINEL DO LAB', icon: LayoutDashboard },
{ id: 'lab-bancada', label: 'BANCADA', icon: FlaskConical }, { id: 'lab-bancada', label: 'BANCADA', icon: FlaskConical },
+26 -19
View File
@@ -658,60 +658,60 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebar
</button> </button>
)} )}
{/* Ações — mobile: empilhadas, ícone + nome, largura cheia; desktop: fileira de ícones */} {/* Ações — mobile: empilhadas, ícone + nome, largura cheia; desktop: fileira de ícones */}
<div className="flex flex-col lg:flex-row gap-2 w-full"> <div className="flex flex-col gap-2 w-full">
{(currentRole !== 'paciente') && ( {(currentRole !== 'paciente') && (
<button onClick={() => { setShowAtividade(true); loadAtividade(); onCloseControls?.(); }} title="Atividade da equipe hoje" <button onClick={() => { setShowAtividade(true); loadAtividade(); onCloseControls?.(); }} title="Atividade da equipe hoje"
className="w-full lg:flex-1 flex items-center justify-start lg:justify-center gap-3 lg:gap-0 px-4 lg:px-0 py-2.5 bg-white text-gray-500 lg:text-gray-400 hover:text-teal-600 border border-gray-200 rounded-xl transition-all hover:border-teal-200 hover:shadow-sm"> className="w-full flex items-center justify-start gap-3 px-4 py-2.5 bg-white text-gray-500 hover:text-teal-600 border border-gray-200 rounded-xl transition-all hover:border-teal-200 hover:shadow-sm">
<History size={20} className="shrink-0" /> <History size={20} className="shrink-0" />
<span className="lg:hidden text-sm font-bold">Atividade da equipe</span> <span className="text-sm font-bold">Atividade da equipe</span>
</button> </button>
)} )}
{(currentRole === 'admin' || currentRole === 'donoclinica' || currentRole === 'donoconsultorio') && ( {(currentRole === 'admin' || currentRole === 'donoclinica' || currentRole === 'donoconsultorio') && (
<button onClick={handleImportarGoogle} disabled={importando} title="Importar agendamentos do Google para o sistema" <button onClick={handleImportarGoogle} disabled={importando} title="Importar agendamentos do Google para o sistema"
className="w-full lg:flex-1 flex items-center justify-start lg:justify-center gap-3 lg:gap-0 px-4 lg:px-0 py-2.5 bg-white text-gray-500 lg:text-gray-400 hover:text-emerald-600 border border-gray-200 rounded-xl transition-all hover:border-emerald-200 hover:shadow-sm disabled:opacity-50"> className="w-full flex items-center justify-start gap-3 px-4 py-2.5 bg-white text-gray-500 hover:text-emerald-600 border border-gray-200 rounded-xl transition-all hover:border-emerald-200 hover:shadow-sm disabled:opacity-50">
{importando ? <Loader2 size={20} className="animate-spin shrink-0" /> : <Download size={20} className="shrink-0" />} {importando ? <Loader2 size={20} className="animate-spin shrink-0" /> : <Download size={20} className="shrink-0" />}
<span className="lg:hidden text-sm font-bold">Importar do Google</span> <span className="text-sm font-bold">Importar do Google</span>
</button> </button>
)} )}
{['admin', 'donoclinica', 'donoconsultorio', 'funcionario'].includes(currentRole) && ( {['admin', 'donoclinica', 'donoconsultorio', 'funcionario'].includes(currentRole) && (
<button onClick={() => { setShowPedidos(true); onCloseControls?.(); }} title="Pedidos de horário aguardando confirmação (encaminhados pela Secretária IA)" <button onClick={() => { setShowPedidos(true); onCloseControls?.(); }} title="Pedidos de horário aguardando confirmação (encaminhados pela Secretária IA)"
className="relative w-full lg:flex-1 flex items-center justify-start lg:justify-center gap-3 lg:gap-0 px-4 lg:px-0 py-2.5 bg-white text-gray-500 lg:text-gray-400 hover:text-amber-600 border border-gray-200 rounded-xl transition-all hover:border-amber-200 hover:shadow-sm"> className="relative w-full flex items-center justify-start gap-3 px-4 py-2.5 bg-white text-gray-500 hover:text-amber-600 border border-gray-200 rounded-xl transition-all hover:border-amber-200 hover:shadow-sm">
<CalendarClock size={20} className="shrink-0" /> <CalendarClock size={20} className="shrink-0" />
<span className="lg:hidden text-sm font-bold">Pedidos pendentes</span> <span className="text-sm font-bold">Pedidos pendentes</span>
{pedidosCount > 0 && <span className="absolute -top-1 -right-1 lg:top-0 lg:right-1 bg-amber-500 text-white text-[10px] font-black rounded-full min-w-[18px] h-[18px] flex items-center justify-center px-1 shadow">{pedidosCount}</span>} {pedidosCount > 0 && <span className="absolute -top-1 -right-1 bg-amber-500 text-white text-[10px] font-black rounded-full min-w-[18px] h-[18px] flex items-center justify-center px-1 shadow">{pedidosCount}</span>}
</button> </button>
)} )}
{(currentRole === 'admin' || currentRole === 'donoclinica' || currentRole === 'donoconsultorio') && ( {(currentRole === 'admin' || currentRole === 'donoclinica' || currentRole === 'donoconsultorio') && (
<button onClick={() => { setIsSettingsOpen(true); onCloseControls?.(); }} title="Configurações da agenda" <button onClick={() => { setIsSettingsOpen(true); onCloseControls?.(); }} title="Configurações da agenda"
className="w-full lg:flex-1 flex items-center justify-start lg:justify-center gap-3 lg:gap-0 px-4 lg:px-0 py-2.5 bg-white text-gray-500 lg:text-gray-400 hover:text-teal-600 border border-gray-200 rounded-xl transition-all hover:border-teal-200 hover:shadow-sm"> className="w-full flex items-center justify-start gap-3 px-4 py-2.5 bg-white text-gray-500 hover:text-teal-600 border border-gray-200 rounded-xl transition-all hover:border-teal-200 hover:shadow-sm">
<GearIcon size={20} className="shrink-0" /> <GearIcon size={20} className="shrink-0" />
<span className="lg:hidden text-sm font-bold">Configurações</span> <span className="text-sm font-bold">Configurações</span>
</button> </button>
)} )}
{souDentista && ( {souDentista && (
<button onClick={() => { setShowMeusHorarios(true); onCloseControls?.(); }} title="Configurar meus dias e horários de atendimento" <button onClick={() => { setShowMeusHorarios(true); onCloseControls?.(); }} title="Configurar meus dias e horários de atendimento"
className="w-full lg:flex-1 flex items-center justify-start lg:justify-center gap-3 lg:gap-0 px-4 lg:px-0 py-2.5 bg-white text-gray-500 lg:text-gray-400 hover:text-teal-600 border border-gray-200 rounded-xl transition-all hover:border-teal-200 hover:shadow-sm"> className="w-full flex items-center justify-start gap-3 px-4 py-2.5 bg-white text-gray-500 hover:text-teal-600 border border-gray-200 rounded-xl transition-all hover:border-teal-200 hover:shadow-sm">
<Clock size={20} className="shrink-0" /> <Clock size={20} className="shrink-0" />
<span className="lg:hidden text-sm font-bold">Meus Horários</span> <span className="text-sm font-bold">Meus Horários</span>
</button> </button>
)} )}
{souDentista && ( {souDentista && (
<button onClick={() => { setShowInteresses(true); loadInteresses(); onCloseControls?.(); }} title="Pedidos de interesse na sua agenda" <button onClick={() => { setShowInteresses(true); loadInteresses(); onCloseControls?.(); }} title="Pedidos de interesse na sua agenda"
className="relative w-full lg:flex-1 flex items-center justify-start lg:justify-center gap-3 lg:gap-0 px-4 lg:px-0 py-2.5 bg-white text-gray-500 lg:text-gray-400 hover:text-teal-600 border border-gray-200 rounded-xl transition-all hover:border-teal-200 hover:shadow-sm"> className="relative w-full flex items-center justify-start gap-3 px-4 py-2.5 bg-white text-gray-500 hover:text-teal-600 border border-gray-200 rounded-xl transition-all hover:border-teal-200 hover:shadow-sm">
<Bell size={20} className="shrink-0" /> <Bell size={20} className="shrink-0" />
<span className="lg:hidden text-sm font-bold">Interesses na agenda</span> <span className="text-sm font-bold">Interesses na agenda</span>
{interessesAguardando > 0 && ( {interessesAguardando > 0 && (
<span className="absolute top-1.5 left-6 lg:left-auto lg:-top-1 lg:-right-1 bg-red-500 text-white text-[9px] font-black rounded-full w-4 h-4 flex items-center justify-center">{interessesAguardando}</span> <span className="absolute top-1.5 left-6 bg-red-500 text-white text-[9px] font-black rounded-full w-4 h-4 flex items-center justify-center">{interessesAguardando}</span>
)} )}
</button> </button>
)} )}
{(currentRole !== 'paciente' && !souDentista) && ( {(currentRole !== 'paciente' && !souDentista) && (
<button onClick={() => { setShowPendencias(true); loadPendencias(); onCloseControls?.(); }} title="Pacientes a reagendar" <button onClick={() => { setShowPendencias(true); loadPendencias(); onCloseControls?.(); }} title="Pacientes a reagendar"
className="relative w-full lg:flex-1 flex items-center justify-start lg:justify-center gap-3 lg:gap-0 px-4 lg:px-0 py-2.5 bg-white text-gray-500 lg:text-gray-400 hover:text-amber-600 border border-gray-200 rounded-xl transition-all hover:border-amber-200 hover:shadow-sm"> className="relative w-full flex items-center justify-start gap-3 px-4 py-2.5 bg-white text-gray-500 hover:text-amber-600 border border-gray-200 rounded-xl transition-all hover:border-amber-200 hover:shadow-sm">
<CalendarClock size={20} className="shrink-0" /> <CalendarClock size={20} className="shrink-0" />
<span className="lg:hidden text-sm font-bold">Pacientes a reagendar</span> <span className="text-sm font-bold">Pacientes a reagendar</span>
{pendencias.length > 0 && ( {pendencias.length > 0 && (
<span className="absolute top-1.5 left-6 lg:left-auto lg:-top-1 lg:-right-1 bg-amber-500 text-white text-[9px] font-black rounded-full w-4 h-4 flex items-center justify-center">{pendencias.length}</span> <span className="absolute top-1.5 left-6 bg-amber-500 text-white text-[9px] font-black rounded-full w-4 h-4 flex items-center justify-center">{pendencias.length}</span>
)} )}
</button> </button>
)} )}
@@ -758,6 +758,8 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebar
buttonText={{ today: 'HOJE', month: 'MÊS', week: 'SEMANA', day: 'DIA' }} buttonText={{ today: 'HOJE', month: 'MÊS', week: 'SEMANA', day: 'DIA' }}
slotMinTime="08:00:00" slotMinTime="08:00:00"
slotMaxTime="19:00:00" slotMaxTime="19:00:00"
slotDuration="00:30:00"
slotLabelInterval="01:00:00"
allDaySlot={false} allDaySlot={false}
events={events} events={events}
selectable={true} selectable={true}
@@ -902,8 +904,13 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebar
.fc .fc-col-header-cell-cushion { font-size: .65rem; padding: 2px; } .fc .fc-col-header-cell-cushion { font-size: .65rem; padding: 2px; }
.fc .fc-timegrid-slot-label-cushion, .fc .fc-timegrid-axis-cushion { font-size: .6rem; } .fc .fc-timegrid-slot-label-cushion, .fc .fc-timegrid-axis-cushion { font-size: .6rem; }
.fc .fc-timegrid-event .fc-event-main { padding: 1px 2px; } .fc .fc-timegrid-event .fc-event-main { padding: 1px 2px; }
.fc-footer-toolbar.fc-toolbar { margin-top: .5rem; }
} }
/* Linha da MEIA-HORA (:30): divide a hora em 2, fraca/tracejada como no Google
(a linha cheia da hora vem do border padrão das células). */
.fc .fc-timegrid-slot-minor {
border-top: 1px dashed #eef1f4 !important;
}
.fc-footer-toolbar.fc-toolbar { margin-top: .5rem; }
`}</style> `}</style>
<AppointmentModal <AppointmentModal
isOpen={isModalOpen} isOpen={isModalOpen}
+70 -2
View File
@@ -5,7 +5,7 @@ import {
CheckCircle2, XCircle, Clock, Search, ChevronLeft, ChevronRight, CheckCircle2, XCircle, Clock, Search, ChevronLeft, ChevronRight,
TrendingUp, Stethoscope, FlaskConical, User, ToggleLeft, ToggleRight, TrendingUp, Stethoscope, FlaskConical, User, ToggleLeft, ToggleRight,
Save, AlertTriangle, Wifi, WifiOff, Briefcase, Trash2, Bell, Send, Save, AlertTriangle, Wifi, WifiOff, Briefcase, Trash2, Bell, Send,
MapPin, Phone, MoreVertical, X, ArrowLeft, UserX, Sparkles, DoorOpen, UserSearch MapPin, Phone, MoreVertical, X, ArrowLeft, UserX, Sparkles, DoorOpen, UserSearch, Power
} from 'lucide-react'; } from 'lucide-react';
const apiFetch = (url: string, opts: RequestInit = {}) => { const apiFetch = (url: string, opts: RequestInit = {}) => {
@@ -74,7 +74,7 @@ const KPI: React.FC<{ icon: React.ElementType; label: string; value: string | nu
); );
}; };
type SuperAdminTab = 'overview' | 'users' | 'clinicas' | 'logs' | 'financeiro' | 'notificacoes'; type SuperAdminTab = 'overview' | 'users' | 'clinicas' | 'logs' | 'financeiro' | 'notificacoes' | 'secretaria';
/* ═══════════════════════════════════════════════════ MAIN VIEW */ /* ═══════════════════════════════════════════════════ MAIN VIEW */
export const SuperAdminView: React.FC<{ tab?: SuperAdminTab }> = ({ tab = 'overview' }) => { export const SuperAdminView: React.FC<{ tab?: SuperAdminTab }> = ({ tab = 'overview' }) => {
@@ -86,6 +86,74 @@ export const SuperAdminView: React.FC<{ tab?: SuperAdminTab }> = ({ tab = 'overv
{tab === 'logs' && <TabLogs />} {tab === 'logs' && <TabLogs />}
{tab === 'financeiro' && <TabFinanceiro />} {tab === 'financeiro' && <TabFinanceiro />}
{tab === 'notificacoes' && <TabNotificacoes />} {tab === 'notificacoes' && <TabNotificacoes />}
{tab === 'secretaria' && <TabSecretaria />}
</div>
);
};
/* ═══════════════════════════════════════════════════ TAB SECRETÁRIA (liga/desliga por sessão) */
const TabSecretaria: React.FC = () => {
const [log, setLog] = useState<any[]>([]);
const [configurado, setConfigurado] = useState(true);
const [loading, setLoading] = useState(true);
const load = useCallback(() => {
setLoading(true);
apiFetch('/api/superadmin/secretaria-desligamentos')
.then(r => r.json())
.then(d => { setLog(Array.isArray(d.log) ? d.log : []); setConfigurado(d.configurado !== false); })
.catch(() => {})
.finally(() => setLoading(false));
}, []);
useEffect(() => { load(); }, [load]);
const totalOff = log.filter(l => !l.enabled).length;
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-3 flex-wrap">
<div>
<h2 className="text-lg font-black text-gray-900 uppercase tracking-tight flex items-center gap-2"><Power size={18} className="text-red-600" /> Secretária IA liga/desliga por sessão</h2>
<p className="text-xs text-gray-400 font-medium">Quem ligou/desligou a Secretária de cada número, e o motivo do desligamento. {totalOff > 0 && `${totalOff} desligamento(s).`}</p>
</div>
<button onClick={load} className="p-2 rounded-xl border border-gray-200 text-gray-400 hover:text-gray-700"><RefreshCw size={16} /></button>
</div>
{!configurado ? (
<div className="bg-amber-50 text-amber-700 text-sm rounded-2xl p-4 flex items-center gap-2"><AlertTriangle size={16} /> Integração com o motor da Secretária não está configurada neste ambiente.</div>
) : loading ? (
<div className="text-center text-gray-400 py-16">Carregando</div>
) : log.length === 0 ? (
<div className="text-center text-gray-400 py-16">Nenhum registro de liga/desliga ainda.</div>
) : (
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-x-auto">
<table className="w-full text-sm min-w-[640px]">
<thead className="bg-gray-50 text-[10px] font-black text-gray-400 uppercase tracking-widest">
<tr>
<th className="text-left px-4 py-3">Ação</th>
<th className="text-left px-4 py-3">Número</th>
<th className="text-left px-4 py-3">Quem</th>
<th className="text-left px-4 py-3">Quando</th>
<th className="text-left px-4 py-3">Motivo</th>
</tr>
</thead>
<tbody>
{log.map((l, i) => (
<tr key={i} className={`border-t border-gray-50 ${!l.enabled ? 'bg-red-50/30' : ''}`}>
<td className="px-4 py-3">
{l.enabled
? <span className="inline-flex items-center gap-1 text-emerald-700 text-[11px] font-black uppercase"><ToggleRight size={14} /> Ligou</span>
: <span className="inline-flex items-center gap-1 text-red-600 text-[11px] font-black uppercase"><ToggleLeft size={14} /> Desligou</span>}
</td>
<td className="px-4 py-3 text-gray-700 font-bold whitespace-nowrap">{l.phone || l.name || l.instance_id}</td>
<td className="px-4 py-3 text-gray-600 whitespace-nowrap">{l.by || '—'}</td>
<td className="px-4 py-3 text-gray-500 whitespace-nowrap">{fmt(l.at)}</td>
<td className="px-4 py-3 text-gray-600">{l.reason || <span className="text-gray-300"></span>}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div> </div>
); );
}; };
@@ -0,0 +1,134 @@
// Liga/desliga a Secretária IA APENAS desta sessão (instância aberta no /wa-inbox).
// Rate-limit 1x/hora, motivo obrigatório ao desligar, histórico de quem apertou.
// Fica na thin sidebar; abre um modal com o textarea de motivo + histórico.
import React, { useState, useEffect, useCallback } from 'react';
import { Power, X, Loader2, History, AlertTriangle } from 'lucide-react';
import { secSessionPowerApi, SessionPower } from '../services/secretariaApi';
const userName = (() => {
try { return JSON.parse(localStorage.getItem('SCOREODONTO_USER_DATA') || '{}')?.nome || 'Usuário'; }
catch { return 'Usuário'; }
})();
const fmt = (iso?: string | null) => {
if (!iso) return '';
try { return new Date(iso).toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }); }
catch { return ''; }
};
export const SessaoSecretariaPower: React.FC<{ instanceId: string | null }> = ({ instanceId }) => {
const [sp, setSp] = useState<SessionPower | null>(null);
const [open, setOpen] = useState(false);
const [reason, setReason] = useState('');
const [busy, setBusy] = useState(false);
const [erro, setErro] = useState<string | null>(null);
const carregar = useCallback(async () => {
if (!instanceId) return;
try { setSp(await secSessionPowerApi.get(instanceId)); } catch { /* silencioso */ }
}, [instanceId]);
useEffect(() => { carregar(); }, [carregar]);
if (!instanceId) return null;
const enabled = sp ? sp.enabled : true;
const toggle = async () => {
if (!sp?.can_toggle) return;
const proximo = !enabled;
if (proximo === false && !reason.trim()) { setErro('Escreva o motivo para desligar.'); return; }
setBusy(true); setErro(null);
try {
await secSessionPowerApi.set(instanceId, proximo, reason.trim(), userName);
setReason('');
await carregar();
} catch (e: any) {
setErro(e?.message || 'Não foi possível alterar.');
await carregar();
} finally { setBusy(false); }
};
return (
<>
<button
title={enabled ? 'Secretária IA ligada nesta sessão — clique para gerenciar' : 'Secretária IA DESLIGADA nesta sessão'}
onClick={() => { setErro(null); setOpen(true); }}
className={`w-10 h-10 mb-2 rounded-full flex items-center justify-center transition-colors ${
enabled ? 'text-emerald-600 hover:bg-[#d9dbdf]/60' : 'text-white bg-red-600 hover:bg-red-700 animate-pulse'
}`}
>
<Power className="w-5 h-5" />
</button>
{open && (
<div className="fixed inset-0 bg-black/60 z-[80] flex items-center justify-center backdrop-blur-md p-4" onClick={() => setOpen(false)}>
<div className="bg-white w-full max-w-md rounded-[1.5rem] shadow-2xl border border-gray-100 flex flex-col max-h-[90vh] overflow-hidden" onClick={(e) => e.stopPropagation()}>
<div className="px-6 py-5 border-b border-gray-50 flex items-center justify-between bg-gradient-to-br from-gray-50 to-white">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-xl ${enabled ? 'bg-emerald-500' : 'bg-red-600'} shadow`}><Power size={18} className="text-white" /></div>
<div>
<h2 className="text-base font-black text-gray-900 uppercase tracking-tighter leading-none">Secretária desta sessão</h2>
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mt-1">{enabled ? 'Ligada' : 'Desligada'} · este número</p>
</div>
</div>
<button onClick={() => setOpen(false)} className="p-2 hover:bg-gray-100 rounded-xl text-gray-400"><X size={20} /></button>
</div>
{erro && <div className="px-6 py-2 text-xs font-bold bg-red-50 text-red-600">{erro}</div>}
<div className="p-6 space-y-4 overflow-y-auto">
{!enabled && sp?.reason && (
<div className="text-xs bg-red-50 text-red-700 rounded-xl p-3">
<span className="font-black">Desligada.</span> Motivo: {sp.reason}
{sp.changed_by && <div className="text-red-400 mt-1">por {sp.changed_by} · {fmt(sp.changed_at)}</div>}
</div>
)}
{/* Motivo (só ao desligar) */}
{enabled && (
<div>
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">Motivo para desligar</label>
<textarea value={reason} onChange={(e) => setReason(e.target.value)} rows={3}
placeholder="Ex.: revisando as respostas da IA; horário de pico; treinamento…"
className="w-full bg-gray-50 border-2 border-transparent focus:border-red-500 rounded-xl px-3 py-2 text-sm outline-none resize-y" />
</div>
)}
{/* Rate-limit */}
{!sp?.can_toggle && (
<p className="text-[11px] text-amber-600 font-bold flex items-center gap-1.5"><AlertTriangle size={13} /> é possível alternar 1x por hora. Disponível às {fmt(sp?.next_toggle_at)}.</p>
)}
<button onClick={toggle} disabled={busy || !sp?.can_toggle}
className={`w-full py-2.5 rounded-xl text-sm font-black uppercase tracking-wide flex items-center justify-center gap-2 disabled:opacity-40 ${
enabled ? 'bg-red-600 hover:bg-red-700 text-white' : 'bg-emerald-600 hover:bg-emerald-700 text-white'
}`}>
{busy ? <Loader2 size={16} className="animate-spin" /> : <Power size={16} />}
{enabled ? 'Desligar nesta sessão' : 'Ligar nesta sessão'}
</button>
{/* Histórico */}
{!!sp?.history?.length && (
<div>
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest flex items-center gap-1.5 mb-2"><History size={12} /> Histórico</p>
<div className="space-y-1.5 max-h-40 overflow-y-auto">
{sp.history.map((h, i) => (
<div key={i} className="text-[11px] flex items-start gap-2">
<span className={`mt-1 w-1.5 h-1.5 rounded-full shrink-0 ${h.enabled ? 'bg-emerald-500' : 'bg-red-500'}`} />
<div className="min-w-0">
<span className="font-bold text-gray-700">{h.enabled ? 'Ligou' : 'Desligou'}</span>
<span className="text-gray-400"> · {h.by || '—'} · {fmt(h.at)}</span>
{!h.enabled && h.reason && <div className="text-gray-500 leading-snug">{h.reason}</div>}
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
)}
</>
);
};
@@ -7,6 +7,7 @@ import {
BrainCircuit, BrainCircuit,
} from 'lucide-react'; } from 'lucide-react';
import { getAvatarProxyUrl } from '../utils/inboxUtils'; import { getAvatarProxyUrl } from '../utils/inboxUtils';
import { SessaoSecretariaPower } from './SessaoSecretariaPower';
export type TabType = 'chats' | 'broadcasts' | 'groups' | 'settings'; export type TabType = 'chats' | 'broadcasts' | 'groups' | 'settings';
@@ -64,6 +65,8 @@ export default function ThinSidebar({ activeTab, setActiveTab, activeInstance, o
{/* Bottom Icons */} {/* Bottom Icons */}
<div className="w-full flex flex-col items-center pb-2"> <div className="w-full flex flex-col items-center pb-2">
{/* Liga/desliga a Secretária IA APENAS desta sessão (número atual) */}
<SessaoSecretariaPower instanceId={activeInstance?.instance ?? null} />
{/* Configurações — abre o painel na área central (aba 'settings') */} {/* Configurações — abre o painel na área central (aba 'settings') */}
{renderIcon('settings', Settings, 'Configurações')} {renderIcon('settings', Settings, 'Configurações')}
@@ -90,6 +90,22 @@ export const secPowerApi = {
set: (enabled: boolean) => api.post<{ enabled: boolean }>(`${BASE}/power`, { enabled }).then((r) => r.data), set: (enabled: boolean) => api.post<{ enabled: boolean }>(`${BASE}/power`, { enabled }).then((r) => r.data),
} }
// Liga/desliga a Secretária POR SESSÃO (instância atual do /wa-inbox).
export interface SessionPower {
enabled: boolean
reason: string | null
changed_by: string | null
changed_at: string | null
can_toggle: boolean
next_toggle_at: string | null
history: { enabled: boolean; reason: string | null; by: string | null; at: string }[]
}
export const secSessionPowerApi = {
get: (instanceId: string) => api.get<SessionPower>(`${BASE}/session-power`, { params: { instance_id: instanceId } }).then((r) => r.data),
set: (instanceId: string, enabled: boolean, reason: string, by: string) =>
api.post<{ ok: boolean; enabled: boolean }>(`${BASE}/session-power`, { instance_id: instanceId, enabled, reason, by }).then((r) => r.data),
}
// ─── Agents ─────────────────────────────────────────────────────────────────── // ─── Agents ───────────────────────────────────────────────────────────────────
export const secAgentApi = { export const secAgentApi = {