Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2adc3095ac | |||
| 049afeef45 | |||
| 8ea8faa4c2 | |||
| 86cf253161 | |||
| 89588c2674 | |||
| 56443765d4 | |||
| d26fd80a28 | |||
| b42257f584 | |||
| 1d0de944e1 | |||
| 7cfd14ae09 | |||
| 6087846556 | |||
| 6f8bd4162f | |||
| 2f37944afa | |||
| 27dd45efbd | |||
| 2d0396fcb1 | |||
| c8443a146a | |||
| e9f8a24a93 | |||
| e8d1a5547e | |||
| b253feb4aa | |||
| eafc1578e3 | |||
| 8b9776f081 | |||
| 9fd078e36f | |||
| 1e73a0c01f | |||
| 727d0a245d | |||
| d33ebd3f76 | |||
| dcf33befec | |||
| 3096db150d | |||
| 343ca560dc | |||
| d6aa1cdeac | |||
| c78c745f3f | |||
| 8ec1e6204d | |||
| 69a0bce4d7 | |||
| f24a5a82e8 | |||
| dab74de207 | |||
| 5a790ff000 | |||
| 29f9139b6c | |||
| e0b37797b9 | |||
| 041803ebb3 | |||
| 3ca89ab21c | |||
| ffcace0133 | |||
| 65244c983f | |||
| d8d8617a6c | |||
| f343d8e7a7 | |||
| b1ddfbf349 | |||
| 8cac06e37c | |||
| b67fa15c55 | |||
| 1e16edf2be | |||
| cfeac891fb | |||
| f30b0ab732 | |||
| ddcc639ab6 | |||
| 882af16458 | |||
| 4dea9ab28a | |||
| 26f39a60f3 | |||
| 7a5f5cf0fa | |||
| f806835ae3 |
@@ -10,6 +10,7 @@
|
||||
// POST /book — cria agendamento real (anti-overbooking + vínculo de paciente)
|
||||
import express from 'express';
|
||||
import { getConfig } from './config.js';
|
||||
import { validarCpfCnpj, validarNascimento } from './validacao.js';
|
||||
import { cautelasParaIA } from './checklist-defs.js';
|
||||
import { resolverFeriado, feriadoNacionalNome } from './feriados.js';
|
||||
|
||||
@@ -99,7 +100,7 @@ async function janelasClinicaNoDia(pool, clinicaId, dateStr, dow, clinicHours) {
|
||||
// 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];
|
||||
@@ -117,11 +118,79 @@ 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;
|
||||
}
|
||||
|
||||
// ── Fuso horário por clínica ─────────────────────────────────────────────────────
|
||||
// Brasil não tem horário de verão → offset estável, dispensa lib de TZ.
|
||||
const DOW_MAP = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
|
||||
async function getClinicaTZ(pool, clinicaId) {
|
||||
try { const { rows } = await pool.query('SELECT timezone FROM clinicas WHERE id=$1', [clinicaId]); return rows[0]?.timezone || 'America/Sao_Paulo'; }
|
||||
catch { return 'America/Sao_Paulo'; }
|
||||
}
|
||||
// "Agora" no fuso da clínica → { year, month, day, hour, minute, dow, hm }.
|
||||
function partsNow(tz, base = new Date()) {
|
||||
const f = new Intl.DateTimeFormat('en-US', { timeZone: tz, hour12: false, weekday: 'short', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||
const p = {}; for (const x of f.formatToParts(base)) p[x.type] = x.value;
|
||||
const hour = p.hour === '24' ? 0 : +p.hour;
|
||||
return { year: +p.year, month: +p.month, day: +p.day, hour, minute: +p.minute, dow: DOW_MAP[p.weekday], hm: hour * 60 + (+p.minute) };
|
||||
}
|
||||
// Offset do fuso (min) num instante.
|
||||
function tzOffsetMin(tz, date) {
|
||||
const f = new Intl.DateTimeFormat('en-US', { timeZone: tz, hour12: false, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
const p = {}; for (const x of f.formatToParts(date)) p[x.type] = x.value;
|
||||
const asUTC = Date.UTC(+p.year, +p.month - 1, +p.day, (p.hour === '24' ? 0 : +p.hour), +p.minute, +p.second);
|
||||
return Math.round((asUTC - date.getTime()) / 60000);
|
||||
}
|
||||
// Instante UTC de um wall-clock (y, mon, d, h, min) no fuso tz.
|
||||
function zonedToUtc(y, mon, d, h, min, tz) {
|
||||
const guess = Date.UTC(y, mon - 1, d, h, min, 0);
|
||||
return new Date(guess - tzOffsetMin(tz, new Date(guess)) * 60000);
|
||||
}
|
||||
|
||||
// ── Comunicação interna: janelas de horário permitido p/ contatar um número ──────
|
||||
const toMin = (t) => { const [h, m] = String(t ?? '').split(':').map(Number); return (h || 0) * 60 + (m || 0); };
|
||||
// clock = partsNow(tz) da clínica. Retorna o 1º número contactável AGORA ou null.
|
||||
function contatoPermitidoAgora(numeros, clock) {
|
||||
const dow = clock.dow, hm = clock.hm;
|
||||
for (const n of (numeros || [])) {
|
||||
const tel = String(n?.numero || '').replace(/\D/g, '');
|
||||
if (tel.length < 10) continue;
|
||||
for (const j of (n.janelas || [])) {
|
||||
if (!Array.isArray(j.dias) || !j.dias.includes(dow)) continue;
|
||||
if (hm >= toMin(j.inicio) && hm <= toMin(j.fim)) return { numero: tel, descricao: n.descricao || null, situacoes: n.situacoes || null };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// Próxima janela permitida → { label, ts:ISO } | null. ts é o instante REAL no fuso tz.
|
||||
function proximaJanela(numeros, clock, tz) {
|
||||
const nowDow = clock.dow, nowHm = clock.hm;
|
||||
let best = null; // { offset, ini, dow, inicio }
|
||||
for (const n of (numeros || [])) {
|
||||
if (String(n?.numero || '').replace(/\D/g, '').length < 10) continue;
|
||||
for (const j of (n.janelas || [])) {
|
||||
if (!Array.isArray(j.dias)) continue;
|
||||
for (const d of j.dias) {
|
||||
let offset = (d - nowDow + 7) % 7;
|
||||
const ini = toMin(j.inicio);
|
||||
if (offset === 0 && ini <= nowHm) offset = 7; // hoje já passou → próxima semana
|
||||
if (!best || offset < best.offset || (offset === best.offset && ini < best.ini)) best = { offset, ini, dow: d, inicio: j.inicio };
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!best) return null;
|
||||
const quando = best.offset === 0 ? 'hoje' : best.offset === 1 ? 'amanhã' : DIAS_NOME[best.dow];
|
||||
// data-alvo = hoje (da clínica) + offset dias, resolvida como instante no fuso tz.
|
||||
const td = new Date(Date.UTC(clock.year, clock.month - 1, clock.day)); td.setUTCDate(td.getUTCDate() + best.offset);
|
||||
const [h, m] = String(best.inicio).split(':').map(Number);
|
||||
const ts = zonedToUtc(td.getUTCFullYear(), td.getUTCMonth() + 1, td.getUTCDate(), h || 0, m || 0, tz).toISOString();
|
||||
return { label: `${quando} às ${best.inicio}`, ts };
|
||||
}
|
||||
|
||||
export function createAgendaBridge(pool) {
|
||||
const router = express.Router();
|
||||
|
||||
@@ -165,7 +234,13 @@ export function createAgendaBridge(pool) {
|
||||
// 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);
|
||||
// "Hoje" e "agora" no fuso da CLÍNICA (não do container). Os slots são rótulos
|
||||
// wall-clock (fmt); comparamos contra o "agora" da clínica expresso no mesmo frame.
|
||||
const tz = await getClinicaTZ(pool, clinicaId);
|
||||
const cn = partsNow(tz);
|
||||
const start0 = req.query.date ? new Date(`${req.query.date}T00:00:00`) : new Date(cn.year, cn.month - 1, cn.day);
|
||||
start0.setHours(0, 0, 0, 0);
|
||||
const nowClinic = new Date(cn.year, cn.month - 1, cn.day, cn.hour, cn.minute, 0, 0);
|
||||
const end0 = new Date(start0); end0.setDate(end0.getDate() + days);
|
||||
const { rows: busy } = await pool.query(
|
||||
`SELECT dentistaid, start_time, end_time FROM agendamentos
|
||||
@@ -175,7 +250,7 @@ export function createAgendaBridge(pool) {
|
||||
const busyBy = new Map();
|
||||
for (const bz of busy) { const a = busyBy.get(bz.dentistaid) || []; a.push(bz); busyBy.set(bz.dentistaid, a); }
|
||||
|
||||
const now = new Date();
|
||||
const now = nowClinic;
|
||||
const slots = [];
|
||||
for (let i = 0; i < days && slots.length < CANDIDATE_CAP; i++) {
|
||||
const day = new Date(start0); day.setDate(day.getDate() + i);
|
||||
@@ -264,10 +339,11 @@ export function createAgendaBridge(pool) {
|
||||
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).
|
||||
// "Hoje" no fuso da CLÍNICA (o servidor pode rodar em outro fuso).
|
||||
const tz = await getClinicaTZ(pool, clinicaId);
|
||||
const hojeBR = req.query.date
|
||||
? String(req.query.date)
|
||||
: new Date().toLocaleDateString('en-CA', { timeZone: 'America/Sao_Paulo' }); // YYYY-MM-DD
|
||||
: new Date().toLocaleDateString('en-CA', { timeZone: tz }); // 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); };
|
||||
@@ -309,7 +385,7 @@ export function createAgendaBridge(pool) {
|
||||
if (nac) feriados.push({ data: str, nome: nac, origem: 'nacional' });
|
||||
}
|
||||
|
||||
res.json({ hoje, proximo_aberto: proximo, semana, feriados_proximos: feriados.slice(0, 8) });
|
||||
res.json({ hoje, proximo_aberto: proximo, semana, feriados_proximos: feriados.slice(0, 8), timezone: tz });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
@@ -376,6 +452,12 @@ export function createAgendaBridge(pool) {
|
||||
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 */ }
|
||||
// Procedimentos que a clínica NÃO atende → a Secretária responde com o script.
|
||||
let procedimentos_nao_atende = [];
|
||||
try {
|
||||
const { rows } = await pool.query(`SELECT nome, motivo, como_falar FROM agenda_procedimentos WHERE clinica_id = $1 AND atende = 0 ORDER BY ordem, nome`, [clinicaId]);
|
||||
procedimentos_nao_atende = rows.map((r) => ({ nome: r.nome, motivo: r.motivo || null, como_falar: r.como_falar || null }));
|
||||
} catch { /* tabela pode não existir ainda */ }
|
||||
// Cautelas do checklist do dono (declaração × dados reais) → a IA não chuta.
|
||||
let cautelas = [];
|
||||
try {
|
||||
@@ -391,6 +473,7 @@ export function createAgendaBridge(pool) {
|
||||
especialidades: Array.isArray(d.especialidades) ? d.especialidades : (d.especialidade ? [d.especialidade] : []),
|
||||
})),
|
||||
situacoes,
|
||||
procedimentos_nao_atende,
|
||||
cautelas,
|
||||
});
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
@@ -407,11 +490,121 @@ export function createAgendaBridge(pool) {
|
||||
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);
|
||||
`SELECT id, nome, telefone, usuario_id 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 });
|
||||
const legacyTel = String(d.telefone || '').replace(/\D/g, '') || null;
|
||||
// Perfil de comunicação interna (múltiplos números + janelas de horário permitido).
|
||||
let numeros = [], temPerfil = false;
|
||||
if (d.usuario_id) {
|
||||
const { rows: pc } = await pool.query('SELECT numeros FROM pessoa_comunicacao WHERE usuario_id=$1 AND clinica_id=$2', [d.usuario_id, clinicaId]);
|
||||
if (pc[0]) { temPerfil = true; numeros = Array.isArray(pc[0].numeros) ? pc[0].numeros : []; }
|
||||
}
|
||||
// Sem perfil → back-compat: usa o telefone legado e NÃO bloqueia (não quebra dentistas antigos).
|
||||
if (!temPerfil) {
|
||||
return res.json({ encontrado: true, dentista_id: d.id, nome: d.nome, telefone: legacyTel, tem_perfil: false,
|
||||
contato_agora: legacyTel ? { numero: legacyTel, descricao: null } : null, bloqueado_por_janela: false, proxima_janela: null });
|
||||
}
|
||||
// Com perfil → só permite contato dentro de uma janela; senão devolve a próxima.
|
||||
const tz = await getClinicaTZ(pool, clinicaId); const clock = partsNow(tz);
|
||||
const agora = contatoPermitidoAgora(numeros, clock);
|
||||
const prox = agora ? null : proximaJanela(numeros, clock, tz);
|
||||
res.json({ encontrado: true, dentista_id: d.id, nome: d.nome, tem_perfil: true,
|
||||
telefone: agora ? agora.numero : null, contato_agora: agora,
|
||||
bloqueado_por_janela: !agora, proxima_janela: prox ? prox.label : null, proxima_janela_ts: prox ? prox.ts : null });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── GET /responsavel-contato ────────────────────────────────────────────────────
|
||||
// Resolve o contato do DONO/RESPONSÁVEL p/ uma decisão da clínica, com a janela.
|
||||
// nome? = pessoa específica (da lista de contatos — cobre o caso do dono cujo número
|
||||
// está na conta de dentista); sem nome = dono/responsável por papel. Mesma forma do
|
||||
// /dentista-contato (contato_agora / bloqueado_por_janela / proxima_janela[_ts]).
|
||||
router.get('/responsavel-contato', async (req, res) => {
|
||||
const clinicaId = String(req.query.clinica_id || '');
|
||||
if (!clinicaId) return res.status(400).json({ error: 'clinica_id obrigatório' });
|
||||
try {
|
||||
let row = null;
|
||||
if (req.query.nome) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT pc.usuario_id, pc.numeros, u.nome, v.role
|
||||
FROM pessoa_comunicacao pc
|
||||
LEFT JOIN usuarios u ON u.id = pc.usuario_id
|
||||
LEFT JOIN vinculos v ON v.usuario_id = pc.usuario_id AND v.clinica_id = pc.clinica_id
|
||||
WHERE pc.clinica_id = $1 AND lower(u.nome) LIKE '%'||lower($2)||'%' LIMIT 1`,
|
||||
[clinicaId, String(req.query.nome)]);
|
||||
row = rows[0] || null;
|
||||
}
|
||||
if (!row) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT pc.usuario_id, pc.numeros, u.nome, v.role
|
||||
FROM pessoa_comunicacao pc
|
||||
JOIN vinculos v ON v.usuario_id = pc.usuario_id AND v.clinica_id = pc.clinica_id
|
||||
LEFT JOIN usuarios u ON u.id = pc.usuario_id
|
||||
WHERE pc.clinica_id = $1 AND v.role IN ('donoclinica','donoconsultorio','admin')
|
||||
ORDER BY v.role LIMIT 1`,
|
||||
[clinicaId]);
|
||||
row = rows[0] || null;
|
||||
}
|
||||
if (!row) return res.json({ encontrado: false });
|
||||
const numeros = Array.isArray(row.numeros) ? row.numeros : [];
|
||||
const tz = await getClinicaTZ(pool, clinicaId); const clock = partsNow(tz);
|
||||
const agora = contatoPermitidoAgora(numeros, clock);
|
||||
const prox = agora ? null : proximaJanela(numeros, clock, tz);
|
||||
res.json({ encontrado: true, usuario_id: row.usuario_id, nome: row.nome, papel: row.role,
|
||||
telefone: agora ? agora.numero : null, contato_agora: agora,
|
||||
bloqueado_por_janela: !agora, proxima_janela: prox ? prox.label : null, proxima_janela_ts: prox ? prox.ts : null });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── GET /numeros-internos ───────────────────────────────────────────────────────
|
||||
// Só os dígitos dos números de pessoa_comunicacao da clínica (dentistas/equipe). O
|
||||
// motor usa p/ NÃO tratar um número interno como paciente (anti-loop/leak).
|
||||
router.get('/numeros-internos', 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 } = await pool.query('SELECT numeros FROM pessoa_comunicacao WHERE clinica_id = $1', [clinicaId]);
|
||||
const set = new Set();
|
||||
for (const r of rows) for (const n of (Array.isArray(r.numeros) ? r.numeros : [])) {
|
||||
const d = String(n?.numero || '').replace(/\D/g, '');
|
||||
if (d.length >= 8) set.add(d);
|
||||
}
|
||||
res.json({ numeros: [...set] });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── GET /contatos ─────────────────────────────────────────────────────────────
|
||||
// "Agenda de contatos internos" p/ roteamento por SITUAÇÃO. Lista todas as pessoas da
|
||||
// clínica (dono/dentista/funcionário) com o papel, as situações de cada número e se pode
|
||||
// ser contatada agora. A Secretária escolhe QUEM acionar pelo ASSUNTO (encaixe→dentista;
|
||||
// decisão/desconto/financeiro→dono). Não expõe o número cru (o envio é via /dentista-contato).
|
||||
router.get('/contatos', 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 } = await pool.query(
|
||||
`SELECT pc.usuario_id, pc.numeros, v.role, u.nome
|
||||
FROM pessoa_comunicacao pc
|
||||
LEFT JOIN vinculos v ON v.usuario_id = pc.usuario_id AND v.clinica_id = pc.clinica_id
|
||||
LEFT JOIN usuarios u ON u.id = pc.usuario_id
|
||||
WHERE pc.clinica_id = $1`, [clinicaId]);
|
||||
const tz = await getClinicaTZ(pool, clinicaId); const clock = partsNow(tz);
|
||||
const contatos = [];
|
||||
for (const r of rows) {
|
||||
const nums = Array.isArray(r.numeros) ? r.numeros : [];
|
||||
for (const n of nums) {
|
||||
if (String(n.numero || '').replace(/\D/g, '').length < 10) continue;
|
||||
const agora = contatoPermitidoAgora([n], clock);
|
||||
const prox = agora ? null : proximaJanela([n], clock, tz);
|
||||
contatos.push({
|
||||
nome: r.nome || null, papel: r.role || null,
|
||||
descricao: n.descricao || null, situacoes: n.situacoes || null,
|
||||
contato_agora: !!agora, proxima_janela: prox ? prox.label : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
res.json({ total: contatos.length, contatos });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
@@ -473,6 +666,116 @@ export function createAgendaBridge(pool) {
|
||||
}
|
||||
});
|
||||
|
||||
// ── GET /encaixe/analisar ─────────────────────────────────────────────────────
|
||||
// Fluxo de "adiantar consulta": dado um dia (e horário desejado), diz se o horário
|
||||
// está OCUPADO (por quem) e qual o horário LIVRE mais cedo que dá para oferecer/
|
||||
// encaixar — incluindo a janela FLEX (zona cinza), pois na contenção a Secretária
|
||||
// pode usá-la. Query: clinica_id, dentista_id?, data (YYYY-MM-DD), hora? (HH:MM).
|
||||
router.get('/encaixe/analisar', async (req, res) => {
|
||||
const clinicaId = String(req.query.clinica_id || '');
|
||||
const data = String(req.query.data || '');
|
||||
if (!clinicaId || !data) return res.status(400).json({ error: 'clinica_id e data obrigatórios' });
|
||||
const dentistaId = req.query.dentista_id ? String(req.query.dentista_id) : null;
|
||||
const horaDesejada = req.query.hora ? String(req.query.hora).slice(0, 5) : null;
|
||||
try {
|
||||
const day = new Date(`${data}T00:00:00`);
|
||||
const dow = day.getDay();
|
||||
const dParams = [clinicaId]; let dWhere = 'clinica_id = $1 AND ativo IS DISTINCT FROM 0';
|
||||
if (dentistaId) { dParams.push(dentistaId); dWhere += ` AND id = $${dParams.length}`; }
|
||||
const { rows: dentistas } = await pool.query(`SELECT id, nome FROM dentistas WHERE ${dWhere}`, dParams);
|
||||
if (!dentistas.length) return res.json({ mais_cedo: null, contestado: null });
|
||||
|
||||
const clinicHours = await carregarHorariosClinica(pool, clinicaId);
|
||||
const { janelas: clinicWins, fechado } = await janelasClinicaNoDia(pool, clinicaId, data, dow, clinicHours);
|
||||
if (fechado || !clinicWins.length) return res.json({ mais_cedo: null, contestado: null, motivo: 'fechado' });
|
||||
|
||||
// Jornada FLEX-inclusiva (usa hora_*_flex quando houver — a zona cinza entra no encaixe).
|
||||
const { rows: horarios } = await pool.query(
|
||||
`SELECT dentista_id,
|
||||
COALESCE(hora_inicio_flex, hora_inicio) AS hora_inicio,
|
||||
COALESCE(hora_fim_flex, hora_fim) AS hora_fim
|
||||
FROM dentistas_horarios WHERE clinica_id = $1 AND ativo = 1 AND dia_semana = $2`, [clinicaId, dow]);
|
||||
const hoursBy = new Map();
|
||||
for (const h of horarios) { const a = hoursBy.get(h.dentista_id) || []; a.push([hm(h.hora_inicio), hm(h.hora_fim)]); hoursBy.set(h.dentista_id, a); }
|
||||
|
||||
const start0 = new Date(day); start0.setHours(0, 0, 0, 0);
|
||||
const end0 = new Date(start0); end0.setDate(end0.getDate() + 1);
|
||||
const { rows: busy } = await pool.query(
|
||||
`SELECT id, dentistaid, pacientenome, pacientecelular, start_time, end_time FROM agendamentos
|
||||
WHERE clinica_id=$1 AND start_time>=$2 AND start_time<$3
|
||||
AND (status IS NULL OR lower(status) NOT IN ('cancelado','falta','remarcar'))`,
|
||||
[clinicaId, start0.toISOString(), end0.toISOString()]);
|
||||
const busyBy = new Map();
|
||||
for (const bz of busy) { const a = busyBy.get(bz.dentistaid) || []; a.push(bz); busyBy.set(bz.dentistaid, a); }
|
||||
|
||||
// Quem ocupa o horário desejado (direto, sem depender do grid).
|
||||
let contestado = null;
|
||||
if (horaDesejada) {
|
||||
const alvo = new Date(`${data}T${horaDesejada}:00`);
|
||||
const occ = busy.find((b) => new Date(b.start_time) <= alvo && new Date(b.end_time || b.start_time) > alvo)
|
||||
|| busy.find((b) => fmt(new Date(b.start_time)).slice(11, 16) === horaDesejada);
|
||||
if (occ) {
|
||||
const dn = dentistas.find((d) => d.id === occ.dentistaid);
|
||||
contestado = { agendamento_id: occ.id, dentista_id: occ.dentistaid, dentista_nome: dn?.nome || null,
|
||||
paciente_nome: occ.pacientenome || null, paciente_celular: occ.pacientecelular || null,
|
||||
start: fmt(new Date(occ.start_time)), hora: fmt(new Date(occ.start_time)).slice(11, 16) };
|
||||
}
|
||||
}
|
||||
|
||||
// Slots LIVRES do dia (flex-inclusivo).
|
||||
const now = new Date(); const dur = SLOT_MIN; const livres = [];
|
||||
for (const dent of dentistas) {
|
||||
const dw = (hoursBy.get(dent.id) && hoursBy.get(dent.id).length) ? hoursBy.get(dent.id) : clinicWins;
|
||||
const windows = interseccao(clinicWins, dw);
|
||||
const bs = busyBy.get(dent.id) || [];
|
||||
for (const [ini, fim] of windows) {
|
||||
const [hi, mi] = String(ini).split(':').map(Number);
|
||||
const [hf, mf] = String(fim).split(':').map(Number);
|
||||
let t = new Date(day); t.setHours(hi, mi || 0, 0, 0);
|
||||
const limit = new Date(day); limit.setHours(hf, mf || 0, 0, 0);
|
||||
while (t.getTime() + dur * 60000 <= limit.getTime()) {
|
||||
const s = new Date(t), e = new Date(t.getTime() + dur * 60000); t = e;
|
||||
if (s <= now) continue;
|
||||
if (bs.some((b) => new Date(b.start_time) < e && new Date(b.end_time || b.start_time) > s)) continue;
|
||||
livres.push({ dentista_id: dent.id, dentista_nome: dent.nome, start: fmt(s), end: fmt(e), hora: fmt(s).slice(11, 16) });
|
||||
}
|
||||
}
|
||||
}
|
||||
livres.sort((a, b) => a.start.localeCompare(b.start));
|
||||
// mais_cedo = livre imediatamente ANTES do desejado (o mais perto por baixo); senão o 1º livre.
|
||||
let mais_cedo = null;
|
||||
if (horaDesejada) {
|
||||
const antes = livres.filter((s) => s.hora < horaDesejada);
|
||||
mais_cedo = antes.length ? antes[antes.length - 1] : (livres[0] || null);
|
||||
} else {
|
||||
mais_cedo = livres[0] || null;
|
||||
}
|
||||
res.json({ mais_cedo, contestado, total_livres: livres.length });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── POST /encaixe/mover ───────────────────────────────────────────────────────
|
||||
// Move um agendamento existente para um novo horário (o A aceitou adiantar).
|
||||
// Body: { clinica_id, agendamento_id, start, end? }.
|
||||
router.post('/encaixe/mover', async (req, res) => {
|
||||
const b = req.body || {};
|
||||
if (!b.clinica_id || !b.agendamento_id || !b.start) return res.status(400).json({ error: 'clinica_id, agendamento_id e start obrigatórios' });
|
||||
const end = b.end || new Date(new Date(b.start).getTime() + SLOT_MIN * 60000).toISOString();
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const { rows } = await client.query('SELECT id, dentistaid FROM agendamentos WHERE id=$1 AND clinica_id=$2 FOR UPDATE', [b.agendamento_id, b.clinica_id]);
|
||||
const ag = rows[0];
|
||||
if (!ag) { await client.query('ROLLBACK'); return res.status(404).json({ error: 'Agendamento não encontrado.' }); }
|
||||
const conflict = await findConflict(client, ag.dentistaid, b.start, end, b.agendamento_id);
|
||||
if (conflict) { await client.query('ROLLBACK'); return res.status(409).json({ error: 'Horário de destino já ocupado.' }); }
|
||||
await client.query('UPDATE agendamentos SET start_time=$1, end_time=$2, updated_at=NOW() WHERE id=$3', [b.start, end, b.agendamento_id]);
|
||||
await client.query('COMMIT');
|
||||
res.json({ ok: true });
|
||||
} catch (e) { try { await client.query('ROLLBACK'); } catch { /* ignore */ } res.status(500).json({ error: e.message }); }
|
||||
finally { client.release(); }
|
||||
});
|
||||
|
||||
// ════════ Cadastro de pacientes (Secretária super inteligente) ════════════════
|
||||
const soDigitos = (v) => String(v || '').replace(/\D/g, '');
|
||||
function idadeDe(dob) {
|
||||
@@ -516,6 +819,19 @@ export function createAgendaBridge(pool) {
|
||||
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' });
|
||||
// Validação server-side (dígito verificador REAL): a Ana relata ok:false p/ corrigir.
|
||||
if (b.cpf) {
|
||||
const d = validarCpfCnpj(b.cpf);
|
||||
if (!d.valido) return res.json({ ok: false, campo: 'cpf', mensagem: 'O CPF/CNPJ informado não é válido. Peça para a pessoa conferir e reenviar.' });
|
||||
}
|
||||
if (b.data_nascimento) {
|
||||
const n = validarNascimento(b.data_nascimento);
|
||||
if (!n.valido) {
|
||||
const msg = n.motivo === 'futura' ? 'A data de nascimento está no futuro.' : n.motivo === 'muito_antiga' ? 'A data de nascimento não parece plausível.' : 'A data de nascimento não é uma data válida.';
|
||||
return res.json({ ok: false, campo: 'data_nascimento', mensagem: `${msg} Peça para conferir e reenviar (DD/MM/AAAA).` });
|
||||
}
|
||||
b.data_nascimento = n.iso; // normaliza p/ YYYY-MM-DD antes de gravar
|
||||
}
|
||||
const cpf = soDigitos(b.cpf);
|
||||
const telefone = b.telefone ? soDigitos(b.telefone) : null;
|
||||
const idade = idadeDe(b.data_nascimento);
|
||||
@@ -557,19 +873,22 @@ export function createAgendaBridge(pool) {
|
||||
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.
|
||||
// dados_confirmados=false: cadastro veio da Secretária (IA) → a recepção CONFIRMA
|
||||
// no check-in (Feature C). O modal de confirmação some quando a humana valida.
|
||||
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`,
|
||||
grupofamiliarid=COALESCE($7,grupofamiliarid), grupofamiliarrelacao=COALESCE($8,grupofamiliarrelacao),
|
||||
dados_confirmados=false 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)`,
|
||||
`INSERT INTO pacientes (id, clinica_id, nome, cpf, telefone, email, convenio, datanascimento, grupofamiliarid, grupofamiliarrelacao, dados_confirmados)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,false)`,
|
||||
[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 } });
|
||||
@@ -579,5 +898,229 @@ export function createAgendaBridge(pool) {
|
||||
} finally { client.release(); }
|
||||
});
|
||||
|
||||
// ── Helper: confirma que o agendamento pertence ao telefone (segurança) ──────
|
||||
// Impede a IA de cancelar/remarcar a consulta de terceiros só pelo id.
|
||||
async function agendamentoDoTelefone(db, clinicaId, agendamentoId, phone) {
|
||||
const tel = soDigitos(phone).slice(-8);
|
||||
const { rows } = await db.query(
|
||||
`SELECT a.id, a.dentistaid, a.pacientenome, a.procedimento, a.status, a.sala_id,
|
||||
to_char(a.start_time,'YYYY-MM-DD') AS data, to_char(a.start_time,'HH24:MI') AS hora,
|
||||
regexp_replace(coalesce(a.pacientecelular,''),'\\D','','g') AS ag_tel,
|
||||
regexp_replace(coalesce(p.telefone,''),'\\D','','g') AS pac_tel,
|
||||
d.nome AS dentista
|
||||
FROM agendamentos a
|
||||
LEFT JOIN pacientes p ON p.id = a.pacienteid
|
||||
LEFT JOIN dentistas d ON d.id = a.dentistaid
|
||||
WHERE a.id = $1 AND a.clinica_id = $2 LIMIT 1`,
|
||||
[agendamentoId, clinicaId]);
|
||||
const a = rows[0];
|
||||
if (!a) return { ok: false, motivo: 'nao_encontrado' };
|
||||
if (tel && !(String(a.ag_tel).endsWith(tel) || String(a.pac_tel).endsWith(tel))) {
|
||||
return { ok: false, motivo: 'nao_pertence' };
|
||||
}
|
||||
return { ok: true, ag: a };
|
||||
}
|
||||
const statusInativo = (s) => ['cancelado', 'falta', 'faltou'].includes(String(s || '').toLowerCase());
|
||||
|
||||
// ── GET /consultas ────────────────────────────────────────────────────────────
|
||||
// Consultas do telefone/família. Futuras por padrão; incluir_passadas=true traz o histórico.
|
||||
router.get('/consultas', 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' });
|
||||
const incluirPassadas = String(req.query.incluir_passadas || '') === 'true';
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT a.id, to_char(a.start_time,'YYYY-MM-DD') AS data, to_char(a.start_time,'HH24:MI') AS hora,
|
||||
to_char(a.end_time,'HH24:MI') AS hora_fim, a.status, a.procedimento, a.pacientenome,
|
||||
d.nome AS dentista
|
||||
FROM agendamentos a
|
||||
LEFT JOIN dentistas d ON d.id = a.dentistaid
|
||||
LEFT JOIN pacientes p ON p.id = a.pacienteid
|
||||
WHERE a.clinica_id = $1
|
||||
AND ( regexp_replace(coalesce(a.pacientecelular,''),'\\D','','g') LIKE '%'||$2
|
||||
OR regexp_replace(coalesce(p.telefone,''),'\\D','','g') LIKE '%'||$2 )
|
||||
AND lower(coalesce(a.status,'agendado')) NOT IN ('cancelado','falta','faltou')
|
||||
${incluirPassadas ? '' : "AND a.start_time >= NOW() - interval '2 hours'"}
|
||||
ORDER BY a.start_time ASC LIMIT 20`,
|
||||
[clinicaId, tel]);
|
||||
res.json({
|
||||
total: rows.length,
|
||||
consultas: rows.map((r) => ({
|
||||
agendamento_id: r.id, data: r.data, hora: r.hora, hora_fim: r.hora_fim,
|
||||
dentista: r.dentista || null, procedimento: r.procedimento || 'Consulta',
|
||||
status: r.status || 'agendado', paciente: r.pacientenome || null,
|
||||
})),
|
||||
});
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── POST /confirmar ───────────────────────────────────────────────────────────
|
||||
// Paciente confirma presença numa consulta futura. Body: clinica_id, agendamento_id, phone.
|
||||
router.post('/confirmar', async (req, res) => {
|
||||
const b = req.body || {};
|
||||
if (!b.clinica_id || !b.agendamento_id) return res.status(400).json({ error: 'clinica_id e agendamento_id obrigatórios' });
|
||||
try {
|
||||
const chk = await agendamentoDoTelefone(pool, b.clinica_id, b.agendamento_id, b.phone || '');
|
||||
if (!chk.ok) return res.status(chk.motivo === 'nao_encontrado' ? 404 : 403).json({ error: chk.motivo === 'nao_encontrado' ? 'Consulta não encontrada.' : 'Esta consulta não é deste número.' });
|
||||
if (statusInativo(chk.ag.status)) return res.status(409).json({ error: 'Consulta não está ativa.' });
|
||||
await pool.query(
|
||||
`UPDATE agendamentos SET status='confirmado', updated_by_nome='Secretária IA', updated_at=NOW(), version=COALESCE(version,1)+1 WHERE id=$1`,
|
||||
[b.agendamento_id]);
|
||||
res.json({ ok: true, agendamento_id: b.agendamento_id, status: 'confirmado', data: chk.ag.data, hora: chk.ag.hora, dentista: chk.ag.dentista || null });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── POST /cancelar ────────────────────────────────────────────────────────────
|
||||
// Body: clinica_id, agendamento_id, phone, motivo?
|
||||
router.post('/cancelar', async (req, res) => {
|
||||
const b = req.body || {};
|
||||
if (!b.clinica_id || !b.agendamento_id) return res.status(400).json({ error: 'clinica_id e agendamento_id obrigatórios' });
|
||||
try {
|
||||
const chk = await agendamentoDoTelefone(pool, b.clinica_id, b.agendamento_id, b.phone || '');
|
||||
if (!chk.ok) return res.status(chk.motivo === 'nao_encontrado' ? 404 : 403).json({ error: chk.motivo === 'nao_encontrado' ? 'Consulta não encontrada.' : 'Esta consulta não é deste número.' });
|
||||
if (statusInativo(chk.ag.status)) return res.status(409).json({ error: 'Consulta já estava cancelada/inativa.' });
|
||||
const motivo = b.motivo ? String(b.motivo).slice(0, 300) : null;
|
||||
await pool.query(
|
||||
`UPDATE agendamentos
|
||||
SET status='cancelado', canceled_by_nome='Secretária IA', canceled_at=NOW(),
|
||||
version=COALESCE(version,1)+1,
|
||||
observacoes = CASE WHEN $2::text IS NULL THEN observacoes
|
||||
ELSE trim(both ' ' from coalesce(observacoes,'') || ' | Cancelado via WhatsApp: ' || $2) END
|
||||
WHERE id=$1`,
|
||||
[b.agendamento_id, motivo]);
|
||||
res.json({ ok: true, agendamento_id: b.agendamento_id, status: 'cancelado', data: chk.ag.data, hora: chk.ag.hora, dentista: chk.ag.dentista || null });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── POST /remarcar ────────────────────────────────────────────────────────────
|
||||
// Move a consulta para um novo horário livre. Body: clinica_id, agendamento_id, phone,
|
||||
// dentista_id, start (ISO), end?, sala_id?. Anti-overbooking (exclui a própria consulta).
|
||||
router.post('/remarcar', async (req, res) => {
|
||||
const b = req.body || {};
|
||||
if (!b.clinica_id || !b.agendamento_id || !b.dentista_id || !b.start) {
|
||||
return res.status(400).json({ error: 'clinica_id, agendamento_id, dentista_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 {
|
||||
const chk = await agendamentoDoTelefone(client, b.clinica_id, b.agendamento_id, b.phone || '');
|
||||
if (!chk.ok) { client.release(); return res.status(chk.motivo === 'nao_encontrado' ? 404 : 403).json({ error: chk.motivo === 'nao_encontrado' ? 'Consulta não encontrada.' : 'Esta consulta não é deste número.' }); }
|
||||
if (statusInativo(chk.ag.status)) { client.release(); return res.status(409).json({ error: 'Consulta não está ativa para remarcar.' }); }
|
||||
await client.query('BEGIN');
|
||||
const conflict = await findConflict(client, b.dentista_id, b.start, end, b.agendamento_id);
|
||||
if (conflict) { await client.query('ROLLBACK'); client.release(); return res.status(409).json({ error: 'Novo horário já ocupado.', conflict_id: conflict.id }); }
|
||||
const { rows: dd } = await client.query('SELECT setor_id FROM dentistas WHERE id=$1 AND clinica_id=$2', [b.dentista_id, b.clinica_id]);
|
||||
const setorId = dd[0]?.setor_id ?? null;
|
||||
const { rows: upd } = await client.query(
|
||||
`UPDATE agendamentos
|
||||
SET dentistaid=$2, start_time=$3, end_time=$4, status='agendado', setor_id=COALESCE($5, setor_id),
|
||||
sala_id=COALESCE($6, sala_id), updated_by_nome='Secretária IA', updated_at=NOW(),
|
||||
version=COALESCE(version,1)+1
|
||||
WHERE id=$1 RETURNING id, to_char(start_time,'YYYY-MM-DD') AS data, to_char(start_time,'HH24:MI') AS hora`,
|
||||
[b.agendamento_id, b.dentista_id, b.start, end, setorId, b.sala_id || null]);
|
||||
await client.query('COMMIT');
|
||||
const { rows: dn } = await pool.query('SELECT nome FROM dentistas WHERE id=$1', [b.dentista_id]);
|
||||
res.json({ ok: true, agendamento_id: b.agendamento_id, status: 'agendado', data: upd[0]?.data, hora: upd[0]?.hora, dentista: dn[0]?.nome || null });
|
||||
} catch (e) {
|
||||
try { await client.query('ROLLBACK'); } catch { /* ignore */ }
|
||||
res.status(500).json({ error: e.message });
|
||||
} finally { try { client.release(); } catch { /* já liberado */ } }
|
||||
});
|
||||
|
||||
// ── GET /paciente-contexto ────────────────────────────────────────────────────
|
||||
// Contexto INTERNO do paciente p/ a Secretária: dentista responsável + se há
|
||||
// tratamento em andamento. NÃO é p/ recitar ao cliente — serve p/ dar continuidade
|
||||
// (agendar com o MESMO dentista) sem ficar falando de tratamento nem repetir nomes.
|
||||
router.get('/paciente-contexto', async (req, res) => {
|
||||
const clinicaId = String(req.query.clinica_id || '');
|
||||
const pacienteId = req.query.paciente_id ? String(req.query.paciente_id) : null;
|
||||
const tel = soDigitos(req.query.phone).slice(-8);
|
||||
// Inadimplente = parcela (RECEITA) vencida há mais de N dias e ainda em aberto.
|
||||
// N vem do motor (config do dono); default 30. Falta recente = no-show nos últimos 60 dias.
|
||||
const inadDias = Math.max(0, parseInt(String(req.query.inadimplente_dias || '30'), 10) || 30);
|
||||
if (!clinicaId || (!pacienteId && !tel)) return res.status(400).json({ error: 'clinica_id e (paciente_id ou phone) obrigatórios' });
|
||||
try {
|
||||
let pacientes;
|
||||
if (pacienteId) {
|
||||
const { rows } = await pool.query('SELECT id, nome FROM pacientes WHERE id=$1 AND clinica_id=$2', [pacienteId, clinicaId]);
|
||||
pacientes = rows;
|
||||
} else {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, nome FROM pacientes WHERE clinica_id=$1 AND regexp_replace(coalesce(telefone,''),'\\D','','g') LIKE '%'||$2`, [clinicaId, tel]);
|
||||
pacientes = rows;
|
||||
}
|
||||
const out = [];
|
||||
for (const p of pacientes) {
|
||||
// Orçamento/tratamento em aberto → dentista responsável + em_tratamento.
|
||||
const { rows: orc } = await pool.query(
|
||||
`SELECT dentista_nome, dentista_id FROM orcamentos
|
||||
WHERE paciente_id=$1 AND lower(coalesce(status,'')) NOT IN ('concluido','cancelado','recusado','rejeitado','finalizado')
|
||||
ORDER BY created_at DESC NULLS LAST LIMIT 1`, [p.id]).catch(() => ({ rows: [] }));
|
||||
let dentistaNome = orc[0]?.dentista_nome || null;
|
||||
let dentistaId = orc[0]?.dentista_id || null;
|
||||
let emTratamento = orc.length > 0;
|
||||
if (!dentistaNome) {
|
||||
const { rows: ag } = await pool.query(
|
||||
`SELECT a.dentistaid, d.nome AS dentista, (a.start_time >= NOW() - interval '120 days') AS recente
|
||||
FROM agendamentos a LEFT JOIN dentistas d ON d.id=a.dentistaid
|
||||
WHERE a.pacienteid=$1 AND lower(coalesce(a.status,'agendado')) NOT IN ('cancelado','falta','faltou')
|
||||
ORDER BY a.start_time DESC LIMIT 1`, [p.id]).catch(() => ({ rows: [] }));
|
||||
dentistaNome = ag[0]?.dentista || null; dentistaId = ag[0]?.dentistaid || null;
|
||||
if (ag[0]?.recente) emTratamento = true;
|
||||
}
|
||||
// Inadimplência: parcelas RECEITA vencidas há > N dias, ainda em aberto
|
||||
// (pago_em nulo e status não-pago). Resumo é INTERNO (nunca recitado ao paciente).
|
||||
const { rows: inad } = await pool.query(
|
||||
`SELECT COUNT(*)::int AS n, COALESCE(SUM(valor),0) AS total, MIN(datavencimento) AS mais_antiga
|
||||
FROM financeiro
|
||||
WHERE paciente_id=$1 AND clinica_id=$2 AND deleted_at IS NULL
|
||||
AND upper(coalesce(tipo,'RECEITA'))='RECEITA'
|
||||
AND pago_em IS NULL
|
||||
AND lower(coalesce(status,'')) NOT IN ('pago','recebido','cancelado')
|
||||
AND datavencimento < (CURRENT_DATE - ($3 || ' days')::interval)`,
|
||||
[p.id, clinicaId, inadDias]).catch(() => ({ rows: [{ n: 0, total: 0, mais_antiga: null }] }));
|
||||
const inadN = inad[0]?.n || 0;
|
||||
const inadimplente = inadN > 0;
|
||||
const inadimplencia_resumo = inadimplente
|
||||
? `${inadN} parcela(s) vencida(s) há +${inadDias}d, total R$ ${Number(inad[0].total).toFixed(2)}, mais antiga venc. ${String(inad[0].mais_antiga).slice(0, 10)}`
|
||||
: null;
|
||||
|
||||
// Falta recente (no-show) nos últimos 60 dias.
|
||||
const { rows: falta } = await pool.query(
|
||||
`SELECT COUNT(*)::int AS n FROM agendamentos
|
||||
WHERE pacienteid=$1 AND lower(coalesce(status,'')) IN ('falta','faltou')
|
||||
AND start_time >= NOW() - interval '60 days'`, [p.id]).catch(() => ({ rows: [{ n: 0 }] }));
|
||||
const falta_recente = (falta[0]?.n || 0) > 0;
|
||||
|
||||
out.push({ paciente_id: p.id, nome: p.nome, dentista_responsavel: dentistaNome, dentista_id: dentistaId, em_tratamento: emTratamento, inadimplente, inadimplencia_resumo, falta_recente });
|
||||
}
|
||||
res.json({ pacientes: out });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── POST /notificar ───────────────────────────────────────────────────────────
|
||||
// Cria uma notificação in-app para a EQUIPE da clínica (todos os vínculos). Usado
|
||||
// pela Secretária p/ avisar a recepção de eventos que exigem humano (ex.: cliente
|
||||
// insistiu num dentista específico). Server-to-server (x-nw-agenda-secret).
|
||||
router.post('/notificar', async (req, res) => {
|
||||
const { clinica_id, titulo, mensagem, tipo } = req.body || {};
|
||||
if (!clinica_id || !titulo) return res.status(400).json({ error: 'clinica_id e titulo obrigatórios' });
|
||||
const t = ['sucesso', 'info', 'alerta', 'erro', 'aviso'].includes(String(tipo)) ? String(tipo) : 'alerta';
|
||||
try {
|
||||
const { rows } = await pool.query('SELECT DISTINCT usuario_id FROM vinculos WHERE clinica_id = $1', [clinica_id]);
|
||||
let n = 0;
|
||||
for (const r of rows) {
|
||||
if (!r.usuario_id) continue;
|
||||
await pool.query(
|
||||
`INSERT INTO notificacoes (id, titulo, mensagem, tipo, lida, data, usuario_id, enviado_por)
|
||||
VALUES (gen_random_uuid(), $1, $2, $3, 0, NOW(), $4, 'secretaria')`,
|
||||
[String(titulo).slice(0, 120), String(mensagem || '').slice(0, 500), t, r.usuario_id]);
|
||||
n++;
|
||||
}
|
||||
res.json({ ok: true, notificados: n });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -243,6 +243,50 @@ export function createAgendaConfig(pool) {
|
||||
finally { client.release(); }
|
||||
});
|
||||
|
||||
// ── Procedimentos que a clínica atende / não atende (dono) ────────────────────
|
||||
router.get('/clinica/:clinicaId/procedimentos-atende', async (req, res) => {
|
||||
const clinicaId = req.params.clinicaId;
|
||||
try {
|
||||
let { rows } = await pool.query(
|
||||
`SELECT id, nome, atende, motivo, como_falar, ordem FROM agenda_procedimentos WHERE clinica_id = $1 ORDER BY ordem, nome`, [clinicaId]);
|
||||
// Primeira vez: semeia a lista a partir do catálogo de procedimentos da clínica (atende=1).
|
||||
if (!rows.length) {
|
||||
const { rows: cat } = await pool.query(
|
||||
`SELECT DISTINCT nome FROM procedimentos WHERE (clinica_id = $1 OR clinica_id IS NULL) AND ativo IS DISTINCT FROM false AND nome IS NOT NULL AND nome <> '' ORDER BY nome`, [clinicaId]);
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
let i = 0;
|
||||
for (const c of cat) await client.query(`INSERT INTO agenda_procedimentos (id, clinica_id, nome, atende, ordem) VALUES ($1,$2,$3,1,$4)`, [uid('prc'), clinicaId, c.nome, i++]);
|
||||
await client.query('COMMIT');
|
||||
} catch { try { await client.query('ROLLBACK'); } catch {} } finally { client.release(); }
|
||||
({ rows } = await pool.query(`SELECT id, nome, atende, motivo, como_falar, ordem FROM agenda_procedimentos WHERE clinica_id = $1 ORDER BY ordem, nome`, [clinicaId]));
|
||||
}
|
||||
res.json({ procedimentos: rows });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
// Substitui toda a lista. Body: { procedimentos: [{nome, atende, motivo, como_falar}] }
|
||||
router.put('/clinica/:clinicaId/procedimentos-atende', 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 os procedimentos.' });
|
||||
const linhas = Array.isArray(req.body?.procedimentos) ? req.body.procedimentos : [];
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query('DELETE FROM agenda_procedimentos WHERE clinica_id = $1', [clinicaId]);
|
||||
let i = 0;
|
||||
for (const p of linhas) {
|
||||
const nome = String(p?.nome || '').trim(); if (!nome) continue;
|
||||
const atende = (p?.atende === false || p?.atende === 0) ? 0 : 1;
|
||||
await client.query(
|
||||
`INSERT INTO agenda_procedimentos (id, clinica_id, nome, atende, motivo, como_falar, ordem) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
|
||||
[uid('prc'), clinicaId, nome, atende, atende ? null : (String(p?.motivo || '').trim() || null), atende ? null : (String(p?.como_falar || '').trim() || null), 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) {
|
||||
@@ -349,8 +393,18 @@ export function createAgendaConfig(pool) {
|
||||
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`;
|
||||
// Horário pode ser AJUSTADO na confirmação: a IA fixou um horário, mas a humana
|
||||
// combinou outro com o paciente por telefone. Aceita HH:MM válido; senão usa o do pedido.
|
||||
const hi = /^([01]\d|2[0-3]):[0-5]\d$/.test(String(req.body?.hora_inicio || ''))
|
||||
? String(req.body.hora_inicio)
|
||||
: String(ped.hora_inicio).slice(0, 5);
|
||||
// Dia pode ser TROCADO na confirmação: o paciente pediu outro dia. Aceita YYYY-MM-DD
|
||||
// válido e não-passado; senão usa o do pedido.
|
||||
const pedData = (ped.data instanceof Date ? ped.data.toISOString().slice(0, 10) : String(ped.data).slice(0, 10));
|
||||
const reqData = String(req.body?.data || '');
|
||||
const hojeStr = new Date().toISOString().slice(0, 10);
|
||||
const dataConf = (/^\d{4}-\d{2}-\d{2}$/.test(reqData) && reqData >= hojeStr) ? reqData : pedData;
|
||||
const start = `${dataConf} ${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]);
|
||||
@@ -400,5 +454,40 @@ export function createAgendaConfig(pool) {
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Nomes NOSSOS (base de pacientes) por telefone — p/ o /wa-inbox mostrar o nome que
|
||||
// cadastramos no lugar do número/pushname. Casa pelos últimos 8 dígitos. Um número
|
||||
// pode ter VÁRIOS pacientes (grupo familiar): devolve todos + um `principal`
|
||||
// (1 paciente → o nome; N → "1º nome +N"). Titular designado virá na Feature B.
|
||||
router.post('/clinica/:clinicaId/nomes-por-telefone', async (req, res) => {
|
||||
const clinicaId = req.params.clinicaId;
|
||||
if (!(await ehMembro(clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Sem acesso.' });
|
||||
const lista = Array.isArray(req.body?.telefones) ? req.body.telefones : [];
|
||||
const chaves = [...new Set(lista.map((t) => String(t || '').replace(/\D/g, '').slice(-8)).filter((k) => k.length >= 8))];
|
||||
if (chaves.length === 0) return res.json({ mapa: {} });
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, nome, grupofamiliarrelacao,
|
||||
right(regexp_replace(coalesce(telefone,''),'\\D','','g'), 8) AS chave
|
||||
FROM pacientes
|
||||
WHERE clinica_id = $1
|
||||
AND right(regexp_replace(coalesce(telefone,''),'\\D','','g'), 8) = ANY($2::text[])
|
||||
ORDER BY nome`,
|
||||
[clinicaId, chaves]);
|
||||
const mapa = {};
|
||||
for (const p of rows) {
|
||||
(mapa[p.chave] ??= { pacientes: [] }).pacientes.push({ id: p.id, nome: p.nome, relacao: p.grupofamiliarrelacao || null });
|
||||
}
|
||||
for (const k of Object.keys(mapa)) {
|
||||
const ps = mapa[k].pacientes;
|
||||
mapa[k].count = ps.length;
|
||||
// Principal: se há titular no grupo, mostra o titular; senão "1º nome +N".
|
||||
const titular = ps.find((x) => String(x.relacao || '').toLowerCase() === 'titular');
|
||||
if (titular) mapa[k].principal = ps.length <= 1 ? titular.nome : `${titular.nome.split(' ')[0]} +${ps.length - 1}`;
|
||||
else { const primeiro = ps[0]?.nome || ''; mapa[k].principal = ps.length <= 1 ? primeiro : `${primeiro.split(' ')[0]} +${ps.length - 1}`; }
|
||||
}
|
||||
res.json({ mapa });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -98,6 +98,21 @@ export async function ensureAgendaSchema(pool) {
|
||||
)`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_agenda_situacoes_clinica ON agenda_situacoes (clinica_id)`);
|
||||
|
||||
// ── agenda_procedimentos — o que a clínica ATENDE ou não. Desmarcado (atende=0)
|
||||
// = não atende: guarda o motivo e o "como_falar" (script p/ a Secretária responder).
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS agenda_procedimentos (
|
||||
id varchar PRIMARY KEY,
|
||||
clinica_id varchar NOT NULL,
|
||||
nome text NOT NULL,
|
||||
atende smallint DEFAULT 1,
|
||||
motivo text,
|
||||
como_falar text,
|
||||
ordem integer DEFAULT 0,
|
||||
created_at timestamptz DEFAULT now()
|
||||
)`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_agenda_procedimentos_clinica ON agenda_procedimentos (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.
|
||||
|
||||
+195
-28
@@ -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
|
||||
@@ -48,18 +60,19 @@ async function resolveOwnerId(pool, clinicaId) {
|
||||
// Retorna { ok: true } ou { ok: false, status, error }.
|
||||
async function guardSessionAction(pool, req, tail, userId) {
|
||||
const method = req.method.toUpperCase();
|
||||
const m = tail.match(/^\/sessions(?:\/([^/?]+)(\/qr|\/connect)?)?/);
|
||||
const m = tail.match(/^\/sessions(?:\/([^/?]+)(\/qr|\/connect|\/disconnect)?)?/);
|
||||
if (!m) return { ok: true }; // não é uma rota de sessão
|
||||
|
||||
const instanceId = m[1] || null;
|
||||
const sub = m[2] || null;
|
||||
|
||||
const isCreate = method === 'POST' && !instanceId; // POST /sessions
|
||||
const isRescan = (method === 'GET' && sub === '/qr') ||
|
||||
(method === 'POST' && sub === '/connect'); // parear/reconectar
|
||||
const isDelete = method === 'DELETE' && instanceId && !sub; // DELETE /sessions/:id
|
||||
const isCreate = method === 'POST' && !instanceId; // POST /sessions
|
||||
const isRescan = (method === 'GET' && sub === '/qr') ||
|
||||
(method === 'POST' && sub === '/connect'); // parear/reconectar
|
||||
const isDisconnect = method === 'POST' && sub === '/disconnect'; // desligar a sessão (gestão da conexão)
|
||||
const isDelete = method === 'DELETE' && instanceId && !sub; // DELETE /sessions/:id
|
||||
|
||||
if (!isCreate && !isRescan && !isDelete) return { ok: true }; // GET lista / etc → livre
|
||||
if (!isCreate && !isRescan && !isDisconnect && !isDelete) return { ok: true }; // GET lista / etc → livre
|
||||
|
||||
const clinicaId = req.headers['x-nw-clinica'] ? String(req.headers['x-nw-clinica']) : null;
|
||||
if (!clinicaId) return { ok: false, status: 403, error: 'Workspace não identificado para esta ação.' };
|
||||
@@ -80,14 +93,17 @@ async function guardSessionAction(pool, req, tail, userId) {
|
||||
authz = rows[0] || null;
|
||||
} catch { /* nega por padrão */ }
|
||||
|
||||
if (isRescan && authz?.can_rescan) return { ok: true };
|
||||
// Desconectar é gestão da conexão (o inverso de reconectar) → mesma permissão de re-scan.
|
||||
if ((isRescan || isDisconnect) && authz?.can_rescan) return { ok: true };
|
||||
if (isDelete && authz?.can_delete) return { ok: true };
|
||||
|
||||
return {
|
||||
ok: false, status: 403,
|
||||
error: isDelete
|
||||
? 'Somente o dono pode excluir esta sessão. Peça autorização de exclusão.'
|
||||
: 'Somente o dono pode reconectar esta sessão. Peça autorização de re-scan.',
|
||||
: isDisconnect
|
||||
? 'Somente o dono pode desconectar esta sessão. Peça autorização de re-scan.'
|
||||
: 'Somente o dono pode reconectar esta sessão. Peça autorização de re-scan.',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -115,7 +131,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]);
|
||||
@@ -140,8 +156,11 @@ async function operatorSignature(pool, userId, clinicaId) {
|
||||
// Gate de acesso a ÁREAS (Inbox / Secretária). Dono passa sempre; os demais só com
|
||||
// delegação (can_inbox / can_secretaria) do dono em alguma sessão do workspace.
|
||||
async function guardAreaAccess(pool, tail, userId, clinicaId) {
|
||||
const isSecretaria = /^\/(secretaria|sec)(\/|$|\?)/.test(tail);
|
||||
const isInbox = /^\/(inbox|conversations)(\/|$|\?)/.test(tail);
|
||||
// /sec/handoff = assumir/soltar o CHAT (ação de INBOX), não config da Secretária.
|
||||
// O resto de /sec/* e todo /secretaria/* é config (agents/nodes/slots/power) = Secretária.
|
||||
const isHandoff = /^\/sec\/handoff(\/|$|\?)/.test(tail);
|
||||
const isSecretaria = !isHandoff && /^\/(secretaria|sec)(\/|$|\?)/.test(tail);
|
||||
const isInbox = isHandoff || /^\/(inbox|conversations)(\/|$|\?)/.test(tail);
|
||||
if (!isSecretaria && !isInbox) return { ok: true };
|
||||
if (!pool || !clinicaId) return { ok: true }; // sem workspace resolvido, não barra aqui
|
||||
const ownerId = await resolveOwnerId(pool, clinicaId);
|
||||
@@ -160,8 +179,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 };
|
||||
@@ -195,27 +215,58 @@ export function createRestProxy(pool) {
|
||||
const area = await guardAreaAccess(pool, tail, userId, clinicaId);
|
||||
if (!area.ok) return res.status(area.status).json({ error: area.error });
|
||||
|
||||
// Só o DONO liga/desliga a Secretária (global / por sessão / por chat), quando
|
||||
// quiser. Fail-closed: os demais nem veem o controle na UI e aqui levam 403.
|
||||
if (req.method === 'POST' && /^\/secretaria\/(power|session-power|chat-power)(\/|$|\?)/.test(tail)) {
|
||||
const owner = clinicaId ? await resolveOwner(pool, clinicaId) : null;
|
||||
if (!owner || owner.ownerId !== userId) return res.status(403).json({ error: 'Apenas o dono do workspace pode ligar/desligar a Secretária.' });
|
||||
}
|
||||
|
||||
// Gate de ações destrutivas do inbox (apagar conversa/mensagem).
|
||||
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 +275,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 +287,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 +393,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 +456,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);
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// Validação de documentos e data de nascimento para o cadastro de pacientes.
|
||||
// CPF/CNPJ com dígito verificador REAL (não só formato); nascimento com data válida,
|
||||
// não-futura e idade plausível. Usado no cadastro via Secretária (server-side).
|
||||
|
||||
const soDigitos = (s) => String(s || '').replace(/\D/g, '');
|
||||
|
||||
// ── CPF ──────────────────────────────────────────────────────────────────────
|
||||
function validarCPF(entrada) {
|
||||
const cpf = soDigitos(entrada);
|
||||
if (cpf.length !== 11) return false;
|
||||
if (/^(\d)\1{10}$/.test(cpf)) return false; // 000..., 111..., etc.
|
||||
const dv = (base, pesoIni) => {
|
||||
let soma = 0;
|
||||
for (let i = 0; i < base.length; i++) soma += Number(base[i]) * (pesoIni - i);
|
||||
const r = (soma * 10) % 11;
|
||||
return r === 10 ? 0 : r;
|
||||
};
|
||||
const d1 = dv(cpf.slice(0, 9), 10);
|
||||
const d2 = dv(cpf.slice(0, 10), 11);
|
||||
return d1 === Number(cpf[9]) && d2 === Number(cpf[10]);
|
||||
}
|
||||
|
||||
// ── CNPJ ─────────────────────────────────────────────────────────────────────
|
||||
function validarCNPJ(entrada) {
|
||||
const cnpj = soDigitos(entrada);
|
||||
if (cnpj.length !== 14) return false;
|
||||
if (/^(\d)\1{13}$/.test(cnpj)) return false;
|
||||
const dv = (len) => {
|
||||
const pesos = len === 12 ? [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2] : [6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2];
|
||||
let soma = 0;
|
||||
for (let i = 0; i < len; i++) soma += Number(cnpj[i]) * pesos[i];
|
||||
const r = soma % 11;
|
||||
return r < 2 ? 0 : 11 - r;
|
||||
};
|
||||
return dv(12) === Number(cnpj[12]) && dv(13) === Number(cnpj[13]);
|
||||
}
|
||||
|
||||
// CPF (11) ou CNPJ (14). Retorna { valido, tipo }.
|
||||
function validarCpfCnpj(entrada) {
|
||||
const d = soDigitos(entrada);
|
||||
if (d.length === 11) return { valido: validarCPF(d), tipo: 'cpf' };
|
||||
if (d.length === 14) return { valido: validarCNPJ(d), tipo: 'cnpj' };
|
||||
return { valido: false, tipo: null };
|
||||
}
|
||||
|
||||
// ── Data de nascimento ─────────────────────────────────────────────────────
|
||||
// Aceita DD/MM/AAAA, DD-MM-AAAA ou AAAA-MM-DD. Retorna { valido, iso, motivo }.
|
||||
// Rejeita data inexistente (31/02), futura, e idade fora de 0..120 anos.
|
||||
function validarNascimento(entrada) {
|
||||
const s = String(entrada || '').trim();
|
||||
let y, m, d;
|
||||
let mt;
|
||||
if ((mt = s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/))) { y = +mt[1]; m = +mt[2]; d = +mt[3]; }
|
||||
else if ((mt = s.match(/^(\d{1,2})[/-](\d{1,2})[/-](\d{4})$/))) { d = +mt[1]; m = +mt[2]; y = +mt[3]; }
|
||||
else return { valido: false, motivo: 'formato' };
|
||||
if (m < 1 || m > 12 || d < 1 || d > 31) return { valido: false, motivo: 'invalida' };
|
||||
const dt = new Date(Date.UTC(y, m - 1, d));
|
||||
// Confere que a data existe de fato (evita 31/02, 30/02, etc.).
|
||||
if (dt.getUTCFullYear() !== y || dt.getUTCMonth() !== m - 1 || dt.getUTCDate() !== d) return { valido: false, motivo: 'invalida' };
|
||||
const hoje = new Date();
|
||||
if (dt.getTime() > hoje.getTime()) return { valido: false, motivo: 'futura' };
|
||||
const idade = (hoje - dt) / (365.25 * 24 * 3600 * 1000);
|
||||
if (idade > 120) return { valido: false, motivo: 'muito_antiga' };
|
||||
const iso = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
|
||||
return { valido: true, iso };
|
||||
}
|
||||
|
||||
export { soDigitos, validarCPF, validarCNPJ, validarCpfCnpj, validarNascimento };
|
||||
+324
-35
@@ -12,6 +12,8 @@ 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';
|
||||
import { validarCpfCnpj, validarNascimento } from './newwhats/validacao.js';
|
||||
|
||||
const { Pool } = pg;
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'scoreodonto_secret_key_2026';
|
||||
@@ -165,6 +167,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 +844,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 +859,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 }); }
|
||||
});
|
||||
@@ -876,7 +885,7 @@ app.post('/api/clinicas/:clinicaId/membros', tenantGuard, requireCapability('ges
|
||||
});
|
||||
|
||||
app.put('/api/clinicas/:clinicaId/membros/:userId', tenantGuard, requireCapability('gestao-equipe'), async (req, res) => {
|
||||
const { role, setor_id, funcao_id } = req.body;
|
||||
const { role, setor_id, funcao_id, nome } = req.body;
|
||||
try {
|
||||
const reqNivel = await nivelNaClinica(req.authUser.userId, req.params.clinicaId);
|
||||
const alvoNivel = await nivelNaClinica(req.params.userId, req.params.clinicaId);
|
||||
@@ -885,6 +894,11 @@ app.put('/api/clinicas/:clinicaId/membros/:userId', tenantGuard, requireCapabili
|
||||
}
|
||||
await pool.query('UPDATE vinculos SET role = COALESCE($1, role), setor_id = $2, funcao_id = $3 WHERE usuario_id = $4 AND clinica_id = $5',
|
||||
[role || null, setor_id || null, funcao_id || null, req.params.userId, req.params.clinicaId]);
|
||||
// Nome do titular da conta (usado, entre outros, na assinatura das mensagens do
|
||||
// WhatsApp). Editável pelo dono p/ quando a pessoa da conta muda (ex.: troca de secretária).
|
||||
if (typeof nome === 'string' && nome.trim()) {
|
||||
await pool.query('UPDATE usuarios SET nome = $1 WHERE id = $2', [nome.trim().toUpperCase(), req.params.userId]);
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
|
||||
});
|
||||
@@ -1008,6 +1022,33 @@ app.put('/api/clinicas/:id/color', authGuard, async (req, res) => {
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// Fusos horários válidos do Brasil (whitelist — evita injeção de TZ inválido).
|
||||
const TIMEZONES_BR = [
|
||||
'America/Sao_Paulo', 'America/Campo_Grande', 'America/Cuiaba', 'America/Manaus',
|
||||
'America/Rio_Branco', 'America/Belem', 'America/Fortaleza', 'America/Recife',
|
||||
'America/Bahia', 'America/Boa_Vista', 'America/Porto_Velho', 'America/Maceio', 'America/Noronha',
|
||||
];
|
||||
app.get('/api/clinicas/:id/timezone', authGuard, async (req, res) => {
|
||||
try {
|
||||
const { rows } = await pool.query('SELECT timezone FROM clinicas WHERE id = $1', [req.params.id]);
|
||||
res.json({ timezone: rows[0]?.timezone || 'America/Sao_Paulo', opcoes: TIMEZONES_BR });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
app.put('/api/clinicas/:id/timezone', authGuard, async (req, res) => {
|
||||
const tz = String(req.body?.timezone || '');
|
||||
if (!TIMEZONES_BR.includes(tz)) return res.status(400).json({ error: 'Fuso horário inválido.' });
|
||||
// Só dono/admin da unidade altera o fuso.
|
||||
const { rows: _own } = await pool.query(
|
||||
`SELECT 1 FROM clinicas WHERE id=$1 AND owner_id=$2
|
||||
UNION SELECT 1 FROM vinculos WHERE clinica_id=$1 AND usuario_id=$2 AND role = ANY(ARRAY['donoclinica','donoconsultorio','admin']) LIMIT 1`,
|
||||
[req.params.id, req.authUser?.userId]);
|
||||
if (!_own.length) return res.status(403).json({ error: 'Apenas o dono/admin pode alterar o fuso horário.' });
|
||||
try {
|
||||
await pool.query('UPDATE clinicas SET timezone = $1 WHERE id = $2', [tz, req.params.id]);
|
||||
res.json({ success: true, timezone: tz });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// Renomear a unidade (clínica/consultório/laboratório). Só o dono (ou vínculo dono/admin).
|
||||
app.put('/api/clinicas/:id/nome', authGuard, async (req, res) => {
|
||||
const nome = (req.body?.nome_fantasia || '').trim();
|
||||
@@ -1919,7 +1960,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 +1988,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
|
||||
};
|
||||
|
||||
@@ -2105,6 +2176,43 @@ app.put('/api/pacientes/:id', authGuard, requireCapabilityRow('agenda', 'pacient
|
||||
}
|
||||
});
|
||||
|
||||
// Paciente único (p/ o modal de confirmação no check-in — Feature C).
|
||||
app.get('/api/pacientes/:id', authGuard, requireCapabilityRow('agenda', 'pacientes'), async (req, res) => {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, nome, cpf, telefone, datanascimento, dados_confirmados,
|
||||
grupofamiliarid, grupofamiliarrelacao, clinica_id FROM pacientes WHERE id=$1`, [req.params.id]);
|
||||
if (!rows.length) return res.status(404).json({ error: 'Paciente não encontrado.' });
|
||||
if (!await userHasVinculo(req.authUser?.userId, rows[0].clinica_id)) return res.status(403).json({ error: 'Acesso negado.' });
|
||||
res.json({ paciente: rows[0] });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// Confirma os dados do paciente no check-in (recepção): valida CPF/CNPJ + nascimento,
|
||||
// atualiza nome/cpf/telefone/nascimento e marca dados_confirmados=true. O `telefone`
|
||||
// pode ser o número PRÓPRIO deste paciente (dependente que ganhou WhatsApp próprio).
|
||||
app.post('/api/pacientes/:id/confirmar-dados', authGuard, requireCapabilityRow('agenda', 'pacientes'), async (req, res) => {
|
||||
try {
|
||||
const { rows: pc } = await pool.query('SELECT clinica_id FROM pacientes WHERE id=$1', [req.params.id]);
|
||||
if (!pc.length) return res.status(404).json({ error: 'Paciente não encontrado.' });
|
||||
if (!await userHasVinculo(req.authUser?.userId, pc[0].clinica_id)) return res.status(403).json({ error: 'Acesso negado.' });
|
||||
const { nome, cpf, telefone } = req.body || {};
|
||||
if (cpf) { const d = validarCpfCnpj(cpf); if (!d.valido) return res.json({ ok: false, campo: 'cpf', mensagem: 'CPF/CNPJ inválido — confira e corrija.' }); }
|
||||
let nascIso = null;
|
||||
if (req.body?.data_nascimento) {
|
||||
const n = validarNascimento(req.body.data_nascimento);
|
||||
if (!n.valido) return res.json({ ok: false, campo: 'data_nascimento', mensagem: 'Data de nascimento inválida — confira (DD/MM/AAAA).' });
|
||||
nascIso = n.iso;
|
||||
}
|
||||
await pool.query(
|
||||
`UPDATE pacientes SET nome=COALESCE($2,nome), cpf=COALESCE($3,cpf), telefone=COALESCE($4,telefone),
|
||||
datanascimento=COALESCE($5,datanascimento), dados_confirmados=true WHERE id=$1`,
|
||||
[req.params.id, nome || null, cpf || null, telefone || null, nascIso]);
|
||||
schedulePush('pacientes');
|
||||
res.json({ ok: true });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// --- DENTISTAS ---
|
||||
app.get('/api/dentistas', authGuard, async (req, res) => {
|
||||
try {
|
||||
@@ -6137,6 +6245,23 @@ app.post('/api/pedidos-exame', authGuard, async (req, res) => {
|
||||
|
||||
async function runMigrations() {
|
||||
const migrations = [
|
||||
// Fuso horário por workspace (clínica) — usado p/ a Secretária e a agenda operarem
|
||||
// no horário local da unidade. Default = Brasília (comportamento atual).
|
||||
`ALTER TABLE clinicas ADD COLUMN IF NOT EXISTS timezone TEXT DEFAULT 'America/Sao_Paulo'`,
|
||||
// Comunicação interna: perfil de contato de cada NÃO-paciente (dentista/funcionário).
|
||||
// Números de WhatsApp múltiplos (cada um com descrição, situações e janelas de horário
|
||||
// permitido p/ a Secretária enviar msg — evita mensagem fora do expediente), endereço e
|
||||
// disponibilidade de agenda (abrir/fechar). Chave por (usuario_id, clinica_id).
|
||||
`CREATE TABLE IF NOT EXISTS pessoa_comunicacao (
|
||||
usuario_id TEXT NOT NULL,
|
||||
clinica_id TEXT NOT NULL,
|
||||
endereco JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
numeros JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
agenda JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
completo BOOLEAN NOT NULL DEFAULT false,
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
PRIMARY KEY (usuario_id, clinica_id)
|
||||
)`,
|
||||
`ALTER TABLE pacientes ADD COLUMN IF NOT EXISTS grupofamiliarid TEXT`,
|
||||
`ALTER TABLE pacientes ADD COLUMN IF NOT EXISTS grupofamiliarrelacao TEXT`,
|
||||
`ALTER TABLE pacientes ADD COLUMN IF NOT EXISTS indicadorporid TEXT`,
|
||||
@@ -6148,6 +6273,9 @@ async function runMigrations() {
|
||||
`ALTER TABLE pacientes ADD COLUMN IF NOT EXISTS cidade TEXT`,
|
||||
`ALTER TABLE pacientes ADD COLUMN IF NOT EXISTS estado TEXT`,
|
||||
`ALTER TABLE pacientes ADD COLUMN IF NOT EXISTS cep TEXT`,
|
||||
// Feature C (check-in): cadastro da Secretária entra como não-confirmado
|
||||
// (default true p/ os já existentes; a Secretária grava false ao cadastrar).
|
||||
`ALTER TABLE pacientes ADD COLUMN IF NOT EXISTS dados_confirmados BOOLEAN DEFAULT true`,
|
||||
`CREATE TABLE IF NOT EXISTS modelos_receita (
|
||||
id TEXT PRIMARY KEY,
|
||||
nome TEXT NOT NULL,
|
||||
@@ -6662,6 +6790,7 @@ async function runMigrations() {
|
||||
`ALTER TABLE salas ADD COLUMN IF NOT EXISTS diaria_consulta BOOLEAN DEFAULT false`,
|
||||
`ALTER TABLE salas ADD COLUMN IF NOT EXISTS semanal_consulta BOOLEAN DEFAULT false`,
|
||||
`ALTER TABLE salas ADD COLUMN IF NOT EXISTS mensal_consulta BOOLEAN DEFAULT false`,
|
||||
`ALTER TABLE salas ADD COLUMN IF NOT EXISTS contrato TEXT`,
|
||||
`UPDATE salas s SET area = CASE u.role WHEN 'biomedico' THEN 'biomedicina' WHEN 'protetico' THEN 'protese' ELSE 'odontologia' END
|
||||
FROM usuarios u WHERE u.id = s.owner_usuario_id AND s.area IS NULL`,
|
||||
`UPDATE salas SET area = 'odontologia' WHERE area IS NULL`,
|
||||
@@ -7500,6 +7629,131 @@ async function resolveOrigem(req, cep, clinicaId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Comunicação interna (perfil de contato de não-pacientes) ────────────────────
|
||||
// Regra de completude que destrava o gate: endereço + ≥1 número (com descrição e ≥1
|
||||
// janela de horário) + disponibilidade de agenda decidida.
|
||||
function comunicacaoCompleto(p, opts) {
|
||||
const { ehDentista, jornadaOk, ehDono, horarioClinicaOk } = opts || {};
|
||||
const e = p.endereco || {};
|
||||
const nums = Array.isArray(p.numeros) ? p.numeros : [];
|
||||
const enderecoOk = !!(e.cidade && e.estado && (e.cep || e.logradouro));
|
||||
const numerosOk = nums.length >= 1 && nums.every((n) =>
|
||||
n && String(n.numero || '').replace(/\D/g, '').length >= 10 && String(n.descricao || '').trim() &&
|
||||
Array.isArray(n.janelas) && n.janelas.length >= 1);
|
||||
// Agenda = fonte única. DENTISTA exige jornada real (dentistas_horarios); DONO exige o
|
||||
// horário de funcionamento da CLÍNICA (clinicas_horarios) — ambos em "Horários de Funcionamento".
|
||||
const jornadaExigidaOk = !ehDentista || !!jornadaOk;
|
||||
const clinicaExigidaOk = !ehDono || !!horarioClinicaOk;
|
||||
return enderecoOk && numerosOk && jornadaExigidaOk && clinicaExigidaOk;
|
||||
}
|
||||
async function papelNaClinica(usuarioId, clinicaId) {
|
||||
const { rows } = await pool.query('SELECT role FROM vinculos WHERE usuario_id=$1 AND clinica_id=$2', [usuarioId, clinicaId]);
|
||||
return rows[0]?.role || '';
|
||||
}
|
||||
// Jornada de atendimento do dentista já configurada? (fonte real usada pela Secretária)
|
||||
async function jornadaConfigurada(usuarioId, clinicaId) {
|
||||
const { rows: dr } = await pool.query('SELECT id FROM dentistas WHERE usuario_id=$1 AND clinica_id=$2 LIMIT 1', [usuarioId, clinicaId]);
|
||||
const dentId = dr[0]?.id;
|
||||
if (!dentId) return false;
|
||||
const { rows } = await pool.query('SELECT 1 FROM dentistas_horarios WHERE dentista_id=$1 AND COALESCE(ativo,1) <> 0 LIMIT 1', [dentId]);
|
||||
return rows.length > 0;
|
||||
}
|
||||
// Horário de FUNCIONAMENTO da clínica já configurado?
|
||||
async function clinicaHorariosConfigurados(clinicaId) {
|
||||
const { rows } = await pool.query('SELECT 1 FROM clinicas_horarios WHERE clinica_id=$1 AND COALESCE(ativo,1) <> 0 LIMIT 1', [clinicaId]);
|
||||
return rows.length > 0;
|
||||
}
|
||||
const EH_DONO = (role) => ['donoclinica', 'donoconsultorio'].includes(role);
|
||||
// Clínica do usuário: param explícito → primeiro vínculo.
|
||||
async function resolveClinicaUsuario(userId, clinicaIdParam) {
|
||||
if (clinicaIdParam) return String(clinicaIdParam);
|
||||
const { rows } = await pool.query('SELECT clinica_id FROM vinculos WHERE usuario_id = $1 ORDER BY id LIMIT 1', [userId]);
|
||||
return rows[0]?.clinica_id || null;
|
||||
}
|
||||
// Só o próprio, ou um gestor (dono/admin) da mesma clínica, edita o perfil de outro.
|
||||
async function podeEditarComunicacao(reqUserId, alvoUserId, clinicaId) {
|
||||
if (reqUserId === alvoUserId) return true;
|
||||
const { rows } = await pool.query('SELECT role FROM vinculos WHERE usuario_id=$1 AND clinica_id=$2', [reqUserId, clinicaId]);
|
||||
return ['donoclinica', 'donoconsultorio', 'admin'].includes(rows[0]?.role || '');
|
||||
}
|
||||
|
||||
// Lookup: este número já está cadastrado por OUTRA pessoa da clínica? (notificação de
|
||||
// número compartilhado — a mesma pessoa pode ter contas/papéis diferentes).
|
||||
// Definido ANTES de /:usuarioId para não ser capturado pela rota-parâmetro.
|
||||
app.get('/api/comunicacao/numero-lookup', authGuard, async (req, res) => {
|
||||
try {
|
||||
const clinicaId = String(req.query.clinicaId || '');
|
||||
const tel = String(req.query.numero || '').replace(/\D/g, '').slice(-8);
|
||||
if (!clinicaId || tel.length < 8) return res.json({ encontrados: [] });
|
||||
const { rows } = await pool.query(
|
||||
`SELECT pc.usuario_id, pc.numeros, u.nome, v.role
|
||||
FROM pessoa_comunicacao pc
|
||||
LEFT JOIN usuarios u ON u.id = pc.usuario_id
|
||||
LEFT JOIN vinculos v ON v.usuario_id = pc.usuario_id AND v.clinica_id = pc.clinica_id
|
||||
WHERE pc.clinica_id = $1 AND pc.usuario_id <> $2`,
|
||||
[clinicaId, req.authUser.userId]);
|
||||
const encontrados = [];
|
||||
for (const r of rows) {
|
||||
const nums = Array.isArray(r.numeros) ? r.numeros : [];
|
||||
if (nums.some((n) => String(n.numero || '').replace(/\D/g, '').slice(-8) === tel)) {
|
||||
encontrados.push({ usuario_id: r.usuario_id, nome: r.nome, role: r.role });
|
||||
}
|
||||
}
|
||||
res.json({ encontrados });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
app.get('/api/comunicacao/:usuarioId', authGuard, async (req, res) => {
|
||||
try {
|
||||
const alvo = req.params.usuarioId === 'me' ? req.authUser.userId : req.params.usuarioId;
|
||||
const clinicaId = await resolveClinicaUsuario(alvo, req.query.clinicaId);
|
||||
if (!clinicaId) return res.json({ usuario_id: alvo, clinica_id: null, endereco: {}, numeros: [], agenda: {}, completo: true, sem_clinica: true });
|
||||
if (!(await podeEditarComunicacao(req.authUser.userId, alvo, clinicaId))) return res.status(403).json({ error: 'Sem permissão.' });
|
||||
const { rows } = await pool.query('SELECT endereco, numeros, agenda, completo FROM pessoa_comunicacao WHERE usuario_id=$1 AND clinica_id=$2', [alvo, clinicaId]);
|
||||
const p = rows[0] || { endereco: {}, numeros: [], agenda: {}, completo: false };
|
||||
// Perfil ainda sem endereço próprio → sugere o endereço da CLÍNICA (só confirmar, não redigitar).
|
||||
let enderecoDaClinica = false;
|
||||
if (!p.endereco || !p.endereco.cidade) {
|
||||
const { rows: cr } = await pool.query('SELECT logradouro, numero, bairro, cidade, estado, cep FROM clinicas WHERE id=$1', [clinicaId]);
|
||||
if (cr[0] && (cr[0].cidade || cr[0].logradouro)) { p.endereco = { ...cr[0], ...(p.endereco || {}) }; enderecoDaClinica = true; }
|
||||
}
|
||||
const role = await papelNaClinica(alvo, clinicaId);
|
||||
const ehDentista = role === 'dentista';
|
||||
const ehDono = EH_DONO(role);
|
||||
const jornadaOk = ehDentista ? await jornadaConfigurada(alvo, clinicaId) : true;
|
||||
const horarioClinicaOk = ehDono ? await clinicaHorariosConfigurados(clinicaId) : true;
|
||||
// `completo` recomputado AO VIVO (jornada/horário podem ter sido configurados fora do modal).
|
||||
const completoLive = comunicacaoCompleto({ endereco: p.endereco, numeros: p.numeros }, { ehDentista, jornadaOk, ehDono, horarioClinicaOk });
|
||||
res.json({ usuario_id: alvo, clinica_id: clinicaId, ...p, completo: completoLive, endereco_da_clinica: enderecoDaClinica, role, jornada_configurada: jornadaOk, horario_clinica_configurado: horarioClinicaOk });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
app.put('/api/comunicacao/:usuarioId', authGuard, async (req, res) => {
|
||||
try {
|
||||
const alvo = req.params.usuarioId === 'me' ? req.authUser.userId : req.params.usuarioId;
|
||||
const b = req.body || {};
|
||||
const clinicaId = await resolveClinicaUsuario(alvo, b.clinicaId || req.query.clinicaId);
|
||||
if (!clinicaId) return res.status(400).json({ error: 'Clínica não encontrada para o usuário.' });
|
||||
if (!(await podeEditarComunicacao(req.authUser.userId, alvo, clinicaId))) return res.status(403).json({ error: 'Sem permissão.' });
|
||||
const endereco = b.endereco && typeof b.endereco === 'object' ? b.endereco : {};
|
||||
const numeros = Array.isArray(b.numeros) ? b.numeros : [];
|
||||
const agenda = b.agenda && typeof b.agenda === 'object' ? b.agenda : {};
|
||||
const role = await papelNaClinica(alvo, clinicaId);
|
||||
const ehDentista = role === 'dentista';
|
||||
const ehDono = EH_DONO(role);
|
||||
const jornadaOk = ehDentista ? await jornadaConfigurada(alvo, clinicaId) : true;
|
||||
const horarioClinicaOk = ehDono ? await clinicaHorariosConfigurados(clinicaId) : true;
|
||||
const completo = comunicacaoCompleto({ endereco, numeros }, { ehDentista, jornadaOk, ehDono, horarioClinicaOk });
|
||||
await pool.query(
|
||||
`INSERT INTO pessoa_comunicacao (usuario_id, clinica_id, endereco, numeros, agenda, completo, updated_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,NOW())
|
||||
ON CONFLICT (usuario_id, clinica_id) DO UPDATE
|
||||
SET endereco=EXCLUDED.endereco, numeros=EXCLUDED.numeros, agenda=EXCLUDED.agenda, completo=EXCLUDED.completo, updated_at=NOW()`,
|
||||
[alvo, clinicaId, JSON.stringify(endereco), JSON.stringify(numeros), JSON.stringify(agenda), completo]);
|
||||
res.json({ ok: true, completo });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Minhas salas (como locador)
|
||||
app.get('/api/salas/minhas', authGuard, async (req, res) => {
|
||||
try {
|
||||
@@ -7564,14 +7818,15 @@ app.post('/api/salas', authGuard, async (req, res) => {
|
||||
await pool.query(
|
||||
`INSERT INTO salas (id, owner_usuario_id, clinica_id, nome, tipo, descricao, equipamentos, endereco, bairro, cidade, estado, cep,
|
||||
valor_hora, valor_turno, valor_diaria, valor_semanal, valor_mensal, disponivel, area,
|
||||
hora_consulta, turno_consulta, diaria_consulta, semanal_consulta, mensal_consulta)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24)`,
|
||||
hora_consulta, turno_consulta, diaria_consulta, semanal_consulta, mensal_consulta, contrato)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25)`,
|
||||
[id, req.authUser.userId, b.clinicaId || null, b.nome, SALA_TIPOS.includes(b.tipo) ? b.tipo : 'independente',
|
||||
b.descricao || null, b.equipamentos || null, b.endereco || null,
|
||||
b.bairro?.toUpperCase() || null, String(b.cidade).toUpperCase(), String(b.estado).toUpperCase(), b.cep || null,
|
||||
b.valorHora ?? null, b.valorTurno ?? null, b.valorDiaria ?? null, b.valorSemanal ?? null, b.valorMensal ?? null,
|
||||
b.disponivel !== false, area,
|
||||
b.horaConsulta === true, b.turnoConsulta === true, b.diariaConsulta === true, b.semanalConsulta === true, b.mensalConsulta === true]);
|
||||
b.horaConsulta === true, b.turnoConsulta === true, b.diariaConsulta === true, b.semanalConsulta === true, b.mensalConsulta === true,
|
||||
b.contrato || null]);
|
||||
await setGeoByCep('salas', id, b.cep);
|
||||
await registrarAudit(pool, { clinicaId: b.clinicaId || null, entidade: 'sala', entidadeId: id, acao: 'criou', actorId: req.authUser.userId, actorNome: await actorNome(req.authUser.userId), detalhes: { nome: b.nome } });
|
||||
res.json({ success: true, id });
|
||||
@@ -7588,7 +7843,7 @@ app.put('/api/salas/:id', authGuard, async (req, res) => {
|
||||
endereco: b.endereco, bairro: b.bairro?.toUpperCase(), cidade: b.cidade ? String(b.cidade).toUpperCase() : undefined,
|
||||
estado: b.estado ? String(b.estado).toUpperCase() : undefined, cep: b.cep,
|
||||
valor_hora: b.valorHora, valor_turno: b.valorTurno, valor_diaria: b.valorDiaria, valor_semanal: b.valorSemanal, valor_mensal: b.valorMensal,
|
||||
area: b.area, disponivel: b.disponivel,
|
||||
area: b.area, disponivel: b.disponivel, contrato: b.contrato,
|
||||
hora_consulta: b.horaConsulta, turno_consulta: b.turnoConsulta, diaria_consulta: b.diariaConsulta, semanal_consulta: b.semanalConsulta, mensal_consulta: b.mensalConsulta };
|
||||
const keys = Object.keys(COLS).filter(k => COLS[k] !== undefined);
|
||||
if (!keys.length) return res.json({ success: true });
|
||||
@@ -7624,6 +7879,9 @@ app.get('/api/salas/marketplace', authGuard, async (req, res) => {
|
||||
|
||||
const params = [];
|
||||
let where = `WHERE s.disponivel = true`;
|
||||
// Não listar as PRÓPRIAS salas (não se aluga a própria) nem as da unidade ativa.
|
||||
params.push(req.authUser.userId); where += ` AND s.owner_usuario_id <> $${params.length}`;
|
||||
if (clinicaId) { params.push(String(clinicaId)); where += ` AND (s.clinica_id IS NULL OR s.clinica_id <> $${params.length})`; }
|
||||
if (tipo && SALA_TIPOS.includes(tipo)) { params.push(tipo); where += ` AND s.tipo = $${params.length}`; }
|
||||
if (estado) { params.push(String(estado).toUpperCase()); where += ` AND s.estado = $${params.length}`; }
|
||||
if (cidade) { params.push(`%${String(cidade).toUpperCase()}%`); where += ` AND s.cidade ILIKE $${params.length}`; }
|
||||
@@ -7642,7 +7900,7 @@ app.get('/api/salas/marketplace', authGuard, async (req, res) => {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT s.id, s.nome, s.tipo, s.descricao, s.equipamentos, s.endereco, s.bairro, s.cidade, s.estado, s.cep, s.area,
|
||||
s.valor_hora, s.valor_turno, s.valor_diaria, s.valor_semanal, s.valor_mensal,
|
||||
s.hora_consulta, s.turno_consulta, s.diaria_consulta, s.semanal_consulta, s.mensal_consulta,
|
||||
s.hora_consulta, s.turno_consulta, s.diaria_consulta, s.semanal_consulta, s.mensal_consulta, s.contrato,
|
||||
s.owner_usuario_id, u.nome AS owner_nome, c.nome_fantasia AS clinica_nome, ${distSelect}
|
||||
FROM salas s LEFT JOIN usuarios u ON u.id = s.owner_usuario_id LEFT JOIN clinicas c ON c.id = s.clinica_id
|
||||
${where} ${order} LIMIT 200`, params);
|
||||
@@ -9400,6 +9658,37 @@ 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 headers = { 'x-nw-key': cfg.integrationKey, 'x-nw-email': email };
|
||||
const pull = async (path, escopo) => {
|
||||
try {
|
||||
const r = await fetch(`${cfg.motorUrl}/api/ext/v1${path}?limit=${limit}`, { headers });
|
||||
if (!r.ok) return [];
|
||||
const j = await r.json();
|
||||
return (Array.isArray(j) ? j : []).map((e) => ({ ...e, escopo }));
|
||||
} catch { return []; }
|
||||
};
|
||||
// "global" = botão que desativa a Secretária para TODOS os chats; "sessao" = por número.
|
||||
const [glob, sess] = await Promise.all([
|
||||
pull('/secretaria/power/log', 'global'),
|
||||
pull('/secretaria/session-power/log', 'sessao'),
|
||||
]);
|
||||
const log = [...glob, ...sess].sort((a, b) => new Date(b.at) - new Date(a.at));
|
||||
res.json({ configurado: true, 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 +10414,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; }
|
||||
|
||||
+64
-4
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { HybridBackend } from './services/backend.ts';
|
||||
|
||||
import { Sidebar } from './components/Sidebar.tsx';
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
|
||||
import { ToastContainer } from './components/Toast.tsx';
|
||||
import { ComunicacaoInternaModal } from './components/ComunicacaoInternaModal.tsx';
|
||||
import { useAppVersion } from './buildInfo.ts';
|
||||
|
||||
import { Dashboard } from './views/Dashboard.tsx';
|
||||
@@ -121,6 +122,7 @@ export type ViewKey =
|
||||
| 'superadmin-logs'
|
||||
| 'superadmin-financeiro'
|
||||
| 'superadmin-notificacoes'
|
||||
| 'superadmin-secretaria'
|
||||
| 'diretorio-profissionais';
|
||||
|
||||
// ------- URL <-> VIEW mapping -------
|
||||
@@ -181,6 +183,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 +251,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 +319,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; }
|
||||
@@ -477,6 +488,37 @@ const App: React.FC = () => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Gate de comunicação interna: todo NÃO-paciente com clínica ativa preenche o perfil
|
||||
// (endereço + números com janelas de horário; agenda só p/ dentista). Gate PROGRESSIVO:
|
||||
// reaparece a cada login com "Lembrar mais tarde"; a partir da 4ª aparição vira bloqueio
|
||||
// até preencher. Conta as aparições em localStorage por clínica.
|
||||
const [comGate, setComGate] = useState<{ show: boolean; forced: boolean; clinicaId?: string }>({ show: false, forced: false });
|
||||
const gateCountedFor = useRef<string | null>(null); // evita contar 2x no mesmo mount (StrictMode)
|
||||
useEffect(() => {
|
||||
const ws: any = HybridBackend.getActiveWorkspace();
|
||||
const role = HybridBackend.getCurrentRole();
|
||||
const aplica = HybridBackend.isAuthenticated() && !HybridBackend.isPreviewMode() &&
|
||||
role && role !== 'paciente' && role !== 'superadmin' &&
|
||||
ws?.id && ws?.tipo !== 'sala' && ws?.tipo !== 'laboratorio';
|
||||
if (!aplica) { setComGate({ show: false, forced: false }); return; }
|
||||
const key = `comgate:${ws.id}`;
|
||||
let cancel = false;
|
||||
fetch(`${(window as any).__API_URL__ || ''}/api/comunicacao/me?clinicaId=${encodeURIComponent(ws.id)}`,
|
||||
{ headers: { Authorization: `Bearer ${localStorage.getItem('SCOREODONTO_AUTH_TOKEN')}` } })
|
||||
.then(r => (r.ok ? r.json() : { completo: true }))
|
||||
.then(d => {
|
||||
if (cancel) return;
|
||||
if (d?.sem_clinica || d?.completo) { try { localStorage.removeItem(key); } catch { /* ignore */ } setComGate({ show: false, forced: false }); return; }
|
||||
// Incompleto → conta a aparição 1x por carga da página.
|
||||
let count = 0; try { count = parseInt(localStorage.getItem(key) || '0', 10) || 0; } catch { /* ignore */ }
|
||||
if (gateCountedFor.current !== ws.id) { count += 1; try { localStorage.setItem(key, String(count)); } catch { /* ignore */ } gateCountedFor.current = ws.id; }
|
||||
setComGate({ show: true, forced: count >= 4, clinicaId: ws.id });
|
||||
})
|
||||
.catch(() => { if (!cancel) setComGate({ show: false, forced: false }); });
|
||||
return () => { cancel = true; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeWorkspace?.id, currentRole]);
|
||||
|
||||
// Roteamento pós-login/cadastro.
|
||||
const onAuthSuccess = (result?: { noWorkspace?: boolean; userRole?: string }) => {
|
||||
if (result?.noWorkspace) {
|
||||
@@ -508,7 +550,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 +603,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':
|
||||
@@ -580,6 +623,12 @@ const App: React.FC = () => {
|
||||
{HybridBackend.isAuthenticated() && HybridBackend.mustChangePassword() && (
|
||||
<ChangePasswordModal forced onDone={() => forcePwRerender(t => t + 1)} />
|
||||
)}
|
||||
{comGate.show && !HybridBackend.mustChangePassword() && (
|
||||
<ComunicacaoInternaModal forced={comGate.forced} clinicaId={comGate.clinicaId}
|
||||
onSnooze={() => setComGate(s => ({ ...s, show: false }))}
|
||||
onClose={() => setComGate(s => ({ ...s, show: false }))}
|
||||
onSaved={(c) => { if (c) { try { localStorage.removeItem(`comgate:${comGate.clinicaId}`); } catch { /* ignore */ } setComGate({ show: false, forced: false }); } }} />
|
||||
)}
|
||||
<div className="flex h-screen bg-gray-50 text-gray-800 overflow-hidden relative">
|
||||
<style>{`
|
||||
:root { --brand-color: ${clinColor}; }
|
||||
@@ -599,7 +648,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 +701,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,5 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { StackModal, StackPage, useStackModal } from './StackModal.tsx';
|
||||
import { fmtHora, fmtDataCurta, fmtDataHora } from '../utils/appTime.ts';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { useToast } from '../contexts/ToastContext.tsx';
|
||||
import { AvaliacaoEditor } from './AvaliacaoEditor.tsx';
|
||||
@@ -137,9 +138,9 @@ const HistoryPage: React.FC<{ appointmentId: string }> = ({ appointmentId }) =>
|
||||
<p className="text-[10px] text-gray-400 font-medium">{new Date(h.at).toLocaleString('pt-BR')}</p>
|
||||
{h.detalhes?.de && h.detalhes?.para && (
|
||||
<p className="text-[10px] text-gray-500 mt-0.5">
|
||||
{new Date(h.detalhes.de.start).toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}
|
||||
{fmtDataHora(h.detalhes.de.start)}
|
||||
{' → '}
|
||||
{new Date(h.detalhes.para.start).toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}
|
||||
{fmtDataHora(h.detalhes.para.start)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -304,8 +305,8 @@ const DentistaRestritoView: React.FC<{ ag: any; nome: string; dentNome: string;
|
||||
<div className="grid grid-cols-2 gap-4 py-2 border-y border-gray-100">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[9px] font-black text-gray-400 uppercase tracking-widest">Data e hora</label>
|
||||
<p className="flex items-center gap-1.5 text-xs font-bold text-gray-700"><CalendarRange size={14} className="text-teal-500" />{ag.start ? new Date(ag.start).toLocaleDateString('pt-BR', { weekday: 'short', day: 'numeric', month: 'short' }) : '—'}</p>
|
||||
<p className="flex items-center gap-1.5 text-xs font-bold text-gray-700"><Clock size={14} className="text-teal-500" />{ag.start ? new Date(ag.start).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }) : '—'}</p>
|
||||
<p className="flex items-center gap-1.5 text-xs font-bold text-gray-700"><CalendarRange size={14} className="text-teal-500" />{ag.start ? fmtDataCurta(ag.start) : '—'}</p>
|
||||
<p className="flex items-center gap-1.5 text-xs font-bold text-gray-700"><Clock size={14} className="text-teal-500" />{ag.start ? fmtHora(ag.start) : '—'}</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[9px] font-black text-gray-400 uppercase tracking-widest">Profissional</label>
|
||||
@@ -421,8 +422,8 @@ const DetailPage: React.FC<Props> = (p) => {
|
||||
<div className="grid grid-cols-2 gap-4 py-2 border-y border-gray-100">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[9px] font-black text-gray-400 uppercase tracking-widest">Data e hora</label>
|
||||
<p className="flex items-center gap-1.5 text-xs font-bold text-gray-700"><CalendarRange size={14} className="text-teal-500" />{ag.start ? new Date(ag.start).toLocaleDateString('pt-BR', { weekday: 'short', day: 'numeric', month: 'short' }) : '—'}</p>
|
||||
<p className="flex items-center gap-1.5 text-xs font-bold text-gray-700"><Clock size={14} className="text-teal-500" />{ag.start ? new Date(ag.start).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }) : '—'}</p>
|
||||
<p className="flex items-center gap-1.5 text-xs font-bold text-gray-700"><CalendarRange size={14} className="text-teal-500" />{ag.start ? fmtDataCurta(ag.start) : '—'}</p>
|
||||
<p className="flex items-center gap-1.5 text-xs font-bold text-gray-700"><Clock size={14} className="text-teal-500" />{ag.start ? fmtHora(ag.start) : '—'}</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[9px] font-black text-gray-400 uppercase tracking-widest">Profissional</label>
|
||||
|
||||
@@ -65,42 +65,41 @@ export const AgendaPresence: React.FC = () => {
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Grupo oculto que expande PARA BAIXO dentro da div (inline) — largura cheia
|
||||
da coluna/gaveta, sem flutuar; assim não é cortado pelo overflow no desktop. */}
|
||||
{open && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[55]" onClick={() => setOpen(false)} />
|
||||
<div className="absolute right-0 mt-2 w-64 bg-white rounded-2xl shadow-2xl border border-gray-100 z-[56] overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-gray-50">
|
||||
<p className="text-[10px] font-black uppercase text-gray-400 tracking-widest flex items-center gap-1.5">
|
||||
<Circle size={8} className="fill-emerald-500 text-emerald-500" /> Online agora ({online.length})
|
||||
</p>
|
||||
</div>
|
||||
<div className="max-h-72 overflow-y-auto custom-scrollbar">
|
||||
{online.length === 0 && (
|
||||
<p className="px-4 py-3 text-[11px] text-gray-400 font-bold uppercase">Só você por aqui.</p>
|
||||
)}
|
||||
{online.map(o => (
|
||||
<div key={o.usuario_id} className="flex items-center gap-2.5 px-4 py-2.5">
|
||||
<span className="relative w-7 h-7 rounded-full flex items-center justify-center text-[9px] font-black text-white shrink-0" style={{ backgroundColor: colorFor(o.nome) }}>
|
||||
{initials(o.nome)}
|
||||
<Circle size={9} className="absolute -bottom-0.5 -right-0.5 fill-emerald-500 text-white" />
|
||||
</span>
|
||||
<span className="text-xs font-bold text-gray-700 uppercase truncate">{o.nome}</span>
|
||||
</div>
|
||||
))}
|
||||
{ultimasOffline.length > 0 && (
|
||||
<div className="px-4 py-2 border-t border-gray-50">
|
||||
<p className="text-[10px] font-black uppercase text-gray-400 tracking-widest mb-1">Últimas visitas</p>
|
||||
{ultimasOffline.slice(0, 6).map(u => (
|
||||
<div key={u.usuario_id} className="flex items-center justify-between py-1">
|
||||
<span className="text-[11px] font-bold text-gray-500 uppercase truncate">{u.usuario_nome}</span>
|
||||
<span className="text-[9px] text-gray-300 font-bold whitespace-nowrap ml-2">{relTime(u.last_view_at)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 w-full bg-white rounded-2xl border border-gray-200 shadow-sm overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-gray-50">
|
||||
<p className="text-[10px] font-black uppercase text-gray-400 tracking-widest flex items-center gap-1.5">
|
||||
<Circle size={8} className="fill-emerald-500 text-emerald-500" /> Online agora ({online.length})
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
<div className="max-h-72 overflow-y-auto custom-scrollbar">
|
||||
{online.length === 0 && (
|
||||
<p className="px-4 py-3 text-[11px] text-gray-400 font-bold uppercase">Só você por aqui.</p>
|
||||
)}
|
||||
{online.map(o => (
|
||||
<div key={o.usuario_id} className="flex items-center gap-2.5 px-4 py-2.5">
|
||||
<span className="relative w-7 h-7 rounded-full flex items-center justify-center text-[9px] font-black text-white shrink-0" style={{ backgroundColor: colorFor(o.nome) }}>
|
||||
{initials(o.nome)}
|
||||
<Circle size={9} className="absolute -bottom-0.5 -right-0.5 fill-emerald-500 text-white" />
|
||||
</span>
|
||||
<span className="text-xs font-bold text-gray-700 uppercase truncate">{o.nome}</span>
|
||||
</div>
|
||||
))}
|
||||
{ultimasOffline.length > 0 && (
|
||||
<div className="px-4 py-2 border-t border-gray-50">
|
||||
<p className="text-[10px] font-black uppercase text-gray-400 tracking-widest mb-1">Últimas visitas</p>
|
||||
{ultimasOffline.slice(0, 6).map(u => (
|
||||
<div key={u.usuario_id} className="flex items-center justify-between py-1">
|
||||
<span className="text-[11px] font-bold text-gray-500 uppercase truncate">{u.usuario_nome}</span>
|
||||
<span className="text-[9px] text-gray-300 font-bold whitespace-nowrap ml-2">{relTime(u.last_view_at)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { X, Plus, Trash2, MapPin, Phone, Clock, CalendarClock, Save, Loader2, MessageSquare, AlertTriangle, CheckCircle2 } from 'lucide-react';
|
||||
import { useToast } from '../contexts/ToastContext.tsx';
|
||||
import { buscarCep } from '../services/viacep.ts';
|
||||
import { HorariosConfig } from './HorariosConfig.tsx';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
|
||||
// Comunicação interna: perfil de contato de não-pacientes (dentistas/funcionários).
|
||||
// Números de WhatsApp múltiplos (cada um com descrição, situações e janelas de horário
|
||||
// permitido p/ a Secretária enviar msg — proteção contra mensagem fora do expediente),
|
||||
// endereço e disponibilidade de agenda (abrir/fechar + jornada).
|
||||
|
||||
const API_URL = (window as any).__API_URL__ || '';
|
||||
const COLOR = '#0d9488';
|
||||
const UF = ['', 'AC', 'AL', 'AM', 'AP', 'BA', 'CE', 'DF', 'ES', 'GO', 'MA', 'MG', 'MS', 'MT', 'PA', 'PB', 'PE', 'PI', 'PR', 'RJ', 'RN', 'RO', 'RR', 'RS', 'SC', 'SE', 'SP', 'TO'];
|
||||
const DIAS = [{ v: 0, l: 'Dom' }, { v: 1, l: 'Seg' }, { v: 2, l: 'Ter' }, { v: 3, l: 'Qua' }, { v: 4, l: 'Qui' }, { v: 5, l: 'Sex' }, { v: 6, l: 'Sáb' }];
|
||||
|
||||
const api = (path: string, opts: RequestInit = {}) =>
|
||||
fetch(`${API_URL}${path}`, {
|
||||
...opts,
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('SCOREODONTO_AUTH_TOKEN')}` },
|
||||
});
|
||||
|
||||
interface Janela { dias: number[]; inicio: string; fim: string }
|
||||
interface Numero { numero: string; descricao: string; situacoes: string; janelas: Janela[] }
|
||||
interface Endereco { cep?: string; logradouro?: string; numero?: string; complemento?: string; bairro?: string; cidade?: string; estado?: string }
|
||||
interface Agenda { aberta?: boolean; horarios?: Janela[] }
|
||||
|
||||
const input = 'w-full px-3 py-2.5 bg-gray-50 border border-gray-200 rounded-xl text-sm font-medium text-gray-700 outline-none focus:border-teal-400';
|
||||
const label = 'text-[10px] font-black text-gray-400 uppercase tracking-widest block mb-1';
|
||||
|
||||
// ── Editor de janelas de horário (dias + faixa) — reutilizado p/ contato e agenda ──
|
||||
const JanelaEditor: React.FC<{ janelas: Janela[]; onChange: (j: Janela[]) => void; addLabel: string }> = ({ janelas, onChange, addLabel }) => {
|
||||
const upd = (i: number, patch: Partial<Janela>) => onChange(janelas.map((j, k) => (k === i ? { ...j, ...patch } : j)));
|
||||
const toggleDia = (i: number, d: number) => {
|
||||
const j = janelas[i]; const has = j.dias.includes(d);
|
||||
upd(i, { dias: has ? j.dias.filter(x => x !== d) : [...j.dias, d].sort((a, b) => a - b) });
|
||||
};
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{janelas.map((j, i) => (
|
||||
<div key={i} className="rounded-xl border border-gray-200 p-3 bg-white space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{DIAS.map(d => {
|
||||
const on = j.dias.includes(d.v);
|
||||
return (
|
||||
<button key={d.v} type="button" onClick={() => toggleDia(i, d.v)}
|
||||
className={`w-8 h-8 rounded-lg text-[10px] font-black transition-all ${on ? 'text-white' : 'bg-gray-100 text-gray-400 hover:bg-gray-200'}`}
|
||||
style={on ? { backgroundColor: COLOR } : {}}>{d.l}</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button type="button" onClick={() => onChange(janelas.filter((_, k) => k !== i))} className="p-1.5 text-gray-300 hover:text-red-500"><Trash2 size={15} /></button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-black text-gray-400 uppercase">Das</span>
|
||||
<input type="time" value={j.inicio} onChange={e => upd(i, { inicio: e.target.value })} className="px-2 py-1.5 bg-gray-50 border border-gray-200 rounded-lg text-sm font-bold text-gray-700 outline-none focus:border-teal-400" />
|
||||
<span className="text-[10px] font-black text-gray-400 uppercase">até</span>
|
||||
<input type="time" value={j.fim} onChange={e => upd(i, { fim: e.target.value })} className="px-2 py-1.5 bg-gray-50 border border-gray-200 rounded-lg text-sm font-bold text-gray-700 outline-none focus:border-teal-400" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" onClick={() => onChange([...janelas, { dias: [1, 2, 3, 4, 5], inicio: '08:00', fim: '18:00' }])}
|
||||
className="w-full py-2 rounded-xl border border-dashed border-gray-300 text-[11px] font-black uppercase text-gray-400 hover:border-teal-400 hover:text-teal-600 flex items-center justify-center gap-1.5">
|
||||
<Plus size={13} /> {addLabel}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ComunicacaoInternaModal: React.FC<{
|
||||
usuarioId?: string; clinicaId?: string; nome?: string; forced?: boolean;
|
||||
onClose: () => void; onSaved?: (completo: boolean) => void; onSnooze?: () => void;
|
||||
}> = ({ usuarioId = 'me', clinicaId, nome, forced, onClose, onSaved, onSnooze }) => {
|
||||
const toast = useToast();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [endereco, setEndereco] = useState<Endereco>({});
|
||||
const [numeros, setNumeros] = useState<Numero[]>([]);
|
||||
const [enderecoDaClinica, setEnderecoDaClinica] = useState(false);
|
||||
const [isDentista, setIsDentista] = useState(false); // dentista precisa da jornada
|
||||
const [isDono, setIsDono] = useState(false); // dono precisa do horário da clínica
|
||||
const [jornadaOk, setJornadaOk] = useState(false); // jornada real (dentistas_horarios) configurada
|
||||
const [horarioClinicaOk, setHorarioClinicaOk] = useState(false); // clinicas_horarios configurado
|
||||
const [horariosTab, setHorariosTab] = useState<'dentistas' | 'clinica' | null>(null); // abre "Horários de Funcionamento"
|
||||
const [trocarOpen, setTrocarOpen] = useState(false); // dropdown "Trocar de clínica" (escape do gate forçado)
|
||||
|
||||
// Escape do gate forçado: o gate é POR clínica; travar ESTA não deve prender o
|
||||
// usuário no sistema. Lista as OUTRAS clínicas (exceto a atual e salas) para trocar.
|
||||
const outrasClinicas = (HybridBackend.getWorkspaces() || [])
|
||||
.filter((w: any) => w.tipo !== 'sala' && w.id !== clinicaId);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const qs = clinicaId ? `?clinicaId=${encodeURIComponent(clinicaId)}` : '';
|
||||
const r = await api(`/api/comunicacao/${usuarioId}${qs}`);
|
||||
const d = await r.json();
|
||||
setEndereco(d.endereco || {});
|
||||
setNumeros(Array.isArray(d.numeros) ? d.numeros : []);
|
||||
setEnderecoDaClinica(!!d.endereco_da_clinica);
|
||||
setIsDentista((d.role || '') === 'dentista');
|
||||
setIsDono(['donoclinica', 'donoconsultorio'].includes(d.role || ''));
|
||||
setJornadaOk(!!d.jornada_configurada);
|
||||
setHorarioClinicaOk(!!d.horario_clinica_configurado);
|
||||
} catch { /* mantém vazio */ } finally { setLoading(false); }
|
||||
}, [usuarioId, clinicaId]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const setEnd = (k: keyof Endereco, v: string) => setEndereco(p => ({ ...p, [k]: v }));
|
||||
const onCep = async () => {
|
||||
const cep = (endereco.cep || '').replace(/\D/g, '');
|
||||
if (cep.length !== 8) return;
|
||||
const e = await buscarCep(cep);
|
||||
if (e) setEndereco(p => ({ ...p, logradouro: e.logradouro || p.logradouro, bairro: e.bairro || p.bairro, cidade: e.cidade || p.cidade, estado: e.estado || p.estado }));
|
||||
};
|
||||
|
||||
const updNum = (i: number, patch: Partial<Numero>) => setNumeros(ns => ns.map((n, k) => (k === i ? { ...n, ...patch } : n)));
|
||||
const addNum = () => setNumeros(ns => [...ns, { numero: '', descricao: '', situacoes: '', janelas: [{ dias: [1, 2, 3, 4, 5], inicio: '08:00', fim: '18:00' }] }]);
|
||||
|
||||
// Notificação de número compartilhado: ao sair do campo, verifica se o número já é de
|
||||
// outra conta/papel da clínica (a mesma pessoa pode ser dono E dentista, por ex.).
|
||||
const [avisos, setAvisos] = useState<Record<number, { nome: string; role: string }[]>>({});
|
||||
const roleLabel = (r: string) => (({ dentista: 'dentista', donoclinica: 'dono', donoconsultorio: 'dono', funcionario: 'funcionário', admin: 'admin' } as Record<string, string>)[r] || r || 'conta');
|
||||
const checkNumero = async (i: number, numero: string) => {
|
||||
const tel = (numero || '').replace(/\D/g, '');
|
||||
if (tel.length < 10 || !clinicaId) { setAvisos(a => ({ ...a, [i]: [] })); return; }
|
||||
try {
|
||||
const r = await api(`/api/comunicacao/numero-lookup?clinicaId=${encodeURIComponent(clinicaId)}&numero=${encodeURIComponent(tel)}`);
|
||||
const d = await r.json();
|
||||
setAvisos(a => ({ ...a, [i]: Array.isArray(d.encontrados) ? d.encontrados : [] }));
|
||||
} catch { /* silencioso */ }
|
||||
};
|
||||
|
||||
// Completude (espelha o backend) — orienta o preenchimento e destrava o gate.
|
||||
const enderecoOk = !!(endereco.cidade && endereco.estado && (endereco.cep || endereco.logradouro));
|
||||
const numerosOk = numeros.length >= 1 && numeros.every(n => (n.numero || '').replace(/\D/g, '').length >= 10 && (n.descricao || '').trim() && (n.janelas?.length ?? 0) >= 1);
|
||||
const jornadaFeita = !isDentista || jornadaOk; // dentista: jornada real configurada
|
||||
const clinicaFeita = !isDono || horarioClinicaOk; // dono: horário da clínica configurado
|
||||
const completo = enderecoOk && numerosOk && jornadaFeita && clinicaFeita;
|
||||
|
||||
const salvar = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const r = await api(`/api/comunicacao/${usuarioId}`, { method: 'PUT', body: JSON.stringify({ clinicaId, endereco, numeros }) });
|
||||
const d = await r.json();
|
||||
if (!r.ok) throw new Error(d.error || 'Erro ao salvar.');
|
||||
toast.success('DADOS SALVOS!');
|
||||
onSaved?.(!!d.completo);
|
||||
if (!forced || d.completo) onClose();
|
||||
else toast.error('AINDA FALTAM CAMPOS OBRIGATÓRIOS PARA LIBERAR O SISTEMA.');
|
||||
} catch (e: any) { toast.error(String(e.message).toUpperCase()); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[70] bg-white flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="px-5 sm:px-8 py-4 border-b border-gray-100 flex items-center justify-between shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl flex items-center justify-center" style={{ backgroundColor: `${COLOR}18`, color: COLOR }}><MessageSquare size={20} /></div>
|
||||
<div>
|
||||
<h2 className="text-sm sm:text-base font-black text-gray-900 uppercase">Comunicação interna{nome ? ` · ${nome}` : ''}</h2>
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest" style={{ color: completo ? '#16a34a' : COLOR }}>
|
||||
{completo ? 'Perfil completo' : 'Preencha para liberar o sistema'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{!forced && <button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-xl text-gray-500"><X size={20} /></button>}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex-1 flex items-center justify-center text-gray-400"><Loader2 className="animate-spin" size={28} /></div>
|
||||
) : (
|
||||
<>
|
||||
{/* Conteúdo */}
|
||||
<div className="flex-1 overflow-y-auto px-5 sm:px-8 py-6">
|
||||
<div className="max-w-3xl mx-auto space-y-8">
|
||||
|
||||
{forced && (
|
||||
<div className="rounded-2xl bg-amber-50 border border-amber-200 p-4 flex gap-3">
|
||||
<AlertTriangle size={18} className="text-amber-500 shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-amber-700 font-medium leading-relaxed">
|
||||
Precisamos destes dados para o sistema funcionar corretamente e para a <b>Secretária IA</b> só contatar você
|
||||
nos horários certos. Mensagens fora do expediente podem gerar problemas — por isso as <b>janelas de horário</b> são obrigatórias.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Endereço ── */}
|
||||
<section>
|
||||
<h3 className="flex items-center gap-2 text-xs font-black text-gray-700 uppercase tracking-wide mb-1"><MapPin size={15} style={{ color: COLOR }} /> Endereço</h3>
|
||||
{enderecoDaClinica && <p className="text-[11px] text-teal-600 font-bold mb-3">Puxamos o endereço da clínica — é só conferir (ou ajustar, se o seu for diferente).</p>}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-6 gap-3">
|
||||
<div className="sm:col-span-2">
|
||||
<label className={label}>CEP</label>
|
||||
<input className={input} value={endereco.cep || ''} onChange={e => setEnd('cep', e.target.value)} onBlur={onCep} placeholder="00000-000" />
|
||||
</div>
|
||||
<div className="sm:col-span-4">
|
||||
<label className={label}>Logradouro</label>
|
||||
<input className={input} value={endereco.logradouro || ''} onChange={e => setEnd('logradouro', e.target.value)} placeholder="Rua / Avenida" />
|
||||
</div>
|
||||
<div className="sm:col-span-1">
|
||||
<label className={label}>Número</label>
|
||||
<input className={input} value={endereco.numero || ''} onChange={e => setEnd('numero', e.target.value)} />
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<label className={label}>Complemento</label>
|
||||
<input className={input} value={endereco.complemento || ''} onChange={e => setEnd('complemento', e.target.value)} />
|
||||
</div>
|
||||
<div className="sm:col-span-3">
|
||||
<label className={label}>Bairro</label>
|
||||
<input className={input} value={endereco.bairro || ''} onChange={e => setEnd('bairro', e.target.value)} />
|
||||
</div>
|
||||
<div className="sm:col-span-4">
|
||||
<label className={label}>Cidade</label>
|
||||
<input className={input} value={endereco.cidade || ''} onChange={e => setEnd('cidade', e.target.value)} />
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<label className={label}>UF</label>
|
||||
<select className={input} value={endereco.estado || ''} onChange={e => setEnd('estado', e.target.value)}>
|
||||
{UF.map(u => <option key={u} value={u}>{u || '—'}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Telefones / WhatsApp ── */}
|
||||
<section>
|
||||
<h3 className="flex items-center gap-2 text-xs font-black text-gray-700 uppercase tracking-wide mb-1"><Phone size={15} style={{ color: COLOR }} /> Números de WhatsApp</h3>
|
||||
<p className="text-[11px] text-gray-400 font-medium mb-3">Cadastre todos os seus números. Para cada um, descreva quando ele pode ser usado e em quais horários a Secretária pode enviar mensagem.</p>
|
||||
<div className="space-y-4">
|
||||
{numeros.map((n, i) => (
|
||||
<div key={i} className="rounded-2xl border border-gray-200 p-4 space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-1 grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className={label}>Número</label>
|
||||
<input className={input} value={n.numero} onChange={e => updNum(i, { numero: e.target.value })} onBlur={e => checkNumero(i, e.target.value)} placeholder="(67) 99999-9999" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={label}>Descrição</label>
|
||||
<input className={input} value={n.descricao} onChange={e => updNum(i, { descricao: e.target.value })} placeholder="Ex.: WhatsApp de trabalho" />
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" onClick={() => setNumeros(ns => ns.filter((_, k) => k !== i))} className="p-2 text-gray-300 hover:text-red-500 mt-5"><Trash2 size={16} /></button>
|
||||
</div>
|
||||
{(avisos[i]?.length ?? 0) > 0 && (
|
||||
<div className="rounded-xl bg-amber-50 border border-amber-200 p-2.5 text-[11px] text-amber-700 font-medium flex gap-2">
|
||||
<AlertTriangle size={14} className="shrink-0 mt-0.5 text-amber-500" />
|
||||
<span>Este número já está cadastrado por <b>{avisos[i].map(x => `${x.nome} (${roleLabel(x.role)})`).join(', ')}</b>. Pode compartilhar o mesmo número — descreva abaixo as situações deste papel; a Secretária identifica pelo assunto quem deve responder.</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className={label}>Situações em que a Secretária pode mandar msg neste número</label>
|
||||
<textarea className={input} rows={2} value={n.situacoes} onChange={e => updNum(i, { situacoes: e.target.value })} placeholder="Ex.: só confirmações de encaixe e avisos urgentes; nunca assuntos pessoais." />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest flex items-center gap-1.5 mb-1"><Clock size={12} /> Horários permitidos para envio</label>
|
||||
<JanelaEditor janelas={n.janelas || []} onChange={j => updNum(i, { janelas: j })} addLabel="Adicionar janela de horário" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" onClick={addNum} className="w-full py-3 rounded-2xl border border-dashed border-gray-300 text-xs font-black uppercase text-gray-400 hover:border-teal-400 hover:text-teal-600 flex items-center justify-center gap-2">
|
||||
<Plus size={15} /> Adicionar número
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Agenda de atendimento — fonte única: "Horários de Funcionamento" ── */}
|
||||
{isDentista && (
|
||||
<section>
|
||||
<h3 className="flex items-center gap-2 text-xs font-black text-gray-700 uppercase tracking-wide mb-1"><CalendarClock size={15} style={{ color: COLOR }} /> Agenda de atendimento</h3>
|
||||
<p className="text-[11px] text-gray-400 font-medium mb-3">São os horários em que a Secretária pode marcar consultas para você. Ficam em "Horários de Funcionamento" (a mesma jornada que a agenda usa).</p>
|
||||
{jornadaOk ? (
|
||||
<div className="rounded-xl bg-green-50 border border-green-200 p-3.5 flex items-center justify-between">
|
||||
<span className="flex items-center gap-2 text-xs font-black text-green-700 uppercase"><CheckCircle2 size={15} /> Jornada configurada</span>
|
||||
<button type="button" onClick={() => setHorariosTab('dentistas')} className="text-[10px] font-black uppercase text-teal-600 hover:text-teal-700">Editar</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl bg-amber-50 border border-amber-200 p-3.5">
|
||||
<p className="text-[11px] text-amber-700 font-medium mb-2 flex gap-2"><AlertTriangle size={14} className="shrink-0 mt-0.5 text-amber-500" /> Você ainda não configurou sua agenda de atendimento. Sem ela, a Secretária não consegue marcar consultas com você.</p>
|
||||
<button type="button" onClick={() => setHorariosTab('dentistas')} className="w-full py-2.5 rounded-xl text-[11px] font-black uppercase text-white hover:opacity-90" style={{ backgroundColor: COLOR }}>Configurar minha agenda</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── Horário de funcionamento da clínica — só para o DONO ── */}
|
||||
{isDono && (
|
||||
<section>
|
||||
<h3 className="flex items-center gap-2 text-xs font-black text-gray-700 uppercase tracking-wide mb-1"><CalendarClock size={15} style={{ color: COLOR }} /> Horário da clínica</h3>
|
||||
<p className="text-[11px] text-gray-400 font-medium mb-3">É o horário de funcionamento que a Secretária usa para atender e marcar consultas. Fica em "Horários de Funcionamento".</p>
|
||||
{horarioClinicaOk ? (
|
||||
<div className="rounded-xl bg-green-50 border border-green-200 p-3.5 flex items-center justify-between">
|
||||
<span className="flex items-center gap-2 text-xs font-black text-green-700 uppercase"><CheckCircle2 size={15} /> Horário da clínica configurado</span>
|
||||
<button type="button" onClick={() => setHorariosTab('clinica')} className="text-[10px] font-black uppercase text-teal-600 hover:text-teal-700">Editar</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl bg-amber-50 border border-amber-200 p-3.5">
|
||||
<p className="text-[11px] text-amber-700 font-medium mb-2 flex gap-2"><AlertTriangle size={14} className="shrink-0 mt-0.5 text-amber-500" /> A clínica ainda não tem horário de funcionamento. Sem ele, a Secretária não consegue atender nem marcar consultas.</p>
|
||||
<button type="button" onClick={() => setHorariosTab('clinica')} className="w-full py-2.5 rounded-xl text-[11px] font-black uppercase text-white hover:opacity-90" style={{ backgroundColor: COLOR }}>Configurar horário da clínica</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-5 sm:px-8 py-4 border-t border-gray-100 shrink-0">
|
||||
<div className="max-w-3xl mx-auto flex items-center gap-3">
|
||||
<div className="flex-1 flex flex-wrap gap-x-3 gap-y-1 text-[10px] font-black uppercase tracking-wide">
|
||||
<span className={enderecoOk ? 'text-green-600' : 'text-gray-300'}>{enderecoOk ? '✓' : '○'} Endereço</span>
|
||||
<span className={numerosOk ? 'text-green-600' : 'text-gray-300'}>{numerosOk ? '✓' : '○'} Números</span>
|
||||
{isDentista && <span className={jornadaFeita ? 'text-green-600' : 'text-gray-300'}>{jornadaFeita ? '✓' : '○'} Agenda</span>}
|
||||
{isDono && <span className={clinicaFeita ? 'text-green-600' : 'text-gray-300'}>{clinicaFeita ? '✓' : '○'} Clínica</span>}
|
||||
</div>
|
||||
{!forced && onSnooze && (
|
||||
<button onClick={onSnooze} className="px-4 py-3 rounded-xl text-xs font-black uppercase text-gray-500 border border-gray-200 hover:bg-gray-50">
|
||||
Lembrar mais tarde
|
||||
</button>
|
||||
)}
|
||||
{/* Escape do gate forçado: sair para OUTRA clínica sem preencher esta. */}
|
||||
{forced && outrasClinicas.length > 0 && (
|
||||
<div className="relative">
|
||||
<button type="button" onClick={() => setTrocarOpen(o => !o)}
|
||||
className="px-4 py-3 rounded-xl text-xs font-black uppercase text-gray-600 border border-gray-200 hover:bg-gray-50 flex items-center gap-1.5">
|
||||
Trocar de clínica
|
||||
</button>
|
||||
{trocarOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[71]" onClick={() => setTrocarOpen(false)} />
|
||||
<div className="absolute right-0 bottom-full mb-2 z-[72] min-w-[220px] bg-white border border-gray-200 rounded-2xl shadow-xl py-2 max-h-[50vh] overflow-y-auto">
|
||||
<p className="px-4 py-1.5 text-[9px] font-black text-gray-400 uppercase tracking-widest">Ir para outra clínica</p>
|
||||
{outrasClinicas.map((w: any) => (
|
||||
<button key={w.id} type="button" onClick={() => HybridBackend.switchWorkspace(w.id)}
|
||||
className="w-full flex items-center gap-2.5 px-4 py-2 hover:bg-gray-50 text-left">
|
||||
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: w.cor || '#2563eb' }} />
|
||||
<span className="text-xs font-bold text-gray-700 uppercase truncate">{w.nome}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<button onClick={salvar} disabled={saving}
|
||||
className="px-6 py-3 text-white rounded-xl text-xs font-black uppercase hover:opacity-90 disabled:opacity-50 flex items-center justify-center gap-2" style={{ backgroundColor: completo ? '#16a34a' : COLOR }}>
|
||||
{saving ? <Loader2 size={15} className="animate-spin" /> : completo ? <CheckCircle2 size={15} /> : <Save size={15} />}
|
||||
{forced ? (completo ? 'Salvar e liberar' : 'Salvar progresso') : 'Salvar'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* "Horários de Funcionamento" por cima — fonte única (dentistas_horarios / clinicas_horarios). */}
|
||||
{horariosTab && (
|
||||
<HorariosConfig isOpen clinicaId={clinicaId} initialTab={horariosTab} soDentista={horariosTab === 'dentistas' && usuarioId === 'me'}
|
||||
onClose={() => { setHorariosTab(null); load(); }} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ComunicacaoInternaModal;
|
||||
@@ -0,0 +1,135 @@
|
||||
// Feature C — modal de confirmação de dados no CHECK-IN. Abre quando a recepção
|
||||
// clica "Atender" e o paciente veio da Secretária (dados_confirmados=false). A recepção
|
||||
// confere/ajusta Nome, Data de nascimento, CPF e Telefone (o número PRÓPRIO deste
|
||||
// paciente — um dependente pode ter ganhado WhatsApp próprio) e vê o grupo familiar.
|
||||
// Salva na base de Pacientes e marca dados_confirmados=true (o modal para de abrir).
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, Loader2, Check, Users, ShieldCheck } from 'lucide-react';
|
||||
import { HybridBackend } from '../services/backend';
|
||||
import { useToast } from '../contexts/ToastContext.tsx';
|
||||
|
||||
// Normaliza p/ YYYY-MM-DD (aceita DD/MM/AAAA); senão devolve vazio p/ o <input type=date>.
|
||||
const paraInputDate = (s?: string | null): string => {
|
||||
const v = String(s || '').trim();
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(v)) return v;
|
||||
const m = v.match(/^(\d{1,2})[/-](\d{1,2})[/-](\d{4})$/);
|
||||
if (m) return `${m[3]}-${m[2].padStart(2, '0')}-${m[1].padStart(2, '0')}`;
|
||||
return '';
|
||||
};
|
||||
|
||||
interface Props {
|
||||
pacienteId: string | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirmed: () => void; // segue o fluxo (ex.: prosseguir com o Atender)
|
||||
}
|
||||
|
||||
export const ConfirmarDadosPaciente: React.FC<Props> = ({ pacienteId, isOpen, onClose, onConfirmed }) => {
|
||||
const toast = useToast();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [nome, setNome] = useState('');
|
||||
const [nasc, setNasc] = useState('');
|
||||
const [cpf, setCpf] = useState('');
|
||||
const [tel, setTel] = useState('');
|
||||
const [familia, setFamilia] = useState<{ grupo: any; membros: any[] }>({ grupo: null, membros: [] });
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !pacienteId) return;
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
HybridBackend.getPacienteById(pacienteId),
|
||||
HybridBackend.getFamiliaPaciente(pacienteId).catch(() => ({ grupo: null, membros: [] })),
|
||||
]).then(([p, fam]) => {
|
||||
if (!alive) return;
|
||||
setNome(p?.nome || ''); setNasc(paraInputDate(p?.datanascimento)); setCpf(p?.cpf || ''); setTel(p?.telefone || '');
|
||||
setFamilia(fam as any);
|
||||
}).catch(() => { if (alive) toast.error('Não foi possível carregar o paciente.'); })
|
||||
.finally(() => { if (alive) setLoading(false); });
|
||||
return () => { alive = false; };
|
||||
}, [isOpen, pacienteId]);
|
||||
|
||||
if (!isOpen || !pacienteId) return null;
|
||||
|
||||
const salvar = async () => {
|
||||
if (!nome.trim()) { toast.error('Informe o nome.'); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
const r = await HybridBackend.confirmarDadosPaciente(pacienteId, {
|
||||
nome: nome.trim(), cpf: cpf.trim() || undefined, telefone: tel.trim() || undefined, data_nascimento: nasc || undefined,
|
||||
});
|
||||
if (!r.ok) { toast.error(r.mensagem || 'Dados inválidos.'); setSaving(false); return; }
|
||||
toast.success('Dados confirmados!');
|
||||
onConfirmed(); onClose();
|
||||
} catch { toast.error('Erro ao confirmar.'); } finally { setSaving(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[90] bg-black/60 backdrop-blur-md flex items-center justify-center p-4" onClick={onClose}>
|
||||
<div className="bg-white w-full max-w-md rounded-[1.75rem] shadow-2xl border border-gray-100 flex flex-col max-h-[92vh] 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-teal-50 to-white">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 bg-teal-500 rounded-2xl shadow-lg shadow-teal-200"><ShieldCheck size={20} className="text-white" /></div>
|
||||
<div>
|
||||
<h2 className="text-base font-black text-gray-900 uppercase tracking-tighter leading-none">Confirme os dados</h2>
|
||||
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mt-1">Cadastro veio da Secretária IA · confira no check-in</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-xl text-gray-400"><X size={20} /></button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20"><Loader2 size={24} className="animate-spin text-gray-400" /></div>
|
||||
) : (
|
||||
<div className="p-6 space-y-4 overflow-y-auto">
|
||||
<Campo label="Nome completo"><input value={nome} onChange={e => setNome(e.target.value)} className={inpCls} /></Campo>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Campo label="Data de nascimento"><input type="date" value={nasc} onChange={e => setNasc(e.target.value)} className={inpCls} /></Campo>
|
||||
<Campo label="CPF"><input value={cpf} onChange={e => setCpf(e.target.value)} placeholder="000.000.000-00" inputMode="numeric" className={inpCls} /></Campo>
|
||||
</div>
|
||||
<Campo label="Telefone deste paciente">
|
||||
<input value={tel} onChange={e => setTel(e.target.value)} placeholder="(67) 90000-0000" inputMode="tel" className={inpCls} />
|
||||
<p className="text-[10px] text-gray-400 mt-1">Se for dependente com WhatsApp próprio, ajuste o número dele aqui.</p>
|
||||
</Campo>
|
||||
|
||||
{/* Grupo familiar (contexto) */}
|
||||
{(familia.grupo || familia.membros.length > 0) && (
|
||||
<div className="rounded-2xl bg-gray-50 p-3">
|
||||
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest flex items-center gap-1.5 mb-2">
|
||||
<Users size={12} /> Grupo familiar{familia.grupo?.nome ? ` · ${familia.grupo.nome}` : ''}
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{familia.membros.map((m: any) => (
|
||||
<div key={m.id} className="flex items-center gap-2 text-xs">
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${String(m.relacao || m.grupofamiliarrelacao || '').toLowerCase() === 'titular' ? 'bg-teal-500' : 'bg-gray-300'}`} />
|
||||
<span className="font-bold text-gray-700 truncate">{m.nome}</span>
|
||||
{String(m.relacao || m.grupofamiliarrelacao || '').toLowerCase() === 'titular' && <span className="text-[9px] font-black uppercase text-teal-600">titular</span>}
|
||||
</div>
|
||||
))}
|
||||
{familia.membros.length === 0 && <p className="text-[11px] text-gray-400">Sem outros membros vinculados.</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="px-6 py-4 border-t border-gray-50 flex gap-2">
|
||||
<button onClick={onClose} className="flex-1 py-2.5 rounded-xl text-sm font-black uppercase tracking-wide text-gray-500 hover:bg-gray-100">Depois</button>
|
||||
<button onClick={salvar} disabled={saving || loading}
|
||||
className="flex-[2] py-2.5 rounded-xl text-sm font-black uppercase tracking-wide bg-teal-600 hover:bg-teal-700 text-white flex items-center justify-center gap-2 disabled:opacity-50">
|
||||
{saving ? <Loader2 size={16} className="animate-spin" /> : <Check size={16} />} Confirmar e continuar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const inpCls = 'w-full bg-gray-50 border-2 border-transparent focus:border-teal-500 rounded-xl px-3 py-2 text-sm outline-none';
|
||||
const Campo: React.FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => (
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">{label}</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -2,7 +2,8 @@
|
||||
// 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';
|
||||
import { X, Clock, CalendarOff, Plus, Trash2, Save, Loader2, User, Copy, AlertTriangle, Check } from 'lucide-react';
|
||||
import { SegmentedTabs } from './SegmentedTabs.tsx';
|
||||
|
||||
const API = (import.meta as any).env?.VITE_API_URL || '/api';
|
||||
const BASE = `${API}/nw/agenda-config`;
|
||||
@@ -88,8 +89,8 @@ const GradeSemanal: React.FC<{ faixas: Faixa[]; onChange: (f: Faixa[]) => void;
|
||||
);
|
||||
};
|
||||
|
||||
export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; clinicaId?: string; initialTab?: 'clinica' | 'dentistas' | 'situacoes'; soDentista?: boolean }> = ({ isOpen, onClose, clinicaId, initialTab, soDentista }) => {
|
||||
const [aba, setAba] = useState<'clinica' | 'dentistas' | 'situacoes'>(initialTab || 'clinica');
|
||||
export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; clinicaId?: string; initialTab?: 'clinica' | 'dentistas' | 'situacoes' | 'procedimentos'; soDentista?: boolean }> = ({ isOpen, onClose, clinicaId, initialTab, soDentista }) => {
|
||||
const [aba, setAba] = useState<'clinica' | 'dentistas' | 'situacoes' | 'procedimentos'>(initialTab || 'clinica');
|
||||
useEffect(() => { if (isOpen && initialTab) setAba(initialTab); }, [isOpen, initialTab]);
|
||||
const [erro, setErro] = useState<string | null>(null);
|
||||
const [ok, setOk] = useState<string | null>(null);
|
||||
@@ -109,6 +110,7 @@ export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; cl
|
||||
|
||||
// Situações (regras que a Secretária IA segue)
|
||||
const [situacoes, setSituacoes] = useState<string[]>([]);
|
||||
const [procedimentos, setProcedimentos] = useState<Array<{ nome: string; atende: boolean; motivo: string; como_falar: string }>>([]);
|
||||
|
||||
const flash = (setter: (v: string | null) => void, msg: string) => { setter(msg); setTimeout(() => setter(null), 3000); };
|
||||
|
||||
@@ -137,10 +139,20 @@ export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; cl
|
||||
const carregarSituacoes = useCallback(async () => {
|
||||
if (!clinicaId) return;
|
||||
try { const s = await req(`/clinica/${clinicaId}/situacoes`); setSituacoes((s.situacoes || []).map((r: any) => r.texto)); }
|
||||
finally { /* noop */ }
|
||||
}, [clinicaId]);
|
||||
const carregarProcedimentos = useCallback(async () => {
|
||||
if (!clinicaId) return;
|
||||
try { const d = await req(`/clinica/${clinicaId}/procedimentos-atende`); setProcedimentos((d.procedimentos || []).map((r: any) => ({ nome: r.nome, atende: r.atende !== 0 && r.atende !== false, motivo: r.motivo || '', como_falar: r.como_falar || '' }))); }
|
||||
catch (e: any) { flash(setErro, e.message); }
|
||||
}, [clinicaId]);
|
||||
|
||||
useEffect(() => { if (isOpen) { carregarClinica(); carregarDentistas(); if (!soDentista) carregarSituacoes(); } }, [isOpen, carregarClinica, carregarDentistas, carregarSituacoes, soDentista]);
|
||||
useEffect(() => { if (isOpen) { carregarClinica(); carregarDentistas(); if (!soDentista) { carregarSituacoes(); carregarProcedimentos(); } } }, [isOpen, carregarClinica, carregarDentistas, carregarSituacoes, carregarProcedimentos, soDentista]);
|
||||
const salvarProcedimentos = async () => {
|
||||
setSaving(true);
|
||||
try { await req(`/clinica/${clinicaId}/procedimentos-atende`, { method: 'PUT', body: JSON.stringify({ procedimentos: procedimentos.map((p) => ({ nome: p.nome.trim(), atende: p.atende, motivo: p.motivo, como_falar: p.como_falar })).filter((p) => p.nome) }) }); flash(setOk, 'Procedimentos salvos!'); }
|
||||
catch (e: any) { flash(setErro, e.message); } finally { setSaving(false); }
|
||||
};
|
||||
const carregarFolgas = useCallback((id: string) => {
|
||||
req(`/dentista/${id}/folgas`).then((f) => setFolgas(f.folgas || [])).catch(() => setFolgas([]));
|
||||
}, []);
|
||||
@@ -204,13 +216,10 @@ export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; cl
|
||||
</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 className="px-8 pt-4">
|
||||
<SegmentedTabs
|
||||
tabs={(soDentista ? (['dentistas'] as const) : (['clinica', 'dentistas', 'situacoes', 'procedimentos'] as const)).map((t) => ({ id: t, label: t === 'clinica' ? 'Clínica' : t === 'dentistas' ? 'Dentistas' : t === 'situacoes' ? 'Situações' : 'Procedimentos' }))}
|
||||
value={aba} onChange={setAba} />
|
||||
</div>
|
||||
|
||||
{(erro || ok) && (
|
||||
@@ -301,7 +310,7 @@ export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; cl
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
) : aba === 'situacoes' ? (
|
||||
<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>
|
||||
@@ -325,6 +334,47 @@ export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; cl
|
||||
</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 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" /> Procedimentos que a clínica atende</h3>
|
||||
<button onClick={salvarProcedimentos} 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">
|
||||
Marque o que a clínica <b>atende</b>. Ao <b>desmarcar</b>, a Secretária IA não vai agendar aquele procedimento — preencha o <b>motivo</b> e o <b>como falar</b> (o que ela deve responder ao paciente).
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{procedimentos.map((p, i) => (
|
||||
<div key={i} className="rounded-2xl border border-gray-100 p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<button type="button" onClick={() => setProcedimentos(procedimentos.map((x, j) => j === i ? { ...x, atende: !x.atende } : x))}
|
||||
className={`w-6 h-6 rounded-lg flex items-center justify-center border-2 shrink-0 transition-colors ${p.atende ? 'bg-teal-600 border-teal-600 text-white' : 'bg-white border-gray-300 text-transparent'}`}>
|
||||
<Check size={14} />
|
||||
</button>
|
||||
<input value={p.nome} onChange={(e) => setProcedimentos(procedimentos.map((x, j) => j === i ? { ...x, nome: e.target.value } : x))}
|
||||
placeholder="Nome do procedimento"
|
||||
className="flex-1 bg-gray-50 border-2 border-transparent focus:border-teal-600 rounded-xl px-3 py-2 text-sm font-bold text-gray-800 outline-none" />
|
||||
<span className={`text-[10px] font-black uppercase ${p.atende ? 'text-teal-600' : 'text-red-500'}`}>{p.atende ? 'Atende' : 'Não atende'}</span>
|
||||
<button onClick={() => setProcedimentos(procedimentos.filter((_, j) => j !== i))} className="text-gray-300 hover:text-red-600"><Trash2 size={16} /></button>
|
||||
</div>
|
||||
{!p.atende && (
|
||||
<div className="mt-2 pl-9 space-y-2">
|
||||
<input value={p.motivo} onChange={(e) => setProcedimentos(procedimentos.map((x, j) => j === i ? { ...x, motivo: e.target.value } : x))}
|
||||
placeholder="Motivo de não atender (uso interno)"
|
||||
className="w-full bg-gray-50 border-2 border-transparent focus:border-teal-600 rounded-xl px-3 py-2 text-xs font-medium text-gray-700 outline-none" />
|
||||
<textarea value={p.como_falar} rows={2} onChange={(e) => setProcedimentos(procedimentos.map((x, j) => j === i ? { ...x, como_falar: e.target.value } : x))}
|
||||
placeholder="Como a Secretária deve falar isso ao paciente…"
|
||||
className="w-full bg-gray-50 border-2 border-transparent focus:border-teal-600 rounded-xl px-3 py-2 text-xs font-medium text-gray-700 outline-none resize-y" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button onClick={() => setProcedimentos([...procedimentos, { nome: '', atende: true, motivo: '', como_falar: '' }])} 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} /> Novo procedimento</button>
|
||||
</div>
|
||||
{procedimentos.length === 0 && <p className="text-[11px] text-gray-400 italic">Nenhum procedimento na lista. Adicione os que a clínica atende (ou não).</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// 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';
|
||||
import { X, CalendarClock, Check, Ban, Loader2, Phone, User, HelpCircle, MessageCircle, ChevronDown, ChevronLeft, ChevronRight, Clock, Sparkles, CalendarDays } from 'lucide-react';
|
||||
|
||||
const API = (import.meta as any).env?.VITE_API_URL || '/api';
|
||||
const BASE = `${API}/nw/agenda-config`;
|
||||
@@ -17,10 +17,85 @@ async function req(path: string, opts: RequestInit = {}) {
|
||||
|
||||
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 }) => {
|
||||
// Grade de horários (07:00–20:00, passo 30min) para AJUSTAR o horário na confirmação.
|
||||
// Garante que o horário sugerido pela IA esteja sempre presente (mesmo fora do passo).
|
||||
function gerarSlots(sugerido?: string): string[] {
|
||||
const set = new Set<string>();
|
||||
for (let m = 7 * 60; m <= 20 * 60; m += 30) {
|
||||
set.add(`${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`);
|
||||
}
|
||||
const s = (sugerido || '').slice(0, 5);
|
||||
if (/^\d{2}:\d{2}$/.test(s)) set.add(s);
|
||||
return [...set].sort();
|
||||
}
|
||||
|
||||
// ── Mini-calendário (troca de DIA na confirmação) ────────────────────────────
|
||||
const WD = ['D', 'S', 'T', 'Q', 'Q', 'S', 'S'];
|
||||
const MESES = ['Janeiro', 'Fevereiro', 'Março', 'Abril', 'Maio', 'Junho', 'Julho', 'Agosto', 'Setembro', 'Outubro', 'Novembro', 'Dezembro'];
|
||||
const toISO = (y: number, m: number, d: number) => `${y}-${String(m + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
|
||||
const hojeISO = () => { const n = new Date(); return toISO(n.getFullYear(), n.getMonth(), n.getDate()); };
|
||||
|
||||
// Calendário mensal compacto, sem libs. Dia sugerido pela IA marcado (bolinha âmbar),
|
||||
// selecionado em verde, hoje com anel, passado desabilitado. Navega por mês.
|
||||
const MiniCalendar: React.FC<{ value: string; suggested: string; onSelect: (iso: string) => void }> = ({ value, suggested, onSelect }) => {
|
||||
const base = (value || suggested || hojeISO()).slice(0, 10);
|
||||
const [view, setView] = useState<{ y: number; m: number }>({ y: Number(base.slice(0, 4)), m: Number(base.slice(5, 7)) - 1 });
|
||||
const hoje = hojeISO();
|
||||
const firstDow = new Date(view.y, view.m, 1).getDay();
|
||||
const nDias = new Date(view.y, view.m + 1, 0).getDate();
|
||||
const cells: (number | null)[] = [...Array(firstDow).fill(null), ...Array.from({ length: nDias }, (_, i) => i + 1)];
|
||||
const passo = (delta: number) => setView(v => { const m = v.m + delta; return m < 0 ? { y: v.y - 1, m: 11 } : m > 11 ? { y: v.y + 1, m: 0 } : { y: v.y, m }; });
|
||||
return (
|
||||
<div className="w-64 max-w-full">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<button type="button" onClick={() => passo(-1)} className="p-1 rounded-lg text-gray-400 hover:text-gray-700 hover:bg-gray-100 transition-colors"><ChevronLeft size={16} /></button>
|
||||
<span className="text-xs font-black uppercase tracking-wide text-gray-700">{MESES[view.m]} {view.y}</span>
|
||||
<button type="button" onClick={() => passo(1)} className="p-1 rounded-lg text-gray-400 hover:text-gray-700 hover:bg-gray-100 transition-colors"><ChevronRight size={16} /></button>
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-1 text-center mb-1">
|
||||
{WD.map((w, i) => <span key={i} className="text-[10px] font-black text-gray-300 uppercase">{w}</span>)}
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{cells.map((d, i) => {
|
||||
if (d === null) return <span key={i} />;
|
||||
const iso = toISO(view.y, view.m, d);
|
||||
const past = iso < hoje;
|
||||
const on = iso === value;
|
||||
const ia = iso === suggested;
|
||||
const isHoje = iso === hoje;
|
||||
return (
|
||||
<button key={i} type="button" disabled={past} onClick={() => onSelect(iso)}
|
||||
title={ia ? 'Sugerido pela IA' : undefined}
|
||||
className={`relative h-8 rounded-lg text-xs font-bold tabular-nums transition-all ${
|
||||
past ? 'text-gray-300 cursor-not-allowed'
|
||||
: on ? 'bg-emerald-600 text-white shadow-sm'
|
||||
: 'text-gray-700 hover:bg-emerald-50 hover:text-emerald-700'} ${isHoje && !on ? 'ring-1 ring-inset ring-teal-300' : ''}`}>
|
||||
{d}
|
||||
{ia && !on && <span className="absolute bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-amber-400" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const PedidosPendentes: React.FC<{
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
clinicaId?: string;
|
||||
onChange?: () => void;
|
||||
// Abre o chat do WhatsApp do solicitante (drawer da agenda) — telefone direto,
|
||||
// funciona mesmo para lead sem cadastro.
|
||||
onOpenChat?: (t: { pacienteId?: string; pacienteNome?: string; phone?: string }) => void;
|
||||
}> = ({ isOpen, onClose, clinicaId, onChange, onOpenChat }) => {
|
||||
const [pedidos, setPedidos] = useState<any[]>([]);
|
||||
const [dentistas, setDentistas] = useState<any[]>([]);
|
||||
const [sel, setSel] = useState<Record<string, string>>({}); // pedido_id → dentista_id escolhido
|
||||
const [horaSel, setHoraSel] = useState<Record<string, string>>({}); // pedido_id → HH:MM ajustado
|
||||
const [horaOpen, setHoraOpen] = useState<Record<string, boolean>>({}); // pedido_id → grade aberta
|
||||
const [dataSel, setDataSel] = useState<Record<string, string>>({}); // pedido_id → YYYY-MM-DD ajustado
|
||||
const [dataOpen, setDataOpen] = useState<Record<string, boolean>>({}); // pedido_id → calendário aberto
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [erro, setErro] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -41,8 +116,11 @@ export const PedidosPendentes: React.FC<{ isOpen: boolean; onClose: () => void;
|
||||
const confirmar = async (p: any) => {
|
||||
const dentistaId = p.dentista_id || sel[p.id];
|
||||
if (!dentistaId) { setErro('Escolha o dentista para confirmar.'); return; }
|
||||
// Horário e DIA: os ajustados pela humana (se mexeu) ou os que a IA fixou.
|
||||
const hora = horaSel[p.id] || String(p.hora_inicio || '').slice(0, 5);
|
||||
const data = dataSel[p.id] || String(p.data || '').slice(0, 10);
|
||||
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?.(); }
|
||||
try { await req(`/clinica/${clinicaId}/pedidos/${p.id}/confirmar`, { method: 'POST', body: JSON.stringify({ dentista_id: dentistaId, hora_inicio: hora, data }) }); await carregar(); onChange?.(); }
|
||||
catch (e: any) { setErro(e.message); } finally { setBusy(null); }
|
||||
};
|
||||
const recusar = async (p: any) => {
|
||||
@@ -88,6 +166,12 @@ export const PedidosPendentes: React.FC<{ isOpen: boolean; onClose: () => void;
|
||||
{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">
|
||||
{onOpenChat && p.paciente_telefone && (
|
||||
<button onClick={() => onOpenChat({ pacienteNome: p.paciente_nome, phone: p.paciente_telefone })}
|
||||
className="bg-[#25D366]/10 hover:bg-[#25D366]/20 text-[#128C7E] text-[11px] font-black uppercase px-3 py-1.5 rounded-xl flex items-center gap-1">
|
||||
<MessageCircle size={13} /> Abrir chat
|
||||
</button>
|
||||
)}
|
||||
<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
|
||||
@@ -103,9 +187,46 @@ export const PedidosPendentes: React.FC<{ isOpen: boolean; onClose: () => void;
|
||||
<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>
|
||||
{/* Dia AJUSTÁVEL: a IA fixou p.data; a humana troca se o paciente quiser outro dia. */}
|
||||
{(() => {
|
||||
const sugerido = String(p.data || '').slice(0, 10);
|
||||
const atual = dataSel[p.id] || sugerido;
|
||||
const mudou = atual !== sugerido;
|
||||
return (
|
||||
<button type="button" onClick={() => setDataOpen({ ...dataOpen, [p.id]: !dataOpen[p.id] })}
|
||||
title="Trocar o dia combinado com o paciente"
|
||||
className="flex items-center gap-1.5 text-sm font-black text-gray-800 hover:text-teal-700 transition-colors">
|
||||
<CalendarDays size={14} className="text-teal-600" />
|
||||
<span>{dm(atual)}</span>
|
||||
{mudou ? (
|
||||
<span className="text-[9px] font-black uppercase text-teal-700 bg-teal-50 px-1.5 py-0.5 rounded-md">trocado</span>
|
||||
) : (
|
||||
<span className="text-[9px] font-black uppercase text-amber-600 bg-amber-50 px-1.5 py-0.5 rounded-md flex items-center gap-0.5"><Sparkles size={9} /> IA</span>
|
||||
)}
|
||||
<ChevronDown size={14} className={`text-gray-400 transition-transform ${dataOpen[p.id] ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
{/* Horário AJUSTÁVEL: a IA fixou p.hora_inicio; a humana troca pelo combinado com o paciente. */}
|
||||
{(() => {
|
||||
const sugerida = String(p.hora_inicio).slice(0, 5);
|
||||
const atual = horaSel[p.id] || sugerida;
|
||||
const mudou = atual !== sugerida;
|
||||
return (
|
||||
<button type="button" onClick={() => setHoraOpen({ ...horaOpen, [p.id]: !horaOpen[p.id] })}
|
||||
title="Ajustar o horário combinado com o paciente"
|
||||
className="flex items-center gap-1.5 text-sm font-black text-gray-800 hover:text-teal-700 transition-colors">
|
||||
<Clock size={14} className="text-teal-600" />
|
||||
<span>{atual}{p.hora_fim && !mudou ? `–${String(p.hora_fim).slice(0, 5)}` : ''}</span>
|
||||
{mudou ? (
|
||||
<span className="text-[9px] font-black uppercase text-teal-700 bg-teal-50 px-1.5 py-0.5 rounded-md">ajustado</span>
|
||||
) : (
|
||||
<span className="text-[9px] font-black uppercase text-amber-600 bg-amber-50 px-1.5 py-0.5 rounded-md flex items-center gap-0.5"><Sparkles size={9} /> IA</span>
|
||||
)}
|
||||
<ChevronDown size={14} className={`text-gray-400 transition-transform ${horaOpen[p.id] ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
<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>}
|
||||
@@ -114,15 +235,26 @@ export const PedidosPendentes: React.FC<{ isOpen: boolean; onClose: () => void;
|
||||
{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 flex-col items-end gap-0.5">
|
||||
<select value={sel[p.id] || ''} onChange={(e) => setSel({ ...sel, [p.id]: e.target.value })}
|
||||
className={`bg-gray-50 border-2 rounded-xl px-2 py-1.5 text-xs font-bold text-gray-800 outline-none transition-colors ${
|
||||
sel[p.id] ? 'border-transparent focus:border-teal-600' : 'border-amber-400 ring-2 ring-amber-100'}`}>
|
||||
<option value="">Escolher dentista…</option>
|
||||
{dentistas.map((d) => <option key={d.id} value={d.id}>{d.nome}</option>)}
|
||||
</select>
|
||||
{!sel[p.id] && <span className="text-[9px] font-black uppercase tracking-wide text-amber-600">Escolha o dentista</span>}
|
||||
</div>
|
||||
)}
|
||||
<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">
|
||||
{onOpenChat && p.paciente_telefone && (
|
||||
<button onClick={() => onOpenChat({ pacienteNome: p.paciente_nome, phone: p.paciente_telefone })}
|
||||
className="bg-[#25D366]/10 hover:bg-[#25D366]/20 text-[#128C7E] text-[11px] font-black uppercase px-3 py-1.5 rounded-xl flex items-center gap-1">
|
||||
<MessageCircle size={13} /> Abrir chat
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => confirmar(p)} disabled={busy === p.id || (!p.dentista_id && !sel[p.id])}
|
||||
title={!p.dentista_id && !sel[p.id] ? 'Escolha o dentista antes de confirmar' : undefined}
|
||||
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 disabled:cursor-not-allowed">
|
||||
{busy === p.id ? <Loader2 size={13} className="animate-spin" /> : <Check size={13} />} Confirmar
|
||||
</button>
|
||||
<button onClick={() => recusar(p)} disabled={busy === p.id}
|
||||
@@ -132,6 +264,62 @@ export const PedidosPendentes: React.FC<{ isOpen: boolean; onClose: () => void;
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Calendário — escondido por padrão, abre pelo chevron do dia. Troca o dia
|
||||
combinado com o paciente; o sugerido pela IA vem marcado (bolinha âmbar). */}
|
||||
{dataOpen[p.id] && (() => {
|
||||
const sugerido = String(p.data || '').slice(0, 10);
|
||||
const atual = dataSel[p.id] || sugerido;
|
||||
return (
|
||||
<div className="mt-3 pt-3 border-t border-gray-100">
|
||||
<div className="text-[10px] font-black text-gray-400 uppercase tracking-widest mb-2 flex items-center gap-1.5">
|
||||
<CalendarDays size={12} /> Dia combinado com o paciente
|
||||
</div>
|
||||
<MiniCalendar value={atual} suggested={sugerido} onSelect={(iso) => setDataSel({ ...dataSel, [p.id]: iso })} />
|
||||
{dataSel[p.id] && dataSel[p.id] !== sugerido && (
|
||||
<button type="button" onClick={() => setDataSel({ ...dataSel, [p.id]: sugerido })}
|
||||
className="mt-2 text-[10px] font-bold text-gray-400 hover:text-gray-600 uppercase tracking-wide">
|
||||
↺ Voltar ao sugerido ({dm(sugerido)})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Grade de horários — escondida por padrão, abre pelo chevron. Escolha rápida
|
||||
do horário decidido com o paciente; o sugerido pela IA vem marcado. */}
|
||||
{horaOpen[p.id] && (() => {
|
||||
const sugerida = String(p.hora_inicio).slice(0, 5);
|
||||
const atual = horaSel[p.id] || sugerida;
|
||||
return (
|
||||
<div className="mt-3 pt-3 border-t border-gray-100">
|
||||
<div className="text-[10px] font-black text-gray-400 uppercase tracking-widest mb-2 flex items-center gap-1.5">
|
||||
<Clock size={12} /> Horário combinado com o paciente
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{gerarSlots(sugerida).map((h) => {
|
||||
const on = h === atual;
|
||||
const ia = h === sugerida;
|
||||
return (
|
||||
<button key={h} type="button" onClick={() => setHoraSel({ ...horaSel, [p.id]: h })}
|
||||
className={`relative px-2.5 py-1.5 rounded-lg text-xs font-black tabular-nums transition-all border ${
|
||||
on ? 'bg-emerald-600 text-white border-emerald-600 shadow-sm'
|
||||
: 'bg-gray-50 text-gray-600 border-transparent hover:border-emerald-300 hover:bg-emerald-50'}`}>
|
||||
{h}
|
||||
{ia && !on && <span className="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-amber-400" title="Sugerido pela IA" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{horaSel[p.id] && horaSel[p.id] !== sugerida && (
|
||||
<button type="button" onClick={() => setHoraSel({ ...horaSel, [p.id]: sugerida })}
|
||||
className="mt-2 text-[10px] font-bold text-gray-400 hover:text-gray-600 uppercase tracking-wide">
|
||||
↺ Voltar ao sugerido ({sugerida})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// SegmentedTabs — controle segmentado (abas em cápsula) PADRÃO do app.
|
||||
// Grupo de abas mutuamente exclusivas num trilho cinza; a ativa vira um cartão
|
||||
// branco com a cor de destaque. Responsivo: no mobile ocupa a largura toda e
|
||||
// distribui igual; no desktop encolhe ao conteúdo. Referência: /minhas-salas.
|
||||
import React from 'react';
|
||||
|
||||
export interface SegTab<T extends string = string> {
|
||||
id: T;
|
||||
label: string;
|
||||
icon?: React.ElementType;
|
||||
badge?: number;
|
||||
}
|
||||
|
||||
interface SegmentedTabsProps<T extends string> {
|
||||
tabs: SegTab<T>[];
|
||||
value: T;
|
||||
onChange: (id: T) => void;
|
||||
/** Cor de destaque da aba ativa (texto + badge). Padrão: teal do app. */
|
||||
accent?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SegmentedTabs<T extends string>({ tabs, value, onChange, accent = '#0d9488', className = '' }: SegmentedTabsProps<T>) {
|
||||
return (
|
||||
<div className={`flex gap-1 bg-gray-100/80 p-1 rounded-2xl w-full sm:w-fit overflow-x-auto ${className}`}>
|
||||
{tabs.map((t) => {
|
||||
const active = value === t.id;
|
||||
const Icon = t.icon;
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => onChange(t.id)}
|
||||
className={`flex-1 sm:flex-none flex items-center justify-center gap-2 px-3 sm:px-5 py-2.5 rounded-xl text-[11px] font-black uppercase tracking-wider whitespace-nowrap transition-all ${active ? 'bg-white shadow-sm' : 'text-gray-500 hover:text-gray-700'}`}
|
||||
style={active ? { color: accent } : {}}
|
||||
>
|
||||
{Icon && <Icon size={14} className="shrink-0" />}
|
||||
<span className="truncate">{t.label}</span>
|
||||
{t.badge ? (
|
||||
<span className="text-[9px] font-black px-1.5 py-0.5 rounded-full text-white shrink-0" style={{ backgroundColor: accent }}>{t.badge}</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</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';
|
||||
@@ -35,8 +35,9 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
|
||||
}
|
||||
return () => { alive = false; };
|
||||
}, [activeWorkspace?.id]);
|
||||
const canSeeInbox = !waAccess || waAccess.isOwner || waAccess.inbox;
|
||||
const canSeeSecretaria = !waAccess || waAccess.isOwner || waAccess.secretaria;
|
||||
// Fail-CLOSED: enquanto waAccess não carregou (null) ou negou, NÃO mostra as áreas.
|
||||
const canSeeInbox = !!waAccess && (waAccess.isOwner || waAccess.inbox);
|
||||
const canSeeSecretaria = !!waAccess && (waAccess.isOwner || waAccess.secretaria);
|
||||
|
||||
const pluginMenuItems: Array<{ id: string; label: string; icon: any; color: string }> = [];
|
||||
if (activePluginIds.includes('rx-scoreodonto')) {
|
||||
@@ -54,9 +55,9 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
|
||||
// ativo (a CONFIG do plugin fica no catálogo PLUGINS, exclusivo do superadmin).
|
||||
if (isPluginActive('newwhats')) {
|
||||
if (canSeeInbox) pluginMenuItems.push({ id: 'wa-inbox', label: 'WHATSAPP', icon: MessageCircle, color: '#16a34a' });
|
||||
// INSTÂNCIAS (gestão/QR) só para o dono; os demais operam via Inbox.
|
||||
if (!waAccess || waAccess.isOwner) pluginMenuItems.push({ id: 'wa-sessions', label: 'INSTÂNCIAS', icon: Smartphone, color: '#0ea5e9' });
|
||||
if (canSeeSecretaria) pluginMenuItems.push({ id: 'wa-secretaria', label: 'SECRETÁRIA IA', icon: Bot, color: '#2563eb' });
|
||||
// INSTÂNCIAS (gestão/QR) só para o dono; os demais operam via Inbox. Fail-closed.
|
||||
if (waAccess?.isOwner) pluginMenuItems.push({ id: 'wa-sessions', label: 'INSTÂNCIAS', icon: Smartphone, color: '#0ea5e9' });
|
||||
// SECRETÁRIA IA saiu do menu — vive agora como aba DENTRO do WhatsApp (wa-inbox).
|
||||
}
|
||||
// Locação de Salas: menus de LOCADOR (administra as próprias salas) e LOCATÁRIO (aluga de
|
||||
// terceiros). São decisões de NEGÓCIO do titular — não cabem a funcionário (subordinado) nem
|
||||
@@ -83,6 +84,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 },
|
||||
@@ -121,7 +123,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
|
||||
{ id: 'configuracoes', label: 'CONFIGURAÇÕES', icon: Settings },
|
||||
].filter(item => {
|
||||
// superadmin: itens específicos de administração global
|
||||
if (currentRole === 'superadmin') return ['superadmin-overview', 'superadmin-users', 'superadmin-clinicas', 'superadmin-logs', 'superadmin-financeiro', 'superadmin-notificacoes', 'plugins', 'configuracoes'].includes(item.id);
|
||||
if (currentRole === 'superadmin') return ['superadmin-overview', 'superadmin-users', 'superadmin-clinicas', 'superadmin-logs', 'superadmin-financeiro', 'superadmin-notificacoes', 'superadmin-secretaria', 'plugins', 'configuracoes'].includes(item.id);
|
||||
|
||||
// telas de superadmin são exclusivas do superadmin (não vazam p/ admin/donos)
|
||||
if (item.id.startsWith('superadmin')) return false;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// Estilo PADRÃO de filtros/busca do app (fonte única). Referência visual:
|
||||
// marketplace de profissionais / alugar salas.
|
||||
import React, { forwardRef } from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
|
||||
// Classes de campo e rótulo dos filtros (select/input dentro do card de filtros).
|
||||
export const inputNew = 'w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl text-sm font-semibold text-gray-700 outline-none focus:border-teal-400 focus:bg-white transition-colors';
|
||||
export const labelNew = 'text-[11px] font-bold text-gray-500 mb-1.5 block';
|
||||
|
||||
// Campo de BUSCA padrão (ícone à esquerda + input arredondado). Encaminha ref e
|
||||
// todos os props nativos (value, onChange, onKeyDown, onFocus, autoFocus, etc.).
|
||||
interface SearchInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
/** classe do wrapper (ex.: largura: 'w-full' | 'min-w-[220px]'). */
|
||||
wrapperClassName?: string;
|
||||
}
|
||||
export const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
|
||||
({ wrapperClassName = 'w-full', className = '', ...rest }, ref) => (
|
||||
<div className={`relative ${wrapperClassName}`}>
|
||||
<Search size={16} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none" />
|
||||
<input
|
||||
ref={ref}
|
||||
{...rest}
|
||||
className={`w-full pl-10 pr-4 py-2.5 bg-gray-50 border border-gray-200 rounded-xl text-sm font-medium text-gray-700 outline-none focus:border-teal-400 focus:bg-white transition-colors ${className}`}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
);
|
||||
SearchInput.displayName = 'SearchInput';
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 86 KiB |
@@ -21,6 +21,18 @@ const enforceUppercase = <T extends object>(data: T): T => {
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || '/api';
|
||||
|
||||
// Limpa o estado LOCAL da inbox (/wa-inbox): a instância ativa selecionada e o
|
||||
// cache de chats por instância (inboxCache:v1:*). Sem isso, o próximo usuário
|
||||
// (ex.: superadmin após outro login) herda a sessão e vê chats antigos de outra.
|
||||
const clearInboxLocalState = () => {
|
||||
try {
|
||||
localStorage.removeItem('activeInstanceId');
|
||||
Object.keys(localStorage)
|
||||
.filter(k => k.startsWith('inboxCache:'))
|
||||
.forEach(k => localStorage.removeItem(k));
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
// Returns headers always including the JWT token stored after login
|
||||
let _sessionExpiredHandled = false;
|
||||
const apiFetch = async (url: string, options: RequestInit = {}) => {
|
||||
@@ -38,6 +50,7 @@ const apiFetch = async (url: string, options: RequestInit = {}) => {
|
||||
try {
|
||||
['SCOREODONTO_AUTH_TOKEN', 'SCOREODONTO_USER_DATA', 'SCOREODONTO_WORKSPACES', 'SCOREODONTO_ACTIVE_WORKSPACE', 'SCOREODONTO_PREVIEW_ROLE']
|
||||
.forEach(k => localStorage.removeItem(k));
|
||||
clearInboxLocalState();
|
||||
} catch { /* ignore */ }
|
||||
window.location.href = '/?session=expired';
|
||||
window.location.reload();
|
||||
@@ -119,6 +132,7 @@ export const HybridBackend = {
|
||||
localStorage.removeItem('SCOREODONTO_WORKSPACES');
|
||||
localStorage.removeItem('SCOREODONTO_ACTIVE_WORKSPACE');
|
||||
localStorage.removeItem('SCOREODONTO_PREVIEW_ROLE');
|
||||
clearInboxLocalState(); // não vazar instância/chats do /wa-inbox p/ o próximo login
|
||||
window.location.href = '/';
|
||||
window.location.reload();
|
||||
},
|
||||
@@ -393,6 +407,7 @@ export const HybridBackend = {
|
||||
'SCOREODONTO_CACHE_NOTIFICACOES',
|
||||
];
|
||||
TENANT_CACHE_KEYS.forEach(k => localStorage.removeItem(k));
|
||||
clearInboxLocalState(); // troca de clínica não deve carregar a inbox da anterior
|
||||
console.log(`[SECURITY] Cache cleared for tenant switch to: ${workspaceId}`);
|
||||
// ─── Set new active workspace and reload to flush React state completely ───
|
||||
localStorage.setItem('SCOREODONTO_ACTIVE_WORKSPACE', JSON.stringify(workspace));
|
||||
@@ -1481,6 +1496,18 @@ export const HybridBackend = {
|
||||
return { grupo: j.grupo || null, membros: j.membros || [] };
|
||||
},
|
||||
|
||||
// Paciente único (modal de confirmação no check-in — Feature C).
|
||||
getPacienteById: async (id: string): Promise<any | null> => {
|
||||
const res = await apiFetch(`${API_URL}/pacientes/${id}`, {});
|
||||
const j = await res.json().catch(() => ({} as any));
|
||||
return j.paciente || null;
|
||||
},
|
||||
// Confirma os dados no check-in: valida server-side; { ok:false, mensagem } se inválido.
|
||||
confirmarDadosPaciente: async (id: string, payload: { nome?: string; cpf?: string; telefone?: string; data_nascimento?: string }): Promise<{ ok: boolean; mensagem?: string; campo?: string }> => {
|
||||
const res = await apiFetch(`${API_URL}/pacientes/${id}/confirmar-dados`, { method: 'POST', body: JSON.stringify(payload) });
|
||||
return await res.json().catch(() => ({ ok: false, mensagem: 'Falha ao confirmar.' }));
|
||||
},
|
||||
|
||||
// ── NewWhats: ownership & autorizações de sessão de WhatsApp ──────────────
|
||||
// Dono do workspace tem poder total; delega re-scan/exclusão por sessão a outras contas.
|
||||
getWaSessionAuthz: async (instanceId: string): Promise<{
|
||||
@@ -1506,15 +1533,17 @@ export const HybridBackend = {
|
||||
|
||||
// Acesso agregado do usuário logado às áreas do WhatsApp (para esconder menu/botões).
|
||||
// Fail-open: se o endpoint estiver indisponível, não esconde nada (backend é a autoridade).
|
||||
getWaAccess: async (): Promise<{ isOwner: boolean; inbox: boolean; secretaria: boolean; delete_msg: boolean }> => {
|
||||
getWaAccess: async (): Promise<{ isOwner: boolean; inbox: boolean; secretaria: boolean; delete_msg: boolean; signature?: string | null }> => {
|
||||
const clinicaId = HybridBackend.getActiveWorkspace()?.id || '';
|
||||
const open = { isOwner: false, inbox: true, secretaria: true, delete_msg: true };
|
||||
if (!clinicaId) return open;
|
||||
// Fail-CLOSED: sem clínica resolvida ou em erro, NEGA acesso — nunca vaza as áreas
|
||||
// do WhatsApp (Inbox/Secretária) por falha transitória. Só libera com resposta OK.
|
||||
const closed = { isOwner: false, inbox: false, secretaria: false, delete_msg: false, signature: null };
|
||||
if (!clinicaId) return closed;
|
||||
try {
|
||||
const res = await apiFetch(`${API_URL}/nw/access?clinicaId=${encodeURIComponent(clinicaId)}`, {});
|
||||
if (!res.ok) return open;
|
||||
if (!res.ok) return closed;
|
||||
return await res.json();
|
||||
} catch { return open; }
|
||||
} catch { return closed; }
|
||||
},
|
||||
|
||||
setWaSessionAuthz: async (instanceId: string, usuarioId: string, perms: WaPerms): Promise<{ ok: boolean; error?: string }> => {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// Fuso de REFERÊNCIA do armazenamento. As colunas de horário (agendamentos.start_time)
|
||||
// são `timestamp` naive; o backend (container) roda em America/Sao_Paulo, então o node-pg
|
||||
// serializa o wall-clock naive nesse fuso. Para exibir o horário LITERAL (o mesmo que a
|
||||
// Secretária ofereceu/gravou) em qualquer navegador, formatamos SEMPRE neste fuso — e não
|
||||
// no fuso local do navegador (que causaria o deslocamento de ±1h).
|
||||
export const STORAGE_TZ = 'America/Sao_Paulo';
|
||||
|
||||
const D = (v?: string | number | Date | null) => (v === null || v === undefined || v === '' ? null : new Date(v));
|
||||
|
||||
export const fmtHora = (v?: string | number | Date | null): string => {
|
||||
const d = D(v); return d ? d.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', timeZone: STORAGE_TZ }) : '—';
|
||||
};
|
||||
export const fmtDataCurta = (v?: string | number | Date | null): string => {
|
||||
const d = D(v); return d ? d.toLocaleDateString('pt-BR', { weekday: 'short', day: 'numeric', month: 'short', timeZone: STORAGE_TZ }) : '—';
|
||||
};
|
||||
export const fmtDataHora = (v?: string | number | Date | null): string => {
|
||||
const d = D(v); return d ? d.toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit', timeZone: STORAGE_TZ }) : '—';
|
||||
};
|
||||
|
||||
// Converte um instante (ISO/Date) para o WALL-CLOCK naive no fuso canônico, no formato
|
||||
// "YYYY-MM-DDTHH:mm:ss" (SEM offset). Uso: alimentar o FullCalendar, que — sem o plugin
|
||||
// de timezone (luxon) — IGNORA o prop `timeZone` nomeado e cai no fuso do NAVEGADOR.
|
||||
// Passando a string naive, o calendário exibe o horário literal em qualquer navegador
|
||||
// (mesma garantia do fmtHora, mas para os eventos do grid). Retorna undefined se vazio.
|
||||
export const toCalendarNaive = (v?: string | number | Date | null): string | undefined => {
|
||||
const d = D(v); if (!d) return undefined;
|
||||
// 'sv-SE' produz "YYYY-MM-DD HH:mm:ss" (ISO-like); trocamos o espaço por 'T'.
|
||||
return d.toLocaleString('sv-SE', { timeZone: STORAGE_TZ, hour12: false }).replace(' ', 'T');
|
||||
};
|
||||
@@ -3,11 +3,13 @@ import FullCalendar from '@fullcalendar/react';
|
||||
import dayGridPlugin from '@fullcalendar/daygrid';
|
||||
import timeGridPlugin from '@fullcalendar/timegrid';
|
||||
import interactionPlugin from '@fullcalendar/interaction';
|
||||
import { fmtDataHora, toCalendarNaive } from '../utils/appTime.ts';
|
||||
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 { ConfirmarDadosPaciente } from '../components/ConfirmarDadosPaciente.tsx';
|
||||
import { WhatsChatDrawer } from './newwhats/WhatsChatDrawer.tsx';
|
||||
import { AgendaPresence } from '../components/AgendaPresence.tsx';
|
||||
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
|
||||
@@ -375,7 +377,9 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebar
|
||||
const [selectedDateInfo, setSelectedDateInfo] = useState<any>(null);
|
||||
const [selectedAppointment, setSelectedAppointment] = useState<Agendamento | null>(null);
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const [whatsTarget, setWhatsTarget] = useState<{ pacienteId: string; pacienteNome: string } | null>(null);
|
||||
const [whatsTarget, setWhatsTarget] = useState<{ pacienteId?: string; pacienteNome?: string; phone?: string } | null>(null);
|
||||
const [confirmarDadosId, setConfirmarDadosId] = useState<string | null>(null); // Feature C: paciente a confirmar no check-in
|
||||
const [pendingAtender, setPendingAtender] = useState(false); // segue o Atender após confirmar
|
||||
const [interesses, setInteresses] = useState<any[]>([]);
|
||||
const [showInteresses, setShowInteresses] = useState(false);
|
||||
const [pendencias, setPendencias] = useState<any[]>([]);
|
||||
@@ -484,8 +488,11 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebar
|
||||
return {
|
||||
id: ag.id,
|
||||
title: `${nomePac} - ${ag.procedimento}`,
|
||||
start: ag.start,
|
||||
end: ag.end,
|
||||
// Wall-clock naive no fuso canônico: o FullCalendar (sem plugin luxon)
|
||||
// ignora o prop timeZone nomeado e usaria o fuso do navegador — passando
|
||||
// naive, exibe o horário literal (mesmo que o card) em qualquer navegador.
|
||||
start: toCalendarNaive(ag.start),
|
||||
end: toCalendarNaive(ag.end),
|
||||
backgroundColor: cor,
|
||||
borderColor: cor,
|
||||
extendedProps: { ...ag, pacienteNome: nomePac, isGoogle: false, dentistaNome: dentist?.nome }
|
||||
@@ -597,7 +604,9 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebar
|
||||
const currentUserEmail = HybridBackend.getCurrentUser();
|
||||
const gtoStore = useGTOStore();
|
||||
|
||||
const handleAtender = async () => {
|
||||
// Prossegue o atendimento (Lançar GTO). Separado p/ ser chamado após a confirmação
|
||||
// de dados (Feature C), quando ela é necessária.
|
||||
const prosseguirAtender = async () => {
|
||||
if (!selectedAppointment || !onNavigate) return;
|
||||
try {
|
||||
const result = await HybridBackend.getPacientes(selectedAppointment.pacienteNome);
|
||||
@@ -614,6 +623,24 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebar
|
||||
onNavigate('lancar-gto');
|
||||
};
|
||||
|
||||
// Feature C: ao Atender (check-in), se o paciente veio da Secretária e ainda NÃO
|
||||
// teve os dados confirmados, abre o modal de confirmação ANTES de prosseguir.
|
||||
const handleAtender = async () => {
|
||||
if (!selectedAppointment || !onNavigate) return;
|
||||
const pid = (selectedAppointment as any).pacienteid || (selectedAppointment as any).pacienteId;
|
||||
if (pid) {
|
||||
try {
|
||||
const pac = await HybridBackend.getPacienteById(pid);
|
||||
if (pac && pac.dados_confirmados === false) {
|
||||
setConfirmarDadosId(pid);
|
||||
setPendingAtender(true); // após confirmar, segue o atender
|
||||
return;
|
||||
}
|
||||
} catch { /* falha ao checar → segue normal */ }
|
||||
}
|
||||
await prosseguirAtender();
|
||||
};
|
||||
|
||||
const filteredDentists = dentists?.filter(d => {
|
||||
if (currentRole === 'admin' || currentRole === 'donoclinica' || currentRole === 'funcionario') return true;
|
||||
if (currentRole === 'dentista') return d.email === currentUserEmail;
|
||||
@@ -658,60 +685,60 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebar
|
||||
</button>
|
||||
)}
|
||||
{/* Ações — mobile: empilhadas, ícone + nome, largura cheia; desktop: fileira de ícones */}
|
||||
<div className="flex flex-col lg:flex-row gap-2 w-full">
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
{(currentRole !== 'paciente') && (
|
||||
<button onClick={() => { setShowAtividade(true); loadAtividade(); onCloseControls?.(); }} title="Atividade da equipe hoje"
|
||||
className="w-full lg:flex-1 flex items-center justify-start lg:justify-center gap-3 lg:gap-0 px-4 lg:px-0 py-2.5 bg-white text-gray-500 lg:text-gray-400 hover:text-teal-600 border border-gray-200 rounded-xl transition-all hover:border-teal-200 hover:shadow-sm">
|
||||
className="w-full flex items-center justify-start gap-3 px-4 py-2.5 bg-white text-gray-500 hover:text-teal-600 border border-gray-200 rounded-xl transition-all hover:border-teal-200 hover:shadow-sm">
|
||||
<History size={20} className="shrink-0" />
|
||||
<span className="lg:hidden text-sm font-bold">Atividade da equipe</span>
|
||||
<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="w-full lg:flex-1 flex items-center justify-start lg:justify-center gap-3 lg:gap-0 px-4 lg:px-0 py-2.5 bg-white text-gray-500 lg:text-gray-400 hover:text-emerald-600 border border-gray-200 rounded-xl transition-all hover:border-emerald-200 hover:shadow-sm disabled:opacity-50">
|
||||
className="w-full flex items-center justify-start gap-3 px-4 py-2.5 bg-white text-gray-500 hover:text-emerald-600 border border-gray-200 rounded-xl transition-all hover:border-emerald-200 hover:shadow-sm disabled:opacity-50">
|
||||
{importando ? <Loader2 size={20} className="animate-spin shrink-0" /> : <Download size={20} className="shrink-0" />}
|
||||
<span className="lg:hidden text-sm font-bold">Importar do Google</span>
|
||||
<span className="text-sm font-bold">Importar do Google</span>
|
||||
</button>
|
||||
)}
|
||||
{['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 lg:flex-1 flex items-center justify-start lg:justify-center gap-3 lg:gap-0 px-4 lg:px-0 py-2.5 bg-white text-gray-500 lg:text-gray-400 hover:text-amber-600 border border-gray-200 rounded-xl transition-all hover:border-amber-200 hover:shadow-sm">
|
||||
className="relative w-full flex items-center justify-start gap-3 px-4 py-2.5 bg-white text-gray-500 hover:text-amber-600 border border-gray-200 rounded-xl transition-all hover:border-amber-200 hover:shadow-sm">
|
||||
<CalendarClock size={20} className="shrink-0" />
|
||||
<span className="lg:hidden text-sm font-bold">Pedidos pendentes</span>
|
||||
{pedidosCount > 0 && <span className="absolute -top-1 -right-1 lg:top-0 lg:right-1 bg-amber-500 text-white text-[10px] font-black rounded-full min-w-[18px] h-[18px] flex items-center justify-center px-1 shadow">{pedidosCount}</span>}
|
||||
<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 lg:flex-1 flex items-center justify-start lg:justify-center gap-3 lg:gap-0 px-4 lg:px-0 py-2.5 bg-white text-gray-500 lg:text-gray-400 hover:text-teal-600 border border-gray-200 rounded-xl transition-all hover:border-teal-200 hover:shadow-sm">
|
||||
className="w-full flex items-center justify-start gap-3 px-4 py-2.5 bg-white text-gray-500 hover:text-teal-600 border border-gray-200 rounded-xl transition-all hover:border-teal-200 hover:shadow-sm">
|
||||
<GearIcon size={20} className="shrink-0" />
|
||||
<span className="lg:hidden text-sm font-bold">Configurações</span>
|
||||
<span className="text-sm font-bold">Configurações</span>
|
||||
</button>
|
||||
)}
|
||||
{souDentista && (
|
||||
<button onClick={() => { setShowMeusHorarios(true); onCloseControls?.(); }} title="Configurar meus dias e horários de atendimento"
|
||||
className="w-full lg:flex-1 flex items-center justify-start lg:justify-center gap-3 lg:gap-0 px-4 lg:px-0 py-2.5 bg-white text-gray-500 lg:text-gray-400 hover:text-teal-600 border border-gray-200 rounded-xl transition-all hover:border-teal-200 hover:shadow-sm">
|
||||
className="w-full flex items-center justify-start gap-3 px-4 py-2.5 bg-white text-gray-500 hover:text-teal-600 border border-gray-200 rounded-xl transition-all hover:border-teal-200 hover:shadow-sm">
|
||||
<Clock size={20} className="shrink-0" />
|
||||
<span className="lg:hidden text-sm font-bold">Meus Horários</span>
|
||||
<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 lg:flex-1 flex items-center justify-start lg:justify-center gap-3 lg:gap-0 px-4 lg:px-0 py-2.5 bg-white text-gray-500 lg:text-gray-400 hover:text-teal-600 border border-gray-200 rounded-xl transition-all hover:border-teal-200 hover:shadow-sm">
|
||||
className="relative w-full flex items-center justify-start gap-3 px-4 py-2.5 bg-white text-gray-500 hover:text-teal-600 border border-gray-200 rounded-xl transition-all hover:border-teal-200 hover:shadow-sm">
|
||||
<Bell size={20} className="shrink-0" />
|
||||
<span className="lg:hidden text-sm font-bold">Interesses na agenda</span>
|
||||
<span className="text-sm font-bold">Interesses na agenda</span>
|
||||
{interessesAguardando > 0 && (
|
||||
<span className="absolute top-1.5 left-6 lg:left-auto lg:-top-1 lg:-right-1 bg-red-500 text-white text-[9px] font-black rounded-full w-4 h-4 flex items-center justify-center">{interessesAguardando}</span>
|
||||
<span className="absolute top-1.5 left-6 bg-red-500 text-white text-[9px] font-black rounded-full w-4 h-4 flex items-center justify-center">{interessesAguardando}</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{(currentRole !== 'paciente' && !souDentista) && (
|
||||
<button onClick={() => { setShowPendencias(true); loadPendencias(); onCloseControls?.(); }} title="Pacientes a reagendar"
|
||||
className="relative w-full lg:flex-1 flex items-center justify-start lg:justify-center gap-3 lg:gap-0 px-4 lg:px-0 py-2.5 bg-white text-gray-500 lg:text-gray-400 hover:text-amber-600 border border-gray-200 rounded-xl transition-all hover:border-amber-200 hover:shadow-sm">
|
||||
className="relative w-full flex items-center justify-start gap-3 px-4 py-2.5 bg-white text-gray-500 hover:text-amber-600 border border-gray-200 rounded-xl transition-all hover:border-amber-200 hover:shadow-sm">
|
||||
<CalendarClock size={20} className="shrink-0" />
|
||||
<span className="lg:hidden text-sm font-bold">Pacientes a reagendar</span>
|
||||
<span className="text-sm font-bold">Pacientes a reagendar</span>
|
||||
{pendencias.length > 0 && (
|
||||
<span className="absolute top-1.5 left-6 lg:left-auto lg:-top-1 lg:-right-1 bg-amber-500 text-white text-[9px] font-black rounded-full w-4 h-4 flex items-center justify-center">{pendencias.length}</span>
|
||||
<span className="absolute top-1.5 left-6 bg-amber-500 text-white text-[9px] font-black rounded-full w-4 h-4 flex items-center justify-center">{pendencias.length}</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
@@ -749,6 +776,11 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebar
|
||||
<FullCalendar
|
||||
key={isMobile ? 'mobile' : 'desktop'}
|
||||
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
|
||||
/* Sem o plugin luxon, um timeZone NOMEADO ("America/Sao_Paulo") é ignorado
|
||||
e o FullCalendar usa o fuso do navegador. Por isso os eventos entram como
|
||||
wall-clock naive (toCalendarNaive) e aqui deixamos 'local': o horário é
|
||||
exibido literal (mesmo do card), sem depender do fuso do navegador. */
|
||||
timeZone="local"
|
||||
initialView={isMobile ? 'timeGridDay' : 'timeGridWeek'}
|
||||
headerToolbar={isMobile
|
||||
? { left: 'prev,next', center: 'title', right: 'today' }
|
||||
@@ -758,6 +790,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}
|
||||
@@ -811,9 +845,22 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebar
|
||||
onClose={() => setWhatsTarget(null)}
|
||||
pacienteId={whatsTarget?.pacienteId}
|
||||
pacienteNome={whatsTarget?.pacienteNome}
|
||||
phone={whatsTarget?.phone}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
|
||||
{/* Feature C: confirmação de dados no check-in (paciente cadastrado pela Secretária) */}
|
||||
<ConfirmarDadosPaciente
|
||||
isOpen={!!confirmarDadosId}
|
||||
pacienteId={confirmarDadosId}
|
||||
onClose={() => { setConfirmarDadosId(null); setPendingAtender(false); }}
|
||||
onConfirmed={() => {
|
||||
setConfirmarDadosId(null);
|
||||
if (pendingAtender) { setPendingAtender(false); prosseguirAtender(); }
|
||||
refresh();
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Lista "A REAGENDAR" — pendências de falta/cancelamento/remarcação */}
|
||||
{showPendencias && (
|
||||
<div className="fixed inset-0 bg-black/50 z-[60] flex items-center justify-center backdrop-blur-sm p-4" onClick={() => setShowPendencias(false)}>
|
||||
@@ -902,8 +949,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}
|
||||
@@ -917,6 +969,7 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebar
|
||||
<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}
|
||||
onOpenChat={(t) => setWhatsTarget(t)}
|
||||
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 && (
|
||||
@@ -936,7 +989,7 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebar
|
||||
<div key={i.id} className="border border-gray-100 rounded-xl p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-sm font-bold text-gray-800">
|
||||
{new Date(i.start_time).toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}
|
||||
{fmtDataHora(i.start_time)}
|
||||
</p>
|
||||
<span className={`text-[9px] font-black uppercase px-2 py-0.5 rounded-full ${i.status === 'aguardando' ? 'bg-yellow-50 text-yellow-700' : i.status === 'liberado' ? 'bg-emerald-50 text-emerald-700' : 'bg-gray-100 text-gray-500'}`}>{i.status}</span>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { HybridBackend } from '../services/backend.ts';
|
||||
import { useToast } from '../contexts/ToastContext.tsx';
|
||||
import { useConfirm } from '../contexts/ConfirmContext.tsx';
|
||||
import { PageHeader } from '../components/PageHeader.tsx';
|
||||
import { SegmentedTabs } from '../components/SegmentedTabs.tsx';
|
||||
|
||||
const COLOR = '#0d9488';
|
||||
const fmtBRL = (v: number) => 'R$ ' + Number(v || 0).toLocaleString('pt-BR', { minimumFractionDigits: 2 });
|
||||
@@ -22,14 +23,9 @@ export const ComissoesView: React.FC = () => {
|
||||
return (
|
||||
<div className="space-y-6 pb-10">
|
||||
<PageHeader title="COMISSÕES & REPASSE" />
|
||||
<div className="flex gap-2 border-b border-gray-200">
|
||||
{([['relatorio', 'Relatório / Fechamento', Wallet], ['regras', 'Regras de %', Percent], ['historico', 'Histórico', History]] as const).map(([id, label, Icon]) => (
|
||||
<button key={id} onClick={() => setAba(id)}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 text-xs font-black uppercase tracking-wide border-b-2 -mb-px transition-colors ${aba === id ? 'border-teal-600 text-teal-700' : 'border-transparent text-gray-400 hover:text-gray-600'}`}>
|
||||
<Icon size={15} /> {label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<SegmentedTabs
|
||||
tabs={[{ id: 'relatorio', label: 'Relatório / Fechamento', icon: Wallet }, { id: 'regras', label: 'Regras de %', icon: Percent }, { id: 'historico', label: 'Histórico', icon: History }]}
|
||||
value={aba} onChange={setAba} />
|
||||
|
||||
{aba === 'relatorio' && <AbaRelatorio toast={toast} confirm={confirm} />}
|
||||
{aba === 'regras' && <AbaRegras toast={toast} confirm={confirm} />}
|
||||
|
||||
@@ -11,6 +11,15 @@ import { buscarCep } from '../services/viacep.ts';
|
||||
const FIELD_CLS = 'w-full border border-gray-200 rounded-xl px-3 py-2.5 text-sm font-medium text-gray-800 outline-none focus:border-teal-500 focus:ring-2 focus:ring-teal-100 transition-all';
|
||||
const LABEL_CLS = 'text-[10px] font-black text-gray-400 uppercase tracking-wider block mb-1';
|
||||
const TIPO_LABEL: Record<string, string> = { pessoal: 'Pessoal', consultorio: 'Consultório', clinica: 'Clínica' };
|
||||
const TZ_LABEL: Record<string, string> = {
|
||||
'America/Sao_Paulo': 'Brasília (UTC-3) — SP/RJ/MG/Sul/NE', 'America/Campo_Grande': 'Campo Grande/MS (UTC-4)',
|
||||
'America/Cuiaba': 'Cuiabá/MT (UTC-4)', 'America/Manaus': 'Manaus/AM (UTC-4)', 'America/Rio_Branco': 'Rio Branco/AC (UTC-5)',
|
||||
'America/Belem': 'Belém/PA (UTC-3)', 'America/Fortaleza': 'Fortaleza/CE (UTC-3)', 'America/Recife': 'Recife/PE (UTC-3)',
|
||||
'America/Bahia': 'Salvador/BA (UTC-3)', 'America/Boa_Vista': 'Boa Vista/RR (UTC-4)', 'America/Porto_Velho': 'Porto Velho/RO (UTC-4)',
|
||||
'America/Maceio': 'Maceió/AL (UTC-3)', 'America/Noronha': 'F. de Noronha (UTC-2)',
|
||||
};
|
||||
const tzApi = (path: string, opts: RequestInit = {}) =>
|
||||
fetch(`${(window as any).__API_URL__ || ''}${path}`, { ...opts, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('SCOREODONTO_AUTH_TOKEN')}` } });
|
||||
|
||||
// --- Card: Meu Perfil (Sistema) ---
|
||||
const MeuPerfilCard: React.FC<{ clinColor: string; role: string }> = ({ clinColor, role }) => {
|
||||
@@ -100,6 +109,7 @@ const UnidadePerfilCard: React.FC<{ workspaceId?: string; clinColor: string }> =
|
||||
const [f, setF] = useState<any>({});
|
||||
const [allAreas, setAllAreas] = useState<{ slug: string; nome: string; cor: string }[]>([]);
|
||||
const [clinAreas, setClinAreas] = useState<string[]>([]);
|
||||
const [tzOpts, setTzOpts] = useState<string[]>([]);
|
||||
useEffect(() => { HybridBackend.getAreas().then(setAllAreas); }, []);
|
||||
|
||||
const load = async () => {
|
||||
@@ -108,6 +118,7 @@ const UnidadePerfilCard: React.FC<{ workspaceId?: string; clinColor: string }> =
|
||||
try {
|
||||
const r = await HybridBackend.getClinicaProfile(workspaceId); if (r.success) setF(r.clinica || {});
|
||||
setClinAreas(await HybridBackend.getClinicaAreas(workspaceId));
|
||||
try { const tr = await tzApi(`/api/clinicas/${workspaceId}/timezone`); if (tr.ok) { const td = await tr.json(); setTzOpts(td.opcoes || []); setF((p: any) => ({ ...p, timezone: td.timezone })); } } catch { /* ignore */ }
|
||||
}
|
||||
catch { /* ignore */ } finally { setLoading(false); }
|
||||
};
|
||||
@@ -136,6 +147,7 @@ const UnidadePerfilCard: React.FC<{ workspaceId?: string; clinColor: string }> =
|
||||
});
|
||||
if (!r.success) { toast.error(r.message || 'ERRO AO SALVAR.'); return; }
|
||||
await HybridBackend.setClinicaAreas(workspaceId, clinAreas);
|
||||
if (f.timezone) { try { await tzApi(`/api/clinicas/${workspaceId}/timezone`, { method: 'PUT', body: JSON.stringify({ timezone: f.timezone }) }); } catch { /* ignore */ } }
|
||||
toast.success('DADOS DA UNIDADE ATUALIZADOS!'); setEditing(false);
|
||||
} catch { toast.error('ERRO AO SALVAR.'); } finally { setSaving(false); }
|
||||
};
|
||||
@@ -181,6 +193,13 @@ const UnidadePerfilCard: React.FC<{ workspaceId?: string; clinColor: string }> =
|
||||
<div><label className={LABEL_CLS}>Cidade</label><input className={`${FIELD_CLS} uppercase`} value={f.cidade || ''} onChange={e => set('cidade', e.target.value.toUpperCase())} /></div>
|
||||
<div><label className={LABEL_CLS}>Estado</label><input maxLength={2} className={`${FIELD_CLS} uppercase`} value={f.estado || ''} onChange={e => set('estado', e.target.value.toUpperCase())} /></div>
|
||||
<div><label className={LABEL_CLS}>Cor</label><input type="color" className="w-full h-10 border border-gray-200 rounded-xl px-1" value={f.cor || clinColor} onChange={e => set('cor', e.target.value)} /></div>
|
||||
<div className="md:col-span-2">
|
||||
<label className={LABEL_CLS}>Fuso horário da unidade</label>
|
||||
<select className={FIELD_CLS} value={f.timezone || 'America/Sao_Paulo'} onChange={e => set('timezone', e.target.value)}>
|
||||
{(tzOpts.length ? tzOpts : ['America/Sao_Paulo']).map(tz => <option key={tz} value={tz}>{TZ_LABEL[tz] || tz}</option>)}
|
||||
</select>
|
||||
<p className="text-[10px] text-gray-400 mt-1">A Secretária e a agenda usam este fuso para o horário local desta unidade.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLS}>Áreas de atuação da unidade</label>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { HybridBackend } from '../services/backend.ts';
|
||||
import { useToast } from '../contexts/ToastContext.tsx';
|
||||
import { useConfirm } from '../contexts/ConfirmContext.tsx';
|
||||
import { PageHeader } from '../components/PageHeader.tsx';
|
||||
import { SegmentedTabs } from '../components/SegmentedTabs.tsx';
|
||||
|
||||
const COLOR = '#0d9488'; // teal
|
||||
|
||||
@@ -473,13 +474,7 @@ export const ContratosView: React.FC = () => {
|
||||
: <button onClick={() => setEditor('novo')} className="bg-teal-600 hover:bg-teal-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 transition-colors uppercase font-bold text-sm shadow-sm"><Plus size={18} /> NOVO MODELO</button>}
|
||||
</PageHeader>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{([['contratos', 'Contratos', FileSignature], ['modelos', 'Modelos', Library]] as const).map(([id, label, Icon]) => (
|
||||
<button key={id} onClick={() => setTab(id)} className={`flex items-center gap-2 px-5 py-2.5 rounded-xl text-[11px] font-black uppercase tracking-wider transition-all ${tab === id ? 'text-white shadow-md' : 'bg-white text-gray-500 border border-gray-200 hover:bg-gray-50'}`} style={tab === id ? { backgroundColor: COLOR } : {}}>
|
||||
<Icon size={14} /> {label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<SegmentedTabs tabs={[{ id: 'contratos', label: 'Contratos', icon: FileSignature }, { id: 'modelos', label: 'Modelos', icon: Library }]} value={tab} onChange={setTab} accent={COLOR} />
|
||||
|
||||
{loading ? <div className="flex justify-center py-16"><RefreshCw size={24} className="animate-spin" style={{ color: COLOR }} /></div> : (
|
||||
<>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Users, Building, Briefcase, Plus, Trash2, Edit2, X, Check, UserPlus, Shield, ChevronDown, Copy, KeyRound } from 'lucide-react';
|
||||
import { Users, Building, Briefcase, Plus, Trash2, Edit2, X, Check, UserPlus, Shield, ChevronDown, Copy, KeyRound, MessageSquare } from 'lucide-react';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { useToast } from '../contexts/ToastContext.tsx';
|
||||
import { useConfirm } from '../contexts/ConfirmContext.tsx';
|
||||
import { PageHeader } from '../components/PageHeader.tsx';
|
||||
import { GoogleConnectButton } from '../components/GoogleConnectButton.tsx';
|
||||
import { ComunicacaoInternaModal } from '../components/ComunicacaoInternaModal.tsx';
|
||||
|
||||
type Tab = 'usuarios' | 'setores' | 'funcoes';
|
||||
|
||||
@@ -128,6 +129,7 @@ const UsuariosTab: React.FC<{ clinicaId: string; isOwner: boolean; myNivel: numb
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [editing, setEditing] = useState<Membro | null>(null);
|
||||
const [comMembro, setComMembro] = useState<Membro | null>(null); // perfil de comunicação interna
|
||||
|
||||
// Add form
|
||||
const [email, setEmail] = useState('');
|
||||
@@ -202,6 +204,7 @@ const UsuariosTab: React.FC<{ clinicaId: string; isOwner: boolean; myNivel: numb
|
||||
role: editing.role,
|
||||
setor_id: editing.setor_id || null,
|
||||
funcao_id: editing.funcao_id || null,
|
||||
nome: editing.nome?.trim() || undefined,
|
||||
});
|
||||
if (!res.success) { toast.error(res.message || 'ERRO AO ATUALIZAR.'); return; }
|
||||
toast.success('MEMBRO ATUALIZADO!');
|
||||
@@ -264,6 +267,10 @@ const UsuariosTab: React.FC<{ clinicaId: string; isOwner: boolean; myNivel: numb
|
||||
</div>
|
||||
{podeGerenciar(m) && (
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button onClick={() => setComMembro({ ...m })} title="Comunicação interna"
|
||||
className="p-2 hover:bg-teal-50 rounded-lg transition-colors text-teal-500">
|
||||
<MessageSquare size={15} />
|
||||
</button>
|
||||
<button onClick={() => handleResetSenha(m)} title="Resetar senha"
|
||||
className="p-2 hover:bg-amber-50 rounded-lg transition-colors text-amber-500">
|
||||
<KeyRound size={15} />
|
||||
@@ -366,6 +373,12 @@ const UsuariosTab: React.FC<{ clinicaId: string; isOwner: boolean; myNivel: numb
|
||||
{editing && (
|
||||
<Modal title={`EDITAR — ${editing.nome}`} onClose={() => setEditing(null)}>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-gray-500 uppercase block mb-1">NOME</label>
|
||||
<input value={editing.nome || ''} onChange={e => setEditing({ ...editing, nome: e.target.value.toUpperCase() })}
|
||||
className="w-full border border-gray-200 rounded-xl px-3 py-2 text-sm font-bold uppercase focus:outline-none focus:border-teal-400" />
|
||||
<p className="text-[10px] text-gray-400 mt-1 leading-snug">Usado na assinatura das mensagens do WhatsApp. Troque quando a pessoa desta conta mudar (ex.: nova secretária).</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-gray-500 uppercase block mb-1">ACESSO NO SISTEMA</label>
|
||||
<select value={editing.role} onChange={e => setEditing({ ...editing, role: e.target.value })}
|
||||
@@ -420,6 +433,11 @@ const UsuariosTab: React.FC<{ clinicaId: string; isOwner: boolean; myNivel: numb
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{comMembro && (
|
||||
<ComunicacaoInternaModal usuarioId={comMembro.id} clinicaId={clinicaId} nome={comMembro.nome}
|
||||
onClose={() => setComMembro(null)} onSaved={() => setComMembro(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { HybridBackend } from '../services/backend.ts';
|
||||
import { useToast } from '../contexts/ToastContext.tsx';
|
||||
import { useConfirm } from '../contexts/ConfirmContext.tsx';
|
||||
import { PageHeader } from '../components/PageHeader.tsx';
|
||||
import { SegmentedTabs } from '../components/SegmentedTabs.tsx';
|
||||
|
||||
const fmtBRL = (v: number) => 'R$ ' + Number(v || 0).toLocaleString('pt-BR', { minimumFractionDigits: 2 });
|
||||
const dataBR = (d: string) => (d || '').slice(0, 10).split('-').reverse().join('/');
|
||||
@@ -137,11 +138,7 @@ export const GlosasView: React.FC = () => {
|
||||
return (
|
||||
<div className="space-y-6 pb-10">
|
||||
<PageHeader title="GLOSAS DE CONVÊNIO" />
|
||||
<div className="flex gap-2 border-b border-gray-200">
|
||||
{([['registrar', 'Recebíveis / Marcar glosa'], ['glosas', `Glosas (${glosas.length})`]] as const).map(([id, label]) => (
|
||||
<button key={id} onClick={() => setAba(id)} className={`px-4 py-2.5 text-xs font-black uppercase tracking-wide border-b-2 -mb-px transition-colors ${aba === id ? 'border-teal-600 text-teal-700' : 'border-transparent text-gray-400 hover:text-gray-600'}`}>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
<SegmentedTabs tabs={[{ id: 'registrar', label: 'Recebíveis / Marcar glosa' }, { id: 'glosas', label: `Glosas (${glosas.length})` }]} value={aba} onChange={setAba} />
|
||||
|
||||
{loading ? <div className="flex justify-center py-16"><RefreshCw className="animate-spin text-teal-600" size={28} /></div> : aba === 'registrar' ? (
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden">
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Building2, Wallet, Clock, RefreshCw, Inbox, CheckCircle2, Phone, CalendarDays, Stethoscope, Briefcase, User } from 'lucide-react';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { PageHeader } from '../components/PageHeader.tsx';
|
||||
import { SegmentedTabs } from '../components/SegmentedTabs.tsx';
|
||||
|
||||
const fmtBRL = (v: number) => 'R$ ' + Number(v || 0).toLocaleString('pt-BR', { minimumFractionDigits: 2 });
|
||||
const dataBR = (d: string) => (d || '').slice(0, 10).split('-').reverse().join('/');
|
||||
@@ -55,11 +56,7 @@ export const LabClientesView: React.FC<{ tab?: 'clientes' | 'financeiro' }> = ({
|
||||
<button onClick={carregar} className="bg-white border border-gray-200 text-gray-600 p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2 text-xs font-bold shadow-sm"><RefreshCw size={16} /></button>
|
||||
</PageHeader>
|
||||
|
||||
<div className="flex border-b border-gray-100">
|
||||
{(['clientes', 'financeiro'] as const).map(t => (
|
||||
<button key={t} onClick={() => setAba(t)} className={`px-4 py-2.5 text-xs font-black uppercase border-b-2 -mb-px transition-colors ${aba === t ? 'border-teal-600 text-teal-700' : 'border-transparent text-gray-400 hover:text-gray-600'}`}>{t === 'clientes' ? 'Clientes (CRM)' : 'Financeiro'}</button>
|
||||
))}
|
||||
</div>
|
||||
<SegmentedTabs tabs={[{ id: 'clientes', label: 'Clientes (CRM)' }, { id: 'financeiro', label: 'Financeiro' }]} value={aba} onChange={setAba} />
|
||||
|
||||
{loading ? <div className="flex justify-center py-16"><RefreshCw className="animate-spin text-teal-600" size={28} /></div> : (
|
||||
<>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useHybridBackend } from '../hooks/useHybridBackend.ts';
|
||||
import { useToast } from '../contexts/ToastContext.tsx';
|
||||
import { useConfirm } from '../contexts/ConfirmContext.tsx';
|
||||
import { PageHeader } from '../components/PageHeader.tsx';
|
||||
import { SearchInput } from '../components/filterStyles.tsx';
|
||||
import { OrcamentoModal, DEFAULT_CLAUSULAS } from './OrcamentoModal.tsx';
|
||||
|
||||
type StatusFilter = 'TODOS' | 'Ativo' | 'Rascunho' | 'Assinado' | 'Encerrado';
|
||||
@@ -148,16 +149,7 @@ export const OrcamentosView: React.FC = () => {
|
||||
|
||||
{/* Search + status filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 mb-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none" size={16} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar por paciente ou título..."
|
||||
className="w-full pl-9 pr-3 py-2 border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-emerald-100 focus:border-emerald-300 outline-none bg-white"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<SearchInput wrapperClassName="flex-1" placeholder="Buscar por paciente ou título..." value={search} onChange={e => setSearch(e.target.value)} />
|
||||
<div className="flex items-center gap-1 bg-gray-100 rounded-lg p-1 overflow-x-auto">
|
||||
{STATUS_TABS.map(s => (
|
||||
<button
|
||||
|
||||
@@ -4,6 +4,7 @@ import { HybridBackend } from '../services/backend.ts';
|
||||
import { useToast } from '../contexts/ToastContext.tsx';
|
||||
import { useConfirm } from '../contexts/ConfirmContext.tsx';
|
||||
import { PageHeader } from '../components/PageHeader.tsx';
|
||||
import { SegmentedTabs } from '../components/SegmentedTabs.tsx';
|
||||
import { rotulosPaciente } from '../utils/pacienteLabel.ts';
|
||||
import { ProteticoPicker } from '../components/ProteticoPicker.tsx';
|
||||
import { SearchSelect } from '../components/SearchSelect.tsx';
|
||||
@@ -318,11 +319,8 @@ const OSDetailModal: React.FC<{ id: string; modo: 'bancada' | 'clinica'; onClose
|
||||
</div>
|
||||
</div>
|
||||
{/* Abas */}
|
||||
<div className="flex border-b border-gray-100 px-2 overflow-x-auto">
|
||||
{TABS.map(t => (
|
||||
<button key={t.id} onClick={() => setTab(t.id)}
|
||||
className={`px-3 py-2.5 text-xs font-black uppercase border-b-2 -mb-px whitespace-nowrap transition-colors ${tab === t.id ? 'border-violet-600 text-violet-700' : 'border-transparent text-gray-400 hover:text-gray-600'}`}>{t.label}</button>
|
||||
))}
|
||||
<div className="px-4 pt-3">
|
||||
<SegmentedTabs tabs={TABS.map(t => ({ id: t.id, label: t.label }))} value={tab} onChange={setTab} accent="#7c3aed" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-5">
|
||||
@@ -1037,10 +1035,8 @@ const CotacoesClinicaModal: React.FC<{ onClose: () => void; onEscolhida: () => v
|
||||
<h3 className="font-black text-gray-800 uppercase text-sm flex items-center gap-2"><FileText size={16} className="text-violet-600" /> Cotações de prótese</h3>
|
||||
<button onClick={onClose}><X size={20} className="text-gray-400" /></button>
|
||||
</div>
|
||||
<div className="flex border-b border-gray-100 px-2">
|
||||
{(['lista', 'novo'] as const).map(t => (
|
||||
<button key={t} onClick={() => setAba(t)} className={`px-3 py-2.5 text-xs font-black uppercase border-b-2 -mb-px ${aba === t ? 'border-violet-600 text-violet-700' : 'border-transparent text-gray-400'}`}>{t === 'lista' ? 'Meus pedidos' : 'Pedir cotação'}</button>
|
||||
))}
|
||||
<div className="px-4 pt-3">
|
||||
<SegmentedTabs tabs={[{ id: 'lista', label: 'Meus pedidos' }, { id: 'novo', label: 'Pedir cotação' }]} value={aba} onChange={setAba} accent="#7c3aed" />
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-5">
|
||||
{aba === 'novo' ? (
|
||||
|
||||
@@ -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,76 @@ 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 — motivos de desativação</h2>
|
||||
<p className="text-xs text-gray-400 font-medium">Motivos de desativar a Secretária — para TODOS os chats (global) ou por sessão. Contexto GERAL p/ identificar bugs/estratégias no atendimento e corrigir. Sem dados das clínicas. {totalOff > 0 && `${totalOff} desativação(ões).`}</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">Escopo</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">
|
||||
{l.escopo === 'global'
|
||||
? <span className="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-black uppercase bg-red-50 text-red-700 border border-red-200">Todos os chats</span>
|
||||
: <span className="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-black uppercase bg-gray-100 text-gray-500 border border-gray-200">Uma sessão</span>}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Search,
|
||||
MoreVertical,
|
||||
@@ -10,7 +10,11 @@ import {
|
||||
MessageSquarePlus,
|
||||
RotateCw,
|
||||
ArrowLeft,
|
||||
Info,
|
||||
Bot,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { roleMeta } from './utils/roleMeta';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { format } from 'date-fns';
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
@@ -22,17 +26,24 @@ import { useChatFilter } from './hooks/inbox/useChatFilter';
|
||||
import { useChatWindow } from './hooks/inbox/useChatWindow';
|
||||
import { useMessageInput } from './hooks/inbox/useMessageInput';
|
||||
import { nw } from './services/nwClient';
|
||||
import { chatApi } from './services/chatApiService';
|
||||
import { useChatStore } from './store/chatStore';
|
||||
import ForwardModal from './components/ForwardModal';
|
||||
import LabelManagerModal from './components/LabelManagerModal';
|
||||
import { useProtocolPanel } from './hooks/useProtocolPanel';
|
||||
import ChatSidebar from './components/ChatSidebar';
|
||||
import ThinSidebar, { TabType } from './components/ThinSidebar';
|
||||
import InboxHeader from './components/InboxHeader';
|
||||
import MessageArea from './components/MessageArea';
|
||||
import ConversationSearch from './components/ConversationSearch';
|
||||
import SettingsPanel from './components/SettingsPanel';
|
||||
import InputBar from './components/InputBar';
|
||||
import NewConversationModal from './components/NewConversationModal';
|
||||
import ContactProfile from './components/ContactProfile';
|
||||
import ProtocolPanel from './components/ProtocolPanel';
|
||||
import { HybridBackend } from '../../services/backend';
|
||||
import { PedidosPendentes } from '../../components/PedidosPendentes.tsx';
|
||||
import { AgendaView } from '../AgendaView.tsx';
|
||||
|
||||
type Chat = NewWhatsChat;
|
||||
|
||||
@@ -63,6 +74,7 @@ export function InboxView({ onNavigate }: { onNavigate?: (view: string) => void
|
||||
handleSendText,
|
||||
handleLoadMoreHistory,
|
||||
handleMenuAction,
|
||||
handleDeleteMessage,
|
||||
} = useNewInboxBridge()
|
||||
|
||||
// ── Filtros de chat (hook dedicado) ───────────────────────────────────────
|
||||
@@ -81,7 +93,6 @@ export function InboxView({ onNavigate }: { onNavigate?: (view: string) => void
|
||||
// ── Chat window (hook dedicado) ───────────────────────────────────────────
|
||||
const {
|
||||
scrollRef,
|
||||
isTyping,
|
||||
replyingTo,
|
||||
reactionPickerFor,
|
||||
hasMoreHistory,
|
||||
@@ -100,20 +111,45 @@ export function InboxView({ onNavigate }: { onNavigate?: (view: string) => void
|
||||
handleSendAudio,
|
||||
handleSyncAvatar,
|
||||
toggleReactionPicker,
|
||||
handleClearConversation,
|
||||
editingMessage,
|
||||
setEditingMessage,
|
||||
forwardingMessage,
|
||||
setForwardingMessage,
|
||||
} = useChatWindow({
|
||||
selectedChat,
|
||||
activeInstanceId: activeInstance?.instance ?? null,
|
||||
onLoadMoreHistory: handleLoadMoreHistory,
|
||||
})
|
||||
|
||||
// "digitando..." do CONTATO (presença composing/recording do chat aberto).
|
||||
const activePresence = selectedChat?.remote_jid ? presenceByJid[selectedChat.remote_jid] : undefined
|
||||
const isTyping = !!activePresence && (Date.now() - activePresence.ts <= 8000) && (activePresence.state === 'composing' || activePresence.state === 'recording')
|
||||
|
||||
// ── Mensagem de entrada (hook dedicado) ───────────────────────────────────
|
||||
const { newMessage, setNewMessage, mentions, setMentions, handleSendMessage } = useMessageInput({
|
||||
selectedChat,
|
||||
replyingTo,
|
||||
onClearReply: () => setReplyingTo(null),
|
||||
onSendText: handleSendText,
|
||||
editingMessage,
|
||||
onClearEdit: () => setEditingMessage(null),
|
||||
onEditText: async (messageId, text) => {
|
||||
if (!selectedChat?.id) return
|
||||
try {
|
||||
await chatApi.editMessage(String(selectedChat.id), messageId, text)
|
||||
useChatStore.getState().patchMessage(messageId, { body: text })
|
||||
} catch (e) { console.error('Erro ao editar:', e) }
|
||||
},
|
||||
})
|
||||
|
||||
// Entra em modo edição: preenche o input com o texto atual da mensagem.
|
||||
const handleEnterEdit = useCallback((msg: any) => {
|
||||
setEditingMessage(msg)
|
||||
setReplyingTo(null)
|
||||
setNewMessage(msg?.text ?? msg?.body ?? '')
|
||||
}, [setEditingMessage, setReplyingTo, setNewMessage])
|
||||
|
||||
// ── Painel de Protocolo ───────────────────────────────────────────────────
|
||||
const [isProtocolOpen, setIsProtocolOpen] = useState(false)
|
||||
const protocolPanel = useProtocolPanel(selectedChat ? String(selectedChat.id) : null)
|
||||
@@ -121,19 +157,99 @@ export function InboxView({ onNavigate }: { onNavigate?: (view: string) => void
|
||||
// ── Estado local de UI ────────────────────────────────────────────────────
|
||||
const [activeTab, setActiveTab] = useState<TabType>('chats')
|
||||
const [isInstanceDropdownOpen, setIsInstanceDropdownOpen] = useState(false)
|
||||
const [isRoleInfoOpen, setIsRoleInfoOpen] = useState(false)
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const roleInfoRef = useRef<HTMLDivElement>(null)
|
||||
const [isOptionsMenuOpen, setIsOptionsMenuOpen] = useState(false)
|
||||
const [drafts] = useState<Record<string, string>>({})
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
const [isNewConvoModalOpen, setIsNewConvoModalOpen] = useState(false)
|
||||
|
||||
// Permissão de apagar conversa/mensagem (dono ou liberado). Fail-open enquanto carrega.
|
||||
const [canDeleteMsg, setCanDeleteMsg] = useState(true)
|
||||
// Permissão de apagar conversa/mensagem (dono ou liberado). Fail-closed: só libera após confirmar.
|
||||
const [canDeleteMsg, setCanDeleteMsg] = useState(false)
|
||||
// "Limpar conversa" é só do DONO do workspace. Fail-closed: só aparece se confirmado dono.
|
||||
const [isWaOwner, setIsWaOwner] = useState(false)
|
||||
// Secretária IA: só o dono ou quem tem can_secretaria acessa a config. Fail-closed.
|
||||
const [canSecretaria, setCanSecretaria] = useState(false)
|
||||
const [labelModalOpen, setLabelModalOpen] = useState(false)
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
HybridBackend.getWaAccess().then(a => { if (alive) setCanDeleteMsg(a.isOwner || a.delete_msg) }).catch(() => {})
|
||||
HybridBackend.getWaAccess().then(a => { if (alive) { setCanDeleteMsg(a.isOwner || a.delete_msg); setIsWaOwner(!!a.isOwner); setCanSecretaria(a.isOwner || a.secretaria); useChatStore.getState().setOperatorSignature(a.signature ?? null) } }).catch(() => {})
|
||||
return () => { alive = false }
|
||||
}, [])
|
||||
|
||||
// ── Fila da Secretária ────────────────────────────────────────────────────
|
||||
// Pedidos que a Secretária IA encaminhou p/ a humana confirmar (zona cinza).
|
||||
// Carregamos os telefones dos pendentes e cruzamos com a lista de conversas
|
||||
// (últimos 8 dígitos, tolerante ao 9º dígito/DDI) → flag `pendenteSec` por chat.
|
||||
// A aba "Fila" filtra a lista por essa flag; a lista normal só ganha o selo.
|
||||
const [pendingPhones, setPendingPhones] = useState<Set<string>>(new Set())
|
||||
const [pedidosCount, setPedidosCount] = useState(0) // total de pedidos pendentes (badge do botão)
|
||||
const [showPedidos, setShowPedidos] = useState(false)
|
||||
const [showAgenda, setShowAgenda] = useState(false) // agenda em overlay, sem sair do inbox
|
||||
const carregarPendentes = useCallback(async () => {
|
||||
const cid = (HybridBackend.getActiveWorkspace() as any)?.id
|
||||
if (!cid) return
|
||||
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}` } : {} })
|
||||
const j = r.ok ? await r.json() : { pedidos: [] }
|
||||
const keys = new Set<string>()
|
||||
for (const p of (j.pedidos || [])) {
|
||||
const k = String(p.paciente_telefone || '').replace(/\D/g, '').slice(-8)
|
||||
if (k) keys.add(k)
|
||||
}
|
||||
setPendingPhones(keys)
|
||||
setPedidosCount((j.pedidos || []).length)
|
||||
} catch { /* silencioso: fila é auxiliar, não bloqueia o inbox */ }
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
carregarPendentes()
|
||||
const t = setInterval(carregarPendentes, 30000) // mantém a fila fresca sem recarregar a página
|
||||
return () => { clearInterval(t) }
|
||||
}, [carregarPendentes])
|
||||
|
||||
// ── Nomes NOSSOS (base de pacientes) por telefone ─────────────────────────
|
||||
// Mostra o nome que a clínica cadastrou no lugar do número/pushname do WhatsApp.
|
||||
// Um número pode ter VÁRIOS pacientes (grupo familiar) → `principal` = "1º nome +N".
|
||||
// Fallback (sem paciente): pushname → telefone. Casa pelos últimos 8 dígitos.
|
||||
const [nomesNossos, setNomesNossos] = useState<Record<string, { principal: string; count: number }>>({})
|
||||
const phonesKey = useMemo(
|
||||
() => [...new Set(chatsByScope.map(c => String(c.phone || c.remote_jid || '').replace(/\D/g, '').slice(-8)).filter(k => k.length >= 8))].sort().join(','),
|
||||
[chatsByScope],
|
||||
)
|
||||
useEffect(() => {
|
||||
const cid = (HybridBackend.getActiveWorkspace() as any)?.id
|
||||
const telefones = phonesKey ? phonesKey.split(',') : []
|
||||
if (!cid || telefones.length === 0) return
|
||||
let alive = true
|
||||
const token = localStorage.getItem('SCOREODONTO_AUTH_TOKEN')
|
||||
fetch(`/api/nw/agenda-config/clinica/${cid}/nomes-por-telefone`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
||||
body: JSON.stringify({ telefones }),
|
||||
}).then(r => (r.ok ? r.json() : { mapa: {} }))
|
||||
.then(j => { if (alive) setNomesNossos(j.mapa || {}) })
|
||||
.catch(() => { /* silencioso: sem nossos nomes, cai no pushname */ })
|
||||
return () => { alive = false }
|
||||
}, [phonesKey])
|
||||
const nomeKey = useCallback((c: any) => String(c?.phone || c?.remote_jid || '').replace(/\D/g, '').slice(-8), [])
|
||||
const selectedNomeNosso = selectedChat ? nomesNossos[nomeKey(selectedChat)]?.principal : undefined
|
||||
|
||||
// Enriquecimento: nome NOSSO + flag `pendenteSec` (fila) por conversa.
|
||||
const chatsEnriched = useMemo(() => {
|
||||
const temNomes = Object.keys(nomesNossos).length > 0
|
||||
if (pendingPhones.size === 0 && !temNomes) return chatsByScope
|
||||
return chatsByScope.map(c => {
|
||||
const key = String(c.phone || c.remote_jid || '').replace(/\D/g, '').slice(-8)
|
||||
const nomeNosso = key ? nomesNossos[key]?.principal : undefined
|
||||
const pend = !!(key && pendingPhones.has(key))
|
||||
return (nomeNosso || pend) ? { ...c, ...(nomeNosso ? { nomeNosso } : {}), ...(pend ? { pendenteSec: true } : {}) } : c
|
||||
})
|
||||
}, [chatsByScope, pendingPhones, nomesNossos])
|
||||
const filaChats = useMemo(() => chatsEnriched.filter((c: any) => c.pendenteSec), [chatsEnriched])
|
||||
|
||||
// Preloader: aparece APENAS em hard reload (F5 / URL direta) — nunca em
|
||||
// navegação client-side (router.push). Detectado por:
|
||||
// 1. performance.getEntriesByType('navigation')[0].type === 'reload'
|
||||
@@ -171,6 +287,7 @@ export function InboxView({ onNavigate }: { onNavigate?: (view: string) => void
|
||||
resize()
|
||||
const click = (e: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) setIsInstanceDropdownOpen(false)
|
||||
if (roleInfoRef.current && !roleInfoRef.current.contains(e.target as Node)) setIsRoleInfoOpen(false)
|
||||
if (optionsRef.current && !optionsRef.current.contains(e.target as Node)) setIsOptionsMenuOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', click)
|
||||
@@ -189,6 +306,18 @@ export function InboxView({ onNavigate }: { onNavigate?: (view: string) => void
|
||||
handleMenuAction(action, chat, toggleFavorite)
|
||||
}, [handleMenuAction, toggleFavorite])
|
||||
|
||||
// "Abrir chat" a partir do modal de pedidos: seleciona a conversa correspondente
|
||||
// na própria lista do inbox (casa pelos últimos 8 dígitos) e fecha o modal.
|
||||
const handleOpenChatFromFila = useCallback((t: { pacienteNome?: string; phone?: string }) => {
|
||||
const key = String(t.phone || '').replace(/\D/g, '').slice(-8)
|
||||
const chat = key ? chatsStore.find(c => String(c.phone || c.remote_jid || '').replace(/\D/g, '').slice(-8) === key) : undefined
|
||||
if (chat) {
|
||||
handleSelectChat(chat as any)
|
||||
setShowPedidos(false)
|
||||
setActiveTab('chats')
|
||||
}
|
||||
}, [chatsStore, handleSelectChat])
|
||||
|
||||
|
||||
const handleFetchChats = useCallback(async () => {
|
||||
await refreshInstances()
|
||||
@@ -249,9 +378,9 @@ export function InboxView({ onNavigate }: { onNavigate?: (view: string) => void
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden h-full">
|
||||
<ThinSidebar activeTab={activeTab} setActiveTab={setActiveTab} activeInstance={activeInstance} onNavigate={onNavigate} />
|
||||
<ThinSidebar activeTab={activeTab} setActiveTab={setActiveTab} activeInstance={activeInstance} onNavigate={onNavigate} filaCount={filaChats.length} canPower={isWaOwner} />
|
||||
|
||||
{activeTab === 'chats' && (
|
||||
{(activeTab === 'chats' || activeTab === 'fila') && (
|
||||
<div className={`${isMobile ? (selectedChat ? 'hidden' : 'w-full') : 'w-[400px]'} flex flex-col bg-white border-r border-[#d1d7db] shrink-0 z-20`}>
|
||||
{/* Header da sidebar */}
|
||||
<div className="h-[60px] px-4 flex items-center justify-between bg-[#f0f2f5] border-b border-[#d1d7db] shrink-0">
|
||||
@@ -284,10 +413,12 @@ export function InboxView({ onNavigate }: { onNavigate?: (view: string) => void
|
||||
{isInstanceDropdownOpen && (
|
||||
<motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }}
|
||||
className="absolute left-0 top-full bg-white shadow-2xl rounded-xl border border-gray-100 w-72 z-50 py-2">
|
||||
{instances.map(i => (
|
||||
{instances.map(i => {
|
||||
const rm = roleMeta(i.role)
|
||||
return (
|
||||
<button key={i.instance} onClick={() => { selectInstance(i); setIsInstanceDropdownOpen(false) }}
|
||||
className="w-full p-3 hover:bg-gray-50 flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-gray-100 overflow-hidden">
|
||||
<div className="w-8 h-8 rounded-full bg-gray-100 overflow-hidden shrink-0">
|
||||
<img
|
||||
src={getAvatarProxyUrl({
|
||||
instance_name: i.instance,
|
||||
@@ -297,12 +428,74 @@ export function InboxView({ onNavigate }: { onNavigate?: (view: string) => void
|
||||
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<span className="text-sm font-medium block">{i.nome}</span>
|
||||
<span className={`text-xs ${i.status === 'connected' ? 'text-green-500' : 'text-gray-400'}`}>{i.status}</span>
|
||||
<div className="text-left min-w-0 flex-1">
|
||||
<span className="text-sm font-medium block truncate">{i.nome}</span>
|
||||
<span className="text-[11px] flex items-center gap-1 truncate" style={{ color: rm.color }}>
|
||||
<span className="w-1.5 h-1.5 rounded-full inline-block shrink-0" style={{ background: rm.color }} />
|
||||
{rm.label}{!i.own && i.ownerNome ? ` · ${i.ownerNome}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
{!i.own && (
|
||||
<span className="text-[9px] font-bold uppercase tracking-wide text-brand-600 bg-brand-500/10 px-1.5 py-0.5 rounded shrink-0">clínica</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Secretária IA — aba dentro do WhatsApp (saiu do menu lateral).
|
||||
Só o dono ou quem tem can_secretaria acessa a CONFIG (fail-closed). */}
|
||||
{canSecretaria && (
|
||||
<button
|
||||
onClick={() => onNavigate?.('wa-secretaria')}
|
||||
className="p-1.5 rounded-full text-[#8696a0] hover:bg-black/5 hover:text-[#2563eb] transition-colors"
|
||||
title="Secretária IA"
|
||||
>
|
||||
<Bot className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Ícone (i): legenda dos papéis de cada número acessível */}
|
||||
<div className="relative" ref={roleInfoRef}>
|
||||
<button
|
||||
onClick={() => setIsRoleInfoOpen(o => !o)}
|
||||
className={`p-1.5 rounded-full transition-colors ${isRoleInfoOpen ? 'bg-black/10 text-[#111b21]' : 'text-[#8696a0] hover:bg-black/5 hover:text-[#54656f]'}`}
|
||||
title="O que é cada número?"
|
||||
>
|
||||
<Info className="w-4 h-4" />
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{isRoleInfoOpen && (
|
||||
<motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -8 }}
|
||||
className="absolute left-0 top-full mt-1 bg-white shadow-2xl rounded-xl border border-gray-100 w-80 z-50 p-3">
|
||||
<p className="text-[11px] font-bold uppercase tracking-wide text-gray-400 px-1 pb-2">Seus números</p>
|
||||
<div className="space-y-2 max-h-[60vh] overflow-y-auto">
|
||||
{instances.length === 0 && (
|
||||
<p className="text-xs text-gray-400 px-1 py-2">Nenhum número disponível.</p>
|
||||
)}
|
||||
{instances.map(i => {
|
||||
const rm = roleMeta(i.role)
|
||||
const desc = [i.area, i.notes].filter(Boolean).join(' · ')
|
||||
return (
|
||||
<div key={i.instance} className="flex items-start gap-2.5 px-1">
|
||||
<span className="w-2 h-2 rounded-full mt-1.5 shrink-0" style={{ background: rm.color }} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[13px] font-semibold text-[#111b21] truncate">{i.nome}</span>
|
||||
{!i.own && <span className="text-[9px] font-bold uppercase text-brand-600 bg-brand-500/10 px-1 py-0.5 rounded shrink-0">clínica</span>}
|
||||
</div>
|
||||
<p className="text-[11px] font-medium" style={{ color: rm.color }}>{rm.label}</p>
|
||||
{i.phone && <p className="text-[11px] text-gray-500">{i.phone}</p>}
|
||||
{desc && <p className="text-[11px] text-gray-400 leading-snug">{desc}</p>}
|
||||
{!i.own && i.ownerNome && <p className="text-[10px] text-gray-400">caixa de {i.ownerNome}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
@@ -350,9 +543,9 @@ export function InboxView({ onNavigate }: { onNavigate?: (view: string) => void
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Lista de chats */}
|
||||
{/* Lista de chats — na aba "Fila", só as conversas com pendência da Secretária */}
|
||||
<ChatSidebar
|
||||
chats={chatsByScope}
|
||||
chats={activeTab === 'fila' ? filaChats : chatsEnriched}
|
||||
loading={loading}
|
||||
selectedChat={selectedChat}
|
||||
searchTerm={searchTerm}
|
||||
@@ -393,10 +586,27 @@ export function InboxView({ onNavigate }: { onNavigate?: (view: string) => void
|
||||
isSyncingAvatar={isSyncingAvatar}
|
||||
isProtocolOpen={isProtocolOpen}
|
||||
hasActiveProtocol={protocolPanel.hasActive}
|
||||
onSearchClick={() => {}}
|
||||
isOwner={isWaOwner}
|
||||
nomeNosso={selectedNomeNosso}
|
||||
activeInstanceId={activeInstance?.instance ?? null}
|
||||
pedidosCount={pedidosCount}
|
||||
onOpenPedidos={() => setShowPedidos(true)}
|
||||
onOpenAgenda={() => setShowAgenda(true)}
|
||||
onClearConversation={handleClearConversation}
|
||||
onManageLabels={() => setLabelModalOpen(true)}
|
||||
onSearchClick={() => setIsSearchOpen(true)}
|
||||
onToggleMenu={() => setIsChatMenuOpen(!isChatMenuOpen)}
|
||||
onProtocolClick={() => { setIsProtocolOpen(!isProtocolOpen); setIsProfileOpen(false) }}
|
||||
/>
|
||||
{isSearchOpen && (
|
||||
<ConversationSearch
|
||||
messages={messages as any}
|
||||
onClose={() => setIsSearchOpen(false)}
|
||||
onQueryChange={setSearchQuery}
|
||||
hasOlder={hasMoreHistory}
|
||||
loadOlder={async () => { const r = await handleLoadMoreHistory(); return !!r?.hasMore; }}
|
||||
/>
|
||||
)}
|
||||
<MessageArea
|
||||
messages={messages as any}
|
||||
selectedChat={selectedChat}
|
||||
@@ -405,14 +615,15 @@ export function InboxView({ onNavigate }: { onNavigate?: (view: string) => void
|
||||
onScroll={handleScroll}
|
||||
historyLoadingMore={loadingMore}
|
||||
hasMoreHistory={hasMoreHistory}
|
||||
highlightTerm={isSearchOpen ? searchQuery : ''}
|
||||
reactionPickerFor={reactionPickerFor}
|
||||
onReply={setReplyingTo}
|
||||
onToggleReactionPicker={toggleReactionPicker}
|
||||
onReact={handleReactToMessage}
|
||||
onForward={() => {}}
|
||||
onDelete={() => {}}
|
||||
onForward={(msg: any) => setForwardingMessage(msg)}
|
||||
onDelete={(msg: any) => { if (selectedChat && msg?.id) handleDeleteMessage(String(selectedChat.id), String(msg.id)); }}
|
||||
canDelete={canDeleteMsg}
|
||||
onEdit={() => {}}
|
||||
onEdit={handleEnterEdit}
|
||||
quickReactions={['👍', '❤️', '😂', '😮', '😢', '🙏']}
|
||||
getGroupSenderLabel={(msg: any) => {
|
||||
if (msg.direction === 'out') return 'Você'
|
||||
@@ -432,6 +643,8 @@ export function InboxView({ onNavigate }: { onNavigate?: (view: string) => void
|
||||
onPresence={handleTyping}
|
||||
onCancelReply={() => setReplyingTo(null)}
|
||||
replyingTo={replyingTo}
|
||||
editingMessage={editingMessage}
|
||||
onCancelEdit={() => { setEditingMessage(null); setNewMessage('') }}
|
||||
selectedChat={selectedChat}
|
||||
isMobile={isMobile}
|
||||
onSendMedia={handleSendMedia}
|
||||
@@ -446,6 +659,7 @@ export function InboxView({ onNavigate }: { onNavigate?: (view: string) => void
|
||||
chat={selectedChat}
|
||||
messages={messages as any}
|
||||
onClose={() => setIsProfileOpen(false)}
|
||||
nomeNosso={selectedNomeNosso}
|
||||
/>
|
||||
<ProtocolPanel
|
||||
isOpen={isProtocolOpen && !isMobile}
|
||||
@@ -479,6 +693,52 @@ export function InboxView({ onNavigate }: { onNavigate?: (view: string) => void
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<LabelManagerModal
|
||||
isOpen={labelModalOpen}
|
||||
onClose={() => setLabelModalOpen(false)}
|
||||
chat={selectedChat as any}
|
||||
onChanged={() => { if (activeInstance?.instance) useChatStore.getState().loadChats(activeInstance.instance) }}
|
||||
/>
|
||||
|
||||
<ForwardModal
|
||||
isOpen={!!forwardingMessage}
|
||||
onClose={() => setForwardingMessage(null)}
|
||||
chats={chatsByScope as any}
|
||||
onForward={async (toChatIds: string[]) => {
|
||||
if (!forwardingMessage || !selectedChat?.id) return
|
||||
try {
|
||||
await chatApi.forward(String(selectedChat.id), String((forwardingMessage as any).id), toChatIds)
|
||||
} catch (e) { console.error('Erro ao encaminhar:', e) }
|
||||
setForwardingMessage(null)
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Fila da Secretária — mesmo modal da /agenda (confirmar/recusar/ajustar horário) */}
|
||||
<PedidosPendentes
|
||||
isOpen={showPedidos}
|
||||
onClose={() => setShowPedidos(false)}
|
||||
clinicaId={(HybridBackend.getActiveWorkspace() as any)?.id}
|
||||
onOpenChat={handleOpenChatFromFila}
|
||||
onChange={carregarPendentes}
|
||||
/>
|
||||
|
||||
{/* Agenda em overlay — reutiliza a AgendaView inteira p/ conferir horários vagos
|
||||
em outro dia sem sair do inbox. Overlay full-screen: zero mudança estrutural. */}
|
||||
{showAgenda && (
|
||||
<div className="fixed inset-0 z-[80] bg-white flex flex-col">
|
||||
<div className="h-12 px-4 flex items-center justify-between border-b border-gray-100 bg-[#f0f2f5] shrink-0">
|
||||
<span className="text-sm font-black uppercase tracking-tight text-gray-700">Agenda</span>
|
||||
<button onClick={() => setShowAgenda(false)} className="p-2 hover:bg-black/5 rounded-full text-[#54656f]" title="Fechar agenda (voltar ao chat)">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
{/* Mesmo respiro da página /agenda (App.tsx: p-4 md:p-8 + scroll) */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto scroll-smooth p-4 md:p-8">
|
||||
<AgendaView onNavigate={onNavigate} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1617,27 +1617,57 @@ export function SecretariaView(props: { onNavigate?: (view: string) => void } =
|
||||
const [showFila, setShowFila] = useState(false)
|
||||
const [pendCount, setPendCount] = useState(0)
|
||||
|
||||
// Liga/desliga GLOBAL da Secretária (kill-switch auto_reply) — todos, sem timer
|
||||
// Liga/desliga GLOBAL da Secretária (kill-switch auto_reply) — todos os chats.
|
||||
// Ao desligar exige MOTIVO (contexto geral p/ o super admin) e guarda histórico.
|
||||
const [secOn, setSecOn] = useState(true)
|
||||
const [powerBusy, setPowerBusy] = useState(false)
|
||||
const [powerModal, setPowerModal] = useState(false)
|
||||
const [powerReason, setPowerReason] = useState('')
|
||||
const [powerData, setPowerData] = useState<any>(null)
|
||||
const [powerErr, setPowerErr] = useState<string | null>(null)
|
||||
|
||||
// Guard de acesso (fail-closed): a CONFIG da Secretária é só do dono ou de quem tem
|
||||
// can_secretaria. Protege contra navegação por URL direta a /wa-secretaria — sem isto,
|
||||
// qualquer operador do inbox abriria esta tela. Enquanto verifica, não renderiza nada.
|
||||
const [accessChecked, setAccessChecked] = useState(false)
|
||||
const [hasSecretariaAccess, setHasSecretariaAccess] = useState(false)
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
HybridBackend.getWaAccess().then((a) => {
|
||||
if (!alive) return
|
||||
const ok = !!(a.isOwner || a.secretaria)
|
||||
setHasSecretariaAccess(ok); setAccessChecked(true)
|
||||
if (!ok) props.onNavigate?.('wa-inbox')
|
||||
}).catch(() => { if (alive) { setAccessChecked(true); props.onNavigate?.('wa-inbox') } })
|
||||
return () => { alive = false }
|
||||
}, [])
|
||||
|
||||
// Boot
|
||||
useEffect(() => {
|
||||
store.loadAgents()
|
||||
store.loadCalendar()
|
||||
store.loadNumbers()
|
||||
secPowerApi.get().then((r) => setSecOn(r.enabled)).catch(() => {})
|
||||
secPowerApi.get().then((r) => { setSecOn(r.enabled); setPowerData(r) }).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const handleTogglePower = useCallback(async () => {
|
||||
const openPowerModal = useCallback(() => {
|
||||
setPowerErr(null); setPowerReason('')
|
||||
secPowerApi.get().then((r) => { setSecOn(r.enabled); setPowerData(r) }).catch(() => {})
|
||||
setPowerModal(true)
|
||||
}, [])
|
||||
|
||||
const confirmTogglePower = 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])
|
||||
if (!proximo && !powerReason.trim()) { setPowerErr('Escreva o motivo para desativar.'); return }
|
||||
setPowerBusy(true); setPowerErr(null)
|
||||
try {
|
||||
const userName = (() => { try { return JSON.parse(localStorage.getItem('SCOREODONTO_USER_DATA') || '{}')?.nome || 'Usuário' } catch { return 'Usuário' } })()
|
||||
const r = await secPowerApi.set(proximo, powerReason.trim(), userName)
|
||||
setSecOn(r.enabled); setPowerReason('')
|
||||
const fresh = await secPowerApi.get().catch(() => null); if (fresh) setPowerData(fresh)
|
||||
} catch (e: any) { setPowerErr(e?.message || 'Não foi possível alterar.') } finally { setPowerBusy(false) }
|
||||
}, [secOn, powerBusy, powerReason])
|
||||
|
||||
// Contagem de pendências para o badge (dono) — atualiza ao abrir e a cada 5 min.
|
||||
useEffect(() => {
|
||||
@@ -1704,13 +1734,70 @@ export function SecretariaView(props: { onNavigate?: (view: string) => void } =
|
||||
setActiveTab('conversations')
|
||||
}
|
||||
|
||||
// Fail-closed: enquanto verifica ou se negado, não renderiza a config (evita flash e vazamento).
|
||||
if (!accessChecked || !hasSecretariaAccess) return null
|
||||
|
||||
return (
|
||||
<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?.('wa-inbox')}
|
||||
onPendencias={ehDono ? () => setShowPendencias(true) : undefined} pendenciasBadge={pendCount}
|
||||
secOn={secOn} onTogglePower={handleTogglePower} />
|
||||
secOn={secOn} onTogglePower={openPowerModal} />
|
||||
|
||||
{powerModal && (
|
||||
<div className="fixed inset-0 bg-black/60 z-[80] flex items-center justify-center backdrop-blur-md p-4" onClick={() => setPowerModal(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 ${secOn ? '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 — todos os chats</h2>
|
||||
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mt-1">{secOn ? 'Ativa' : 'Desativada'} · vale para TODAS as sessões</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => setPowerModal(false)} className="p-2 hover:bg-gray-100 rounded-xl text-gray-400"><X size={20} /></button>
|
||||
</div>
|
||||
{powerErr && <div className="px-6 py-2 text-xs font-bold bg-red-50 text-red-600">{powerErr}</div>}
|
||||
<div className="p-6 space-y-4 overflow-y-auto">
|
||||
{!secOn && powerData?.reason && (
|
||||
<div className="text-xs bg-red-50 text-red-700 rounded-xl p-3"><span className="font-black">Desativada.</span> Motivo: {powerData.reason}</div>
|
||||
)}
|
||||
{secOn && (
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">Motivo para desativar (todos os chats)</label>
|
||||
<textarea value={powerReason} onChange={(e) => setPowerReason(e.target.value)} rows={3}
|
||||
placeholder="Ex.: bug na forma de agendar; estratégia a ajustar no atendimento da IA…"
|
||||
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" />
|
||||
<p className="text-[10px] text-gray-400 mt-1">Desativa a Secretária para TODAS as sessões até alguém reativar. O motivo aparece no super admin.</p>
|
||||
</div>
|
||||
)}
|
||||
<button onClick={confirmTogglePower} disabled={powerBusy}
|
||||
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 ${secOn ? 'bg-red-600 hover:bg-red-700 text-white' : 'bg-emerald-600 hover:bg-emerald-700 text-white'}`}>
|
||||
{powerBusy ? <Loader2 size={16} className="animate-spin" /> : <Power size={16} />}
|
||||
{secOn ? 'Desativar (todos os chats)' : 'Ativar (todos os chats)'}
|
||||
</button>
|
||||
{!!powerData?.history?.length && (
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest mb-2">Histórico</p>
|
||||
<div className="space-y-1.5 max-h-40 overflow-y-auto">
|
||||
{powerData.history.map((h: any, i: number) => (
|
||||
<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 ? 'Ativou' : 'Desativou'}</span>
|
||||
<span className="text-gray-400"> · {h.by || '—'} · {h.at ? new Date(h.at).toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }) : ''}</span>
|
||||
{!h.enabled && h.reason && <div className="text-gray-500 leading-snug">{h.reason}</div>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ehDono && (
|
||||
<>
|
||||
|
||||
@@ -358,7 +358,7 @@ const SessionCard = React.forwardRef<HTMLDivElement, {
|
||||
|
||||
<div className="flex items-start justify-between relative z-10">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`w-14 h-14 rounded-2xl overflow-hidden flex items-center justify-center border-2 shadow-lg ${isConnected ? 'bg-emerald-500/10 border-emerald-500/30 text-emerald-400' : 'bg-gray-50 border-gray-200 text-gray-500'}`}>
|
||||
<div className={`w-14 h-14 rounded-full overflow-hidden flex items-center justify-center border-2 shadow-lg ${isConnected ? 'bg-emerald-500/10 border-emerald-500/30 text-emerald-400' : 'bg-gray-50 border-gray-200 text-gray-500'}`}>
|
||||
{inst.avatar
|
||||
? <img src={inst.avatar} alt={inst.name} className="w-full h-full object-cover" onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; (e.currentTarget.nextSibling as HTMLElement)?.style.setProperty('display', 'flex') }} />
|
||||
: null}
|
||||
@@ -435,11 +435,20 @@ const SessionCard = React.forwardRef<HTMLDivElement, {
|
||||
<UserCog className="w-3.5 h-3.5" /> Acesso
|
||||
</button>
|
||||
)}
|
||||
<button onClick={onDisconnect}
|
||||
className="flex items-center gap-1.5 px-3 py-2 text-xs font-semibold text-gray-600 hover:text-gray-800 bg-gray-50 hover:bg-gray-100 rounded-xl transition-colors border border-gray-100"
|
||||
>
|
||||
<LogOut className="w-3.5 h-3.5" /> Desconectar
|
||||
</button>
|
||||
{/* Desconectar = gestão da conexão (o backend exige dono ou can_rescan). */}
|
||||
{rescanLocked ? (
|
||||
<div title="Somente o dono pode desconectar esta sessão — peça autorização de re-scan."
|
||||
className="flex items-center gap-1.5 px-3 py-2 text-xs font-semibold text-gray-400 bg-gray-500/5 rounded-xl border border-gray-200 cursor-not-allowed"
|
||||
>
|
||||
<Lock className="w-3.5 h-3.5" /> Desconectar
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={onDisconnect}
|
||||
className="flex items-center gap-1.5 px-3 py-2 text-xs font-semibold text-gray-600 hover:text-gray-800 bg-gray-50 hover:bg-gray-100 rounded-xl transition-colors border border-gray-100"
|
||||
>
|
||||
<LogOut className="w-3.5 h-3.5" /> Desconectar
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : rescanLocked ? (
|
||||
<div className="flex-1 flex items-center justify-center gap-2 bg-gray-500/5 text-gray-400 font-black py-3 rounded-xl text-[11px] uppercase tracking-widest border border-gray-200 cursor-not-allowed"
|
||||
@@ -652,9 +661,9 @@ export function SessionsView({ onNavigate }: { onNavigate?: (view: string) => vo
|
||||
// ── Ownership & permissões por sessão ──────────────────────────────────────
|
||||
const [authzByInstance, setAuthzByInstance] = useState<Record<string, SessionPerms>>({});
|
||||
const [workspaceOwner, setWorkspaceOwner] = useState<{ isOwner: boolean; ownerNome: string | null }>({ isOwner: false, ownerNome: null });
|
||||
// Endpoint de authz disponível? Se não (backend antigo/erro), a UI NÃO bloqueia —
|
||||
// o enforcement real é do proxy do backend. Começa true (otimista) até saber.
|
||||
const [authzAvailable, setAuthzAvailable] = useState(true);
|
||||
// Endpoint de authz disponível? Fail-CLOSED: se não (backend antigo/erro), a UI TRAVA as
|
||||
// ações destrutivas (o proxy segue sendo a autoridade). Começa false até confirmar.
|
||||
const [authzAvailable, setAuthzAvailable] = useState(false);
|
||||
const [permsModalFor, setPermsModalFor] = useState<Instance | null>(null);
|
||||
const instanceIdsKey = instances.map((i) => i.id).join(',');
|
||||
|
||||
@@ -680,7 +689,7 @@ export function SessionsView({ onNavigate }: { onNavigate?: (view: string) => vo
|
||||
if (r?.available) available = true;
|
||||
map[id] = r
|
||||
? { isOwner: r.isOwner, canRescan: r.me.can_rescan, canDelete: r.me.can_delete, ownerNome: r.ownerNome }
|
||||
: { isOwner: false, canRescan: true, canDelete: true, ownerNome: null };
|
||||
: { isOwner: false, canRescan: false, canDelete: false, ownerNome: null }; // fail-closed em erro
|
||||
if (r?.available && r.isOwner) owner = { isOwner: true, ownerNome: r.ownerNome };
|
||||
else if (r?.available && !owner.ownerNome && r.ownerNome) owner.ownerNome = r.ownerNome;
|
||||
}
|
||||
@@ -692,18 +701,18 @@ export function SessionsView({ onNavigate }: { onNavigate?: (view: string) => vo
|
||||
}, [instanceIdsKey]);
|
||||
|
||||
// Permissões efetivas de uma instância.
|
||||
// Sem authz disponível → permissivo (não bloqueia; backend é a autoridade).
|
||||
// Fail-CLOSED: sem authz disponível (endpoint em erro), trava as ações na UI — o backend
|
||||
// segue sendo a autoridade, mas a UI não deve expor botões destrutivos por falha transitória.
|
||||
const permsFor = (inst: Instance): SessionPerms => {
|
||||
if (!authzAvailable) return { isOwner: false, canRescan: true, canDelete: true, ownerNome: null };
|
||||
if (!authzAvailable) return { isOwner: false, canRescan: false, canDelete: false, ownerNome: null };
|
||||
return authzByInstance[inst.id]
|
||||
?? (workspaceOwner.isOwner
|
||||
? { ...OWNER_PERMS, ownerNome: workspaceOwner.ownerNome }
|
||||
: { isOwner: false, canRescan: false, canDelete: false, ownerNome: workspaceOwner.ownerNome });
|
||||
};
|
||||
|
||||
// "Nova Instância" (scan inicial): só o dono. Mas se o endpoint não está
|
||||
// disponível ainda, não bloqueia na UI (o backend decide).
|
||||
const canCreateInstance = !authzAvailable || workspaceOwner.isOwner;
|
||||
// "Nova Instância" (scan inicial): só o dono (fail-closed — o backend também exige dono).
|
||||
const canCreateInstance = authzAvailable && workspaceOwner.isOwner;
|
||||
|
||||
// Já existe uma sessão ativa no motor para esta conta → mostra mensagem de
|
||||
// sucesso em vez de sugerir novo pareamento.
|
||||
|
||||
@@ -15,10 +15,13 @@
|
||||
*/
|
||||
import React, { useEffect, useMemo, useState, useCallback } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, ChevronDown, User, Phone, Send, MessageCircle, Loader2, WifiOff, QrCode } from 'lucide-react';
|
||||
import { X, ChevronDown, User, Users, Phone, Send, MessageCircle, Loader2, WifiOff, QrCode } from 'lucide-react';
|
||||
|
||||
import { HybridBackend } from '../../services/backend';
|
||||
import { useChatStore } from './store/chatStore';
|
||||
import { chatApi } from './services/chatApiService';
|
||||
import ForwardModal from './components/ForwardModal';
|
||||
import LabelManagerModal from './components/LabelManagerModal';
|
||||
import { useInstanceStore } from './store/instanceStore';
|
||||
import { formatPhone } from './utils/inboxUtils';
|
||||
import { nw } from './services/nwClient';
|
||||
@@ -29,6 +32,7 @@ import { useChatWindow } from './hooks/inbox/useChatWindow';
|
||||
import { useMessageInput } from './hooks/inbox/useMessageInput';
|
||||
|
||||
import InboxHeader from './components/InboxHeader';
|
||||
import ConversationSearch from './components/ConversationSearch';
|
||||
import MessageArea from './components/MessageArea';
|
||||
import InputBar from './components/InputBar';
|
||||
import ContactProfile from './components/ContactProfile';
|
||||
@@ -39,6 +43,10 @@ export interface WhatsChatDrawerProps {
|
||||
onClose: () => void;
|
||||
pacienteId?: string | null;
|
||||
pacienteNome?: string | null;
|
||||
// Telefone CRU direto (ex.: pedido da fila da Secretária, que pode ser um lead SEM
|
||||
// cadastro). Quando fornecido, entra como 1ª opção de número e resolve o chat sem
|
||||
// depender de lookup por cadastro.
|
||||
phone?: string | null;
|
||||
onNavigate?: (view: string) => void;
|
||||
}
|
||||
|
||||
@@ -65,8 +73,16 @@ function samePhone(a: string, b: string): boolean {
|
||||
return da.slice(-8) === db.slice(-8);
|
||||
}
|
||||
|
||||
// Nome curto p/ o header: 1º nome + um pouco do 2º (até 5 letras + … se cortar).
|
||||
function shortContactName(full: string): string {
|
||||
const parts = String(full || '').trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length <= 1) return parts[0] || full;
|
||||
const second = parts[1];
|
||||
return `${parts[0]} ${second.length > 5 ? second.slice(0, 5) + '…' : second}`;
|
||||
}
|
||||
|
||||
// ─── Conteúdo interno (só monta quando aberto → hooks/socket sob demanda) ──────
|
||||
const DrawerInner: React.FC<Omit<WhatsChatDrawerProps, 'isOpen'>> = ({ onClose, pacienteId, pacienteNome, onNavigate }) => {
|
||||
const DrawerInner: React.FC<Omit<WhatsChatDrawerProps, 'isOpen'>> = ({ onClose, pacienteId, pacienteNome, phone, onNavigate }) => {
|
||||
const { activeInstance, instances, connectedInstances } = useInstanceSelector();
|
||||
const loadingInstances = useInstanceStore((s) => s.loading);
|
||||
// Há WhatsApp utilizável? (pelo menos uma instância conectada)
|
||||
@@ -77,32 +93,65 @@ const DrawerInner: React.FC<Omit<WhatsChatDrawerProps, 'isOpen'>> = ({ onClose,
|
||||
loadingMessages,
|
||||
handleSendText,
|
||||
handleLoadMoreHistory,
|
||||
handleDeleteMessage,
|
||||
presenceByJid,
|
||||
activeInstanceId,
|
||||
} = useNewInboxBridge();
|
||||
|
||||
// Permissão de apagar mensagem (dono ou liberado). Fail-safe: false enquanto carrega.
|
||||
const [canDeleteMsg, setCanDeleteMsg] = useState(false);
|
||||
// "Limpar conversa" é só do DONO do workspace. Fail-closed.
|
||||
const [isWaOwner, setIsWaOwner] = useState(false);
|
||||
const [labelModalOpen, setLabelModalOpen] = useState(false);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
HybridBackend.getWaAccess().then(a => { if (alive) { setCanDeleteMsg(!!(a.isOwner || a.delete_msg)); setIsWaOwner(!!a.isOwner); useChatStore.getState().setOperatorSignature(a.signature ?? null); } }).catch(() => {});
|
||||
return () => { alive = false; };
|
||||
}, []);
|
||||
|
||||
const {
|
||||
scrollRef, isTyping, replyingTo, reactionPickerFor, hasMoreHistory, loadingMore,
|
||||
scrollRef, replyingTo, reactionPickerFor, hasMoreHistory, loadingMore,
|
||||
isProfileOpen, isSyncingAvatar, isChatMenuOpen,
|
||||
setReplyingTo, setIsProfileOpen, setIsChatMenuOpen,
|
||||
handleTyping, handleScroll, handleSelectChat: onChatWindowSelect,
|
||||
handleReactToMessage, handleSendMedia, handleSendAudio, handleSyncAvatar, toggleReactionPicker,
|
||||
handleClearConversation,
|
||||
editingMessage, setEditingMessage, forwardingMessage, setForwardingMessage,
|
||||
} = useChatWindow({
|
||||
selectedChat,
|
||||
activeInstanceId: activeInstance?.instance ?? null,
|
||||
onLoadMoreHistory: handleLoadMoreHistory,
|
||||
});
|
||||
|
||||
// "digitando..." do CONTATO (presença composing/recording do chat aberto).
|
||||
const activePresence = selectedChat?.remote_jid ? presenceByJid[selectedChat.remote_jid] : undefined;
|
||||
const isTyping = !!activePresence && (Date.now() - activePresence.ts <= 8000) && (activePresence.state === 'composing' || activePresence.state === 'recording');
|
||||
|
||||
const { newMessage, setNewMessage, mentions, setMentions, handleSendMessage } = useMessageInput({
|
||||
selectedChat,
|
||||
replyingTo,
|
||||
onClearReply: () => setReplyingTo(null),
|
||||
onSendText: handleSendText,
|
||||
editingMessage,
|
||||
onClearEdit: () => setEditingMessage(null),
|
||||
onEditText: async (messageId, text) => {
|
||||
if (!selectedChat?.id) return;
|
||||
try {
|
||||
await chatApi.editMessage(String(selectedChat.id), messageId, text);
|
||||
useChatStore.getState().patchMessage(messageId, { body: text });
|
||||
} catch (e) { console.error('Erro ao editar:', e); }
|
||||
},
|
||||
});
|
||||
const handleEnterEdit = useCallback((msg: any) => {
|
||||
setEditingMessage(msg); setReplyingTo(null); setNewMessage(msg?.text ?? msg?.body ?? '');
|
||||
}, [setEditingMessage, setReplyingTo, setNewMessage]);
|
||||
|
||||
// ── Números disponíveis (paciente + grupo familiar) ──────────────────────
|
||||
const [numeros, setNumeros] = useState<NumeroOpcao[]>([]);
|
||||
const [selectedPhone, setSelectedPhone] = useState<string>('');
|
||||
const [numDropdownOpen, setNumDropdownOpen] = useState(false);
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// ── Estado de resolução da conversa ──────────────────────────────────────
|
||||
const [resolving, setResolving] = useState(false);
|
||||
@@ -126,6 +175,8 @@ const DrawerInner: React.FC<Omit<WhatsChatDrawerProps, 'isOpen'>> = ({ onClose,
|
||||
opts.push({ phone: d, nome, relacao });
|
||||
};
|
||||
|
||||
// 0) Telefone CRU direto (pedido da fila): 1ª opção, funciona mesmo sem cadastro.
|
||||
if (phone) push(phone, pacienteNome || 'Contato', 'Contato');
|
||||
// 1) Telefone do próprio paciente — via cadastro (desambigua por id).
|
||||
if (pacienteNome) {
|
||||
try {
|
||||
@@ -150,7 +201,7 @@ const DrawerInner: React.FC<Omit<WhatsChatDrawerProps, 'isOpen'>> = ({ onClose,
|
||||
setSelectedPhone(opts[0]?.phone || '');
|
||||
})();
|
||||
return () => { alive = false; };
|
||||
}, [pacienteId, pacienteNome]);
|
||||
}, [pacienteId, pacienteNome, phone]);
|
||||
|
||||
// Resolve a conversa (chatId) a partir do telefone selecionado, sem enviar nada.
|
||||
const resolveChat = useCallback(async (phoneDigits: string) => {
|
||||
@@ -230,48 +281,59 @@ const DrawerInner: React.FC<Omit<WhatsChatDrawerProps, 'isOpen'>> = ({ onClose,
|
||||
transition={{ type: 'spring', damping: 30, stiffness: 300 }}
|
||||
className="fixed inset-y-0 right-0 z-[191] w-full sm:w-[440px] bg-[#f0f2f5] shadow-2xl flex flex-col overflow-hidden"
|
||||
>
|
||||
{/* Barra superior do drawer: contato + dropdown de números + fechar */}
|
||||
<div className="shrink-0 bg-[#008069] text-white px-3 py-2.5 flex items-center gap-2">
|
||||
{/* Barra superior: nome + dropdown do GRUPO FAMILIAR (troca a conversa) + fechar */}
|
||||
<div className="shrink-0 bg-[#008069] text-white px-3 py-2 flex items-center gap-2.5">
|
||||
<MessageCircle className="w-5 h-5 shrink-0" />
|
||||
<div className="min-w-0 flex-1 relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => numeros.length > 1 && setNumDropdownOpen((v) => !v)}
|
||||
className={`flex items-center gap-1.5 max-w-full ${numeros.length > 1 ? 'cursor-pointer' : 'cursor-default'}`}
|
||||
>
|
||||
<span className="font-bold text-sm truncate">{tituloContato}</span>
|
||||
{selectedNumero && (
|
||||
<span className="text-[11px] text-white/70 truncate">
|
||||
{formatPhone(normalizeSendPhone(selectedNumero.phone))}
|
||||
</span>
|
||||
)}
|
||||
{numeros.length > 1 && <ChevronDown className={`w-4 h-4 shrink-0 transition-transform ${numDropdownOpen ? 'rotate-180' : ''}`} />}
|
||||
</button>
|
||||
{/* Nome (truncado) + pill dropdown do grupo familiar — NA MESMA LINHA */}
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<p className="font-bold text-sm truncate leading-tight" title={tituloContato}>{shortContactName(tituloContato)}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNumDropdownOpen((v) => !v)}
|
||||
className="shrink-0 inline-flex items-center gap-1.5 rounded-full bg-white/15 hover:bg-white/25 px-3 py-1.5 text-[11px] font-bold uppercase tracking-wide transition-colors"
|
||||
>
|
||||
<Users className="w-3.5 h-3.5 shrink-0" />
|
||||
<span className="truncate max-w-[90px]">{selectedNumero?.relacao || 'Paciente'}</span>
|
||||
<ChevronDown className={`w-3.5 h-3.5 shrink-0 transition-transform ${numDropdownOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{numDropdownOpen && numeros.length > 1 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -6 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -6 }}
|
||||
className="absolute left-0 top-full mt-1 w-72 bg-white text-gray-800 rounded-xl shadow-2xl border border-gray-100 py-1.5 z-10"
|
||||
>
|
||||
<p className="px-3 py-1 text-[9px] font-black uppercase tracking-widest text-gray-400">Número / grupo familiar</p>
|
||||
{numeros.map((n) => (
|
||||
<button
|
||||
key={n.phone}
|
||||
onClick={() => { setSelectedPhone(n.phone); setNumDropdownOpen(false); }}
|
||||
className={`w-full text-left px-3 py-2 hover:bg-gray-50 flex items-center gap-2.5 ${n.phone === selectedPhone ? 'bg-teal-50' : ''}`}
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-teal-50 flex items-center justify-center shrink-0">
|
||||
<User size={15} className="text-teal-600" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-black uppercase truncate">{n.nome}</p>
|
||||
<p className="text-[10px] text-gray-400 font-bold">
|
||||
<span className="text-teal-500">{n.relacao}</span> · {formatPhone(normalizeSendPhone(n.phone))}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
{numDropdownOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[5]" onClick={() => setNumDropdownOpen(false)} />
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -6 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -6 }}
|
||||
className="absolute left-0 top-full mt-1.5 w-72 bg-white text-gray-800 rounded-xl shadow-2xl border border-gray-100 py-1.5 z-10 overflow-hidden"
|
||||
>
|
||||
<p className="px-3 py-1.5 text-[9px] font-black uppercase tracking-widest text-gray-400 flex items-center gap-1.5">
|
||||
<Users size={11} className="text-teal-500" /> Grupo familiar
|
||||
</p>
|
||||
{numeros.map((n) => (
|
||||
<button
|
||||
key={n.phone}
|
||||
onClick={() => { setSelectedPhone(n.phone); setNumDropdownOpen(false); }}
|
||||
className={`w-full text-left px-3 py-2 hover:bg-gray-50 flex items-center gap-2.5 transition-colors ${n.phone === selectedPhone ? 'bg-teal-50' : ''}`}
|
||||
>
|
||||
<div className={`w-9 h-9 rounded-full flex items-center justify-center shrink-0 ${n.phone === selectedPhone ? 'bg-teal-500 text-white' : 'bg-teal-50 text-teal-600'}`}>
|
||||
<User size={16} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-black uppercase truncate">{n.nome}</p>
|
||||
<p className="text-[10px] text-gray-400 font-bold truncate">
|
||||
<span className="text-teal-500">{n.relacao}</span> · {formatPhone(normalizeSendPhone(n.phone))}
|
||||
</p>
|
||||
</div>
|
||||
{n.phone === selectedPhone && <span className="text-[9px] font-black text-teal-600 uppercase shrink-0">ativo</span>}
|
||||
</button>
|
||||
))}
|
||||
{numeros.length <= 1 && (
|
||||
<p className="px-3 pt-2 pb-1 mt-1 border-t border-gray-50 text-[10px] text-gray-400 leading-snug">
|
||||
Cadastre o <b className="text-gray-500">grupo familiar</b> no paciente (mãe, filho, cônjuge…) para trocar de conversa aqui e notificar sobre o paciente.
|
||||
</p>
|
||||
)}
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
@@ -378,9 +440,21 @@ const DrawerInner: React.FC<Omit<WhatsChatDrawerProps, 'isOpen'>> = ({ onClose,
|
||||
onSyncAvatar={handleSyncAvatar}
|
||||
isChatMenuOpen={isChatMenuOpen}
|
||||
isSyncingAvatar={isSyncingAvatar}
|
||||
onSearchClick={() => {}}
|
||||
isOwner={isWaOwner}
|
||||
onClearConversation={handleClearConversation}
|
||||
onManageLabels={() => setLabelModalOpen(true)}
|
||||
onSearchClick={() => setIsSearchOpen(true)}
|
||||
onToggleMenu={() => setIsChatMenuOpen(!isChatMenuOpen)}
|
||||
/>
|
||||
{isSearchOpen && (
|
||||
<ConversationSearch
|
||||
messages={messages as any}
|
||||
onClose={() => setIsSearchOpen(false)}
|
||||
onQueryChange={setSearchQuery}
|
||||
hasOlder={hasMoreHistory}
|
||||
loadOlder={async () => { const r = await handleLoadMoreHistory(); return !!r?.hasMore; }}
|
||||
/>
|
||||
)}
|
||||
<MessageArea
|
||||
messages={messages as any}
|
||||
selectedChat={selectedChat}
|
||||
@@ -388,14 +462,16 @@ const DrawerInner: React.FC<Omit<WhatsChatDrawerProps, 'isOpen'>> = ({ onClose,
|
||||
msgLoading={loadingMessages}
|
||||
onScroll={handleScroll}
|
||||
historyLoadingMore={loadingMore}
|
||||
highlightTerm={isSearchOpen ? searchQuery : ''}
|
||||
hasMoreHistory={hasMoreHistory}
|
||||
reactionPickerFor={reactionPickerFor}
|
||||
onReply={setReplyingTo}
|
||||
onToggleReactionPicker={toggleReactionPicker}
|
||||
onReact={handleReactToMessage}
|
||||
onForward={() => {}}
|
||||
onDelete={() => {}}
|
||||
onEdit={() => {}}
|
||||
onForward={(msg: any) => setForwardingMessage(msg)}
|
||||
onDelete={(msg: any) => { if (selectedChat && msg?.id) handleDeleteMessage(String(selectedChat.id), String(msg.id)); }}
|
||||
canDelete={canDeleteMsg}
|
||||
onEdit={handleEnterEdit}
|
||||
quickReactions={['👍', '❤️', '😂', '😮', '😢', '🙏']}
|
||||
getGroupSenderLabel={(msg: any) => {
|
||||
if (msg.direction === 'out') return 'Você';
|
||||
@@ -415,6 +491,8 @@ const DrawerInner: React.FC<Omit<WhatsChatDrawerProps, 'isOpen'>> = ({ onClose,
|
||||
onPresence={handleTyping}
|
||||
onCancelReply={() => setReplyingTo(null)}
|
||||
replyingTo={replyingTo}
|
||||
editingMessage={editingMessage}
|
||||
onCancelEdit={() => { setEditingMessage(null); setNewMessage(''); }}
|
||||
selectedChat={selectedChat}
|
||||
isMobile={true}
|
||||
onSendMedia={handleSendMedia}
|
||||
@@ -438,6 +516,24 @@ const DrawerInner: React.FC<Omit<WhatsChatDrawerProps, 'isOpen'>> = ({ onClose,
|
||||
)}
|
||||
</div>
|
||||
</motion.aside>
|
||||
<LabelManagerModal
|
||||
isOpen={labelModalOpen}
|
||||
onClose={() => setLabelModalOpen(false)}
|
||||
chat={selectedChat as any}
|
||||
onChanged={() => { if (activeInstance?.instance) useChatStore.getState().loadChats(activeInstance.instance); }}
|
||||
/>
|
||||
|
||||
<ForwardModal
|
||||
isOpen={!!forwardingMessage}
|
||||
onClose={() => setForwardingMessage(null)}
|
||||
chats={useChatStore.getState().chats as any}
|
||||
onForward={async (toChatIds: string[]) => {
|
||||
if (!forwardingMessage || !selectedChat?.id) return;
|
||||
try { await chatApi.forward(String(selectedChat.id), String((forwardingMessage as any).id), toChatIds); }
|
||||
catch (e) { console.error('Erro ao encaminhar:', e); }
|
||||
setForwardingMessage(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Bot } from 'lucide-react';
|
||||
import { handoffApi, HANDOFF_MINUTES } from '../services/chatApiService';
|
||||
import { socketService } from '../services/socketService';
|
||||
|
||||
interface AiPauseButtonProps {
|
||||
// jid do contato (o handoff é por conversa, chaveado pelo jid).
|
||||
jid: string | null | undefined;
|
||||
// instância atual — p/ saber o estado EFETIVO do liga/desliga (chat/sessão/global).
|
||||
instanceId?: string | null;
|
||||
}
|
||||
|
||||
const WINDOW_MS = HANDOFF_MINUTES * 60 * 1000;
|
||||
|
||||
// Botão do atendente (canto inferior direito) para PAUSAR/RETOMAR a Secretária IA.
|
||||
// Ao pausar, um contador simples aparece no centro; passados 15 min o motor reativa a
|
||||
// IA sozinho (e o evento de handoff volta o botão ao normal).
|
||||
const AiPauseButton: React.FC<AiPauseButtonProps> = ({ jid, instanceId }) => {
|
||||
const [mode, setMode] = useState<'ia' | 'humano'>('ia');
|
||||
const [humanAt, setHumanAt] = useState<number | null>(null);
|
||||
const [remaining, setRemaining] = useState<number>(0);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [secEnabled, setSecEnabled] = useState<boolean | null>(null); // liga/desliga efetivo
|
||||
const tickRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!jid) return;
|
||||
let alive = true;
|
||||
handoffApi.get(jid, instanceId).then((r) => {
|
||||
if (!alive) return;
|
||||
setMode(r.mode);
|
||||
setHumanAt(r.humanAt ? new Date(r.humanAt).getTime() : null);
|
||||
setSecEnabled(r.secEnabled);
|
||||
}).catch(() => {});
|
||||
return () => { alive = false; };
|
||||
}, [jid, instanceId]);
|
||||
|
||||
// Atualiza ao vivo se o motor mudar o modo (escalonamento / timeout de retorno).
|
||||
useEffect(() => {
|
||||
const onHandoff = (d: any) => {
|
||||
if (d?.mode !== 'ia' && d?.mode !== 'humano') return;
|
||||
setMode(d.mode);
|
||||
setHumanAt(d.mode === 'humano' ? (d.handoffHumanAt ? new Date(d.handoffHumanAt).getTime() : Date.now()) : null);
|
||||
};
|
||||
const onEscalated = () => { setMode('humano'); setHumanAt(Date.now()); };
|
||||
socketService.on('handoff', onHandoff);
|
||||
socketService.on('escalated', onEscalated);
|
||||
return () => { socketService.off?.('handoff', onHandoff); socketService.off?.('escalated', onEscalated); };
|
||||
}, []);
|
||||
|
||||
// Contador: roda 1x/s enquanto pausado; ao zerar, otimisticamente volta p/ IA.
|
||||
useEffect(() => {
|
||||
if (tickRef.current) { clearInterval(tickRef.current); tickRef.current = null; }
|
||||
if (mode !== 'humano' || !humanAt) { setRemaining(0); return; }
|
||||
const compute = () => {
|
||||
const left = WINDOW_MS - (Date.now() - humanAt);
|
||||
if (left <= 0) { setRemaining(0); setMode('ia'); setHumanAt(null); }
|
||||
else setRemaining(left);
|
||||
};
|
||||
compute();
|
||||
tickRef.current = setInterval(compute, 1000);
|
||||
return () => { if (tickRef.current) clearInterval(tickRef.current); };
|
||||
}, [mode, humanAt]);
|
||||
|
||||
const toggle = useCallback(async () => {
|
||||
if (!jid || busy) return;
|
||||
const next = mode === 'ia' ? 'humano' : 'ia';
|
||||
const prevMode = mode, prevAt = humanAt;
|
||||
setBusy(true);
|
||||
setMode(next); setHumanAt(next === 'humano' ? Date.now() : null); // otimista
|
||||
try { await handoffApi.set(jid, next); }
|
||||
catch { setMode(prevMode); setHumanAt(prevAt); }
|
||||
finally { setBusy(false); }
|
||||
}, [jid, mode, humanAt, busy]);
|
||||
|
||||
if (!jid) return null;
|
||||
const paused = mode === 'humano';
|
||||
const mmss = () => {
|
||||
const t = Math.max(0, Math.ceil(remaining / 1000));
|
||||
return `${String(Math.floor(t / 60)).padStart(2, '0')}:${String(t % 60).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// Secretária DESLIGADA p/ este chat (sessão/global/override): não faz sentido
|
||||
// mostrar "IA ativa" (laranja) nem oferecer pausa — ela já não responde. Botão
|
||||
// fica apagado/cinza e informa. Só o dono religa (cabeçalho do chat / power).
|
||||
if (secEnabled === false) {
|
||||
return (
|
||||
<button
|
||||
disabled
|
||||
title="Secretária IA desligada neste chat — ligue no cabeçalho do chat ou no botão de energia"
|
||||
className="absolute bottom-6 right-6 z-30 w-11 h-11 rounded-full shadow-lg flex items-center justify-center border bg-white text-gray-300 border-[#d1d7db] cursor-not-allowed opacity-80"
|
||||
>
|
||||
<Bot className="w-5 h-5" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Contador central (só quando pausado) */}
|
||||
{paused && remaining > 0 && (
|
||||
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-30 flex items-center gap-2 bg-[#f59e0b]/95 text-white px-3.5 py-1.5 rounded-full shadow-lg text-[13px] font-bold pointer-events-none">
|
||||
<Bot className="w-4 h-4" />
|
||||
IA pausada · {mmss()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Botão robô: laranja = IA ativa; cinza = pausada */}
|
||||
<button
|
||||
onClick={toggle}
|
||||
disabled={busy}
|
||||
title={paused ? `Secretária IA pausada (${mmss()}) — clique para retomar` : 'Pausar a Secretária IA por 15 min (assumir a conversa)'}
|
||||
className={`absolute bottom-6 right-6 z-30 w-11 h-11 rounded-full shadow-lg flex items-center justify-center border transition-all active:scale-90 disabled:opacity-60 ${
|
||||
paused
|
||||
? 'bg-white text-[#8696a0] border-[#d1d7db] hover:text-[#54656f] hover:bg-[#f0f2f5]'
|
||||
: 'bg-[#f59e0b] text-white border-[#f59e0b] hover:bg-[#d97706]'
|
||||
}`}
|
||||
>
|
||||
<Bot className="w-5 h-5" />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AiPauseButton;
|
||||
@@ -193,13 +193,27 @@ export default function ChatListItem({
|
||||
<div className="flex-1 min-w-0 flex flex-col justify-center py-0.5 pr-1 text-left">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-[16px] leading-[21px] font-normal text-[#111b21] truncate">
|
||||
{chat.displayName || chat.subject || chat.phone}
|
||||
{(chat as any).nomeNosso || chat.displayName || chat.subject || chat.phone}
|
||||
</h3>
|
||||
<span className={`text-[12px] leading-[14px] flex-shrink-0 ml-2 ${chat.unread_count > 0 ? 'text-[#00a884] font-medium' : 'text-[#667781]'}`}>
|
||||
{relativeTime}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{((chat as any).pendenteSec || (Array.isArray((chat as any).labels) && (chat as any).labels.length > 0)) && (
|
||||
<div className="flex items-center gap-1 mt-0.5 flex-wrap">
|
||||
{/* Selo da fila da Secretária: há pedido aguardando a humana confirmar */}
|
||||
{(chat as any).pendenteSec && (
|
||||
<span className="text-[10px] font-black uppercase tracking-wide px-1.5 py-[1px] rounded-full bg-amber-500 text-white flex items-center gap-0.5">
|
||||
<Clock className="w-2.5 h-2.5" /> Pendente
|
||||
</span>
|
||||
)}
|
||||
{Array.isArray((chat as any).labels) && (chat as any).labels.slice(0, 3).map((l: any) => (
|
||||
<span key={l.id} className="text-[10px] font-medium px-1.5 py-[1px] rounded-full text-white truncate max-w-[100px]" style={{ backgroundColor: l.color }}>{l.name}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between mt-1 h-5">
|
||||
<div className="flex items-center gap-1 flex-1 min-w-0">
|
||||
{(() => {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// Liga/desliga a Secretária IA SÓ NESTE CHAT (override do dono, precedência máxima:
|
||||
// vence a sessão e o global). Botão no header do chat + popover com 3 estados.
|
||||
// Só o dono vê/opera (canPower). Persiste até mudar; "Herdar" remove o override.
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Bot, Check, Loader2, Power, PowerOff, CornerUpLeft } from 'lucide-react';
|
||||
import { secChatPowerApi, ChatPower } from '../services/secretariaApi';
|
||||
|
||||
const byName = (() => {
|
||||
try { return JSON.parse(localStorage.getItem('SCOREODONTO_USER_DATA') || '{}')?.nome || 'Dono'; }
|
||||
catch { return 'Dono'; }
|
||||
})();
|
||||
|
||||
export const ChatSecretariaPower: React.FC<{ instanceId: string | null; jid: string | null; canPower?: boolean }> = ({ instanceId, jid, canPower }) => {
|
||||
const [cp, setCp] = useState<ChatPower | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const carregar = useCallback(async () => {
|
||||
if (!instanceId || !jid) return;
|
||||
try { setCp(await secChatPowerApi.get(instanceId, jid)); } catch { /* silencioso */ }
|
||||
}, [instanceId, jid]);
|
||||
|
||||
useEffect(() => { setCp(null); carregar(); }, [carregar]);
|
||||
|
||||
// Fecha o popover ao clicar fora.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const h = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); };
|
||||
document.addEventListener('mousedown', h);
|
||||
return () => document.removeEventListener('mousedown', h);
|
||||
}, [open]);
|
||||
|
||||
if (!instanceId || !jid || !canPower) return null;
|
||||
|
||||
const overridden = !!cp?.overridden;
|
||||
const enabled = cp?.enabled;
|
||||
// Cor do ícone: verde = ligada só aqui; vermelho = desligada só aqui; neutro = herda.
|
||||
const tone = !overridden ? 'text-[#54656f]' : enabled ? 'text-emerald-600' : 'text-red-600';
|
||||
|
||||
const aplicar = async (val: boolean | null) => {
|
||||
if (!instanceId || !jid) return;
|
||||
setBusy(true);
|
||||
try { await secChatPowerApi.set(instanceId, jid, val, byName); await carregar(); setOpen(false); }
|
||||
catch { /* mantém aberto */ } finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const Opt = ({ icon, label, active, danger, onClick }: { icon: React.ReactNode; label: string; active: boolean; danger?: boolean; onClick: () => void }) => (
|
||||
<button onClick={onClick} disabled={busy}
|
||||
className={`w-full flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium text-left hover:bg-[#f5f6f6] transition-colors disabled:opacity-50 ${danger ? 'text-red-600' : 'text-[#111b21]'}`}>
|
||||
<span className="shrink-0">{icon}</span>
|
||||
<span className="flex-1">{label}</span>
|
||||
{active && <Check size={14} className="text-emerald-600 shrink-0" />}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
onClick={() => setOpen(o => !o)}
|
||||
title="Secretária IA neste chat"
|
||||
className={`relative p-2 rounded-full transition-all hover:bg-black/5 ${open ? 'bg-black/10' : ''} ${tone}`}
|
||||
>
|
||||
<Bot className="w-5 h-5" />
|
||||
{overridden && <span className={`absolute top-1.5 right-1.5 w-2 h-2 rounded-full border border-white ${enabled ? 'bg-emerald-500' : 'bg-red-500'}`} />}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full mt-1 bg-white shadow-2xl rounded-xl border border-gray-100 w-60 z-50 py-1.5 overflow-hidden">
|
||||
<p className="px-3 pt-1 pb-1.5 text-[10px] font-black uppercase tracking-widest text-gray-400">Secretária neste chat</p>
|
||||
{busy && <div className="px-3 py-2 flex items-center gap-2 text-xs text-gray-400"><Loader2 size={13} className="animate-spin" /> aplicando…</div>}
|
||||
<Opt icon={<Power size={15} className="text-emerald-600" />} label="Ligar só neste chat" active={overridden && enabled === true} onClick={() => aplicar(true)} />
|
||||
<Opt icon={<PowerOff size={15} className="text-red-600" />} label="Desligar só neste chat" active={overridden && enabled === false} danger onClick={() => aplicar(false)} />
|
||||
{overridden && (
|
||||
<>
|
||||
<div className="my-1 border-t border-gray-100" />
|
||||
<Opt icon={<CornerUpLeft size={15} className="text-gray-400" />} label="Herdar padrão (global/sessão)" active={false} onClick={() => aplicar(null)} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -28,6 +28,7 @@ interface ContactProfileProps {
|
||||
chat: any;
|
||||
messages: Message[];
|
||||
onClose: () => void;
|
||||
nomeNosso?: string; // nome da NOSSA base (paciente) — precede o pushname do WhatsApp
|
||||
}
|
||||
|
||||
const ContactProfile: React.FC<ContactProfileProps> = ({
|
||||
@@ -35,6 +36,7 @@ const ContactProfile: React.FC<ContactProfileProps> = ({
|
||||
chat,
|
||||
messages,
|
||||
onClose,
|
||||
nomeNosso,
|
||||
}) => {
|
||||
// Filter media messages
|
||||
const mediaMessages = useMemo(() => {
|
||||
@@ -75,7 +77,7 @@ const ContactProfile: React.FC<ContactProfileProps> = ({
|
||||
{/* Profile Photo */}
|
||||
<Avatar chat={chat} size={200} className="mb-5 shadow-lg" />
|
||||
<h1 className="text-[24px] font-normal text-[#111b21] mb-1 text-center">
|
||||
{chat.lead_name || chat.subject || formatPhone(chat.phone, chat.remote_jid)}
|
||||
{nomeNosso || chat.lead_name || chat.subject || formatPhone(chat.phone, chat.remote_jid)}
|
||||
</h1>
|
||||
<p className="text-[16px] text-[#667781] font-normal mb-1">
|
||||
{formatPhone(chat.phone, chat.remote_jid)}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Search, ChevronUp, ChevronDown, X, Loader2 } from 'lucide-react';
|
||||
|
||||
interface Msg { message_id?: string; id?: string | number; text?: string; transcription?: string | null }
|
||||
|
||||
// Barra de busca DENTRO da conversa: filtra as mensagens por texto (corpo +
|
||||
// transcrição), rola até o resultado e o realça. Navega com ↑/↓ (ou Enter/Shift+
|
||||
// Enter). Se o termo não estiver nas mensagens já carregadas, CARREGA o histórico
|
||||
// antigo automaticamente (uma página por vez, até achar ou acabar). Usa o id do DOM
|
||||
// já existente na bolha (id="msg-<message_id>").
|
||||
const MAX_PAGES = 40; // teto de páginas antigas a carregar por busca (~evita runaway)
|
||||
|
||||
const ConversationSearch: React.FC<{
|
||||
messages: Msg[];
|
||||
onClose: () => void;
|
||||
loadOlder?: () => Promise<boolean>; // carrega 1 página antiga; retorna se ainda há mais
|
||||
hasOlder?: boolean;
|
||||
onQueryChange?: (q: string) => void; // avisa o pai p/ realçar o termo nas bolhas
|
||||
}> = ({ messages, onClose, loadOlder, hasOlder, onQueryChange }) => {
|
||||
const [q, setQ] = useState('');
|
||||
const [idx, setIdx] = useState(0);
|
||||
const [autoLoading, setAutoLoading] = useState(false);
|
||||
const [pagesLoaded, setPagesLoaded] = useState(0);
|
||||
const loadingRef = useRef(false);
|
||||
|
||||
const matches = useMemo(() => {
|
||||
const t = q.trim().toLowerCase();
|
||||
if (!t) return [] as string[];
|
||||
return messages
|
||||
.filter((m) => `${m.text ?? ''} ${m.transcription ?? ''}`.toLowerCase().includes(t))
|
||||
.map((m) => String(m.message_id ?? m.id ?? ''))
|
||||
.filter(Boolean);
|
||||
}, [q, messages]);
|
||||
|
||||
// Reinicia navegação e o contador de páginas ao mudar o termo; avisa o pai.
|
||||
useEffect(() => { setIdx(0); setPagesLoaded(0); onQueryChange?.(q); }, [q]);
|
||||
// Ao desmontar (fechar busca), limpa o realce.
|
||||
useEffect(() => () => onQueryChange?.(''), []);
|
||||
|
||||
// Sem resultados nas mensagens carregadas → puxa mais histórico antigo (1 página
|
||||
// por vez; o React recomputa `matches` e o efeito re-roda até achar ou acabar).
|
||||
useEffect(() => {
|
||||
if (!q.trim() || !loadOlder || !hasOlder || loadingRef.current) return;
|
||||
if (matches.length > 0 || pagesLoaded >= MAX_PAGES) return;
|
||||
loadingRef.current = true;
|
||||
setAutoLoading(true);
|
||||
let cancelled = false;
|
||||
loadOlder()
|
||||
.then(() => { if (!cancelled) setPagesLoaded((p) => p + 1); })
|
||||
.finally(() => { if (!cancelled) { loadingRef.current = false; setAutoLoading(false); } });
|
||||
return () => { cancelled = true; loadingRef.current = false; };
|
||||
}, [q, matches.length, hasOlder, pagesLoaded, loadOlder]);
|
||||
|
||||
// Rola até o resultado atual e o realça brevemente.
|
||||
useEffect(() => {
|
||||
if (!matches.length) return;
|
||||
const id = matches[Math.min(idx, matches.length - 1)];
|
||||
const el = document.getElementById(`msg-${id}`);
|
||||
if (!el) return;
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
el.classList.add('bg-[#00a884]/15', 'rounded-xl', 'transition-colors');
|
||||
const t = setTimeout(() => el.classList.remove('bg-[#00a884]/15', 'rounded-xl'), 1400);
|
||||
return () => clearTimeout(t);
|
||||
}, [idx, matches]);
|
||||
|
||||
const go = (d: number) => { if (matches.length) setIdx((i) => (i + d + matches.length) % matches.length); };
|
||||
|
||||
// Texto do contador: buscando / n de N / nenhum.
|
||||
const counter = !q.trim()
|
||||
? ''
|
||||
: matches.length
|
||||
? `${idx + 1}/${matches.length}`
|
||||
: (autoLoading ? '' : '0');
|
||||
|
||||
return (
|
||||
<div className="shrink-0 flex items-center gap-2 px-3 py-2 bg-[#f0f2f5] border-b border-[#d1d7db] z-20">
|
||||
<Search className="w-4 h-4 text-[#54656f] shrink-0" />
|
||||
<input
|
||||
autoFocus
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') go(e.shiftKey ? -1 : 1);
|
||||
if (e.key === 'Escape') onClose();
|
||||
}}
|
||||
placeholder="Buscar na conversa..."
|
||||
className="flex-1 min-w-0 bg-transparent outline-none text-sm text-[#111b21] placeholder:text-[#8696a0]"
|
||||
/>
|
||||
{autoLoading && !matches.length && (
|
||||
<span className="flex items-center gap-1 text-[10px] text-[#667781] font-bold shrink-0">
|
||||
<Loader2 className="w-3 h-3 animate-spin" /> antigas…
|
||||
</span>
|
||||
)}
|
||||
{counter && <span className="text-[11px] text-[#667781] font-bold shrink-0 tabular-nums">{counter}</span>}
|
||||
<button onClick={() => go(-1)} disabled={!matches.length} title="Anterior"
|
||||
className="p-1 rounded-full hover:bg-black/10 disabled:opacity-40 text-[#54656f] transition-colors"><ChevronUp className="w-4 h-4" /></button>
|
||||
<button onClick={() => go(1)} disabled={!matches.length} title="Próximo"
|
||||
className="p-1 rounded-full hover:bg-black/10 disabled:opacity-40 text-[#54656f] transition-colors"><ChevronDown className="w-4 h-4" /></button>
|
||||
<button onClick={onClose} title="Fechar busca"
|
||||
className="p-1 rounded-full hover:bg-black/10 text-[#54656f] transition-colors"><X className="w-4 h-4" /></button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConversationSearch;
|
||||
@@ -0,0 +1,103 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { X, Search, Send, Check } from 'lucide-react';
|
||||
|
||||
interface ForwardModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
chats: any[];
|
||||
onForward: (toChatIds: string[]) => Promise<void> | void;
|
||||
}
|
||||
|
||||
// Modal para escolher um ou mais chats de destino e encaminhar a mensagem selecionada.
|
||||
const ForwardModal: React.FC<ForwardModalProps> = ({ isOpen, onClose, chats, onForward }) => {
|
||||
const [query, setQuery] = useState('');
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
const label = (c: any) => c.displayName || c.subject || c.name || c.phone || c.remote_jid || String(c.id);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return chats;
|
||||
return chats.filter((c) => label(c).toLowerCase().includes(q));
|
||||
}, [chats, query]);
|
||||
|
||||
if (!isOpen || typeof document === 'undefined') return null;
|
||||
|
||||
const toggle = (id: string) => {
|
||||
setSelected((prev) => {
|
||||
const n = new Set(prev);
|
||||
n.has(id) ? n.delete(id) : n.add(id);
|
||||
return n;
|
||||
});
|
||||
};
|
||||
|
||||
const handleForward = async () => {
|
||||
if (selected.size === 0 || sending) return;
|
||||
setSending(true);
|
||||
try { await onForward(Array.from(selected)); }
|
||||
finally { setSending(false); setSelected(new Set()); setQuery(''); }
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 backdrop-blur-sm p-4" onClick={onClose}>
|
||||
<div className="w-full max-w-md bg-white rounded-2xl shadow-2xl overflow-hidden flex flex-col max-h-[80vh]" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100">
|
||||
<h3 className="text-[15px] font-bold text-[#111b21]">Encaminhar para…</h3>
|
||||
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-700 rounded-full transition-colors"><X className="w-5 h-5" /></button>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-2.5 border-b border-gray-100">
|
||||
<div className="flex items-center gap-2 bg-[#f0f2f5] rounded-lg px-3 py-2">
|
||||
<Search className="w-4 h-4 text-[#667781] shrink-0" />
|
||||
<input
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Pesquisar conversa"
|
||||
className="w-full bg-transparent text-[14px] text-[#111b21] placeholder:text-[#667781] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{filtered.length === 0 && (
|
||||
<div className="px-4 py-6 text-center text-[13px] text-[#667781]">Nenhuma conversa encontrada.</div>
|
||||
)}
|
||||
{filtered.map((c) => {
|
||||
const id = String(c.id);
|
||||
const isSel = selected.has(id);
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => toggle(id)}
|
||||
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-[#f5f6f6] transition-colors text-left"
|
||||
>
|
||||
<div className={`w-5 h-5 rounded-full border-2 flex items-center justify-center shrink-0 transition-colors ${isSel ? 'bg-[#00a884] border-[#00a884]' : 'border-gray-300'}`}>
|
||||
{isSel && <Check className="w-3 h-3 text-white" />}
|
||||
</div>
|
||||
<span className="text-[14px] text-[#111b21] truncate">{label(c)}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3 border-t border-gray-100 flex items-center justify-between">
|
||||
<span className="text-[12px] text-[#667781]">{selected.size} selecionada(s)</span>
|
||||
<button
|
||||
onClick={handleForward}
|
||||
disabled={selected.size === 0 || sending}
|
||||
className="flex items-center gap-2 bg-[#00a884] text-white text-[14px] font-bold px-4 py-2 rounded-lg disabled:opacity-40 hover:bg-[#008f6f] transition-colors"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
{sending ? 'Encaminhando…' : 'Encaminhar'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
};
|
||||
|
||||
export default ForwardModal;
|
||||
@@ -9,12 +9,15 @@ import {
|
||||
Tag,
|
||||
X,
|
||||
ClipboardList,
|
||||
CalendarClock,
|
||||
CalendarDays,
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
formatPhone,
|
||||
} from '../utils/inboxUtils';
|
||||
import Avatar from './ui/Avatar';
|
||||
import { ChatSecretariaPower } from './ChatSecretariaPower';
|
||||
|
||||
interface InboxHeaderProps {
|
||||
selectedChat: any;
|
||||
@@ -24,6 +27,14 @@ interface InboxHeaderProps {
|
||||
isSyncingAvatar: boolean;
|
||||
isProtocolOpen?: boolean;
|
||||
hasActiveProtocol?: boolean;
|
||||
isOwner?: boolean; // só o dono do workspace vê "Limpar conversa" (ação destrutiva). Fail-closed.
|
||||
nomeNosso?: string; // nome da NOSSA base (paciente) — precede o pushname do WhatsApp
|
||||
activeInstanceId?: string | null; // instância atual (p/ override da Secretária por chat)
|
||||
pedidosCount?: number; // badge do botão de fila (0 = sem badge)
|
||||
onOpenPedidos?: () => void; // abre o modal "Pedidos aguardando confirmação"
|
||||
onOpenAgenda?: () => void; // abre a agenda (overlay) sem sair do inbox
|
||||
onClearConversation?: () => void;
|
||||
onManageLabels?: () => void;
|
||||
onBack: () => void;
|
||||
onSearchClick: () => void;
|
||||
onToggleMenu: () => void;
|
||||
@@ -40,6 +51,14 @@ const InboxHeader: React.FC<InboxHeaderProps> = ({
|
||||
isSyncingAvatar,
|
||||
isProtocolOpen,
|
||||
hasActiveProtocol,
|
||||
isOwner,
|
||||
nomeNosso,
|
||||
activeInstanceId,
|
||||
pedidosCount = 0,
|
||||
onOpenPedidos,
|
||||
onOpenAgenda,
|
||||
onClearConversation,
|
||||
onManageLabels,
|
||||
onBack,
|
||||
onSearchClick,
|
||||
onToggleMenu,
|
||||
@@ -69,7 +88,7 @@ const InboxHeader: React.FC<InboxHeaderProps> = ({
|
||||
|
||||
<div className="flex flex-col min-w-0">
|
||||
<h3 className="text-[16px] leading-[21px] font-bold text-[#111b21] flex items-center gap-2 truncate group-hover/header:text-[#00a884] transition-colors">
|
||||
{selectedChat.lead_name || selectedChat.subject || formatPhone(selectedChat.phone, selectedChat.remote_jid)}
|
||||
{nomeNosso || selectedChat.lead_name || selectedChat.subject || formatPhone(selectedChat.phone, selectedChat.remote_jid)}
|
||||
</h3>
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
{isTyping ? (
|
||||
@@ -91,6 +110,33 @@ const InboxHeader: React.FC<InboxHeaderProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Secretária IA só neste chat (override do dono, precedência máxima) */}
|
||||
<ChatSecretariaPower instanceId={activeInstanceId ?? null} jid={selectedChat?.remote_jid ?? null} canPower={isOwner} />
|
||||
{/* Agenda — abre a agenda (overlay) p/ conferir dias/horários vagos sem sair do chat */}
|
||||
{onOpenAgenda && (
|
||||
<button
|
||||
onClick={onOpenAgenda}
|
||||
className="p-2 rounded-full transition-all text-[#54656f] hover:bg-black/5 hover:text-[#128C7E]"
|
||||
title="Abrir agenda"
|
||||
>
|
||||
<CalendarDays className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
{/* Fila da Secretária — modal "Pedidos aguardando confirmação" (mesmo da agenda) */}
|
||||
{onOpenPedidos && (
|
||||
<button
|
||||
onClick={onOpenPedidos}
|
||||
className="relative p-2 rounded-full transition-all text-[#54656f] hover:bg-black/5 hover:text-amber-600"
|
||||
title="Pedidos aguardando confirmação"
|
||||
>
|
||||
<CalendarClock className="w-5 h-5" />
|
||||
{pedidosCount > 0 && (
|
||||
<span className="absolute top-1 right-1 min-w-[15px] h-[15px] px-1 rounded-full bg-amber-500 text-white text-[9px] font-black flex items-center justify-center pointer-events-none">
|
||||
{pedidosCount > 99 ? '99+' : pedidosCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{onProtocolClick && (
|
||||
<button
|
||||
onClick={onProtocolClick}
|
||||
@@ -121,6 +167,9 @@ const InboxHeader: React.FC<InboxHeaderProps> = ({
|
||||
|
||||
<AnimatePresence>
|
||||
{isChatMenuOpen && (
|
||||
<>
|
||||
{/* Backdrop: fecha o menu ao clicar fora */}
|
||||
<div className="fixed inset-0 z-40" onClick={onToggleMenu} />
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
@@ -138,16 +187,29 @@ const InboxHeader: React.FC<InboxHeaderProps> = ({
|
||||
<RefreshCw className={`w-4 h-4 ${isSyncingAvatar ? 'animate-spin' : ''}`} />
|
||||
Sincronizar Foto
|
||||
</button>
|
||||
<button className="w-full flex items-center gap-3 px-4 py-2.5 text-[14px] text-[#111b21] hover:bg-[#f5f6f6] transition-colors">
|
||||
<button
|
||||
onClick={() => { onToggleMenu(); onManageLabels?.(); }}
|
||||
className="w-full flex items-center gap-3 px-4 py-2.5 text-[14px] text-[#111b21] hover:bg-[#f5f6f6] transition-colors"
|
||||
>
|
||||
<Tag className="w-4 h-4" />
|
||||
Gerenciar etiquetas
|
||||
</button>
|
||||
<div className="border-t border-gray-100 my-1"></div>
|
||||
<button className="w-full flex items-center gap-3 px-4 py-2.5 text-[14px] font-bold text-red-600 hover:bg-red-50 transition-colors">
|
||||
<X className="w-4 h-4" />
|
||||
Limpar conversa
|
||||
</button>
|
||||
{/* Ação destrutiva — só o dono do workspace (dentista/protético/
|
||||
biomédico dono da clínica/consultório). Secretária/agente não vê. */}
|
||||
{isOwner && (
|
||||
<>
|
||||
<div className="border-t border-gray-100 my-1"></div>
|
||||
<button
|
||||
onClick={() => { onToggleMenu(); onClearConversation?.(); }}
|
||||
className="w-full flex items-center gap-3 px-4 py-2.5 text-[14px] font-bold text-red-600 hover:bg-red-50 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
Limpar conversa
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
@@ -43,6 +43,8 @@ interface InputBarProps {
|
||||
onSendAudio: (blob: Blob) => Promise<void>;
|
||||
replyingTo: Message | null;
|
||||
onCancelReply: () => void;
|
||||
editingMessage?: Message | null;
|
||||
onCancelEdit?: () => void;
|
||||
isMobile: boolean;
|
||||
selectedChat: any;
|
||||
instanceId?: string | null;
|
||||
@@ -59,6 +61,8 @@ const InputBar: React.FC<InputBarProps> = ({
|
||||
onSendAudio,
|
||||
replyingTo,
|
||||
onCancelReply,
|
||||
editingMessage,
|
||||
onCancelEdit,
|
||||
isMobile,
|
||||
selectedChat,
|
||||
instanceId,
|
||||
@@ -205,10 +209,11 @@ const InputBar: React.FC<InputBarProps> = ({
|
||||
}, []);
|
||||
|
||||
const handleSendSticker = useCallback(async (blob: Blob) => {
|
||||
if (!instanceId || !selectedChat?.remote_jid) return;
|
||||
if (!selectedChat?.id) return;
|
||||
setShowStickerPanel(false);
|
||||
await mediaApi.sendSticker(instanceId, selectedChat.remote_jid, blob);
|
||||
}, [instanceId, selectedChat]);
|
||||
// mediaApi do satélite é por chatId (não instanceId/jid).
|
||||
await mediaApi.sendSticker(String(selectedChat.id), blob);
|
||||
}, [selectedChat]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
// Mention dropdown navigation
|
||||
@@ -405,6 +410,32 @@ const InputBar: React.FC<InputBarProps> = ({
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Edit banner */}
|
||||
<AnimatePresence>
|
||||
{editingMessage && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 20 }}
|
||||
className="mx-4 mb-2 bg-white/80 backdrop-blur-sm border-l-[4px] border-[#f59e0b] rounded-lg px-4 py-3 flex items-start justify-between gap-4 shadow-sm"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[12px] font-medium text-[#b45309] mb-1">Editando mensagem</div>
|
||||
<div className="text-sm text-gray-500 font-medium truncate italic">
|
||||
{editingMessage.text || 'Mensagem'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="bg-gray-100 hover:bg-gray-200 text-gray-500 p-1 rounded-full transition-colors"
|
||||
onClick={onCancelEdit}
|
||||
>
|
||||
<X className="w-3.5 h-3.5 stroke-[3px]" />
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Reply Preview */}
|
||||
<AnimatePresence>
|
||||
{replyingTo && (
|
||||
@@ -513,30 +544,36 @@ const InputBar: React.FC<InputBarProps> = ({
|
||||
Mensagem Rica
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Emoji */}
|
||||
<button
|
||||
onClick={() => { setShowEmojiPicker(true); setShowStickerPanel(false); setShowAttachMenu(false); }}
|
||||
className="p-2.5 hover:bg-[#f0f2f5] rounded-xl transition-all text-[#54656f] active:scale-95 group relative"
|
||||
title="Emojis"
|
||||
>
|
||||
<Smile className="w-[22px] h-[22px]" />
|
||||
<span className="absolute left-full ml-2 whitespace-nowrap text-xs bg-[#111b21] text-white px-2 py-1 rounded-lg opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity">
|
||||
Emoji
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Figurinhas */}
|
||||
{instanceId && (
|
||||
<button
|
||||
onClick={() => { setShowStickerPanel(true); setShowEmojiPicker(false); setShowAttachMenu(false); }}
|
||||
className="p-2.5 hover:bg-[#f0f2f5] rounded-xl transition-all text-[#54656f] active:scale-95 group relative"
|
||||
title="Figurinhas"
|
||||
>
|
||||
<Sticker className="w-[22px] h-[22px]" />
|
||||
<span className="absolute left-full ml-2 whitespace-nowrap text-xs bg-[#111b21] text-white px-2 py-1 rounded-lg opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity">
|
||||
Figurinhas
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Emoji — mantém no lugar */}
|
||||
<button
|
||||
onClick={() => { setShowEmojiPicker(!showEmojiPicker); setShowStickerPanel(false); }}
|
||||
className={`p-2 hover:bg-black/10 rounded-full transition-all active:scale-95 ${showEmojiPicker ? 'text-[#00a884]' : 'text-[#54656f]'}`}
|
||||
title="Emojis"
|
||||
>
|
||||
<Smile className="w-[26px] h-[26px]" />
|
||||
</button>
|
||||
|
||||
{/* Figurinhas */}
|
||||
{instanceId && (
|
||||
<button
|
||||
onClick={() => { setShowStickerPanel(!showStickerPanel); setShowEmojiPicker(false); }}
|
||||
className={`p-2 hover:bg-black/10 rounded-full transition-all active:scale-95 ${showStickerPanel ? 'text-[#00a884]' : 'text-[#54656f]'}`}
|
||||
title="Figurinhas"
|
||||
>
|
||||
<Sticker className="w-[24px] h-[24px]" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 relative">
|
||||
@@ -589,7 +626,7 @@ const InputBar: React.FC<InputBarProps> = ({
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
placeholder="Digite uma mensagem"
|
||||
className="w-full px-3 py-2.5 bg-white rounded-lg text-[15px] text-[#111b21] border-none focus:ring-0 focus:outline-none placeholder:text-[#667781] resize-none shadow-sm transition-all overflow-hidden leading-[1.4]"
|
||||
className="w-full px-3.5 py-3 bg-white rounded-xl text-[15px] text-[#111b21] border-none focus:ring-0 focus:outline-none placeholder:text-[#667781] resize-none shadow-sm transition-all overflow-hidden leading-[1.4]"
|
||||
value={newMessage}
|
||||
onChange={(e) => {
|
||||
onNewMessageChange(e.target.value);
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { X, Plus, Trash2, Check, Tag } from 'lucide-react';
|
||||
import { labelApi, type Label } from '../services/chatApiService';
|
||||
|
||||
interface LabelManagerModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
// Se fornecido, mostra atribuição de etiquetas para ESTA conversa.
|
||||
chat?: { id: any; labels?: Label[] } | null;
|
||||
// Chamado após qualquer mudança (para o inbox recarregar os chips).
|
||||
onChanged?: () => void;
|
||||
}
|
||||
|
||||
const PALETTE = ['#00a884', '#ef4444', '#f59e0b', '#3b82f6', '#8b5cf6', '#ec4899', '#14b8a6', '#64748b'];
|
||||
|
||||
const LabelManagerModal: React.FC<LabelManagerModalProps> = ({ isOpen, onClose, chat, onChanged }) => {
|
||||
const [labels, setLabels] = useState<Label[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newColor, setNewColor] = useState(PALETTE[0]);
|
||||
const [assigned, setAssigned] = useState<Set<string>>(new Set());
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const list = await labelApi.list();
|
||||
setLabels(Array.isArray(list) ? list : []);
|
||||
} catch { /* ignore */ } finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
load();
|
||||
setAssigned(new Set((chat?.labels ?? []).map((l) => l.id)));
|
||||
}, [isOpen, chat, load]);
|
||||
|
||||
if (!isOpen || typeof document === 'undefined') return null;
|
||||
|
||||
const create = async () => {
|
||||
if (!newName.trim()) return;
|
||||
try {
|
||||
await labelApi.create(newName.trim(), newColor);
|
||||
setNewName('');
|
||||
await load();
|
||||
onChanged?.();
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
const remove = async (id: string) => {
|
||||
try { await labelApi.delete(id); setAssigned((s) => { const n = new Set(s); n.delete(id); return n; }); await load(); onChanged?.(); }
|
||||
catch { /* ignore */ }
|
||||
};
|
||||
|
||||
const toggleAssign = (id: string) => {
|
||||
setAssigned((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
|
||||
};
|
||||
|
||||
const saveAssignments = async () => {
|
||||
if (!chat?.id || saving) return;
|
||||
setSaving(true);
|
||||
try { await labelApi.setForChat(String(chat.id), Array.from(assigned)); onChanged?.(); onClose(); }
|
||||
catch { /* ignore */ } finally { setSaving(false); }
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 backdrop-blur-sm p-4" onClick={onClose}>
|
||||
<div className="w-full max-w-md bg-white rounded-2xl shadow-2xl overflow-hidden flex flex-col max-h-[85vh]" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100">
|
||||
<h3 className="text-[15px] font-bold text-[#111b21] flex items-center gap-2"><Tag className="w-4 h-4 text-[#00a884]" /> Etiquetas</h3>
|
||||
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-700 rounded-full transition-colors"><X className="w-5 h-5" /></button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* Atribuição à conversa */}
|
||||
{chat && (
|
||||
<div className="px-4 py-3 border-b border-gray-100">
|
||||
<div className="text-[12px] font-bold text-[#667781] uppercase tracking-wide mb-2">Nesta conversa</div>
|
||||
{labels.length === 0 && <div className="text-[13px] text-[#667781]">Nenhuma etiqueta criada ainda.</div>}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{labels.map((l) => {
|
||||
const on = assigned.has(l.id);
|
||||
return (
|
||||
<button key={l.id} onClick={() => toggleAssign(l.id)}
|
||||
className={`flex items-center gap-1.5 pl-2 pr-2.5 py-1 rounded-full text-[13px] font-medium border transition-all ${on ? 'text-white' : 'text-[#111b21] bg-white'}`}
|
||||
style={on ? { backgroundColor: l.color, borderColor: l.color } : { borderColor: l.color }}>
|
||||
{on && <Check className="w-3.5 h-3.5" />}
|
||||
<span style={!on ? { color: l.color } : undefined}>{l.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lista/gestão */}
|
||||
<div className="px-4 py-3">
|
||||
<div className="text-[12px] font-bold text-[#667781] uppercase tracking-wide mb-2">Todas as etiquetas</div>
|
||||
{loading && <div className="text-[13px] text-[#667781]">Carregando…</div>}
|
||||
<div className="space-y-1.5">
|
||||
{labels.map((l) => (
|
||||
<div key={l.id} className="flex items-center gap-2.5 py-1">
|
||||
<span className="w-3.5 h-3.5 rounded-full shrink-0" style={{ backgroundColor: l.color }} />
|
||||
<span className="flex-1 text-[14px] text-[#111b21] truncate">{l.name}</span>
|
||||
<button onClick={() => remove(l.id)} className="p-1 text-gray-300 hover:text-red-500 transition-colors"><Trash2 className="w-4 h-4" /></button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Criar */}
|
||||
<div className="px-4 py-3 border-t border-gray-100">
|
||||
<div className="text-[12px] font-bold text-[#667781] uppercase tracking-wide mb-2">Nova etiqueta</div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<input value={newName} onChange={(e) => setNewName(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && create()}
|
||||
placeholder="Nome da etiqueta"
|
||||
className="flex-1 bg-[#f0f2f5] rounded-lg px-3 py-2 text-[14px] text-[#111b21] placeholder:text-[#667781] focus:outline-none" />
|
||||
<button onClick={create} disabled={!newName.trim()}
|
||||
className="flex items-center gap-1 bg-[#00a884] text-white text-[13px] font-bold px-3 py-2 rounded-lg disabled:opacity-40 hover:bg-[#008f6f] transition-colors">
|
||||
<Plus className="w-4 h-4" /> Criar
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{PALETTE.map((c) => (
|
||||
<button key={c} onClick={() => setNewColor(c)}
|
||||
className={`w-6 h-6 rounded-full transition-transform ${newColor === c ? 'ring-2 ring-offset-2 ring-gray-400 scale-110' : ''}`}
|
||||
style={{ backgroundColor: c }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{chat && (
|
||||
<div className="px-4 py-3 border-t border-gray-100 flex justify-end">
|
||||
<button onClick={saveAssignments} disabled={saving}
|
||||
className="bg-[#00a884] text-white text-[14px] font-bold px-4 py-2 rounded-lg disabled:opacity-40 hover:bg-[#008f6f] transition-colors">
|
||||
{saving ? 'Salvando…' : 'Salvar nesta conversa'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
};
|
||||
|
||||
export default LabelManagerModal;
|
||||
@@ -118,7 +118,22 @@ export const extractFirstUrl = (text: string | null | undefined): string | null
|
||||
return m ? m[0] : null;
|
||||
};
|
||||
|
||||
// Renders plain text with URLs converted to clickable <a> tags
|
||||
// Formatação inline estilo WhatsApp: *negrito*, _itálico_, ~tachado~. Guarda contra
|
||||
// falso-positivo exigindo não-espaço colado às marcas (evita "3 * 4" virar negrito).
|
||||
const INLINE_RE = /(\*\S(?:[^*\n]*\S)?\*|_\S(?:[^_\n]*\S)?_|~\S(?:[^~\n]*\S)?~)/g;
|
||||
const renderInline = (text: string, keyBase: string): React.ReactNode[] => {
|
||||
if (!text) return [];
|
||||
return text.split(INLINE_RE).map((part, i) => {
|
||||
if (!part) return null;
|
||||
const key = `${keyBase}-${i}`;
|
||||
if (/^\*\S(?:[^*\n]*\S)?\*$/.test(part)) return <strong key={key} className="font-semibold">{part.slice(1, -1)}</strong>;
|
||||
if (/^_\S(?:[^_\n]*\S)?_$/.test(part)) return <em key={key}>{part.slice(1, -1)}</em>;
|
||||
if (/^~\S(?:[^~\n]*\S)?~$/.test(part)) return <s key={key}>{part.slice(1, -1)}</s>;
|
||||
return <React.Fragment key={key}>{part}</React.Fragment>;
|
||||
});
|
||||
};
|
||||
|
||||
// Renders text with URLs as <a> tags + WhatsApp inline formatting (bold/italic/strike)
|
||||
export const renderTextWithLinks = (text: string): React.ReactNode[] => {
|
||||
const parts = text.split(/(https?:\/\/[^\s<>"{}|\\^`[\]]{4,})/g);
|
||||
return parts.map((part, i) => {
|
||||
@@ -136,7 +151,7 @@ export const renderTextWithLinks = (text: string): React.ReactNode[] => {
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return <span key={i}>{part}</span>;
|
||||
return <React.Fragment key={i}>{renderInline(part, String(i))}</React.Fragment>;
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { normalizeJid, parseSafeDate } from '../utils/inboxUtils';
|
||||
import { Message } from '../types/inboxTypes';
|
||||
import { mediaApi } from '../services/chatApiService';
|
||||
import { useChatStore } from '../store/chatStore';
|
||||
import AiPauseButton from './AiPauseButton';
|
||||
|
||||
// Message interface removed and moved to types.ts
|
||||
|
||||
@@ -33,6 +34,7 @@ interface MessageAreaProps {
|
||||
quickReactions: string[];
|
||||
getGroupSenderLabel: (msg: Message) => string;
|
||||
canDelete?: boolean;
|
||||
highlightTerm?: string;
|
||||
}
|
||||
|
||||
const MessageArea: React.FC<MessageAreaProps> = ({
|
||||
@@ -54,6 +56,7 @@ const MessageArea: React.FC<MessageAreaProps> = ({
|
||||
quickReactions,
|
||||
getGroupSenderLabel,
|
||||
canDelete = true,
|
||||
highlightTerm,
|
||||
}) => {
|
||||
const [showScrollBottom, setShowScrollBottom] = useState(false);
|
||||
const lastScrollTop = useRef(0);
|
||||
@@ -144,6 +147,8 @@ const MessageArea: React.FC<MessageAreaProps> = ({
|
||||
return (
|
||||
<>
|
||||
<div className="flex-1 relative flex flex-col min-h-0 bg-transparent overflow-hidden">
|
||||
{/* Botão do atendente: pausar/retomar a Secretária IA nesta conversa. */}
|
||||
<AiPauseButton jid={selectedChat?.remote_jid} instanceId={selectedChat?.instance_name ?? selectedChat?.instanceId} />
|
||||
|
||||
<div
|
||||
className="flex-1 overflow-y-auto px-4 md:px-14 py-6 flex flex-col custom-scrollbar relative z-10"
|
||||
@@ -219,6 +224,7 @@ const MessageArea: React.FC<MessageAreaProps> = ({
|
||||
selectedChat={selectedChat}
|
||||
onOpenMedia={(m) => setViewerMsgId(m.message_id)}
|
||||
onRedownloadMedia={handleRedownloadMedia}
|
||||
highlightTerm={highlightTerm}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
@@ -237,7 +243,7 @@ const MessageArea: React.FC<MessageAreaProps> = ({
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.8, y: 10 }}
|
||||
onClick={() => scrollToBottom()}
|
||||
className="absolute bottom-6 right-6 w-11 h-11 bg-white text-[#54656f] rounded-full shadow-lg flex items-center justify-center hover:bg-[#f0f2f5] transition-all z-30 border border-[#d1d7db] active:scale-90"
|
||||
className="absolute bottom-[80px] right-6 w-11 h-11 bg-white text-[#54656f] rounded-full shadow-lg flex items-center justify-center hover:bg-[#f0f2f5] transition-all z-30 border border-[#d1d7db] active:scale-90"
|
||||
>
|
||||
<ArrowDown className="w-5 h-5" />
|
||||
{selectedChat?.unread_count > 0 && (
|
||||
|
||||
@@ -104,7 +104,7 @@ const AudioBubble: React.FC<AudioBubbleProps> = ({ msg, fullMediaUrl, isOut, Tim
|
||||
onEnded={() => { setPlaying(false); setCurrentTime(0) }}
|
||||
preload="metadata"
|
||||
/>
|
||||
<div className="flex items-center gap-2.5 bg-[#f0f2f5] px-3 py-2.5 rounded-xl border border-black/5 w-[260px]">
|
||||
<div className="flex items-center gap-2.5 bg-[#f0f2f5] px-3 py-2.5 rounded-xl border border-black/5 w-[260px] max-w-full">
|
||||
{/* Play/pause */}
|
||||
<button
|
||||
onClick={toggle}
|
||||
@@ -341,7 +341,9 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({
|
||||
// o trecho casado vira <mark>. Sem termo/sem match → comportamento normal.
|
||||
const renderTextHL = (text?: string | null): React.ReactNode => {
|
||||
const t = (highlightTerm ?? '').trim();
|
||||
const src = text ?? '';
|
||||
// Assinatura do operador: 1ª linha "*NOME*" (negrito WhatsApp) seguida de \n vira
|
||||
// "*NOME:*" — exibe "NOME:" em negrito antes da quebra. Idempotente (não duplica :).
|
||||
const src = (text ?? '').replace(/^\*([^*\n]+?):?\*(\n)/, '*$1:*$2');
|
||||
if (!t) return renderTextWithLinks(src);
|
||||
const lower = src.toLowerCase();
|
||||
const lt = t.toLowerCase();
|
||||
@@ -750,13 +752,13 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({
|
||||
<div>
|
||||
<div
|
||||
onClick={mediaUnavailable ? undefined : handleRedownload}
|
||||
className={`flex items-center gap-2.5 bg-[#f0f2f5] px-3 py-2.5 rounded-xl border border-black/5 w-[260px] transition-colors ${mediaUnavailable ? 'cursor-default opacity-60' : 'cursor-pointer hover:bg-[#e8eaed]'}`}
|
||||
className={`flex items-center gap-2.5 bg-[#f0f2f5] px-3 py-2.5 rounded-xl border border-black/5 w-[260px] max-w-full transition-colors ${mediaUnavailable ? 'cursor-default opacity-60' : 'cursor-pointer hover:bg-[#e8eaed]'}`}
|
||||
>
|
||||
{isRedownloading
|
||||
? <div className="w-8 h-8 border-2 border-slate-400 border-t-transparent rounded-full animate-spin shrink-0" />
|
||||
: <div className="w-10 h-10 rounded-full bg-[#8696a0] flex items-center justify-center shrink-0"><Mic className="w-4 h-4 text-white" /></div>
|
||||
}
|
||||
<span className="text-[12px] text-[#667781]">
|
||||
<span className="text-[12px] text-[#667781] min-w-0 truncate">
|
||||
{isRedownloading ? 'Baixando...' : mediaUnavailable ? 'Áudio não disponível' : 'Toque para baixar áudio'}
|
||||
</span>
|
||||
</div>
|
||||
@@ -898,7 +900,7 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({
|
||||
{/* Authentic WhatsApp Message Menu Button */}
|
||||
<button
|
||||
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
||||
className={`absolute top-1 right-1 p-1 text-slate-500 opacity-0 group-hover:opacity-100 hover:text-slate-300 transition-opacity z-30`}
|
||||
className={`absolute top-1 right-1 p-1 text-slate-500 hover:text-slate-300 transition-opacity z-30 ${(isMenuOpen || reactionPickerFor === msg.message_id) ? 'opacity-0 pointer-events-none' : 'opacity-0 group-hover:opacity-100'}`}
|
||||
>
|
||||
<ChevronDown className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
@@ -121,6 +121,8 @@ function ProtocolCard({ protocol, isActive, updatingId, onStatusChange }: Protoc
|
||||
|
||||
<AnimatePresence>
|
||||
{showActions && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[9]" onClick={() => setShowActions(false)} />
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
@@ -141,6 +143,7 @@ function ProtocolCard({ protocol, isActive, updatingId, onStatusChange }: Protoc
|
||||
)
|
||||
})}
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
// 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; canPower?: boolean }> = ({ instanceId, canPower }) => {
|
||||
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]);
|
||||
|
||||
// Fail-closed: só o dono controla o liga/desliga da Secretária. Para os demais o
|
||||
// botão nem aparece (antes, o GET dava 403 e o estado caía no fail-open "ligada").
|
||||
if (!instanceId || !canPower) return null;
|
||||
const known = sp !== null; // estado real carregado?
|
||||
const enabled = known ? sp!.enabled : false; // desconhecido NUNCA vira "ligada"
|
||||
|
||||
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={!known ? 'Secretária IA — carregando estado…' : 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 ${
|
||||
!known ? 'text-gray-300 hover:bg-[#d9dbdf]/60'
|
||||
: 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -42,7 +42,9 @@ export default function SettingsPanel({ instanceId }: SettingsPanelProps) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [version, setVersion] = useState<any>(null);
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8008';
|
||||
// Same-origin (NÃO localhost do PC — isso disparava o prompt de "acessar rede local").
|
||||
// A troca de engine é recurso do standalone; no satélite degrada gracioso (404).
|
||||
const API_URL = '';
|
||||
const authHeader = (): Record<string, string> => {
|
||||
const t = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
|
||||
return t ? { Authorization: `Bearer ${t}` } : {};
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Heart, Clock, Loader2 } from 'lucide-react';
|
||||
import { stickerApi, type StickerRecord } from '../services/chatApiService';
|
||||
import { getStickerFromCache, setStickerInCache } from '../hooks/useStickerCache';
|
||||
|
||||
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3003';
|
||||
const LIST_VERSION_KEY = 'nw:sticker-list-version';
|
||||
|
||||
interface StickerPanelProps {
|
||||
@@ -15,10 +14,10 @@ interface StickerPanelProps {
|
||||
onSend: (blob: Blob) => void;
|
||||
}
|
||||
|
||||
// Converte URL Wasabi → URL do proxy do backend
|
||||
// Converte path Wasabi → URL same-origin do proxy nw (NÃO localhost do PC).
|
||||
function stickerSrcUrl(wasabiPath: string): string {
|
||||
const clean = wasabiPath.startsWith('/') ? wasabiPath.slice(1) : wasabiPath;
|
||||
return `${API}/api/storage/view/${clean}`;
|
||||
return `/api/nw/media/${clean}`;
|
||||
}
|
||||
|
||||
// Carrega sticker: tenta IndexedDB, depois proxy, depois persiste no cache
|
||||
|
||||
@@ -5,19 +5,25 @@ import {
|
||||
Users,
|
||||
Settings,
|
||||
BrainCircuit,
|
||||
CalendarClock,
|
||||
} from 'lucide-react';
|
||||
import { getAvatarProxyUrl } from '../utils/inboxUtils';
|
||||
import { SessaoSecretariaPower } from './SessaoSecretariaPower';
|
||||
|
||||
export type TabType = 'chats' | 'broadcasts' | 'groups' | 'settings';
|
||||
export type TabType = 'chats' | 'broadcasts' | 'groups' | 'settings' | 'fila';
|
||||
|
||||
interface ThinSidebarProps {
|
||||
activeTab: TabType;
|
||||
setActiveTab: (tab: TabType) => void;
|
||||
activeInstance: any; // We'll pass the active instance to show the avatar
|
||||
onNavigate?: (view: string) => void; // satélite: navegação entre as views do plugin
|
||||
// Fila da Secretária: nº de conversas com pedido pendente (badge da aba). 0 = sem badge.
|
||||
filaCount?: number;
|
||||
// Só o dono controla o liga/desliga da Secretária (botão de power). Fail-closed.
|
||||
canPower?: boolean;
|
||||
}
|
||||
|
||||
export default function ThinSidebar({ activeTab, setActiveTab, activeInstance, onNavigate }: ThinSidebarProps) {
|
||||
export default function ThinSidebar({ activeTab, setActiveTab, activeInstance, onNavigate, filaCount = 0, canPower = false }: ThinSidebarProps) {
|
||||
const isConnected = activeInstance?.status === 'connected' || activeInstance?.status === 'open';
|
||||
|
||||
const renderIcon = (id: TabType, IconComponent: any, title: string) => {
|
||||
@@ -50,6 +56,18 @@ export default function ThinSidebar({ activeTab, setActiveTab, activeInstance, o
|
||||
{/* Top Icons */}
|
||||
<div className="flex-1 w-full flex flex-col items-center">
|
||||
{renderIcon('chats', MessageCircle, 'Conversas')}
|
||||
{/* Fila da Secretária: conversas com pedido aguardando confirmação da humana */}
|
||||
<div className="relative">
|
||||
{renderIcon('fila', CalendarClock, 'Fila da Secretária')}
|
||||
{filaCount > 0 && (
|
||||
<span
|
||||
className="absolute -top-0.5 right-0 min-w-[18px] h-[18px] px-1 rounded-full bg-amber-500 text-white text-[10px] font-black flex items-center justify-center pointer-events-none shadow"
|
||||
title={`${filaCount} pendente(s)`}
|
||||
>
|
||||
{filaCount > 99 ? '99+' : filaCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{renderIcon('broadcasts', Megaphone, 'Transmissões')}
|
||||
{renderIcon('groups', Users, 'Sessões')}
|
||||
{/* Secretária IA — abre a tela de configuração da secretária virtual */}
|
||||
@@ -64,6 +82,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} canPower={canPower} />
|
||||
{/* Configurações — abre o painel na área central (aba 'settings') */}
|
||||
{renderIcon('settings', Settings, 'Configurações')}
|
||||
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
* - abertura de perfil / menu de chat
|
||||
*/
|
||||
import { useState, useRef, useCallback } from 'react'
|
||||
import { mediaApi } from '../../services/chatApiService'
|
||||
import { mediaApi, chatApi, avatarApi } from '../../services/chatApiService'
|
||||
import { useChatStore } from '../../store/chatStore'
|
||||
import type { NewWhatsChat } from '../../types/inboxTypes'
|
||||
import type { Message } from '../../types/inboxTypes'
|
||||
|
||||
@@ -39,20 +40,32 @@ export function useChatWindow({
|
||||
const [isProfileOpen, setIsProfileOpen] = useState(false)
|
||||
const [isSyncingAvatar, setIsSyncingAvatar] = useState(false)
|
||||
const [isChatMenuOpen, setIsChatMenuOpen] = useState(false)
|
||||
const [editingMessage, setEditingMessage] = useState<Message | null>(null)
|
||||
const [forwardingMessage, setForwardingMessage] = useState<Message | null>(null)
|
||||
|
||||
// ── Handlers ────────────────────────────────────────────────────────────────
|
||||
|
||||
// Chamado quando O OPERADOR digita no input. NÃO deve acender o "digitando" — esse
|
||||
// indicador é da presença `composing` do CONTATO (outro lado), não da minha. Antes,
|
||||
// digitar no input marcava isTyping=true localmente, mostrando "digitando" para mim
|
||||
// mesmo. Mantido como no-op até o motor repassar a presença do contato pelo stream.
|
||||
// Envia MINHA presença "composing" ao contato ao digitar (throttle ~3s) e "paused"
|
||||
// depois de ~4s de ociosidade. Não altera o indicador local (que é do CONTATO).
|
||||
const lastComposingRef = useRef(0)
|
||||
const pausedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const handleTyping = useCallback(() => {
|
||||
if (!selectedChat) return
|
||||
if (!isTypingRef.current) {
|
||||
isTypingRef.current = true
|
||||
setIsTyping(true)
|
||||
const chatId = selectedChat?.id ? String(selectedChat.id) : null
|
||||
if (!chatId) return
|
||||
const now = Date.now()
|
||||
if (now - lastComposingRef.current > 3000) {
|
||||
lastComposingRef.current = now
|
||||
chatApi.typing(chatId, 'composing').catch(() => {})
|
||||
}
|
||||
if (typingTimeout.current) clearTimeout(typingTimeout.current)
|
||||
typingTimeout.current = setTimeout(() => {
|
||||
isTypingRef.current = false
|
||||
setIsTyping(false)
|
||||
}, 3000)
|
||||
if (pausedTimerRef.current) clearTimeout(pausedTimerRef.current)
|
||||
pausedTimerRef.current = setTimeout(() => {
|
||||
lastComposingRef.current = 0
|
||||
chatApi.typing(chatId, 'paused').catch(() => {})
|
||||
}, 4000)
|
||||
}, [selectedChat])
|
||||
|
||||
const handleScroll = useCallback(async () => {
|
||||
@@ -70,39 +83,54 @@ export function useChatWindow({
|
||||
setHasMoreHistory(true)
|
||||
}, [])
|
||||
|
||||
const handleReactToMessage = useCallback((_msg: Message, _emoji: string) => {
|
||||
// Reage a uma mensagem: envia ao WhatsApp e atualiza o store na hora (otimista).
|
||||
// Toggle: clicar no mesmo emoji já reagido remove a reação. Reverte em caso de erro.
|
||||
const handleReactToMessage = useCallback(async (msg: Message, emoji: string) => {
|
||||
setReactionPickerFor(null)
|
||||
// Será implementado via endpoint /api/instances/:id/react
|
||||
}, [])
|
||||
|
||||
const handleSendMedia = useCallback(async (file: File, caption?: string) => {
|
||||
if (!selectedChat || !activeInstanceId) return
|
||||
const chatId = selectedChat?.id ? String(selectedChat.id) : null
|
||||
if (!chatId || !msg?.id) return
|
||||
const current = (msg as any).reactions?.me as string | undefined
|
||||
const next = current === emoji ? '' : emoji
|
||||
useChatStore.getState().reactMessage(chatId, String(msg.id), next)
|
||||
try {
|
||||
await mediaApi.sendMedia(activeInstanceId, selectedChat.remote_jid, file, caption || undefined, undefined)
|
||||
await chatApi.react(chatId, String(msg.id), next)
|
||||
} catch (err) {
|
||||
console.error('Erro ao reagir:', err)
|
||||
useChatStore.getState().reactMessage(chatId, String(msg.id), current || '') // reverte
|
||||
}
|
||||
}, [selectedChat])
|
||||
|
||||
// O mediaApi do satélite é por chatId (a ext resolve jid/instância): sendMedia
|
||||
// (chatId, file, caption?) e sendAudio(chatId, blob). Antes passávamos a assinatura
|
||||
// antiga do motor (instanceId, jid, ...) e o blob caía no lugar errado → o FormData
|
||||
// recebia o jid (string) em vez do Blob e quebrava o envio.
|
||||
const handleSendMedia = useCallback(async (file: File, caption?: string) => {
|
||||
if (!selectedChat?.id) return
|
||||
try {
|
||||
await mediaApi.sendMedia(String(selectedChat.id), file, caption || undefined)
|
||||
} catch (err) {
|
||||
console.error('Erro ao enviar mídia:', err)
|
||||
}
|
||||
}, [selectedChat, activeInstanceId])
|
||||
}, [selectedChat])
|
||||
|
||||
const handleSendAudio = useCallback(async (blob: Blob) => {
|
||||
if (!selectedChat || !activeInstanceId) return
|
||||
if (!selectedChat?.id) return
|
||||
try {
|
||||
await mediaApi.sendAudio(activeInstanceId, selectedChat.remote_jid, blob)
|
||||
await mediaApi.sendAudio(String(selectedChat.id), blob)
|
||||
} catch (err) {
|
||||
console.error('Erro ao enviar áudio:', err)
|
||||
}
|
||||
}, [selectedChat, activeInstanceId])
|
||||
}, [selectedChat])
|
||||
|
||||
const handleSyncAvatar = useCallback(async () => {
|
||||
if (!selectedChat || !activeInstanceId) return
|
||||
setIsSyncingAvatar(true)
|
||||
try {
|
||||
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3003'
|
||||
const token = localStorage.getItem('token')
|
||||
await fetch(
|
||||
`${API}/api/inbox/avatar/${encodeURIComponent(selectedChat.remote_jid)}?instance=${activeInstanceId}`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
)
|
||||
// Via proxy nw (NÃO localhost do PC — isso disparava o prompt de "acessar rede local").
|
||||
const avatar = await avatarApi.refresh(activeInstanceId, selectedChat.remote_jid)
|
||||
if (avatar && selectedChat.id) useChatStore.getState().patchChat(String(selectedChat.id), { contactAvatarUrl: avatar } as any)
|
||||
} catch (err) {
|
||||
console.error('Erro ao sincronizar foto:', err)
|
||||
} finally {
|
||||
setIsSyncingAvatar(false)
|
||||
}
|
||||
@@ -112,6 +140,23 @@ export function useChatWindow({
|
||||
setReactionPickerFor(prev => prev === id ? null : id)
|
||||
}, [])
|
||||
|
||||
// "Limpar conversa" (só o dono — a visibilidade do botão já é gateada por isOwner).
|
||||
// Remove todas as mensagens do chat no motor e esvazia a lista local. Não revoga no
|
||||
// WhatsApp; é limpeza local do inbox (análogo ao "Limpar conversa" do WhatsApp).
|
||||
const handleClearConversation = useCallback(async () => {
|
||||
if (!selectedChat?.id) return
|
||||
const id = String(selectedChat.id)
|
||||
if (typeof window !== 'undefined' &&
|
||||
!window.confirm('Limpar esta conversa? Todas as mensagens serão removidas do inbox. Esta ação não pode ser desfeita.')) return
|
||||
try {
|
||||
await chatApi.clearConversation(id)
|
||||
useChatStore.getState().setMessages(id, [])
|
||||
} catch (err) {
|
||||
console.error('Erro ao limpar conversa:', err)
|
||||
if (typeof window !== 'undefined') window.alert('Não foi possível limpar a conversa.')
|
||||
}
|
||||
}, [selectedChat])
|
||||
|
||||
return {
|
||||
// Refs
|
||||
scrollRef,
|
||||
@@ -130,6 +175,10 @@ export function useChatWindow({
|
||||
setReplyingTo,
|
||||
setIsProfileOpen,
|
||||
setIsChatMenuOpen,
|
||||
editingMessage,
|
||||
setEditingMessage,
|
||||
forwardingMessage,
|
||||
setForwardingMessage,
|
||||
|
||||
// Handlers
|
||||
handleTyping,
|
||||
@@ -140,5 +189,6 @@ export function useChatWindow({
|
||||
handleSendAudio,
|
||||
handleSyncAvatar,
|
||||
toggleReactionPicker,
|
||||
handleClearConversation,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,21 @@ export interface LegacyInstance {
|
||||
phone: string | null
|
||||
avatar: string | null
|
||||
profilePictureUrl: string | null
|
||||
// Seletor multi-conta:
|
||||
role: string | null
|
||||
label: string | null
|
||||
area: string | null
|
||||
notes: string | null
|
||||
clinicaId: string | null
|
||||
ownerNome: string | null
|
||||
own: boolean
|
||||
}
|
||||
|
||||
// Conta ativa da inbox — lida pelo nwClient p/ decidir o x-nw-clinica das rotas de
|
||||
// inbox (clinicaId null = conta própria → o proxy fala como o próprio usuário).
|
||||
const INBOX_ACCOUNT_KEY = 'nw:inbox-account'
|
||||
function persistInboxAccount(clinicaId: string | null, instanceId: string) {
|
||||
try { localStorage.setItem(INBOX_ACCOUNT_KEY, JSON.stringify({ clinicaId, instanceId })) } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export interface MappedInstance {
|
||||
@@ -46,11 +61,18 @@ export function useInstanceSelector() {
|
||||
const instancesLegacy = useMemo<LegacyInstance[]>(() =>
|
||||
instances.map((i) => ({
|
||||
instance: i.id,
|
||||
nome: i.name,
|
||||
nome: i.label || i.name,
|
||||
status: i.status === 'CONNECTED' ? 'connected' : i.status.toLowerCase(),
|
||||
phone: i.phone,
|
||||
avatar: i.avatar ?? null,
|
||||
profilePictureUrl: i.avatar ?? null,
|
||||
role: i.role ?? null,
|
||||
label: i.label ?? null,
|
||||
area: i.area ?? null,
|
||||
notes: i.notes ?? null,
|
||||
clinicaId: i.clinicaId ?? null,
|
||||
ownerNome: i.ownerNome ?? null,
|
||||
own: i.own ?? true,
|
||||
})),
|
||||
[instances]
|
||||
)
|
||||
@@ -83,20 +105,38 @@ export function useInstanceSelector() {
|
||||
useEffect(() => {
|
||||
if (!activeId && instances.length > 0) {
|
||||
const first = instances[0]
|
||||
persistInboxAccount(first.clinicaId ?? null, first.id)
|
||||
setActiveId(first.id)
|
||||
socketService.joinInstance(first.id)
|
||||
}
|
||||
}, [instances, activeId, setActiveId])
|
||||
|
||||
// Mantém nw:inbox-account SEMPRE em sincronia com a instância ativa — inclusive
|
||||
// quando activeId é restaurado do localStorage (aí o auto-select acima não roda).
|
||||
// Sem isto, o nw:inbox-account fica com a clínica de uma seleção anterior e o
|
||||
// inbox da conta própria vai com x-nw-clinica errado → proxy reescreve → 404.
|
||||
useEffect(() => {
|
||||
if (!activeId || instances.length === 0) return
|
||||
const inst = instances.find((i) => i.id === activeId)
|
||||
if (inst) {
|
||||
persistInboxAccount(inst.clinicaId ?? null, activeId)
|
||||
socketService.syncAccount() // realtime no tenant certo (própria vs. clínica)
|
||||
}
|
||||
}, [activeId, instances])
|
||||
|
||||
// ── Troca de instância ────────────────────────────────────────────────────
|
||||
|
||||
const selectInstance = useCallback((legacyInstance: LegacyInstance | { instance: string }) => {
|
||||
const id = legacyInstance.instance
|
||||
if (id === activeId) return
|
||||
// Fixa a conta ativa ANTES de carregar os chats: o nwClient usa isso p/ decidir
|
||||
// o x-nw-clinica (conta própria vs. caixa da clínica) nas rotas de inbox.
|
||||
const clinicaId = instances.find((i) => i.id === id)?.clinicaId ?? null
|
||||
persistInboxAccount(clinicaId, id)
|
||||
resetChats([])
|
||||
setActiveId(id)
|
||||
socketService.joinInstance(id)
|
||||
}, [activeId, setActiveId, resetChats])
|
||||
}, [activeId, setActiveId, resetChats, instances])
|
||||
|
||||
// ── Refresh ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -11,11 +11,14 @@ import type { NewWhatsChat } from '../../types/inboxTypes'
|
||||
interface UseMessageInputOptions {
|
||||
selectedChat: NewWhatsChat | null
|
||||
replyingTo?: { message_id: string } | null
|
||||
editingMessage?: { id: string | number } | null
|
||||
onEditText?: (messageId: string, text: string) => Promise<void>
|
||||
onClearEdit?: () => void
|
||||
onClearReply?: () => void
|
||||
onSendText: (text: string, mentions?: string[], replyToMessageId?: string) => Promise<void>
|
||||
}
|
||||
|
||||
export function useMessageInput({ selectedChat, replyingTo, onClearReply, onSendText }: UseMessageInputOptions) {
|
||||
export function useMessageInput({ selectedChat, replyingTo, editingMessage, onEditText, onClearEdit, onClearReply, onSendText }: UseMessageInputOptions) {
|
||||
const [newMessage, setNewMessage] = useState('')
|
||||
const [mentions, setMentions] = useState<string[]>([])
|
||||
|
||||
@@ -23,13 +26,20 @@ export function useMessageInput({ selectedChat, replyingTo, onClearReply, onSend
|
||||
e?.preventDefault()
|
||||
if (!selectedChat || !newMessage.trim()) return
|
||||
const text = newMessage
|
||||
// Modo edição: salva a edição da mensagem em vez de enviar uma nova.
|
||||
if (editingMessage) {
|
||||
setNewMessage('')
|
||||
onClearEdit?.()
|
||||
await onEditText?.(String(editingMessage.id), text)
|
||||
return
|
||||
}
|
||||
const mentionJids = mentions.length > 0 ? [...mentions] : undefined
|
||||
const replyToMessageId = replyingTo?.message_id
|
||||
setNewMessage('')
|
||||
setMentions([])
|
||||
onClearReply?.()
|
||||
await onSendText(text, mentionJids, replyToMessageId)
|
||||
}, [selectedChat, newMessage, mentions, replyingTo, onSendText, onClearReply])
|
||||
}, [selectedChat, newMessage, mentions, replyingTo, editingMessage, onEditText, onClearEdit, onSendText, onClearReply])
|
||||
|
||||
return {
|
||||
newMessage,
|
||||
|
||||
@@ -61,7 +61,7 @@ function chatToLegacy(c: ReturnType<typeof useChatStore.getState>['chats'][0]):
|
||||
is_pinned: c.isPinned,
|
||||
is_archived: c.isArchived,
|
||||
bot_paused: c.botPaused ?? null,
|
||||
labels: [],
|
||||
labels: (c as any).labels ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ function messagesToLegacy(msgs: ReturnType<typeof useChatStore.getState>['messag
|
||||
quoted_message_text: m.replyTo?.body ?? null,
|
||||
quoted_participant: m.replyTo ? (m.replyTo.fromMe ? 'me' : null) : null,
|
||||
participant: m.pushName ?? null,
|
||||
reactions: m.reactions ?? null,
|
||||
senderJid: m.senderJid ?? null,
|
||||
sender_jid: m.senderJid ?? null,
|
||||
remote_jid: m.chatId,
|
||||
@@ -118,7 +119,9 @@ export function useNewInboxBridge() {
|
||||
// ── Carregar chats quando instância ativa muda ─────────────────────────────
|
||||
useEffect(() => {
|
||||
const { activeInstanceId } = instanceStore
|
||||
if (!activeInstanceId) return
|
||||
// Sem instância ativa (ex.: superadmin, ou instância resetada por não ser
|
||||
// acessível) → esvazia a lista para NÃO deixar chats de outra sessão na tela.
|
||||
if (!activeInstanceId) { chatStore.setChats([]); return }
|
||||
chatStore.loadChats(activeInstanceId)
|
||||
socketService.joinInstance(activeInstanceId)
|
||||
// Reconcilia nomes em background (cache 30min — leve em escala)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Shims dos services do motor sobre a ext API do satélite (/api/nw/v1/*).
|
||||
// Mantém a MESMA interface que o chatApiService.ts do motor expõe, normalizando
|
||||
// os shapes da ext API para o formato que os stores/hooks portados esperam.
|
||||
import { nw } from './nwClient';
|
||||
import { nw, nwToken } from './nwClient';
|
||||
|
||||
// ─── Instâncias ── (ext /sessions) ──────────────────────────────────────────
|
||||
export const instanceApi = {
|
||||
@@ -13,6 +13,29 @@ export const instanceApi = {
|
||||
delete: (id: string) => nw.delete(`/sessions/${id}`),
|
||||
};
|
||||
|
||||
// ─── Contas da inbox ── (/api/nw/accounts, fora do proxy /nw/v1) ─────────────
|
||||
// Números que o usuário logado pode operar: os PRÓPRIOS + os LIBERADOS pelo dono
|
||||
// (can_inbox). Cada item traz o papel (sec_numbers) p/ o seletor e o ícone (i).
|
||||
export interface InboxAccount {
|
||||
instanceId: string; phone: string | null; name: string | null;
|
||||
status: string | null; avatar: string | null;
|
||||
role: string | null; label: string | null; area: string | null; notes: string | null;
|
||||
clinicaId: string | null; ownerEmail: string | null; ownerNome: string | null;
|
||||
own: boolean; canInbox: boolean;
|
||||
}
|
||||
export const accountsApi = {
|
||||
list: async (): Promise<InboxAccount[]> => {
|
||||
try {
|
||||
const res = await fetch(`${nw.base()}/nw/accounts`, {
|
||||
headers: { Authorization: `Bearer ${nwToken() ?? ''}` },
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const j = await res.json();
|
||||
return Array.isArray(j) ? j : [];
|
||||
} catch { return []; }
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Avatar self-heal ── (ext /contacts/:jid/avatar/refresh) ─────────────────
|
||||
// A URL do CDN do WhatsApp expira e passa a 404; aqui pedimos ao motor para
|
||||
// re-buscar a foto atual pela conexão viva. Retorna a URL nova ou null.
|
||||
@@ -25,6 +48,33 @@ export const avatarApi = {
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Handoff IA/humano ── (ext /sec/handoff/:jid — pausar/retomar a Secretária) ──
|
||||
export const handoffApi = {
|
||||
// secEnabled = estado efetivo do liga/desliga (chat/sessão/global) p/ o botão de
|
||||
// pausa não mostrar "IA ativa" quando a Secretária está desligada. null = desconhecido.
|
||||
get: async (jid: string, instanceId?: string | null): Promise<{ mode: 'ia' | 'humano'; humanAt: string | null; secEnabled: boolean | null }> => {
|
||||
try {
|
||||
const q = instanceId ? `?instance_id=${encodeURIComponent(instanceId)}` : '';
|
||||
const r = await nw.get(`/sec/handoff/${encodeURIComponent(jid)}${q}`);
|
||||
return { mode: r?.mode === 'humano' ? 'humano' : 'ia', humanAt: r?.handoffHumanAt ?? null, secEnabled: r?.sec_enabled ?? null };
|
||||
}
|
||||
catch { return { mode: 'ia', humanAt: null, secEnabled: null }; }
|
||||
},
|
||||
set: (jid: string, mode: 'ia' | 'humano') => nw.patch(`/sec/handoff/${encodeURIComponent(jid)}`, { mode }),
|
||||
};
|
||||
// Janela de pausa antes do retorno automático da IA (igual ao motor: HANDOFF_TIMEOUT_MS).
|
||||
export const HANDOFF_MINUTES = 15;
|
||||
|
||||
// ─── Etiquetas ── (ext /labels + /inbox/:chatId/labels) ─────────────────────
|
||||
export interface Label { id: string; name: string; color: string }
|
||||
export const labelApi = {
|
||||
list: (): Promise<Label[]> => nw.get('/labels'),
|
||||
create: (name: string, color: string): Promise<Label> => nw.post('/labels', { name, color }),
|
||||
update: (id: string, patch: { name?: string; color?: string }) => nw.patch(`/labels/${id}`, patch),
|
||||
delete: (id: string) => nw.delete(`/labels/${id}`),
|
||||
setForChat: (chatId: string, labelIds: string[]) => nw.put(`/inbox/${chatId}/labels`, { labelIds }),
|
||||
};
|
||||
|
||||
// ─── Chats ── (ext /inbox → shape achatado; normaliza p/ mapBackendChat) ────
|
||||
export const chatApi = {
|
||||
list: async (instanceId: string, opts: { search?: string; archived?: boolean; limit?: number } = {}) => {
|
||||
@@ -55,6 +105,25 @@ export const chatApi = {
|
||||
archive: (chatId: string, archived: boolean) => nw.patch(`/inbox/${chatId}/archive`, { archived }),
|
||||
pin: (chatId: string, pinned: boolean) => nw.patch(`/inbox/${chatId}/pin`, { pinned }),
|
||||
delete: (chatId: string) => nw.delete(`/inbox/${chatId}`),
|
||||
// Apaga UMA mensagem para todos (revoke no WhatsApp). messageId = id interno (uuid).
|
||||
deleteMessage: (chatId: string, messageId: string) => nw.delete(`/inbox/${chatId}/messages/${messageId}`),
|
||||
// "Limpar conversa": remove TODAS as mensagens do chat no motor (mantém o chat). Local
|
||||
// (não revoga no WhatsApp). Gate de dono/can_delete_msg validado no proxy nw.
|
||||
clearConversation: (chatId: string) => nw.delete(`/inbox/${chatId}/messages`),
|
||||
// Assina a presença do contato (composing/recording) — chamar ao abrir o chat.
|
||||
subscribePresence: (chatId: string) => nw.post(`/inbox/${chatId}/presence`),
|
||||
// Reage a uma mensagem (emoji ''= remove). messageId = id interno (uuid).
|
||||
react: (chatId: string, messageId: string, emoji: string) =>
|
||||
nw.post(`/inbox/${chatId}/messages/${messageId}/react`, { emoji }),
|
||||
// Edita uma mensagem de texto enviada por você.
|
||||
editMessage: (chatId: string, messageId: string, text: string) =>
|
||||
nw.patch(`/inbox/${chatId}/messages/${messageId}`, { text }),
|
||||
// Encaminha uma mensagem para outros chats (ids internos).
|
||||
forward: (chatId: string, messageId: string, toChatIds: string[]) =>
|
||||
nw.post(`/inbox/${chatId}/messages/${messageId}/forward`, { toChatIds }),
|
||||
// Envia minha presença ao contato (composing/paused).
|
||||
typing: (chatId: string, state: 'composing' | 'paused' | 'recording') =>
|
||||
nw.post(`/inbox/${chatId}/typing`, { state }),
|
||||
};
|
||||
|
||||
// ─── Mensagens ── (ext devolve array; envolve em {messages, hasMore}) ───────
|
||||
@@ -83,24 +152,34 @@ export const messageApi = {
|
||||
nw.post(`/inbox/${chatId}/send`, { text, ...(replyToMessageId ? { replyToMessageId } : {}) }),
|
||||
};
|
||||
|
||||
// Lê um Blob/File como base64 puro (sem o prefixo data:...;base64,).
|
||||
const toBase64 = (blob: Blob): Promise<string> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const r = new FileReader();
|
||||
r.onloadend = () => resolve(String(r.result).split(',')[1] || '');
|
||||
r.onerror = () => reject(new Error('Falha ao ler o arquivo'));
|
||||
r.readAsDataURL(blob);
|
||||
});
|
||||
|
||||
// ─── Mídia ── (ext /inbox/:chatId/send-media | send-audio) ──────────────────
|
||||
// Imagem/vídeo/documento/figurinha vão via MULTIPART (o proxy encaminha o stream ao
|
||||
// motor — sem limite de base64). Áudio (nota de voz) vai em base64/JSON: é pequeno e
|
||||
// o motor converte p/ OGG/Opus via ffmpeg.
|
||||
export const mediaApi = {
|
||||
sendMedia: (chatId: string, file: File, caption?: string, replyToMessageId?: string) => {
|
||||
sendMedia: (chatId: string, file: File, caption?: string) => {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
if (caption) fd.append('caption', caption);
|
||||
if (replyToMessageId) fd.append('replyToMessageId', replyToMessageId);
|
||||
return nw.post(`/inbox/${chatId}/send-media`, fd);
|
||||
},
|
||||
sendAudio: (chatId: string, blob: Blob) => {
|
||||
const fd = new FormData();
|
||||
fd.append('audio', blob, 'audio.ogg');
|
||||
return nw.post(`/inbox/${chatId}/send-audio`, fd);
|
||||
sendAudio: async (chatId: string, blob: Blob) => {
|
||||
const audio = await toBase64(blob);
|
||||
return nw.post(`/inbox/${chatId}/send-audio`, { audio, mimetype: blob.type || 'audio/ogg' });
|
||||
},
|
||||
// Sticker: reaproveita o endpoint de mídia (a ext detecta image/webp → sticker).
|
||||
sendSticker: (chatId: string, blob: Blob) => {
|
||||
const fd = new FormData();
|
||||
fd.append('file', new File([blob], 'sticker.webp', { type: 'image/webp' }));
|
||||
fd.append('sticker', 'true');
|
||||
return nw.post(`/inbox/${chatId}/send-media`, fd);
|
||||
},
|
||||
// Re-download sob demanda: ~95% das mídias vêm com mediaUrl NULL (lazy). O motor
|
||||
|
||||
@@ -9,14 +9,39 @@ export function nwToken(): string | null {
|
||||
try { return localStorage.getItem(TOKEN_KEY); } catch { return null; }
|
||||
}
|
||||
|
||||
// Clínica do workspace ativo — escopo por-requisição (modelo de canal).
|
||||
function activeClinicaId(): string | null {
|
||||
// Clínica do workspace ativo (agenda/secretária) — escopo por-requisição (modelo de canal).
|
||||
function activeWorkspaceId(): string | null {
|
||||
try { return JSON.parse(localStorage.getItem('SCOREODONTO_ACTIVE_WORKSPACE') || '{}')?.id || null; } catch { return null; }
|
||||
}
|
||||
|
||||
function authHeaders(extra?: Record<string, string>): Record<string, string> {
|
||||
// Conta ativa da INBOX (seletor de número multi-conta). Quando o usuário escolhe um
|
||||
// número no seletor, guardamos { clinicaId, instanceId }. clinicaId null = conta
|
||||
// PRÓPRIA (sem x-nw-clinica → o proxy fala com o motor como o próprio usuário).
|
||||
export const INBOX_ACCOUNT_KEY = 'nw:inbox-account';
|
||||
function inboxAccountClinica(): { set: boolean; clinicaId: string | null } {
|
||||
try {
|
||||
const raw = localStorage.getItem(INBOX_ACCOUNT_KEY);
|
||||
if (!raw) return { set: false, clinicaId: null };
|
||||
const a = JSON.parse(raw);
|
||||
return a && a.instanceId ? { set: true, clinicaId: a.clinicaId ?? null } : { set: false, clinicaId: null };
|
||||
} catch { return { set: false, clinicaId: null }; }
|
||||
}
|
||||
|
||||
// Resolve o x-nw-clinica da requisição. Rotas de inbox usam SEMPRE a CONTA ATIVA do
|
||||
// seletor (nunca o workspace global da agenda) — assim a caixa própria não herda a
|
||||
// clínica selecionada na agenda, o que faria o proxy reescrever para o dono errado
|
||||
// e o motor devolver 404. clinicaId null (conta própria/não-setado) → sem header.
|
||||
// As demais rotas (agenda/secretária) seguem o workspace global.
|
||||
function clinicaForPath(path: string): string | null {
|
||||
if (/^\/(inbox|conversations)(\/|$|\?)/.test(path)) {
|
||||
return inboxAccountClinica().clinicaId;
|
||||
}
|
||||
return activeWorkspaceId();
|
||||
}
|
||||
|
||||
function authHeaders(path: string, extra?: Record<string, string>): Record<string, string> {
|
||||
const t = nwToken();
|
||||
const c = activeClinicaId();
|
||||
const c = clinicaForPath(path);
|
||||
return {
|
||||
...(t ? { Authorization: `Bearer ${t}` } : {}),
|
||||
...(c ? { 'x-nw-clinica': c } : {}),
|
||||
@@ -28,7 +53,7 @@ async function req(method: string, path: string, body?: any): Promise<any> {
|
||||
const isForm = typeof FormData !== 'undefined' && body instanceof FormData;
|
||||
const res = await fetch(`${API}/nw/v1${path}`, {
|
||||
method,
|
||||
headers: authHeaders(isForm ? undefined : (body != null ? { 'Content-Type': 'application/json' } : undefined)),
|
||||
headers: authHeaders(path, isForm ? undefined : (body != null ? { 'Content-Type': 'application/json' } : undefined)),
|
||||
body: body == null ? undefined : (isForm ? body : JSON.stringify(body)),
|
||||
});
|
||||
const text = await res.text();
|
||||
|
||||
@@ -86,8 +86,39 @@ export interface CalendarSlot {
|
||||
// ─── 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),
|
||||
get: () => api.get<SessionPower>(`${BASE}/power`).then((r) => r.data),
|
||||
set: (enabled: boolean, reason = '', by = '') => api.post<{ enabled: boolean }>(`${BASE}/power`, { enabled, reason, by }).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),
|
||||
}
|
||||
|
||||
// Override POR CHAT (decisão do dono, precedência máxima). overridden=false → herda
|
||||
// a cascata global/sessão; enabled null nesse caso. set(enabled=null) remove o override.
|
||||
export interface ChatPower {
|
||||
overridden: boolean
|
||||
enabled: boolean | null
|
||||
changed_by: string | null
|
||||
changed_at: string | null
|
||||
}
|
||||
export const secChatPowerApi = {
|
||||
get: (instanceId: string, jid: string) =>
|
||||
api.get<ChatPower>(`${BASE}/chat-power`, { params: { instance_id: instanceId, jid } }).then((r) => r.data),
|
||||
set: (instanceId: string, jid: string, enabled: boolean | null, by: string) =>
|
||||
api.post<{ ok: boolean; overridden: boolean; enabled: boolean | null }>(`${BASE}/chat-power`, { instance_id: instanceId, jid, enabled, by }).then((r) => r.data),
|
||||
}
|
||||
|
||||
// ─── Agents ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -21,6 +21,16 @@ class SocketShim {
|
||||
private listeners = new Map<string, Set<Listener>>();
|
||||
private retry: ReturnType<typeof setTimeout> | null = null;
|
||||
private closedByUser = false;
|
||||
private currentClinica: string | null = null;
|
||||
|
||||
// Clínica da conta ativa da inbox (nw:inbox-account). null = conta própria.
|
||||
private readClinica(): string | null {
|
||||
try {
|
||||
const raw = localStorage.getItem('nw:inbox-account');
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw)?.clinicaId ?? null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
connect(_token?: string) {
|
||||
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) return;
|
||||
@@ -33,6 +43,11 @@ class SocketShim {
|
||||
try { u = new URL(`${abs}/nw/v1/stream`); } catch { return; }
|
||||
u.protocol = u.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
u.searchParams.set('token', token);
|
||||
// Stream por-tenant: p/ a caixa da clínica, o backend usa ?clinica= para reescrever
|
||||
// a conta-alvo (dono) quando o usuário tem can_inbox. null = conta própria.
|
||||
const clinica = this.readClinica();
|
||||
this.currentClinica = clinica;
|
||||
if (clinica) u.searchParams.set('clinica', clinica);
|
||||
|
||||
let ws: WebSocket;
|
||||
try { ws = new WebSocket(u.toString()); } catch { return; }
|
||||
@@ -73,6 +88,20 @@ class SocketShim {
|
||||
this._emit('message:update', m);
|
||||
break;
|
||||
}
|
||||
case 'message.reaction':
|
||||
// Reação recebida numa mensagem: { messageId, emoji, sender }.
|
||||
this._emit('message:reaction', { messageId: data?.messageId, emoji: data?.emoji, sender: data?.sender });
|
||||
break;
|
||||
case 'message.media_ready':
|
||||
this._emit('message:media_ready', { messageId: data?.messageId, mediaUrl: data?.mediaUrl });
|
||||
break;
|
||||
case 'message.transcription':
|
||||
this._emit('message:transcription', { messageId: data?.messageId, transcription: data?.transcription });
|
||||
break;
|
||||
case 'presence':
|
||||
// Presença do CONTATO (composing/recording/paused/available) por jid.
|
||||
this._emit('presence:update', { jid: data?.jid, state: data?.state });
|
||||
break;
|
||||
case 'conversation.handoff': this._emit('handoff', data); break;
|
||||
case 'conversation.escalated': this._emit('escalated', data); break;
|
||||
default: break;
|
||||
@@ -88,6 +117,16 @@ class SocketShim {
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
// Reabre o stream se a conta ativa (clínica) mudou. O realtime é por-tenant, então
|
||||
// alternar entre a caixa própria e a da clínica exige reconectar no tenant certo.
|
||||
syncAccount() {
|
||||
if (this.readClinica() === this.currentClinica && this.ws) return;
|
||||
if (this.retry) clearTimeout(this.retry);
|
||||
try { this.ws?.close(); } catch { /* ignore */ }
|
||||
this.ws = null;
|
||||
this.connect();
|
||||
}
|
||||
|
||||
// Satélite não envia comandos pelo stream (é read-only); mantidos p/ compat.
|
||||
emit(_event: string, ..._args: any[]) { /* no-op */ }
|
||||
joinInstance(_instanceId: string) { /* no-op — stream é tenant-wide */ }
|
||||
|
||||
@@ -8,6 +8,11 @@ import { immer } from 'zustand/middleware/immer'
|
||||
import { socketService } from '../services/socketService'
|
||||
import { chatApi, messageApi } from '../services/chatApiService'
|
||||
|
||||
// Registra os listeners do socket UMA vez por sessão. Sem isto, cada mount do bridge
|
||||
// (inbox do plugin ou drawer da agenda) reempilha handlers no socketService (que só
|
||||
// acumula), fazendo message:new rodar N vezes → unreadCount inflado e reprocessamento.
|
||||
let _chatListenersReady = false
|
||||
|
||||
// ─── Mapper: resposta aninhada do backend → Chat plano do store ────────────────
|
||||
|
||||
function mapBackendChat(raw: any): Chat {
|
||||
@@ -42,6 +47,7 @@ function mapBackendChat(raw: any): Chat {
|
||||
lastMessageSender: raw.lastMessage?.pushName ?? raw.lastMessageSender ?? null,
|
||||
protocolNumber: raw.protocol?.number ?? raw.protocolNumber ?? null,
|
||||
botPaused: raw.botPaused ?? false,
|
||||
labels: Array.isArray(raw.labels) ? raw.labels : [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +77,7 @@ export interface Chat {
|
||||
lastMessageSender: string | null // pushName do remetente (grupos: quem enviou a última msg)
|
||||
protocolNumber: string | null
|
||||
botPaused: boolean
|
||||
labels: Array<{ id: string; name: string; color: string }>
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
@@ -97,6 +104,7 @@ export interface Message {
|
||||
timestamp: string
|
||||
pushName?: string | null
|
||||
senderJid?: string | null
|
||||
reactions?: Record<string, string> | null // { remetente: emoji } — 'me' = minha reação
|
||||
}
|
||||
|
||||
// ─── Estado ───────────────────────────────────────────────────────────────────
|
||||
@@ -107,8 +115,14 @@ interface ChatState {
|
||||
activeChatId: string | null
|
||||
messages: Record<string, Message[]>
|
||||
messagesLoading: boolean
|
||||
// Presença do contato por jid (composing/recording/paused/available) + timestamp.
|
||||
presenceByJid: Record<string, { state: string; ts: number }>
|
||||
// Assinatura do operador ("Nome") — usada para exibir "*Nome:*" na msg otimista.
|
||||
operatorSignature: string | null
|
||||
|
||||
// Sync
|
||||
setPresence: (jid: string, state: string) => void
|
||||
setOperatorSignature: (sig: string | null) => void
|
||||
setChats: (chats: Chat[]) => void
|
||||
setChatsLoading: (v: boolean) => void
|
||||
upsertChat: (chat: Chat) => void
|
||||
@@ -120,12 +134,14 @@ interface ChatState {
|
||||
appendMessage: (msg: Message) => void
|
||||
patchMessage: (msgId: string, patch: Partial<Message>) => void
|
||||
updateMediaReady: (msgId: string, mediaUrl: string) => void
|
||||
reactMessage: (chatId: string, msgId: string, emoji: string, sender?: string) => void
|
||||
|
||||
// Async
|
||||
loadChats: (instanceId: string, opts?: { search?: string; archived?: boolean }) => Promise<void>
|
||||
loadMessages: (chatId: string, opts?: { before?: string }) => Promise<{ hasMore: boolean }>
|
||||
selectChat: (chatId: string) => Promise<void>
|
||||
sendMessage: (instanceId: string, jid: string, text: string, replyToMessageId?: string, mentions?: string[]) => Promise<void>
|
||||
deleteMessage: (chatId: string, messageId: string) => Promise<void>
|
||||
|
||||
// Socket
|
||||
initSocketListeners: () => void
|
||||
@@ -195,6 +211,15 @@ export const useChatStore = create<ChatState>()(
|
||||
activeChatId: null,
|
||||
messages: {},
|
||||
messagesLoading: false,
|
||||
presenceByJid: {},
|
||||
operatorSignature: null,
|
||||
|
||||
// Presença do contato (composing/recording/…): guarda estado + timestamp por jid.
|
||||
setPresence: (jid, state) =>
|
||||
set((s) => { if (jid) s.presenceByJid[jid] = { state, ts: Date.now() } }),
|
||||
|
||||
setOperatorSignature: (sig) =>
|
||||
set((s) => { s.operatorSignature = sig }),
|
||||
|
||||
// ── Chats ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -262,6 +287,17 @@ export const useChatStore = create<ChatState>()(
|
||||
}
|
||||
}),
|
||||
|
||||
// Reação (otimista + eco do WS): emoji vazio remove a reação do remetente.
|
||||
reactMessage: (chatId, msgId, emoji, sender = 'me') =>
|
||||
set((s) => {
|
||||
const list = s.messages[chatId]
|
||||
const msg = list?.find((m) => m.id === msgId || m.messageId === msgId)
|
||||
if (!msg) return
|
||||
const r = { ...(msg.reactions || {}) }
|
||||
if (emoji) r[sender] = emoji; else delete r[sender]
|
||||
msg.reactions = r
|
||||
}),
|
||||
|
||||
// ── Async ─────────────────────────────────────────────────────────────────
|
||||
|
||||
loadChats: async (instanceId, opts = {}) => {
|
||||
@@ -350,13 +386,19 @@ export const useChatStore = create<ChatState>()(
|
||||
? get().messages[chatId]?.find((m) => m.messageId === replyToMessageId) ?? null
|
||||
: null
|
||||
|
||||
// Otimista com a assinatura "*Nome:*\n" JÁ aplicada (mesma que o proxy adiciona no
|
||||
// servidor) — evita o flicker de a msg aparecer sem "Nome:" e "puxar" depois do eco.
|
||||
// IMPORTANTE: envia o texto CRU (o proxy adiciona a assinatura; não duplicar aqui).
|
||||
const sig = get().operatorSignature
|
||||
const optimisticBody = sig ? `*${sig}:*\n${text}` : text
|
||||
|
||||
const optimistic: Message = {
|
||||
id: `opt-${Date.now()}`,
|
||||
chatId,
|
||||
messageId: `opt-${Date.now()}`,
|
||||
fromMe: true,
|
||||
type: 'TEXT',
|
||||
body: text,
|
||||
body: optimisticBody,
|
||||
mediaUrl: null,
|
||||
mimeType: null,
|
||||
fileName: null,
|
||||
@@ -387,9 +429,21 @@ export const useChatStore = create<ChatState>()(
|
||||
}
|
||||
},
|
||||
|
||||
// Apaga a mensagem para todos (revoke no WhatsApp). Só remove do estado se o
|
||||
// backend confirmar — o erro sobe para a UI (ex.: não é sua / instância off).
|
||||
deleteMessage: async (chatId, messageId) => {
|
||||
await chatApi.deleteMessage(chatId, messageId)
|
||||
set((s) => {
|
||||
if (s.messages[chatId]) s.messages[chatId] = s.messages[chatId].filter((m) => m.id !== messageId)
|
||||
})
|
||||
},
|
||||
|
||||
// ── Socket ────────────────────────────────────────────────────────────────
|
||||
|
||||
initSocketListeners: () => {
|
||||
// Idempotente: só registra uma vez (evita listeners duplicados entre mounts).
|
||||
if (_chatListenersReady) return
|
||||
_chatListenersReady = true
|
||||
// IMPORTANTE: sempre usar get() dentro dos callbacks para ler estado atual.
|
||||
// Capturar `const store = get()` fora criaria uma closure stale.
|
||||
|
||||
@@ -423,6 +477,26 @@ export const useChatStore = create<ChatState>()(
|
||||
get().patchMessage(messageId, { status })
|
||||
})
|
||||
|
||||
socketService.on('presence:update', ({ jid, state }: { jid?: string; state?: string }) => {
|
||||
if (jid && state) get().setPresence(jid, state)
|
||||
})
|
||||
|
||||
// Reação recebida: acha a mensagem pelo messageId (WA) em qualquer chat carregado.
|
||||
socketService.on('message:reaction', ({ messageId, emoji, sender }: { messageId?: string; emoji?: string; sender?: string }) => {
|
||||
if (!messageId) return
|
||||
set((s) => {
|
||||
for (const list of Object.values(s.messages)) {
|
||||
const msg = list.find((m) => m.messageId === messageId)
|
||||
if (msg) {
|
||||
const r = { ...(msg.reactions || {}) }
|
||||
if (emoji) r[sender || 'contato'] = emoji; else delete r[sender || 'contato']
|
||||
msg.reactions = r
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
socketService.on('chat:upsert', (raw: any) => {
|
||||
get().upsertChat(mapBackendChat(raw))
|
||||
})
|
||||
|
||||
@@ -4,10 +4,13 @@
|
||||
*/
|
||||
import { create } from 'zustand'
|
||||
import { immer } from 'zustand/middleware/immer'
|
||||
import { instanceApi } from '../services/chatApiService'
|
||||
import { instanceApi, accountsApi } from '../services/chatApiService'
|
||||
import { socketService } from '../services/socketService'
|
||||
import { notify } from './notificationStore'
|
||||
|
||||
// Registra os listeners do socket UMA vez por sessão (evita acúmulo entre mounts).
|
||||
let _instanceListenersReady = false
|
||||
|
||||
export type InstanceStatus = 'DISCONNECTED' | 'CONNECTING' | 'QR_PENDING' | 'CONNECTED' | 'BANNED'
|
||||
|
||||
export interface Instance {
|
||||
@@ -16,6 +19,15 @@ export interface Instance {
|
||||
status: InstanceStatus
|
||||
phone: string | null
|
||||
avatar: string | null
|
||||
// Metadados do seletor multi-conta (vêm de /api/nw/accounts):
|
||||
role?: string | null // papel (sec_numbers): doctor/clinic/…
|
||||
label?: string | null // rótulo livre ("Consultt Clinic – Recepção")
|
||||
area?: string | null
|
||||
notes?: string | null
|
||||
clinicaId?: string | null // workspace da conta (null = conta própria)
|
||||
ownerEmail?: string | null
|
||||
ownerNome?: string | null
|
||||
own?: boolean // true = número da própria conta do usuário
|
||||
}
|
||||
|
||||
interface InstanceState {
|
||||
@@ -85,10 +97,24 @@ export const useInstanceStore = create<InstanceState>()(
|
||||
loadInstances: async () => {
|
||||
set((s) => { s.loading = true })
|
||||
try {
|
||||
const data = await instanceApi.list()
|
||||
const list: Instance[] = (Array.isArray(data) ? data : (data.instances ?? [])).map((i: any) => ({
|
||||
...i,
|
||||
avatar: i.avatar ?? null,
|
||||
// Fonte: /api/nw/accounts — inclui os números PRÓPRIOS e os LIBERADOS pelo
|
||||
// dono (can_inbox), cada um com papel/rótulo. Assim o seletor da inbox mostra
|
||||
// a caixa própria E a da clínica sem trocar de login.
|
||||
const data = await accountsApi.list()
|
||||
const list: Instance[] = data.map((a) => ({
|
||||
id: a.instanceId,
|
||||
name: a.name || a.phone || a.instanceId,
|
||||
status: (a.status as InstanceStatus) || 'DISCONNECTED',
|
||||
phone: a.phone ?? null,
|
||||
avatar: a.avatar ?? null,
|
||||
role: a.role ?? null,
|
||||
label: a.label ?? null,
|
||||
area: a.area ?? null,
|
||||
notes: a.notes ?? null,
|
||||
clinicaId: a.clinicaId ?? null,
|
||||
ownerEmail: a.ownerEmail ?? null,
|
||||
ownerNome: a.ownerNome ?? null,
|
||||
own: a.own ?? true,
|
||||
}))
|
||||
set((s) => {
|
||||
s.loading = false
|
||||
@@ -104,6 +130,13 @@ export const useInstanceStore = create<InstanceState>()(
|
||||
}
|
||||
return fromDb
|
||||
})
|
||||
// Segurança: se a instância ativa persistida NÃO está na lista acessível
|
||||
// (ex.: superadmin após outro login, ou acesso revogado), reseta — para
|
||||
// não abrir a inbox de uma sessão que este usuário não pode ver.
|
||||
if (s.activeInstanceId && !list.some((i) => i.id === s.activeInstanceId)) {
|
||||
s.activeInstanceId = null
|
||||
try { localStorage.removeItem('activeInstanceId') } catch { /* ignore */ }
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
set((s) => { s.loading = false })
|
||||
@@ -139,6 +172,8 @@ export const useInstanceStore = create<InstanceState>()(
|
||||
// ── Socket ────────────────────────────────────────────────────────────────
|
||||
|
||||
initSocketListeners: () => {
|
||||
if (_instanceListenersReady) return
|
||||
_instanceListenersReady = true
|
||||
socketService.on('instance:status', ({
|
||||
instanceId,
|
||||
status,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
// Papéis de número (sec_numbers) — rótulo em PT + cor. Usado pelo seletor de número
|
||||
// da inbox e pelo ícone (i) de legenda. Espelha o ROLE_META da Secretária, porém
|
||||
// leve (sem ícones lucide), para badges e pontos coloridos.
|
||||
export const ROLE_LABELS: Record<string, { label: string; color: string }> = {
|
||||
secretary_virtual: { label: 'Secretária Virtual', color: '#8b5cf6' },
|
||||
clinic: { label: 'Clínica / Recepção', color: '#3b82f6' },
|
||||
doctor: { label: 'Dentista / Dr.', color: '#10b981' },
|
||||
specialist: { label: 'Especialista', color: '#a855f7' },
|
||||
manager: { label: 'Gerente', color: '#f97316' },
|
||||
reserve: { label: 'Reserva / Sala', color: '#eab308' },
|
||||
human_secretary: { label: 'Secretária', color: '#f43f5e' },
|
||||
}
|
||||
|
||||
export function roleMeta(role?: string | null): { label: string; color: string } {
|
||||
return (role && ROLE_LABELS[role]) || { label: 'Sem papel definido', color: '#9ca3af' }
|
||||
}
|
||||
@@ -147,6 +147,7 @@ const VitrineProtetico: React.FC<{ proteticoId: string }> = ({ proteticoId }) =>
|
||||
);
|
||||
};
|
||||
import { PageHeader } from '../../components/PageHeader.tsx';
|
||||
import { SegmentedTabs } from '../../components/SegmentedTabs.tsx';
|
||||
import { useToast } from '../../contexts/ToastContext.tsx';
|
||||
import { useConfirm } from '../../contexts/ConfirmContext.tsx';
|
||||
import { HybridBackend } from '../../services/backend.ts';
|
||||
@@ -229,9 +230,8 @@ const CAT_META: Record<CatModelo, { label: string; icon: any }> = {
|
||||
|
||||
const inputCls = 'w-full px-3 py-2.5 bg-gray-50 border border-gray-200 rounded-xl text-xs font-bold text-gray-700 outline-none focus:border-teal-400';
|
||||
const labelCls = 'text-[10px] font-black text-gray-400 uppercase tracking-widest block mb-1';
|
||||
// Estilo da busca (versão nova) — rótulos title-case e campos maiores/arredondados.
|
||||
const inputNew = 'w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl text-sm font-semibold text-gray-700 outline-none focus:border-teal-400 focus:bg-white transition-colors';
|
||||
const labelNew = 'text-[11px] font-bold text-gray-500 mb-1.5 block';
|
||||
// Estilo padrão de filtros — fonte única em components/filterStyles.
|
||||
import { inputNew, labelNew } from '../../components/filterStyles.tsx';
|
||||
|
||||
// ─── Modal: enviar proposta ──────────────────────────────────────────────────
|
||||
const PropostaModal: React.FC<{ prof: Profissional; onClose: () => void; onSent: () => void }> = ({ prof, onClose, onSent }) => {
|
||||
@@ -705,22 +705,8 @@ export const ProfissionaisPlugin: React.FC = () => {
|
||||
/>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{TABS.filter(t => t.show).map(t => {
|
||||
const Icon = t.icon;
|
||||
const active = tab === t.id;
|
||||
return (
|
||||
<button key={t.id} onClick={() => setTab(t.id)}
|
||||
className={`flex items-center gap-2 px-5 py-2.5 rounded-xl text-[11px] font-black uppercase tracking-wider transition-all ${active ? 'text-white shadow-md' : 'bg-white text-gray-500 border border-gray-200 hover:bg-gray-50'}`}
|
||||
style={active ? { backgroundColor: COLOR } : {}}>
|
||||
<Icon size={14} /> {t.label}
|
||||
{t.id === 'propostas' && pendentesRecebidas > 0 && (
|
||||
<span className={`text-[9px] font-black px-1.5 py-0.5 rounded-full ${active ? 'bg-white/25' : 'bg-amber-100 text-amber-700'}`}>{pendentesRecebidas}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<SegmentedTabs accent={COLOR} value={tab} onChange={setTab}
|
||||
tabs={TABS.filter(t => t.show).map(t => ({ id: t.id, label: t.label, icon: t.icon, badge: t.id === 'propostas' && pendentesRecebidas > 0 ? pendentesRecebidas : undefined }))} />
|
||||
|
||||
{/* BUSCAR — bloqueia se faltar endereço/CEP da origem */}
|
||||
{tab === 'buscar' && enderecoOk === false && (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
DoorOpen, MapPin, Search, RefreshCw, Plus, Pencil, Trash2, X, Save,
|
||||
CalendarClock, CheckCircle2, XCircle, Clock, Building2, Briefcase, Home, Inbox, Send
|
||||
CalendarClock, CheckCircle2, XCircle, Clock, Building2, Briefcase, Home, Inbox, Send, Eraser, ChevronUp, ChevronLeft, ChevronRight, FileText
|
||||
} from 'lucide-react';
|
||||
import { PageHeader } from '../../components/PageHeader.tsx';
|
||||
import { useToast } from '../../contexts/ToastContext.tsx';
|
||||
@@ -41,6 +41,7 @@ interface Sala {
|
||||
valor_diaria: string | null;
|
||||
valor_semanal: string | null;
|
||||
valor_mensal: string | null;
|
||||
contrato?: string | null;
|
||||
hora_consulta?: boolean;
|
||||
turno_consulta?: boolean;
|
||||
diaria_consulta?: boolean;
|
||||
@@ -106,8 +107,88 @@ const precos = (s: Sala): Array<[string, string]> => {
|
||||
.map(([l, v]) => [l, fmtValor(v as string | null) || 'Sob consulta'] as [string, string]);
|
||||
};
|
||||
|
||||
// ── Calendário de reserva ────────────────────────────────────────────────────
|
||||
const MODELOS: Array<{ id: string; label: string; valor: keyof Sala; consulta: keyof Sala }> = [
|
||||
{ id: 'hora', label: 'Hora', valor: 'valor_hora', consulta: 'hora_consulta' },
|
||||
{ id: 'turno', label: 'Período', valor: 'valor_turno', consulta: 'turno_consulta' },
|
||||
{ id: 'diaria', label: 'Dia', valor: 'valor_diaria', consulta: 'diaria_consulta' },
|
||||
{ id: 'semanal', label: 'Semana', valor: 'valor_semanal', consulta: 'semanal_consulta' },
|
||||
{ id: 'mensal', label: 'Mês', valor: 'valor_mensal', consulta: 'mensal_consulta' },
|
||||
];
|
||||
const TURNOS: Record<string, { label: string; ini: number; fim: number }> = {
|
||||
manha: { label: 'Manhã', ini: 8, fim: 12 },
|
||||
tarde: { label: 'Tarde', ini: 13, fim: 18 },
|
||||
noite: { label: 'Noite', ini: 18, fim: 22 },
|
||||
};
|
||||
const WEEKDAYS = ['D', 'S', 'T', 'Q', 'Q', 'S', 'S'];
|
||||
const MESES = ['Janeiro', 'Fevereiro', 'Março', 'Abril', 'Maio', 'Junho', 'Julho', 'Agosto', 'Setembro', 'Outubro', 'Novembro', 'Dezembro'];
|
||||
const pad2 = (n: number) => String(n).padStart(2, '0');
|
||||
const toLocalInput = (d: Date) => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}T${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
||||
const mesmoDia = (a: Date, b: Date) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
|
||||
// Células do mês (com padding p/ alinhar ao dia da semana).
|
||||
const monthGrid = (ref: Date): (Date | null)[] => {
|
||||
const first = new Date(ref.getFullYear(), ref.getMonth(), 1);
|
||||
const dias = new Date(ref.getFullYear(), ref.getMonth() + 1, 0).getDate();
|
||||
const cells: (Date | null)[] = [];
|
||||
for (let i = 0; i < first.getDay(); i++) cells.push(null);
|
||||
for (let d = 1; d <= dias; d++) cells.push(new Date(ref.getFullYear(), ref.getMonth(), d));
|
||||
return cells;
|
||||
};
|
||||
// Início/fim conforme o modelo escolhido.
|
||||
const computeRange = (dia: Date, modelo: string, hora: string, qtd: number, turno: string): { ini: Date; fim: Date } => {
|
||||
const b = new Date(dia); b.setHours(0, 0, 0, 0);
|
||||
const ini = new Date(b), fim = new Date(b);
|
||||
const n = Math.max(1, qtd);
|
||||
if (modelo === 'hora') {
|
||||
const [h, m] = hora.split(':').map(Number); ini.setHours(h || 8, m || 0, 0, 0);
|
||||
fim.setTime(ini.getTime() + n * 3600_000);
|
||||
} else if (modelo === 'turno') {
|
||||
const t = TURNOS[turno] || TURNOS.manha; ini.setHours(t.ini, 0, 0, 0); fim.setHours(t.fim, 0, 0, 0);
|
||||
} else if (modelo === 'semanal') {
|
||||
ini.setHours(8, 0, 0, 0); fim.setDate(fim.getDate() + (n * 7 - 1)); fim.setHours(18, 0, 0, 0);
|
||||
} else if (modelo === 'mensal') {
|
||||
ini.setHours(8, 0, 0, 0); fim.setMonth(fim.getMonth() + n); fim.setDate(fim.getDate() - 1); fim.setHours(18, 0, 0, 0);
|
||||
} else { // diária — n dias consecutivos
|
||||
ini.setHours(8, 0, 0, 0); fim.setDate(fim.getDate() + (n - 1)); fim.setHours(18, 0, 0, 0);
|
||||
}
|
||||
return { ini, fim };
|
||||
};
|
||||
|
||||
// Quantidade máxima por modelo + rótulo da unidade.
|
||||
const UNIDADE: Record<string, { sing: string; plur: string; max: number }> = {
|
||||
hora: { sing: 'hora', plur: 'horas', max: 12 },
|
||||
diaria: { sing: 'dia', plur: 'dias', max: 30 },
|
||||
semanal: { sing: 'semana', plur: 'semanas', max: 8 },
|
||||
mensal: { sing: 'mês', plur: 'meses', max: 12 },
|
||||
};
|
||||
|
||||
// Modelo padrão de contrato de locação — o dono pode editar por sala; se em branco, vale este.
|
||||
const CONTRATO_MODELO = `CONTRATO DE LOCAÇÃO DE ESPAÇO / SALA
|
||||
|
||||
1. OBJETO
|
||||
A presente locação tem por objeto a cessão temporária do espaço/sala identificado nesta plataforma, para uso profissional durante o período reservado.
|
||||
|
||||
2. RESPONSABILIDADE
|
||||
Todos os procedimentos, atendimentos e serviços realizados no espaço durante o período locado são de INTEIRA E EXCLUSIVA RESPONSABILIDADE DO LOCATÁRIO (quem aluga), abrangendo as esferas civil, criminal, ética, sanitária e trabalhista, perante pacientes, terceiros e órgãos de classe. O LOCADOR (dono da sala) não responde por qualquer ato praticado pelo locatário.
|
||||
|
||||
3. USO E CONSERVAÇÃO
|
||||
O locatário compromete-se a utilizar o espaço e os equipamentos com zelo, devolvendo-os nas mesmas condições em que os recebeu. Eventuais danos serão integralmente ressarcidos pelo locatário.
|
||||
|
||||
4. REGULARIDADE PROFISSIONAL
|
||||
O locatário declara possuir registro profissional ativo e todas as licenças e autorizações exigidas para o exercício de sua atividade, isentando o locador de qualquer responsabilidade a esse respeito.
|
||||
|
||||
5. PAGAMENTO
|
||||
O valor e a forma de pagamento são os informados no pedido de reserva e combinados diretamente entre as partes.
|
||||
|
||||
6. CANCELAMENTO
|
||||
Cancelamentos devem ser comunicados com antecedência razoável, conforme acordado entre as partes.
|
||||
|
||||
Ao aceitar, o LOCATÁRIO declara ter lido e concordado integralmente com os termos acima.`;
|
||||
|
||||
const inputCls = 'w-full px-3 py-2.5 bg-gray-50 border border-gray-200 rounded-xl text-xs font-bold text-gray-700 outline-none focus:border-teal-400';
|
||||
const labelCls = 'text-[10px] font-black text-gray-400 uppercase tracking-widest block mb-1';
|
||||
// Estilo padrão de filtros — fonte única em components/filterStyles.
|
||||
import { inputNew, labelNew } from '../../components/filterStyles.tsx';
|
||||
|
||||
// ─── Modal: cadastrar / editar sala ──────────────────────────────────────────
|
||||
const SalaFormModal: React.FC<{ sala: Sala | null; onClose: () => void; onSaved: () => void }> = ({ sala, onClose, onSaved }) => {
|
||||
@@ -127,6 +208,7 @@ const SalaFormModal: React.FC<{ sala: Sala | null; onClose: () => void; onSaved:
|
||||
diariaConsulta: sala?.diaria_consulta === true, semanalConsulta: sala?.semanal_consulta === true,
|
||||
mensalConsulta: sala?.mensal_consulta === true,
|
||||
disponivel: sala?.disponivel !== false,
|
||||
contrato: sala?.contrato ?? CONTRATO_MODELO,
|
||||
});
|
||||
const set = (k: string, v: any) => setF(p => ({ ...p, [k]: v }));
|
||||
|
||||
@@ -193,6 +275,14 @@ const SalaFormModal: React.FC<{ sala: Sala | null; onClose: () => void; onSaved:
|
||||
<label className={labelCls}>Equipamentos</label>
|
||||
<input className={inputCls} value={f.equipamentos} onChange={e => set('equipamentos', e.target.value)} placeholder="CADEIRA, FOTOPOLIMERIZADOR, RX..." />
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest flex items-center gap-1.5"><FileText size={12} /> Contrato de locação</label>
|
||||
<button type="button" onClick={() => set('contrato', CONTRATO_MODELO)} className="text-[9px] font-black uppercase text-teal-600 hover:text-teal-700">Restaurar modelo</button>
|
||||
</div>
|
||||
<textarea className={`${inputCls} font-medium normal-case`} rows={6} value={f.contrato} onChange={e => set('contrato', e.target.value)} placeholder="Termos do contrato que o locatário deverá aceitar ao reservar..." />
|
||||
<p className="text-[9px] text-gray-400 font-bold uppercase mt-1">Exibido ao locatário no passo de pagamento; ele precisa aceitar para reservar. Já preenchemos um modelo — edite como quiser.</p>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className={labelCls}>Endereço</label>
|
||||
<input className={inputCls} value={f.endereco} onChange={e => set('endereco', e.target.value)} placeholder="RUA / NÚMERO / COMPLEMENTO" />
|
||||
@@ -276,6 +366,53 @@ const ReservaModal: React.FC<{ sala: Sala; onClose: () => void; onReserved: () =
|
||||
const [profissionais, setProfissionais] = useState<Array<{ usuario_id: string; nome: string }>>([]);
|
||||
const [profissionalId, setProfissionalId] = useState('');
|
||||
|
||||
// ── Calendário de reserva ──
|
||||
const hoje0 = new Date(); hoje0.setHours(0, 0, 0, 0);
|
||||
const [mesRef, setMesRef] = useState(new Date(hoje0.getFullYear(), hoje0.getMonth(), 1));
|
||||
const [diaSel, setDiaSel] = useState<Date | null>(null);
|
||||
const [diasMulti, setDiasMulti] = useState<Date[]>([]); // modelo "Dia": dias avulsos (1, 5, 20…)
|
||||
const [hora, setHora] = useState('08:00');
|
||||
const [qtd, setQtd] = useState(1);
|
||||
const [turno, setTurno] = useState('manha');
|
||||
const HORAS = Array.from({ length: 29 }, (_, i) => `${pad2(7 + Math.floor(i / 2))}:${i % 2 ? '30' : '00'}`); // 07:00–21:00
|
||||
const modelosDisp = MODELOS.filter(m => fmtValor(sala[m.valor] as any) || sala[m.consulta]);
|
||||
const diasOcupados = new Set(ocupadas.map(o => new Date(o.inicio).toDateString()));
|
||||
const ocupadasDoDia = diaSel ? ocupadas.filter(o => new Date(o.inicio).toDateString() === diaSel.toDateString()) : [];
|
||||
const rangeFim = diaSel ? computeRange(diaSel, f.modelo, hora, qtd, turno).fim : null;
|
||||
const diasMultiSorted = [...diasMulti].sort((a, b) => a.getTime() - b.getTime());
|
||||
const temSelecao = f.modelo === 'diaria' ? diasMulti.length > 0 : !!diaSel;
|
||||
|
||||
// ── Wizard (4 passos, formato slide) ──
|
||||
const [step, setStep] = useState(0);
|
||||
const [formaPagamento, setFormaPagamento] = useState('pix');
|
||||
const [showContrato, setShowContrato] = useState(false);
|
||||
const [contratoAceito, setContratoAceito] = useState(false);
|
||||
const contratoTexto = (sala.contrato && sala.contrato.trim()) || CONTRATO_MODELO;
|
||||
const STEPS = ['Dados', 'Calendário', 'Resumo', 'Pagamento'];
|
||||
const FORMAS: Record<string, string> = { pix: 'PIX', dinheiro: 'Dinheiro', transferencia: 'Transferência', cartao: 'Cartão' };
|
||||
const modeloLabel = MODELOS.find(m => m.id === f.modelo)?.label || '';
|
||||
const total = (() => {
|
||||
const m = MODELOS.find(x => x.id === f.modelo); const v = m ? Number(sala[m.valor]) : NaN;
|
||||
if (!Number.isFinite(v)) return 0;
|
||||
if (f.modelo === 'turno') return v;
|
||||
if (f.modelo === 'diaria') return v * diasMulti.length;
|
||||
return v * Math.max(1, qtd);
|
||||
})();
|
||||
|
||||
// Modelo padrão = 1º disponível.
|
||||
useEffect(() => { if (modelosDisp.length && !modelosDisp.some(m => m.id === f.modelo)) setF(p => ({ ...p, modelo: modelosDisp[0].id })); /* eslint-disable-next-line */ }, []);
|
||||
// Recalcula início/fim conforme dia + modelo + horário.
|
||||
useEffect(() => {
|
||||
if (!diaSel) return;
|
||||
const { ini, fim } = computeRange(diaSel, f.modelo, hora, qtd, turno);
|
||||
setF(p => ({ ...p, inicio: toLocalInput(ini), fim: toLocalInput(fim) }));
|
||||
}, [diaSel, f.modelo, hora, qtd, turno]);
|
||||
// Ao trocar de modelo, volta a quantidade para 1 e limpa a multi-seleção de dias.
|
||||
useEffect(() => { setQtd(1); setDiasMulti([]); }, [f.modelo]);
|
||||
// Depois de escolher o(s) dia(s), revela os controles (que ficam abaixo do calendário).
|
||||
const ctrlRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => { if (diaSel || diasMulti.length) ctrlRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }, [diaSel, diasMulti.length, f.modelo]);
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch(`/api/salas/${sala.id}/reservas`)
|
||||
.then(r => (r.ok ? r.json() : []))
|
||||
@@ -293,20 +430,32 @@ const ReservaModal: React.FC<{ sala: Sala; onClose: () => void; onReserved: () =
|
||||
}, [comoUnidade]);
|
||||
|
||||
const handleReserve = async () => {
|
||||
if (!f.inicio || !f.fim) { toast.error('INFORME INÍCIO E FIM.'); return; }
|
||||
// Modelo "Dia" → uma reserva por dia avulso selecionado; demais → um único intervalo.
|
||||
const ranges = f.modelo === 'diaria'
|
||||
? diasMultiSorted.map(d => computeRange(d, 'diaria', hora, 1, turno))
|
||||
: (f.inicio && f.fim ? [{ ini: new Date(f.inicio), fim: new Date(f.fim) }] : []);
|
||||
if (!ranges.length) { toast.error(f.modelo === 'diaria' ? 'SELECIONE AO MENOS UM DIA.' : 'INFORME INÍCIO E FIM.'); return; }
|
||||
if (!contratoAceito) { toast.error('É PRECISO ACEITAR O CONTRATO DE LOCAÇÃO.'); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await apiFetch(`/api/salas/${sala.id}/reservas`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
inicio: new Date(f.inicio).toISOString(), fim: new Date(f.fim).toISOString(), modelo: f.modelo, observacoes: f.observacoes || null,
|
||||
clinicaId: comoUnidade && ws?.id ? ws.id : null,
|
||||
profissionalUsuarioId: comoUnidade && profissionalId ? profissionalId : null,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Erro ao reservar.');
|
||||
toast.success('RESERVA SOLICITADA! AGUARDE A CONFIRMAÇÃO DO LOCADOR.');
|
||||
const obs = [f.observacoes, `Forma de pagamento: ${FORMAS[formaPagamento] || formaPagamento}`, 'Contrato de locação: aceito pelo locatário'].filter(Boolean).join(' · ') || null;
|
||||
const base = {
|
||||
modelo: f.modelo, observacoes: obs,
|
||||
clinicaId: comoUnidade && ws?.id ? ws.id : null,
|
||||
profissionalUsuarioId: comoUnidade && profissionalId ? profissionalId : null,
|
||||
};
|
||||
let ok = 0; const erros: string[] = [];
|
||||
for (const r of ranges) {
|
||||
const res = await apiFetch(`/api/salas/${sala.id}/reservas`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ...base, inicio: r.ini.toISOString(), fim: r.fim.toISOString() }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (res.ok) ok++; else erros.push(data.error || 'erro');
|
||||
}
|
||||
if (!ok) throw new Error(erros[0] || 'Erro ao reservar.');
|
||||
toast.success(ranges.length > 1 ? `${ok} RESERVAS SOLICITADAS! AGUARDE A CONFIRMAÇÃO DO LOCADOR.` : 'RESERVA SOLICITADA! AGUARDE A CONFIRMAÇÃO DO LOCADOR.');
|
||||
if (erros.length) toast.error(`${erros.length} DIA(S) NÃO PUDERAM SER RESERVADOS (JÁ OCUPADOS?).`);
|
||||
onReserved();
|
||||
onClose();
|
||||
} catch (e: any) { toast.error(e.message.toUpperCase()); }
|
||||
@@ -315,81 +464,331 @@ const ReservaModal: React.FC<{ sala: Sala; onClose: () => void; onReserved: () =
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center backdrop-blur-sm p-4">
|
||||
<div className="bg-white rounded-2xl w-full max-w-md shadow-2xl overflow-hidden max-h-[92vh] overflow-y-auto">
|
||||
<div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-black text-gray-900 uppercase">RESERVAR {sala.nome}</h3>
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest" style={{ color: COLOR }}>{[sala.cidade, sala.estado].filter(Boolean).join(' · ')}</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-xl transition-colors"><X size={18} /></button>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
{ws?.id && (
|
||||
<div className="bg-white rounded-[1.5rem] w-full max-w-lg shadow-2xl overflow-hidden max-h-[92vh] flex flex-col">
|
||||
{/* Header + stepper */}
|
||||
<div className="px-6 pt-5 pb-4 border-b border-gray-100 shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className={labelCls}>Reservar como</label>
|
||||
<div className="flex gap-2">
|
||||
{[{ v: false, l: 'PESSOAL' }, { v: true, l: ws.nome?.toUpperCase() || 'UNIDADE' }].map(o => (
|
||||
<button key={String(o.v)} type="button" onClick={() => setComoUnidade(o.v)}
|
||||
className={`flex-1 py-2.5 rounded-xl text-[10px] font-black uppercase border transition-all truncate px-2 ${comoUnidade === o.v ? 'text-white border-transparent' : 'bg-gray-50 text-gray-500 border-gray-200 hover:bg-gray-100'}`}
|
||||
style={comoUnidade === o.v ? { backgroundColor: COLOR } : {}}>
|
||||
{o.l}
|
||||
</button>
|
||||
))}
|
||||
<h3 className="text-sm font-black text-gray-900 uppercase">Reservar {sala.nome}</h3>
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest" style={{ color: COLOR }}>{[sala.cidade, sala.estado].filter(Boolean).join(' · ')}</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-xl transition-colors"><X size={18} /></button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-4">
|
||||
{STEPS.map((s, i) => (
|
||||
<button key={s} type="button" onClick={() => i < step && setStep(i)} className="flex-1 flex flex-col items-center gap-1.5">
|
||||
<div className={`w-full h-1.5 rounded-full transition-colors ${i <= step ? '' : 'bg-gray-100'}`} style={i <= step ? { backgroundColor: COLOR } : {}} />
|
||||
<span className={`text-[8px] font-black uppercase tracking-wide ${i === step ? 'text-gray-700' : 'text-gray-300'}`}>{s}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Slides */}
|
||||
<div className="flex-1 overflow-hidden min-h-[340px]">
|
||||
<div className="flex h-full transition-transform duration-300 ease-out" style={{ transform: `translateX(-${step * 100}%)` }}>
|
||||
|
||||
{/* PASSO 1 — Dados */}
|
||||
<div className="w-full shrink-0 overflow-y-auto p-6 space-y-4">
|
||||
{ws?.id && (
|
||||
<div>
|
||||
<label className={labelCls}>Reservar como</label>
|
||||
<div className="flex gap-2">
|
||||
{[{ v: false, l: 'PESSOAL' }, { v: true, l: ws.nome?.toUpperCase() || 'UNIDADE' }].map(o => (
|
||||
<button key={String(o.v)} type="button" onClick={() => setComoUnidade(o.v)}
|
||||
className={`flex-1 py-2.5 rounded-xl text-[10px] font-black uppercase border transition-all truncate px-2 ${comoUnidade === o.v ? 'text-white border-transparent' : 'bg-gray-50 text-gray-500 border-gray-200 hover:bg-gray-100'}`}
|
||||
style={comoUnidade === o.v ? { backgroundColor: COLOR } : {}}>{o.l}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{comoUnidade && (
|
||||
<div>
|
||||
<label className={labelCls}>Profissional que vai usar a sala (opcional)</label>
|
||||
<select className={inputCls} value={profissionalId} onChange={e => setProfissionalId(e.target.value)}>
|
||||
<option value="">EU MESMO ({HybridBackend.getCurrentUserName()?.toUpperCase() || 'SOLICITANTE'})</option>
|
||||
{profissionais.map(p => <option key={p.usuario_id} value={p.usuario_id}>{p.nome?.toUpperCase()}</option>)}
|
||||
</select>
|
||||
<p className="text-[9px] text-gray-400 font-bold uppercase mt-1">O anti-conflito verifica a agenda do profissional selecionado</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className={labelCls}>Modelo de locação</label>
|
||||
<div className="grid grid-cols-3 sm:grid-cols-5 gap-2 mt-1">
|
||||
{(modelosDisp.length ? modelosDisp : [MODELOS[0]]).map(m => {
|
||||
const ativo = f.modelo === m.id;
|
||||
const preco = fmtValor(sala[m.valor] as any) || (sala[m.consulta] ? 'sob consulta' : '—');
|
||||
return (
|
||||
<button key={m.id} type="button" onClick={() => setF(p => ({ ...p, modelo: m.id }))}
|
||||
className={`p-2.5 rounded-xl border text-center transition-all ${ativo ? 'text-white border-transparent shadow-sm' : 'bg-gray-50 text-gray-600 border-gray-200 hover:bg-gray-100'}`}
|
||||
style={ativo ? { backgroundColor: COLOR } : {}}>
|
||||
<div className="text-[11px] font-black uppercase leading-none">{m.label}</div>
|
||||
<div className={`text-[9px] font-bold mt-1 leading-none ${ativo ? 'text-white/80' : 'text-gray-400'}`}>{preco}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Observações</label>
|
||||
<textarea className={inputCls} rows={4} value={f.observacoes} onChange={e => setF(p => ({ ...p, observacoes: e.target.value }))} placeholder="Alguma informação para o locador…" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{comoUnidade && (
|
||||
<div>
|
||||
<label className={labelCls}>Profissional que vai usar a sala (opcional)</label>
|
||||
<select className={inputCls} value={profissionalId} onChange={e => setProfissionalId(e.target.value)}>
|
||||
<option value="">EU MESMO ({HybridBackend.getCurrentUserName()?.toUpperCase() || 'SOLICITANTE'})</option>
|
||||
{profissionais.map(p => <option key={p.usuario_id} value={p.usuario_id}>{p.nome?.toUpperCase()}</option>)}
|
||||
</select>
|
||||
<p className="text-[9px] text-gray-400 font-bold uppercase mt-1">O anti-conflito verifica a agenda do profissional selecionado</p>
|
||||
|
||||
{/* PASSO 2 — Calendário */}
|
||||
<div className="w-full shrink-0 overflow-y-auto p-6 space-y-4">
|
||||
<div className="rounded-2xl border border-gray-100 p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<button type="button" onClick={() => setMesRef(m => new Date(m.getFullYear(), m.getMonth() - 1, 1))} className="p-1.5 rounded-lg hover:bg-gray-100 text-gray-500"><ChevronLeft size={18} /></button>
|
||||
<span className="text-xs font-black text-gray-800 uppercase tracking-wide">{MESES[mesRef.getMonth()]} {mesRef.getFullYear()}</span>
|
||||
<button type="button" onClick={() => setMesRef(m => new Date(m.getFullYear(), m.getMonth() + 1, 1))} className="p-1.5 rounded-lg hover:bg-gray-100 text-gray-500"><ChevronRight size={18} /></button>
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-1 mb-1">
|
||||
{WEEKDAYS.map((w, i) => <div key={i} className="text-center text-[9px] font-black text-gray-300 uppercase py-1">{w}</div>)}
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{monthGrid(mesRef).map((dia, i) => {
|
||||
if (!dia) return <div key={i} />;
|
||||
const passado = dia < hoje0;
|
||||
const sel = f.modelo === 'diaria'
|
||||
? diasMulti.some(d => mesmoDia(d, dia))
|
||||
: !!(diaSel && mesmoDia(dia, diaSel));
|
||||
const emRange = !!(diaSel && rangeFim && (f.modelo === 'semanal' || f.modelo === 'mensal') && dia > diaSel && dia <= rangeFim);
|
||||
const ocupado = diasOcupados.has(dia.toDateString());
|
||||
const onPick = () => {
|
||||
if (f.modelo === 'diaria') {
|
||||
setDiasMulti(prev => prev.some(d => mesmoDia(d, dia)) ? prev.filter(d => !mesmoDia(d, dia)) : [...prev, dia]);
|
||||
} else {
|
||||
setDiaSel(dia);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<button key={i} type="button" disabled={passado} onClick={onPick}
|
||||
className={`relative h-9 rounded-lg text-xs font-bold flex items-center justify-center transition-all ${passado ? 'text-gray-200 cursor-not-allowed' : sel ? 'text-white shadow' : emRange ? 'text-teal-700' : 'text-gray-600 hover:bg-gray-100'}`}
|
||||
style={sel ? { backgroundColor: COLOR } : emRange ? { backgroundColor: `${COLOR}22` } : {}}>
|
||||
{dia.getDate()}
|
||||
{ocupado && !sel && <span className="absolute bottom-1 w-1 h-1 rounded-full bg-amber-400" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{temSelecao && (
|
||||
<div ref={ctrlRef} className="rounded-2xl border-2 p-4 space-y-3" style={{ borderColor: `${COLOR}33`, backgroundColor: `${COLOR}0a` }}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-[9px] font-black text-gray-400 uppercase tracking-widest">Selecionado</p>
|
||||
<p className="text-xs font-black text-gray-800">
|
||||
{f.modelo === 'diaria'
|
||||
? `${diasMulti.length} ${diasMulti.length === 1 ? 'dia' : 'dias'}`
|
||||
: `${diaSel?.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' })}${(f.modelo === 'semanal' || f.modelo === 'mensal') && rangeFim ? ` → ${rangeFim.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' })}` : ''}`}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-[10px] font-black uppercase px-2.5 py-1 rounded-full" style={{ backgroundColor: `${COLOR}18`, color: COLOR }}>{modeloLabel}</span>
|
||||
</div>
|
||||
|
||||
{f.modelo === 'hora' && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className={labelCls}>Início</label>
|
||||
<select className={inputCls} value={hora} onChange={e => setHora(e.target.value)}>
|
||||
{HORAS.map(h => <option key={h} value={h}>{h}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Duração</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" onClick={() => setQtd(q => Math.max(1, q - 1))} className="w-9 h-9 rounded-lg border border-gray-200 bg-white text-gray-500 font-black text-lg leading-none">−</button>
|
||||
<span className="flex-1 text-center text-sm font-black text-gray-800">{qtd}h</span>
|
||||
<button type="button" onClick={() => setQtd(q => Math.min(12, q + 1))} className="w-9 h-9 rounded-lg border border-gray-200 bg-white text-gray-500 font-black text-lg leading-none">+</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{f.modelo === 'turno' && (
|
||||
<div>
|
||||
<label className={labelCls}>Período</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{Object.entries(TURNOS).map(([k, t]) => (
|
||||
<button key={k} type="button" onClick={() => setTurno(k)}
|
||||
className={`py-2 rounded-xl text-[11px] font-black uppercase border transition-all ${turno === k ? 'text-white border-transparent' : 'bg-white text-gray-500 border-gray-200 hover:bg-gray-100'}`}
|
||||
style={turno === k ? { backgroundColor: COLOR } : {}}>
|
||||
{t.label}<div className="text-[8px] font-bold opacity-70">{pad2(t.ini)}h–{pad2(t.fim)}h</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(f.modelo === 'semanal' || f.modelo === 'mensal') && (
|
||||
<div>
|
||||
<label className={labelCls}>Quantidade de {UNIDADE[f.modelo]?.plur}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" onClick={() => setQtd(q => Math.max(1, q - 1))} className="w-9 h-9 rounded-lg border border-gray-200 bg-white text-gray-500 font-black text-lg leading-none">−</button>
|
||||
<span className="flex-1 text-center text-sm font-black text-gray-800">{qtd} {qtd === 1 ? UNIDADE[f.modelo]?.sing : UNIDADE[f.modelo]?.plur}</span>
|
||||
<button type="button" onClick={() => setQtd(q => Math.min(UNIDADE[f.modelo]?.max || 12, q + 1))} className="w-9 h-9 rounded-lg border border-gray-200 bg-white text-gray-500 font-black text-lg leading-none">+</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{f.modelo === 'diaria' && (
|
||||
<div>
|
||||
<label className={labelCls}>Dias selecionados ({diasMulti.length})</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{diasMultiSorted.map((d, i) => {
|
||||
const ocup = diasOcupados.has(d.toDateString());
|
||||
return (
|
||||
<button key={i} type="button" onClick={() => setDiasMulti(prev => prev.filter(x => !mesmoDia(x, d)))}
|
||||
className="flex items-center gap-1 pl-2.5 pr-1.5 py-1 rounded-full text-[10px] font-black text-white" style={{ backgroundColor: ocup ? '#d97706' : COLOR }}>
|
||||
{d.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' })}
|
||||
<X size={11} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-[9px] font-bold text-gray-400 uppercase mt-1.5">Toque no calendário para adicionar/remover dias · cada dia vira uma reserva (08h–18h)</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ocupadasDoDia.length > 0 && (
|
||||
<div className="p-3 rounded-xl bg-amber-50 border border-amber-100">
|
||||
<p className="text-[9px] font-black text-amber-600 uppercase tracking-widest mb-1.5 flex items-center gap-1"><Clock size={11} /> Já ocupado neste dia</p>
|
||||
<div className="space-y-1 max-h-24 overflow-y-auto">
|
||||
{ocupadasDoDia.map(o => (
|
||||
<p key={o.id} className="text-[10px] font-bold text-amber-700">
|
||||
{new Date(o.inicio).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}–{new Date(o.fim).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })} ({o.status})
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className={labelCls}>Início *</label>
|
||||
<input type="datetime-local" className={inputCls} value={f.inicio} onChange={e => setF(p => ({ ...p, inicio: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Fim *</label>
|
||||
<input type="datetime-local" className={inputCls} value={f.fim} onChange={e => setF(p => ({ ...p, fim: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Modelo de locação</label>
|
||||
<select className={inputCls} value={f.modelo} onChange={e => setF(p => ({ ...p, modelo: e.target.value }))}>
|
||||
{precos(sala).length === 0 && <option value="hora">HORA</option>}
|
||||
{precos(sala).map(([l]) => {
|
||||
const map: Record<string, string> = { HORA: 'hora', TURNO: 'turno', 'DIÁRIA': 'diaria', SEMANA: 'semanal', 'MÊS': 'mensal' };
|
||||
return <option key={l} value={map[l]}>{l}</option>;
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Observações</label>
|
||||
<textarea className={inputCls} rows={2} value={f.observacoes} onChange={e => setF(p => ({ ...p, observacoes: e.target.value }))} />
|
||||
</div>
|
||||
{ocupadas.length > 0 && (
|
||||
<div className="p-3 rounded-xl bg-amber-50 border border-amber-100">
|
||||
<p className="text-[9px] font-black text-amber-600 uppercase tracking-widest mb-1.5 flex items-center gap-1"><Clock size={11} /> Horários já ocupados</p>
|
||||
<div className="space-y-1 max-h-28 overflow-y-auto">
|
||||
{ocupadas.map(o => (
|
||||
<p key={o.id} className="text-[10px] font-bold text-amber-700">{fmtData(o.inicio)} → {fmtData(o.fim)} ({o.status})</p>
|
||||
|
||||
{/* PASSO 3 — Resumo detalhado + históricos */}
|
||||
<div className="w-full shrink-0 overflow-y-auto p-6 space-y-4">
|
||||
<div className="rounded-2xl border border-gray-100 overflow-hidden">
|
||||
{([
|
||||
['Sala', sala.nome],
|
||||
['Local', [sala.cidade, sala.estado].filter(Boolean).join(' · ') || '—'],
|
||||
['Modelo', modeloLabel],
|
||||
...(f.modelo === 'diaria'
|
||||
? [
|
||||
['Quantidade', `${diasMulti.length} ${diasMulti.length === 1 ? 'dia' : 'dias'}`],
|
||||
['Dias', diasMultiSorted.map(d => d.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' })).join(', ') || '—'],
|
||||
]
|
||||
: [
|
||||
...(UNIDADE[f.modelo] ? [['Quantidade', `${qtd} ${qtd === 1 ? UNIDADE[f.modelo].sing : UNIDADE[f.modelo].plur}`]] : []),
|
||||
['Início', f.inicio ? fmtData(new Date(f.inicio).toISOString()) : '—'],
|
||||
['Fim', f.fim ? fmtData(new Date(f.fim).toISOString()) : '—'],
|
||||
]) as Array<[string, string]>,
|
||||
['Reservar como', comoUnidade ? (ws?.nome || 'Unidade') : 'Pessoal'],
|
||||
] as Array<[string, string]>).map(([k, v]) => (
|
||||
<div key={k} className="flex items-center justify-between px-4 py-2.5 border-b border-gray-50 last:border-0">
|
||||
<span className="text-[10px] font-black text-gray-400 uppercase tracking-widest">{k}</span>
|
||||
<span className="text-xs font-bold text-gray-800 text-right max-w-[62%] truncate">{v}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-teal-50/60">
|
||||
<span className="text-[10px] font-black text-teal-600 uppercase tracking-widest">Total estimado</span>
|
||||
<span className="text-base font-black text-teal-700">{total > 0 ? fmtValor(String(total)) : 'Sob consulta'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest mb-2 flex items-center gap-1"><Clock size={11} /> Histórico de reservas desta sala</p>
|
||||
{ocupadas.length === 0 ? (
|
||||
<p className="text-[11px] text-gray-300 font-bold uppercase py-2">Sem reservas registradas</p>
|
||||
) : (
|
||||
<div className="space-y-1.5 max-h-44 overflow-y-auto">
|
||||
{ocupadas.slice().sort((a, b) => new Date(b.inicio).getTime() - new Date(a.inicio).getTime()).map(o => (
|
||||
<div key={o.id} className="flex items-center justify-between text-[11px] bg-gray-50 rounded-lg px-3 py-1.5">
|
||||
<span className="font-bold text-gray-600">{fmtData(o.inicio)} → {new Date(o.fim).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}</span>
|
||||
<span className={`text-[9px] font-black uppercase px-1.5 py-0.5 rounded-full ${o.status === 'confirmada' ? 'bg-emerald-100 text-emerald-700' : o.status === 'pendente' ? 'bg-amber-100 text-amber-700' : 'bg-gray-200 text-gray-500'}`}>{o.status}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PASSO 4 — Pagamento */}
|
||||
<div className="w-full shrink-0 overflow-y-auto p-6 space-y-4">
|
||||
<div className="rounded-2xl bg-gradient-to-br from-teal-50 to-white border border-teal-100 p-5 text-center">
|
||||
<p className="text-[10px] font-black text-teal-600 uppercase tracking-widest">Total{modeloLabel ? ` · ${modeloLabel}` : ''}</p>
|
||||
<p className="text-3xl font-black text-teal-700 mt-1">{total > 0 ? fmtValor(String(total)) : 'Sob consulta'}</p>
|
||||
{total > 0 && f.modelo === 'diaria' && diasMulti.length > 1 && <p className="text-[10px] text-teal-500 font-bold mt-1">{diasMulti.length} dias × {fmtValor(String(Number(sala.valor_diaria)))}</p>}
|
||||
{total > 0 && f.modelo !== 'diaria' && UNIDADE[f.modelo] && qtd > 1 && <p className="text-[10px] text-teal-500 font-bold mt-1">{qtd} {UNIDADE[f.modelo].plur} × {fmtValor(String(Number(sala[MODELOS.find(m => m.id === f.modelo)!.valor])))}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Forma de pagamento</label>
|
||||
<div className="grid grid-cols-2 gap-2 mt-1">
|
||||
{Object.entries(FORMAS).map(([k, l]) => (
|
||||
<button key={k} type="button" onClick={() => setFormaPagamento(k)}
|
||||
className={`py-2.5 rounded-xl text-[11px] font-black uppercase border transition-all ${formaPagamento === k ? 'text-white border-transparent' : 'bg-gray-50 text-gray-500 border-gray-200 hover:bg-gray-100'}`}
|
||||
style={formaPagamento === k ? { backgroundColor: COLOR } : {}}>{l}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Contrato de locação</label>
|
||||
<button type="button" onClick={() => setShowContrato(true)}
|
||||
className="w-full flex items-center justify-between rounded-xl border p-3.5 transition-colors hover:bg-gray-50"
|
||||
style={{ borderColor: contratoAceito ? '#16a34a55' : `${COLOR}44`, backgroundColor: contratoAceito ? '#16a34a0d' : `${COLOR}0a` }}>
|
||||
<span className="flex items-center gap-2 text-xs font-black text-gray-700 uppercase"><FileText size={15} style={{ color: contratoAceito ? '#16a34a' : COLOR }} /> Ler contrato de locação</span>
|
||||
<span className="text-[10px] font-black uppercase flex items-center gap-1" style={{ color: contratoAceito ? '#16a34a' : COLOR }}>
|
||||
{contratoAceito ? <><CheckCircle2 size={13} /> Aceito</> : 'Abrir'}
|
||||
</span>
|
||||
</button>
|
||||
<label className="flex items-start gap-2 mt-2 px-1 cursor-pointer">
|
||||
<input type="checkbox" checked={contratoAceito} onChange={e => setContratoAceito(e.target.checked)} className="mt-0.5 w-4 h-4 accent-teal-600 shrink-0" />
|
||||
<span className="text-[11px] font-bold text-gray-600 leading-snug">Li e concordo com o contrato de locação — os procedimentos realizados são de minha inteira responsabilidade.</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="rounded-xl bg-gray-50 border border-gray-100 p-3.5 text-[11px] text-gray-500 leading-relaxed">
|
||||
<span className="font-black text-gray-600 uppercase">Como funciona: </span>ao solicitar, o locador recebe seu pedido e confirma. O pagamento é combinado diretamente com ele após a confirmação — sua preferência ({FORMAS[formaPagamento]}) vai junto no pedido.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 pb-6 flex gap-3">
|
||||
<button onClick={onClose} className="flex-1 py-3 border border-gray-200 rounded-xl text-xs font-black uppercase text-gray-500 hover:bg-gray-50 transition-colors">CANCELAR</button>
|
||||
<button onClick={handleReserve} disabled={saving} className="flex-1 py-3 text-white rounded-xl text-xs font-black uppercase transition-colors flex items-center justify-center gap-2 disabled:opacity-60" style={{ backgroundColor: COLOR }}>
|
||||
{saving ? <RefreshCw size={14} className="animate-spin" /> : <CalendarClock size={14} />} SOLICITAR RESERVA
|
||||
|
||||
{/* Footer nav */}
|
||||
<div className="px-6 py-4 border-t border-gray-100 flex gap-3 shrink-0">
|
||||
<button onClick={() => (step === 0 ? onClose() : setStep(s => s - 1))} className="flex-1 py-3 border border-gray-200 rounded-xl text-xs font-black uppercase text-gray-500 hover:bg-gray-50 transition-colors">
|
||||
{step === 0 ? 'Cancelar' : 'Voltar'}
|
||||
</button>
|
||||
{step < 3 ? (
|
||||
<button onClick={() => setStep(s => s + 1)} disabled={step === 1 && !temSelecao}
|
||||
className="flex-1 py-3 text-white rounded-xl text-xs font-black uppercase transition-opacity hover:opacity-90 disabled:opacity-40 flex items-center justify-center gap-2" style={{ backgroundColor: COLOR }}>
|
||||
Próximo <ChevronRight size={14} />
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={handleReserve} disabled={saving || !temSelecao || !contratoAceito}
|
||||
className="flex-1 py-3 text-white rounded-xl text-xs font-black uppercase transition-opacity hover:opacity-90 disabled:opacity-50 flex items-center justify-center gap-2" style={{ backgroundColor: COLOR }}>
|
||||
{saving ? <RefreshCw size={14} className="animate-spin" /> : <CalendarClock size={14} />} Solicitar Reserva
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal: contrato de locação */}
|
||||
{showContrato && (
|
||||
<div className="fixed inset-0 bg-black/60 z-[60] flex items-center justify-center backdrop-blur-sm p-4" onClick={() => setShowContrato(false)}>
|
||||
<div className="bg-white rounded-2xl w-full max-w-lg shadow-2xl max-h-[85vh] flex flex-col overflow-hidden" onClick={e => e.stopPropagation()}>
|
||||
<div className="px-5 py-4 border-b border-gray-100 flex items-center justify-between shrink-0">
|
||||
<h4 className="text-sm font-black text-gray-900 uppercase flex items-center gap-2"><FileText size={16} style={{ color: COLOR }} /> Contrato de locação</h4>
|
||||
<button onClick={() => setShowContrato(false)} className="p-1.5 hover:bg-gray-100 rounded-lg text-gray-500"><X size={16} /></button>
|
||||
</div>
|
||||
<div className="p-5 overflow-y-auto text-[11px] text-gray-600 leading-relaxed whitespace-pre-wrap font-medium">{contratoTexto}</div>
|
||||
<div className="p-4 border-t border-gray-100 shrink-0 flex gap-2">
|
||||
<button onClick={() => setShowContrato(false)} className="flex-1 py-3 border border-gray-200 rounded-xl text-xs font-black uppercase text-gray-500 hover:bg-gray-50">Fechar</button>
|
||||
<button onClick={() => { setContratoAceito(true); setShowContrato(false); }} className="flex-1 py-3 text-white rounded-xl text-xs font-black uppercase hover:opacity-90 flex items-center justify-center gap-2" style={{ backgroundColor: COLOR }}><CheckCircle2 size={14} /> Li e concordo</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -516,8 +915,15 @@ export const SalasPlugin: React.FC<{ view?: 'minhas' | 'alugar' }> = ({ view })
|
||||
const [fCep, setFCep] = useState('');
|
||||
const [fRaio, setFRaio] = useState('');
|
||||
const [fArea, setFArea] = useState('');
|
||||
const [fBusca, setFBusca] = useState('');
|
||||
const [showFiltros, setShowFiltros] = useState(true);
|
||||
const [areas, setAreas] = useState<{ slug: string; nome: string }[]>([]);
|
||||
useEffect(() => { HybridBackend.getAreas().then(setAreas); }, []);
|
||||
const limparFiltros = () => { setFTipo(''); setFEstado(''); setFCidade(''); setFCep(''); setFRaio(''); setFArea(''); setFBusca(''); };
|
||||
// Busca por texto (nome/cidade/bairro) aplicada sobre o resultado do backend.
|
||||
const salasFiltradas = fBusca.trim()
|
||||
? salas.filter(s => `${s.nome} ${(s as any).cidade || ''} ${(s as any).bairro || ''}`.toLowerCase().includes(fBusca.trim().toLowerCase()))
|
||||
: salas;
|
||||
// minhas
|
||||
const [minhas, setMinhas] = useState<Sala[]>([]);
|
||||
// reservas
|
||||
@@ -588,6 +994,11 @@ export const SalasPlugin: React.FC<{ view?: 'minhas' | 'alugar' }> = ({ view })
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
// Trocar de rota (/alugar-salas ↔ /minhas-salas) reaproveita este componente e
|
||||
// só muda a prop `view` — o tab do useState não reage sozinho. Reseta a aba p/ a
|
||||
// do contexto novo (senão a URL muda mas o conteúdo continua na aba anterior).
|
||||
useEffect(() => { setTab(view === 'minhas' ? 'minhas' : 'marketplace'); }, [view]);
|
||||
|
||||
useEffect(() => {
|
||||
if (tab === 'marketplace') fetchMarketplace();
|
||||
if (tab === 'minhas') fetchMinhas();
|
||||
@@ -641,23 +1052,26 @@ export const SalasPlugin: React.FC<{ view?: 'minhas' | 'alugar' }> = ({ view })
|
||||
: 'Alugue salas equipadas ou disponibilize as suas para outros profissionais.'}
|
||||
/>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{TABS.map(t => {
|
||||
const Icon = t.icon;
|
||||
const active = tab === t.id;
|
||||
return (
|
||||
<button key={t.id} onClick={() => setTab(t.id)}
|
||||
className={`flex items-center gap-2 px-5 py-2.5 rounded-xl text-[11px] font-black uppercase tracking-wider transition-all ${active ? 'text-white shadow-md' : 'bg-white text-gray-500 border border-gray-200 hover:bg-gray-50'}`}
|
||||
style={active ? { backgroundColor: COLOR } : {}}>
|
||||
<Icon size={14} /> {t.label}
|
||||
{t.id === 'reservas' && pendentesRecebidas > 0 && (
|
||||
<span className={`text-[9px] font-black px-1.5 py-0.5 rounded-full ${active ? 'bg-white/25' : 'bg-amber-100 text-amber-700'}`}>{pendentesRecebidas}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* Tabs — segmented control: no mobile ocupa a largura toda e distribui igual;
|
||||
no desktop encolhe ao conteúdo. */}
|
||||
{TABS.length > 1 && (
|
||||
<div className="flex gap-1 bg-gray-100/80 p-1 rounded-2xl w-full sm:w-fit">
|
||||
{TABS.map(t => {
|
||||
const Icon = t.icon;
|
||||
const active = tab === t.id;
|
||||
return (
|
||||
<button key={t.id} onClick={() => setTab(t.id)}
|
||||
className={`flex-1 sm:flex-none flex items-center justify-center gap-2 px-3 sm:px-5 py-2.5 rounded-xl text-[11px] font-black uppercase tracking-wider transition-all ${active ? 'bg-white shadow-sm' : 'text-gray-500 hover:text-gray-700'}`}
|
||||
style={active ? { color: COLOR } : {}}>
|
||||
<Icon size={14} className="shrink-0" /> <span className="truncate">{t.label}</span>
|
||||
{t.id === 'reservas' && pendentesRecebidas > 0 && (
|
||||
<span className="text-[9px] font-black px-1.5 py-0.5 rounded-full text-white" style={{ backgroundColor: COLOR }}>{pendentesRecebidas}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* MARKETPLACE */}
|
||||
{tab === 'marketplace' && enderecoOk === false && (
|
||||
@@ -666,58 +1080,92 @@ export const SalasPlugin: React.FC<{ view?: 'minhas' | 'alugar' }> = ({ view })
|
||||
{tab === 'marketplace' && enderecoOk !== false && (
|
||||
<>
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-5">
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3 mb-4">
|
||||
<div>
|
||||
<label className={labelCls}>Área</label>
|
||||
<select className={inputCls} value={fArea} onChange={e => setFArea(e.target.value)}>
|
||||
<option value="">Todas</option>
|
||||
{areas.map(a => <option key={a.slug} value={a.slug}>{a.nome}</option>)}
|
||||
</select>
|
||||
{/* Busca por nome/cidade/bairro (sobre o resultado) */}
|
||||
<div className="relative mb-5">
|
||||
<Search size={18} className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none" />
|
||||
<input
|
||||
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-200 rounded-2xl text-sm font-medium text-gray-700 outline-none focus:border-teal-400 focus:bg-white transition-colors"
|
||||
value={fBusca} onChange={e => setFBusca(e.target.value)}
|
||||
placeholder="Buscar sala por nome, cidade ou bairro..." />
|
||||
</div>
|
||||
|
||||
{showFiltros && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className={labelNew}>Área</label>
|
||||
<select className={inputNew} value={fArea} onChange={e => setFArea(e.target.value)}>
|
||||
<option value="">Todas</option>
|
||||
{areas.map(a => <option key={a.slug} value={a.slug}>{a.nome}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelNew}>Tipo</label>
|
||||
<select className={inputNew} value={fTipo} onChange={e => setFTipo(e.target.value)}>
|
||||
<option value="">Todos</option>
|
||||
<option value="clinica">Sala Clínica</option>
|
||||
<option value="consultorio">Sala Consultório</option>
|
||||
<option value="independente">Sala Independente</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelNew}>Estado</label>
|
||||
<select className={inputNew} value={fEstado} onChange={e => setFEstado(e.target.value)}>
|
||||
{UF_LIST.map(uf => <option key={uf} value={uf}>{uf || 'Todos'}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 md:items-end">
|
||||
<div>
|
||||
<label className={labelNew}>Cidade</label>
|
||||
<input className={`${inputNew} uppercase`} value={fCidade} onChange={e => setFCidade(e.target.value.toUpperCase())} placeholder="CAMPO GRANDE" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelNew}>Meu CEP</label>
|
||||
<input className={inputNew} value={fCep} onChange={e => setFCep(e.target.value)} placeholder="79000-000" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<label className={`${labelNew} mb-0`}>Raio de Busca</label>
|
||||
<span className="text-[10px] font-black px-2 py-0.5 rounded-full bg-teal-100 text-teal-700">{fRaio || 0} KM</span>
|
||||
</div>
|
||||
<input type="range" min="0" max="200" step="5" value={fRaio || 0}
|
||||
onChange={e => setFRaio(e.target.value === '0' ? '' : e.target.value)}
|
||||
className="w-full accent-teal-600 cursor-pointer" />
|
||||
{!fCep && <p className="text-[10px] text-gray-400 mt-1">Informe o CEP para filtrar por raio.</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Tipo</label>
|
||||
<select className={inputCls} value={fTipo} onChange={e => setFTipo(e.target.value)}>
|
||||
<option value="">Todos</option>
|
||||
<option value="clinica">SALA CLÍNICA</option>
|
||||
<option value="consultorio">SALA CONSULTÓRIO</option>
|
||||
<option value="independente">SALA INDEPENDENTE</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Estado</label>
|
||||
<select className={inputCls} value={fEstado} onChange={e => setFEstado(e.target.value)}>
|
||||
{UF_LIST.map(uf => <option key={uf} value={uf}>{uf || 'Todos os estados'}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Cidade</label>
|
||||
<input className={`${inputCls} uppercase`} value={fCidade} onChange={e => setFCidade(e.target.value.toUpperCase())} placeholder="CAMPO GRANDE" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Meu CEP (busca por raio)</label>
|
||||
<input className={inputCls} value={fCep} onChange={e => setFCep(e.target.value)} placeholder="00000-000" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>Raio (km)</label>
|
||||
<input type="number" min="1" className={inputCls} value={fRaio} onChange={e => setFRaio(e.target.value)} placeholder="10" />
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between mt-5 pt-4 border-t border-gray-50">
|
||||
<button onClick={() => setShowFiltros(s => !s)}
|
||||
className="flex items-center gap-1.5 text-xs font-bold text-gray-500 hover:text-gray-700 transition-colors">
|
||||
<ChevronUp size={14} className={`transition-transform ${showFiltros ? '' : 'rotate-180'}`} />
|
||||
{showFiltros ? 'Ocultar Filtros' : 'Mostrar Filtros'}
|
||||
</button>
|
||||
<div className="flex items-center gap-4">
|
||||
<button onClick={limparFiltros} className="flex items-center gap-1.5 text-xs font-bold text-gray-500 hover:text-gray-700 transition-colors">
|
||||
<Eraser size={13} /> Limpar Filtros
|
||||
</button>
|
||||
<button onClick={fetchMarketplace} disabled={loading}
|
||||
className="flex items-center gap-2 px-6 py-3 text-white rounded-xl text-xs font-black uppercase tracking-widest transition-opacity disabled:opacity-60 hover:opacity-90" style={{ backgroundColor: COLOR }}>
|
||||
{loading ? <RefreshCw size={14} className="animate-spin" /> : <Search size={14} />} Buscar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={fetchMarketplace} disabled={loading}
|
||||
className="flex items-center gap-2 px-5 py-2.5 text-white rounded-xl text-xs font-black uppercase tracking-widest transition-colors disabled:opacity-60 hover:opacity-90" style={{ backgroundColor: COLOR }}>
|
||||
{loading ? <RefreshCw size={14} className="animate-spin" /> : <Search size={14} />} BUSCAR
|
||||
</button>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12"><RefreshCw size={24} className="animate-spin" style={{ color: COLOR }} /></div>
|
||||
) : salas.length === 0 ? (
|
||||
) : salasFiltradas.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
<DoorOpen size={40} className="mx-auto mb-3 opacity-40" />
|
||||
<p className="font-bold text-sm uppercase">Nenhuma sala encontrada</p>
|
||||
<p className="text-xs mt-1">Ajuste os filtros ou aguarde novos anúncios</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{salas.map(s => <SalaCard key={s.id} sala={s} onReserve={() => setReservingSala(s)} />)}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{salasFiltradas.map(s => <SalaCard key={s.id} sala={s} onReserve={() => setReservingSala(s)} />)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -726,10 +1174,15 @@ export const SalasPlugin: React.FC<{ view?: 'minhas' | 'alugar' }> = ({ view })
|
||||
{/* MINHAS SALAS */}
|
||||
{tab === 'minhas' && (
|
||||
<>
|
||||
<button onClick={() => setEditingSala('new')}
|
||||
className="flex items-center gap-2 px-5 py-2.5 text-white rounded-xl text-xs font-black uppercase tracking-widest transition-colors hover:opacity-90" style={{ backgroundColor: COLOR }}>
|
||||
<Plus size={14} /> CADASTRAR SALA
|
||||
</button>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<h3 className="text-sm font-black text-gray-800 uppercase tracking-tight">
|
||||
{minhas.length > 0 ? `${minhas.length} sala${minhas.length > 1 ? 's' : ''} cadastrada${minhas.length > 1 ? 's' : ''}` : 'Suas salas'}
|
||||
</h3>
|
||||
<button onClick={() => setEditingSala('new')}
|
||||
className="w-full sm:w-auto flex items-center justify-center gap-2 px-5 py-2.5 text-white rounded-xl text-xs font-black uppercase tracking-widest transition-opacity hover:opacity-90 shadow-sm" style={{ backgroundColor: COLOR }}>
|
||||
<Plus size={16} /> CADASTRAR SALA
|
||||
</button>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12"><RefreshCw size={24} className="animate-spin" style={{ color: COLOR }} /></div>
|
||||
) : minhas.length === 0 ? (
|
||||
@@ -739,7 +1192,7 @@ export const SalasPlugin: React.FC<{ view?: 'minhas' | 'alugar' }> = ({ view })
|
||||
<p className="text-xs mt-1">Disponibilize uma sala e receba solicitações de reserva</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{minhas.map(s => <SalaCard key={s.id} sala={s} mine onEdit={() => setEditingSala(s)} onDelete={() => handleDelete(s)} />)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user