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
ruicesar 30ea2f0e10 Merge pull request 'feat(secretaria): agente/cérebro por número na aba Números' (#2) from feat/newwhats-satelite into main
build-and-promote / build (push) Has been skipped
build-and-promote / promote (push) Successful in 52s
2026-07-06 16:07:07 +02:00
10 changed files with 488 additions and 64 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) {
+21 -10
View File
@@ -301,29 +301,39 @@ export function createAccountsRoute(pool) {
}; };
try { try {
// Papéis: sec_numbers são globais no motor — um fetch, indexado por instance_id. // Papéis (sec_numbers) são resolvidos no motor pela CONTA (x-nw-email). Um
const numbers = await motorGet('/secretaria/numbers', email); // usuário sem conta no motor (ex.: recepcionista que só existe no scoreodonto)
const roleOf = new Map(numbers.map((n) => [n.instance_id, n])); // não tem números próprios — então o papel de cada número LIBERADO precisa ser
const meta = (instanceId, fallbackName) => { // buscado com o e-mail do DONO da sessão, não do usuário logado. Cache por e-mail.
const n = roleOf.get(instanceId) || {}; 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 }; return { role: n.role || null, label: n.label || fallbackName || null, area: n.area || null, notes: n.notes || null };
}; };
const out = []; const out = [];
const seen = new Set(); const seen = new Set();
const push = (s, extra) => { const pushWith = (s, roleMap, extra) => {
if (!s || seen.has(s.id)) return; if (!s || seen.has(s.id)) return;
seen.add(s.id); seen.add(s.id);
out.push({ out.push({
instanceId: s.id, phone: s.phone || null, name: s.name || null, instanceId: s.id, phone: s.phone || null, name: s.name || null,
status: s.status || null, avatar: s.avatar || null, status: s.status || null, avatar: s.avatar || null,
...meta(s.id, s.name), ...extra, ...metaFrom(roleMap, s.id, s.name), ...extra,
}); });
}; };
// 1) Sessões PRÓPRIAS (conta do próprio usuário no motor). // 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)) { for (const s of await motorGet('/sessions', email)) {
push(s, { clinicaId: null, ownerEmail: email, ownerNome: null, own: true, canInbox: true }); 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). // 2) Sessões LIBERADAS por outros donos (can_inbox por sessão).
@@ -343,8 +353,9 @@ export function createAccountsRoute(pool) {
const { rows } = await pool.query('SELECT nome FROM usuarios WHERE id = $1', [owner.ownerId]); const { rows } = await pool.query('SELECT nome FROM usuarios WHERE id = $1', [owner.ownerId]);
ownerNome = rows[0]?.nome || null; ownerNome = rows[0]?.nome || null;
} catch { /* opcional */ } } 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)) { for (const s of await motorGet('/sessions', owner.email)) {
if (allowed.has(s.id)) push(s, { clinicaId, ownerEmail: owner.email, ownerNome, own: false, canInbox: true }); if (allowed.has(s.id)) pushWith(s, ownerRoles, { clinicaId, ownerEmail: owner.email, ownerNome, own: false, canInbox: true });
} }
} }
+78 -27
View File
@@ -1927,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) {
@@ -1937,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
}; };
@@ -9408,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 = {
+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 = {