Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8cac06e37c | |||
| b67fa15c55 | |||
| 1e16edf2be | |||
| cfeac891fb | |||
| f30b0ab732 | |||
| ddcc639ab6 | |||
| 882af16458 | |||
| 4dea9ab28a | |||
| 26f39a60f3 | |||
| 7a5f5cf0fa | |||
| f806835ae3 | |||
| 30ea2f0e10 | |||
| ec76dfeec2 | |||
| 40bed96515 | |||
| f4c2c6861a | |||
| 668d672531 | |||
| ca2cfa1f35 | |||
| 773e8d4bf2 | |||
| 0a324bf8c8 | |||
| 77dec56dd0 | |||
| 03711e6280 |
@@ -10,23 +10,96 @@
|
||||
// POST /book — cria agendamento real (anti-overbooking + vínculo de paciente)
|
||||
import express from 'express';
|
||||
import { getConfig } from './config.js';
|
||||
import { cautelasParaIA } from './checklist-defs.js';
|
||||
import { resolverFeriado, feriadoNacionalNome } from './feriados.js';
|
||||
|
||||
const SLOT_MIN = 30; // duração padrão do slot (min)
|
||||
const SLOT_MIN = 60; // duração/espaçamento padrão do slot (1h entre atendimentos)
|
||||
const HORIZON_DAYS = 7; // janela padrão de busca
|
||||
const MAX_SLOTS = 20;
|
||||
// A agenda do scoreodonto é livre (quase ninguém preenche dentistas_horarios).
|
||||
// Sem jornada configurada, oferecemos horário comercial padrão (seg–sex 08–18).
|
||||
const MAX_SLOTS = 20; // quantos slots RETORNAR (após ordenar por horário)
|
||||
const CANDIDATE_CAP = 240; // teto de GERAÇÃO (clínica + salas competem por horário)
|
||||
// Fallback quando a CLÍNICA não configurou horário de funcionamento: comercial
|
||||
// padrão (seg–sex 08–18). Uma vez configurado (clinicas_horarios), manda ele.
|
||||
const DEFAULT_INI = '08:00';
|
||||
const DEFAULT_FIM = '18:00';
|
||||
const DIAS_NOME = ['domingo', 'segunda-feira', 'terça-feira', 'quarta-feira', 'quinta-feira', 'sexta-feira', 'sábado'];
|
||||
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
const ymd = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
const fmt = (d) => `${ymd(d)} ${pad(d.getHours())}:${pad(d.getMinutes())}:00`;
|
||||
const hm = (t) => String(t ?? '').slice(0, 5); // 'HH:MM:SS'|Date → 'HH:MM'
|
||||
|
||||
// Interseção de duas listas de janelas [ini,fim] (strings 'HH:MM'). Retorna as
|
||||
// sobreposições (ex.: clínica 08–18 ∩ dentista 13–20 = 13–18).
|
||||
function interseccao(a, b) {
|
||||
const out = [];
|
||||
for (const [ai, af] of a) for (const [bi, bf] of b) {
|
||||
const ini = ai > bi ? ai : bi;
|
||||
const fim = af < bf ? af : bf;
|
||||
if (ini < fim) out.push([ini, fim]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Carrega o horário de FUNCIONAMENTO da clínica → { byDow: Map<dow,[[ini,fim]]>, hasConfig }.
|
||||
async function carregarHorariosClinica(pool, clinicaId) {
|
||||
const byDow = new Map();
|
||||
let hasConfig = false;
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT dia_semana, hora_inicio, hora_fim FROM clinicas_horarios WHERE clinica_id = $1 AND ativo = 1`,
|
||||
[clinicaId]);
|
||||
hasConfig = rows.length > 0;
|
||||
for (const r of rows) {
|
||||
const arr = byDow.get(r.dia_semana) || [];
|
||||
arr.push([hm(r.hora_inicio), hm(r.hora_fim)]);
|
||||
byDow.set(r.dia_semana, arr);
|
||||
}
|
||||
} catch { /* tabela pode não existir ainda → cai no fallback */ }
|
||||
return { byDow, hasConfig };
|
||||
}
|
||||
|
||||
// Carrega as folgas (por data) dos dentistas da clínica → Map<dentista_id,[{ini,fim}]>.
|
||||
async function carregarFolgas(pool, clinicaId) {
|
||||
const byDent = new Map();
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT f.dentista_id, to_char(f.data_inicio,'YYYY-MM-DD') AS ini, to_char(f.data_fim,'YYYY-MM-DD') AS fim
|
||||
FROM dentistas_folgas f JOIN dentistas d ON d.id = f.dentista_id
|
||||
WHERE d.clinica_id = $1`, [clinicaId]);
|
||||
for (const r of rows) {
|
||||
const arr = byDent.get(r.dentista_id) || [];
|
||||
arr.push([r.ini, r.fim]);
|
||||
byDent.set(r.dentista_id, arr);
|
||||
}
|
||||
} catch { /* tabela pode não existir ainda */ }
|
||||
return byDent;
|
||||
}
|
||||
const emFolga = (folgas, dateStr) => (folgas || []).some(([i, f]) => dateStr >= i && dateStr <= f);
|
||||
|
||||
// Janelas de funcionamento da CLÍNICA num dia específico, já considerando feriado.
|
||||
// Retorna { janelas: [[ini,fim]], fechado: bool, motivo: string|null }.
|
||||
async function janelasClinicaNoDia(pool, clinicaId, dateStr, dow, clinicHours) {
|
||||
const fer = await resolverFeriado(pool, clinicaId, dateStr);
|
||||
if (fer && fer.fecha) return { janelas: [], fechado: true, motivo: `Feriado: ${fer.nome}` };
|
||||
// Feriado com horário especial (fecha=0 + horas) sobrepõe o horário normal.
|
||||
if (fer && !fer.fecha && fer.hora_inicio && fer.hora_fim) {
|
||||
return { janelas: [[hm(fer.hora_inicio), hm(fer.hora_fim)]], fechado: false, motivo: `Horário especial (${fer.nome})` };
|
||||
}
|
||||
// Horário normal do dia da semana.
|
||||
let janelas;
|
||||
if (clinicHours.hasConfig) {
|
||||
janelas = (clinicHours.byDow.get(dow) || []).map(([i, f]) => [i, f]);
|
||||
} else {
|
||||
janelas = (dow >= 1 && dow <= 5) ? [[DEFAULT_INI, DEFAULT_FIM]] : [];
|
||||
}
|
||||
if (!janelas.length) return { janelas: [], fechado: true, motivo: DIAS_NOME[dow] === 'domingo' || DIAS_NOME[dow] === 'sábado' ? `Fechado (${DIAS_NOME[dow]})` : 'Fechado neste dia' };
|
||||
return { janelas, fechado: false, motivo: null };
|
||||
}
|
||||
|
||||
// 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 —
|
||||
// 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;
|
||||
const { rows: dr } = await db.query('SELECT usuario_id, email FROM dentistas WHERE id = $1', [dentistaId]);
|
||||
let ids = [dentistaId];
|
||||
@@ -44,8 +117,9 @@ async function findConflict(db, dentistaId, start, end) {
|
||||
WHERE dentistaid = ANY($1)
|
||||
AND (status IS NULL OR lower(status) NOT IN ('cancelado','falta','remarcar'))
|
||||
AND start_time < $3 AND COALESCE(end_time, start_time) > $2
|
||||
AND ($4::text IS NULL OR id <> $4)
|
||||
LIMIT 1`,
|
||||
[ids, start, end || start]);
|
||||
[ids, start, end || start, excludeId]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
@@ -70,6 +144,7 @@ export function createAgendaBridge(pool) {
|
||||
const duration = Math.max(10, parseInt(req.query.duration, 10) || SLOT_MIN);
|
||||
const days = Math.min(30, Math.max(1, parseInt(req.query.days, 10) || HORIZON_DAYS));
|
||||
const from = req.query.date ? new Date(`${req.query.date}T00:00:00`) : new Date();
|
||||
const periodo = req.query.periodo ? String(req.query.periodo).toLowerCase() : null; // manha|tarde
|
||||
|
||||
try {
|
||||
// Dentistas no escopo da clínica.
|
||||
@@ -79,13 +154,18 @@ export function createAgendaBridge(pool) {
|
||||
const { rows: dentistas } = await pool.query(`SELECT id, nome FROM dentistas WHERE ${dWhere}`, dParams);
|
||||
if (!dentistas.length) return res.json({ slots: [], motivo: 'Nenhum dentista cadastrado na clínica.' });
|
||||
|
||||
// Jornadas configuradas (opcional).
|
||||
// Jornada dos DENTISTAS (dentistas_horarios) — opcional, por dentista.
|
||||
const { rows: horarios } = await pool.query(
|
||||
`SELECT dentista_id, dia_semana, hora_inicio, hora_fim
|
||||
FROM dentistas_horarios WHERE clinica_id = $1 AND ativo = 1`, [clinicaId]);
|
||||
const hoursBy = new Map();
|
||||
for (const h of horarios) { const a = hoursBy.get(h.dentista_id) || []; a.push(h); hoursBy.set(h.dentista_id, a); }
|
||||
|
||||
// Horário de FUNCIONAMENTO da clínica (config do dono) — a "casca" externa.
|
||||
const clinicHours = await carregarHorariosClinica(pool, clinicaId);
|
||||
// Folgas por data dos dentistas (férias/indisponibilidade).
|
||||
const folgasBy = await carregarFolgas(pool, clinicaId);
|
||||
|
||||
const start0 = new Date(from); start0.setHours(0, 0, 0, 0);
|
||||
const end0 = new Date(start0); end0.setDate(end0.getDate() + days);
|
||||
const { rows: busy } = await pool.query(
|
||||
@@ -98,23 +178,29 @@ export function createAgendaBridge(pool) {
|
||||
|
||||
const now = new Date();
|
||||
const slots = [];
|
||||
for (let i = 0; i < days && slots.length < MAX_SLOTS; i++) {
|
||||
for (let i = 0; i < days && slots.length < CANDIDATE_CAP; i++) {
|
||||
const day = new Date(start0); day.setDate(day.getDate() + i);
|
||||
const dow = day.getDay();
|
||||
// Janelas da CLÍNICA no dia (já considera feriado). Fechado → pula o dia.
|
||||
const { janelas: clinicWins, fechado } = await janelasClinicaNoDia(pool, clinicaId, ymd(day), dow, clinicHours);
|
||||
if (fechado || !clinicWins.length) continue;
|
||||
for (const dent of dentistas) {
|
||||
if (slots.length >= MAX_SLOTS) break;
|
||||
if (slots.length >= CANDIDATE_CAP) break;
|
||||
if (emFolga(folgasBy.get(dent.id), ymd(day))) continue; // dentista de folga nesse dia
|
||||
const cfg = hoursBy.get(dent.id);
|
||||
// Janelas do dia: jornada configurada OU padrão comercial (seg–sex).
|
||||
const windows = (cfg && cfg.length)
|
||||
? cfg.filter((h) => h.dia_semana === dow).map((h) => [h.hora_inicio, h.hora_fim])
|
||||
: ((dow >= 1 && dow <= 5) ? [[DEFAULT_INI, DEFAULT_FIM]] : []);
|
||||
// Dentista: se configurou jornada, usa a do dia; senão, atende sempre
|
||||
// que a clínica está aberta. Efetivo = CLÍNICA ∩ DENTISTA.
|
||||
const dentWins = (cfg && cfg.length)
|
||||
? cfg.filter((h) => h.dia_semana === dow).map((h) => [hm(h.hora_inicio), hm(h.hora_fim)])
|
||||
: clinicWins;
|
||||
const windows = interseccao(clinicWins, dentWins);
|
||||
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() + duration * 60000 <= limit.getTime() && slots.length < MAX_SLOTS) {
|
||||
while (t.getTime() + duration * 60000 <= limit.getTime() && slots.length < CANDIDATE_CAP) {
|
||||
const s = new Date(t);
|
||||
const e = new Date(t.getTime() + duration * 60000);
|
||||
t = e;
|
||||
@@ -125,13 +211,211 @@ export function createAgendaBridge(pool) {
|
||||
}
|
||||
}
|
||||
}
|
||||
slots.sort((a, b) => a.start.localeCompare(b.start));
|
||||
res.json({ slots: slots.slice(0, MAX_SLOTS) });
|
||||
|
||||
// ── B (salas): slots do dentista num QUARTO alugado (sala_reservas aprovada).
|
||||
// Disponibilidade = janela da reserva [inicio,fim]; casa o dentista da clínica
|
||||
// pelo usuario_id. Respeita folgas e agendamentos existentes (por dentista).
|
||||
if (slots.length < CANDIDATE_CAP) {
|
||||
try {
|
||||
const { rows: reservas } = await pool.query(
|
||||
`SELECT r.sala_id, r.inicio, r.fim, s.nome AS sala_nome, d.id AS dentista_id, d.nome AS dentista_nome
|
||||
FROM sala_reservas r
|
||||
JOIN salas s ON s.id = r.sala_id
|
||||
JOIN dentistas d ON d.usuario_id = r.profissional_usuario_id
|
||||
WHERE d.clinica_id = $1
|
||||
AND lower(coalesce(r.status,'')) NOT IN ('cancelada','cancelado','recusada','recusado','rejeitada','pendente','negada')
|
||||
AND r.fim > $2 AND r.inicio < $3
|
||||
ORDER BY r.inicio`,
|
||||
[clinicaId, start0.toISOString(), end0.toISOString()]);
|
||||
const step = duration * 60000;
|
||||
for (const rv of reservas) {
|
||||
if (slots.length >= CANDIDATE_CAP) break;
|
||||
const bs = busyBy.get(rv.dentista_id) || [];
|
||||
const rIni = new Date(rv.inicio), rFim = new Date(rv.fim);
|
||||
let t = new Date(Math.max(rIni.getTime(), start0.getTime(), now.getTime()));
|
||||
const off = (t.getTime() - rIni.getTime()) % step;
|
||||
if (off !== 0) t = new Date(t.getTime() + (step - off)); // alinha ao grid da reserva
|
||||
while (t.getTime() + step <= rFim.getTime() && slots.length < CANDIDATE_CAP) {
|
||||
const s = new Date(t), e = new Date(t.getTime() + step); t = e;
|
||||
if (s <= now) continue;
|
||||
if (emFolga(folgasBy.get(rv.dentista_id), ymd(s))) continue;
|
||||
if (bs.some((b) => new Date(b.start_time) < e && new Date(b.end_time || b.start_time) > s)) continue;
|
||||
slots.push({ dentista_id: rv.dentista_id, dentista_nome: rv.dentista_nome, start: fmt(s), end: fmt(e), sala_id: rv.sala_id, local: rv.sala_nome });
|
||||
}
|
||||
}
|
||||
} catch { /* sala_reservas pode não existir */ }
|
||||
}
|
||||
|
||||
let out = slots.sort((a, b) => a.start.localeCompare(b.start));
|
||||
// Filtro manhã/tarde (a Secretária pergunta o período antes de oferecer).
|
||||
if (periodo === 'manha' || periodo === 'manhã') out = out.filter((s) => parseInt(s.start.slice(11, 13), 10) < 12);
|
||||
else if (periodo === 'tarde') out = out.filter((s) => parseInt(s.start.slice(11, 13), 10) >= 12);
|
||||
res.json({ slots: out.slice(0, MAX_SLOTS) });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── GET /status ──────────────────────────────────────────────────────────────
|
||||
// Situação de funcionamento da clínica p/ a Secretária SABER quando responder
|
||||
// "estamos fechados". Query: clinica_id (obrig.), date? (YYYY-MM-DD, def: hoje BR).
|
||||
// Retorna: hoje (aberto/motivo/horários), próximo dia aberto, grade da semana e
|
||||
// próximos feriados.
|
||||
router.get('/status', async (req, res) => {
|
||||
const clinicaId = String(req.query.clinica_id || '');
|
||||
if (!clinicaId) return res.status(400).json({ error: 'clinica_id obrigatório' });
|
||||
try {
|
||||
// "Hoje" no fuso de Brasília (o servidor pode rodar em UTC).
|
||||
const hojeBR = req.query.date
|
||||
? String(req.query.date)
|
||||
: new Date().toLocaleDateString('en-CA', { timeZone: 'America/Sao_Paulo' }); // YYYY-MM-DD
|
||||
const clinicHours = await carregarHorariosClinica(pool, clinicaId);
|
||||
const dateAt = (str) => new Date(`${str}T12:00:00`); // meio-dia: evita borda de fuso
|
||||
const addYmd = (str, k) => { const d = dateAt(str); d.setDate(d.getDate() + k); return ymd(d); };
|
||||
const statusDoDia = async (str) => {
|
||||
const dow = dateAt(str).getDay();
|
||||
const r = await janelasClinicaNoDia(pool, clinicaId, str, dow, clinicHours);
|
||||
return {
|
||||
data: str, dia_semana: dow, dia_nome: DIAS_NOME[dow],
|
||||
aberto: !r.fechado && r.janelas.length > 0, motivo: r.motivo,
|
||||
horarios: r.janelas.map(([i, f]) => ({ inicio: i, fim: f })),
|
||||
};
|
||||
};
|
||||
|
||||
// Grade dos próximos 7 dias (inclui hoje).
|
||||
const semana = [];
|
||||
for (let k = 0; k < 7; k++) semana.push(await statusDoDia(addYmd(hojeBR, k)));
|
||||
const hoje = semana[0];
|
||||
// Próximo dia aberto: primeiro aberto na semana; se nenhum, varre +14.
|
||||
let proximo = semana.find((s) => s.aberto) || null;
|
||||
if (!proximo) for (let k = 7; k <= 21; k++) { const s = await statusDoDia(addYmd(hojeBR, k)); if (s.aberto) { proximo = s; break; } }
|
||||
|
||||
// Próximos feriados (45 dias) — clínica (1 query) + nacionais (em memória).
|
||||
const feriados = [];
|
||||
let excMap = new Map();
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT data, nome, fecha, recorrente FROM feriados WHERE clinica_id = $1`, [clinicaId]);
|
||||
for (const r of rows) {
|
||||
const ds = (r.data instanceof Date) ? ymd(r.data) : String(r.data).slice(0, 10);
|
||||
excMap.set(ds, r);
|
||||
if (r.recorrente === 1) excMap.set('R' + ds.slice(5), r);
|
||||
}
|
||||
} catch { /* tabela pode não existir */ }
|
||||
for (let k = 0; k < 45; k++) {
|
||||
const str = addYmd(hojeBR, k);
|
||||
const exc = excMap.get(str) || excMap.get('R' + str.slice(5));
|
||||
if (exc) { if (exc.fecha !== 0) feriados.push({ data: str, nome: exc.nome, origem: 'clinica' }); continue; }
|
||||
const nac = feriadoNacionalNome(str);
|
||||
if (nac) feriados.push({ data: str, nome: nac, origem: 'nacional' });
|
||||
}
|
||||
|
||||
res.json({ hoje, proximo_aberto: proximo, semana, feriados_proximos: feriados.slice(0, 8) });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── POST /pedido ──────────────────────────────────────────────────────────────
|
||||
// A Secretária IA registra um pedido de horário FORA da janela livre (zona cinza:
|
||||
// mais cedo/mais tarde) para a SECRETÁRIA HUMANA confirmar. A IA não agenda esses.
|
||||
// Body: { clinica_id, data (YYYY-MM-DD), hora (HH:MM), hora_fim?, dentista_nome?,
|
||||
// nome_cliente, telefone?, procedimento? }
|
||||
router.post('/pedido', async (req, res) => {
|
||||
const b = req.body || {};
|
||||
if (!b.clinica_id || !b.data || !b.hora) return res.status(400).json({ error: 'clinica_id, data e hora obrigatórios' });
|
||||
try {
|
||||
// Dia totalmente fechado (feriado/fechamento) → não adianta criar pedido.
|
||||
const fer = await resolverFeriado(pool, b.clinica_id, b.data);
|
||||
if (fer && fer.fecha) return res.json({ ok: false, motivo: `Fechado nesse dia (${fer.nome}).` });
|
||||
// Resolve o dentista pelo nome (opcional).
|
||||
let dentistaId = null;
|
||||
if (b.dentista_nome) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id FROM dentistas WHERE clinica_id = $1 AND lower(nome) LIKE '%'||lower($2)||'%' LIMIT 1`,
|
||||
[b.clinica_id, b.dentista_nome]);
|
||||
dentistaId = rows[0]?.id || null;
|
||||
}
|
||||
const id = `ped_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||||
await pool.query(
|
||||
`INSERT INTO agenda_pedidos (id, clinica_id, dentista_id, data, hora_inicio, hora_fim, paciente_nome, paciente_telefone, procedimento, status)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,'pendente')`,
|
||||
[id, b.clinica_id, dentistaId, b.data, hm(b.hora), b.hora_fim ? hm(b.hora_fim) : null,
|
||||
b.nome_cliente || null, (String(b.telefone || '').replace(/\D/g, '')) || null, b.procedimento || null]);
|
||||
res.json({ ok: true, pedido_id: id });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── POST /duvida ──────────────────────────────────────────────────────────────
|
||||
// Encaminha uma DÚVIDA/caso incerto à secretária humana (sem agendar). Cria um
|
||||
// agenda_pedidos tipo='duvida' com o resumo; entra na mesma fila de pendentes.
|
||||
router.post('/duvida', async (req, res) => {
|
||||
const b = req.body || {};
|
||||
if (!b.clinica_id || !b.resumo) return res.status(400).json({ error: 'clinica_id e resumo obrigatórios' });
|
||||
try {
|
||||
const id = `ped_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||||
await pool.query(
|
||||
`INSERT INTO agenda_pedidos (id, clinica_id, tipo, resumo, paciente_nome, paciente_telefone, status)
|
||||
VALUES ($1,$2,'duvida',$3,$4,$5,'pendente')`,
|
||||
[id, b.clinica_id, String(b.resumo).slice(0, 2000),
|
||||
b.nome_cliente || null, (String(b.telefone || '').replace(/\D/g, '')) || null]);
|
||||
res.json({ ok: true, pedido_id: id });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── GET /contexto ─────────────────────────────────────────────────────────────
|
||||
// Contexto clínico p/ a Secretária: dentistas + especialidades + "situações"
|
||||
// (regras da clínica). Query: clinica_id.
|
||||
router.get('/contexto', async (req, res) => {
|
||||
const clinicaId = String(req.query.clinica_id || '');
|
||||
if (!clinicaId) return res.status(400).json({ error: 'clinica_id obrigatório' });
|
||||
try {
|
||||
const { rows: dentistas } = await pool.query(
|
||||
`SELECT nome, especialidade, especialidades FROM dentistas WHERE clinica_id = $1 AND ativo IS DISTINCT FROM 0 ORDER BY nome`, [clinicaId]);
|
||||
let situacoes = [];
|
||||
try {
|
||||
const { rows } = await pool.query(`SELECT texto FROM agenda_situacoes WHERE clinica_id = $1 AND ativo = 1 ORDER BY ordem, created_at`, [clinicaId]);
|
||||
situacoes = rows.map((r) => r.texto);
|
||||
} catch { /* tabela pode não existir ainda */ }
|
||||
// Cautelas do checklist do dono (declaração × dados reais) → a IA não chuta.
|
||||
let cautelas = [];
|
||||
try {
|
||||
const respostas = {};
|
||||
const { rows } = await pool.query(`SELECT chave, resposta FROM agenda_checklist WHERE clinica_id = $1`, [clinicaId]);
|
||||
for (const r of rows) respostas[r.chave] = r.resposta == null ? null : Number(r.resposta);
|
||||
const nPac = await pool.query(`SELECT COUNT(*) n FROM pacientes WHERE clinica_id = $1`, [clinicaId]).then((x) => Number(x.rows[0]?.n || 0)).catch(() => 0);
|
||||
cautelas = cautelasParaIA(respostas, { dentistas: dentistas.length, pacientes: nPac });
|
||||
} catch { /* checklist novo/indisponível */ }
|
||||
res.json({
|
||||
dentistas: dentistas.map((d) => ({
|
||||
nome: d.nome,
|
||||
especialidades: Array.isArray(d.especialidades) ? d.especialidades : (d.especialidade ? [d.especialidade] : []),
|
||||
})),
|
||||
situacoes,
|
||||
cautelas,
|
||||
});
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── GET /dentista-contato ─────────────────────────────────────────────────────
|
||||
// Resolve o WhatsApp de um dentista da clínica (Parte A: a IA aciona o Dr).
|
||||
// Query: clinica_id (obrig.), nome? OU dentista_id?
|
||||
router.get('/dentista-contato', async (req, res) => {
|
||||
const clinicaId = String(req.query.clinica_id || '');
|
||||
if (!clinicaId) return res.status(400).json({ error: 'clinica_id obrigatório' });
|
||||
try {
|
||||
const params = [clinicaId]; let where = 'clinica_id = $1';
|
||||
if (req.query.dentista_id) { params.push(String(req.query.dentista_id)); where += ` AND id = $${params.length}`; }
|
||||
else if (req.query.nome) { params.push(String(req.query.nome)); where += ` AND lower(nome) LIKE '%'||lower($${params.length})||'%'`; }
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, nome, telefone FROM dentistas WHERE ${where} AND ativo IS DISTINCT FROM 0 ORDER BY nome LIMIT 1`, params);
|
||||
if (!rows.length) return res.json({ encontrado: false });
|
||||
const d = rows[0];
|
||||
const tel = String(d.telefone || '').replace(/\D/g, '');
|
||||
res.json({ encontrado: true, dentista_id: d.id, nome: d.nome, telefone: tel || null });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── POST /book ────────────────────────────────────────────────────────────────
|
||||
// Body: { clinica_id, dentista_id, start, end?, paciente_nome, paciente_celular, procedimento? }
|
||||
router.post('/book', async (req, res) => {
|
||||
@@ -175,11 +459,11 @@ export function createAgendaBridge(pool) {
|
||||
const { rows: ins } = await client.query(
|
||||
`INSERT INTO agendamentos
|
||||
(id, clinica_id, pacientenome, pacientecelular, pacienteid, dentistaid, procedimento,
|
||||
start_time, end_time, status, setor_id, created_by_nome, created_at, updated_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,'agendado',$10,'Secretária IA',NOW(),NOW())
|
||||
start_time, end_time, status, setor_id, created_by_nome, created_at, updated_at, sala_id)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,'agendado',$10,'Secretária IA',NOW(),NOW(),$11)
|
||||
RETURNING id, start_time, end_time, dentistaid`,
|
||||
[id, b.clinica_id, b.paciente_nome || 'Cliente WhatsApp', b.paciente_celular || null,
|
||||
pacienteId, b.dentista_id, b.procedimento || 'Consulta', b.start, end, setorId]);
|
||||
pacienteId, b.dentista_id, b.procedimento || 'Consulta', b.start, end, setorId, b.sala_id || null]);
|
||||
await client.query('COMMIT');
|
||||
res.json({ ok: true, agendamento: ins[0], paciente_vinculado: !!pacienteId, paciente_criado: pacienteCriado });
|
||||
} catch (e) {
|
||||
@@ -190,5 +474,221 @@ 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) ════════════════
|
||||
const soDigitos = (v) => String(v || '').replace(/\D/g, '');
|
||||
function idadeDe(dob) {
|
||||
if (!dob) return null;
|
||||
const s = String(dob).trim();
|
||||
let d = null, m = s.match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||
if (m) d = new Date(+m[1], +m[2] - 1, +m[3]);
|
||||
else { m = s.match(/^(\d{2})\/(\d{2})\/(\d{4})/); if (m) d = new Date(+m[3], +m[2] - 1, +m[1]); }
|
||||
if (!d || isNaN(d.getTime())) return null;
|
||||
const n = new Date(); let a = n.getFullYear() - d.getFullYear();
|
||||
const mo = n.getMonth() - d.getMonth();
|
||||
if (mo < 0 || (mo === 0 && n.getDate() < d.getDate())) a--;
|
||||
return a >= 0 && a < 130 ? a : null;
|
||||
}
|
||||
|
||||
// GET /pacientes/lookup?clinica_id=&phone= — quem está cadastrado NESTE número (a família).
|
||||
router.get('/pacientes/lookup', async (req, res) => {
|
||||
const clinicaId = String(req.query.clinica_id || '');
|
||||
const tel = soDigitos(req.query.phone).slice(-8);
|
||||
if (!clinicaId || !tel) return res.status(400).json({ error: 'clinica_id e phone obrigatórios' });
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, nome, cpf, telefone, datanascimento, convenio, grupofamiliarid, grupofamiliarrelacao
|
||||
FROM pacientes
|
||||
WHERE clinica_id = $1 AND regexp_replace(coalesce(telefone,''),'\\D','','g') LIKE '%'||$2
|
||||
ORDER BY (grupofamiliarrelacao = 'titular' OR grupofamiliarrelacao IS NULL) DESC, nome`,
|
||||
[clinicaId, tel]);
|
||||
res.json({
|
||||
encontrados: rows.length,
|
||||
pacientes: rows.map((p) => ({
|
||||
id: p.id, nome: p.nome, cpf: p.cpf || null,
|
||||
data_nascimento: p.datanascimento || null, idade: idadeDe(p.datanascimento),
|
||||
convenio: p.convenio || null, relacao: p.grupofamiliarrelacao || null, grupo_id: p.grupofamiliarid || null,
|
||||
})),
|
||||
});
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// POST /pacientes — cadastra/atualiza. Dedup por CPF; idade por nascimento; grupo familiar
|
||||
// (dependente vinculado ao titular via grupofamiliarid = id do titular).
|
||||
router.post('/pacientes', async (req, res) => {
|
||||
const b = req.body || {};
|
||||
if (!b.clinica_id || !b.nome) return res.status(400).json({ error: 'clinica_id e nome obrigatórios' });
|
||||
const cpf = soDigitos(b.cpf);
|
||||
const telefone = b.telefone ? soDigitos(b.telefone) : null;
|
||||
const idade = idadeDe(b.data_nascimento);
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
// 1) Dedup forte por CPF (identificador confiável — evita duplicar quem já existe).
|
||||
let existente = null;
|
||||
if (cpf) {
|
||||
const { rows } = await client.query(
|
||||
`SELECT * FROM pacientes WHERE clinica_id=$1 AND regexp_replace(coalesce(cpf,''),'\\D','','g')=$2 LIMIT 1`, [b.clinica_id, cpf]);
|
||||
existente = rows[0] || null;
|
||||
}
|
||||
// 2) Resolve o RESPONSÁVEL (para dependentes): por id, cpf ou telefone (o titular do número).
|
||||
let responsavel = null;
|
||||
if (b.responsavel_id) {
|
||||
const { rows } = await client.query('SELECT * FROM pacientes WHERE id=$1 AND clinica_id=$2', [b.responsavel_id, b.clinica_id]); responsavel = rows[0] || null;
|
||||
} else if (b.responsavel_cpf) {
|
||||
const { rows } = await client.query(`SELECT * FROM pacientes WHERE clinica_id=$1 AND regexp_replace(coalesce(cpf,''),'\\D','','g')=$2 LIMIT 1`, [b.clinica_id, soDigitos(b.responsavel_cpf)]); responsavel = rows[0] || null;
|
||||
} else if (b.responsavel_telefone) {
|
||||
const { rows } = await client.query(
|
||||
`SELECT * FROM pacientes WHERE clinica_id=$1 AND regexp_replace(coalesce(telefone,''),'\\D','','g') LIKE '%'||$2
|
||||
AND (grupofamiliarrelacao='titular' OR grupofamiliarrelacao IS NULL) ORDER BY nome LIMIT 1`,
|
||||
[b.clinica_id, soDigitos(b.responsavel_telefone).slice(-8)]); responsavel = rows[0] || null;
|
||||
}
|
||||
// Dependente = tem responsável OU menor de 18 (padrão) sem marcar eh_titular.
|
||||
const ehDependente = !!responsavel || (idade != null && idade < 18 && !b.eh_titular);
|
||||
let relacao = b.relacao || (ehDependente ? (idade != null && idade < 18 ? 'filho(a)' : 'dependente') : (b.eh_titular ? 'titular' : null));
|
||||
// Telefone do dependente sem número próprio → herda o do responsável (compartilhado).
|
||||
let telFinal = telefone;
|
||||
if (ehDependente && !telFinal && responsavel && responsavel.telefone) telFinal = soDigitos(responsavel.telefone);
|
||||
// 3) Grupo familiar: titular vira cabeça (grupofamiliarid = id do titular) + grupos_familiares.
|
||||
let grupoId = null;
|
||||
if (ehDependente && responsavel) {
|
||||
grupoId = responsavel.grupofamiliarid || responsavel.id;
|
||||
if (!responsavel.grupofamiliarid) {
|
||||
await client.query(`UPDATE pacientes SET grupofamiliarid=$1, grupofamiliarrelacao=COALESCE(grupofamiliarrelacao,'titular') WHERE id=$1`, [responsavel.id]);
|
||||
}
|
||||
await client.query(`INSERT INTO grupos_familiares (id, nome, responsavel_id, created_at) VALUES ($1,$2,$3,NOW()) ON CONFLICT (id) DO NOTHING`, [grupoId, responsavel.nome, responsavel.id]);
|
||||
}
|
||||
// 4) Atualiza (dedup) OU cria.
|
||||
if (existente) {
|
||||
await client.query(
|
||||
`UPDATE pacientes SET nome=$2, telefone=COALESCE($3,telefone), email=COALESCE($4,email),
|
||||
convenio=COALESCE($5,convenio), datanascimento=COALESCE($6,datanascimento),
|
||||
grupofamiliarid=COALESCE($7,grupofamiliarid), grupofamiliarrelacao=COALESCE($8,grupofamiliarrelacao) WHERE id=$1`,
|
||||
[existente.id, b.nome, telFinal, b.email || null, b.convenio || null, b.data_nascimento || null, grupoId, relacao]);
|
||||
await client.query('COMMIT');
|
||||
return res.json({ ok: true, atualizado: true, paciente: { id: existente.id, nome: b.nome, relacao, grupo_id: grupoId, idade } });
|
||||
}
|
||||
const id = `pac_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||||
await client.query(
|
||||
`INSERT INTO pacientes (id, clinica_id, nome, cpf, telefone, email, convenio, datanascimento, grupofamiliarid, grupofamiliarrelacao)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
|
||||
[id, b.clinica_id, b.nome, cpf || null, telFinal, b.email || null, b.convenio || null, b.data_nascimento || null, grupoId, relacao]);
|
||||
await client.query('COMMIT');
|
||||
res.json({ ok: true, criado: true, paciente: { id, nome: b.nome, cpf: cpf || null, relacao, grupo_id: grupoId, idade, dependente: ehDependente } });
|
||||
} catch (e) {
|
||||
try { await client.query('ROLLBACK'); } catch { /* ignore */ }
|
||||
res.status(500).json({ error: e.message });
|
||||
} finally { client.release(); }
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
// Config de horários/feriados — API para o FRONTEND do scoreodonto (dono/dentista).
|
||||
// Auth: JWT do scoreodonto (Authorization: Bearer). Ownership:
|
||||
// - Horário da CLÍNICA + feriados → só o DONO (clinicas.owner_id / vínculo dono).
|
||||
// - Horário do DENTISTA → o próprio dentista (dentistas.usuario_id) OU o dono.
|
||||
//
|
||||
// Montado em /api/nw/agenda-config pelo registerNewwhats.
|
||||
import express from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { feriadosNacionais } from './feriados.js';
|
||||
import { CHECKLIST_QUESTOES, pendenciasDeConfig } from './checklist-defs.js';
|
||||
|
||||
function authFromReq(req) {
|
||||
try {
|
||||
const tk = (req.headers['authorization'] || '').replace(/^Bearer\s+/i, '').trim();
|
||||
if (!tk) return null;
|
||||
const p = jwt.verify(tk, process.env.JWT_SECRET);
|
||||
return { userId: p.userId || null, email: (p.email || '').toLowerCase() || null };
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// É dono do workspace (clínica)? owner_id direto OU vínculo dono/admin.
|
||||
async function isDono(pool, clinicaId, userId) {
|
||||
if (!clinicaId || !userId) return false;
|
||||
try {
|
||||
const { rows: cl } = await pool.query('SELECT owner_id FROM clinicas WHERE id = $1', [clinicaId]);
|
||||
if (cl[0]?.owner_id && cl[0].owner_id === userId) return true;
|
||||
const { rows: v } = await pool.query(
|
||||
`SELECT 1 FROM vinculos WHERE clinica_id = $1 AND usuario_id = $2
|
||||
AND role IN ('donoclinica','donoconsultorio','admin') LIMIT 1`, [clinicaId, userId]);
|
||||
return v.length > 0;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
const uid = (p) => `${p}_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||||
const hhmm = (v) => { const m = String(v ?? '').match(/^(\d{1,2}):(\d{2})/); return m ? `${m[1].padStart(2, '0')}:${m[2]}` : null; };
|
||||
|
||||
export function createAgendaConfig(pool) {
|
||||
const router = express.Router();
|
||||
|
||||
router.use((req, res, next) => {
|
||||
const auth = authFromReq(req);
|
||||
if (!auth) return res.status(401).json({ error: 'Sessão inválida.' });
|
||||
req.nwUser = auth;
|
||||
next();
|
||||
});
|
||||
|
||||
// ── Horário de FUNCIONAMENTO da clínica (dono) ────────────────────────────────
|
||||
router.get('/clinica/:clinicaId/horarios', async (req, res) => {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, dia_semana, to_char(hora_inicio,'HH24:MI') AS hora_inicio,
|
||||
to_char(hora_fim,'HH24:MI') AS hora_fim, ativo
|
||||
FROM clinicas_horarios WHERE clinica_id = $1 ORDER BY dia_semana, hora_inicio`,
|
||||
[req.params.clinicaId]);
|
||||
res.json({ horarios: rows });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Substitui TODA a grade da clínica (mais simples e previsível que patch por dia).
|
||||
// Body: { horarios: [{ dia_semana, hora_inicio, hora_fim }] }
|
||||
router.put('/clinica/:clinicaId/horarios', async (req, res) => {
|
||||
const clinicaId = req.params.clinicaId;
|
||||
if (!(await isDono(pool, clinicaId, req.nwUser.userId)))
|
||||
return res.status(403).json({ error: 'Apenas o dono pode configurar o horário da clínica.' });
|
||||
const linhas = Array.isArray(req.body?.horarios) ? req.body.horarios : [];
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query('DELETE FROM clinicas_horarios WHERE clinica_id = $1', [clinicaId]);
|
||||
for (const h of linhas) {
|
||||
const dow = Number(h.dia_semana); const ini = hhmm(h.hora_inicio); const fim = hhmm(h.hora_fim);
|
||||
if (!(dow >= 0 && dow <= 6) || !ini || !fim || ini >= fim) continue;
|
||||
await client.query(
|
||||
`INSERT INTO clinicas_horarios (id, clinica_id, dia_semana, hora_inicio, hora_fim, ativo)
|
||||
VALUES ($1,$2,$3,$4,$5,1)`, [uid('ch'), clinicaId, dow, ini, fim]);
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
res.json({ ok: true });
|
||||
} catch (e) { try { await client.query('ROLLBACK'); } catch {} res.status(500).json({ error: e.message }); }
|
||||
finally { client.release(); }
|
||||
});
|
||||
|
||||
// ── Horário do DENTISTA (o próprio dentista ou o dono) ────────────────────────
|
||||
router.get('/dentista/:dentistaId/horarios', async (req, res) => {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, dia_semana, to_char(hora_inicio,'HH24:MI') AS hora_inicio,
|
||||
to_char(hora_fim,'HH24:MI') AS hora_fim,
|
||||
to_char(hora_inicio_flex,'HH24:MI') AS hora_inicio_flex,
|
||||
to_char(hora_fim_flex,'HH24:MI') AS hora_fim_flex, ativo
|
||||
FROM dentistas_horarios WHERE dentista_id = $1 ORDER BY dia_semana, hora_inicio`,
|
||||
[req.params.dentistaId]);
|
||||
res.json({ horarios: rows });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.put('/dentista/:dentistaId/horarios', async (req, res) => {
|
||||
const dentistaId = req.params.dentistaId;
|
||||
let dent;
|
||||
try { const { rows } = await pool.query('SELECT clinica_id, usuario_id FROM dentistas WHERE id = $1', [dentistaId]); dent = rows[0]; }
|
||||
catch (e) { return res.status(500).json({ error: e.message }); }
|
||||
if (!dent) return res.status(404).json({ error: 'Dentista não encontrado.' });
|
||||
const ehProprio = dent.usuario_id && dent.usuario_id === req.nwUser.userId;
|
||||
const ehDono = await isDono(pool, dent.clinica_id, req.nwUser.userId);
|
||||
if (!ehProprio && !ehDono)
|
||||
return res.status(403).json({ error: 'Só o próprio dentista (ou o dono) pode configurar estes horários.' });
|
||||
const linhas = Array.isArray(req.body?.horarios) ? req.body.horarios : [];
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query('DELETE FROM dentistas_horarios WHERE dentista_id = $1', [dentistaId]);
|
||||
for (const h of linhas) {
|
||||
const dow = Number(h.dia_semana); const ini = hhmm(h.hora_inicio); const fim = hhmm(h.hora_fim);
|
||||
if (!(dow >= 0 && dow <= 6) || !ini || !fim || ini >= fim) continue;
|
||||
// Janela estendida (zona cinza): flex_inicio ≤ inicio e flex_fim ≥ fim.
|
||||
let fIni = hhmm(h.hora_inicio_flex), fFim = hhmm(h.hora_fim_flex);
|
||||
if (fIni && fIni >= ini) fIni = null; // só vale se for MAIS cedo
|
||||
if (fFim && fFim <= fim) fFim = null; // só vale se for MAIS tarde
|
||||
await client.query(
|
||||
`INSERT INTO dentistas_horarios (id, dentista_id, clinica_id, dia_semana, hora_inicio, hora_fim, hora_inicio_flex, hora_fim_flex, ativo)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,1)`, [uid('dh'), dentistaId, dent.clinica_id, dow, ini, fim, fIni, fFim]);
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
res.json({ ok: true });
|
||||
} catch (e) { try { await client.query('ROLLBACK'); } catch {} res.status(500).json({ error: e.message }); }
|
||||
finally { client.release(); }
|
||||
});
|
||||
|
||||
// ── Folgas/férias do DENTISTA por data (o próprio dentista ou o dono) ─────────
|
||||
async function guardDentista(pool, dentistaId, user) {
|
||||
const { rows } = await pool.query('SELECT clinica_id, usuario_id FROM dentistas WHERE id = $1', [dentistaId]);
|
||||
const dent = rows[0];
|
||||
if (!dent) return { ok: false, code: 404, msg: 'Dentista não encontrado.' };
|
||||
const ehProprio = dent.usuario_id && dent.usuario_id === user.userId;
|
||||
const ehDono = await isDono(pool, dent.clinica_id, user.userId);
|
||||
if (!ehProprio && !ehDono) return { ok: false, code: 403, msg: 'Só o próprio dentista (ou o dono) pode alterar.' };
|
||||
return { ok: true, dent };
|
||||
}
|
||||
|
||||
router.get('/dentista/:dentistaId/folgas', async (req, res) => {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, to_char(data_inicio,'YYYY-MM-DD') AS data_inicio, to_char(data_fim,'YYYY-MM-DD') AS data_fim, motivo
|
||||
FROM dentistas_folgas WHERE dentista_id = $1 AND data_fim >= CURRENT_DATE ORDER BY data_inicio`,
|
||||
[req.params.dentistaId]);
|
||||
res.json({ folgas: rows });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Body: { data_inicio (YYYY-MM-DD), data_fim? (def = inicio), motivo? }
|
||||
router.post('/dentista/:dentistaId/folgas', async (req, res) => {
|
||||
const g = await guardDentista(pool, req.params.dentistaId, req.nwUser).catch((e) => ({ ok: false, code: 500, msg: e.message }));
|
||||
if (!g.ok) return res.status(g.code).json({ error: g.msg });
|
||||
const b = req.body || {};
|
||||
if (!b.data_inicio) return res.status(400).json({ error: 'data_inicio obrigatória' });
|
||||
const fim = b.data_fim && b.data_fim >= b.data_inicio ? b.data_fim : b.data_inicio;
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO dentistas_folgas (id, dentista_id, clinica_id, data_inicio, data_fim, motivo)
|
||||
VALUES ($1,$2,$3,$4,$5,$6)
|
||||
RETURNING id, to_char(data_inicio,'YYYY-MM-DD') AS data_inicio, to_char(data_fim,'YYYY-MM-DD') AS data_fim, motivo`,
|
||||
[uid('fol'), req.params.dentistaId, g.dent.clinica_id, b.data_inicio, fim, b.motivo || null]);
|
||||
res.status(201).json(rows[0]);
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.delete('/dentista/:dentistaId/folgas/:id', async (req, res) => {
|
||||
const g = await guardDentista(pool, req.params.dentistaId, req.nwUser).catch((e) => ({ ok: false, code: 500, msg: e.message }));
|
||||
if (!g.ok) return res.status(g.code).json({ error: g.msg });
|
||||
try { await pool.query('DELETE FROM dentistas_folgas WHERE id = $1 AND dentista_id = $2', [req.params.id, req.params.dentistaId]); res.json({ ok: true }); }
|
||||
catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── Feriados/fechamentos (dono) ───────────────────────────────────────────────
|
||||
// Lista os NACIONAIS (computados) + as exceções da clínica de um ano.
|
||||
router.get('/clinica/:clinicaId/feriados', async (req, res) => {
|
||||
const clinicaId = req.params.clinicaId;
|
||||
const ano = Number(req.query.ano) || new Date().getFullYear();
|
||||
try {
|
||||
const nac = Object.entries(feriadosNacionais(ano)).map(([data, nome]) => ({ data, nome, origem: 'nacional' }));
|
||||
const { rows: exc } = await pool.query(
|
||||
`SELECT id, to_char(data,'YYYY-MM-DD') AS data, nome, fecha,
|
||||
to_char(hora_inicio,'HH24:MI') AS hora_inicio, to_char(hora_fim,'HH24:MI') AS hora_fim, recorrente
|
||||
FROM feriados WHERE clinica_id = $1 AND (extract(year from data) = $2 OR recorrente = 1) ORDER BY data`,
|
||||
[clinicaId, ano]);
|
||||
res.json({ nacionais: nac, excecoes: exc.map((e) => ({ ...e, origem: 'clinica' })) });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Cria uma exceção da clínica (fechamento/ponte/horário especial). Dono.
|
||||
// Body: { data (YYYY-MM-DD), nome, fecha?(bool), hora_inicio?, hora_fim?, recorrente?(bool) }
|
||||
router.post('/clinica/:clinicaId/feriados', async (req, res) => {
|
||||
const clinicaId = req.params.clinicaId;
|
||||
if (!(await isDono(pool, clinicaId, req.nwUser.userId)))
|
||||
return res.status(403).json({ error: 'Apenas o dono pode configurar feriados/fechamentos.' });
|
||||
const b = req.body || {};
|
||||
if (!b.data || !b.nome) return res.status(400).json({ error: 'data e nome obrigatórios' });
|
||||
const fecha = b.fecha === false ? 0 : 1;
|
||||
const ini = fecha ? null : hhmm(b.hora_inicio);
|
||||
const fim = fecha ? null : hhmm(b.hora_fim);
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO feriados (id, clinica_id, data, nome, fecha, hora_inicio, hora_fim, recorrente)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
|
||||
RETURNING id, to_char(data,'YYYY-MM-DD') AS data, nome, fecha, recorrente`,
|
||||
[uid('fer'), clinicaId, b.data, b.nome, fecha, ini, fim, b.recorrente ? 1 : 0]);
|
||||
res.status(201).json(rows[0]);
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.delete('/clinica/:clinicaId/feriados/:id', async (req, res) => {
|
||||
if (!(await isDono(pool, req.params.clinicaId, req.nwUser.userId)))
|
||||
return res.status(403).json({ error: 'Apenas o dono pode remover feriados/fechamentos.' });
|
||||
try {
|
||||
await pool.query('DELETE FROM feriados WHERE id = $1 AND clinica_id = $2', [req.params.id, req.params.clinicaId]);
|
||||
res.json({ ok: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── Situações / regras da clínica (dono) — a "área de situações" da Secretária ─
|
||||
router.get('/clinica/:clinicaId/situacoes', async (req, res) => {
|
||||
try {
|
||||
const { rows } = await pool.query(`SELECT id, texto, ativo, ordem FROM agenda_situacoes WHERE clinica_id = $1 ORDER BY ordem, created_at`, [req.params.clinicaId]);
|
||||
res.json({ situacoes: rows });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
// Substitui toda a lista. Body: { situacoes: [texto] }
|
||||
router.put('/clinica/:clinicaId/situacoes', async (req, res) => {
|
||||
const clinicaId = req.params.clinicaId;
|
||||
if (!(await isDono(pool, clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Apenas o dono pode configurar as situações.' });
|
||||
const linhas = Array.isArray(req.body?.situacoes) ? req.body.situacoes : [];
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query('DELETE FROM agenda_situacoes WHERE clinica_id = $1', [clinicaId]);
|
||||
let i = 0;
|
||||
for (const s of linhas) {
|
||||
const t = String(s || '').trim(); if (!t) continue;
|
||||
await client.query(`INSERT INTO agenda_situacoes (id, clinica_id, texto, ativo, ordem) VALUES ($1,$2,$3,1,$4)`, [uid('sit'), clinicaId, t, i++]);
|
||||
}
|
||||
await client.query('COMMIT'); res.json({ ok: true });
|
||||
} catch (e) { try { await client.query('ROLLBACK'); } catch {} res.status(500).json({ error: e.message }); }
|
||||
finally { client.release(); }
|
||||
});
|
||||
|
||||
// ── Checklist do DONO + pendências (só o dono do workspace) ───────────────────
|
||||
// Conta dentistas/pacientes reais da clínica p/ conferir contra as respostas.
|
||||
async function contagens(clinicaId) {
|
||||
const q = async (sql) => { try { const { rows } = await pool.query(sql, [clinicaId]); return Number(rows[0]?.n || 0); } catch { return 0; } };
|
||||
const dentistas = await q(`SELECT COUNT(*) n FROM dentistas WHERE clinica_id = $1 AND ativo IS DISTINCT FROM 0`);
|
||||
const pacientes = await q(`SELECT COUNT(*) n FROM pacientes WHERE clinica_id = $1`);
|
||||
return { dentistas, pacientes };
|
||||
}
|
||||
async function respostasChecklist(clinicaId) {
|
||||
const map = {};
|
||||
try { const { rows } = await pool.query('SELECT chave, resposta FROM agenda_checklist WHERE clinica_id = $1', [clinicaId]); for (const r of rows) map[r.chave] = r.resposta == null ? null : Number(r.resposta); } catch { /* tabela nova */ }
|
||||
return map;
|
||||
}
|
||||
|
||||
router.get('/clinica/:clinicaId/checklist', async (req, res) => {
|
||||
const clinicaId = req.params.clinicaId;
|
||||
if (!(await isDono(pool, clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Apenas o dono acessa o checklist.' });
|
||||
try {
|
||||
const map = await respostasChecklist(clinicaId);
|
||||
res.json({ questoes: CHECKLIST_QUESTOES.map((q) => ({ ...q, resposta: map[q.chave] ?? null })) });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
// Salva respostas. Body: { respostas: { chave: 1|0|null } }
|
||||
router.put('/clinica/:clinicaId/checklist', async (req, res) => {
|
||||
const clinicaId = req.params.clinicaId;
|
||||
if (!(await isDono(pool, clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Apenas o dono pode configurar o checklist.' });
|
||||
const respostas = req.body?.respostas || {};
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
for (const q of CHECKLIST_QUESTOES) {
|
||||
if (!(q.chave in respostas)) continue;
|
||||
const v = respostas[q.chave];
|
||||
const r = v === 1 || v === true ? 1 : v === 0 || v === false ? 0 : null;
|
||||
await client.query(
|
||||
`INSERT INTO agenda_checklist (clinica_id, chave, resposta, updated_at) VALUES ($1,$2,$3,now())
|
||||
ON CONFLICT (clinica_id, chave) DO UPDATE SET resposta = EXCLUDED.resposta, updated_at = now()`,
|
||||
[clinicaId, q.chave, r]);
|
||||
}
|
||||
await client.query('COMMIT'); res.json({ ok: true });
|
||||
} catch (e) { try { await client.query('ROLLBACK'); } catch {} res.status(500).json({ error: e.message }); }
|
||||
finally { client.release(); }
|
||||
});
|
||||
|
||||
// Área de pendências do DONO — engloba TUDO: pendências de config (checklist ×
|
||||
// banco) + a fila operacional (pedidos de horário + dúvidas aguardando humano).
|
||||
router.get('/clinica/:clinicaId/pendencias', async (req, res) => {
|
||||
const clinicaId = req.params.clinicaId;
|
||||
if (!(await isDono(pool, clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Apenas o dono acessa as pendências.' });
|
||||
try {
|
||||
const counts = await contagens(clinicaId);
|
||||
const respostas = await respostasChecklist(clinicaId);
|
||||
const config = pendenciasDeConfig(respostas, counts);
|
||||
let horarios = 0, duvidas = 0;
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT COALESCE(tipo,'horario') tipo, COUNT(*) n FROM agenda_pedidos WHERE clinica_id = $1 AND status = 'pendente' GROUP BY 1`, [clinicaId]);
|
||||
for (const r of rows) { if (r.tipo === 'duvida') duvidas = Number(r.n); else horarios = Number(r.n); }
|
||||
} catch { /* tabela nova */ }
|
||||
res.json({
|
||||
contagens: counts,
|
||||
config,
|
||||
operacionais: { horarios, duvidas, total: horarios + duvidas },
|
||||
total: config.length + horarios + duvidas,
|
||||
});
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── Pedidos pendentes (zona cinza) — a secretária humana confirma/recusa ──────
|
||||
async function ehMembro(clinicaId, userId) {
|
||||
if (await isDono(pool, clinicaId, userId)) return true;
|
||||
try { const { rows } = await pool.query('SELECT 1 FROM vinculos WHERE clinica_id = $1 AND usuario_id = $2 LIMIT 1', [clinicaId, userId]); return rows.length > 0; }
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
router.get('/clinica/:clinicaId/pedidos', async (req, res) => {
|
||||
if (!(await ehMembro(req.params.clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Sem acesso.' });
|
||||
const status = req.query.status ? String(req.query.status) : 'pendente';
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT p.id, COALESCE(p.tipo,'horario') AS tipo, p.resumo,
|
||||
to_char(p.data,'YYYY-MM-DD') AS data, to_char(p.hora_inicio,'HH24:MI') AS hora_inicio,
|
||||
to_char(p.hora_fim,'HH24:MI') AS hora_fim, p.paciente_nome, p.paciente_telefone, p.procedimento,
|
||||
p.dentista_id, d.nome AS dentista_nome, p.status, p.created_at
|
||||
FROM agenda_pedidos p LEFT JOIN dentistas d ON d.id = p.dentista_id
|
||||
WHERE p.clinica_id = $1 AND p.status = $2 ORDER BY p.created_at DESC LIMIT 100`,
|
||||
[req.params.clinicaId, status]);
|
||||
res.json({ pedidos: rows, total: rows.length });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Confirma → cria o agendamento real e marca o pedido como confirmado.
|
||||
// Body: { dentista_id? (se o pedido não tinha), duracao_min? }
|
||||
router.post('/clinica/:clinicaId/pedidos/:id/confirmar', async (req, res) => {
|
||||
const clinicaId = req.params.clinicaId;
|
||||
if (!(await ehMembro(clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Sem acesso.' });
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const { rows } = await client.query('SELECT * FROM agenda_pedidos WHERE id = $1 AND clinica_id = $2 FOR UPDATE', [req.params.id, clinicaId]);
|
||||
const ped = rows[0];
|
||||
if (!ped) { await client.query('ROLLBACK'); return res.status(404).json({ error: 'Pedido não encontrado.' }); }
|
||||
if (ped.status !== 'pendente') { await client.query('ROLLBACK'); return res.status(409).json({ error: 'Pedido já resolvido.' }); }
|
||||
const dentistaId = req.body?.dentista_id || ped.dentista_id;
|
||||
if (!dentistaId) { await client.query('ROLLBACK'); return res.status(400).json({ error: 'Escolha o dentista para confirmar.' }); }
|
||||
const dur = Math.max(10, Number(req.body?.duracao_min) || 30);
|
||||
const hi = String(ped.hora_inicio).slice(0, 5);
|
||||
const start = `${(ped.data instanceof Date ? ped.data.toISOString().slice(0, 10) : String(ped.data).slice(0, 10))} ${hi}:00`;
|
||||
const endD = new Date(`${start.replace(' ', 'T')}`); endD.setMinutes(endD.getMinutes() + dur);
|
||||
const end = `${start.slice(0, 10)} ${String(endD.getHours()).padStart(2, '0')}:${String(endD.getMinutes()).padStart(2, '0')}:00`;
|
||||
const { rows: dd } = await client.query('SELECT setor_id FROM dentistas WHERE id = $1', [dentistaId]);
|
||||
const agId = `ag_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||||
await client.query(
|
||||
`INSERT INTO agendamentos (id, clinica_id, pacientenome, pacientecelular, dentistaid, procedimento,
|
||||
start_time, end_time, status, setor_id, sala_id, created_by_nome, created_at, updated_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'agendado',$9,$10,'Secretária (confirmado)',NOW(),NOW())`,
|
||||
[agId, clinicaId, ped.paciente_nome || 'Cliente WhatsApp', ped.paciente_telefone || null, dentistaId,
|
||||
ped.procedimento || 'Consulta', start, end, dd[0]?.setor_id || null, ped.sala_id || null]);
|
||||
await client.query(`UPDATE agenda_pedidos SET status='confirmado', agendamento_id=$1, resolved_at=NOW(), resolved_by=$2 WHERE id=$3`,
|
||||
[agId, req.nwUser.userId, req.params.id]);
|
||||
await client.query('COMMIT');
|
||||
res.json({ ok: true, agendamento_id: agId });
|
||||
} catch (e) { try { await client.query('ROLLBACK'); } catch {} res.status(500).json({ error: e.message }); }
|
||||
finally { client.release(); }
|
||||
});
|
||||
|
||||
// Resolve uma DÚVIDA (tipo='duvida') sem criar agendamento — a humana já
|
||||
// decidiu/atendeu (ou vai agendar manualmente uma avaliação).
|
||||
router.post('/clinica/:clinicaId/pedidos/:id/resolver', async (req, res) => {
|
||||
if (!(await ehMembro(req.params.clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Sem acesso.' });
|
||||
try {
|
||||
await pool.query(`UPDATE agenda_pedidos SET status='resolvido', resolved_at=NOW(), resolved_by=$1 WHERE id=$2 AND clinica_id=$3 AND status='pendente'`,
|
||||
[req.nwUser.userId, req.params.id, req.params.clinicaId]);
|
||||
res.json({ ok: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.post('/clinica/:clinicaId/pedidos/:id/recusar', async (req, res) => {
|
||||
if (!(await ehMembro(req.params.clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Sem acesso.' });
|
||||
try {
|
||||
await pool.query(`UPDATE agenda_pedidos SET status='recusado', resolved_at=NOW(), resolved_by=$1 WHERE id=$2 AND clinica_id=$3 AND status='pendente'`,
|
||||
[req.nwUser.userId, req.params.id, req.params.clinicaId]);
|
||||
res.json({ ok: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Lista dentistas da clínica (p/ a UI do dono escolher quem configurar / e o
|
||||
// dentista achar seu registro). Retorna também se o usuário logado é o dentista.
|
||||
router.get('/clinica/:clinicaId/dentistas', async (req, res) => {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, nome, usuario_id, ativo FROM dentistas WHERE clinica_id = $1 ORDER BY ordem NULLS LAST, nome`,
|
||||
[req.params.clinicaId]);
|
||||
res.json({ dentistas: rows.map((d) => ({ ...d, eh_voce: d.usuario_id === req.nwUser.userId })) });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// Schema (idempotente) das tabelas de horário/feriado usadas pela Secretária.
|
||||
// Chamado uma vez no registerNewwhats. Espelha os tipos de `dentistas_horarios`.
|
||||
//
|
||||
// clinicas_horarios — horário de FUNCIONAMENTO da clínica (config do DONO).
|
||||
// feriados — EXCEÇÕES da clínica (fechamento/ponte/horário especial);
|
||||
// os feriados NACIONAIS são computados (ver feriados.js).
|
||||
|
||||
export async function ensureAgendaSchema(pool) {
|
||||
try {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS clinicas_horarios (
|
||||
id varchar PRIMARY KEY,
|
||||
clinica_id varchar NOT NULL,
|
||||
dia_semana integer NOT NULL, -- 0=domingo … 6=sábado
|
||||
hora_inicio time without time zone NOT NULL,
|
||||
hora_fim time without time zone NOT NULL,
|
||||
ativo smallint DEFAULT 1
|
||||
)`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_clinicas_horarios_clinica ON clinicas_horarios (clinica_id)`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS feriados (
|
||||
id varchar PRIMARY KEY,
|
||||
clinica_id varchar NOT NULL,
|
||||
data date NOT NULL,
|
||||
nome varchar NOT NULL,
|
||||
fecha smallint DEFAULT 1, -- 1=fechado o dia; 0=abre em horário especial
|
||||
hora_inicio time without time zone, -- horário especial (quando fecha=0)
|
||||
hora_fim time without time zone,
|
||||
recorrente smallint DEFAULT 0, -- 1=repete todo ano (mesmo dia/mês)
|
||||
created_at timestamptz DEFAULT now()
|
||||
)`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_feriados_clinica_data ON feriados (clinica_id, data)`);
|
||||
|
||||
// ── dentistas_folgas — folga/férias/indisponibilidade por DATA do dentista.
|
||||
// O dentista (ou o dono) marca dias em que NÃO atende; o /slots pula.
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS dentistas_folgas (
|
||||
id varchar PRIMARY KEY,
|
||||
dentista_id varchar NOT NULL,
|
||||
clinica_id varchar,
|
||||
data_inicio date NOT NULL,
|
||||
data_fim date NOT NULL, -- inclusive; = data_inicio p/ 1 dia
|
||||
motivo varchar,
|
||||
created_at timestamptz DEFAULT now()
|
||||
)`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_dentistas_folgas_dent ON dentistas_folgas (dentista_id, data_inicio, data_fim)`);
|
||||
|
||||
// ── B (salas): agendamentos.sala_id — permite agendar num quarto alugado.
|
||||
// Aditivo/nullable: NÃO afeta o fluxo existente (clínica) quando nulo.
|
||||
await pool.query(`ALTER TABLE agendamentos ADD COLUMN IF NOT EXISTS sala_id varchar`);
|
||||
|
||||
// ── Janela ESTENDIDA do dentista (zona cinza): a IA agenda livre em
|
||||
// [hora_inicio, hora_fim]; entre a estendida e a livre exige confirmação
|
||||
// humana. Nulo = sem zona cinza (só a janela livre).
|
||||
await pool.query(`ALTER TABLE dentistas_horarios ADD COLUMN IF NOT EXISTS hora_inicio_flex time without time zone`);
|
||||
await pool.query(`ALTER TABLE dentistas_horarios ADD COLUMN IF NOT EXISTS hora_fim_flex time without time zone`);
|
||||
|
||||
// ── agenda_pedidos — pedidos da ZONA CINZA aguardando a secretária humana.
|
||||
// A IA cria (status 'pendente') e diz "vou confirmar"; a humana confirma
|
||||
// (vira agendamento) ou recusa.
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS agenda_pedidos (
|
||||
id varchar PRIMARY KEY,
|
||||
clinica_id varchar NOT NULL,
|
||||
dentista_id varchar,
|
||||
sala_id varchar,
|
||||
data date NOT NULL,
|
||||
hora_inicio time without time zone NOT NULL,
|
||||
hora_fim time without time zone,
|
||||
paciente_nome varchar,
|
||||
paciente_telefone varchar,
|
||||
procedimento varchar,
|
||||
origem varchar DEFAULT 'secretaria_ia',
|
||||
status varchar DEFAULT 'pendente', -- pendente | confirmado | recusado
|
||||
agendamento_id varchar, -- preenchido ao confirmar
|
||||
created_at timestamptz DEFAULT now(),
|
||||
resolved_at timestamptz,
|
||||
resolved_by varchar
|
||||
)`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_agenda_pedidos_clinica_status ON agenda_pedidos (clinica_id, status, created_at)`);
|
||||
// Pedido também serve para DÚVIDAS (sem horário): tipo + resumo; data/hora nullable.
|
||||
await pool.query(`ALTER TABLE agenda_pedidos ADD COLUMN IF NOT EXISTS tipo varchar DEFAULT 'horario'`); // horario | duvida
|
||||
await pool.query(`ALTER TABLE agenda_pedidos ADD COLUMN IF NOT EXISTS resumo text`);
|
||||
await pool.query(`ALTER TABLE agenda_pedidos ALTER COLUMN data DROP NOT NULL`).catch(() => {});
|
||||
await pool.query(`ALTER TABLE agenda_pedidos ALTER COLUMN hora_inicio DROP NOT NULL`).catch(() => {});
|
||||
|
||||
// ── agenda_situacoes — "área de situações": regras da clínica que a Secretária
|
||||
// IA deve seguir (ex.: "plano X não cobre canal posterior — ofereça avaliação").
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS agenda_situacoes (
|
||||
id varchar PRIMARY KEY,
|
||||
clinica_id varchar NOT NULL,
|
||||
texto text NOT NULL,
|
||||
ativo smallint DEFAULT 1,
|
||||
ordem integer DEFAULT 0,
|
||||
created_at timestamptz DEFAULT now()
|
||||
)`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_agenda_situacoes_clinica ON agenda_situacoes (clinica_id)`);
|
||||
|
||||
// ── agenda_checklist — respostas Sim/Não do DONO sobre a clínica (ver
|
||||
// checklist-defs.js). Conferidas contra o banco → geram pendências de config
|
||||
// e deixam a Secretária cautelosa. resposta: 1=sim, 0=não, NULL=não respondido.
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS agenda_checklist (
|
||||
clinica_id varchar NOT NULL,
|
||||
chave varchar NOT NULL,
|
||||
resposta smallint,
|
||||
updated_at timestamptz DEFAULT now(),
|
||||
PRIMARY KEY (clinica_id, chave)
|
||||
)`);
|
||||
return true;
|
||||
} catch (e) {
|
||||
// Não derruba o boot do satélite se o banco estiver indisponível no momento.
|
||||
console.error('[newwhats] ensureAgendaSchema falhou:', e.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// Checklist do DONO do workspace: perguntas Sim/Não sobre a clínica que a
|
||||
// Secretária IA usa para saber quando PODE agendar sozinha e quando deve ter
|
||||
// cautela. As respostas são conferidas contra o banco (dentistas/pacientes):
|
||||
// quando a declaração do dono não bate com os dados, geramos uma PENDÊNCIA
|
||||
// para ele configurar/cadastrar — e a IA fica cautelosa (encaminha ao humano).
|
||||
//
|
||||
// Fonte única: config (UI), consistência (pendências) e injeção no cérebro
|
||||
// (cautelas) consomem esta mesma definição para nunca divergirem.
|
||||
|
||||
export const CHECKLIST_QUESTOES = [
|
||||
{
|
||||
chave: 'multi_dentista',
|
||||
pergunta: 'A clínica tem mais de um dentista?',
|
||||
ajuda: 'Se sim, a Secretária não pode assumir um único dentista nem "clínico geral" por padrão — precisa saber quem atende cada paciente.',
|
||||
},
|
||||
{
|
||||
chave: 'pacientes_cadastrados',
|
||||
pergunta: 'Os pacientes já estão cadastrados no sistema (/pacientes)?',
|
||||
ajuda: 'Mesmo que sim, o cadastro pode estar INCOMPLETO (nem todos os familiares). A Secretária confirma a identidade antes de agendar e encaminha ao humano quando não reconhece quem fala.',
|
||||
},
|
||||
{
|
||||
chave: 'telefone_compartilhado',
|
||||
pergunta: 'Um mesmo telefone pode ser de vários pacientes (família)?',
|
||||
ajuda: 'Ex.: o número está no cadastro do filho, mas quem manda mensagem é a mãe. Se sim, a Secretária SEMPRE confirma com quem está falando e para quem é o atendimento — nunca assume pelo número.',
|
||||
},
|
||||
{
|
||||
chave: 'dentista_fixo',
|
||||
pergunta: 'Cada paciente tem um dentista de referência (fixo)?',
|
||||
ajuda: 'Se sim, um paciente de retorno deve voltar para o MESMO dentista — a IA não deve agendá-lo no clínico geral.',
|
||||
},
|
||||
];
|
||||
|
||||
const sim = (r, k) => r[k] === 1;
|
||||
const nao = (r, k) => r[k] === 0;
|
||||
|
||||
// Pendências de CONFIG do dono: a declaração não bate com o banco.
|
||||
// counts = { dentistas, pacientes }.
|
||||
export function pendenciasDeConfig(respostas, counts) {
|
||||
const r = respostas || {};
|
||||
const dentistas = Number(counts?.dentistas || 0);
|
||||
const pacientes = Number(counts?.pacientes || 0);
|
||||
const P = [];
|
||||
|
||||
if (sim(r, 'multi_dentista') && dentistas <= 1)
|
||||
P.push({ chave: 'multi_dentista', severidade: 'alta', titulo: 'Cadastre os demais dentistas',
|
||||
detalhe: `Você indicou que a clínica tem mais de um dentista, mas há apenas ${dentistas} cadastrado. A Secretária não consegue direcionar cada paciente ao dentista certo — cadastre os outros dentistas.` });
|
||||
if (nao(r, 'multi_dentista') && dentistas > 1)
|
||||
P.push({ chave: 'multi_dentista', severidade: 'media', titulo: 'Confirme quantos dentistas atendem',
|
||||
detalhe: `Você indicou um único dentista, mas há ${dentistas} cadastrados no sistema. Ajuste a resposta ou os cadastros.` });
|
||||
|
||||
if (sim(r, 'pacientes_cadastrados') && pacientes === 0)
|
||||
P.push({ chave: 'pacientes_cadastrados', severidade: 'alta', titulo: 'Cadastre os pacientes',
|
||||
detalhe: 'Você indicou que os pacientes estão cadastrados, mas não há nenhum paciente no sistema. A Secretária não reconhece quem já é paciente.' });
|
||||
if (r['pacientes_cadastrados'] == null || nao(r, 'pacientes_cadastrados'))
|
||||
P.push({ chave: 'pacientes_cadastrados', severidade: 'media', titulo: 'Complete o cadastro de pacientes',
|
||||
detalhe: 'Enquanto os pacientes não estiverem cadastrados, a Secretária vai encaminhar todo retorno para a secretária humana confirmar (não agenda sozinha).' });
|
||||
|
||||
if (sim(r, 'dentista_fixo') && dentistas <= 1 && sim(r, 'multi_dentista'))
|
||||
P.push({ chave: 'dentista_fixo', severidade: 'media', titulo: 'Cadastre os dentistas de referência',
|
||||
detalhe: 'Você indicou que cada paciente tem um dentista fixo, mas o sistema ainda não tem os dentistas todos cadastrados para fazer esse vínculo.' });
|
||||
|
||||
if (sim(r, 'telefone_compartilhado'))
|
||||
P.push({ chave: 'telefone_compartilhado', severidade: 'media', titulo: 'Complete o cadastro de cada familiar',
|
||||
detalhe: 'Como um mesmo telefone pode ser de vários pacientes (ex.: cadastrado só no filho, mas quem fala é a mãe), a Secretária vai SEMPRE confirmar quem está falando e não assume pelo número. Mantenha cada familiar cadastrado e vinculado ao número para ela acertar mais e encaminhar menos ao humano.' });
|
||||
|
||||
return P;
|
||||
}
|
||||
|
||||
// Cautelas para injetar no cérebro da IA (texto direto no system prompt).
|
||||
export function cautelasParaIA(respostas, counts) {
|
||||
const r = respostas || {};
|
||||
const dentistas = Number(counts?.dentistas || 0);
|
||||
const pacientes = Number(counts?.pacientes || 0);
|
||||
const C = [];
|
||||
|
||||
const multi = sim(r, 'multi_dentista') || dentistas > 1;
|
||||
const temPacientes = pacientes > 0 || sim(r, 'pacientes_cadastrados');
|
||||
const telShared = sim(r, 'telefone_compartilhado') || (temPacientes && r['telefone_compartilhado'] == null);
|
||||
|
||||
if (multi)
|
||||
C.push('A clínica tem MAIS DE UM dentista: NUNCA assuma um dentista nem "clínico geral" por padrão para quem já é paciente. Descubra/pergunte qual dentista atende. Sem certeza → encaminhe ao humano.');
|
||||
if (sim(r, 'multi_dentista') && dentistas <= 1)
|
||||
C.push('ATENÇÃO: o sistema ainda não tem todos os dentistas cadastrados. Para pacientes de retorno ou que pedem um dentista específico, encaminhe ao humano — NÃO agende no clínico geral.');
|
||||
if (sim(r, 'dentista_fixo'))
|
||||
C.push('Cada paciente tem um dentista fixo: um retorno deve voltar ao MESMO dentista. Se você não sabe qual é (falta cadastro), encaminhe ao humano.');
|
||||
|
||||
// Identidade de quem fala — o cadastro é incompleto e o telefone é ambíguo.
|
||||
if (temPacientes)
|
||||
C.push('IDENTIDADE: o cadastro pode estar INCOMPLETO. Ao achar um paciente pelo telefone, NÃO assuma que é quem está falando — confirme o NOME de quem fala e PARA QUEM é o atendimento antes de qualquer coisa.');
|
||||
if (telShared)
|
||||
C.push('TELEFONE COMPARTILHADO: um mesmo número pode ser de vários da família (ex.: está no cadastro do filho, mas quem fala é a mãe; ou perguntam pelo tratamento do marido). SEMPRE pergunte com quem você fala e de quem é o atendimento; se a pessoa/atendimento não bater com o cadastro do número, encaminhe ao humano — não agende no paciente errado.');
|
||||
if (pacientes === 0 || nao(r, 'pacientes_cadastrados'))
|
||||
C.push('O cadastro de pacientes está incompleto: você NÃO consegue confirmar histórico nem o dentista de quem diz já ser paciente. Nesses casos, encaminhe ao humano em vez de agendar.');
|
||||
|
||||
// Profissional citado que não existe na lista de dentistas (sempre possível).
|
||||
C.push('PROFISSIONAL NÃO CADASTRADO: se o cliente citar um dentista/profissional que NÃO está na lista de dentistas acima (ex.: prótese com um Dr. de fora), NÃO invente nem agende — encaminhe ao humano com encaminhar_humano e diga NO RESUMO que o profissional citado não está cadastrado, para o dono cadastrá-lo.');
|
||||
|
||||
return C;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// Feriados nacionais brasileiros (fixos + móveis) + resolução com exceções da
|
||||
// clínica (tabela `feriados`). Usado pela agenda-bridge para a Secretária saber
|
||||
// quando é feriado e o /slots pular esses dias.
|
||||
//
|
||||
// Fixos: datas nacionais. Móveis: derivados da Páscoa (Computus de Meeus).
|
||||
// Consciência Negra (20/11) é nacional desde 2024 (Lei 14.759/2023).
|
||||
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
const ymd = (y, m, d) => `${y}-${pad(m)}-${pad(d)}`;
|
||||
|
||||
// Domingo de Páscoa (algoritmo de Meeus/Jones/Butcher) — retorna {mes, dia}.
|
||||
function pascoa(ano) {
|
||||
const a = ano % 19;
|
||||
const b = Math.floor(ano / 100);
|
||||
const c = ano % 100;
|
||||
const d = Math.floor(b / 4);
|
||||
const e = b % 4;
|
||||
const f = Math.floor((b + 8) / 25);
|
||||
const g = Math.floor((b - f + 1) / 3);
|
||||
const h = (19 * a + b - d - g + 15) % 30;
|
||||
const i = Math.floor(c / 4);
|
||||
const k = c % 4;
|
||||
const l = (32 + 2 * e + 2 * i - h - k) % 7;
|
||||
const m = Math.floor((a + 11 * h + 22 * l) / 451);
|
||||
const mes = Math.floor((h + l - 7 * m + 114) / 31);
|
||||
const dia = ((h + l - 7 * m + 114) % 31) + 1;
|
||||
return { mes, dia };
|
||||
}
|
||||
|
||||
// Soma dias a uma data (UTC, evita fuso) → 'YYYY-MM-DD'.
|
||||
function addDias(y, m, d, delta) {
|
||||
const base = new Date(Date.UTC(y, m - 1, d));
|
||||
base.setUTCDate(base.getUTCDate() + delta);
|
||||
return ymd(base.getUTCFullYear(), base.getUTCMonth() + 1, base.getUTCDate());
|
||||
}
|
||||
|
||||
// Mapa { 'YYYY-MM-DD': 'Nome do feriado' } dos feriados NACIONAIS do ano.
|
||||
export function feriadosNacionais(ano) {
|
||||
const p = pascoa(ano);
|
||||
const py = ano, pm = p.mes, pd = p.dia;
|
||||
const map = {
|
||||
[ymd(ano, 1, 1)]: 'Confraternização Universal',
|
||||
[ymd(ano, 4, 21)]: 'Tiradentes',
|
||||
[ymd(ano, 5, 1)]: 'Dia do Trabalho',
|
||||
[ymd(ano, 9, 7)]: 'Independência do Brasil',
|
||||
[ymd(ano, 10, 12)]: 'Nossa Senhora Aparecida',
|
||||
[ymd(ano, 11, 2)]: 'Finados',
|
||||
[ymd(ano, 11, 15)]: 'Proclamação da República',
|
||||
[ymd(ano, 11, 20)]: 'Consciência Negra',
|
||||
[ymd(ano, 12, 25)]: 'Natal',
|
||||
// Móveis (relativos ao Domingo de Páscoa)
|
||||
[addDias(py, pm, pd, -48)]: 'Carnaval (segunda-feira)',
|
||||
[addDias(py, pm, pd, -47)]: 'Carnaval (terça-feira)',
|
||||
[addDias(py, pm, pd, -2)]: 'Sexta-feira Santa',
|
||||
[addDias(py, pm, pd, 60)]: 'Corpus Christi',
|
||||
};
|
||||
return map;
|
||||
}
|
||||
|
||||
// Nome do feriado nacional numa data 'YYYY-MM-DD', ou null.
|
||||
export function feriadoNacionalNome(dateStr) {
|
||||
const ano = Number(String(dateStr).slice(0, 4));
|
||||
if (!ano) return null;
|
||||
return feriadosNacionais(ano)[dateStr] ?? null;
|
||||
}
|
||||
|
||||
// Resolve o feriado de uma data para uma clínica: nacional OU exceção da clínica
|
||||
// (tabela `feriados`, que também pode ANULAR um dia — ver `fecha`). Retorna
|
||||
// { fecha: bool, nome, hora_inicio?, hora_fim? } ou null (dia normal).
|
||||
//
|
||||
// Regras:
|
||||
// - Exceção da clínica tem prioridade (permite abrir num feriado nacional, ou
|
||||
// fechar num dia comum, ou definir horário especial).
|
||||
// - Sem exceção: cai no feriado nacional (fecha o dia inteiro).
|
||||
export async function resolverFeriado(pool, clinicaId, dateStr) {
|
||||
try {
|
||||
const mmdd = String(dateStr).slice(5); // 'MM-DD'
|
||||
const { rows } = await pool.query(
|
||||
`SELECT nome, fecha, hora_inicio, hora_fim FROM feriados
|
||||
WHERE clinica_id = $1
|
||||
AND (data = $2 OR (recorrente = 1 AND to_char(data,'MM-DD') = $3))
|
||||
ORDER BY (data = $2) DESC LIMIT 1`,
|
||||
[clinicaId, dateStr, mmdd]);
|
||||
if (rows.length) {
|
||||
const r = rows[0];
|
||||
return { fecha: r.fecha !== false, nome: r.nome || 'Fechamento', hora_inicio: r.hora_inicio || null, hora_fim: r.hora_fim || null, origem: 'clinica' };
|
||||
}
|
||||
} catch { /* tabela pode não existir ainda */ }
|
||||
const nac = feriadoNacionalNome(dateStr);
|
||||
if (nac) return { fecha: true, nome: nac, hora_inicio: null, hora_fim: null, origem: 'nacional' };
|
||||
return null;
|
||||
}
|
||||
+26
-10
@@ -6,7 +6,7 @@
|
||||
// registerNewwhats(app, pool, { superadminGuard })
|
||||
import { createRoutes } from './routes.js'
|
||||
import { createWebhookReceiver } from './webhook-receiver.js'
|
||||
import { createRestProxy, provisionNewwhatsAccount } from './proxy.js'
|
||||
import { createRestProxy, createAccountsRoute, provisionNewwhatsAccount } from './proxy.js'
|
||||
import { getConfig, isConfigured, loadConfigFromDb, saveConfigToDb } from './config.js'
|
||||
import { Readable } from 'node:stream'
|
||||
|
||||
@@ -24,6 +24,8 @@ export async function provisionNewwhatsAccountEager(pool, email) {
|
||||
} catch { return false }
|
||||
}
|
||||
import { createAgendaBridge } from './agenda-bridge.js'
|
||||
import { createAgendaConfig } from './agenda-config.js'
|
||||
import { ensureAgendaSchema } from './agenda-schema.js'
|
||||
|
||||
// Valida uma chave contra o motor. Envia um email-sonda (a chave mestra exige
|
||||
// x-nw-email). 200 = ok com dados; 404 = chave válida mas email-sonda inexistente
|
||||
@@ -48,26 +50,39 @@ export function registerNewwhats(app, pool, opts = {}) {
|
||||
// Auth própria por segredo compartilhado (x-nw-agenda-secret). Server-to-server.
|
||||
app.use('/api/nw/agenda', createAgendaBridge(pool));
|
||||
|
||||
// ── Proxy de MÍDIA: /api/nw/media/* → motor /media/* ───────────────────────
|
||||
// A mídia do WhatsApp vive no motor (servida do Wasabi/local, pública). O
|
||||
// satélite a repassa mantendo same-origin no <img>/<video> (sem exigir header
|
||||
// de auth no browser), com suporte a Range para áudio/vídeo.
|
||||
// ── Schema de horários/feriados (idempotente) + config (dono/dentista) ─────
|
||||
// clinicas_horarios + feriados; usados pela agenda-bridge (Secretária) e pelas
|
||||
// telas de configuração do scoreodonto.
|
||||
ensureAgendaSchema(pool).catch((e) => console.error('[newwhats] schema agenda:', e?.message));
|
||||
app.use('/api/nw/agenda-config', createAgendaConfig(pool));
|
||||
|
||||
// ── Proxy de MÍDIA: /api/nw/media/* → motor ────────────────────────────────
|
||||
// A mídia/avatar vivem no motor (Wasabi ou local). O Wasabi é servido por
|
||||
// /api/storage/view/* (plugin UnifiedStorageProvider, que cobre Wasabi + fallback
|
||||
// local); a mídia local ANTIGA fica no /media estático. Tentamos o storage do
|
||||
// plugin primeiro e caímos no /media se der 404. Same-origin no <img>/<video>.
|
||||
app.use('/api/nw/media', async (req, res) => {
|
||||
if (req.method !== 'GET') { res.status(405).end(); return; }
|
||||
const cfg = getConfig();
|
||||
if (!cfg.motorUrl) { res.status(503).end(); return; }
|
||||
const rest = req.path.replace(/^\//, '');
|
||||
const url = `${cfg.motorUrl.replace(/\/$/, '')}/media/${rest}`;
|
||||
const base = cfg.motorUrl.replace(/\/$/, '');
|
||||
const headers = {};
|
||||
if (req.headers.range) headers.range = req.headers.range;
|
||||
const candidates = [`${base}/api/storage/view/${rest}`, `${base}/media/${rest}`];
|
||||
try {
|
||||
const headers = {};
|
||||
if (req.headers.range) headers.range = req.headers.range;
|
||||
const r = await fetch(url, { headers });
|
||||
let r = null;
|
||||
for (const url of candidates) {
|
||||
r = await fetch(url, { headers });
|
||||
if (r.status !== 404) break; // achou (ou erro que não é 404) → usa esse
|
||||
}
|
||||
res.status(r.status);
|
||||
for (const h of ['content-type', 'content-length', 'content-range', 'accept-ranges', 'cache-control', 'content-disposition']) {
|
||||
const v = r.headers.get(h);
|
||||
if (v) res.setHeader(h, v);
|
||||
}
|
||||
if (!res.getHeader('cache-control')) res.setHeader('cache-control', 'private, max-age=3600');
|
||||
// Mídia é imutável (path único por arquivo) → cache longo no browser.
|
||||
if (!res.getHeader('cache-control')) res.setHeader('cache-control', 'public, max-age=604800, immutable');
|
||||
if (r.body) Readable.fromWeb(r.body).pipe(res);
|
||||
else res.end();
|
||||
} catch (e) {
|
||||
@@ -160,6 +175,7 @@ export function registerNewwhats(app, pool, opts = {}) {
|
||||
// ── Operação ────────────────────────────────────────────────────────────────
|
||||
app.use('/nw', createRoutes(pool)); // Secretária do motor consulta a clínica
|
||||
app.use(createWebhookReceiver(pool)); // webhook motor → satélite
|
||||
app.get('/api/nw/accounts', createAccountsRoute(pool)); // seletor multi-conta da inbox (próprias + liberadas) — ANTES do catch-all
|
||||
app.use('/api/nw/v1', createRestProxy(pool)); // proxy do inbox → motor (express 5: use, não wildcard) + enforcement de ownership
|
||||
|
||||
console.log('[newwhats] integração registrada (/nw, /api/webhook/newwhats, /api/nw/v1/*, /api/nw/config)');
|
||||
|
||||
+171
-18
@@ -39,6 +39,18 @@ async function resolveOwnerId(pool, clinicaId) {
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// Resolve o dono do workspace COM e-mail. Retorna { ownerId, email } ou null.
|
||||
// Usado para a inbox compartilhada: a caixa da clínica vive na conta (tenant) do
|
||||
// dono no motor, então falamos com o motor usando o e-mail DELE.
|
||||
async function resolveOwner(pool, clinicaId) {
|
||||
const ownerId = await resolveOwnerId(pool, clinicaId);
|
||||
if (!ownerId) return null;
|
||||
try {
|
||||
const { rows } = await pool.query('SELECT lower(email) AS email FROM usuarios WHERE id = $1', [ownerId]);
|
||||
return { ownerId, email: rows[0]?.email || null };
|
||||
} catch { return { ownerId, email: null }; }
|
||||
}
|
||||
|
||||
// Autoriza (ou nega 403) uma ação sensível de sessão de WhatsApp.
|
||||
// Mapeamento das ações do motor:
|
||||
// POST /sessions → scan INICIAL (criar) → só o dono
|
||||
@@ -115,7 +127,7 @@ export async function provisionNewwhatsAccount(pool, cfg, email) {
|
||||
// Monta a assinatura do operador para prefixar mensagens: primeiro nome; se houver
|
||||
// outro membro do workspace com o MESMO primeiro nome, desambigua com "Nome I." (inicial
|
||||
// do primeiro sobrenome). Retorna null se não houver nome.
|
||||
async function operatorSignature(pool, userId, clinicaId) {
|
||||
export async function operatorSignature(pool, userId, clinicaId) {
|
||||
if (!pool || !userId) return null;
|
||||
try {
|
||||
const { rows } = await pool.query('SELECT nome FROM usuarios WHERE id = $1', [userId]);
|
||||
@@ -160,8 +172,9 @@ async function guardAreaAccess(pool, tail, userId, clinicaId) {
|
||||
// Só o dono ou quem tiver can_delete_msg. (Complementa o gate de acesso ao inbox.)
|
||||
async function guardInboxDestructive(pool, method, tail, userId, clinicaId) {
|
||||
const isDeleteChat = method === 'DELETE' && /^\/inbox\/[^/]+\/?(\?|$)/.test(tail);
|
||||
const isClearMsgs = method === 'DELETE' && /^\/inbox\/[^/]+\/messages\/?(\?|$)/.test(tail); // limpar conversa (todas as msgs)
|
||||
const isDeleteMsg = method === 'DELETE' && /^\/inbox\/[^/]+\/messages\/[^/]+/.test(tail);
|
||||
if (!isDeleteChat && !isDeleteMsg) return { ok: true };
|
||||
if (!isDeleteChat && !isClearMsgs && !isDeleteMsg) return { ok: true };
|
||||
if (!pool || !clinicaId) return { ok: true };
|
||||
const ownerId = await resolveOwnerId(pool, clinicaId);
|
||||
if (ownerId && ownerId === userId) return { ok: true };
|
||||
@@ -199,23 +212,47 @@ export function createRestProxy(pool) {
|
||||
const destructive = await guardInboxDestructive(pool, req.method, tail, userId, clinicaId);
|
||||
if (!destructive.ok) return res.status(destructive.status).json({ error: destructive.error });
|
||||
|
||||
// Assinatura do operador: prefixa o texto enviado com "*Nome*\n" (identifica
|
||||
// quem respondeu numa caixa compartilhada). Só no envio de texto por humano.
|
||||
// Assinatura do operador: prefixa o texto enviado com "*Nome:*\n" (identifica quem
|
||||
// respondeu numa caixa compartilhada). O ":" fica DENTRO do negrito para que TODOS
|
||||
// os dispositivos (contato/outros operadores) e jids vejam "Nome:" em negrito
|
||||
// consistente — não só a nossa UI. Só no envio de texto por humano.
|
||||
if (req.method === 'POST' && /^\/inbox\/[^/]+\/send$/.test(tail) && req.body && typeof req.body.text === 'string' && req.body.text.trim()) {
|
||||
const sig = await operatorSignature(pool, userId, clinicaId);
|
||||
if (sig) req.body.text = `*${sig}*\n${req.body.text}`;
|
||||
if (sig) req.body.text = `*${sig}:*\n${req.body.text}`;
|
||||
}
|
||||
|
||||
// Conta-alvo no motor. Por padrão é a do próprio usuário (e-mail do JWT). Mas a
|
||||
// inbox da clínica vive na conta (tenant) do DONO — então, nas rotas de inbox,
|
||||
// se o usuário NÃO é dono do workspace e chegou até aqui (guardAreaAccess já
|
||||
// exigiu can_inbox), falamos com o motor como o dono. Assim, logado como ele
|
||||
// mesmo, o usuário vê a caixa do número da clínica no seletor — sem trocar de
|
||||
// login. Só a inbox comuta a conta; demais rotas seguem com o e-mail próprio.
|
||||
let targetEmail = email;
|
||||
if (/^\/(inbox|conversations)(\/|$|\?)/.test(tail) && clinicaId) {
|
||||
const owner = await resolveOwner(pool, clinicaId);
|
||||
if (owner && owner.ownerId !== userId && owner.email) targetEmail = owner.email;
|
||||
}
|
||||
|
||||
const url = `${cfg.motorUrl}/api/ext/v1${tail}`;
|
||||
|
||||
// Upload de arquivo grande: o corpo é multipart e NÃO passa pelo body-parser JSON
|
||||
// (o stream chega intacto). Encaminhamos o stream cru ao motor, preservando o
|
||||
// Content-Type (com boundary) — sem base64, sem limite de JSON.
|
||||
const isMultipart = /multipart\/form-data/i.test(req.headers['content-type'] || '');
|
||||
|
||||
const opts = { method: req.method, headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-nw-key': cfg.integrationKey,
|
||||
'x-nw-email': email,
|
||||
'x-nw-email': targetEmail,
|
||||
// clínica do workspace ativo (modelo de canal) — repassa ao motor se veio.
|
||||
...(req.headers['x-nw-clinica'] ? { 'x-nw-clinica': String(req.headers['x-nw-clinica']) } : {}),
|
||||
...(isMultipart
|
||||
? { 'content-type': req.headers['content-type'] }
|
||||
: { 'Content-Type': 'application/json' }),
|
||||
} };
|
||||
if (!['GET', 'HEAD'].includes(req.method) && req.body && Object.keys(req.body).length) {
|
||||
if (isMultipart) {
|
||||
opts.body = req; // stream cru do upload → motor
|
||||
opts.duplex = 'half'; // exigido pelo fetch (undici) ao enviar stream
|
||||
} else if (!['GET', 'HEAD'].includes(req.method) && req.body && Object.keys(req.body).length) {
|
||||
opts.body = JSON.stringify(req.body);
|
||||
}
|
||||
|
||||
@@ -224,8 +261,8 @@ export function createRestProxy(pool) {
|
||||
let text = await r.text();
|
||||
// Self-heal: a conta ainda não foi espelhada no motor. Provisiona (idempotente)
|
||||
// e refaz o request UMA vez — assim "quando a pessoa for mexer, tudo funciona".
|
||||
if (r.status === 404 && /conta n[ãa]o encontrada/i.test(text) && email) {
|
||||
const ok = await provisionNewwhatsAccount(pool, cfg, email);
|
||||
if (r.status === 404 && /conta n[ãa]o encontrada/i.test(text) && targetEmail) {
|
||||
const ok = await provisionNewwhatsAccount(pool, cfg, targetEmail);
|
||||
if (ok) { r = await fetch(url, opts); text = await r.text(); }
|
||||
}
|
||||
res.status(r.status);
|
||||
@@ -236,6 +273,99 @@ export function createRestProxy(pool) {
|
||||
};
|
||||
}
|
||||
|
||||
// ── GET /api/nw/accounts — contas de WhatsApp que o usuário logado pode operar ──
|
||||
//
|
||||
// Alimenta o seletor de número da inbox (multi-conta, SEM trocar de login) e o
|
||||
// ícone (i) de papéis. Junta: (1) as sessões da PRÓPRIA conta do usuário no motor
|
||||
// e (2) as sessões LIBERADAS pelo dono (can_inbox por sessão em wa_session_authz),
|
||||
// buscadas no motor com o e-mail do dono. Cada item traz o papel (sec_numbers).
|
||||
export function createAccountsRoute(pool) {
|
||||
return async function accountsRoute(req, res) {
|
||||
const cfg = getConfig();
|
||||
if (!cfg.motorUrl || !cfg.integrationKey) {
|
||||
return res.status(503).json({ error: 'Integração NewWhats não configurada.' });
|
||||
}
|
||||
const auth = authFromReq(req);
|
||||
if (!auth) return res.status(401).json({ error: 'Sessão inválida (faça login no scoreodonto).' });
|
||||
const { email, userId } = auth;
|
||||
|
||||
const motorGet = async (path, asEmail) => {
|
||||
try {
|
||||
const r = await fetch(`${cfg.motorUrl}/api/ext/v1${path}`, {
|
||||
headers: { 'x-nw-key': cfg.integrationKey, 'x-nw-email': asEmail },
|
||||
});
|
||||
if (!r.ok) return [];
|
||||
const j = await r.json();
|
||||
return Array.isArray(j) ? j : [];
|
||||
} catch { return []; }
|
||||
};
|
||||
|
||||
try {
|
||||
// Papéis (sec_numbers) são resolvidos no motor pela CONTA (x-nw-email). Um
|
||||
// usuário sem conta no motor (ex.: recepcionista que só existe no scoreodonto)
|
||||
// não tem números próprios — então o papel de cada número LIBERADO precisa ser
|
||||
// buscado com o e-mail do DONO da sessão, não do usuário logado. Cache por e-mail.
|
||||
const numbersCache = new Map();
|
||||
const numbersFor = async (asEmail) => {
|
||||
if (!numbersCache.has(asEmail)) {
|
||||
const nums = await motorGet('/secretaria/numbers', asEmail);
|
||||
numbersCache.set(asEmail, new Map(nums.map((n) => [n.instance_id, n])));
|
||||
}
|
||||
return numbersCache.get(asEmail);
|
||||
};
|
||||
const metaFrom = (roleMap, instanceId, fallbackName) => {
|
||||
const n = roleMap.get(instanceId) || {};
|
||||
return { role: n.role || null, label: n.label || fallbackName || null, area: n.area || null, notes: n.notes || null };
|
||||
};
|
||||
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
const pushWith = (s, roleMap, extra) => {
|
||||
if (!s || seen.has(s.id)) return;
|
||||
seen.add(s.id);
|
||||
out.push({
|
||||
instanceId: s.id, phone: s.phone || null, name: s.name || null,
|
||||
status: s.status || null, avatar: s.avatar || null,
|
||||
...metaFrom(roleMap, s.id, s.name), ...extra,
|
||||
});
|
||||
};
|
||||
|
||||
// 1) Sessões PRÓPRIAS (conta do próprio usuário no motor) — papel da própria conta.
|
||||
const ownRoles = await numbersFor(email);
|
||||
for (const s of await motorGet('/sessions', email)) {
|
||||
pushWith(s, ownRoles, { clinicaId: null, ownerEmail: email, ownerNome: null, own: true, canInbox: true });
|
||||
}
|
||||
|
||||
// 2) Sessões LIBERADAS por outros donos (can_inbox por sessão).
|
||||
const { rows: grants } = await pool.query(
|
||||
`SELECT DISTINCT clinica_id, instance_id FROM wa_session_authz
|
||||
WHERE usuario_id = $1 AND can_inbox = true`, [userId]);
|
||||
const byClinica = new Map();
|
||||
for (const g of grants) {
|
||||
if (!byClinica.has(g.clinica_id)) byClinica.set(g.clinica_id, new Set());
|
||||
byClinica.get(g.clinica_id).add(g.instance_id);
|
||||
}
|
||||
for (const [clinicaId, allowed] of byClinica) {
|
||||
const owner = await resolveOwner(pool, clinicaId);
|
||||
if (!owner || !owner.email || owner.ownerId === userId) continue; // dono próprio já veio em (1)
|
||||
let ownerNome = null;
|
||||
try {
|
||||
const { rows } = await pool.query('SELECT nome FROM usuarios WHERE id = $1', [owner.ownerId]);
|
||||
ownerNome = rows[0]?.nome || null;
|
||||
} catch { /* opcional */ }
|
||||
const ownerRoles = await numbersFor(owner.email); // papéis dos números vêm da conta do DONO
|
||||
for (const s of await motorGet('/sessions', owner.email)) {
|
||||
if (allowed.has(s.id)) pushWith(s, ownerRoles, { clinicaId, ownerEmail: owner.email, ownerNome, own: false, canInbox: true });
|
||||
}
|
||||
}
|
||||
|
||||
res.json(out);
|
||||
} catch (e) {
|
||||
res.status(502).json({ error: `Falha ao montar contas: ${e.message}` });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ── Túnel WebSocket: /api/nw/v1/stream (browser) → motor /api/ext/v1/stream ──
|
||||
//
|
||||
// O inbox em tempo real do motor (QR, status da sessão, mensagens novas) roda
|
||||
@@ -249,24 +379,47 @@ export function createRestProxy(pool) {
|
||||
// Motor: autentica o /api/ext/v1/stream pela x-nw-key + x-nw-email (a chave é a
|
||||
// mestra; o tenant é resolvido pelo email do usuário) e exige o path EXATO, sem
|
||||
// query — por isso reescrevemos um request-line limpo. O email vem do JWT.
|
||||
export function createWsTunnel() {
|
||||
return function wsTunnel(req, clientSocket, head) {
|
||||
//
|
||||
// Conta-alvo do stream: por padrão a própria. Se o browser mandar ?clinica= (a
|
||||
// conta ativa do seletor) e o usuário não for o dono mas tiver can_inbox nela,
|
||||
// o stream passa a ser o do DONO — assim a caixa compartilhada recebe mensagens
|
||||
// novas em tempo real. Mesma regra do proxy REST (reescrita de x-nw-email).
|
||||
export function createWsTunnel(pool) {
|
||||
return async function wsTunnel(req, clientSocket, head) {
|
||||
const fail = (statusLine) => {
|
||||
try { clientSocket.write(`HTTP/1.1 ${statusLine}\r\n\r\n`); } catch { /* socket já morto */ }
|
||||
clientSocket.destroy();
|
||||
};
|
||||
|
||||
// Auth: JWT do scoreodonto no query string (?token=).
|
||||
let token;
|
||||
try { token = new URL(req.url, 'http://localhost').searchParams.get('token'); }
|
||||
catch { return fail('400 Bad Request'); }
|
||||
let email;
|
||||
// Auth: JWT do scoreodonto no query string (?token=); conta ativa em ?clinica=.
|
||||
let token, clinica;
|
||||
try {
|
||||
const u = new URL(req.url, 'http://localhost');
|
||||
token = u.searchParams.get('token');
|
||||
clinica = u.searchParams.get('clinica');
|
||||
} catch { return fail('400 Bad Request'); }
|
||||
let email, userId;
|
||||
try {
|
||||
const p = jwt.verify(token, process.env.JWT_SECRET);
|
||||
email = (p.email || '').toLowerCase();
|
||||
userId = p.userId || null;
|
||||
} catch { return fail('401 Unauthorized'); }
|
||||
if (!email) return fail('401 Unauthorized');
|
||||
|
||||
// Reescrita da conta-alvo p/ a caixa compartilhada (só leitura via can_inbox).
|
||||
let targetEmail = email;
|
||||
if (clinica && pool && userId) {
|
||||
try {
|
||||
const owner = await resolveOwner(pool, clinica);
|
||||
if (owner && owner.email && owner.ownerId !== userId) {
|
||||
const { rows } = await pool.query(
|
||||
'SELECT 1 FROM wa_session_authz WHERE clinica_id = $1 AND usuario_id = $2 AND can_inbox = true LIMIT 1',
|
||||
[clinica, userId]);
|
||||
if (rows.length) targetEmail = owner.email;
|
||||
}
|
||||
} catch { /* mantém a própria conta */ }
|
||||
}
|
||||
|
||||
const cfg = getConfig();
|
||||
if (!cfg.motorUrl || !cfg.integrationKey) return fail('503 Service Unavailable');
|
||||
|
||||
@@ -289,7 +442,7 @@ export function createWsTunnel() {
|
||||
`${req.method} /api/ext/v1/stream HTTP/1.1\r\n` +
|
||||
`Host: ${host}\r\n` +
|
||||
`x-nw-key: ${cfg.integrationKey}\r\n` +
|
||||
`x-nw-email: ${email}\r\n` +
|
||||
`x-nw-email: ${targetEmail}\r\n` +
|
||||
`${headers}\r\n\r\n`
|
||||
);
|
||||
if (head && head.length) proxySocket.write(head);
|
||||
|
||||
+88
-29
@@ -12,6 +12,7 @@ import path from 'path';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import crypto from 'crypto';
|
||||
import { registerNewwhats, createWsTunnel, provisionNewwhatsAccountEager } from './newwhats/index.js';
|
||||
import { operatorSignature } from './newwhats/proxy.js';
|
||||
|
||||
const { Pool } = pg;
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'scoreodonto_secret_key_2026';
|
||||
@@ -165,6 +166,9 @@ try {
|
||||
// EXPRESS
|
||||
// =============================================================================
|
||||
const app = express();
|
||||
// Envio de mídia pelo plugin WhatsApp vai em base64 (JSON) — precisa de mais folga
|
||||
// que o limite global. Este parser roda ANTES do global (2mb) só para /api/nw/v1.
|
||||
app.use('/api/nw/v1', express.json({ limit: '12mb' }));
|
||||
app.use(express.json({ limit: '2mb' }));
|
||||
app.use(cors({
|
||||
origin: process.env.CORS_ORIGIN || '*',
|
||||
@@ -839,9 +843,12 @@ app.get('/api/nw/access', tenantGuard, async (req, res) => {
|
||||
const clinicaId = req.clinicaId;
|
||||
const meId = req.authUser.userId;
|
||||
if (!clinicaId) return res.status(400).json({ error: 'clinicaId é obrigatório.' });
|
||||
// Assinatura do operador (mesma do proxy) — o frontend usa para exibir "Nome:" na
|
||||
// mensagem otimista NA HORA, sem esperar o eco do servidor (evita o flicker).
|
||||
const signature = await operatorSignature(pool, meId, clinicaId);
|
||||
const owner = await resolveWorkspaceOwner(clinicaId);
|
||||
if (owner && owner.ownerId === meId) {
|
||||
return res.json({ isOwner: true, inbox: true, secretaria: true, delete_msg: true });
|
||||
return res.json({ isOwner: true, inbox: true, secretaria: true, delete_msg: true, signature });
|
||||
}
|
||||
const { rows } = await pool.query(
|
||||
`SELECT bool_or(can_inbox) AS inbox, bool_or(can_secretaria) AS secretaria, bool_or(can_delete_msg) AS delete_msg
|
||||
@@ -851,6 +858,7 @@ app.get('/api/nw/access', tenantGuard, async (req, res) => {
|
||||
inbox: !!rows[0]?.inbox,
|
||||
secretaria: !!rows[0]?.secretaria,
|
||||
delete_msg: !!rows[0]?.delete_msg,
|
||||
signature,
|
||||
});
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
@@ -1919,7 +1927,25 @@ app.get('/api/dashboard/stats', tenantGuard, async (req, res) => {
|
||||
try {
|
||||
const clinicaId = req.clinicaId;
|
||||
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) {
|
||||
const cachedStats = await redis.get(cacheKey);
|
||||
if (cachedStats) {
|
||||
@@ -1929,43 +1955,55 @@ app.get('/api/dashboard/stats', tenantGuard, async (req, res) => {
|
||||
}
|
||||
|
||||
const { rows: pacientes } = await pool.query('SELECT COUNT(*) as count FROM pacientes WHERE clinica_id = $1', [clinicaId]);
|
||||
const { rows: 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: 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)
|
||||
const { rows: growth } = 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]);
|
||||
// Filtro de agenda: dentista → só os próprios agendamentos.
|
||||
const agFilter = (col, params) => {
|
||||
let w = `${col === 'a' ? 'a.' : ''}clinica_id = $1`;
|
||||
if (isDentista) {
|
||||
if (!dentistaIds.length) return { where: w + ' AND false', params };
|
||||
params.push(dentistaIds);
|
||||
w += ` AND ${col === 'a' ? 'a.' : ''}dentistaid = ANY($${params.length})`;
|
||||
}
|
||||
return { where: w, params };
|
||||
};
|
||||
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(`
|
||||
SELECT a.id, a.pacientenome, a.start_time, a.status,
|
||||
d.nome as "dentistaNome"
|
||||
FROM agendamentos a
|
||||
LEFT JOIN dentistas d ON a.dentistaid = d.id
|
||||
WHERE a.clinica_id = $1
|
||||
ORDER BY a.start_time DESC
|
||||
LIMIT 5
|
||||
`, [clinicaId]);
|
||||
SELECT a.id, a.pacientenome, a.start_time, a.status, d.nome as "dentistaNome"
|
||||
FROM agendamentos a LEFT JOIN dentistas d ON a.dentistaid = d.id
|
||||
WHERE ${rec.where}
|
||||
ORDER BY a.start_time DESC LIMIT 5`, rec.params);
|
||||
|
||||
const responseData = {
|
||||
stats: {
|
||||
totalPacientes: parseInt(pacientes[0].count),
|
||||
totalAgendamentos: parseInt(agendamentos[0].count),
|
||||
totalLeads: parseInt(leads[0].count),
|
||||
faturamentoTotal: parseFloat(faturamento[0].total),
|
||||
despesasTotal: parseFloat(pendente[0].total)
|
||||
faturamentoTotal,
|
||||
despesasTotal
|
||||
},
|
||||
growth: growth,
|
||||
growth,
|
||||
recentAgendamentos: recent
|
||||
};
|
||||
|
||||
@@ -9400,6 +9438,27 @@ app.get('/api/superadmin/login-log', superadminGuard, async (req, res) => {
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// 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)
|
||||
// aparecem na tabela mesmo p/ configs gravadas antes delas existirem.
|
||||
const PRICING_DEFAULTS = {
|
||||
@@ -10125,7 +10184,7 @@ const wss = new WebSocketServer({ noServer: true });
|
||||
// Dispatcher único de upgrade WS: roteia por path. O `ws` em modo {server,path}
|
||||
// aborta com 400 qualquer upgrade de outro path, então centralizamos aqui para o
|
||||
// /api/ws (app) coexistir com o túnel do NewWhats (/api/nw/v1/stream → motor).
|
||||
const nwWsTunnel = createWsTunnel();
|
||||
const nwWsTunnel = createWsTunnel(pool);
|
||||
httpServer.on('upgrade', (req, socket, head) => {
|
||||
let pathname;
|
||||
try { pathname = new URL(req.url, 'http://localhost').pathname; }
|
||||
|
||||
@@ -35,6 +35,7 @@ services:
|
||||
- "./backend/.env"
|
||||
environment:
|
||||
- BAILEYS_ENGINE=infinite
|
||||
- TZ=America/Sao_Paulo
|
||||
- PORT=8018
|
||||
- NODE_ENV=production
|
||||
# Senha do banco vem do ambiente (.env raiz em DEV; provisionado fora do git em PROD).
|
||||
|
||||
+25
-3
@@ -121,6 +121,7 @@ export type ViewKey =
|
||||
| 'superadmin-logs'
|
||||
| 'superadmin-financeiro'
|
||||
| 'superadmin-notificacoes'
|
||||
| 'superadmin-secretaria'
|
||||
| 'diretorio-profissionais';
|
||||
|
||||
// ------- URL <-> VIEW mapping -------
|
||||
@@ -181,6 +182,7 @@ const ROUTE_MAP: Record<string, ViewKey> = {
|
||||
'/superadmin/logs': 'superadmin-logs',
|
||||
'/superadmin/financeiro': 'superadmin-financeiro',
|
||||
'/superadmin/notificacoes': 'superadmin-notificacoes',
|
||||
'/superadmin/secretaria': 'superadmin-secretaria',
|
||||
'/aguardando-convite': 'aguardando-convite',
|
||||
'/diretorio-profissionais': 'diretorio-profissionais',
|
||||
'/proteses': 'proteses',
|
||||
@@ -248,6 +250,7 @@ const VIEW_TO_ROUTE: Record<ViewKey, string> = {
|
||||
'superadmin-logs': '/superadmin/logs',
|
||||
'superadmin-financeiro': '/superadmin/financeiro',
|
||||
'superadmin-notificacoes': '/superadmin/notificacoes',
|
||||
'superadmin-secretaria': '/superadmin/secretaria',
|
||||
'diretorio-profissionais': '/diretorio-profissionais',
|
||||
proteses: '/proteses',
|
||||
};
|
||||
@@ -315,6 +318,13 @@ const MobileBottomNav: React.FC<{
|
||||
const App: React.FC = () => {
|
||||
const ver = useAppVersion();
|
||||
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).
|
||||
const [isSidebarCollapsed, setSidebarCollapsed] = useState<boolean>(() => {
|
||||
try { return localStorage.getItem('scoreodonto:sidebar-collapsed') === '1'; } catch { return false; }
|
||||
@@ -508,7 +518,7 @@ const App: React.FC = () => {
|
||||
case 'leads': return <LeadsView />;
|
||||
case 'public': return <PublicContactForm />;
|
||||
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 'financeiro': return <FinanceiroView />;
|
||||
case 'dentistas': return <DentistasView />;
|
||||
@@ -561,6 +571,7 @@ const App: React.FC = () => {
|
||||
case 'superadmin-logs': return <SuperAdminView tab="logs" />;
|
||||
case 'superadmin-financeiro': return <SuperAdminView tab="financeiro" />;
|
||||
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).
|
||||
case 'diretorio-profissionais': return isPluginActive('profissionais-marketplace') ? <ProfissionaisPlugin /> : <DiretorioProfissionaisView />;
|
||||
case 'login':
|
||||
@@ -599,7 +610,7 @@ const App: React.FC = () => {
|
||||
></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
|
||||
activeTab={currentView}
|
||||
setActiveTab={(t) => handleNavigate(t as ViewKey)}
|
||||
@@ -652,7 +663,18 @@ const App: React.FC = () => {
|
||||
</div>
|
||||
<span className="font-bold text-gray-800 text-sm tracking-tight uppercase">SCOREODONTO</span>
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { HorariosConfig } from './HorariosConfig.tsx';
|
||||
import { X, ChevronLeft, ChevronRight, Save, Mail, Calendar, Settings as GearIcon, CheckCircle2 } from 'lucide-react';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { Settings, Dentista } from '../types.ts';
|
||||
@@ -22,6 +23,7 @@ export const AgendaSettingsModal: React.FC<AgendaSettingsModalProps> = ({ isOpen
|
||||
});
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [connectedAccounts, setConnectedAccounts] = useState<any[]>([]);
|
||||
const [showHorarios, setShowHorarios] = useState(false);
|
||||
const toast = useToast();
|
||||
// Escopo: tudo (status Google, convites, settings) é da clínica/workspace ativa.
|
||||
const clinicaId = HybridBackend.getActiveWorkspace()?.id;
|
||||
@@ -84,6 +86,7 @@ export const AgendaSettingsModal: React.FC<AgendaSettingsModalProps> = ({ isOpen
|
||||
const adminAccount = connectedAccounts.find(a => a.owner_id && a.owner_id.includes('@'));
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 bg-black/60 z-[60] flex items-center justify-center backdrop-blur-md p-0">
|
||||
<div className="bg-white shadow-2xl w-full h-dvh md:w-[96vw] md:h-[98vh] rounded-none md:rounded-[2.5rem] flex flex-col overflow-hidden border border-gray-100 animate-in fade-in zoom-in-95 duration-300">
|
||||
|
||||
@@ -135,6 +138,15 @@ export const AgendaSettingsModal: React.FC<AgendaSettingsModalProps> = ({ isOpen
|
||||
/>
|
||||
<p className="text-[10px] text-gray-400 italic ml-1">* Este e-mail será usado para alertas de sistema e envio de comprovantes.</p>
|
||||
</div>
|
||||
|
||||
<button onClick={() => setShowHorarios(true)}
|
||||
className="w-full flex items-center justify-between bg-teal-50 hover:bg-teal-100 border-2 border-teal-100 rounded-2xl p-4 transition-colors group">
|
||||
<div className="text-left">
|
||||
<p className="text-sm font-black text-teal-800 uppercase tracking-tight">Horários de Funcionamento</p>
|
||||
<p className="text-[10px] font-bold text-teal-500 uppercase tracking-widest mt-0.5">Dias/horas da clínica · feriados · horários dos dentistas</p>
|
||||
</div>
|
||||
<span className="text-teal-600 font-black text-lg group-hover:translate-x-1 transition-transform">→</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -275,5 +287,7 @@ export const AgendaSettingsModal: React.FC<AgendaSettingsModalProps> = ({ isOpen
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
<HorariosConfig isOpen={showHorarios} onClose={() => setShowHorarios(false)} clinicaId={clinicaId} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
// Configuração de horários de funcionamento (clínica) + feriados/fechamentos e
|
||||
// horários dos dentistas. Consome /api/nw/agenda-config (o backend garante o
|
||||
// ownership: horário/feriado da clínica = dono; horário do dentista = ele ou dono).
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { X, Clock, CalendarOff, Plus, Trash2, Save, Loader2, User, Copy, AlertTriangle } from 'lucide-react';
|
||||
|
||||
const API = (import.meta as any).env?.VITE_API_URL || '/api';
|
||||
const BASE = `${API}/nw/agenda-config`;
|
||||
const DIAS = ['Domingo', 'Segunda', 'Terça', 'Quarta', 'Quinta', 'Sexta', 'Sábado'];
|
||||
|
||||
async function req(path: string, opts: RequestInit = {}) {
|
||||
const token = localStorage.getItem('SCOREODONTO_AUTH_TOKEN');
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
...opts,
|
||||
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(opts.headers || {}) },
|
||||
});
|
||||
const txt = await res.text();
|
||||
const data = txt ? JSON.parse(txt) : {};
|
||||
if (!res.ok) throw new Error(data.error || `Erro ${res.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
interface Faixa { dia_semana: number; hora_inicio: string; hora_fim: string; hora_inicio_flex?: string | null; hora_fim_flex?: string | null }
|
||||
interface Dentista { id: string; nome: string; usuario_id: string | null; eh_voce: boolean }
|
||||
|
||||
// Editor de grade semanal (7 dias, cada um com 0..n faixas). Reusado p/ clínica e dentista.
|
||||
const GradeSemanal: React.FC<{ faixas: Faixa[]; onChange: (f: Faixa[]) => void; disabled?: boolean; withFlex?: boolean }> = ({ faixas, onChange, disabled, withFlex }) => {
|
||||
const porDia = (d: number) => faixas.filter((f) => f.dia_semana === d);
|
||||
const setDia = (d: number, novas: Faixa[]) => onChange([...faixas.filter((f) => f.dia_semana !== d), ...novas]);
|
||||
// Copia as faixas do dia `d` para TODOS os outros dias.
|
||||
const duplicarParaTodos = (d: number) => {
|
||||
const base = porDia(d).map((f) => ({ hora_inicio: f.hora_inicio, hora_fim: f.hora_fim }));
|
||||
onChange(DIAS.flatMap((_, x) => base.map((f) => ({ dia_semana: x, ...f }))));
|
||||
};
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{DIAS.map((nome, d) => {
|
||||
const fs = porDia(d);
|
||||
return (
|
||||
<div key={d} className="flex items-start gap-3 py-2 border-b border-gray-50">
|
||||
<div className="w-24 shrink-0 pt-2 text-xs font-black text-gray-700 uppercase tracking-tight">{nome}</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
{fs.length === 0 && <span className="text-[11px] text-gray-400 italic">Fechado</span>}
|
||||
{fs.map((f, i) => {
|
||||
const upd = (patch: Partial<Faixa>) => { const n = [...fs]; n[i] = { ...f, ...patch }; setDia(d, n); };
|
||||
return (
|
||||
<div key={i} className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="time" disabled={disabled} value={f.hora_inicio} onChange={(e) => upd({ hora_inicio: e.target.value })}
|
||||
className="bg-gray-50 border-2 border-transparent focus:border-teal-600 rounded-xl px-2 py-1.5 text-sm font-bold text-gray-800 outline-none" />
|
||||
<span className="text-gray-400 text-xs font-bold">até</span>
|
||||
<input type="time" disabled={disabled} value={f.hora_fim} onChange={(e) => upd({ hora_fim: e.target.value })}
|
||||
className="bg-gray-50 border-2 border-transparent focus:border-teal-600 rounded-xl px-2 py-1.5 text-sm font-bold text-gray-800 outline-none" />
|
||||
{withFlex && <span className="text-[9px] font-black text-teal-500 uppercase tracking-wide">IA agenda livre</span>}
|
||||
{!disabled && <button onClick={() => setDia(d, fs.filter((_, j) => j !== i))} className="text-gray-300 hover:text-red-500 p-1"><Trash2 size={15} /></button>}
|
||||
</div>
|
||||
{withFlex && (
|
||||
<div className="flex items-center gap-2 flex-wrap pl-1 text-[11px] text-gray-500">
|
||||
<span className="font-bold text-amber-600">Aceito confirmar também:</span>
|
||||
<span>antes, a partir de</span>
|
||||
<input type="time" disabled={disabled} value={f.hora_inicio_flex ?? ''} onChange={(e) => upd({ hora_inicio_flex: e.target.value || null })}
|
||||
className="bg-amber-50 border-2 border-transparent focus:border-amber-500 rounded-lg px-2 py-1 text-xs font-bold text-gray-800 outline-none w-24" />
|
||||
<span>e depois, até</span>
|
||||
<input type="time" disabled={disabled} value={f.hora_fim_flex ?? ''} onChange={(e) => upd({ hora_fim_flex: e.target.value || null })}
|
||||
className="bg-amber-50 border-2 border-transparent focus:border-amber-500 rounded-lg px-2 py-1 text-xs font-bold text-gray-800 outline-none w-24" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!disabled && (
|
||||
<button onClick={() => setDia(d, [...fs, { dia_semana: d, hora_inicio: '08:00', hora_fim: '18:00' }])}
|
||||
className="text-[11px] font-black text-teal-600 hover:text-teal-800 uppercase tracking-wide flex items-center gap-1">
|
||||
<Plus size={13} /> {fs.length ? 'Outra faixa' : 'Abrir neste dia'}
|
||||
</button>
|
||||
)}
|
||||
{!disabled && fs.length > 0 && (
|
||||
<button onClick={() => duplicarParaTodos(d)} title="Aplicar este mesmo horário a todos os dias da semana"
|
||||
className="text-[10px] font-black text-gray-400 hover:text-teal-600 uppercase tracking-wide flex items-center gap-1">
|
||||
<Copy size={11} /> Copiar p/ todos os dias
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; clinicaId?: string; initialTab?: 'clinica' | 'dentistas' | 'situacoes'; soDentista?: boolean }> = ({ isOpen, onClose, clinicaId, initialTab, soDentista }) => {
|
||||
const [aba, setAba] = useState<'clinica' | 'dentistas' | 'situacoes'>(initialTab || 'clinica');
|
||||
useEffect(() => { if (isOpen && initialTab) setAba(initialTab); }, [isOpen, initialTab]);
|
||||
const [erro, setErro] = useState<string | null>(null);
|
||||
const [ok, setOk] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Clínica
|
||||
const [clinFaixas, setClinFaixas] = useState<Faixa[]>([]);
|
||||
const [feriados, setFeriados] = useState<{ excecoes: any[]; nacionais: any[] }>({ excecoes: [], nacionais: [] });
|
||||
const [novo, setNovo] = useState({ data: '', nome: '', fecha: true });
|
||||
|
||||
// Dentistas
|
||||
const [dentistas, setDentistas] = useState<Dentista[]>([]);
|
||||
const [dentSel, setDentSel] = useState<string>('');
|
||||
const [dentFaixas, setDentFaixas] = useState<Faixa[]>([]);
|
||||
const [folgas, setFolgas] = useState<any[]>([]);
|
||||
const [novaFolga, setNovaFolga] = useState({ data_inicio: '', data_fim: '', motivo: '' });
|
||||
|
||||
// Situações (regras que a Secretária IA segue)
|
||||
const [situacoes, setSituacoes] = useState<string[]>([]);
|
||||
|
||||
const flash = (setter: (v: string | null) => void, msg: string) => { setter(msg); setTimeout(() => setter(null), 3000); };
|
||||
|
||||
const carregarClinica = useCallback(async () => {
|
||||
if (!clinicaId) return;
|
||||
try {
|
||||
const h = await req(`/clinica/${clinicaId}/horarios`);
|
||||
setClinFaixas((h.horarios || []).map((r: any) => ({ dia_semana: r.dia_semana, hora_inicio: r.hora_inicio, hora_fim: r.hora_fim })));
|
||||
const f = await req(`/clinica/${clinicaId}/feriados?ano=${new Date().getFullYear()}`);
|
||||
setFeriados({ excecoes: f.excecoes || [], nacionais: f.nacionais || [] });
|
||||
} catch (e: any) { flash(setErro, e.message); }
|
||||
}, [clinicaId]);
|
||||
|
||||
const carregarDentistas = useCallback(async () => {
|
||||
if (!clinicaId) return;
|
||||
try {
|
||||
const d = await req(`/clinica/${clinicaId}/dentistas`);
|
||||
let list: Dentista[] = d.dentistas || [];
|
||||
if (soDentista) { const meus = list.filter((x) => x.eh_voce); if (meus.length) list = meus; } // dentista só vê o próprio
|
||||
setDentistas(list);
|
||||
const first = list.find((x: Dentista) => x.eh_voce) || list[0];
|
||||
if (first) setDentSel(first.id);
|
||||
} catch (e: any) { flash(setErro, e.message); }
|
||||
}, [clinicaId, soDentista]);
|
||||
|
||||
const carregarSituacoes = useCallback(async () => {
|
||||
if (!clinicaId) return;
|
||||
try { const s = await req(`/clinica/${clinicaId}/situacoes`); setSituacoes((s.situacoes || []).map((r: any) => r.texto)); }
|
||||
catch (e: any) { flash(setErro, e.message); }
|
||||
}, [clinicaId]);
|
||||
|
||||
useEffect(() => { if (isOpen) { carregarClinica(); carregarDentistas(); if (!soDentista) carregarSituacoes(); } }, [isOpen, carregarClinica, carregarDentistas, carregarSituacoes, soDentista]);
|
||||
const carregarFolgas = useCallback((id: string) => {
|
||||
req(`/dentista/${id}/folgas`).then((f) => setFolgas(f.folgas || [])).catch(() => setFolgas([]));
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!dentSel) { setDentFaixas([]); setFolgas([]); return; }
|
||||
req(`/dentista/${dentSel}/horarios`).then((h) =>
|
||||
setDentFaixas((h.horarios || []).map((r: any) => ({ dia_semana: r.dia_semana, hora_inicio: r.hora_inicio, hora_fim: r.hora_fim, hora_inicio_flex: r.hora_inicio_flex, hora_fim_flex: r.hora_fim_flex })))
|
||||
).catch((e) => flash(setErro, e.message));
|
||||
carregarFolgas(dentSel);
|
||||
}, [dentSel, carregarFolgas]);
|
||||
|
||||
const addFolga = async () => {
|
||||
if (!novaFolga.data_inicio) { flash(setErro, 'Informe a data.'); return; }
|
||||
try { await req(`/dentista/${dentSel}/folgas`, { method: 'POST', body: JSON.stringify(novaFolga) }); setNovaFolga({ data_inicio: '', data_fim: '', motivo: '' }); carregarFolgas(dentSel); flash(setOk, 'Folga adicionada!'); }
|
||||
catch (e: any) { flash(setErro, e.message); }
|
||||
};
|
||||
const delFolga = async (id: string) => {
|
||||
try { await req(`/dentista/${dentSel}/folgas/${id}`, { method: 'DELETE' }); carregarFolgas(dentSel); } catch (e: any) { flash(setErro, e.message); }
|
||||
};
|
||||
|
||||
const salvarClinica = async () => {
|
||||
setSaving(true); setErro(null);
|
||||
try { await req(`/clinica/${clinicaId}/horarios`, { method: 'PUT', body: JSON.stringify({ horarios: clinFaixas }) }); flash(setOk, 'Horário da clínica salvo!'); }
|
||||
catch (e: any) { flash(setErro, e.message); } finally { setSaving(false); }
|
||||
};
|
||||
const salvarDentista = async () => {
|
||||
setSaving(true); setErro(null);
|
||||
try { await req(`/dentista/${dentSel}/horarios`, { method: 'PUT', body: JSON.stringify({ horarios: dentFaixas }) }); flash(setOk, 'Horário do dentista salvo!'); }
|
||||
catch (e: any) { flash(setErro, e.message); } finally { setSaving(false); }
|
||||
};
|
||||
const addFeriado = async () => {
|
||||
if (!novo.data || !novo.nome) { flash(setErro, 'Informe data e nome.'); return; }
|
||||
try { await req(`/clinica/${clinicaId}/feriados`, { method: 'POST', body: JSON.stringify(novo) }); setNovo({ data: '', nome: '', fecha: true }); carregarClinica(); flash(setOk, 'Fechamento adicionado!'); }
|
||||
catch (e: any) { flash(setErro, e.message); }
|
||||
};
|
||||
const delFeriado = async (id: string) => {
|
||||
try { await req(`/clinica/${clinicaId}/feriados/${id}`, { method: 'DELETE' }); carregarClinica(); }
|
||||
catch (e: any) { flash(setErro, e.message); }
|
||||
};
|
||||
const salvarSituacoes = async () => {
|
||||
setSaving(true); setErro(null);
|
||||
try { await req(`/clinica/${clinicaId}/situacoes`, { method: 'PUT', body: JSON.stringify({ situacoes: situacoes.map((s) => s.trim()).filter(Boolean) }) }); flash(setOk, 'Situações salvas!'); }
|
||||
catch (e: any) { flash(setErro, e.message); } finally { setSaving(false); }
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 z-[70] flex items-center justify-center backdrop-blur-md p-0">
|
||||
<div className="bg-white shadow-2xl w-full h-dvh md:w-[92vw] md:max-w-4xl md:h-[94vh] rounded-none md:rounded-[2rem] flex flex-col overflow-hidden border border-gray-100">
|
||||
{/* Header */}
|
||||
<div className="px-8 py-6 border-b border-gray-50 flex justify-between items-center bg-gradient-to-br from-gray-50 to-white">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 bg-teal-600 rounded-2xl shadow-lg shadow-teal-200"><Clock size={22} className="text-white" /></div>
|
||||
<div>
|
||||
<h2 className="text-lg font-black text-gray-900 uppercase tracking-tighter">Horários de Funcionamento</h2>
|
||||
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mt-0.5">A Secretária IA usa isto para atender</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-xl text-gray-400"><X size={22} /></button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 px-8 pt-4 border-b border-gray-50">
|
||||
{(soDentista ? (['dentistas'] as const) : (['clinica', 'dentistas', 'situacoes'] as const)).map((t) => (
|
||||
<button key={t} onClick={() => setAba(t)}
|
||||
className={`px-4 py-2.5 text-xs font-black uppercase tracking-wide rounded-t-xl transition-colors ${aba === t ? 'bg-teal-600 text-white' : 'text-gray-400 hover:text-gray-600'}`}>
|
||||
{t === 'clinica' ? 'Clínica' : t === 'dentistas' ? 'Dentistas' : 'Situações'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{(erro || ok) && (
|
||||
<div className={`px-8 py-2 text-xs font-bold ${erro ? 'bg-red-50 text-red-600' : 'bg-green-50 text-green-600'}`}>{erro || ok}</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-8">
|
||||
{aba === 'clinica' ? (
|
||||
<div className="space-y-10">
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-black text-gray-800 uppercase tracking-tight flex items-center gap-2"><Clock size={16} className="text-teal-600" /> Dias e horários que a clínica funciona</h3>
|
||||
<button onClick={salvarClinica} disabled={saving} className="bg-teal-600 hover:bg-teal-700 text-white text-xs font-black uppercase tracking-wide px-4 py-2 rounded-xl flex items-center gap-2 disabled:opacity-50">
|
||||
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />} Salvar
|
||||
</button>
|
||||
</div>
|
||||
<GradeSemanal faixas={clinFaixas} onChange={setClinFaixas} />
|
||||
<p className="text-[11px] text-gray-400 italic mt-3">Sem horário configurado, assume-se seg–sex 08h–18h. Só o dono edita.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="text-sm font-black text-gray-800 uppercase tracking-tight flex items-center gap-2 mb-4"><CalendarOff size={16} className="text-teal-600" /> Feriados e fechamentos</h3>
|
||||
<div className="flex flex-wrap items-end gap-2 mb-4 bg-gray-50 p-4 rounded-2xl">
|
||||
<div><label className="text-[9px] font-black text-gray-400 uppercase block mb-1">Data</label>
|
||||
<input type="date" value={novo.data} onChange={(e) => setNovo({ ...novo, data: e.target.value })} className="bg-white border-2 border-transparent focus:border-teal-600 rounded-xl px-3 py-2 text-sm font-bold outline-none" /></div>
|
||||
<div className="flex-1 min-w-[160px]"><label className="text-[9px] font-black text-gray-400 uppercase block mb-1">Motivo</label>
|
||||
<input type="text" placeholder="Ex: Recesso, ponte" value={novo.nome} onChange={(e) => setNovo({ ...novo, nome: e.target.value })} className="w-full bg-white border-2 border-transparent focus:border-teal-600 rounded-xl px-3 py-2 text-sm font-bold outline-none" /></div>
|
||||
<button onClick={addFeriado} className="bg-gray-900 hover:bg-black text-white text-xs font-black uppercase px-4 py-2.5 rounded-xl flex items-center gap-1"><Plus size={14} /> Adicionar</button>
|
||||
</div>
|
||||
{feriados.excecoes.length > 0 && (
|
||||
<div className="space-y-1 mb-4">
|
||||
{feriados.excecoes.map((f) => (
|
||||
<div key={f.id} className="flex items-center justify-between bg-red-50 rounded-xl px-3 py-2">
|
||||
<span className="text-xs font-bold text-red-700">{f.data.split('-').reverse().join('/')} · {f.nome}</span>
|
||||
<button onClick={() => delFeriado(f.id)} className="text-red-300 hover:text-red-600"><Trash2 size={15} /></button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<details className="text-xs">
|
||||
<summary className="cursor-pointer font-black text-gray-400 uppercase tracking-wide">Feriados nacionais (automáticos)</summary>
|
||||
<div className="mt-2 grid grid-cols-2 gap-1">
|
||||
{feriados.nacionais.map((f) => <div key={f.data} className="text-[11px] text-gray-500">{f.data.split('-').reverse().join('/')} · {f.nome}</div>)}
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
</div>
|
||||
) : aba === 'dentistas' ? (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<User size={16} className="text-teal-600" />
|
||||
<select value={dentSel} onChange={(e) => setDentSel(e.target.value)} className="bg-gray-50 border-2 border-transparent focus:border-teal-600 rounded-xl px-3 py-2 text-sm font-black text-gray-800 outline-none">
|
||||
{dentistas.map((d) => <option key={d.id} value={d.id}>{d.nome}{d.eh_voce ? ' (você)' : ''}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<button onClick={salvarDentista} disabled={saving || !dentSel} className="bg-teal-600 hover:bg-teal-700 text-white text-xs font-black uppercase tracking-wide px-4 py-2 rounded-xl flex items-center gap-2 disabled:opacity-50">
|
||||
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />} Salvar
|
||||
</button>
|
||||
</div>
|
||||
{dentSel ? <GradeSemanal faixas={dentFaixas} onChange={setDentFaixas} withFlex /> : <p className="text-xs text-gray-400">Nenhum dentista.</p>}
|
||||
<p className="text-[11px] text-gray-400 italic">A IA agenda LIVRE na janela principal. Na janela "aceito confirmar" (mais cedo/mais tarde), ela não confirma sozinha — cria um pedido para a secretária humana decidir.</p>
|
||||
|
||||
{dentSel && (
|
||||
<section className="pt-4 border-t border-gray-100">
|
||||
<h3 className="text-sm font-black text-gray-800 uppercase tracking-tight flex items-center gap-2 mb-3"><CalendarOff size={16} className="text-teal-600" /> Folgas e férias (não atende nestas datas)</h3>
|
||||
<div className="flex flex-wrap items-end gap-2 mb-3 bg-gray-50 p-4 rounded-2xl">
|
||||
<div><label className="text-[9px] font-black text-gray-400 uppercase block mb-1">De</label>
|
||||
<input type="date" value={novaFolga.data_inicio} onChange={(e) => setNovaFolga({ ...novaFolga, data_inicio: e.target.value })} className="bg-white border-2 border-transparent focus:border-teal-600 rounded-xl px-3 py-2 text-sm font-bold outline-none" /></div>
|
||||
<div><label className="text-[9px] font-black text-gray-400 uppercase block mb-1">Até (opcional)</label>
|
||||
<input type="date" value={novaFolga.data_fim} onChange={(e) => setNovaFolga({ ...novaFolga, data_fim: e.target.value })} className="bg-white border-2 border-transparent focus:border-teal-600 rounded-xl px-3 py-2 text-sm font-bold outline-none" /></div>
|
||||
<div className="flex-1 min-w-[140px]"><label className="text-[9px] font-black text-gray-400 uppercase block mb-1">Motivo</label>
|
||||
<input type="text" placeholder="Ex: Férias, congresso" value={novaFolga.motivo} onChange={(e) => setNovaFolga({ ...novaFolga, motivo: e.target.value })} className="w-full bg-white border-2 border-transparent focus:border-teal-600 rounded-xl px-3 py-2 text-sm font-bold outline-none" /></div>
|
||||
<button onClick={addFolga} className="bg-gray-900 hover:bg-black text-white text-xs font-black uppercase px-4 py-2.5 rounded-xl flex items-center gap-1"><Plus size={14} /> Adicionar</button>
|
||||
</div>
|
||||
{folgas.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{folgas.map((f) => (
|
||||
<div key={f.id} className="flex items-center justify-between bg-amber-50 rounded-xl px-3 py-2">
|
||||
<span className="text-xs font-bold text-amber-700">
|
||||
{f.data_inicio.split('-').reverse().join('/')}{f.data_fim !== f.data_inicio ? ` até ${f.data_fim.split('-').reverse().join('/')}` : ''}{f.motivo ? ` · ${f.motivo}` : ''}
|
||||
</span>
|
||||
<button onClick={() => delFolga(f.id)} className="text-amber-300 hover:text-amber-600"><Trash2 size={15} /></button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : <p className="text-[11px] text-gray-400 italic">Nenhuma folga futura cadastrada.</p>}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-black text-gray-800 uppercase tracking-tight flex items-center gap-2"><AlertTriangle size={16} className="text-teal-600" /> Situações e regras da Secretária IA</h3>
|
||||
<button onClick={salvarSituacoes} disabled={saving} className="bg-teal-600 hover:bg-teal-700 text-white text-xs font-black uppercase tracking-wide px-4 py-2 rounded-xl flex items-center gap-2 disabled:opacity-50">
|
||||
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />} Salvar
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[11px] text-gray-500 leading-relaxed bg-amber-50 rounded-2xl p-4">
|
||||
Escreva regras que a Secretária IA deve seguir ao atender — uma por caixa. Ex.: <span className="italic">"Pelo plano X não temos profissional para canal em dente posterior; não agende direto — ofereça uma avaliação para o dentista examinar melhor o dente."</span> Quando uma situação exigir decisão humana, a IA encaminha o caso e pede para o paciente aguardar (aparece em Pedidos pendentes).
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{situacoes.map((s, i) => (
|
||||
<div key={i} className="flex items-start gap-2">
|
||||
<textarea value={s} rows={2} onChange={(e) => setSituacoes(situacoes.map((x, j) => j === i ? e.target.value : x))}
|
||||
placeholder="Descreva a situação e o que a IA deve fazer…"
|
||||
className="flex-1 bg-gray-50 border-2 border-transparent focus:border-teal-600 rounded-xl px-3 py-2 text-sm font-medium text-gray-800 outline-none resize-y" />
|
||||
<button onClick={() => setSituacoes(situacoes.filter((_, j) => j !== i))} className="mt-2 text-gray-300 hover:text-red-600"><Trash2 size={16} /></button>
|
||||
</div>
|
||||
))}
|
||||
<button onClick={() => setSituacoes([...situacoes, ''])} className="bg-gray-900 hover:bg-black text-white text-xs font-black uppercase px-4 py-2.5 rounded-xl flex items-center gap-1"><Plus size={14} /> Nova situação</button>
|
||||
</div>
|
||||
{situacoes.length === 0 && <p className="text-[11px] text-gray-400 italic">Nenhuma situação cadastrada. A IA seguirá apenas o roteiro padrão.</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,141 @@
|
||||
// Fila da secretária humana: pedidos de horário da ZONA CINZA que a Secretária IA
|
||||
// registrou ("vou confirmar e retorno"). A humana confirma (vira agendamento) ou
|
||||
// recusa. Consome /api/nw/agenda-config/clinica/:id/pedidos*.
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { X, CalendarClock, Check, Ban, Loader2, Phone, User, HelpCircle } from 'lucide-react';
|
||||
|
||||
const API = (import.meta as any).env?.VITE_API_URL || '/api';
|
||||
const BASE = `${API}/nw/agenda-config`;
|
||||
|
||||
async function req(path: string, opts: RequestInit = {}) {
|
||||
const token = localStorage.getItem('SCOREODONTO_AUTH_TOKEN');
|
||||
const res = await fetch(`${BASE}${path}`, { ...opts, headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(opts.headers || {}) } });
|
||||
const t = await res.text(); const d = t ? JSON.parse(t) : {};
|
||||
if (!res.ok) throw new Error(d.error || `Erro ${res.status}`);
|
||||
return d;
|
||||
}
|
||||
|
||||
const dm = (s: string) => s ? s.split('-').reverse().join('/') : s;
|
||||
|
||||
export const PedidosPendentes: React.FC<{ isOpen: boolean; onClose: () => void; clinicaId?: string; onChange?: () => void }> = ({ isOpen, onClose, clinicaId, onChange }) => {
|
||||
const [pedidos, setPedidos] = useState<any[]>([]);
|
||||
const [dentistas, setDentistas] = useState<any[]>([]);
|
||||
const [sel, setSel] = useState<Record<string, string>>({}); // pedido_id → dentista_id escolhido
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [erro, setErro] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const carregar = useCallback(async () => {
|
||||
if (!clinicaId) return;
|
||||
setLoading(true); setErro(null);
|
||||
try {
|
||||
const p = await req(`/clinica/${clinicaId}/pedidos?status=pendente`);
|
||||
setPedidos(p.pedidos || []);
|
||||
const d = await req(`/clinica/${clinicaId}/dentistas`);
|
||||
setDentistas(d.dentistas || []);
|
||||
} catch (e: any) { setErro(e.message); } finally { setLoading(false); }
|
||||
}, [clinicaId]);
|
||||
|
||||
useEffect(() => { if (isOpen) carregar(); }, [isOpen, carregar]);
|
||||
|
||||
const confirmar = async (p: any) => {
|
||||
const dentistaId = p.dentista_id || sel[p.id];
|
||||
if (!dentistaId) { setErro('Escolha o dentista para confirmar.'); return; }
|
||||
setBusy(p.id); setErro(null);
|
||||
try { await req(`/clinica/${clinicaId}/pedidos/${p.id}/confirmar`, { method: 'POST', body: JSON.stringify({ dentista_id: dentistaId }) }); await carregar(); onChange?.(); }
|
||||
catch (e: any) { setErro(e.message); } finally { setBusy(null); }
|
||||
};
|
||||
const recusar = async (p: any) => {
|
||||
setBusy(p.id); setErro(null);
|
||||
try { await req(`/clinica/${clinicaId}/pedidos/${p.id}/recusar`, { method: 'POST' }); await carregar(); onChange?.(); }
|
||||
catch (e: any) { setErro(e.message); } finally { setBusy(null); }
|
||||
};
|
||||
const resolver = async (p: any) => {
|
||||
setBusy(p.id); setErro(null);
|
||||
try { await req(`/clinica/${clinicaId}/pedidos/${p.id}/resolver`, { method: 'POST' }); await carregar(); onChange?.(); }
|
||||
catch (e: any) { setErro(e.message); } finally { setBusy(null); }
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 z-[70] flex items-center justify-center backdrop-blur-md p-0">
|
||||
<div className="bg-white shadow-2xl w-full h-dvh md:w-[92vw] md:max-w-3xl md:h-[90vh] rounded-none md:rounded-[2rem] flex flex-col overflow-hidden border border-gray-100">
|
||||
<div className="px-8 py-6 border-b border-gray-50 flex justify-between items-center bg-gradient-to-br from-gray-50 to-white">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 bg-amber-500 rounded-2xl shadow-lg shadow-amber-200"><CalendarClock size={22} className="text-white" /></div>
|
||||
<div>
|
||||
<h2 className="text-lg font-black text-gray-900 uppercase tracking-tighter">Pedidos aguardando confirmação</h2>
|
||||
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mt-0.5">Horários fora do padrão que a Secretária IA encaminhou</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-xl text-gray-400"><X size={22} /></button>
|
||||
</div>
|
||||
|
||||
{erro && <div className="px-8 py-2 text-xs font-bold bg-red-50 text-red-600">{erro}</div>}
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-3">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16"><Loader2 size={22} className="animate-spin text-gray-400" /></div>
|
||||
) : pedidos.length === 0 ? (
|
||||
<p className="text-center text-sm text-gray-400 py-16">Nenhum pedido pendente. 🎉</p>
|
||||
) : pedidos.map((p) => p.tipo === 'duvida' ? (
|
||||
<div key={p.id} className="border-2 border-indigo-100 rounded-2xl p-4 bg-indigo-50/40">
|
||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div className="space-y-1.5 max-w-[75%]">
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-black text-indigo-600 uppercase tracking-widest"><HelpCircle size={13} /> Dúvida p/ decidir</div>
|
||||
<div className="text-sm font-bold text-gray-800 leading-snug whitespace-pre-wrap">{p.resumo}</div>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-600 font-bold"><User size={13} className="text-gray-400" /> {p.paciente_nome || 'Cliente'}
|
||||
{p.paciente_telefone && <span className="flex items-center gap-1 text-gray-400"><Phone size={12} /> {p.paciente_telefone}</span>}</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => resolver(p)} disabled={busy === p.id}
|
||||
className="bg-indigo-600 hover:bg-indigo-700 text-white text-[11px] font-black uppercase px-3 py-1.5 rounded-xl flex items-center gap-1 disabled:opacity-50">
|
||||
{busy === p.id ? <Loader2 size={13} className="animate-spin" /> : <Check size={13} />} Resolvida
|
||||
</button>
|
||||
<button onClick={() => recusar(p)} disabled={busy === p.id}
|
||||
className="bg-gray-100 hover:bg-red-50 text-gray-500 hover:text-red-600 text-[11px] font-black uppercase px-3 py-1.5 rounded-xl flex items-center gap-1">
|
||||
<Ban size={13} /> Descartar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div key={p.id} className="border border-gray-200 rounded-2xl p-4 bg-white">
|
||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-black text-gray-800">
|
||||
{dm(p.data)} às {p.hora_inicio}{p.hora_fim ? `–${p.hora_fim}` : ''}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-600 font-bold"><User size={13} className="text-gray-400" /> {p.paciente_nome || 'Cliente'}
|
||||
{p.paciente_telefone && <span className="flex items-center gap-1 text-gray-400"><Phone size={12} /> {p.paciente_telefone}</span>}</div>
|
||||
{p.procedimento && <div className="text-[11px] text-gray-500">{p.procedimento}</div>}
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
{p.dentista_id ? (
|
||||
<span className="text-[11px] font-bold text-teal-600">{p.dentista_nome}</span>
|
||||
) : (
|
||||
<select value={sel[p.id] || ''} onChange={(e) => setSel({ ...sel, [p.id]: e.target.value })}
|
||||
className="bg-gray-50 border-2 border-transparent focus:border-teal-600 rounded-xl px-2 py-1.5 text-xs font-bold text-gray-800 outline-none">
|
||||
<option value="">Escolher dentista…</option>
|
||||
{dentistas.map((d) => <option key={d.id} value={d.id}>{d.nome}</option>)}
|
||||
</select>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => confirmar(p)} disabled={busy === p.id}
|
||||
className="bg-emerald-600 hover:bg-emerald-700 text-white text-[11px] font-black uppercase px-3 py-1.5 rounded-xl flex items-center gap-1 disabled:opacity-50">
|
||||
{busy === p.id ? <Loader2 size={13} className="animate-spin" /> : <Check size={13} />} Confirmar
|
||||
</button>
|
||||
<button onClick={() => recusar(p)} disabled={busy === p.id}
|
||||
className="bg-gray-100 hover:bg-red-50 text-gray-500 hover:text-red-600 text-[11px] font-black uppercase px-3 py-1.5 rounded-xl flex items-center gap-1">
|
||||
<Ban size={13} /> Recusar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,165 @@
|
||||
// Área de PENDÊNCIAS DO DONO do workspace (só o dono vê). Engloba tudo:
|
||||
// 1) Checklist da clínica — perguntas Sim/Não que o dono responde;
|
||||
// 2) Pendências de configuração — quando a resposta não bate com o banco
|
||||
// (ex.: marcou "mais de um dentista" mas só há 1 cadastrado);
|
||||
// 3) Fila operacional — resumo dos pedidos/dúvidas aguardando a secretária humana.
|
||||
// Consome /api/nw/agenda-config/clinica/:id/{checklist,pendencias}.
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { X, ClipboardCheck, AlertTriangle, Check, Ban, Loader2, Save, CalendarClock, HelpCircle, ChevronRight } from 'lucide-react';
|
||||
|
||||
const API = (import.meta as any).env?.VITE_API_URL || '/api';
|
||||
const BASE = `${API}/nw/agenda-config`;
|
||||
|
||||
async function req(path: string, opts: RequestInit = {}) {
|
||||
const token = localStorage.getItem('SCOREODONTO_AUTH_TOKEN');
|
||||
const res = await fetch(`${BASE}${path}`, { ...opts, headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(opts.headers || {}) } });
|
||||
const t = await res.text(); const d = t ? JSON.parse(t) : {};
|
||||
if (!res.ok) throw new Error(d.error || `Erro ${res.status}`);
|
||||
return d;
|
||||
}
|
||||
|
||||
type Questao = { chave: string; pergunta: string; ajuda?: string; resposta: number | null };
|
||||
type PendConfig = { chave: string; severidade: string; titulo: string; detalhe: string };
|
||||
|
||||
export const PendenciasDono: React.FC<{ isOpen: boolean; onClose: () => void; clinicaId?: string; onOpenFila?: () => void }> = ({ isOpen, onClose, clinicaId, onOpenFila }) => {
|
||||
const [questoes, setQuestoes] = useState<Questao[]>([]);
|
||||
const [config, setConfig] = useState<PendConfig[]>([]);
|
||||
const [oper, setOper] = useState<{ horarios: number; duvidas: number; total: number }>({ horarios: 0, duvidas: 0, total: 0 });
|
||||
const [contagens, setContagens] = useState<{ dentistas: number; pacientes: number }>({ dentistas: 0, pacientes: 0 });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [erro, setErro] = useState<string | null>(null);
|
||||
const [ok, setOk] = useState<string | null>(null);
|
||||
|
||||
const flash = (setter: (v: string | null) => void, m: string) => { setter(m); setTimeout(() => setter(null), 3000); };
|
||||
|
||||
const carregar = useCallback(async () => {
|
||||
if (!clinicaId) return;
|
||||
setLoading(true); setErro(null);
|
||||
try {
|
||||
const c = await req(`/clinica/${clinicaId}/checklist`);
|
||||
setQuestoes(c.questoes || []);
|
||||
const p = await req(`/clinica/${clinicaId}/pendencias`);
|
||||
setConfig(p.config || []);
|
||||
setOper(p.operacionais || { horarios: 0, duvidas: 0, total: 0 });
|
||||
setContagens(p.contagens || { dentistas: 0, pacientes: 0 });
|
||||
} catch (e: any) { setErro(e.message); } finally { setLoading(false); }
|
||||
}, [clinicaId]);
|
||||
|
||||
useEffect(() => { if (isOpen) carregar(); }, [isOpen, carregar]);
|
||||
|
||||
const setResposta = (chave: string, resposta: number | null) =>
|
||||
setQuestoes((qs) => qs.map((q) => q.chave === chave ? { ...q, resposta } : q));
|
||||
|
||||
const salvar = async () => {
|
||||
setSaving(true); setErro(null);
|
||||
try {
|
||||
const respostas: Record<string, number | null> = {};
|
||||
questoes.forEach((q) => { respostas[q.chave] = q.resposta; });
|
||||
await req(`/clinica/${clinicaId}/checklist`, { method: 'PUT', body: JSON.stringify({ respostas }) });
|
||||
flash(setOk, 'Checklist salvo!');
|
||||
await carregar();
|
||||
} catch (e: any) { setErro(e.message); } finally { setSaving(false); }
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
const sevColor = (s: string) => s === 'alta' ? 'border-red-200 bg-red-50' : 'border-amber-200 bg-amber-50';
|
||||
const sevText = (s: string) => s === 'alta' ? 'text-red-700' : 'text-amber-700';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 z-[70] flex items-center justify-center backdrop-blur-md p-0">
|
||||
<div className="bg-white shadow-2xl w-full h-dvh md:w-[92vw] md:max-w-3xl md:h-[92vh] rounded-none md:rounded-[2rem] flex flex-col overflow-hidden border border-gray-100">
|
||||
<div className="px-8 py-6 border-b border-gray-50 flex justify-between items-center bg-gradient-to-br from-gray-50 to-white">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 bg-indigo-600 rounded-2xl shadow-lg shadow-indigo-200"><ClipboardCheck size={22} className="text-white" /></div>
|
||||
<div>
|
||||
<h2 className="text-lg font-black text-gray-900 uppercase tracking-tighter">Pendências do workspace</h2>
|
||||
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mt-0.5">Config da clínica + o que a Secretária precisa que você resolva</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-xl text-gray-400"><X size={22} /></button>
|
||||
</div>
|
||||
|
||||
{(erro || ok) && <div className={`px-8 py-2 text-xs font-bold ${erro ? 'bg-red-50 text-red-600' : 'bg-green-50 text-green-600'}`}>{erro || ok}</div>}
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-8">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16"><Loader2 size={22} className="animate-spin text-gray-400" /></div>
|
||||
) : (
|
||||
<>
|
||||
{/* Fila operacional */}
|
||||
<section>
|
||||
<h3 className="text-xs font-black text-gray-500 uppercase tracking-widest mb-3">Aguardando a secretária humana</h3>
|
||||
<button onClick={() => onOpenFila?.()} disabled={!onOpenFila}
|
||||
className="w-full flex items-center justify-between gap-3 border-2 border-amber-100 bg-amber-50/50 rounded-2xl px-4 py-3.5 text-left hover:bg-amber-50 disabled:cursor-default disabled:hover:bg-amber-50/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<CalendarClock size={20} className="text-amber-600" />
|
||||
<div>
|
||||
<div className="text-sm font-black text-gray-800">{oper.total} pedido(s) na fila</div>
|
||||
<div className="text-[11px] text-gray-500 font-bold">{oper.horarios} horário(s) · {oper.duvidas} dúvida(s)</div>
|
||||
</div>
|
||||
</div>
|
||||
{onOpenFila && <ChevronRight size={18} className="text-amber-400" />}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{/* Pendências de configuração */}
|
||||
<section>
|
||||
<h3 className="text-xs font-black text-gray-500 uppercase tracking-widest mb-3">Configuração a resolver</h3>
|
||||
{config.length === 0 ? (
|
||||
<p className="text-sm text-gray-400 flex items-center gap-2 py-2"><Check size={16} className="text-emerald-500" /> Nenhuma pendência de configuração. 🎉</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{config.map((p, i) => (
|
||||
<div key={i} className={`border-2 rounded-2xl p-4 ${sevColor(p.severidade)}`}>
|
||||
<div className={`flex items-center gap-1.5 text-[10px] font-black uppercase tracking-widest ${sevText(p.severidade)}`}><AlertTriangle size={13} /> {p.severidade === 'alta' ? 'Importante' : 'Atenção'}</div>
|
||||
<div className="text-sm font-black text-gray-800 mt-1">{p.titulo}</div>
|
||||
<div className="text-[12px] text-gray-600 mt-0.5 leading-snug">{p.detalhe}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[10px] text-gray-400 mt-2">No sistema: {contagens.dentistas} dentista(s) · {contagens.pacientes} paciente(s) cadastrado(s).</p>
|
||||
</section>
|
||||
|
||||
{/* Checklist */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-xs font-black text-gray-500 uppercase tracking-widest">Checklist da clínica</h3>
|
||||
<button onClick={salvar} disabled={saving} className="bg-indigo-600 hover:bg-indigo-700 text-white text-[11px] font-black uppercase tracking-wide px-4 py-2 rounded-xl flex items-center gap-2 disabled:opacity-50">
|
||||
{saving ? <Loader2 size={13} className="animate-spin" /> : <Save size={13} />} Salvar
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[11px] text-gray-500 bg-gray-50 rounded-2xl p-3 mb-3 leading-relaxed">
|
||||
Responda sobre a clínica. A Secretária usa isto para saber quando pode agendar sozinha — quando algo não bate com o cadastro, ela evita agendar e encaminha ao humano, e aparece aqui em cima como pendência.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{questoes.map((q) => (
|
||||
<div key={q.chave} className="border border-gray-200 rounded-2xl p-4">
|
||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<div className="text-sm font-black text-gray-800 flex items-center gap-1.5"><HelpCircle size={14} className="text-gray-300" /> {q.pergunta}</div>
|
||||
{q.ajuda && <div className="text-[11px] text-gray-400 mt-1 leading-snug">{q.ajuda}</div>}
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<button onClick={() => setResposta(q.chave, q.resposta === 1 ? null : 1)}
|
||||
className={`text-[11px] font-black uppercase px-3 py-1.5 rounded-xl flex items-center gap-1 transition-colors ${q.resposta === 1 ? 'bg-emerald-600 text-white' : 'bg-gray-100 text-gray-500 hover:bg-emerald-50'}`}>
|
||||
<Check size={13} /> Sim
|
||||
</button>
|
||||
<button onClick={() => setResposta(q.chave, q.resposta === 0 ? null : 0)}
|
||||
className={`text-[11px] font-black uppercase px-3 py-1.5 rounded-xl flex items-center gap-1 transition-colors ${q.resposta === 0 ? 'bg-red-600 text-white' : 'bg-gray-100 text-gray-500 hover:bg-red-50'}`}>
|
||||
<Ban size={13} /> Não
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
|
||||
import { useConfirm } from '../contexts/ConfirmContext.tsx';
|
||||
import {
|
||||
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';
|
||||
import { ToothIcon } from './ToothIcon.tsx';
|
||||
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-financeiro', label: 'FINANCEIRO', icon: DollarSign },
|
||||
{ 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: 'lab-home', label: 'PAINEL DO LAB', icon: LayoutDashboard },
|
||||
{ id: 'lab-bancada', label: 'BANCADA', icon: FlaskConical },
|
||||
|
||||
@@ -5,6 +5,8 @@ import timeGridPlugin from '@fullcalendar/timegrid';
|
||||
import interactionPlugin from '@fullcalendar/interaction';
|
||||
import { Calendar as CalendarIcon, Plus, X, Loader2, GripVertical, Settings as GearIcon, Edit, Trash2, CalendarRange, Clock, User, Stethoscope, AlertCircle, CheckCircle2, Bell, CalendarClock, History, Download, PanelLeftClose, PanelLeftOpen } from 'lucide-react';
|
||||
import { AgendaSettingsModal } from '../components/AgendaSettingsModal.tsx';
|
||||
import { HorariosConfig } from '../components/HorariosConfig.tsx';
|
||||
import { PedidosPendentes } from '../components/PedidosPendentes.tsx';
|
||||
import { AgendaDetailModal, ScoreBadge, FamiliaChips } from '../components/AgendaDetailModal.tsx';
|
||||
import { WhatsChatDrawer } from './newwhats/WhatsChatDrawer.tsx';
|
||||
import { AgendaPresence } from '../components/AgendaPresence.tsx';
|
||||
@@ -343,13 +345,33 @@ const AppointmentModal: React.FC<{
|
||||
);
|
||||
};
|
||||
|
||||
export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebarCollapsed?: boolean; onToggleSidebar?: () => void }> = ({ onNavigate, sidebarCollapsed, onToggleSidebar }) => {
|
||||
export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebarCollapsed?: boolean; onToggleSidebar?: () => void; controlsOpen?: boolean; onCloseControls?: () => void }> = ({ onNavigate, sidebarCollapsed, onToggleSidebar, controlsOpen = false, onCloseControls }) => {
|
||||
const { data: agendamentos, refresh } = useHybridBackend<Agendamento[]>(HybridBackend.getAgendamentos);
|
||||
const { data: dentists, refresh: refreshDentists } = useHybridBackend<Dentista[]>(HybridBackend.getDentistas);
|
||||
const { data: googleEvents, refresh: refreshGoogle } = useHybridBackend<any[]>(HybridBackend.getGoogleEvents);
|
||||
const [events, setEvents] = useState<any[]>([]);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||
const [showMeusHorarios, setShowMeusHorarios] = useState(false);
|
||||
const [showPedidos, setShowPedidos] = useState(false);
|
||||
const [pedidosCount, setPedidosCount] = useState(0);
|
||||
// Pedidos da zona cinza aguardando a secretária humana — badge + poll a cada 30 min.
|
||||
useEffect(() => {
|
||||
const cid = (HybridBackend.getActiveWorkspace() as any)?.id;
|
||||
const role = HybridBackend.getCurrentRole();
|
||||
if (!cid || !['admin', 'donoclinica', 'donoconsultorio', 'funcionario'].includes(role)) return;
|
||||
let alive = true;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('SCOREODONTO_AUTH_TOKEN');
|
||||
const r = await fetch(`/api/nw/agenda-config/clinica/${cid}/pedidos?status=pendente`, { headers: token ? { Authorization: `Bearer ${token}` } : {} });
|
||||
if (r.ok && alive) { const j = await r.json(); setPedidosCount(j.total || (j.pedidos || []).length || 0); }
|
||||
} catch { /* silencioso */ }
|
||||
};
|
||||
poll();
|
||||
const t = setInterval(poll, 30 * 60 * 1000); // 30 min
|
||||
return () => { alive = false; clearInterval(t); };
|
||||
}, []);
|
||||
const [selectedDateInfo, setSelectedDateInfo] = useState<any>(null);
|
||||
const [selectedAppointment, setSelectedAppointment] = useState<Agendamento | null>(null);
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
@@ -600,10 +622,25 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebar
|
||||
|
||||
return (
|
||||
<div className="flex flex-col lg:flex-row lg:gap-4 h-full">
|
||||
{/* Coluna de controles (desktop: lateral esquerda vertical; mobile: topo compacto) */}
|
||||
<aside className="shrink-0 mb-3 lg:mb-0 lg:w-56 lg:overflow-y-auto lg:pr-3 lg:border-r lg:border-gray-100">
|
||||
{/* Título + recolher menu (desktop, ao lado) + AGENDAR (mobile, à direita) */}
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
{/* Backdrop da gaveta de controles (mobile) — fecha ao tocar fora. Como a sidebar. */}
|
||||
{controlsOpen && (
|
||||
<div className="fixed inset-0 bg-black/50 z-20 lg:hidden backdrop-blur-sm transition-opacity" onClick={onCloseControls} />
|
||||
)}
|
||||
{/* Coluna de controles — desktop: lateral esquerda fixa; mobile: gaveta deslizante
|
||||
(oculta por padrão p/ dar o máximo de espaço ao calendário; abre pelo ⋯ do header). */}
|
||||
<aside className={`
|
||||
fixed inset-y-0 right-0 z-30 w-72 max-w-[85%] bg-white shadow-2xl overflow-y-auto p-4 transform transition-transform duration-300
|
||||
${controlsOpen ? 'translate-x-0' : 'translate-x-full'}
|
||||
lg:static lg:inset-auto lg:z-auto lg:w-56 lg:max-w-none lg:translate-x-0 lg:transform-none lg:transition-none lg:shadow-none lg:bg-transparent lg:p-0 lg:overflow-y-auto lg:pr-3 lg:border-r lg:border-gray-100
|
||||
shrink-0 lg:mb-0`}>
|
||||
{/* Fechar a gaveta (só mobile) */}
|
||||
<button onClick={onCloseControls} title="Fechar"
|
||||
className="lg:hidden absolute top-3 right-3 p-1.5 rounded-lg text-gray-400 hover:text-gray-700 hover:bg-gray-100 transition-colors z-10">
|
||||
<X size={20} />
|
||||
</button>
|
||||
{/* Título + recolher menu (desktop, ao lado). No mobile o AGENDAR e as
|
||||
ações ficam empilhados abaixo (dentro da gaveta). */}
|
||||
<div className="flex items-center gap-2 mb-3 pr-8 lg:pr-0">
|
||||
{onToggleSidebar && (
|
||||
<button onClick={onToggleSidebar} title={sidebarCollapsed ? 'Mostrar menu lateral' : 'Recolher menu lateral'}
|
||||
className="hidden lg:flex items-center justify-center w-8 h-8 shrink-0 rounded-lg text-gray-400 hover:text-gray-700 hover:bg-gray-100 transition-colors">
|
||||
@@ -611,56 +648,70 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebar
|
||||
</button>
|
||||
)}
|
||||
<h2 className="text-xl lg:text-base font-bold text-gray-900 uppercase flex-1 min-w-0 leading-tight break-words">AGENDA CLÍNICA</h2>
|
||||
{(currentRole !== 'paciente' && !souDentista) && (
|
||||
<button onClick={() => { setReagendarPatient(null); setSelectedAppointment(null); setSelectedDateInfo({ dateStr: new Date().toISOString() }); setIsModalOpen(true); }} title="Agendar"
|
||||
className="lg:hidden shrink-0 bg-teal-600 text-white px-3 py-2 rounded-lg text-xs font-bold uppercase flex items-center gap-1.5 hover:bg-teal-700 shadow-sm">
|
||||
<Plus size={16} /> AGENDAR
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 sm:gap-3 items-center flex-wrap lg:flex-col lg:items-stretch">
|
||||
{/* AGENDAR (desktop, full-width no topo da coluna) */}
|
||||
{/* AGENDAR (full-width no topo da coluna/gaveta — mobile e desktop) */}
|
||||
{(currentRole !== 'paciente' && !souDentista) && (
|
||||
<button onClick={() => { setReagendarPatient(null); setSelectedAppointment(null); setSelectedDateInfo({ dateStr: new Date().toISOString() }); setIsModalOpen(true); }} title="Agendar"
|
||||
className="hidden lg:flex w-full bg-teal-600 text-white px-4 py-2.5 rounded-lg text-sm font-bold uppercase items-center justify-center gap-2 hover:bg-teal-700 shadow-sm transition-colors">
|
||||
<button onClick={() => { setReagendarPatient(null); setSelectedAppointment(null); setSelectedDateInfo({ dateStr: new Date().toISOString() }); setIsModalOpen(true); onCloseControls?.(); }} title="Agendar"
|
||||
className="flex w-full bg-teal-600 text-white px-4 py-2.5 rounded-lg text-sm font-bold uppercase items-center justify-center gap-2 hover:bg-teal-700 shadow-sm transition-colors">
|
||||
<Plus size={18} /> AGENDAR
|
||||
</button>
|
||||
)}
|
||||
{/* ícones de ação — mesmo tamanho, distribuídos horizontalmente */}
|
||||
<div className="flex gap-2 w-full">
|
||||
{/* Ações — mobile: empilhadas, ícone + nome, largura cheia; desktop: fileira de ícones */}
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
{(currentRole !== 'paciente') && (
|
||||
<button onClick={() => { setShowAtividade(true); loadAtividade(); }} title="Atividade da equipe hoje"
|
||||
className="flex-1 flex items-center justify-center py-2.5 bg-white text-gray-400 hover:text-teal-600 border border-gray-200 rounded-xl transition-all hover:border-teal-200 hover:shadow-sm">
|
||||
<History size={20} />
|
||||
<button onClick={() => { setShowAtividade(true); loadAtividade(); onCloseControls?.(); }} title="Atividade da equipe hoje"
|
||||
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" />
|
||||
<span className="text-sm font-bold">Atividade da equipe</span>
|
||||
</button>
|
||||
)}
|
||||
{(currentRole === 'admin' || currentRole === 'donoclinica' || currentRole === 'donoconsultorio') && (
|
||||
<button onClick={handleImportarGoogle} disabled={importando} title="Importar agendamentos do Google para o sistema"
|
||||
className="flex-1 flex items-center justify-center py-2.5 bg-white 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">
|
||||
{importando ? <Loader2 size={20} className="animate-spin" /> : <Download size={20} />}
|
||||
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" />}
|
||||
<span className="text-sm font-bold">Importar do Google</span>
|
||||
</button>
|
||||
)}
|
||||
{(currentRole === 'admin' || currentRole === 'donoclinica') && (
|
||||
<button onClick={() => setIsSettingsOpen(true)} title="Configurações da agenda"
|
||||
className="flex-1 flex items-center justify-center py-2.5 bg-white text-gray-400 hover:text-teal-600 border border-gray-200 rounded-xl transition-all hover:border-teal-200 hover:shadow-sm">
|
||||
<GearIcon size={20} />
|
||||
{['admin', 'donoclinica', 'donoconsultorio', 'funcionario'].includes(currentRole) && (
|
||||
<button onClick={() => { setShowPedidos(true); onCloseControls?.(); }} title="Pedidos de horário aguardando confirmação (encaminhados pela Secretária IA)"
|
||||
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" />
|
||||
<span className="text-sm font-bold">Pedidos pendentes</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>
|
||||
)}
|
||||
{(currentRole === 'admin' || currentRole === 'donoclinica' || currentRole === 'donoconsultorio') && (
|
||||
<button onClick={() => { setIsSettingsOpen(true); onCloseControls?.(); }} title="Configurações da agenda"
|
||||
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" />
|
||||
<span className="text-sm font-bold">Configurações</span>
|
||||
</button>
|
||||
)}
|
||||
{souDentista && (
|
||||
<button onClick={() => { setShowInteresses(true); loadInteresses(); }} title="Pedidos de interesse na sua agenda"
|
||||
className="relative flex-1 flex items-center justify-center py-2.5 bg-white text-gray-400 hover:text-teal-600 border border-gray-200 rounded-xl transition-all hover:border-teal-200 hover:shadow-sm">
|
||||
<Bell size={20} />
|
||||
<button onClick={() => { setShowMeusHorarios(true); onCloseControls?.(); }} title="Configurar meus dias e horários de atendimento"
|
||||
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" />
|
||||
<span className="text-sm font-bold">Meus Horários</span>
|
||||
</button>
|
||||
)}
|
||||
{souDentista && (
|
||||
<button onClick={() => { setShowInteresses(true); loadInteresses(); onCloseControls?.(); }} title="Pedidos de interesse na sua agenda"
|
||||
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" />
|
||||
<span className="text-sm font-bold">Interesses na agenda</span>
|
||||
{interessesAguardando > 0 && (
|
||||
<span className="absolute -top-1 -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>
|
||||
)}
|
||||
{(currentRole !== 'paciente' && !souDentista) && (
|
||||
<button onClick={() => { setShowPendencias(true); loadPendencias(); }} title="Pacientes a reagendar"
|
||||
className="relative flex-1 flex items-center justify-center py-2.5 bg-white text-gray-400 hover:text-amber-600 border border-gray-200 rounded-xl transition-all hover:border-amber-200 hover:shadow-sm">
|
||||
<CalendarClock size={20} />
|
||||
<button onClick={() => { setShowPendencias(true); loadPendencias(); onCloseControls?.(); }} title="Pacientes a reagendar"
|
||||
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" />
|
||||
<span className="text-sm font-bold">Pacientes a reagendar</span>
|
||||
{pendencias.length > 0 && (
|
||||
<span className="absolute -top-1 -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>
|
||||
)}
|
||||
@@ -707,6 +758,8 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebar
|
||||
buttonText={{ today: 'HOJE', month: 'MÊS', week: 'SEMANA', day: 'DIA' }}
|
||||
slotMinTime="08:00:00"
|
||||
slotMaxTime="19:00:00"
|
||||
slotDuration="00:30:00"
|
||||
slotLabelInterval="01:00:00"
|
||||
allDaySlot={false}
|
||||
events={events}
|
||||
selectable={true}
|
||||
@@ -747,7 +800,10 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebar
|
||||
onEditar={() => { setShowDetails(false); setIsModalOpen(true); }}
|
||||
onAtender={handleAtender}
|
||||
onVerWhats={(info) => setWhatsTarget(info)}
|
||||
podeAtender={!!onNavigate && currentRole !== 'paciente'}
|
||||
// "Atender" (abre Lançar GTO) é ação CLÍNICA — atende quem trata: dentista,
|
||||
// biomédico, dono da clínica/consultório, dentista locatário, admin. A recepção
|
||||
// (funcionario) NÃO atende; ela só remarca/desmarca/agenda.
|
||||
podeAtender={!!onNavigate && currentRole !== 'paciente' && currentRole !== 'funcionario'}
|
||||
dentistaRestrito={souDentista}
|
||||
/>
|
||||
|
||||
@@ -848,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-timegrid-slot-label-cushion, .fc .fc-timegrid-axis-cushion { font-size: .6rem; }
|
||||
.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>
|
||||
<AppointmentModal
|
||||
isOpen={isModalOpen}
|
||||
@@ -861,6 +922,9 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebar
|
||||
initialPatient={reagendarPatient}
|
||||
/>
|
||||
<AgendaSettingsModal isOpen={isSettingsOpen} onClose={() => setIsSettingsOpen(false)} dentists={dentists} />
|
||||
<HorariosConfig isOpen={showMeusHorarios} onClose={() => setShowMeusHorarios(false)} clinicaId={(HybridBackend.getActiveWorkspace() as any)?.id} initialTab="dentistas" soDentista />
|
||||
<PedidosPendentes isOpen={showPedidos} onClose={() => setShowPedidos(false)} clinicaId={(HybridBackend.getActiveWorkspace() as any)?.id}
|
||||
onChange={() => { const cid = (HybridBackend.getActiveWorkspace() as any)?.id; const token = localStorage.getItem('SCOREODONTO_AUTH_TOKEN'); if (cid) fetch(`/api/nw/agenda-config/clinica/${cid}/pedidos?status=pendente`, { headers: token ? { Authorization: `Bearer ${token}` } : {} }).then(r => r.ok ? r.json() : { total: 0 }).then(j => setPedidosCount(j.total || 0)).catch(() => {}); }} />
|
||||
|
||||
{showInteresses && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center backdrop-blur-sm p-4" onClick={() => setShowInteresses(false)}>
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
CheckCircle2, XCircle, Clock, Search, ChevronLeft, ChevronRight,
|
||||
TrendingUp, Stethoscope, FlaskConical, User, ToggleLeft, ToggleRight,
|
||||
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';
|
||||
|
||||
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 */
|
||||
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 === 'financeiro' && <TabFinanceiro />}
|
||||
{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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,10 +8,14 @@ import {
|
||||
Sparkles, BookOpen, ShieldCheck, Calendar, AlertTriangle,
|
||||
CheckCircle, Clock, User, RefreshCw, MoreVertical, Power, Cpu,
|
||||
Phone, Shield, Star, Repeat, UserCheck, Building2, Stethoscope,
|
||||
Maximize2, Save, HelpCircle, ArrowLeft,
|
||||
Maximize2, Save, HelpCircle, ArrowLeft, ClipboardCheck,
|
||||
} from 'lucide-react'
|
||||
import { useSecretariaStore } from './store/secretariaStore'
|
||||
import type { SecAgent, BrainNode, SecConversation, BrainNodeType, CalendarSlot, SecNumber, NumberRole } from './services/secretariaApi'
|
||||
import { HybridBackend } from '../../services/backend.ts'
|
||||
import { PendenciasDono } from '../../components/PendenciasDono.tsx'
|
||||
import { PedidosPendentes } from '../../components/PedidosPendentes.tsx'
|
||||
import { secPowerApi } from './services/secretariaApi'
|
||||
|
||||
// ─── Thin Nav ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -25,7 +29,7 @@ const NAV_ITEMS: { id: NavTab; icon: React.ElementType; label: string }[] = [
|
||||
{ id: 'numbers', icon: Phone, label: 'Números' },
|
||||
]
|
||||
|
||||
function ThinNav({ active, onChange, onHelp, onBack }: { active: NavTab; onChange: (t: NavTab) => void; onHelp: () => void; onBack?: () => void }) {
|
||||
function ThinNav({ active, onChange, onHelp, onBack, onPendencias, pendenciasBadge, secOn, onTogglePower }: { active: NavTab; onChange: (t: NavTab) => void; onHelp: () => void; onBack?: () => void; onPendencias?: () => void; pendenciasBadge?: number; secOn?: boolean; onTogglePower?: () => void }) {
|
||||
return (
|
||||
<div className="w-[60px] h-full bg-white border-r border-gray-100 flex flex-col items-center py-4 shrink-0 z-30 gap-1">
|
||||
{onBack && (
|
||||
@@ -54,6 +58,34 @@ function ThinNav({ active, onChange, onHelp, onBack }: { active: NavTab; onChang
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Desligar/ligar a Secretária — GLOBAL, todos os chats, sem timer de retorno.
|
||||
Botão de segurança enquanto a IA amadurece; visível a todos. */}
|
||||
{onTogglePower && (
|
||||
<button
|
||||
title={secOn ? 'Desligar a Secretária (todos os chats)' : 'Secretária DESLIGADA — clique para ligar'}
|
||||
onClick={onTogglePower}
|
||||
className={`relative w-10 h-10 rounded-xl flex items-center justify-center transition-all mb-1 ${
|
||||
secOn ? 'text-emerald-600 hover:bg-emerald-50' : 'text-white bg-red-600 hover:bg-red-700 animate-pulse'
|
||||
}`}
|
||||
>
|
||||
<Power size={20} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Pendências do dono — checklist + config + fila (só o dono do workspace) */}
|
||||
{onPendencias && (
|
||||
<button
|
||||
title="Pendências do workspace"
|
||||
onClick={onPendencias}
|
||||
className="relative w-10 h-10 rounded-xl flex items-center justify-center text-gray-500 hover:text-indigo-600 hover:bg-gray-50 transition-all mb-1"
|
||||
>
|
||||
<ClipboardCheck size={20} />
|
||||
{!!pendenciasBadge && pendenciasBadge > 0 && (
|
||||
<span className="absolute -top-0.5 -right-0.5 min-w-[16px] h-4 px-1 bg-indigo-600 text-white text-[9px] font-black rounded-full flex items-center justify-center">{pendenciasBadge > 99 ? '99+' : pendenciasBadge}</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Ajuda — modelos de como usar e configurar a secretária */}
|
||||
<button
|
||||
title="Ajuda — como usar"
|
||||
@@ -722,9 +754,10 @@ const ROLE_OPTIONS: { value: NumberRole; label: string }[] = [
|
||||
interface BaileysInstance { id: string; name: string; status: string }
|
||||
|
||||
function NumbersPanel({
|
||||
numbers, loading, onCreate, onUpdate, onDelete,
|
||||
numbers, agents, loading, onCreate, onUpdate, onDelete,
|
||||
}: {
|
||||
numbers: SecNumber[]
|
||||
agents: SecAgent[]
|
||||
loading: boolean
|
||||
onCreate: (d: Partial<SecNumber>) => void
|
||||
onUpdate: (id: string, d: Partial<SecNumber>) => void
|
||||
@@ -734,7 +767,8 @@ function NumbersPanel({
|
||||
const [loadingInst, setLoadingInst] = useState(false)
|
||||
const [rolePickerFor, setRolePickerFor] = useState<string | null>(null) // instance_id
|
||||
const [editingId, setEditingId] = useState<string | null>(null) // sec_number.id
|
||||
const [editForm, setEditForm] = useState<{ area: string; priority: number; notes: string }>({ area: '', priority: 10, notes: '' })
|
||||
const [editForm, setEditForm] = useState<{ area: string; priority: number; notes: string; agent_id: string | null }>({ area: '', priority: 10, notes: '', agent_id: null })
|
||||
const agentNameOf = (id: string | null | undefined) => agents.find((a) => a.id === id)?.name ?? null
|
||||
|
||||
useEffect(() => {
|
||||
setLoadingInst(true)
|
||||
@@ -825,6 +859,14 @@ function NumbersPanel({
|
||||
)}
|
||||
{num?.area && <span className="text-[10px] text-gray-400">· {num.area}</span>}
|
||||
</div>
|
||||
{num && (
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
<Brain size={9} className="text-gray-400 shrink-0" />
|
||||
<span className={`text-[10px] truncate ${num.agent_id ? 'text-brand-600 font-semibold' : 'text-gray-400 italic'}`}>
|
||||
{agentNameOf(num.agent_id) ?? 'Cérebro padrão (1º agente ativo)'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
@@ -843,6 +885,8 @@ function NumbersPanel({
|
||||
{/* Role picker dropdown */}
|
||||
<AnimatePresence>
|
||||
{rolePickerFor === inst.id && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setRolePickerFor(null)} />
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
@@ -877,6 +921,7 @@ function NumbersPanel({
|
||||
)
|
||||
})}
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -891,7 +936,7 @@ function NumbersPanel({
|
||||
|
||||
{/* Editar detalhes (área, prioridade) */}
|
||||
{num && !isEditing && (
|
||||
<button onClick={() => { setEditingId(num.id); setEditForm({ area: num.area ?? '', priority: num.priority, notes: num.notes ?? '' }) }}
|
||||
<button onClick={() => { setEditingId(num.id); setEditForm({ area: num.area ?? '', priority: num.priority, notes: num.notes ?? '', agent_id: num.agent_id ?? null }) }}
|
||||
className="w-5 h-5 flex items-center justify-center text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<Pencil size={11} />
|
||||
@@ -914,9 +959,23 @@ function NumbersPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inline edit: área + prioridade */}
|
||||
{/* Inline edit: agente/cérebro + área + prioridade */}
|
||||
{isEditing && (
|
||||
<div className="px-3 pb-3 pt-1 border-t border-gray-100 grid grid-cols-2 gap-2">
|
||||
<div className="col-span-2">
|
||||
<p className="text-[10px] text-gray-500 mb-0.5">Agente / Cérebro deste número</p>
|
||||
<select
|
||||
value={editForm.agent_id ?? ''}
|
||||
onChange={(e) => setEditForm((f) => ({ ...f, agent_id: e.target.value || null }))}
|
||||
className="w-full text-xs bg-gray-50 text-gray-800 border border-gray-200 rounded-lg px-2 py-1.5 focus:outline-none focus:border-brand-500/40"
|
||||
>
|
||||
<option value="">Cérebro padrão (1º agente ativo)</option>
|
||||
{agents.map((a) => (
|
||||
<option key={a.id} value={a.id}>{a.name}{a.active ? '' : ' (inativo)'}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-[9px] text-gray-400 mt-0.5 leading-tight">Cada número responde com o cérebro (persona, regras, conhecimento, agenda) do agente escolhido aqui.</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] text-gray-500 mb-0.5">Área / Especialidade</p>
|
||||
<input
|
||||
@@ -1551,13 +1610,52 @@ export function SecretariaView(props: { onNavigate?: (view: string) => void } =
|
||||
const [openNode, setOpenNode] = useState<BrainNode | null>(null)
|
||||
const [showHelp, setShowHelp] = useState(false)
|
||||
|
||||
// Pendências do dono (checklist + config + fila) — só o dono do workspace
|
||||
const clinicaId = (HybridBackend.getActiveWorkspace() as any)?.id as string | undefined
|
||||
const ehDono = ['admin', 'donoclinica', 'donoconsultorio'].includes(HybridBackend.getCurrentRole?.() || '')
|
||||
const [showPendencias, setShowPendencias] = useState(false)
|
||||
const [showFila, setShowFila] = useState(false)
|
||||
const [pendCount, setPendCount] = useState(0)
|
||||
|
||||
// Liga/desliga GLOBAL da Secretária (kill-switch auto_reply) — todos, sem timer
|
||||
const [secOn, setSecOn] = useState(true)
|
||||
const [powerBusy, setPowerBusy] = useState(false)
|
||||
|
||||
// Boot
|
||||
useEffect(() => {
|
||||
store.loadAgents()
|
||||
store.loadCalendar()
|
||||
store.loadNumbers()
|
||||
secPowerApi.get().then((r) => setSecOn(r.enabled)).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const handleTogglePower = useCallback(async () => {
|
||||
if (powerBusy) return
|
||||
const proximo = !secOn
|
||||
// Ao DESLIGAR (para todos os chats, sem retorno automático), confirma.
|
||||
if (!proximo && !window.confirm('Desligar a Secretária para TODOS os chats? Ela para de responder e só volta quando você ligar de novo.')) return
|
||||
setPowerBusy(true)
|
||||
try { const r = await secPowerApi.set(proximo); setSecOn(r.enabled) }
|
||||
catch { /* mantém estado atual */ } finally { setPowerBusy(false) }
|
||||
}, [secOn, powerBusy])
|
||||
|
||||
// Contagem de pendências para o badge (dono) — atualiza ao abrir e a cada 5 min.
|
||||
useEffect(() => {
|
||||
if (!ehDono || !clinicaId) return
|
||||
let vivo = true
|
||||
const carregar = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('SCOREODONTO_AUTH_TOKEN')
|
||||
const API = (import.meta as any).env?.VITE_API_URL || '/api'
|
||||
const r = await fetch(`${API}/nw/agenda-config/clinica/${clinicaId}/pendencias`, { headers: token ? { Authorization: `Bearer ${token}` } : {} })
|
||||
if (r.ok && vivo) { const d = await r.json(); setPendCount(d.total || 0) }
|
||||
} catch { /* silencioso */ }
|
||||
}
|
||||
carregar()
|
||||
const t = setInterval(carregar, 5 * 60 * 1000)
|
||||
return () => { vivo = false; clearInterval(t) }
|
||||
}, [ehDono, clinicaId, showPendencias, showFila])
|
||||
|
||||
// Sincroniza openNode quando o store atualiza o nó (ex: toggle active, node_model)
|
||||
useEffect(() => {
|
||||
if (!openNode) return
|
||||
@@ -1610,7 +1708,17 @@ export function SecretariaView(props: { onNavigate?: (view: string) => void } =
|
||||
<div className="flex h-full w-full bg-gray-50 overflow-hidden font-sans">
|
||||
|
||||
{/* Thin Nav */}
|
||||
<ThinNav active={activeTab} onChange={handleTabChange} onHelp={() => setShowHelp(true)} onBack={() => props.onNavigate?.('dashboard')} />
|
||||
<ThinNav active={activeTab} onChange={handleTabChange} onHelp={() => setShowHelp(true)} onBack={() => props.onNavigate?.('dashboard')}
|
||||
onPendencias={ehDono ? () => setShowPendencias(true) : undefined} pendenciasBadge={pendCount}
|
||||
secOn={secOn} onTogglePower={handleTogglePower} />
|
||||
|
||||
{ehDono && (
|
||||
<>
|
||||
<PendenciasDono isOpen={showPendencias} onClose={() => setShowPendencias(false)} clinicaId={clinicaId}
|
||||
onOpenFila={() => { setShowPendencias(false); setShowFila(true) }} />
|
||||
<PedidosPendentes isOpen={showFila} onClose={() => setShowFila(false)} clinicaId={clinicaId} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{showHelp && (
|
||||
<HelpModal
|
||||
@@ -1688,6 +1796,7 @@ export function SecretariaView(props: { onNavigate?: (view: string) => void } =
|
||||
{activeTab === 'numbers' && (
|
||||
<NumbersPanel
|
||||
numbers={store.numbers}
|
||||
agents={store.agents}
|
||||
loading={store.loadingNumbers}
|
||||
onCreate={(d) => store.createNumber(d)}
|
||||
onUpdate={(id, d) => store.updateNumber(id, d)}
|
||||
|
||||
@@ -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,
|
||||
} from 'lucide-react';
|
||||
import { getAvatarProxyUrl } from '../utils/inboxUtils';
|
||||
import { SessaoSecretariaPower } from './SessaoSecretariaPower';
|
||||
|
||||
export type TabType = 'chats' | 'broadcasts' | 'groups' | 'settings';
|
||||
|
||||
@@ -64,6 +65,8 @@ export default function ThinSidebar({ activeTab, setActiveTab, activeInstance, o
|
||||
|
||||
{/* Bottom Icons */}
|
||||
<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') */}
|
||||
{renderIcon('settings', Settings, 'Configurações')}
|
||||
|
||||
|
||||
@@ -75,6 +75,7 @@ export interface CalendarSlot {
|
||||
date: string
|
||||
time_start: string
|
||||
time_end: string
|
||||
instance_id?: string | null // número/sessão dona do slot (null = global)
|
||||
attendee_name: string | null
|
||||
attendee_phone: string | null
|
||||
status: 'available' | 'booked' | 'cancelled'
|
||||
@@ -82,6 +83,29 @@ export interface CalendarSlot {
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// ─── Power (liga/desliga global — kill-switch auto_reply) ──────────────────────
|
||||
|
||||
export const secPowerApi = {
|
||||
get: () => api.get<{ enabled: boolean }>(`${BASE}/power`).then((r) => r.data),
|
||||
set: (enabled: boolean) => api.post<{ enabled: boolean }>(`${BASE}/power`, { enabled }).then((r) => r.data),
|
||||
}
|
||||
|
||||
// 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 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const secAgentApi = {
|
||||
@@ -132,6 +156,8 @@ export type NumberRole =
|
||||
export interface SecNumber {
|
||||
id: string
|
||||
instance_id: string
|
||||
clinica_id?: string | null
|
||||
agent_id: string | null // agente/cérebro deste número (separação por sessão)
|
||||
label: string
|
||||
role: NumberRole
|
||||
area: string | null
|
||||
@@ -153,7 +179,7 @@ export const secNumberApi = {
|
||||
// ─── Calendar ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export const secCalApi = {
|
||||
list: (params?: { from?: string; to?: string; status?: string }) =>
|
||||
list: (params?: { from?: string; to?: string; status?: string; instance_id?: string }) =>
|
||||
api.get<CalendarSlot[]>(`${BASE}/calendar`, { params }).then((r) => r.data),
|
||||
create: (d: Partial<CalendarSlot>) => api.post<CalendarSlot>(`${BASE}/calendar`, d).then((r) => r.data),
|
||||
update: (id: string, d: Partial<CalendarSlot>) => api.put<CalendarSlot>(`${BASE}/calendar/${id}`, d).then((r) => r.data),
|
||||
|
||||
Reference in New Issue
Block a user