Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b67fa15c55 | |||
| cfeac891fb | |||
| f30b0ab732 | |||
| ddcc639ab6 | |||
| 882af16458 | |||
| 4dea9ab28a | |||
| 26f39a60f3 |
@@ -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
@@ -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
@@ -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
@@ -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,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 },
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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'} · só 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} /> Só é 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 = {
|
||||||
|
|||||||
Reference in New Issue
Block a user