Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8cac06e37c | |||
| b67fa15c55 | |||
| 1e16edf2be | |||
| cfeac891fb | |||
| f30b0ab732 | |||
| ddcc639ab6 | |||
| 882af16458 | |||
| 4dea9ab28a | |||
| 26f39a60f3 | |||
| 7a5f5cf0fa | |||
| f806835ae3 | |||
| 30ea2f0e10 | |||
| ec76dfeec2 | |||
| 40bed96515 | |||
| f4c2c6861a | |||
| 668d672531 | |||
| ca2cfa1f35 | |||
| 773e8d4bf2 | |||
| 0a324bf8c8 | |||
| 77dec56dd0 | |||
| 03711e6280 | |||
| 09edeb9558 | |||
| 8b1801bd9c | |||
| c2a1f8f701 | |||
| 21bebd3256 | |||
| 372431c0a1 | |||
| ecf52d961a | |||
| 3e1001b7c7 | |||
| 821ae591d4 | |||
| 9babc69172 | |||
| 092635be5a | |||
| 3042ddca38 | |||
| 6593993b3d | |||
| 872acd800a | |||
| 13af039555 | |||
| 9874eb4345 | |||
| abbd9374b4 | |||
| 3751d84da8 | |||
| 776c25c3de | |||
| b031117925 | |||
| b21fb0f159 | |||
| bfba577080 | |||
| facf7b48c2 | |||
| d83e0c82b2 | |||
| e6d8b532e3 | |||
| 8353a9d897 |
@@ -0,0 +1,70 @@
|
||||
# Integração NewWhats (satélite) — scoreodonto.com ↔ Motor
|
||||
|
||||
> **Único deste projeto** (scoreodonto.com). Clonado e **adaptado ao domínio
|
||||
> odonto** a partir do satélite do `mercado.clube67.com`. Backend **ESM** — sem o
|
||||
> host de plugins do mercado; é um módulo registrado no `server.js`. v1.0.0.
|
||||
|
||||
## ⚠️ Regra de registro bidirecional (obrigatória)
|
||||
- Toda mudança que toque o contrato (rotas, payloads, auth, webhook) **deve ser
|
||||
registrada no motor** e na doc central
|
||||
`documentos-unicos/projetos/newwhats.clube67.com/integracao-satelites.md`.
|
||||
- Toda mudança no motor que afete o contrato **deve ser refletida aqui**.
|
||||
- O satélite é um **braço inteligente do motor** — consome/alimenta, não duplica.
|
||||
|
||||
## Diferenças vs o satélite do mercado
|
||||
- **ESM** (não CommonJS) e **sem PluginManager** — registrado via
|
||||
`registerNewwhats(app, pool)` no `server.js`.
|
||||
- **Config por ENV** (não tabela `plugin_configs`): `NEWWHATS_URL`,
|
||||
`NEWWHATS_INTEGRATION_KEY`, `NEWWHATS_WEBHOOK_SECRET`, `NEWWHATS_CLINICA_ID`.
|
||||
- **Negócio adaptado ao odonto**: `/nw/*` e `sync-knowledge` usam
|
||||
clínica/especialidades/dentistas/procedimentos/planos (multi-tenant por
|
||||
`clinica_id`), no lugar de clubes/sorteio/vagas.
|
||||
- **Removidos** (não úteis aqui): `media-transcribe` (o motor já transcreve —
|
||||
ver Fase 5), `smart-router`, `personas`, `follow-up-detector`, `context-builder`
|
||||
(heurísticas específicas do mercado). Reavaliar se necessário.
|
||||
|
||||
## Arquivos
|
||||
| Arquivo | Papel |
|
||||
|---|---|
|
||||
| `config.js` | lê a config da integração das ENV |
|
||||
| `auth.js` | `nwAuth` — valida `x-nw-key`/Bearer nas rotas `/nw/*` |
|
||||
| `routes.js` | `/nw/*` odonto: `/ping`, `/clinica`, `/especialidades`, `/dentistas`, `/procedimentos`, `/planos` |
|
||||
| `sync-knowledge.js` | `buildKnowledgeText(pool, clinicaId)` + `syncKnowledge(pool)` → motor `/api/secretaria/*` |
|
||||
| `webhook-receiver.js` | `POST /api/webhook/newwhats` (HMAC) — `message.new`, `session.status` (enxuto) |
|
||||
| `proxy.js` | `/api/nw/v1/*` → motor `/api/ext/v1/*` (injeta `x-nw-key`) |
|
||||
| `index.js` | `registerNewwhats(app, pool)` — registra tudo |
|
||||
| `manifest.json` | metadados + `config_env` |
|
||||
|
||||
## Contrato (resumo)
|
||||
- **Satélite → Motor**: proxy `/api/nw/v1/*` → ext-api `/api/ext/v1/*` (Bearer/x-nw-key);
|
||||
`sync-knowledge` → `/api/secretaria/*`.
|
||||
- **Motor → Satélite**: webhook `POST /api/webhook/newwhats` (HMAC `webhook_secret`);
|
||||
Secretária consulta `/nw/*` (`integration_key`).
|
||||
|
||||
## Frontend (React SPA)
|
||||
- Plugin `newwhats` registrado em `frontend/views/PluginsView.tsx` (catálogo
|
||||
PLUGINS) — **config exclusiva do superadmin** (a view `plugins` já é gateada em
|
||||
`App.tsx isViewAllowed`).
|
||||
- `ALWAYS_ON` em `pluginRegistry.ts` → Inbox/Secretária aparecem para **todos** os
|
||||
usuários (a ativação por plugin é localStorage e não propaga entre contas).
|
||||
- Views: `frontend/views/WaInboxView.tsx` (conversas/thread/envio) e
|
||||
`WaSecretariaView.tsx` (ask) — consomem `/api/nw/v1/*`. **Estágio 1** (funcional,
|
||||
visual aproximado do motor; refinar para ficar idêntico).
|
||||
- Sidebar: itens WHATSAPP + SECRETÁRIA IA (gated por `isPluginActive('newwhats')`).
|
||||
|
||||
## Pendências / TODO
|
||||
- [ ] **Segurança**: gatear o proxy `/api/nw/v1/*` com a auth do scoreodonto (hoje
|
||||
aberto — qualquer um que alcance a rota é proxiado ao motor).
|
||||
- [x] **Ponte de config** (feito): config persiste na tabela `settings`
|
||||
(`id=newwhats_config`); backend lê do DB com fallback ENV
|
||||
(`config.js loadConfigFromDb`). UI dedicada (`NewwhatsConfigModal`) com
|
||||
**dropdown de clínicas**, secrets que mantêm valor se em branco e **Testar
|
||||
conexão** (`GET /api/nw/status`). Endpoints superadmin: `GET/PUT /api/nw/config`,
|
||||
`GET /api/nw/clinicas`, `GET /api/nw/status`.
|
||||
- [ ] `webhook message.new`: capturar lead / acionar fluxo (hoje só loga).
|
||||
- [ ] Proxy WS (`/api/nw/v1/stream`) — só o REST foi portado (Inbox sem tempo real).
|
||||
- [ ] Parear scoreodonto com o motor (criar apiKey/pair + agente) e preencher as ENV.
|
||||
- [ ] Endpoint/admin para disparar `syncKnowledge` (hoje exportado, sem rota).
|
||||
- [ ] Refinar Inbox/Secretária para ficarem idênticas ao motor.
|
||||
|
||||
> Fonte da verdade do contrato: `documentos-unicos/projetos/newwhats.clube67.com/`.
|
||||
@@ -0,0 +1,694 @@
|
||||
// Ponte de agenda: expõe /api/nw/agenda/* para o MOTOR (tools da Secretária)
|
||||
// operarem na agenda REAL do scoreodonto — em vez do calendário isolado do motor.
|
||||
//
|
||||
// Segurança: chamada server-to-server autenticada por segredo compartilhado
|
||||
// (header x-nw-agenda-secret === webhookSecret configurado). Todo acesso é
|
||||
// preso ao clinica_id enviado (o motor conhece a clínica da instância).
|
||||
//
|
||||
// Endpoints:
|
||||
// GET /slots — horários livres (jornada dos dentistas − agendamentos)
|
||||
// POST /book — cria agendamento real (anti-overbooking + vínculo de paciente)
|
||||
import express from 'express';
|
||||
import { getConfig } from './config.js';
|
||||
import { cautelasParaIA } from './checklist-defs.js';
|
||||
import { resolverFeriado, feriadoNacionalNome } from './feriados.js';
|
||||
|
||||
const SLOT_MIN = 60; // duração/espaçamento padrão do slot (1h entre atendimentos)
|
||||
const HORIZON_DAYS = 7; // janela padrão de busca
|
||||
const MAX_SLOTS = 20; // quantos slots RETORNAR (após ordenar por horário)
|
||||
const CANDIDATE_CAP = 240; // teto de GERAÇÃO (clínica + salas competem por horário)
|
||||
// Fallback quando a CLÍNICA não configurou horário de funcionamento: comercial
|
||||
// padrão (seg–sex 08–18). Uma vez configurado (clinicas_horarios), manda ele.
|
||||
const DEFAULT_INI = '08:00';
|
||||
const DEFAULT_FIM = '18:00';
|
||||
const DIAS_NOME = ['domingo', 'segunda-feira', 'terça-feira', 'quarta-feira', 'quinta-feira', 'sexta-feira', 'sábado'];
|
||||
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
const ymd = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
const fmt = (d) => `${ymd(d)} ${pad(d.getHours())}:${pad(d.getMinutes())}:00`;
|
||||
const hm = (t) => String(t ?? '').slice(0, 5); // 'HH:MM:SS'|Date → 'HH:MM'
|
||||
|
||||
// Interseção de duas listas de janelas [ini,fim] (strings 'HH:MM'). Retorna as
|
||||
// sobreposições (ex.: clínica 08–18 ∩ dentista 13–20 = 13–18).
|
||||
function interseccao(a, b) {
|
||||
const out = [];
|
||||
for (const [ai, af] of a) for (const [bi, bf] of b) {
|
||||
const ini = ai > bi ? ai : bi;
|
||||
const fim = af < bf ? af : bf;
|
||||
if (ini < fim) out.push([ini, fim]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Carrega o horário de FUNCIONAMENTO da clínica → { byDow: Map<dow,[[ini,fim]]>, hasConfig }.
|
||||
async function carregarHorariosClinica(pool, clinicaId) {
|
||||
const byDow = new Map();
|
||||
let hasConfig = false;
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT dia_semana, hora_inicio, hora_fim FROM clinicas_horarios WHERE clinica_id = $1 AND ativo = 1`,
|
||||
[clinicaId]);
|
||||
hasConfig = rows.length > 0;
|
||||
for (const r of rows) {
|
||||
const arr = byDow.get(r.dia_semana) || [];
|
||||
arr.push([hm(r.hora_inicio), hm(r.hora_fim)]);
|
||||
byDow.set(r.dia_semana, arr);
|
||||
}
|
||||
} catch { /* tabela pode não existir ainda → cai no fallback */ }
|
||||
return { byDow, hasConfig };
|
||||
}
|
||||
|
||||
// Carrega as folgas (por data) dos dentistas da clínica → Map<dentista_id,[{ini,fim}]>.
|
||||
async function carregarFolgas(pool, clinicaId) {
|
||||
const byDent = new Map();
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT f.dentista_id, to_char(f.data_inicio,'YYYY-MM-DD') AS ini, to_char(f.data_fim,'YYYY-MM-DD') AS fim
|
||||
FROM dentistas_folgas f JOIN dentistas d ON d.id = f.dentista_id
|
||||
WHERE d.clinica_id = $1`, [clinicaId]);
|
||||
for (const r of rows) {
|
||||
const arr = byDent.get(r.dentista_id) || [];
|
||||
arr.push([r.ini, r.fim]);
|
||||
byDent.set(r.dentista_id, arr);
|
||||
}
|
||||
} catch { /* tabela pode não existir ainda */ }
|
||||
return byDent;
|
||||
}
|
||||
const emFolga = (folgas, dateStr) => (folgas || []).some(([i, f]) => dateStr >= i && dateStr <= f);
|
||||
|
||||
// Janelas de funcionamento da CLÍNICA num dia específico, já considerando feriado.
|
||||
// Retorna { janelas: [[ini,fim]], fechado: bool, motivo: string|null }.
|
||||
async function janelasClinicaNoDia(pool, clinicaId, dateStr, dow, clinicHours) {
|
||||
const fer = await resolverFeriado(pool, clinicaId, dateStr);
|
||||
if (fer && fer.fecha) return { janelas: [], fechado: true, motivo: `Feriado: ${fer.nome}` };
|
||||
// Feriado com horário especial (fecha=0 + horas) sobrepõe o horário normal.
|
||||
if (fer && !fer.fecha && fer.hora_inicio && fer.hora_fim) {
|
||||
return { janelas: [[hm(fer.hora_inicio), hm(fer.hora_fim)]], fechado: false, motivo: `Horário especial (${fer.nome})` };
|
||||
}
|
||||
// Horário normal do dia da semana.
|
||||
let janelas;
|
||||
if (clinicHours.hasConfig) {
|
||||
janelas = (clinicHours.byDow.get(dow) || []).map(([i, f]) => [i, f]);
|
||||
} else {
|
||||
janelas = (dow >= 1 && dow <= 5) ? [[DEFAULT_INI, DEFAULT_FIM]] : [];
|
||||
}
|
||||
if (!janelas.length) return { janelas: [], fechado: true, motivo: DIAS_NOME[dow] === 'domingo' || DIAS_NOME[dow] === 'sábado' ? `Fechado (${DIAS_NOME[dow]})` : 'Fechado neste dia' };
|
||||
return { janelas, fechado: false, motivo: null };
|
||||
}
|
||||
|
||||
// Overbooking: MESMA regra do server.js (agrupa o dentista por usuario_id/email,
|
||||
// para o mesmo profissional atuando em clínicas diferentes). Cópia da lógica —
|
||||
// se a regra do scoreodonto mudar, revisar aqui também.
|
||||
async function findConflict(db, dentistaId, start, end, 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];
|
||||
const uid = dr[0]?.usuario_id || null;
|
||||
const email = (dr[0]?.email || '').trim().toLowerCase() || null;
|
||||
if (uid) {
|
||||
const { rows } = await db.query('SELECT id FROM dentistas WHERE usuario_id = $1', [uid]);
|
||||
if (rows.length) ids = rows.map((r) => r.id);
|
||||
} else if (email) {
|
||||
const { rows } = await db.query('SELECT id FROM dentistas WHERE lower(trim(email)) = $1', [email]);
|
||||
if (rows.length) ids = rows.map((r) => r.id);
|
||||
}
|
||||
const { rows } = await db.query(
|
||||
`SELECT id FROM agendamentos
|
||||
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, excludeId]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
export function createAgendaBridge(pool) {
|
||||
const router = express.Router();
|
||||
|
||||
// Auth server-to-server por segredo compartilhado.
|
||||
router.use((req, res, next) => {
|
||||
const secret = getConfig().webhookSecret;
|
||||
if (!secret || req.headers['x-nw-agenda-secret'] !== secret) {
|
||||
return res.status(401).json({ error: 'Segredo da ponte inválido' });
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// ── GET /slots ──────────────────────────────────────────────────────────────
|
||||
// Query: clinica_id (obrig.), dentista_id?, date? (YYYY-MM-DD), days?, duration?
|
||||
router.get('/slots', async (req, res) => {
|
||||
const clinicaId = String(req.query.clinica_id || '');
|
||||
if (!clinicaId) return res.status(400).json({ error: 'clinica_id obrigatório' });
|
||||
const dentistaId = req.query.dentista_id ? String(req.query.dentista_id) : null;
|
||||
const duration = Math.max(10, parseInt(req.query.duration, 10) || SLOT_MIN);
|
||||
const days = Math.min(30, Math.max(1, parseInt(req.query.days, 10) || HORIZON_DAYS));
|
||||
const from = req.query.date ? new Date(`${req.query.date}T00:00:00`) : new Date();
|
||||
const periodo = req.query.periodo ? String(req.query.periodo).toLowerCase() : null; // manha|tarde
|
||||
|
||||
try {
|
||||
// Dentistas no escopo da clínica.
|
||||
const dParams = [clinicaId];
|
||||
let dWhere = 'clinica_id = $1';
|
||||
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({ slots: [], motivo: 'Nenhum dentista cadastrado na clínica.' });
|
||||
|
||||
// Jornada dos DENTISTAS (dentistas_horarios) — opcional, por dentista.
|
||||
const { rows: horarios } = await pool.query(
|
||||
`SELECT dentista_id, dia_semana, hora_inicio, hora_fim
|
||||
FROM dentistas_horarios WHERE clinica_id = $1 AND ativo = 1`, [clinicaId]);
|
||||
const hoursBy = new Map();
|
||||
for (const h of horarios) { const a = hoursBy.get(h.dentista_id) || []; a.push(h); hoursBy.set(h.dentista_id, a); }
|
||||
|
||||
// Horário de FUNCIONAMENTO da clínica (config do dono) — a "casca" externa.
|
||||
const clinicHours = await carregarHorariosClinica(pool, clinicaId);
|
||||
// Folgas por data dos dentistas (férias/indisponibilidade).
|
||||
const folgasBy = await carregarFolgas(pool, clinicaId);
|
||||
|
||||
const start0 = new Date(from); start0.setHours(0, 0, 0, 0);
|
||||
const end0 = new Date(start0); end0.setDate(end0.getDate() + days);
|
||||
const { rows: busy } = await pool.query(
|
||||
`SELECT dentistaid, 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); }
|
||||
|
||||
const now = new Date();
|
||||
const slots = [];
|
||||
for (let i = 0; i < days && slots.length < CANDIDATE_CAP; i++) {
|
||||
const day = new Date(start0); day.setDate(day.getDate() + i);
|
||||
const dow = day.getDay();
|
||||
// Janelas da CLÍNICA no dia (já considera feriado). Fechado → pula o dia.
|
||||
const { janelas: clinicWins, fechado } = await janelasClinicaNoDia(pool, clinicaId, ymd(day), dow, clinicHours);
|
||||
if (fechado || !clinicWins.length) continue;
|
||||
for (const dent of dentistas) {
|
||||
if (slots.length >= CANDIDATE_CAP) break;
|
||||
if (emFolga(folgasBy.get(dent.id), ymd(day))) continue; // dentista de folga nesse dia
|
||||
const cfg = hoursBy.get(dent.id);
|
||||
// Dentista: se configurou jornada, usa a do dia; senão, atende sempre
|
||||
// que a clínica está aberta. Efetivo = CLÍNICA ∩ DENTISTA.
|
||||
const dentWins = (cfg && cfg.length)
|
||||
? cfg.filter((h) => h.dia_semana === dow).map((h) => [hm(h.hora_inicio), hm(h.hora_fim)])
|
||||
: clinicWins;
|
||||
const windows = interseccao(clinicWins, dentWins);
|
||||
const bs = busyBy.get(dent.id) || [];
|
||||
for (const [ini, fim] of windows) {
|
||||
const [hi, mi] = String(ini).split(':').map(Number);
|
||||
const [hf, mf] = String(fim).split(':').map(Number);
|
||||
let t = new Date(day); t.setHours(hi, mi || 0, 0, 0);
|
||||
const limit = new Date(day); limit.setHours(hf, mf || 0, 0, 0);
|
||||
while (t.getTime() + duration * 60000 <= limit.getTime() && slots.length < CANDIDATE_CAP) {
|
||||
const s = new Date(t);
|
||||
const e = new Date(t.getTime() + duration * 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;
|
||||
slots.push({ dentista_id: dent.id, dentista_nome: dent.nome, start: fmt(s), end: fmt(e) });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── B (salas): slots do dentista num QUARTO alugado (sala_reservas aprovada).
|
||||
// Disponibilidade = janela da reserva [inicio,fim]; casa o dentista da clínica
|
||||
// pelo usuario_id. Respeita folgas e agendamentos existentes (por dentista).
|
||||
if (slots.length < CANDIDATE_CAP) {
|
||||
try {
|
||||
const { rows: reservas } = await pool.query(
|
||||
`SELECT r.sala_id, r.inicio, r.fim, s.nome AS sala_nome, d.id AS dentista_id, d.nome AS dentista_nome
|
||||
FROM sala_reservas r
|
||||
JOIN salas s ON s.id = r.sala_id
|
||||
JOIN dentistas d ON d.usuario_id = r.profissional_usuario_id
|
||||
WHERE d.clinica_id = $1
|
||||
AND lower(coalesce(r.status,'')) NOT IN ('cancelada','cancelado','recusada','recusado','rejeitada','pendente','negada')
|
||||
AND r.fim > $2 AND r.inicio < $3
|
||||
ORDER BY r.inicio`,
|
||||
[clinicaId, start0.toISOString(), end0.toISOString()]);
|
||||
const step = duration * 60000;
|
||||
for (const rv of reservas) {
|
||||
if (slots.length >= CANDIDATE_CAP) break;
|
||||
const bs = busyBy.get(rv.dentista_id) || [];
|
||||
const rIni = new Date(rv.inicio), rFim = new Date(rv.fim);
|
||||
let t = new Date(Math.max(rIni.getTime(), start0.getTime(), now.getTime()));
|
||||
const off = (t.getTime() - rIni.getTime()) % step;
|
||||
if (off !== 0) t = new Date(t.getTime() + (step - off)); // alinha ao grid da reserva
|
||||
while (t.getTime() + step <= rFim.getTime() && slots.length < CANDIDATE_CAP) {
|
||||
const s = new Date(t), e = new Date(t.getTime() + step); t = e;
|
||||
if (s <= now) continue;
|
||||
if (emFolga(folgasBy.get(rv.dentista_id), ymd(s))) continue;
|
||||
if (bs.some((b) => new Date(b.start_time) < e && new Date(b.end_time || b.start_time) > s)) continue;
|
||||
slots.push({ dentista_id: rv.dentista_id, dentista_nome: rv.dentista_nome, start: fmt(s), end: fmt(e), sala_id: rv.sala_id, local: rv.sala_nome });
|
||||
}
|
||||
}
|
||||
} catch { /* sala_reservas pode não existir */ }
|
||||
}
|
||||
|
||||
let out = slots.sort((a, b) => a.start.localeCompare(b.start));
|
||||
// Filtro manhã/tarde (a Secretária pergunta o período antes de oferecer).
|
||||
if (periodo === 'manha' || periodo === 'manhã') out = out.filter((s) => parseInt(s.start.slice(11, 13), 10) < 12);
|
||||
else if (periodo === 'tarde') out = out.filter((s) => parseInt(s.start.slice(11, 13), 10) >= 12);
|
||||
res.json({ slots: out.slice(0, MAX_SLOTS) });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── GET /status ──────────────────────────────────────────────────────────────
|
||||
// Situação de funcionamento da clínica p/ a Secretária SABER quando responder
|
||||
// "estamos fechados". Query: clinica_id (obrig.), date? (YYYY-MM-DD, def: hoje BR).
|
||||
// Retorna: hoje (aberto/motivo/horários), próximo dia aberto, grade da semana e
|
||||
// próximos feriados.
|
||||
router.get('/status', async (req, res) => {
|
||||
const clinicaId = String(req.query.clinica_id || '');
|
||||
if (!clinicaId) return res.status(400).json({ error: 'clinica_id obrigatório' });
|
||||
try {
|
||||
// "Hoje" no fuso de Brasília (o servidor pode rodar em UTC).
|
||||
const hojeBR = req.query.date
|
||||
? String(req.query.date)
|
||||
: new Date().toLocaleDateString('en-CA', { timeZone: 'America/Sao_Paulo' }); // YYYY-MM-DD
|
||||
const clinicHours = await carregarHorariosClinica(pool, clinicaId);
|
||||
const dateAt = (str) => new Date(`${str}T12:00:00`); // meio-dia: evita borda de fuso
|
||||
const addYmd = (str, k) => { const d = dateAt(str); d.setDate(d.getDate() + k); return ymd(d); };
|
||||
const statusDoDia = async (str) => {
|
||||
const dow = dateAt(str).getDay();
|
||||
const r = await janelasClinicaNoDia(pool, clinicaId, str, dow, clinicHours);
|
||||
return {
|
||||
data: str, dia_semana: dow, dia_nome: DIAS_NOME[dow],
|
||||
aberto: !r.fechado && r.janelas.length > 0, motivo: r.motivo,
|
||||
horarios: r.janelas.map(([i, f]) => ({ inicio: i, fim: f })),
|
||||
};
|
||||
};
|
||||
|
||||
// Grade dos próximos 7 dias (inclui hoje).
|
||||
const semana = [];
|
||||
for (let k = 0; k < 7; k++) semana.push(await statusDoDia(addYmd(hojeBR, k)));
|
||||
const hoje = semana[0];
|
||||
// Próximo dia aberto: primeiro aberto na semana; se nenhum, varre +14.
|
||||
let proximo = semana.find((s) => s.aberto) || null;
|
||||
if (!proximo) for (let k = 7; k <= 21; k++) { const s = await statusDoDia(addYmd(hojeBR, k)); if (s.aberto) { proximo = s; break; } }
|
||||
|
||||
// Próximos feriados (45 dias) — clínica (1 query) + nacionais (em memória).
|
||||
const feriados = [];
|
||||
let excMap = new Map();
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT data, nome, fecha, recorrente FROM feriados WHERE clinica_id = $1`, [clinicaId]);
|
||||
for (const r of rows) {
|
||||
const ds = (r.data instanceof Date) ? ymd(r.data) : String(r.data).slice(0, 10);
|
||||
excMap.set(ds, r);
|
||||
if (r.recorrente === 1) excMap.set('R' + ds.slice(5), r);
|
||||
}
|
||||
} catch { /* tabela pode não existir */ }
|
||||
for (let k = 0; k < 45; k++) {
|
||||
const str = addYmd(hojeBR, k);
|
||||
const exc = excMap.get(str) || excMap.get('R' + str.slice(5));
|
||||
if (exc) { if (exc.fecha !== 0) feriados.push({ data: str, nome: exc.nome, origem: 'clinica' }); continue; }
|
||||
const nac = feriadoNacionalNome(str);
|
||||
if (nac) feriados.push({ data: str, nome: nac, origem: 'nacional' });
|
||||
}
|
||||
|
||||
res.json({ hoje, proximo_aberto: proximo, semana, feriados_proximos: feriados.slice(0, 8) });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── POST /pedido ──────────────────────────────────────────────────────────────
|
||||
// A Secretária IA registra um pedido de horário FORA da janela livre (zona cinza:
|
||||
// mais cedo/mais tarde) para a SECRETÁRIA HUMANA confirmar. A IA não agenda esses.
|
||||
// Body: { clinica_id, data (YYYY-MM-DD), hora (HH:MM), hora_fim?, dentista_nome?,
|
||||
// nome_cliente, telefone?, procedimento? }
|
||||
router.post('/pedido', async (req, res) => {
|
||||
const b = req.body || {};
|
||||
if (!b.clinica_id || !b.data || !b.hora) return res.status(400).json({ error: 'clinica_id, data e hora obrigatórios' });
|
||||
try {
|
||||
// Dia totalmente fechado (feriado/fechamento) → não adianta criar pedido.
|
||||
const fer = await resolverFeriado(pool, b.clinica_id, b.data);
|
||||
if (fer && fer.fecha) return res.json({ ok: false, motivo: `Fechado nesse dia (${fer.nome}).` });
|
||||
// Resolve o dentista pelo nome (opcional).
|
||||
let dentistaId = null;
|
||||
if (b.dentista_nome) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id FROM dentistas WHERE clinica_id = $1 AND lower(nome) LIKE '%'||lower($2)||'%' LIMIT 1`,
|
||||
[b.clinica_id, b.dentista_nome]);
|
||||
dentistaId = rows[0]?.id || null;
|
||||
}
|
||||
const id = `ped_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||||
await pool.query(
|
||||
`INSERT INTO agenda_pedidos (id, clinica_id, dentista_id, data, hora_inicio, hora_fim, paciente_nome, paciente_telefone, procedimento, status)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,'pendente')`,
|
||||
[id, b.clinica_id, dentistaId, b.data, hm(b.hora), b.hora_fim ? hm(b.hora_fim) : null,
|
||||
b.nome_cliente || null, (String(b.telefone || '').replace(/\D/g, '')) || null, b.procedimento || null]);
|
||||
res.json({ ok: true, pedido_id: id });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── POST /duvida ──────────────────────────────────────────────────────────────
|
||||
// Encaminha uma DÚVIDA/caso incerto à secretária humana (sem agendar). Cria um
|
||||
// agenda_pedidos tipo='duvida' com o resumo; entra na mesma fila de pendentes.
|
||||
router.post('/duvida', async (req, res) => {
|
||||
const b = req.body || {};
|
||||
if (!b.clinica_id || !b.resumo) return res.status(400).json({ error: 'clinica_id e resumo obrigatórios' });
|
||||
try {
|
||||
const id = `ped_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||||
await pool.query(
|
||||
`INSERT INTO agenda_pedidos (id, clinica_id, tipo, resumo, paciente_nome, paciente_telefone, status)
|
||||
VALUES ($1,$2,'duvida',$3,$4,$5,'pendente')`,
|
||||
[id, b.clinica_id, String(b.resumo).slice(0, 2000),
|
||||
b.nome_cliente || null, (String(b.telefone || '').replace(/\D/g, '')) || null]);
|
||||
res.json({ ok: true, pedido_id: id });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── GET /contexto ─────────────────────────────────────────────────────────────
|
||||
// Contexto clínico p/ a Secretária: dentistas + especialidades + "situações"
|
||||
// (regras da clínica). Query: clinica_id.
|
||||
router.get('/contexto', async (req, res) => {
|
||||
const clinicaId = String(req.query.clinica_id || '');
|
||||
if (!clinicaId) return res.status(400).json({ error: 'clinica_id obrigatório' });
|
||||
try {
|
||||
const { rows: dentistas } = await pool.query(
|
||||
`SELECT nome, especialidade, especialidades FROM dentistas WHERE clinica_id = $1 AND ativo IS DISTINCT FROM 0 ORDER BY nome`, [clinicaId]);
|
||||
let situacoes = [];
|
||||
try {
|
||||
const { rows } = await pool.query(`SELECT texto FROM agenda_situacoes WHERE clinica_id = $1 AND ativo = 1 ORDER BY ordem, created_at`, [clinicaId]);
|
||||
situacoes = rows.map((r) => r.texto);
|
||||
} catch { /* tabela pode não existir ainda */ }
|
||||
// Cautelas do checklist do dono (declaração × dados reais) → a IA não chuta.
|
||||
let cautelas = [];
|
||||
try {
|
||||
const respostas = {};
|
||||
const { rows } = await pool.query(`SELECT chave, resposta FROM agenda_checklist WHERE clinica_id = $1`, [clinicaId]);
|
||||
for (const r of rows) respostas[r.chave] = r.resposta == null ? null : Number(r.resposta);
|
||||
const nPac = await pool.query(`SELECT COUNT(*) n FROM pacientes WHERE clinica_id = $1`, [clinicaId]).then((x) => Number(x.rows[0]?.n || 0)).catch(() => 0);
|
||||
cautelas = cautelasParaIA(respostas, { dentistas: dentistas.length, pacientes: nPac });
|
||||
} catch { /* checklist novo/indisponível */ }
|
||||
res.json({
|
||||
dentistas: dentistas.map((d) => ({
|
||||
nome: d.nome,
|
||||
especialidades: Array.isArray(d.especialidades) ? d.especialidades : (d.especialidade ? [d.especialidade] : []),
|
||||
})),
|
||||
situacoes,
|
||||
cautelas,
|
||||
});
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── GET /dentista-contato ─────────────────────────────────────────────────────
|
||||
// Resolve o WhatsApp de um dentista da clínica (Parte A: a IA aciona o Dr).
|
||||
// Query: clinica_id (obrig.), nome? OU dentista_id?
|
||||
router.get('/dentista-contato', async (req, res) => {
|
||||
const clinicaId = String(req.query.clinica_id || '');
|
||||
if (!clinicaId) return res.status(400).json({ error: 'clinica_id obrigatório' });
|
||||
try {
|
||||
const params = [clinicaId]; let where = 'clinica_id = $1';
|
||||
if (req.query.dentista_id) { params.push(String(req.query.dentista_id)); where += ` AND id = $${params.length}`; }
|
||||
else if (req.query.nome) { params.push(String(req.query.nome)); where += ` AND lower(nome) LIKE '%'||lower($${params.length})||'%'`; }
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, nome, telefone FROM dentistas WHERE ${where} AND ativo IS DISTINCT FROM 0 ORDER BY nome LIMIT 1`, params);
|
||||
if (!rows.length) return res.json({ encontrado: false });
|
||||
const d = rows[0];
|
||||
const tel = String(d.telefone || '').replace(/\D/g, '');
|
||||
res.json({ encontrado: true, dentista_id: d.id, nome: d.nome, telefone: tel || null });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── POST /book ────────────────────────────────────────────────────────────────
|
||||
// Body: { clinica_id, dentista_id, start, end?, paciente_nome, paciente_celular, procedimento? }
|
||||
router.post('/book', async (req, res) => {
|
||||
const b = req.body || {};
|
||||
if (!b.clinica_id || !b.dentista_id || !b.start) {
|
||||
return res.status(400).json({ error: 'clinica_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 {
|
||||
await client.query('BEGIN');
|
||||
const conflict = await findConflict(client, b.dentista_id, b.start, end);
|
||||
if (conflict) { await client.query('ROLLBACK'); return res.status(409).json({ error: 'Horário já ocupado.', conflict_id: conflict.id }); }
|
||||
|
||||
// Vincula paciente pelo telefone (escopo da clínica; casa pelos últimos 8 dígitos).
|
||||
let pacienteId = null;
|
||||
let pacienteCriado = false;
|
||||
if (b.paciente_celular) {
|
||||
const tel = String(b.paciente_celular).replace(/\D/g, '').slice(-8);
|
||||
if (tel) {
|
||||
const { rows } = await client.query(
|
||||
`SELECT id FROM pacientes
|
||||
WHERE clinica_id = $1 AND regexp_replace(coalesce(telefone,''),'\\D','','g') LIKE '%'||$2 LIMIT 1`,
|
||||
[b.clinica_id, tel]);
|
||||
pacienteId = rows[0]?.id || null;
|
||||
}
|
||||
}
|
||||
// Não encontrado → cria um paciente mínimo (lead do WhatsApp vira paciente).
|
||||
if (!pacienteId && b.paciente_nome) {
|
||||
const pid = `pac_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
|
||||
await client.query(
|
||||
'INSERT INTO pacientes (id, nome, telefone, clinica_id) VALUES ($1,$2,$3,$4)',
|
||||
[pid, b.paciente_nome, b.paciente_celular || null, b.clinica_id]);
|
||||
pacienteId = pid;
|
||||
pacienteCriado = true;
|
||||
}
|
||||
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 id = `ag_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
|
||||
const { rows: ins } = await client.query(
|
||||
`INSERT INTO agendamentos
|
||||
(id, clinica_id, pacientenome, pacientecelular, pacienteid, dentistaid, procedimento,
|
||||
start_time, end_time, status, setor_id, created_by_nome, created_at, updated_at, sala_id)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,'agendado',$10,'Secretária IA',NOW(),NOW(),$11)
|
||||
RETURNING id, start_time, end_time, dentistaid`,
|
||||
[id, b.clinica_id, b.paciente_nome || 'Cliente WhatsApp', b.paciente_celular || null,
|
||||
pacienteId, b.dentista_id, b.procedimento || 'Consulta', b.start, end, setorId, b.sala_id || null]);
|
||||
await client.query('COMMIT');
|
||||
res.json({ ok: true, agendamento: ins[0], paciente_vinculado: !!pacienteId, paciente_criado: pacienteCriado });
|
||||
} catch (e) {
|
||||
try { await client.query('ROLLBACK'); } catch { /* ignore */ }
|
||||
res.status(500).json({ error: e.message });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
// ── GET /encaixe/analisar ─────────────────────────────────────────────────────
|
||||
// Fluxo de "adiantar consulta": dado um dia (e horário desejado), diz se o horário
|
||||
// está OCUPADO (por quem) e qual o horário LIVRE mais cedo que dá para oferecer/
|
||||
// encaixar — incluindo a janela FLEX (zona cinza), pois na contenção a Secretária
|
||||
// pode usá-la. Query: clinica_id, dentista_id?, data (YYYY-MM-DD), hora? (HH:MM).
|
||||
router.get('/encaixe/analisar', async (req, res) => {
|
||||
const clinicaId = String(req.query.clinica_id || '');
|
||||
const data = String(req.query.data || '');
|
||||
if (!clinicaId || !data) return res.status(400).json({ error: 'clinica_id e data obrigatórios' });
|
||||
const dentistaId = req.query.dentista_id ? String(req.query.dentista_id) : null;
|
||||
const horaDesejada = req.query.hora ? String(req.query.hora).slice(0, 5) : null;
|
||||
try {
|
||||
const day = new Date(`${data}T00:00:00`);
|
||||
const dow = day.getDay();
|
||||
const dParams = [clinicaId]; let dWhere = 'clinica_id = $1 AND ativo IS DISTINCT FROM 0';
|
||||
if (dentistaId) { dParams.push(dentistaId); dWhere += ` AND id = $${dParams.length}`; }
|
||||
const { rows: dentistas } = await pool.query(`SELECT id, nome FROM dentistas WHERE ${dWhere}`, dParams);
|
||||
if (!dentistas.length) return res.json({ mais_cedo: null, contestado: null });
|
||||
|
||||
const clinicHours = await carregarHorariosClinica(pool, clinicaId);
|
||||
const { janelas: clinicWins, fechado } = await janelasClinicaNoDia(pool, clinicaId, data, dow, clinicHours);
|
||||
if (fechado || !clinicWins.length) return res.json({ mais_cedo: null, contestado: null, motivo: 'fechado' });
|
||||
|
||||
// Jornada FLEX-inclusiva (usa hora_*_flex quando houver — a zona cinza entra no encaixe).
|
||||
const { rows: horarios } = await pool.query(
|
||||
`SELECT dentista_id,
|
||||
COALESCE(hora_inicio_flex, hora_inicio) AS hora_inicio,
|
||||
COALESCE(hora_fim_flex, hora_fim) AS hora_fim
|
||||
FROM dentistas_horarios WHERE clinica_id = $1 AND ativo = 1 AND dia_semana = $2`, [clinicaId, dow]);
|
||||
const hoursBy = new Map();
|
||||
for (const h of horarios) { const a = hoursBy.get(h.dentista_id) || []; a.push([hm(h.hora_inicio), hm(h.hora_fim)]); hoursBy.set(h.dentista_id, a); }
|
||||
|
||||
const start0 = new Date(day); start0.setHours(0, 0, 0, 0);
|
||||
const end0 = new Date(start0); end0.setDate(end0.getDate() + 1);
|
||||
const { rows: busy } = await pool.query(
|
||||
`SELECT id, dentistaid, pacientenome, pacientecelular, start_time, end_time FROM agendamentos
|
||||
WHERE clinica_id=$1 AND start_time>=$2 AND start_time<$3
|
||||
AND (status IS NULL OR lower(status) NOT IN ('cancelado','falta','remarcar'))`,
|
||||
[clinicaId, start0.toISOString(), end0.toISOString()]);
|
||||
const busyBy = new Map();
|
||||
for (const bz of busy) { const a = busyBy.get(bz.dentistaid) || []; a.push(bz); busyBy.set(bz.dentistaid, a); }
|
||||
|
||||
// Quem ocupa o horário desejado (direto, sem depender do grid).
|
||||
let contestado = null;
|
||||
if (horaDesejada) {
|
||||
const alvo = new Date(`${data}T${horaDesejada}:00`);
|
||||
const occ = busy.find((b) => new Date(b.start_time) <= alvo && new Date(b.end_time || b.start_time) > alvo)
|
||||
|| busy.find((b) => fmt(new Date(b.start_time)).slice(11, 16) === horaDesejada);
|
||||
if (occ) {
|
||||
const dn = dentistas.find((d) => d.id === occ.dentistaid);
|
||||
contestado = { agendamento_id: occ.id, dentista_id: occ.dentistaid, dentista_nome: dn?.nome || null,
|
||||
paciente_nome: occ.pacientenome || null, paciente_celular: occ.pacientecelular || null,
|
||||
start: fmt(new Date(occ.start_time)), hora: fmt(new Date(occ.start_time)).slice(11, 16) };
|
||||
}
|
||||
}
|
||||
|
||||
// Slots LIVRES do dia (flex-inclusivo).
|
||||
const now = new Date(); const dur = SLOT_MIN; const livres = [];
|
||||
for (const dent of dentistas) {
|
||||
const dw = (hoursBy.get(dent.id) && hoursBy.get(dent.id).length) ? hoursBy.get(dent.id) : clinicWins;
|
||||
const windows = interseccao(clinicWins, dw);
|
||||
const bs = busyBy.get(dent.id) || [];
|
||||
for (const [ini, fim] of windows) {
|
||||
const [hi, mi] = String(ini).split(':').map(Number);
|
||||
const [hf, mf] = String(fim).split(':').map(Number);
|
||||
let t = new Date(day); t.setHours(hi, mi || 0, 0, 0);
|
||||
const limit = new Date(day); limit.setHours(hf, mf || 0, 0, 0);
|
||||
while (t.getTime() + dur * 60000 <= limit.getTime()) {
|
||||
const s = new Date(t), e = new Date(t.getTime() + dur * 60000); t = e;
|
||||
if (s <= now) continue;
|
||||
if (bs.some((b) => new Date(b.start_time) < e && new Date(b.end_time || b.start_time) > s)) continue;
|
||||
livres.push({ dentista_id: dent.id, dentista_nome: dent.nome, start: fmt(s), end: fmt(e), hora: fmt(s).slice(11, 16) });
|
||||
}
|
||||
}
|
||||
}
|
||||
livres.sort((a, b) => a.start.localeCompare(b.start));
|
||||
// mais_cedo = livre imediatamente ANTES do desejado (o mais perto por baixo); senão o 1º livre.
|
||||
let mais_cedo = null;
|
||||
if (horaDesejada) {
|
||||
const antes = livres.filter((s) => s.hora < horaDesejada);
|
||||
mais_cedo = antes.length ? antes[antes.length - 1] : (livres[0] || null);
|
||||
} else {
|
||||
mais_cedo = livres[0] || null;
|
||||
}
|
||||
res.json({ mais_cedo, contestado, total_livres: livres.length });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── POST /encaixe/mover ───────────────────────────────────────────────────────
|
||||
// Move um agendamento existente para um novo horário (o A aceitou adiantar).
|
||||
// Body: { clinica_id, agendamento_id, start, end? }.
|
||||
router.post('/encaixe/mover', async (req, res) => {
|
||||
const b = req.body || {};
|
||||
if (!b.clinica_id || !b.agendamento_id || !b.start) return res.status(400).json({ error: 'clinica_id, agendamento_id e start obrigatórios' });
|
||||
const end = b.end || new Date(new Date(b.start).getTime() + SLOT_MIN * 60000).toISOString();
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const { rows } = await client.query('SELECT id, dentistaid FROM agendamentos WHERE id=$1 AND clinica_id=$2 FOR UPDATE', [b.agendamento_id, b.clinica_id]);
|
||||
const ag = rows[0];
|
||||
if (!ag) { await client.query('ROLLBACK'); return res.status(404).json({ error: 'Agendamento não encontrado.' }); }
|
||||
const conflict = await findConflict(client, ag.dentistaid, b.start, end, b.agendamento_id);
|
||||
if (conflict) { await client.query('ROLLBACK'); return res.status(409).json({ error: 'Horário de destino já ocupado.' }); }
|
||||
await client.query('UPDATE agendamentos SET start_time=$1, end_time=$2, updated_at=NOW() WHERE id=$3', [b.start, end, b.agendamento_id]);
|
||||
await client.query('COMMIT');
|
||||
res.json({ ok: true });
|
||||
} catch (e) { try { await client.query('ROLLBACK'); } catch { /* ignore */ } res.status(500).json({ error: e.message }); }
|
||||
finally { client.release(); }
|
||||
});
|
||||
|
||||
// ════════ Cadastro de pacientes (Secretária super inteligente) ════════════════
|
||||
const soDigitos = (v) => String(v || '').replace(/\D/g, '');
|
||||
function idadeDe(dob) {
|
||||
if (!dob) return null;
|
||||
const s = String(dob).trim();
|
||||
let d = null, m = s.match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||
if (m) d = new Date(+m[1], +m[2] - 1, +m[3]);
|
||||
else { m = s.match(/^(\d{2})\/(\d{2})\/(\d{4})/); if (m) d = new Date(+m[3], +m[2] - 1, +m[1]); }
|
||||
if (!d || isNaN(d.getTime())) return null;
|
||||
const n = new Date(); let a = n.getFullYear() - d.getFullYear();
|
||||
const mo = n.getMonth() - d.getMonth();
|
||||
if (mo < 0 || (mo === 0 && n.getDate() < d.getDate())) a--;
|
||||
return a >= 0 && a < 130 ? a : null;
|
||||
}
|
||||
|
||||
// GET /pacientes/lookup?clinica_id=&phone= — quem está cadastrado NESTE número (a família).
|
||||
router.get('/pacientes/lookup', async (req, res) => {
|
||||
const clinicaId = String(req.query.clinica_id || '');
|
||||
const tel = soDigitos(req.query.phone).slice(-8);
|
||||
if (!clinicaId || !tel) return res.status(400).json({ error: 'clinica_id e phone obrigatórios' });
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, nome, cpf, telefone, datanascimento, convenio, grupofamiliarid, grupofamiliarrelacao
|
||||
FROM pacientes
|
||||
WHERE clinica_id = $1 AND regexp_replace(coalesce(telefone,''),'\\D','','g') LIKE '%'||$2
|
||||
ORDER BY (grupofamiliarrelacao = 'titular' OR grupofamiliarrelacao IS NULL) DESC, nome`,
|
||||
[clinicaId, tel]);
|
||||
res.json({
|
||||
encontrados: rows.length,
|
||||
pacientes: rows.map((p) => ({
|
||||
id: p.id, nome: p.nome, cpf: p.cpf || null,
|
||||
data_nascimento: p.datanascimento || null, idade: idadeDe(p.datanascimento),
|
||||
convenio: p.convenio || null, relacao: p.grupofamiliarrelacao || null, grupo_id: p.grupofamiliarid || null,
|
||||
})),
|
||||
});
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// POST /pacientes — cadastra/atualiza. Dedup por CPF; idade por nascimento; grupo familiar
|
||||
// (dependente vinculado ao titular via grupofamiliarid = id do titular).
|
||||
router.post('/pacientes', async (req, res) => {
|
||||
const b = req.body || {};
|
||||
if (!b.clinica_id || !b.nome) return res.status(400).json({ error: 'clinica_id e nome obrigatórios' });
|
||||
const cpf = soDigitos(b.cpf);
|
||||
const telefone = b.telefone ? soDigitos(b.telefone) : null;
|
||||
const idade = idadeDe(b.data_nascimento);
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
// 1) Dedup forte por CPF (identificador confiável — evita duplicar quem já existe).
|
||||
let existente = null;
|
||||
if (cpf) {
|
||||
const { rows } = await client.query(
|
||||
`SELECT * FROM pacientes WHERE clinica_id=$1 AND regexp_replace(coalesce(cpf,''),'\\D','','g')=$2 LIMIT 1`, [b.clinica_id, cpf]);
|
||||
existente = rows[0] || null;
|
||||
}
|
||||
// 2) Resolve o RESPONSÁVEL (para dependentes): por id, cpf ou telefone (o titular do número).
|
||||
let responsavel = null;
|
||||
if (b.responsavel_id) {
|
||||
const { rows } = await client.query('SELECT * FROM pacientes WHERE id=$1 AND clinica_id=$2', [b.responsavel_id, b.clinica_id]); responsavel = rows[0] || null;
|
||||
} else if (b.responsavel_cpf) {
|
||||
const { rows } = await client.query(`SELECT * FROM pacientes WHERE clinica_id=$1 AND regexp_replace(coalesce(cpf,''),'\\D','','g')=$2 LIMIT 1`, [b.clinica_id, soDigitos(b.responsavel_cpf)]); responsavel = rows[0] || null;
|
||||
} else if (b.responsavel_telefone) {
|
||||
const { rows } = await client.query(
|
||||
`SELECT * FROM pacientes WHERE clinica_id=$1 AND regexp_replace(coalesce(telefone,''),'\\D','','g') LIKE '%'||$2
|
||||
AND (grupofamiliarrelacao='titular' OR grupofamiliarrelacao IS NULL) ORDER BY nome LIMIT 1`,
|
||||
[b.clinica_id, soDigitos(b.responsavel_telefone).slice(-8)]); responsavel = rows[0] || null;
|
||||
}
|
||||
// Dependente = tem responsável OU menor de 18 (padrão) sem marcar eh_titular.
|
||||
const ehDependente = !!responsavel || (idade != null && idade < 18 && !b.eh_titular);
|
||||
let relacao = b.relacao || (ehDependente ? (idade != null && idade < 18 ? 'filho(a)' : 'dependente') : (b.eh_titular ? 'titular' : null));
|
||||
// Telefone do dependente sem número próprio → herda o do responsável (compartilhado).
|
||||
let telFinal = telefone;
|
||||
if (ehDependente && !telFinal && responsavel && responsavel.telefone) telFinal = soDigitos(responsavel.telefone);
|
||||
// 3) Grupo familiar: titular vira cabeça (grupofamiliarid = id do titular) + grupos_familiares.
|
||||
let grupoId = null;
|
||||
if (ehDependente && responsavel) {
|
||||
grupoId = responsavel.grupofamiliarid || responsavel.id;
|
||||
if (!responsavel.grupofamiliarid) {
|
||||
await client.query(`UPDATE pacientes SET grupofamiliarid=$1, grupofamiliarrelacao=COALESCE(grupofamiliarrelacao,'titular') WHERE id=$1`, [responsavel.id]);
|
||||
}
|
||||
await client.query(`INSERT INTO grupos_familiares (id, nome, responsavel_id, created_at) VALUES ($1,$2,$3,NOW()) ON CONFLICT (id) DO NOTHING`, [grupoId, responsavel.nome, responsavel.id]);
|
||||
}
|
||||
// 4) Atualiza (dedup) OU cria.
|
||||
if (existente) {
|
||||
await client.query(
|
||||
`UPDATE pacientes SET nome=$2, telefone=COALESCE($3,telefone), email=COALESCE($4,email),
|
||||
convenio=COALESCE($5,convenio), datanascimento=COALESCE($6,datanascimento),
|
||||
grupofamiliarid=COALESCE($7,grupofamiliarid), grupofamiliarrelacao=COALESCE($8,grupofamiliarrelacao) WHERE id=$1`,
|
||||
[existente.id, b.nome, telFinal, b.email || null, b.convenio || null, b.data_nascimento || null, grupoId, relacao]);
|
||||
await client.query('COMMIT');
|
||||
return res.json({ ok: true, atualizado: true, paciente: { id: existente.id, nome: b.nome, relacao, grupo_id: grupoId, idade } });
|
||||
}
|
||||
const id = `pac_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||||
await client.query(
|
||||
`INSERT INTO pacientes (id, clinica_id, nome, cpf, telefone, email, convenio, datanascimento, grupofamiliarid, grupofamiliarrelacao)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
|
||||
[id, b.clinica_id, b.nome, cpf || null, telFinal, b.email || null, b.convenio || null, b.data_nascimento || null, grupoId, relacao]);
|
||||
await client.query('COMMIT');
|
||||
res.json({ ok: true, criado: true, paciente: { id, nome: b.nome, cpf: cpf || null, relacao, grupo_id: grupoId, idade, dependente: ehDependente } });
|
||||
} catch (e) {
|
||||
try { await client.query('ROLLBACK'); } catch { /* ignore */ }
|
||||
res.status(500).json({ error: e.message });
|
||||
} finally { client.release(); }
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
// Config de horários/feriados — API para o FRONTEND do scoreodonto (dono/dentista).
|
||||
// Auth: JWT do scoreodonto (Authorization: Bearer). Ownership:
|
||||
// - Horário da CLÍNICA + feriados → só o DONO (clinicas.owner_id / vínculo dono).
|
||||
// - Horário do DENTISTA → o próprio dentista (dentistas.usuario_id) OU o dono.
|
||||
//
|
||||
// Montado em /api/nw/agenda-config pelo registerNewwhats.
|
||||
import express from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { feriadosNacionais } from './feriados.js';
|
||||
import { CHECKLIST_QUESTOES, pendenciasDeConfig } from './checklist-defs.js';
|
||||
|
||||
function authFromReq(req) {
|
||||
try {
|
||||
const tk = (req.headers['authorization'] || '').replace(/^Bearer\s+/i, '').trim();
|
||||
if (!tk) return null;
|
||||
const p = jwt.verify(tk, process.env.JWT_SECRET);
|
||||
return { userId: p.userId || null, email: (p.email || '').toLowerCase() || null };
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// É dono do workspace (clínica)? owner_id direto OU vínculo dono/admin.
|
||||
async function isDono(pool, clinicaId, userId) {
|
||||
if (!clinicaId || !userId) return false;
|
||||
try {
|
||||
const { rows: cl } = await pool.query('SELECT owner_id FROM clinicas WHERE id = $1', [clinicaId]);
|
||||
if (cl[0]?.owner_id && cl[0].owner_id === userId) return true;
|
||||
const { rows: v } = await pool.query(
|
||||
`SELECT 1 FROM vinculos WHERE clinica_id = $1 AND usuario_id = $2
|
||||
AND role IN ('donoclinica','donoconsultorio','admin') LIMIT 1`, [clinicaId, userId]);
|
||||
return v.length > 0;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
const uid = (p) => `${p}_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||||
const hhmm = (v) => { const m = String(v ?? '').match(/^(\d{1,2}):(\d{2})/); return m ? `${m[1].padStart(2, '0')}:${m[2]}` : null; };
|
||||
|
||||
export function createAgendaConfig(pool) {
|
||||
const router = express.Router();
|
||||
|
||||
router.use((req, res, next) => {
|
||||
const auth = authFromReq(req);
|
||||
if (!auth) return res.status(401).json({ error: 'Sessão inválida.' });
|
||||
req.nwUser = auth;
|
||||
next();
|
||||
});
|
||||
|
||||
// ── Horário de FUNCIONAMENTO da clínica (dono) ────────────────────────────────
|
||||
router.get('/clinica/:clinicaId/horarios', async (req, res) => {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, dia_semana, to_char(hora_inicio,'HH24:MI') AS hora_inicio,
|
||||
to_char(hora_fim,'HH24:MI') AS hora_fim, ativo
|
||||
FROM clinicas_horarios WHERE clinica_id = $1 ORDER BY dia_semana, hora_inicio`,
|
||||
[req.params.clinicaId]);
|
||||
res.json({ horarios: rows });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Substitui TODA a grade da clínica (mais simples e previsível que patch por dia).
|
||||
// Body: { horarios: [{ dia_semana, hora_inicio, hora_fim }] }
|
||||
router.put('/clinica/:clinicaId/horarios', async (req, res) => {
|
||||
const clinicaId = req.params.clinicaId;
|
||||
if (!(await isDono(pool, clinicaId, req.nwUser.userId)))
|
||||
return res.status(403).json({ error: 'Apenas o dono pode configurar o horário da clínica.' });
|
||||
const linhas = Array.isArray(req.body?.horarios) ? req.body.horarios : [];
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query('DELETE FROM clinicas_horarios WHERE clinica_id = $1', [clinicaId]);
|
||||
for (const h of linhas) {
|
||||
const dow = Number(h.dia_semana); const ini = hhmm(h.hora_inicio); const fim = hhmm(h.hora_fim);
|
||||
if (!(dow >= 0 && dow <= 6) || !ini || !fim || ini >= fim) continue;
|
||||
await client.query(
|
||||
`INSERT INTO clinicas_horarios (id, clinica_id, dia_semana, hora_inicio, hora_fim, ativo)
|
||||
VALUES ($1,$2,$3,$4,$5,1)`, [uid('ch'), clinicaId, dow, ini, fim]);
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
res.json({ ok: true });
|
||||
} catch (e) { try { await client.query('ROLLBACK'); } catch {} res.status(500).json({ error: e.message }); }
|
||||
finally { client.release(); }
|
||||
});
|
||||
|
||||
// ── Horário do DENTISTA (o próprio dentista ou o dono) ────────────────────────
|
||||
router.get('/dentista/:dentistaId/horarios', async (req, res) => {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, dia_semana, to_char(hora_inicio,'HH24:MI') AS hora_inicio,
|
||||
to_char(hora_fim,'HH24:MI') AS hora_fim,
|
||||
to_char(hora_inicio_flex,'HH24:MI') AS hora_inicio_flex,
|
||||
to_char(hora_fim_flex,'HH24:MI') AS hora_fim_flex, ativo
|
||||
FROM dentistas_horarios WHERE dentista_id = $1 ORDER BY dia_semana, hora_inicio`,
|
||||
[req.params.dentistaId]);
|
||||
res.json({ horarios: rows });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.put('/dentista/:dentistaId/horarios', async (req, res) => {
|
||||
const dentistaId = req.params.dentistaId;
|
||||
let dent;
|
||||
try { const { rows } = await pool.query('SELECT clinica_id, usuario_id FROM dentistas WHERE id = $1', [dentistaId]); dent = rows[0]; }
|
||||
catch (e) { return res.status(500).json({ error: e.message }); }
|
||||
if (!dent) return res.status(404).json({ error: 'Dentista não encontrado.' });
|
||||
const ehProprio = dent.usuario_id && dent.usuario_id === req.nwUser.userId;
|
||||
const ehDono = await isDono(pool, dent.clinica_id, req.nwUser.userId);
|
||||
if (!ehProprio && !ehDono)
|
||||
return res.status(403).json({ error: 'Só o próprio dentista (ou o dono) pode configurar estes horários.' });
|
||||
const linhas = Array.isArray(req.body?.horarios) ? req.body.horarios : [];
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query('DELETE FROM dentistas_horarios WHERE dentista_id = $1', [dentistaId]);
|
||||
for (const h of linhas) {
|
||||
const dow = Number(h.dia_semana); const ini = hhmm(h.hora_inicio); const fim = hhmm(h.hora_fim);
|
||||
if (!(dow >= 0 && dow <= 6) || !ini || !fim || ini >= fim) continue;
|
||||
// Janela estendida (zona cinza): flex_inicio ≤ inicio e flex_fim ≥ fim.
|
||||
let fIni = hhmm(h.hora_inicio_flex), fFim = hhmm(h.hora_fim_flex);
|
||||
if (fIni && fIni >= ini) fIni = null; // só vale se for MAIS cedo
|
||||
if (fFim && fFim <= fim) fFim = null; // só vale se for MAIS tarde
|
||||
await client.query(
|
||||
`INSERT INTO dentistas_horarios (id, dentista_id, clinica_id, dia_semana, hora_inicio, hora_fim, hora_inicio_flex, hora_fim_flex, ativo)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,1)`, [uid('dh'), dentistaId, dent.clinica_id, dow, ini, fim, fIni, fFim]);
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
res.json({ ok: true });
|
||||
} catch (e) { try { await client.query('ROLLBACK'); } catch {} res.status(500).json({ error: e.message }); }
|
||||
finally { client.release(); }
|
||||
});
|
||||
|
||||
// ── Folgas/férias do DENTISTA por data (o próprio dentista ou o dono) ─────────
|
||||
async function guardDentista(pool, dentistaId, user) {
|
||||
const { rows } = await pool.query('SELECT clinica_id, usuario_id FROM dentistas WHERE id = $1', [dentistaId]);
|
||||
const dent = rows[0];
|
||||
if (!dent) return { ok: false, code: 404, msg: 'Dentista não encontrado.' };
|
||||
const ehProprio = dent.usuario_id && dent.usuario_id === user.userId;
|
||||
const ehDono = await isDono(pool, dent.clinica_id, user.userId);
|
||||
if (!ehProprio && !ehDono) return { ok: false, code: 403, msg: 'Só o próprio dentista (ou o dono) pode alterar.' };
|
||||
return { ok: true, dent };
|
||||
}
|
||||
|
||||
router.get('/dentista/:dentistaId/folgas', async (req, res) => {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, to_char(data_inicio,'YYYY-MM-DD') AS data_inicio, to_char(data_fim,'YYYY-MM-DD') AS data_fim, motivo
|
||||
FROM dentistas_folgas WHERE dentista_id = $1 AND data_fim >= CURRENT_DATE ORDER BY data_inicio`,
|
||||
[req.params.dentistaId]);
|
||||
res.json({ folgas: rows });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Body: { data_inicio (YYYY-MM-DD), data_fim? (def = inicio), motivo? }
|
||||
router.post('/dentista/:dentistaId/folgas', async (req, res) => {
|
||||
const g = await guardDentista(pool, req.params.dentistaId, req.nwUser).catch((e) => ({ ok: false, code: 500, msg: e.message }));
|
||||
if (!g.ok) return res.status(g.code).json({ error: g.msg });
|
||||
const b = req.body || {};
|
||||
if (!b.data_inicio) return res.status(400).json({ error: 'data_inicio obrigatória' });
|
||||
const fim = b.data_fim && b.data_fim >= b.data_inicio ? b.data_fim : b.data_inicio;
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO dentistas_folgas (id, dentista_id, clinica_id, data_inicio, data_fim, motivo)
|
||||
VALUES ($1,$2,$3,$4,$5,$6)
|
||||
RETURNING id, to_char(data_inicio,'YYYY-MM-DD') AS data_inicio, to_char(data_fim,'YYYY-MM-DD') AS data_fim, motivo`,
|
||||
[uid('fol'), req.params.dentistaId, g.dent.clinica_id, b.data_inicio, fim, b.motivo || null]);
|
||||
res.status(201).json(rows[0]);
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.delete('/dentista/:dentistaId/folgas/:id', async (req, res) => {
|
||||
const g = await guardDentista(pool, req.params.dentistaId, req.nwUser).catch((e) => ({ ok: false, code: 500, msg: e.message }));
|
||||
if (!g.ok) return res.status(g.code).json({ error: g.msg });
|
||||
try { await pool.query('DELETE FROM dentistas_folgas WHERE id = $1 AND dentista_id = $2', [req.params.id, req.params.dentistaId]); res.json({ ok: true }); }
|
||||
catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── Feriados/fechamentos (dono) ───────────────────────────────────────────────
|
||||
// Lista os NACIONAIS (computados) + as exceções da clínica de um ano.
|
||||
router.get('/clinica/:clinicaId/feriados', async (req, res) => {
|
||||
const clinicaId = req.params.clinicaId;
|
||||
const ano = Number(req.query.ano) || new Date().getFullYear();
|
||||
try {
|
||||
const nac = Object.entries(feriadosNacionais(ano)).map(([data, nome]) => ({ data, nome, origem: 'nacional' }));
|
||||
const { rows: exc } = await pool.query(
|
||||
`SELECT id, to_char(data,'YYYY-MM-DD') AS data, nome, fecha,
|
||||
to_char(hora_inicio,'HH24:MI') AS hora_inicio, to_char(hora_fim,'HH24:MI') AS hora_fim, recorrente
|
||||
FROM feriados WHERE clinica_id = $1 AND (extract(year from data) = $2 OR recorrente = 1) ORDER BY data`,
|
||||
[clinicaId, ano]);
|
||||
res.json({ nacionais: nac, excecoes: exc.map((e) => ({ ...e, origem: 'clinica' })) });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Cria uma exceção da clínica (fechamento/ponte/horário especial). Dono.
|
||||
// Body: { data (YYYY-MM-DD), nome, fecha?(bool), hora_inicio?, hora_fim?, recorrente?(bool) }
|
||||
router.post('/clinica/:clinicaId/feriados', async (req, res) => {
|
||||
const clinicaId = req.params.clinicaId;
|
||||
if (!(await isDono(pool, clinicaId, req.nwUser.userId)))
|
||||
return res.status(403).json({ error: 'Apenas o dono pode configurar feriados/fechamentos.' });
|
||||
const b = req.body || {};
|
||||
if (!b.data || !b.nome) return res.status(400).json({ error: 'data e nome obrigatórios' });
|
||||
const fecha = b.fecha === false ? 0 : 1;
|
||||
const ini = fecha ? null : hhmm(b.hora_inicio);
|
||||
const fim = fecha ? null : hhmm(b.hora_fim);
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO feriados (id, clinica_id, data, nome, fecha, hora_inicio, hora_fim, recorrente)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
|
||||
RETURNING id, to_char(data,'YYYY-MM-DD') AS data, nome, fecha, recorrente`,
|
||||
[uid('fer'), clinicaId, b.data, b.nome, fecha, ini, fim, b.recorrente ? 1 : 0]);
|
||||
res.status(201).json(rows[0]);
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.delete('/clinica/:clinicaId/feriados/:id', async (req, res) => {
|
||||
if (!(await isDono(pool, req.params.clinicaId, req.nwUser.userId)))
|
||||
return res.status(403).json({ error: 'Apenas o dono pode remover feriados/fechamentos.' });
|
||||
try {
|
||||
await pool.query('DELETE FROM feriados WHERE id = $1 AND clinica_id = $2', [req.params.id, req.params.clinicaId]);
|
||||
res.json({ ok: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── Situações / regras da clínica (dono) — a "área de situações" da Secretária ─
|
||||
router.get('/clinica/:clinicaId/situacoes', async (req, res) => {
|
||||
try {
|
||||
const { rows } = await pool.query(`SELECT id, texto, ativo, ordem FROM agenda_situacoes WHERE clinica_id = $1 ORDER BY ordem, created_at`, [req.params.clinicaId]);
|
||||
res.json({ situacoes: rows });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
// Substitui toda a lista. Body: { situacoes: [texto] }
|
||||
router.put('/clinica/:clinicaId/situacoes', async (req, res) => {
|
||||
const clinicaId = req.params.clinicaId;
|
||||
if (!(await isDono(pool, clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Apenas o dono pode configurar as situações.' });
|
||||
const linhas = Array.isArray(req.body?.situacoes) ? req.body.situacoes : [];
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query('DELETE FROM agenda_situacoes WHERE clinica_id = $1', [clinicaId]);
|
||||
let i = 0;
|
||||
for (const s of linhas) {
|
||||
const t = String(s || '').trim(); if (!t) continue;
|
||||
await client.query(`INSERT INTO agenda_situacoes (id, clinica_id, texto, ativo, ordem) VALUES ($1,$2,$3,1,$4)`, [uid('sit'), clinicaId, t, i++]);
|
||||
}
|
||||
await client.query('COMMIT'); res.json({ ok: true });
|
||||
} catch (e) { try { await client.query('ROLLBACK'); } catch {} res.status(500).json({ error: e.message }); }
|
||||
finally { client.release(); }
|
||||
});
|
||||
|
||||
// ── Checklist do DONO + pendências (só o dono do workspace) ───────────────────
|
||||
// Conta dentistas/pacientes reais da clínica p/ conferir contra as respostas.
|
||||
async function contagens(clinicaId) {
|
||||
const q = async (sql) => { try { const { rows } = await pool.query(sql, [clinicaId]); return Number(rows[0]?.n || 0); } catch { return 0; } };
|
||||
const dentistas = await q(`SELECT COUNT(*) n FROM dentistas WHERE clinica_id = $1 AND ativo IS DISTINCT FROM 0`);
|
||||
const pacientes = await q(`SELECT COUNT(*) n FROM pacientes WHERE clinica_id = $1`);
|
||||
return { dentistas, pacientes };
|
||||
}
|
||||
async function respostasChecklist(clinicaId) {
|
||||
const map = {};
|
||||
try { const { rows } = await pool.query('SELECT chave, resposta FROM agenda_checklist WHERE clinica_id = $1', [clinicaId]); for (const r of rows) map[r.chave] = r.resposta == null ? null : Number(r.resposta); } catch { /* tabela nova */ }
|
||||
return map;
|
||||
}
|
||||
|
||||
router.get('/clinica/:clinicaId/checklist', async (req, res) => {
|
||||
const clinicaId = req.params.clinicaId;
|
||||
if (!(await isDono(pool, clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Apenas o dono acessa o checklist.' });
|
||||
try {
|
||||
const map = await respostasChecklist(clinicaId);
|
||||
res.json({ questoes: CHECKLIST_QUESTOES.map((q) => ({ ...q, resposta: map[q.chave] ?? null })) });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
// Salva respostas. Body: { respostas: { chave: 1|0|null } }
|
||||
router.put('/clinica/:clinicaId/checklist', async (req, res) => {
|
||||
const clinicaId = req.params.clinicaId;
|
||||
if (!(await isDono(pool, clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Apenas o dono pode configurar o checklist.' });
|
||||
const respostas = req.body?.respostas || {};
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
for (const q of CHECKLIST_QUESTOES) {
|
||||
if (!(q.chave in respostas)) continue;
|
||||
const v = respostas[q.chave];
|
||||
const r = v === 1 || v === true ? 1 : v === 0 || v === false ? 0 : null;
|
||||
await client.query(
|
||||
`INSERT INTO agenda_checklist (clinica_id, chave, resposta, updated_at) VALUES ($1,$2,$3,now())
|
||||
ON CONFLICT (clinica_id, chave) DO UPDATE SET resposta = EXCLUDED.resposta, updated_at = now()`,
|
||||
[clinicaId, q.chave, r]);
|
||||
}
|
||||
await client.query('COMMIT'); res.json({ ok: true });
|
||||
} catch (e) { try { await client.query('ROLLBACK'); } catch {} res.status(500).json({ error: e.message }); }
|
||||
finally { client.release(); }
|
||||
});
|
||||
|
||||
// Área de pendências do DONO — engloba TUDO: pendências de config (checklist ×
|
||||
// banco) + a fila operacional (pedidos de horário + dúvidas aguardando humano).
|
||||
router.get('/clinica/:clinicaId/pendencias', async (req, res) => {
|
||||
const clinicaId = req.params.clinicaId;
|
||||
if (!(await isDono(pool, clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Apenas o dono acessa as pendências.' });
|
||||
try {
|
||||
const counts = await contagens(clinicaId);
|
||||
const respostas = await respostasChecklist(clinicaId);
|
||||
const config = pendenciasDeConfig(respostas, counts);
|
||||
let horarios = 0, duvidas = 0;
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT COALESCE(tipo,'horario') tipo, COUNT(*) n FROM agenda_pedidos WHERE clinica_id = $1 AND status = 'pendente' GROUP BY 1`, [clinicaId]);
|
||||
for (const r of rows) { if (r.tipo === 'duvida') duvidas = Number(r.n); else horarios = Number(r.n); }
|
||||
} catch { /* tabela nova */ }
|
||||
res.json({
|
||||
contagens: counts,
|
||||
config,
|
||||
operacionais: { horarios, duvidas, total: horarios + duvidas },
|
||||
total: config.length + horarios + duvidas,
|
||||
});
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── Pedidos pendentes (zona cinza) — a secretária humana confirma/recusa ──────
|
||||
async function ehMembro(clinicaId, userId) {
|
||||
if (await isDono(pool, clinicaId, userId)) return true;
|
||||
try { const { rows } = await pool.query('SELECT 1 FROM vinculos WHERE clinica_id = $1 AND usuario_id = $2 LIMIT 1', [clinicaId, userId]); return rows.length > 0; }
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
router.get('/clinica/:clinicaId/pedidos', async (req, res) => {
|
||||
if (!(await ehMembro(req.params.clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Sem acesso.' });
|
||||
const status = req.query.status ? String(req.query.status) : 'pendente';
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT p.id, COALESCE(p.tipo,'horario') AS tipo, p.resumo,
|
||||
to_char(p.data,'YYYY-MM-DD') AS data, to_char(p.hora_inicio,'HH24:MI') AS hora_inicio,
|
||||
to_char(p.hora_fim,'HH24:MI') AS hora_fim, p.paciente_nome, p.paciente_telefone, p.procedimento,
|
||||
p.dentista_id, d.nome AS dentista_nome, p.status, p.created_at
|
||||
FROM agenda_pedidos p LEFT JOIN dentistas d ON d.id = p.dentista_id
|
||||
WHERE p.clinica_id = $1 AND p.status = $2 ORDER BY p.created_at DESC LIMIT 100`,
|
||||
[req.params.clinicaId, status]);
|
||||
res.json({ pedidos: rows, total: rows.length });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Confirma → cria o agendamento real e marca o pedido como confirmado.
|
||||
// Body: { dentista_id? (se o pedido não tinha), duracao_min? }
|
||||
router.post('/clinica/:clinicaId/pedidos/:id/confirmar', async (req, res) => {
|
||||
const clinicaId = req.params.clinicaId;
|
||||
if (!(await ehMembro(clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Sem acesso.' });
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const { rows } = await client.query('SELECT * FROM agenda_pedidos WHERE id = $1 AND clinica_id = $2 FOR UPDATE', [req.params.id, clinicaId]);
|
||||
const ped = rows[0];
|
||||
if (!ped) { await client.query('ROLLBACK'); return res.status(404).json({ error: 'Pedido não encontrado.' }); }
|
||||
if (ped.status !== 'pendente') { await client.query('ROLLBACK'); return res.status(409).json({ error: 'Pedido já resolvido.' }); }
|
||||
const dentistaId = req.body?.dentista_id || ped.dentista_id;
|
||||
if (!dentistaId) { await client.query('ROLLBACK'); return res.status(400).json({ error: 'Escolha o dentista para confirmar.' }); }
|
||||
const dur = Math.max(10, Number(req.body?.duracao_min) || 30);
|
||||
const hi = String(ped.hora_inicio).slice(0, 5);
|
||||
const start = `${(ped.data instanceof Date ? ped.data.toISOString().slice(0, 10) : String(ped.data).slice(0, 10))} ${hi}:00`;
|
||||
const endD = new Date(`${start.replace(' ', 'T')}`); endD.setMinutes(endD.getMinutes() + dur);
|
||||
const end = `${start.slice(0, 10)} ${String(endD.getHours()).padStart(2, '0')}:${String(endD.getMinutes()).padStart(2, '0')}:00`;
|
||||
const { rows: dd } = await client.query('SELECT setor_id FROM dentistas WHERE id = $1', [dentistaId]);
|
||||
const agId = `ag_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||||
await client.query(
|
||||
`INSERT INTO agendamentos (id, clinica_id, pacientenome, pacientecelular, dentistaid, procedimento,
|
||||
start_time, end_time, status, setor_id, sala_id, created_by_nome, created_at, updated_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'agendado',$9,$10,'Secretária (confirmado)',NOW(),NOW())`,
|
||||
[agId, clinicaId, ped.paciente_nome || 'Cliente WhatsApp', ped.paciente_telefone || null, dentistaId,
|
||||
ped.procedimento || 'Consulta', start, end, dd[0]?.setor_id || null, ped.sala_id || null]);
|
||||
await client.query(`UPDATE agenda_pedidos SET status='confirmado', agendamento_id=$1, resolved_at=NOW(), resolved_by=$2 WHERE id=$3`,
|
||||
[agId, req.nwUser.userId, req.params.id]);
|
||||
await client.query('COMMIT');
|
||||
res.json({ ok: true, agendamento_id: agId });
|
||||
} catch (e) { try { await client.query('ROLLBACK'); } catch {} res.status(500).json({ error: e.message }); }
|
||||
finally { client.release(); }
|
||||
});
|
||||
|
||||
// Resolve uma DÚVIDA (tipo='duvida') sem criar agendamento — a humana já
|
||||
// decidiu/atendeu (ou vai agendar manualmente uma avaliação).
|
||||
router.post('/clinica/:clinicaId/pedidos/:id/resolver', async (req, res) => {
|
||||
if (!(await ehMembro(req.params.clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Sem acesso.' });
|
||||
try {
|
||||
await pool.query(`UPDATE agenda_pedidos SET status='resolvido', resolved_at=NOW(), resolved_by=$1 WHERE id=$2 AND clinica_id=$3 AND status='pendente'`,
|
||||
[req.nwUser.userId, req.params.id, req.params.clinicaId]);
|
||||
res.json({ ok: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.post('/clinica/:clinicaId/pedidos/:id/recusar', async (req, res) => {
|
||||
if (!(await ehMembro(req.params.clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Sem acesso.' });
|
||||
try {
|
||||
await pool.query(`UPDATE agenda_pedidos SET status='recusado', resolved_at=NOW(), resolved_by=$1 WHERE id=$2 AND clinica_id=$3 AND status='pendente'`,
|
||||
[req.nwUser.userId, req.params.id, req.params.clinicaId]);
|
||||
res.json({ ok: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Lista dentistas da clínica (p/ a UI do dono escolher quem configurar / e o
|
||||
// dentista achar seu registro). Retorna também se o usuário logado é o dentista.
|
||||
router.get('/clinica/:clinicaId/dentistas', async (req, res) => {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, nome, usuario_id, ativo FROM dentistas WHERE clinica_id = $1 ORDER BY ordem NULLS LAST, nome`,
|
||||
[req.params.clinicaId]);
|
||||
res.json({ dentistas: rows.map((d) => ({ ...d, eh_voce: d.usuario_id === req.nwUser.userId })) });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// Schema (idempotente) das tabelas de horário/feriado usadas pela Secretária.
|
||||
// Chamado uma vez no registerNewwhats. Espelha os tipos de `dentistas_horarios`.
|
||||
//
|
||||
// clinicas_horarios — horário de FUNCIONAMENTO da clínica (config do DONO).
|
||||
// feriados — EXCEÇÕES da clínica (fechamento/ponte/horário especial);
|
||||
// os feriados NACIONAIS são computados (ver feriados.js).
|
||||
|
||||
export async function ensureAgendaSchema(pool) {
|
||||
try {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS clinicas_horarios (
|
||||
id varchar PRIMARY KEY,
|
||||
clinica_id varchar NOT NULL,
|
||||
dia_semana integer NOT NULL, -- 0=domingo … 6=sábado
|
||||
hora_inicio time without time zone NOT NULL,
|
||||
hora_fim time without time zone NOT NULL,
|
||||
ativo smallint DEFAULT 1
|
||||
)`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_clinicas_horarios_clinica ON clinicas_horarios (clinica_id)`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS feriados (
|
||||
id varchar PRIMARY KEY,
|
||||
clinica_id varchar NOT NULL,
|
||||
data date NOT NULL,
|
||||
nome varchar NOT NULL,
|
||||
fecha smallint DEFAULT 1, -- 1=fechado o dia; 0=abre em horário especial
|
||||
hora_inicio time without time zone, -- horário especial (quando fecha=0)
|
||||
hora_fim time without time zone,
|
||||
recorrente smallint DEFAULT 0, -- 1=repete todo ano (mesmo dia/mês)
|
||||
created_at timestamptz DEFAULT now()
|
||||
)`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_feriados_clinica_data ON feriados (clinica_id, data)`);
|
||||
|
||||
// ── dentistas_folgas — folga/férias/indisponibilidade por DATA do dentista.
|
||||
// O dentista (ou o dono) marca dias em que NÃO atende; o /slots pula.
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS dentistas_folgas (
|
||||
id varchar PRIMARY KEY,
|
||||
dentista_id varchar NOT NULL,
|
||||
clinica_id varchar,
|
||||
data_inicio date NOT NULL,
|
||||
data_fim date NOT NULL, -- inclusive; = data_inicio p/ 1 dia
|
||||
motivo varchar,
|
||||
created_at timestamptz DEFAULT now()
|
||||
)`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_dentistas_folgas_dent ON dentistas_folgas (dentista_id, data_inicio, data_fim)`);
|
||||
|
||||
// ── B (salas): agendamentos.sala_id — permite agendar num quarto alugado.
|
||||
// Aditivo/nullable: NÃO afeta o fluxo existente (clínica) quando nulo.
|
||||
await pool.query(`ALTER TABLE agendamentos ADD COLUMN IF NOT EXISTS sala_id varchar`);
|
||||
|
||||
// ── Janela ESTENDIDA do dentista (zona cinza): a IA agenda livre em
|
||||
// [hora_inicio, hora_fim]; entre a estendida e a livre exige confirmação
|
||||
// humana. Nulo = sem zona cinza (só a janela livre).
|
||||
await pool.query(`ALTER TABLE dentistas_horarios ADD COLUMN IF NOT EXISTS hora_inicio_flex time without time zone`);
|
||||
await pool.query(`ALTER TABLE dentistas_horarios ADD COLUMN IF NOT EXISTS hora_fim_flex time without time zone`);
|
||||
|
||||
// ── agenda_pedidos — pedidos da ZONA CINZA aguardando a secretária humana.
|
||||
// A IA cria (status 'pendente') e diz "vou confirmar"; a humana confirma
|
||||
// (vira agendamento) ou recusa.
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS agenda_pedidos (
|
||||
id varchar PRIMARY KEY,
|
||||
clinica_id varchar NOT NULL,
|
||||
dentista_id varchar,
|
||||
sala_id varchar,
|
||||
data date NOT NULL,
|
||||
hora_inicio time without time zone NOT NULL,
|
||||
hora_fim time without time zone,
|
||||
paciente_nome varchar,
|
||||
paciente_telefone varchar,
|
||||
procedimento varchar,
|
||||
origem varchar DEFAULT 'secretaria_ia',
|
||||
status varchar DEFAULT 'pendente', -- pendente | confirmado | recusado
|
||||
agendamento_id varchar, -- preenchido ao confirmar
|
||||
created_at timestamptz DEFAULT now(),
|
||||
resolved_at timestamptz,
|
||||
resolved_by varchar
|
||||
)`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_agenda_pedidos_clinica_status ON agenda_pedidos (clinica_id, status, created_at)`);
|
||||
// Pedido também serve para DÚVIDAS (sem horário): tipo + resumo; data/hora nullable.
|
||||
await pool.query(`ALTER TABLE agenda_pedidos ADD COLUMN IF NOT EXISTS tipo varchar DEFAULT 'horario'`); // horario | duvida
|
||||
await pool.query(`ALTER TABLE agenda_pedidos ADD COLUMN IF NOT EXISTS resumo text`);
|
||||
await pool.query(`ALTER TABLE agenda_pedidos ALTER COLUMN data DROP NOT NULL`).catch(() => {});
|
||||
await pool.query(`ALTER TABLE agenda_pedidos ALTER COLUMN hora_inicio DROP NOT NULL`).catch(() => {});
|
||||
|
||||
// ── agenda_situacoes — "área de situações": regras da clínica que a Secretária
|
||||
// IA deve seguir (ex.: "plano X não cobre canal posterior — ofereça avaliação").
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS agenda_situacoes (
|
||||
id varchar PRIMARY KEY,
|
||||
clinica_id varchar NOT NULL,
|
||||
texto text NOT NULL,
|
||||
ativo smallint DEFAULT 1,
|
||||
ordem integer DEFAULT 0,
|
||||
created_at timestamptz DEFAULT now()
|
||||
)`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_agenda_situacoes_clinica ON agenda_situacoes (clinica_id)`);
|
||||
|
||||
// ── agenda_checklist — respostas Sim/Não do DONO sobre a clínica (ver
|
||||
// checklist-defs.js). Conferidas contra o banco → geram pendências de config
|
||||
// e deixam a Secretária cautelosa. resposta: 1=sim, 0=não, NULL=não respondido.
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS agenda_checklist (
|
||||
clinica_id varchar NOT NULL,
|
||||
chave varchar NOT NULL,
|
||||
resposta smallint,
|
||||
updated_at timestamptz DEFAULT now(),
|
||||
PRIMARY KEY (clinica_id, chave)
|
||||
)`);
|
||||
return true;
|
||||
} catch (e) {
|
||||
// Não derruba o boot do satélite se o banco estiver indisponível no momento.
|
||||
console.error('[newwhats] ensureAgendaSchema falhou:', e.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Middleware de auth das rotas /nw/* — valida a integration_key enviada pelo
|
||||
// motor NewWhats. Aceita header x-nw-key ou Authorization: Bearer <key>.
|
||||
import { timingSafeEqual } from 'crypto'
|
||||
import { getConfig } from './config.js'
|
||||
|
||||
export function nwAuth(req, res, next) {
|
||||
const key = req.headers['x-nw-key']
|
||||
|| (req.headers['authorization'] || '').replace(/^Bearer\s+/i, '').trim()
|
||||
|
||||
if (!key) {
|
||||
return res.status(401).json({ error: 'Chave de integração ausente. Use o header x-nw-key.' })
|
||||
}
|
||||
|
||||
const stored = getConfig().integrationKey
|
||||
if (!stored) {
|
||||
return res.status(503).json({ error: 'Integração NewWhats não configurada (NEWWHATS_INTEGRATION_KEY).' })
|
||||
}
|
||||
|
||||
const a = Buffer.from(stored)
|
||||
const b = Buffer.from(key)
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||
return res.status(403).json({ error: 'Chave de integração inválida.' })
|
||||
}
|
||||
next()
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// Checklist do DONO do workspace: perguntas Sim/Não sobre a clínica que a
|
||||
// Secretária IA usa para saber quando PODE agendar sozinha e quando deve ter
|
||||
// cautela. As respostas são conferidas contra o banco (dentistas/pacientes):
|
||||
// quando a declaração do dono não bate com os dados, geramos uma PENDÊNCIA
|
||||
// para ele configurar/cadastrar — e a IA fica cautelosa (encaminha ao humano).
|
||||
//
|
||||
// Fonte única: config (UI), consistência (pendências) e injeção no cérebro
|
||||
// (cautelas) consomem esta mesma definição para nunca divergirem.
|
||||
|
||||
export const CHECKLIST_QUESTOES = [
|
||||
{
|
||||
chave: 'multi_dentista',
|
||||
pergunta: 'A clínica tem mais de um dentista?',
|
||||
ajuda: 'Se sim, a Secretária não pode assumir um único dentista nem "clínico geral" por padrão — precisa saber quem atende cada paciente.',
|
||||
},
|
||||
{
|
||||
chave: 'pacientes_cadastrados',
|
||||
pergunta: 'Os pacientes já estão cadastrados no sistema (/pacientes)?',
|
||||
ajuda: 'Mesmo que sim, o cadastro pode estar INCOMPLETO (nem todos os familiares). A Secretária confirma a identidade antes de agendar e encaminha ao humano quando não reconhece quem fala.',
|
||||
},
|
||||
{
|
||||
chave: 'telefone_compartilhado',
|
||||
pergunta: 'Um mesmo telefone pode ser de vários pacientes (família)?',
|
||||
ajuda: 'Ex.: o número está no cadastro do filho, mas quem manda mensagem é a mãe. Se sim, a Secretária SEMPRE confirma com quem está falando e para quem é o atendimento — nunca assume pelo número.',
|
||||
},
|
||||
{
|
||||
chave: 'dentista_fixo',
|
||||
pergunta: 'Cada paciente tem um dentista de referência (fixo)?',
|
||||
ajuda: 'Se sim, um paciente de retorno deve voltar para o MESMO dentista — a IA não deve agendá-lo no clínico geral.',
|
||||
},
|
||||
];
|
||||
|
||||
const sim = (r, k) => r[k] === 1;
|
||||
const nao = (r, k) => r[k] === 0;
|
||||
|
||||
// Pendências de CONFIG do dono: a declaração não bate com o banco.
|
||||
// counts = { dentistas, pacientes }.
|
||||
export function pendenciasDeConfig(respostas, counts) {
|
||||
const r = respostas || {};
|
||||
const dentistas = Number(counts?.dentistas || 0);
|
||||
const pacientes = Number(counts?.pacientes || 0);
|
||||
const P = [];
|
||||
|
||||
if (sim(r, 'multi_dentista') && dentistas <= 1)
|
||||
P.push({ chave: 'multi_dentista', severidade: 'alta', titulo: 'Cadastre os demais dentistas',
|
||||
detalhe: `Você indicou que a clínica tem mais de um dentista, mas há apenas ${dentistas} cadastrado. A Secretária não consegue direcionar cada paciente ao dentista certo — cadastre os outros dentistas.` });
|
||||
if (nao(r, 'multi_dentista') && dentistas > 1)
|
||||
P.push({ chave: 'multi_dentista', severidade: 'media', titulo: 'Confirme quantos dentistas atendem',
|
||||
detalhe: `Você indicou um único dentista, mas há ${dentistas} cadastrados no sistema. Ajuste a resposta ou os cadastros.` });
|
||||
|
||||
if (sim(r, 'pacientes_cadastrados') && pacientes === 0)
|
||||
P.push({ chave: 'pacientes_cadastrados', severidade: 'alta', titulo: 'Cadastre os pacientes',
|
||||
detalhe: 'Você indicou que os pacientes estão cadastrados, mas não há nenhum paciente no sistema. A Secretária não reconhece quem já é paciente.' });
|
||||
if (r['pacientes_cadastrados'] == null || nao(r, 'pacientes_cadastrados'))
|
||||
P.push({ chave: 'pacientes_cadastrados', severidade: 'media', titulo: 'Complete o cadastro de pacientes',
|
||||
detalhe: 'Enquanto os pacientes não estiverem cadastrados, a Secretária vai encaminhar todo retorno para a secretária humana confirmar (não agenda sozinha).' });
|
||||
|
||||
if (sim(r, 'dentista_fixo') && dentistas <= 1 && sim(r, 'multi_dentista'))
|
||||
P.push({ chave: 'dentista_fixo', severidade: 'media', titulo: 'Cadastre os dentistas de referência',
|
||||
detalhe: 'Você indicou que cada paciente tem um dentista fixo, mas o sistema ainda não tem os dentistas todos cadastrados para fazer esse vínculo.' });
|
||||
|
||||
if (sim(r, 'telefone_compartilhado'))
|
||||
P.push({ chave: 'telefone_compartilhado', severidade: 'media', titulo: 'Complete o cadastro de cada familiar',
|
||||
detalhe: 'Como um mesmo telefone pode ser de vários pacientes (ex.: cadastrado só no filho, mas quem fala é a mãe), a Secretária vai SEMPRE confirmar quem está falando e não assume pelo número. Mantenha cada familiar cadastrado e vinculado ao número para ela acertar mais e encaminhar menos ao humano.' });
|
||||
|
||||
return P;
|
||||
}
|
||||
|
||||
// Cautelas para injetar no cérebro da IA (texto direto no system prompt).
|
||||
export function cautelasParaIA(respostas, counts) {
|
||||
const r = respostas || {};
|
||||
const dentistas = Number(counts?.dentistas || 0);
|
||||
const pacientes = Number(counts?.pacientes || 0);
|
||||
const C = [];
|
||||
|
||||
const multi = sim(r, 'multi_dentista') || dentistas > 1;
|
||||
const temPacientes = pacientes > 0 || sim(r, 'pacientes_cadastrados');
|
||||
const telShared = sim(r, 'telefone_compartilhado') || (temPacientes && r['telefone_compartilhado'] == null);
|
||||
|
||||
if (multi)
|
||||
C.push('A clínica tem MAIS DE UM dentista: NUNCA assuma um dentista nem "clínico geral" por padrão para quem já é paciente. Descubra/pergunte qual dentista atende. Sem certeza → encaminhe ao humano.');
|
||||
if (sim(r, 'multi_dentista') && dentistas <= 1)
|
||||
C.push('ATENÇÃO: o sistema ainda não tem todos os dentistas cadastrados. Para pacientes de retorno ou que pedem um dentista específico, encaminhe ao humano — NÃO agende no clínico geral.');
|
||||
if (sim(r, 'dentista_fixo'))
|
||||
C.push('Cada paciente tem um dentista fixo: um retorno deve voltar ao MESMO dentista. Se você não sabe qual é (falta cadastro), encaminhe ao humano.');
|
||||
|
||||
// Identidade de quem fala — o cadastro é incompleto e o telefone é ambíguo.
|
||||
if (temPacientes)
|
||||
C.push('IDENTIDADE: o cadastro pode estar INCOMPLETO. Ao achar um paciente pelo telefone, NÃO assuma que é quem está falando — confirme o NOME de quem fala e PARA QUEM é o atendimento antes de qualquer coisa.');
|
||||
if (telShared)
|
||||
C.push('TELEFONE COMPARTILHADO: um mesmo número pode ser de vários da família (ex.: está no cadastro do filho, mas quem fala é a mãe; ou perguntam pelo tratamento do marido). SEMPRE pergunte com quem você fala e de quem é o atendimento; se a pessoa/atendimento não bater com o cadastro do número, encaminhe ao humano — não agende no paciente errado.');
|
||||
if (pacientes === 0 || nao(r, 'pacientes_cadastrados'))
|
||||
C.push('O cadastro de pacientes está incompleto: você NÃO consegue confirmar histórico nem o dentista de quem diz já ser paciente. Nesses casos, encaminhe ao humano em vez de agendar.');
|
||||
|
||||
// Profissional citado que não existe na lista de dentistas (sempre possível).
|
||||
C.push('PROFISSIONAL NÃO CADASTRADO: se o cliente citar um dentista/profissional que NÃO está na lista de dentistas acima (ex.: prótese com um Dr. de fora), NÃO invente nem agende — encaminhe ao humano com encaminhar_humano e diga NO RESUMO que o profissional citado não está cadastrado, para o dono cadastrá-lo.');
|
||||
|
||||
return C;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Config da integração com o motor NewWhats.
|
||||
// Fonte: tabela `settings` (id='newwhats_config') — editável pelo super-admin na UI.
|
||||
// Fallback: variáveis de ambiente (NEWWHATS_*), úteis para bootstrap/deploy.
|
||||
//
|
||||
// NEWWHATS_URL / motorUrl URL do motor
|
||||
// NEWWHATS_INTEGRATION_KEY / integrationKey x-nw-key
|
||||
// NEWWHATS_WEBHOOK_SECRET / webhookSecret HMAC do webhook
|
||||
// NEWWHATS_CLINICA_ID / clinicaId clínica que este satélite atende
|
||||
|
||||
let cache = null; // { motorUrl, integrationKey, webhookSecret, clinicaId } | null
|
||||
|
||||
function fromEnv() {
|
||||
return {
|
||||
motorUrl: (process.env.NEWWHATS_URL || '').replace(/\/$/, ''),
|
||||
integrationKey: process.env.NEWWHATS_INTEGRATION_KEY || '',
|
||||
webhookSecret: process.env.NEWWHATS_WEBHOOK_SECRET || '',
|
||||
clinicaId: process.env.NEWWHATS_CLINICA_ID || '',
|
||||
};
|
||||
}
|
||||
|
||||
// DB (se houver valor) tem prioridade; ENV preenche o que faltar.
|
||||
export function getConfig() {
|
||||
const env = fromEnv();
|
||||
if (!cache) return env;
|
||||
return {
|
||||
motorUrl: (cache.motorUrl || env.motorUrl || '').replace(/\/$/, ''),
|
||||
integrationKey: cache.integrationKey || env.integrationKey || '',
|
||||
webhookSecret: cache.webhookSecret || env.webhookSecret || '',
|
||||
clinicaId: cache.clinicaId || env.clinicaId || '',
|
||||
};
|
||||
}
|
||||
|
||||
export function isConfigured() {
|
||||
const c = getConfig();
|
||||
return Boolean(c.motorUrl && c.integrationKey);
|
||||
}
|
||||
|
||||
// Carrega a config do banco para o cache (chamado no boot e após salvar).
|
||||
export async function loadConfigFromDb(pool) {
|
||||
try {
|
||||
const { rows } = await pool.query("SELECT data FROM settings WHERE id = 'newwhats_config'");
|
||||
cache = rows[0]?.data || {};
|
||||
} catch {
|
||||
if (!cache) cache = {};
|
||||
}
|
||||
return getConfig();
|
||||
}
|
||||
|
||||
// Persiste no banco e atualiza o cache.
|
||||
export async function saveConfigToDb(pool, cfg) {
|
||||
const data = {
|
||||
motorUrl: (cfg.motorUrl || '').replace(/\/$/, ''),
|
||||
integrationKey: cfg.integrationKey || '',
|
||||
webhookSecret: cfg.webhookSecret || '',
|
||||
clinicaId: cfg.clinicaId || '',
|
||||
};
|
||||
await pool.query(
|
||||
`INSERT INTO settings (id, category, data) VALUES ('newwhats_config', 'integration', $1)
|
||||
ON CONFLICT (id) DO UPDATE SET data = EXCLUDED.data`,
|
||||
[JSON.stringify(data)]
|
||||
);
|
||||
cache = data;
|
||||
return getConfig();
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// Feriados nacionais brasileiros (fixos + móveis) + resolução com exceções da
|
||||
// clínica (tabela `feriados`). Usado pela agenda-bridge para a Secretária saber
|
||||
// quando é feriado e o /slots pular esses dias.
|
||||
//
|
||||
// Fixos: datas nacionais. Móveis: derivados da Páscoa (Computus de Meeus).
|
||||
// Consciência Negra (20/11) é nacional desde 2024 (Lei 14.759/2023).
|
||||
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
const ymd = (y, m, d) => `${y}-${pad(m)}-${pad(d)}`;
|
||||
|
||||
// Domingo de Páscoa (algoritmo de Meeus/Jones/Butcher) — retorna {mes, dia}.
|
||||
function pascoa(ano) {
|
||||
const a = ano % 19;
|
||||
const b = Math.floor(ano / 100);
|
||||
const c = ano % 100;
|
||||
const d = Math.floor(b / 4);
|
||||
const e = b % 4;
|
||||
const f = Math.floor((b + 8) / 25);
|
||||
const g = Math.floor((b - f + 1) / 3);
|
||||
const h = (19 * a + b - d - g + 15) % 30;
|
||||
const i = Math.floor(c / 4);
|
||||
const k = c % 4;
|
||||
const l = (32 + 2 * e + 2 * i - h - k) % 7;
|
||||
const m = Math.floor((a + 11 * h + 22 * l) / 451);
|
||||
const mes = Math.floor((h + l - 7 * m + 114) / 31);
|
||||
const dia = ((h + l - 7 * m + 114) % 31) + 1;
|
||||
return { mes, dia };
|
||||
}
|
||||
|
||||
// Soma dias a uma data (UTC, evita fuso) → 'YYYY-MM-DD'.
|
||||
function addDias(y, m, d, delta) {
|
||||
const base = new Date(Date.UTC(y, m - 1, d));
|
||||
base.setUTCDate(base.getUTCDate() + delta);
|
||||
return ymd(base.getUTCFullYear(), base.getUTCMonth() + 1, base.getUTCDate());
|
||||
}
|
||||
|
||||
// Mapa { 'YYYY-MM-DD': 'Nome do feriado' } dos feriados NACIONAIS do ano.
|
||||
export function feriadosNacionais(ano) {
|
||||
const p = pascoa(ano);
|
||||
const py = ano, pm = p.mes, pd = p.dia;
|
||||
const map = {
|
||||
[ymd(ano, 1, 1)]: 'Confraternização Universal',
|
||||
[ymd(ano, 4, 21)]: 'Tiradentes',
|
||||
[ymd(ano, 5, 1)]: 'Dia do Trabalho',
|
||||
[ymd(ano, 9, 7)]: 'Independência do Brasil',
|
||||
[ymd(ano, 10, 12)]: 'Nossa Senhora Aparecida',
|
||||
[ymd(ano, 11, 2)]: 'Finados',
|
||||
[ymd(ano, 11, 15)]: 'Proclamação da República',
|
||||
[ymd(ano, 11, 20)]: 'Consciência Negra',
|
||||
[ymd(ano, 12, 25)]: 'Natal',
|
||||
// Móveis (relativos ao Domingo de Páscoa)
|
||||
[addDias(py, pm, pd, -48)]: 'Carnaval (segunda-feira)',
|
||||
[addDias(py, pm, pd, -47)]: 'Carnaval (terça-feira)',
|
||||
[addDias(py, pm, pd, -2)]: 'Sexta-feira Santa',
|
||||
[addDias(py, pm, pd, 60)]: 'Corpus Christi',
|
||||
};
|
||||
return map;
|
||||
}
|
||||
|
||||
// Nome do feriado nacional numa data 'YYYY-MM-DD', ou null.
|
||||
export function feriadoNacionalNome(dateStr) {
|
||||
const ano = Number(String(dateStr).slice(0, 4));
|
||||
if (!ano) return null;
|
||||
return feriadosNacionais(ano)[dateStr] ?? null;
|
||||
}
|
||||
|
||||
// Resolve o feriado de uma data para uma clínica: nacional OU exceção da clínica
|
||||
// (tabela `feriados`, que também pode ANULAR um dia — ver `fecha`). Retorna
|
||||
// { fecha: bool, nome, hora_inicio?, hora_fim? } ou null (dia normal).
|
||||
//
|
||||
// Regras:
|
||||
// - Exceção da clínica tem prioridade (permite abrir num feriado nacional, ou
|
||||
// fechar num dia comum, ou definir horário especial).
|
||||
// - Sem exceção: cai no feriado nacional (fecha o dia inteiro).
|
||||
export async function resolverFeriado(pool, clinicaId, dateStr) {
|
||||
try {
|
||||
const mmdd = String(dateStr).slice(5); // 'MM-DD'
|
||||
const { rows } = await pool.query(
|
||||
`SELECT nome, fecha, hora_inicio, hora_fim FROM feriados
|
||||
WHERE clinica_id = $1
|
||||
AND (data = $2 OR (recorrente = 1 AND to_char(data,'MM-DD') = $3))
|
||||
ORDER BY (data = $2) DESC LIMIT 1`,
|
||||
[clinicaId, dateStr, mmdd]);
|
||||
if (rows.length) {
|
||||
const r = rows[0];
|
||||
return { fecha: r.fecha !== false, nome: r.nome || 'Fechamento', hora_inicio: r.hora_inicio || null, hora_fim: r.hora_fim || null, origem: 'clinica' };
|
||||
}
|
||||
} catch { /* tabela pode não existir ainda */ }
|
||||
const nac = feriadoNacionalNome(dateStr);
|
||||
if (nac) return { fecha: true, nome: nac, hora_inicio: null, hora_fim: null, origem: 'nacional' };
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
// Integração NewWhats (satélite) para o scoreodonto — ESM, adaptada do mercado.
|
||||
// Registra rotas no app Express existente.
|
||||
//
|
||||
// Uso no server.js:
|
||||
// import { registerNewwhats } from './newwhats/index.js'
|
||||
// registerNewwhats(app, pool, { superadminGuard })
|
||||
import { createRoutes } from './routes.js'
|
||||
import { createWebhookReceiver } from './webhook-receiver.js'
|
||||
import { createRestProxy, createAccountsRoute, provisionNewwhatsAccount } from './proxy.js'
|
||||
import { getConfig, isConfigured, loadConfigFromDb, saveConfigToDb } from './config.js'
|
||||
import { Readable } from 'node:stream'
|
||||
|
||||
export { syncKnowledge, buildKnowledgeText } from './sync-knowledge.js'
|
||||
export { createWsTunnel } from './proxy.js'
|
||||
|
||||
// Provisiona (eager) a conta do email no motor NewWhats, best-effort e idempotente.
|
||||
// Chamado no cadastro de usuário/clínica do scoreodonto: "assim que a conta nasce
|
||||
// aqui, já nasce lá — pronta esperando quando a pessoa for mexer com WhatsApp".
|
||||
export async function provisionNewwhatsAccountEager(pool, email) {
|
||||
try {
|
||||
const cfg = getConfig()
|
||||
if (!cfg.motorUrl || !cfg.integrationKey || !email) return false
|
||||
return await provisionNewwhatsAccount(pool, cfg, email)
|
||||
} catch { return false }
|
||||
}
|
||||
import { createAgendaBridge } from './agenda-bridge.js'
|
||||
import { createAgendaConfig } from './agenda-config.js'
|
||||
import { ensureAgendaSchema } from './agenda-schema.js'
|
||||
|
||||
// Valida uma chave contra o motor. Envia um email-sonda (a chave mestra exige
|
||||
// x-nw-email). 200 = ok com dados; 404 = chave válida mas email-sonda inexistente
|
||||
// (= chave OK); 401 = chave inválida.
|
||||
async function testMotorKey(motorUrl, key) {
|
||||
try {
|
||||
const r = await fetch(`${motorUrl}/api/ext/v1/sessions`, {
|
||||
headers: { 'x-nw-key': key, 'x-nw-email': 'probe@nw.local' },
|
||||
});
|
||||
if (r.ok || r.status === 404) return { ok: true, detail: 'Conexão OK' };
|
||||
return { ok: false, detail: `Motor respondeu ${r.status}` };
|
||||
} catch (e) {
|
||||
return { ok: false, detail: `Motor inacessível: ${e.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
export function registerNewwhats(app, pool, opts = {}) {
|
||||
// passthrough middleware caso o guard não seja fornecido (dev)
|
||||
const superadminGuard = opts.superadminGuard || ((_req, _res, next) => next());
|
||||
|
||||
// ── Ponte de agenda (motor → agenda real do scoreodonto) ───────────────────
|
||||
// Auth própria por segredo compartilhado (x-nw-agenda-secret). Server-to-server.
|
||||
app.use('/api/nw/agenda', createAgendaBridge(pool));
|
||||
|
||||
// ── Schema de horários/feriados (idempotente) + config (dono/dentista) ─────
|
||||
// clinicas_horarios + feriados; usados pela agenda-bridge (Secretária) e pelas
|
||||
// telas de configuração do scoreodonto.
|
||||
ensureAgendaSchema(pool).catch((e) => console.error('[newwhats] schema agenda:', e?.message));
|
||||
app.use('/api/nw/agenda-config', createAgendaConfig(pool));
|
||||
|
||||
// ── Proxy de MÍDIA: /api/nw/media/* → motor ────────────────────────────────
|
||||
// A mídia/avatar vivem no motor (Wasabi ou local). O Wasabi é servido por
|
||||
// /api/storage/view/* (plugin UnifiedStorageProvider, que cobre Wasabi + fallback
|
||||
// local); a mídia local ANTIGA fica no /media estático. Tentamos o storage do
|
||||
// plugin primeiro e caímos no /media se der 404. Same-origin no <img>/<video>.
|
||||
app.use('/api/nw/media', async (req, res) => {
|
||||
if (req.method !== 'GET') { res.status(405).end(); return; }
|
||||
const cfg = getConfig();
|
||||
if (!cfg.motorUrl) { res.status(503).end(); return; }
|
||||
const rest = req.path.replace(/^\//, '');
|
||||
const base = cfg.motorUrl.replace(/\/$/, '');
|
||||
const headers = {};
|
||||
if (req.headers.range) headers.range = req.headers.range;
|
||||
const candidates = [`${base}/api/storage/view/${rest}`, `${base}/media/${rest}`];
|
||||
try {
|
||||
let r = null;
|
||||
for (const url of candidates) {
|
||||
r = await fetch(url, { headers });
|
||||
if (r.status !== 404) break; // achou (ou erro que não é 404) → usa esse
|
||||
}
|
||||
res.status(r.status);
|
||||
for (const h of ['content-type', 'content-length', 'content-range', 'accept-ranges', 'cache-control', 'content-disposition']) {
|
||||
const v = r.headers.get(h);
|
||||
if (v) res.setHeader(h, v);
|
||||
}
|
||||
// Mídia é imutável (path único por arquivo) → cache longo no browser.
|
||||
if (!res.getHeader('cache-control')) res.setHeader('cache-control', 'public, max-age=604800, immutable');
|
||||
if (r.body) Readable.fromWeb(r.body).pipe(res);
|
||||
else res.end();
|
||||
} catch (e) {
|
||||
res.status(502).json({ error: `Mídia indisponível: ${e.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
// Carrega a config do banco para o cache (não bloqueia o boot).
|
||||
loadConfigFromDb(pool).then((c) =>
|
||||
console.log(`[newwhats] config carregada — ${isConfigured() ? 'configurada' : 'AGUARDANDO config'} (clinica=${c.clinicaId || '—'})`)
|
||||
).catch(() => {});
|
||||
|
||||
// ── Config (super-admin) ───────────────────────────────────────────────────
|
||||
// GET: nunca devolve os segredos em claro — só se estão definidos.
|
||||
app.get('/api/nw/config', superadminGuard, (_req, res) => {
|
||||
const c = getConfig();
|
||||
res.json({
|
||||
motorUrl: c.motorUrl,
|
||||
clinicaId: c.clinicaId,
|
||||
hasIntegrationKey: Boolean(c.integrationKey),
|
||||
hasWebhookSecret: Boolean(c.webhookSecret),
|
||||
configured: isConfigured(),
|
||||
});
|
||||
});
|
||||
|
||||
// PUT: secrets em branco MANTÊM o valor atual (não precisa redigitar).
|
||||
app.put('/api/nw/config', superadminGuard, async (req, res) => {
|
||||
try {
|
||||
const cur = getConfig();
|
||||
const b = req.body || {};
|
||||
const next = {
|
||||
motorUrl: (b.motorUrl ?? cur.motorUrl) || '',
|
||||
clinicaId: (b.clinicaId ?? cur.clinicaId) || '',
|
||||
integrationKey: b.integrationKey ? String(b.integrationKey) : cur.integrationKey,
|
||||
webhookSecret: b.webhookSecret ? String(b.webhookSecret) : cur.webhookSecret,
|
||||
};
|
||||
await saveConfigToDb(pool, next);
|
||||
res.json({ ok: true, configured: isConfigured() });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Aplica um TOKEN de pareamento — auto-configura URL + chave + segredo do webhook.
|
||||
// Token = "nwpair_" + base64url(JSON({ u: motorUrl, k: integrationKey, s: webhookSecret })).
|
||||
// A clínica continua sendo escolhida localmente (o motor não conhece os IDs daqui).
|
||||
app.post('/api/nw/config/token', superadminGuard, async (req, res) => {
|
||||
try {
|
||||
let raw = String((req.body || {}).token || '').trim();
|
||||
raw = raw.replace(/^nwpair[_:]\s*/i, '');
|
||||
let p;
|
||||
try { p = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8')); }
|
||||
catch { return res.status(400).json({ error: 'Token inválido (não foi possível decodificar).' }); }
|
||||
|
||||
const motorUrl = (p.u || p.url || '').replace(/\/$/, '');
|
||||
const integrationKey = p.k || p.key || p.integrationKey || '';
|
||||
const webhookSecret = p.s || p.secret || p.webhookSecret || '';
|
||||
if (!motorUrl || !integrationKey) {
|
||||
return res.status(400).json({ error: 'Token incompleto (faltam URL ou chave).' });
|
||||
}
|
||||
|
||||
// Valida a chave contra o motor ANTES de salvar.
|
||||
const { ok: motorOk, detail } = await testMotorKey(motorUrl, integrationKey);
|
||||
if (!motorOk) return res.status(400).json({ error: `Token não validou no motor: ${detail}` });
|
||||
|
||||
const cur = getConfig();
|
||||
await saveConfigToDb(pool, { motorUrl, integrationKey, webhookSecret: webhookSecret || cur.webhookSecret, clinicaId: cur.clinicaId });
|
||||
res.json({ ok: true, configured: isConfigured(), motorUrl, hasWebhookSecret: Boolean(webhookSecret || cur.webhookSecret), detail });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Lista de clínicas para o dropdown.
|
||||
app.get('/api/nw/clinicas', superadminGuard, async (_req, res) => {
|
||||
try {
|
||||
const { rows } = await pool.query('SELECT id, nome_fantasia FROM clinicas ORDER BY nome_fantasia');
|
||||
res.json(rows);
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Testa a conexão com o motor (usa a config atual).
|
||||
app.get('/api/nw/status', superadminGuard, async (_req, res) => {
|
||||
const c = getConfig();
|
||||
if (!c.motorUrl || !c.integrationKey) return res.json({ configured: false, motorOk: false, detail: 'URL ou chave ausente' });
|
||||
const { ok, detail } = await testMotorKey(c.motorUrl, c.integrationKey);
|
||||
res.json({ configured: true, motorOk: ok, detail });
|
||||
});
|
||||
|
||||
// ── Operação ────────────────────────────────────────────────────────────────
|
||||
app.use('/nw', createRoutes(pool)); // Secretária do motor consulta a clínica
|
||||
app.use(createWebhookReceiver(pool)); // webhook motor → satélite
|
||||
app.get('/api/nw/accounts', createAccountsRoute(pool)); // seletor multi-conta da inbox (próprias + liberadas) — ANTES do catch-all
|
||||
app.use('/api/nw/v1', createRestProxy(pool)); // proxy do inbox → motor (express 5: use, não wildcard) + enforcement de ownership
|
||||
|
||||
console.log('[newwhats] integração registrada (/nw, /api/webhook/newwhats, /api/nw/v1/*, /api/nw/config)');
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"id": "newwhats",
|
||||
"name": "NewWhats — Integração WhatsApp (Odonto)",
|
||||
"version": "1.0.0",
|
||||
"project": "scoreodonto.com",
|
||||
"description": "Satélite do motor NewWhats adaptado ao domínio odontológico: expõe /nw/* (clínica, especialidades, dentistas, procedimentos, planos) para a Secretária IA, recebe webhooks do motor e faz proxy do inbox. Config por variáveis de ambiente (ESM, sem tabela plugin_configs).",
|
||||
"runtime": "esm-module",
|
||||
"entry": "index.js#registerNewwhats",
|
||||
"features": [
|
||||
"Rotas /nw/* (dados da clínica para a Secretária IA)",
|
||||
"sync-knowledge (base de conhecimento odonto → motor)",
|
||||
"Webhook receiver (message.new, session.status) com HMAC",
|
||||
"Proxy REST /api/nw/v1/* → motor /api/ext/v1/*"
|
||||
],
|
||||
"config_env": [
|
||||
{ "key": "NEWWHATS_URL", "required": true, "help": "URL do motor NewWhats." },
|
||||
{ "key": "NEWWHATS_INTEGRATION_KEY", "required": true, "help": "x-nw-key: valida /nw/* e é injetada no proxy. Igual à apiKey criada no motor." },
|
||||
{ "key": "NEWWHATS_WEBHOOK_SECRET", "required": false, "help": "Segredo HMAC para validar x-nw-signature nos webhooks. Recomendado." },
|
||||
{ "key": "NEWWHATS_CLINICA_ID", "required": true, "help": "Qual clínica este satélite atende (multi-tenant)." }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
// Proxy REST: /api/nw/v1/* (frontend) → motor /api/ext/v1/*.
|
||||
//
|
||||
// Segurança: valida o JWT do scoreodonto (só usuário logado acessa).
|
||||
// Resolução de conta: SEMPRE via x-nw-email (chave mestra resolve o tenant pelo
|
||||
// email). Todo usuário da clínica tem conta própria no motor — se o motor não
|
||||
// conhecer o email, é erro (404 "conta não encontrada"), não há fallback.
|
||||
// O email vem SEMPRE do token verificado — nunca do cliente.
|
||||
import net from 'node:net';
|
||||
import tls from 'node:tls';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { getConfig } from './config.js';
|
||||
|
||||
function authFromReq(req) {
|
||||
try {
|
||||
const tk = (req.headers['authorization'] || '').replace(/^Bearer\s+/i, '').trim();
|
||||
if (!tk) return null;
|
||||
const p = jwt.verify(tk, process.env.JWT_SECRET);
|
||||
const email = (p.email || '').toLowerCase() || null;
|
||||
if (!email) return null;
|
||||
return { email, userId: p.userId || null };
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// Resolve o dono do workspace (clínica OU sala). Retorna o userId dono ou null.
|
||||
async function resolveOwnerId(pool, clinicaId) {
|
||||
if (!pool || !clinicaId) return null;
|
||||
try {
|
||||
const { rows: cl } = await pool.query('SELECT owner_id FROM clinicas WHERE id = $1', [clinicaId]);
|
||||
if (cl.length) {
|
||||
if (cl[0].owner_id) return cl[0].owner_id;
|
||||
// Clínicas antigas sem owner_id: dono = vínculo donoclinica/donoconsultorio.
|
||||
const { rows: v } = await pool.query(
|
||||
"SELECT usuario_id FROM vinculos WHERE clinica_id = $1 AND role IN ('donoclinica','donoconsultorio') ORDER BY id LIMIT 1",
|
||||
[clinicaId]);
|
||||
return v[0]?.usuario_id || null;
|
||||
}
|
||||
const { rows: sl } = await pool.query('SELECT owner_usuario_id FROM salas WHERE id = $1', [clinicaId]);
|
||||
return sl[0]?.owner_usuario_id || null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// Resolve o dono do workspace COM e-mail. Retorna { ownerId, email } ou null.
|
||||
// Usado para a inbox compartilhada: a caixa da clínica vive na conta (tenant) do
|
||||
// dono no motor, então falamos com o motor usando o e-mail DELE.
|
||||
async function resolveOwner(pool, clinicaId) {
|
||||
const ownerId = await resolveOwnerId(pool, clinicaId);
|
||||
if (!ownerId) return null;
|
||||
try {
|
||||
const { rows } = await pool.query('SELECT lower(email) AS email FROM usuarios WHERE id = $1', [ownerId]);
|
||||
return { ownerId, email: rows[0]?.email || null };
|
||||
} catch { return { ownerId, email: null }; }
|
||||
}
|
||||
|
||||
// Autoriza (ou nega 403) uma ação sensível de sessão de WhatsApp.
|
||||
// Mapeamento das ações do motor:
|
||||
// POST /sessions → scan INICIAL (criar) → só o dono
|
||||
// GET /sessions/:id/qr → re-scan (parear QR) → dono OU can_rescan
|
||||
// POST /sessions/:id/connect → re-scan (reconectar) → dono OU can_rescan
|
||||
// DELETE /sessions/:id → excluir → dono OU can_delete
|
||||
// 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)?)?/);
|
||||
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
|
||||
|
||||
if (!isCreate && !isRescan && !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.' };
|
||||
|
||||
const ownerId = await resolveOwnerId(pool, clinicaId);
|
||||
if (ownerId && ownerId === userId) return { ok: true }; // dono: poder total
|
||||
|
||||
// Scan inicial é exclusivo do dono — não delegável.
|
||||
if (isCreate) return { ok: false, status: 403, error: 'Apenas o dono pode conectar um novo WhatsApp.' };
|
||||
|
||||
// Re-scan / exclusão: exigem delegação por sessão.
|
||||
if (!instanceId) return { ok: false, status: 403, error: 'Sessão não identificada.' };
|
||||
let authz = null;
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
'SELECT can_rescan, can_delete FROM wa_session_authz WHERE clinica_id = $1 AND instance_id = $2 AND usuario_id = $3',
|
||||
[clinicaId, instanceId, userId]);
|
||||
authz = rows[0] || null;
|
||||
} catch { /* nega por padrão */ }
|
||||
|
||||
if (isRescan && 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.',
|
||||
};
|
||||
}
|
||||
|
||||
// Auto-provisiona (idempotente) a conta do email no motor, via chave mestra.
|
||||
// Usado como self-heal quando o motor responde "Conta não encontrada".
|
||||
export async function provisionNewwhatsAccount(pool, cfg, email) {
|
||||
if (!cfg.motorUrl || !cfg.integrationKey || !email) return false;
|
||||
let name = email;
|
||||
try {
|
||||
if (pool) {
|
||||
const { rows } = await pool.query('SELECT nome FROM usuarios WHERE lower(email) = $1', [email.toLowerCase()]);
|
||||
if (rows[0]?.nome) name = rows[0].nome;
|
||||
}
|
||||
} catch { /* usa email como nome */ }
|
||||
try {
|
||||
const r = await fetch(`${cfg.motorUrl}/api/ext/v1/satellites/accounts`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'x-nw-key': cfg.integrationKey },
|
||||
body: JSON.stringify({ email, name }),
|
||||
});
|
||||
return r.ok;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
// 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.
|
||||
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]);
|
||||
const full = String(rows[0]?.nome || '').trim();
|
||||
if (!full) return null;
|
||||
const parts = full.split(/\s+/);
|
||||
const first = parts[0];
|
||||
let ambiguous = false;
|
||||
if (clinicaId) {
|
||||
const { rows: m } = await pool.query(
|
||||
`SELECT u.nome FROM vinculos v JOIN usuarios u ON u.id = v.usuario_id
|
||||
WHERE v.clinica_id = $1 AND u.id <> $2`, [clinicaId, userId]);
|
||||
ambiguous = m.some(x => String(x.nome || '').trim().split(/\s+/)[0].toLowerCase() === first.toLowerCase());
|
||||
}
|
||||
if (ambiguous && parts.length > 1 && parts[1][0]) {
|
||||
return `${first} ${parts[1][0].toUpperCase()}.`;
|
||||
}
|
||||
return first;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// 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);
|
||||
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);
|
||||
if (ownerId && ownerId === userId) return { ok: true };
|
||||
const col = isSecretaria ? 'can_secretaria' : 'can_inbox';
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT 1 FROM wa_session_authz WHERE clinica_id = $1 AND usuario_id = $2 AND ${col} = true LIMIT 1`,
|
||||
[clinicaId, userId]);
|
||||
if (rows.length) return { ok: true };
|
||||
} catch { /* nega por padrão */ }
|
||||
return { ok: false, status: 403, error: isSecretaria ? 'Sem acesso à Secretária. Peça autorização ao dono.' : 'Sem acesso ao Inbox. Peça autorização ao dono.' };
|
||||
}
|
||||
|
||||
// Gate de ações DESTRUTIVAS no inbox (apagar conversa / apagar mensagem).
|
||||
// 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 && !isClearMsgs && !isDeleteMsg) return { ok: true };
|
||||
if (!pool || !clinicaId) return { ok: true };
|
||||
const ownerId = await resolveOwnerId(pool, clinicaId);
|
||||
if (ownerId && ownerId === userId) return { ok: true };
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
'SELECT 1 FROM wa_session_authz WHERE clinica_id = $1 AND usuario_id = $2 AND can_delete_msg = true LIMIT 1',
|
||||
[clinicaId, userId]);
|
||||
if (rows.length) return { ok: true };
|
||||
} catch { /* nega por padrão */ }
|
||||
return { ok: false, status: 403, error: 'Sem permissão para apagar conversas/mensagens. Peça autorização ao dono.' };
|
||||
}
|
||||
|
||||
export function createRestProxy(pool) {
|
||||
return async function restProxy(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 tail = req.originalUrl.replace(/^\/api\/nw\/v1/, '');
|
||||
const clinicaId = req.headers['x-nw-clinica'] ? String(req.headers['x-nw-clinica']) : null;
|
||||
|
||||
// Enforcement de ownership/permissões antes de repassar ao motor.
|
||||
const guard = await guardSessionAction(pool, req, tail, userId);
|
||||
if (!guard.ok) return res.status(guard.status).json({ error: guard.error });
|
||||
|
||||
// Gate de acesso às áreas Inbox / Secretária.
|
||||
const area = await guardAreaAccess(pool, tail, userId, clinicaId);
|
||||
if (!area.ok) return res.status(area.status).json({ error: area.error });
|
||||
|
||||
// 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). 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}`;
|
||||
}
|
||||
|
||||
// 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: {
|
||||
'x-nw-key': cfg.integrationKey,
|
||||
'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 (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);
|
||||
}
|
||||
|
||||
try {
|
||||
let r = await fetch(url, opts);
|
||||
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) && targetEmail) {
|
||||
const ok = await provisionNewwhatsAccount(pool, cfg, targetEmail);
|
||||
if (ok) { r = await fetch(url, opts); text = await r.text(); }
|
||||
}
|
||||
res.status(r.status);
|
||||
try { res.json(JSON.parse(text)); } catch { res.send(text); }
|
||||
} catch (e) {
|
||||
res.status(502).json({ error: `Motor indisponível: ${e.message}` });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ── 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
|
||||
// sobre WebSocket. Aqui abrimos um túnel TCP bruto socket↔socket e injetamos a
|
||||
// x-nw-key no handshake com o motor — a chave nunca chega ao browser.
|
||||
//
|
||||
// Auth do usuário: o browser NÃO envia header Authorization no upgrade WS, então
|
||||
// o JWT do scoreodonto vem por ?token= (mesmo esquema do /api/ws do app). Só
|
||||
// usuário logado abre o túnel; o token fica local — não é repassado ao motor.
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// 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=); 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');
|
||||
|
||||
let target;
|
||||
try { target = new URL(cfg.motorUrl); }
|
||||
catch { return fail('502 Bad Gateway'); }
|
||||
const isHttps = target.protocol === 'https:';
|
||||
const host = target.hostname;
|
||||
const port = Number(target.port) || (isHttps ? 443 : 80);
|
||||
|
||||
const onConnect = () => {
|
||||
// Reescreve o handshake: path EXATO + Host do motor + x-nw-key.
|
||||
// Preserva os headers do WS (sec-websocket-*, upgrade, connection).
|
||||
const headers = Object.entries(req.headers)
|
||||
.filter(([k]) => !['host', 'x-nw-key', 'x-nw-email'].includes(k.toLowerCase()))
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
.join('\r\n');
|
||||
|
||||
proxySocket.write(
|
||||
`${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: ${targetEmail}\r\n` +
|
||||
`${headers}\r\n\r\n`
|
||||
);
|
||||
if (head && head.length) proxySocket.write(head);
|
||||
|
||||
proxySocket.pipe(clientSocket);
|
||||
clientSocket.pipe(proxySocket);
|
||||
};
|
||||
|
||||
const proxySocket = isHttps
|
||||
? tls.connect({ host, port, servername: host }, onConnect)
|
||||
: net.connect(port, host, onConnect);
|
||||
|
||||
proxySocket.on('error', (err) => {
|
||||
console.error('[newwhats] túnel WS erro:', err.message);
|
||||
clientSocket.destroy();
|
||||
});
|
||||
clientSocket.on('error', () => proxySocket.destroy());
|
||||
clientSocket.on('close', () => proxySocket.destroy());
|
||||
proxySocket.on('close', () => clientSocket.destroy());
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// Rotas /nw/* — a Secretária IA do motor consulta os dados desta clínica.
|
||||
// Adaptado do satélite mercado para o domínio ODONTO (scoreodonto).
|
||||
// Todas as rotas exigem a integration_key (nwAuth).
|
||||
import { Router } from 'express'
|
||||
import { nwAuth } from './auth.js'
|
||||
import { getConfig } from './config.js'
|
||||
|
||||
const money = (v) =>
|
||||
v == null ? null : `R$ ${Number(v).toFixed(2).replace('.', ',')}`
|
||||
|
||||
// Resolve a clínica-alvo: query ?clinica_id= tem prioridade; senão a do config.
|
||||
function resolveClinicaId(req) {
|
||||
return String(req.query.clinica_id || getConfig().clinicaId || '').trim()
|
||||
}
|
||||
|
||||
export function createRoutes(pool) {
|
||||
const router = Router()
|
||||
router.use(nwAuth)
|
||||
|
||||
const needClinica = (req, res) => {
|
||||
const id = resolveClinicaId(req)
|
||||
if (!id) res.status(400).json({ error: 'clinica_id ausente (configure NEWWHATS_CLINICA_ID ou passe ?clinica_id=).' })
|
||||
return id
|
||||
}
|
||||
|
||||
router.get('/ping', (_req, res) =>
|
||||
res.json({ ok: true, service: 'scoreodonto', ts: new Date().toISOString() }))
|
||||
|
||||
// Dados da clínica
|
||||
router.get('/clinica', async (req, res) => {
|
||||
const id = needClinica(req, res); if (!id) return
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT nome_fantasia, telefone, cidade, estado, logradouro, numero, bairro, cep
|
||||
FROM clinicas WHERE id = $1`, [id])
|
||||
res.json(rows[0] || null)
|
||||
} catch (e) { res.status(500).json({ error: e.message }) }
|
||||
})
|
||||
|
||||
// Especialidades atendidas
|
||||
router.get('/especialidades', async (req, res) => {
|
||||
const id = needClinica(req, res); if (!id) return
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT nome, descricao, area FROM especialidades
|
||||
WHERE clinica_id = $1 AND ativo::int = 1 ORDER BY ordem NULLS LAST, nome`, [id])
|
||||
res.json(rows)
|
||||
} catch (e) { res.status(500).json({ error: e.message }) }
|
||||
})
|
||||
|
||||
// Dentistas + horários de atendimento
|
||||
router.get('/dentistas', async (req, res) => {
|
||||
const id = needClinica(req, res); if (!id) return
|
||||
try {
|
||||
const { rows: dentistas } = await pool.query(
|
||||
`SELECT id, nome, especialidade FROM dentistas
|
||||
WHERE clinica_id = $1 AND ativo::int = 1 ORDER BY ordem NULLS LAST, nome`, [id])
|
||||
for (const d of dentistas) {
|
||||
const { rows: hr } = await pool.query(
|
||||
`SELECT dia_semana, hora_inicio, hora_fim FROM dentistas_horarios
|
||||
WHERE dentista_id = $1 AND ativo::int = 1 ORDER BY dia_semana, hora_inicio`, [d.id])
|
||||
d.horarios = hr
|
||||
delete d.id
|
||||
}
|
||||
res.json(dentistas)
|
||||
} catch (e) { res.status(500).json({ error: e.message }) }
|
||||
})
|
||||
|
||||
// Procedimentos + valor particular + valores por plano
|
||||
router.get('/procedimentos', async (req, res) => {
|
||||
const id = needClinica(req, res); if (!id) return
|
||||
try {
|
||||
const { rows: procs } = await pool.query(
|
||||
`SELECT id, nome, descricao, categoria, valorparticular FROM procedimentos
|
||||
WHERE clinica_id = $1 AND ativo::int = 1 ORDER BY ordem NULLS LAST, nome`, [id])
|
||||
for (const p of procs) {
|
||||
const { rows: vp } = await pool.query(
|
||||
`SELECT planonome, valor FROM procedimentos_valores_planos WHERE procedimentoid = $1`, [p.id])
|
||||
p.valor_particular = money(p.valorparticular)
|
||||
p.valores_planos = vp.map((x) => ({ plano: x.planonome, valor: money(x.valor) }))
|
||||
delete p.id; delete p.valorparticular
|
||||
}
|
||||
res.json(procs)
|
||||
} catch (e) { res.status(500).json({ error: e.message }) }
|
||||
})
|
||||
|
||||
// Planos / convênios aceitos
|
||||
router.get('/planos', async (req, res) => {
|
||||
const id = needClinica(req, res); if (!id) return
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT nome, tipo, descontopadrao FROM planos WHERE clinica_id = $1 ORDER BY nome`, [id])
|
||||
res.json(rows)
|
||||
} catch (e) { res.status(500).json({ error: e.message }) }
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
// sync-knowledge.js (ODONTO) — monta o nó de conhecimento da Secretária IA com
|
||||
// os dados reais da clínica e sincroniza com o motor NewWhats.
|
||||
// Adaptado do satélite mercado. Fluxo:
|
||||
// 1. Lê dados do banco (clínica, especialidades, dentistas, procedimentos, planos)
|
||||
// 2. Monta texto estruturado do knowledge node
|
||||
// 3. PUT/POST no motor (/api/secretaria/*) — rotas internas, sem auth de sessão
|
||||
import { getConfig } from './config.js'
|
||||
|
||||
const money = (v) => v == null ? null : `R$ ${Number(v).toFixed(2).replace('.', ',')}`
|
||||
const DIAS = ['Domingo', 'Segunda', 'Terça', 'Quarta', 'Quinta', 'Sexta', 'Sábado']
|
||||
|
||||
async function motorFetch(motorUrl, method, path, body) {
|
||||
const res = await fetch(`${motorUrl}/api/secretaria${path}`, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
if (!res.ok) throw new Error(`Motor ${method} /api/secretaria${path} → ${res.status}: ${(await res.text()).slice(0, 200)}`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function buildKnowledgeText(pool, clinicaId) {
|
||||
const lines = []
|
||||
const q = (sql, params) => pool.query(sql, params).then((r) => r.rows)
|
||||
|
||||
const [clinica] = await q(
|
||||
`SELECT nome_fantasia, telefone, cidade, estado, logradouro, numero, bairro
|
||||
FROM clinicas WHERE id = $1`, [clinicaId])
|
||||
const nome = clinica?.nome_fantasia || 'Nossa clínica odontológica'
|
||||
|
||||
lines.push(`=== ${nome.toUpperCase()} ===`)
|
||||
lines.push(`Clínica odontológica. Atendimento via WhatsApp.`)
|
||||
if (clinica?.telefone) lines.push(`Telefone: ${clinica.telefone}`)
|
||||
if (clinica?.logradouro) {
|
||||
const end = [clinica.logradouro, clinica.numero, clinica.bairro, clinica.cidade, clinica.estado].filter(Boolean).join(', ')
|
||||
lines.push(`Endereço: ${end}`)
|
||||
}
|
||||
lines.push('')
|
||||
|
||||
// Especialidades
|
||||
const esp = await q(
|
||||
`SELECT nome, descricao FROM especialidades WHERE clinica_id = $1 AND ativo::int = 1 ORDER BY ordem NULLS LAST, nome`, [clinicaId])
|
||||
if (esp.length) {
|
||||
lines.push(`=== ESPECIALIDADES ATENDIDAS ===`)
|
||||
for (const e of esp) lines.push(`• ${e.nome}${e.descricao ? ` — ${e.descricao}` : ''}`)
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
// Dentistas + horários
|
||||
const dentistas = await q(
|
||||
`SELECT id, nome, especialidade FROM dentistas WHERE clinica_id = $1 AND ativo::int = 1 ORDER BY ordem NULLS LAST, nome`, [clinicaId])
|
||||
if (dentistas.length) {
|
||||
lines.push(`=== DENTISTAS ===`)
|
||||
for (const d of dentistas) {
|
||||
lines.push(`• ${d.nome}${d.especialidade ? ` (${d.especialidade})` : ''}`)
|
||||
const hr = await q(
|
||||
`SELECT dia_semana, hora_inicio, hora_fim FROM dentistas_horarios
|
||||
WHERE dentista_id = $1 AND ativo::int = 1 ORDER BY dia_semana, hora_inicio`, [d.id])
|
||||
if (hr.length) {
|
||||
const fmt = hr.map((h) => `${DIAS[h.dia_semana] ?? h.dia_semana} ${String(h.hora_inicio).slice(0, 5)}-${String(h.hora_fim).slice(0, 5)}`)
|
||||
lines.push(` Atende: ${fmt.join(' | ')}`)
|
||||
}
|
||||
}
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
// Procedimentos + valores
|
||||
const procs = await q(
|
||||
`SELECT id, nome, categoria, valorparticular FROM procedimentos
|
||||
WHERE clinica_id = $1 AND ativo::int = 1 ORDER BY ordem NULLS LAST, nome`, [clinicaId])
|
||||
if (procs.length) {
|
||||
lines.push(`=== PROCEDIMENTOS E VALORES ===`)
|
||||
for (const p of procs) {
|
||||
const part = p.valorparticular != null ? ` — particular: ${money(p.valorparticular)}` : ''
|
||||
lines.push(`• ${p.nome}${p.categoria ? ` [${p.categoria}]` : ''}${part}`)
|
||||
const vp = await q(
|
||||
`SELECT planonome, valor FROM procedimentos_valores_planos WHERE procedimentoid = $1`, [p.id])
|
||||
const comValor = vp.filter((x) => x.valor != null)
|
||||
if (comValor.length) lines.push(` Por plano: ${comValor.map((x) => `${x.planonome} ${money(x.valor)}`).join(', ')}`)
|
||||
}
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
// Planos / convênios
|
||||
const planos = await q(
|
||||
`SELECT nome, tipo, descontopadrao FROM planos WHERE clinica_id = $1 ORDER BY nome`, [clinicaId])
|
||||
if (planos.length) {
|
||||
lines.push(`=== PLANOS / CONVÊNIOS ACEITOS ===`)
|
||||
for (const pl of planos) {
|
||||
const desc = pl.descontopadrao ? ` — desconto padrão ${pl.descontopadrao}%` : ''
|
||||
lines.push(`• ${pl.nome}${pl.tipo ? ` (${pl.tipo})` : ''}${desc}`)
|
||||
}
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
lines.push(`=== ATENDIMENTO ===`)
|
||||
lines.push(`Para agendar, remarcar ou tirar dúvidas, fale pelo WhatsApp.`)
|
||||
lines.push(`Dúvidas sobre valores, convênios ou disponibilidade: informe que vai encaminhar para a equipe.`)
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
export async function syncKnowledge(pool) {
|
||||
const cfg = getConfig()
|
||||
if (!cfg.motorUrl) throw new Error('NEWWHATS_URL não configurado')
|
||||
if (!cfg.clinicaId) throw new Error('NEWWHATS_CLINICA_ID não configurado')
|
||||
|
||||
const knowledgeText = await buildKnowledgeText(pool, cfg.clinicaId)
|
||||
|
||||
const agents = await motorFetch(cfg.motorUrl, 'GET', '/agents')
|
||||
if (!agents.length) throw new Error('Nenhum agente no motor. Crie um agente na Secretária IA.')
|
||||
const agent = agents.find((a) => a.active) || agents[0]
|
||||
|
||||
const nodes = await motorFetch(cfg.motorUrl, 'GET', `/agents/${agent.id}/nodes`)
|
||||
const kn = nodes.find((n) => n.type === 'knowledge')
|
||||
|
||||
let nodeId
|
||||
const title = 'Base de Conhecimento — Clínica Odontológica'
|
||||
if (kn) {
|
||||
await motorFetch(cfg.motorUrl, 'PUT', `/nodes/${kn.id}`, {
|
||||
title, content: knowledgeText, active: true, sort_order: kn.sort_order ?? 3,
|
||||
})
|
||||
nodeId = kn.id
|
||||
} else {
|
||||
const created = await motorFetch(cfg.motorUrl, 'POST', `/agents/${agent.id}/nodes`, {
|
||||
type: 'knowledge', title, content: knowledgeText, sort_order: 3,
|
||||
})
|
||||
nodeId = created.id
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true, agent_id: agent.id, agent: agent.name, node_id: nodeId,
|
||||
chars: knowledgeText.length, preview: knowledgeText, synced_at: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// Receptor de webhooks do motor NewWhats (motor → satélite).
|
||||
// POST /api/webhook/newwhats
|
||||
// Header x-nw-signature: sha256=HMAC-SHA256(webhook_secret, rawBody)
|
||||
// Eventos: message.new (nova mensagem WA), session.status (conexão da instância)
|
||||
//
|
||||
// Versão ENXUTA p/ scoreodonto: valida HMAC, registra o evento e deixa ganchos
|
||||
// (TODO) para captura de lead / auto-resposta. NÃO porta o auto-responder do
|
||||
// mercado (a Secretária do motor já responde via /secretaria/ask).
|
||||
import { Router } from 'express'
|
||||
import { createHmac, timingSafeEqual } from 'crypto'
|
||||
import { getConfig } from './config.js'
|
||||
|
||||
function verifySignature(secret, rawBody, sigHeader) {
|
||||
if (!sigHeader) return false
|
||||
const expected = Buffer.from('sha256=' + createHmac('sha256', secret).update(rawBody).digest('hex'))
|
||||
const received = Buffer.from(String(sigHeader))
|
||||
return expected.length === received.length && timingSafeEqual(expected, received)
|
||||
}
|
||||
|
||||
export function createWebhookReceiver(_pool) {
|
||||
const router = Router()
|
||||
|
||||
router.post('/api/webhook/newwhats', async (req, res) => {
|
||||
try {
|
||||
const secret = getConfig().webhookSecret
|
||||
if (secret) {
|
||||
const rawBody = req.rawBody ?? Buffer.from(JSON.stringify(req.body))
|
||||
if (!verifySignature(secret, rawBody, req.headers['x-nw-signature'])) {
|
||||
return res.status(401).json({ error: 'Assinatura inválida' })
|
||||
}
|
||||
} else {
|
||||
console.warn('[nw-webhook] NEWWHATS_WEBHOOK_SECRET ausente — assinatura ignorada')
|
||||
}
|
||||
|
||||
const { event, data } = req.body || {}
|
||||
if (event === 'message.new') {
|
||||
// TODO(odonto): registrar lead / acionar fluxo. Hoje só loga.
|
||||
console.log(`[nw-webhook] message.new de ${data?.author ?? data?.pushName ?? '?'}: ${String(data?.body ?? '').slice(0, 60)}`)
|
||||
} else if (event === 'session.status') {
|
||||
console.log(`[nw-webhook] session.status → ${data?.instanceId}: ${data?.status}`)
|
||||
} else {
|
||||
console.log(`[nw-webhook] evento não tratado: ${event}`)
|
||||
}
|
||||
res.json({ ok: true })
|
||||
} catch (e) {
|
||||
console.error('[nw-webhook] erro:', e.message)
|
||||
res.status(500).json({ error: e.message })
|
||||
}
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
||||
+468
-57
@@ -11,6 +11,8 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import crypto from 'crypto';
|
||||
import { registerNewwhats, createWsTunnel, provisionNewwhatsAccountEager } from './newwhats/index.js';
|
||||
import { operatorSignature } from './newwhats/proxy.js';
|
||||
|
||||
const { Pool } = pg;
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'scoreodonto_secret_key_2026';
|
||||
@@ -46,7 +48,7 @@ const pool = new Pool({
|
||||
max: 10,
|
||||
idleTimeoutMillis: 30000
|
||||
});
|
||||
pool.on('connect', () => console.log('[PG] Connected to PostgreSQL'));
|
||||
// pool.on('connect', () => console.log('[PG] Connected to PostgreSQL'));
|
||||
pool.on('error', (err) => console.error('[PG] Pool error:', err.message));
|
||||
|
||||
// Helper: transaction wrapper
|
||||
@@ -123,6 +125,8 @@ async function ensureUsuarioPorEmail(email, nome, role) {
|
||||
'INSERT INTO usuarios (id, nome, email, senha, role, must_change_password) VALUES ($1,$2,$3,$4,$5,true)',
|
||||
[id, (nome || e).toUpperCase(), e, await hashSenha(temp), role]
|
||||
);
|
||||
// Espelha a conta no motor NewWhats (eager, best-effort) — pronta p/ WhatsApp.
|
||||
provisionNewwhatsAccountEager(pool, e).catch(() => {});
|
||||
return { id, nome: (nome || e).toUpperCase(), created: true, tempPassword: temp };
|
||||
}
|
||||
|
||||
@@ -162,6 +166,9 @@ try {
|
||||
// EXPRESS
|
||||
// =============================================================================
|
||||
const app = express();
|
||||
// Envio de mídia pelo plugin WhatsApp vai em base64 (JSON) — precisa de mais folga
|
||||
// que o limite global. Este parser roda ANTES do global (2mb) só para /api/nw/v1.
|
||||
app.use('/api/nw/v1', express.json({ limit: '12mb' }));
|
||||
app.use(express.json({ limit: '2mb' }));
|
||||
app.use(cors({
|
||||
origin: process.env.CORS_ORIGIN || '*',
|
||||
@@ -653,6 +660,8 @@ app.post('/api/register', async (req, res) => {
|
||||
|
||||
const token = jwt.sign({ userId, email: email.toLowerCase().trim() }, JWT_SECRET, { expiresIn: '12h' });
|
||||
console.log(`[REGISTER] New user: ${email}, role: ${userRole}, workspaces: ${workspaces.length}`);
|
||||
// Espelha a conta no motor NewWhats (eager, best-effort) — pronta p/ WhatsApp.
|
||||
provisionNewwhatsAccountEager(pool, email.toLowerCase().trim()).catch(() => {});
|
||||
res.json({ success: true, token, user: { id: userId, nome: nomeUpper, email: email.toLowerCase().trim(), role: userRole }, workspaces, noWorkspace: workspaces.length === 0, userRole });
|
||||
} catch (err) {
|
||||
console.error('[REGISTER]', err.message);
|
||||
@@ -707,6 +716,153 @@ app.get('/api/clinicas/:clinicaId/membros', tenantGuard, requireCapability('gest
|
||||
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// NEWWHATS — OWNERSHIP & AUTORIZAÇÕES DE SESSÃO (WhatsApp)
|
||||
// =============================================================================
|
||||
// O "dono" de um workspace (clinicas.owner_id / salas.owner_usuario_id) é o único
|
||||
// com poder total sobre as sessões: scan inicial (criar), re-scan (QR) e exclusão.
|
||||
// Ele pode delegar re-scan/exclusão a outras contas, por sessão específica.
|
||||
// Estes endpoints são LOCAIS do scoreodonto (não vão ao motor). O enforcement em
|
||||
// runtime das ações do motor acontece no proxy /api/nw/v1/* (backend/newwhats).
|
||||
|
||||
// Resolve o dono do workspace (clínica OU sala). Retorna { ownerId, ownerNome, tipo }.
|
||||
async function resolveWorkspaceOwner(clinicaId) {
|
||||
if (!clinicaId) return null;
|
||||
let ownerId = null, tipo = 'clinica';
|
||||
const { rows: cl } = await pool.query('SELECT owner_id FROM clinicas WHERE id = $1', [clinicaId]);
|
||||
if (cl.length) {
|
||||
ownerId = cl[0].owner_id || null;
|
||||
// Clínicas antigas podem ter owner_id NULL: o dono é quem tem vínculo
|
||||
// donoclinica/donoconsultorio (mesma semântica de nivelNaClinica).
|
||||
if (!ownerId) {
|
||||
const { rows: v } = await pool.query(
|
||||
"SELECT usuario_id FROM vinculos WHERE clinica_id = $1 AND role IN ('donoclinica','donoconsultorio') ORDER BY id LIMIT 1",
|
||||
[clinicaId]);
|
||||
ownerId = v[0]?.usuario_id || null;
|
||||
}
|
||||
} else {
|
||||
const { rows: sl } = await pool.query('SELECT owner_usuario_id FROM salas WHERE id = $1', [clinicaId]);
|
||||
if (sl[0]?.owner_usuario_id) { ownerId = sl[0].owner_usuario_id; tipo = 'sala'; }
|
||||
}
|
||||
if (!ownerId) return null;
|
||||
const { rows: u } = await pool.query('SELECT nome FROM usuarios WHERE id = $1', [ownerId]);
|
||||
return { ownerId, ownerNome: u[0]?.nome || null, tipo };
|
||||
}
|
||||
|
||||
// GET /api/nw/authz?clinicaId=&instanceId=
|
||||
// Retorna as permissões da sessão. Dono vê a lista completa de membros + delegações;
|
||||
// não-dono vê apenas as próprias permissões (para a UI condicionar botões).
|
||||
app.get('/api/nw/authz', tenantGuard, async (req, res) => {
|
||||
try {
|
||||
const clinicaId = req.clinicaId;
|
||||
// instanceId é OPCIONAL: sem ele, respondemos apenas o ownership do workspace
|
||||
// (usado para liberar/ocultar "Nova Instância", o scan inicial).
|
||||
const instanceId = String(req.query.instanceId || '').trim();
|
||||
if (!clinicaId) return res.status(400).json({ error: 'clinicaId é obrigatório.' });
|
||||
|
||||
const owner = await resolveWorkspaceOwner(clinicaId);
|
||||
const meId = req.authUser.userId;
|
||||
const isOwner = !!owner && owner.ownerId === meId;
|
||||
|
||||
if (isOwner) {
|
||||
// Membros da clínica (exceto o dono) + salas: sócios/equipe elegíveis à delegação.
|
||||
const { rows: membros } = await pool.query(`
|
||||
SELECT u.id, u.nome, u.email, v.role
|
||||
FROM vinculos v JOIN usuarios u ON u.id = v.usuario_id
|
||||
WHERE v.clinica_id = $1 AND u.id <> $2 ORDER BY u.nome
|
||||
`, [clinicaId, owner.ownerId]);
|
||||
const { rows: authz } = instanceId ? await pool.query(
|
||||
'SELECT usuario_id, can_rescan, can_delete, can_inbox, can_secretaria, can_delete_msg FROM wa_session_authz WHERE clinica_id = $1 AND instance_id = $2',
|
||||
[clinicaId, instanceId]) : { rows: [] };
|
||||
const amap = new Map(authz.map(a => [a.usuario_id, a]));
|
||||
const members = membros.map(m => ({
|
||||
usuarioId: m.id, nome: m.nome, email: m.email, role: m.role,
|
||||
can_rescan: !!amap.get(m.id)?.can_rescan,
|
||||
can_delete: !!amap.get(m.id)?.can_delete,
|
||||
can_inbox: !!amap.get(m.id)?.can_inbox,
|
||||
can_secretaria: !!amap.get(m.id)?.can_secretaria,
|
||||
can_delete_msg: !!amap.get(m.id)?.can_delete_msg,
|
||||
}));
|
||||
return res.json({
|
||||
isOwner: true, ownerId: owner.ownerId, ownerNome: owner.ownerNome,
|
||||
me: { can_rescan: true, can_delete: true, can_inbox: true, can_secretaria: true, can_delete_msg: true },
|
||||
members,
|
||||
});
|
||||
}
|
||||
|
||||
const { rows } = instanceId ? await pool.query(
|
||||
'SELECT can_rescan, can_delete, can_inbox, can_secretaria, can_delete_msg FROM wa_session_authz WHERE clinica_id = $1 AND instance_id = $2 AND usuario_id = $3',
|
||||
[clinicaId, instanceId, meId]) : { rows: [] };
|
||||
return res.json({
|
||||
isOwner: false, ownerId: owner?.ownerId || null, ownerNome: owner?.ownerNome || null,
|
||||
me: { can_rescan: !!rows[0]?.can_rescan, can_delete: !!rows[0]?.can_delete, can_inbox: !!rows[0]?.can_inbox, can_secretaria: !!rows[0]?.can_secretaria, can_delete_msg: !!rows[0]?.can_delete_msg },
|
||||
members: [],
|
||||
});
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// PUT /api/nw/authz — dono concede/revoga re-scan/exclusão de uma sessão a uma conta.
|
||||
// Body: { clinicaId, instanceId, usuarioId, can_rescan, can_delete }
|
||||
app.put('/api/nw/authz', tenantGuard, async (req, res) => {
|
||||
try {
|
||||
const clinicaId = req.clinicaId;
|
||||
const { instanceId, usuarioId, can_rescan = false, can_delete = false, can_inbox = false, can_secretaria = false, can_delete_msg = false } = req.body || {};
|
||||
if (!clinicaId || !instanceId || !usuarioId) return res.status(400).json({ error: 'clinicaId, instanceId e usuarioId são obrigatórios.' });
|
||||
|
||||
const owner = await resolveWorkspaceOwner(clinicaId);
|
||||
if (!owner || owner.ownerId !== req.authUser.userId) {
|
||||
return res.status(403).json({ error: 'Apenas o dono do workspace pode alterar autorizações.' });
|
||||
}
|
||||
if (usuarioId === owner.ownerId) return res.status(400).json({ error: 'O dono já tem poderes totais.' });
|
||||
|
||||
// Confirma que o alvo é membro do workspace.
|
||||
const { rows: vin } = await pool.query(
|
||||
'SELECT 1 FROM vinculos WHERE usuario_id = $1 AND clinica_id = $2', [usuarioId, clinicaId]);
|
||||
if (vin.length === 0) return res.status(400).json({ error: 'Conta não é membro deste workspace.' });
|
||||
|
||||
const rescan = !!can_rescan, del = !!can_delete, inbox = !!can_inbox, sec = !!can_secretaria, delMsg = !!can_delete_msg;
|
||||
if (!rescan && !del && !inbox && !sec && !delMsg) {
|
||||
await pool.query('DELETE FROM wa_session_authz WHERE clinica_id = $1 AND instance_id = $2 AND usuario_id = $3',
|
||||
[clinicaId, instanceId, usuarioId]);
|
||||
} else {
|
||||
await pool.query(`
|
||||
INSERT INTO wa_session_authz (clinica_id, instance_id, usuario_id, can_rescan, can_delete, can_inbox, can_secretaria, can_delete_msg, granted_by, granted_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW())
|
||||
ON CONFLICT (clinica_id, instance_id, usuario_id)
|
||||
DO UPDATE SET can_rescan = EXCLUDED.can_rescan, can_delete = EXCLUDED.can_delete, can_inbox = EXCLUDED.can_inbox, can_secretaria = EXCLUDED.can_secretaria, can_delete_msg = EXCLUDED.can_delete_msg, granted_by = EXCLUDED.granted_by, granted_at = NOW()
|
||||
`, [clinicaId, instanceId, usuarioId, rescan, del, inbox, sec, delMsg, owner.ownerId]);
|
||||
}
|
||||
res.json({ ok: true });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// GET /api/nw/access?clinicaId= — acesso AGREGADO do usuário logado às áreas do
|
||||
// WhatsApp no workspace (usado pelo frontend p/ esconder menu/botões). Dono = tudo.
|
||||
app.get('/api/nw/access', tenantGuard, async (req, res) => {
|
||||
try {
|
||||
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, 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
|
||||
FROM wa_session_authz WHERE clinica_id = $1 AND usuario_id = $2`, [clinicaId, meId]);
|
||||
res.json({
|
||||
isOwner: false,
|
||||
inbox: !!rows[0]?.inbox,
|
||||
secretaria: !!rows[0]?.secretaria,
|
||||
delete_msg: !!rows[0]?.delete_msg,
|
||||
signature,
|
||||
});
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
app.post('/api/clinicas/:clinicaId/membros', tenantGuard, requireCapability('gestao-equipe'), async (req, res) => {
|
||||
const { email, role, setor_id, funcao_id, nome } = req.body;
|
||||
if (!email || !role) return res.status(400).json({ success: false, message: 'E-mail e função são obrigatórios.' });
|
||||
@@ -1771,7 +1927,25 @@ app.get('/api/dashboard/stats', tenantGuard, async (req, res) => {
|
||||
try {
|
||||
const clinicaId = req.clinicaId;
|
||||
if (!clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||||
const cacheKey = `dashboard:stats:${clinicaId}`;
|
||||
// Escopo por PAPEL: um dentista vê só a PRÓPRIA agenda; o financeiro só é
|
||||
// devolvido a dono/admin/funcionário. Sem isso, um dentista recém-cadastrado
|
||||
// via a agenda inteira da clínica + os financeiros no payload (mesmo com o
|
||||
// front escondendo os cards). O cache passa a ser por ESCOPO (não só clínica).
|
||||
let role = null, dentistaIds = [];
|
||||
try {
|
||||
const { rows } = await pool.query('SELECT role FROM vinculos WHERE usuario_id = $1 AND clinica_id = $2 LIMIT 1', [req.authUser.userId, clinicaId]);
|
||||
role = rows[0]?.role || null;
|
||||
} catch { /* segue */ }
|
||||
const isDentista = role === 'dentista';
|
||||
const verFinanceiro = ['admin', 'donoclinica', 'donoconsultorio', 'funcionario'].includes(role);
|
||||
if (isDentista) {
|
||||
try {
|
||||
const { rows } = await pool.query('SELECT id FROM dentistas WHERE usuario_id = $1 AND clinica_id = $2', [req.authUser.userId, clinicaId]);
|
||||
dentistaIds = rows.map((r) => r.id);
|
||||
} catch { /* segue */ }
|
||||
}
|
||||
const scope = isDentista ? `dent:${dentistaIds.join('_') || 'none'}` : (verFinanceiro ? 'full' : 'basic');
|
||||
const cacheKey = `dashboard:stats:${clinicaId}:${scope}`;
|
||||
if (redis) {
|
||||
const cachedStats = await redis.get(cacheKey);
|
||||
if (cachedStats) {
|
||||
@@ -1781,43 +1955,55 @@ app.get('/api/dashboard/stats', tenantGuard, async (req, res) => {
|
||||
}
|
||||
|
||||
const { rows: pacientes } = await pool.query('SELECT COUNT(*) as count FROM pacientes WHERE clinica_id = $1', [clinicaId]);
|
||||
const { rows: agendamentos } = await pool.query('SELECT COUNT(*) as count FROM agendamentos WHERE clinica_id = $1', [clinicaId]);
|
||||
const { rows: leads } = await pool.query('SELECT COUNT(*) as count FROM leads WHERE clinica_id = $1', [clinicaId]);
|
||||
const { rows: faturamento } = await pool.query("SELECT COALESCE(SUM(valor), 0) as total FROM financeiro WHERE (tipo = 'RECEITA' OR tipo IS NULL) AND clinica_id = $1", [clinicaId]);
|
||||
const { rows: pendente } = await pool.query("SELECT COALESCE(SUM(valor), 0) as total FROM financeiro WHERE tipo = 'DESPESA' AND clinica_id = $1", [clinicaId]);
|
||||
|
||||
// Growth data (last 6 months)
|
||||
const { rows: growth } = await pool.query(`
|
||||
SELECT
|
||||
TO_CHAR(datavencimento, 'YYYY-MM') as mes,
|
||||
SUM(CASE WHEN tipo = 'RECEITA' OR tipo IS NULL THEN valor ELSE 0 END) as receita,
|
||||
SUM(CASE WHEN tipo = 'DESPESA' THEN valor ELSE 0 END) as despesa
|
||||
FROM financeiro
|
||||
WHERE datavencimento >= CURRENT_DATE - INTERVAL '6 months' AND clinica_id = $1
|
||||
GROUP BY TO_CHAR(datavencimento, 'YYYY-MM')
|
||||
ORDER BY mes ASC
|
||||
`, [clinicaId]);
|
||||
// Filtro de agenda: dentista → só os próprios agendamentos.
|
||||
const agFilter = (col, params) => {
|
||||
let w = `${col === 'a' ? 'a.' : ''}clinica_id = $1`;
|
||||
if (isDentista) {
|
||||
if (!dentistaIds.length) return { where: w + ' AND false', params };
|
||||
params.push(dentistaIds);
|
||||
w += ` AND ${col === 'a' ? 'a.' : ''}dentistaid = ANY($${params.length})`;
|
||||
}
|
||||
return { where: w, params };
|
||||
};
|
||||
const agp = agFilter('', [clinicaId]);
|
||||
const { rows: agendamentos } = await pool.query(`SELECT COUNT(*) as count FROM agendamentos WHERE ${agp.where}`, agp.params);
|
||||
|
||||
// Recent appointments with patient and dentist names
|
||||
// Financeiro só p/ dono/admin/funcionário.
|
||||
let faturamentoTotal = 0, despesasTotal = 0, growth = [];
|
||||
if (verFinanceiro) {
|
||||
const { rows: faturamento } = await pool.query("SELECT COALESCE(SUM(valor), 0) as total FROM financeiro WHERE (tipo = 'RECEITA' OR tipo IS NULL) AND clinica_id = $1", [clinicaId]);
|
||||
const { rows: pendente } = await pool.query("SELECT COALESCE(SUM(valor), 0) as total FROM financeiro WHERE tipo = 'DESPESA' AND clinica_id = $1", [clinicaId]);
|
||||
const { rows: g } = await pool.query(`
|
||||
SELECT TO_CHAR(datavencimento, 'YYYY-MM') as mes,
|
||||
SUM(CASE WHEN tipo = 'RECEITA' OR tipo IS NULL THEN valor ELSE 0 END) as receita,
|
||||
SUM(CASE WHEN tipo = 'DESPESA' THEN valor ELSE 0 END) as despesa
|
||||
FROM financeiro
|
||||
WHERE datavencimento >= CURRENT_DATE - INTERVAL '6 months' AND clinica_id = $1
|
||||
GROUP BY TO_CHAR(datavencimento, 'YYYY-MM') ORDER BY mes ASC`, [clinicaId]);
|
||||
faturamentoTotal = parseFloat(faturamento[0].total);
|
||||
despesasTotal = parseFloat(pendente[0].total);
|
||||
growth = g;
|
||||
}
|
||||
|
||||
// Agenda recente com os mesmos limites por papel.
|
||||
const rec = agFilter('a', [clinicaId]);
|
||||
const { rows: recent } = await pool.query(`
|
||||
SELECT a.id, a.pacientenome, a.start_time, a.status,
|
||||
d.nome as "dentistaNome"
|
||||
FROM agendamentos a
|
||||
LEFT JOIN dentistas d ON a.dentistaid = d.id
|
||||
WHERE a.clinica_id = $1
|
||||
ORDER BY a.start_time DESC
|
||||
LIMIT 5
|
||||
`, [clinicaId]);
|
||||
SELECT a.id, a.pacientenome, a.start_time, a.status, d.nome as "dentistaNome"
|
||||
FROM agendamentos a LEFT JOIN dentistas d ON a.dentistaid = d.id
|
||||
WHERE ${rec.where}
|
||||
ORDER BY a.start_time DESC LIMIT 5`, rec.params);
|
||||
|
||||
const responseData = {
|
||||
stats: {
|
||||
totalPacientes: parseInt(pacientes[0].count),
|
||||
totalAgendamentos: parseInt(agendamentos[0].count),
|
||||
totalLeads: parseInt(leads[0].count),
|
||||
faturamentoTotal: parseFloat(faturamento[0].total),
|
||||
despesasTotal: parseFloat(pendente[0].total)
|
||||
faturamentoTotal,
|
||||
despesasTotal
|
||||
},
|
||||
growth: growth,
|
||||
growth,
|
||||
recentAgendamentos: recent
|
||||
};
|
||||
|
||||
@@ -1981,7 +2167,7 @@ app.get('/api/dentistas', authGuard, async (req, res) => {
|
||||
|
||||
// Filtra o payload de dentista para só colunas válidas (descarta celular/cro/cro_uf etc.),
|
||||
// converte 'ativo' (boolean) para smallint e serializa as listas (especialidades/dias).
|
||||
const DENTISTA_COLS = new Set(['id', 'clinica_id', 'nome', 'email', 'telefone', 'coragenda', 'especialidade', 'especialidades', 'dias_atendimento', 'ativo', 'ordem', 'usuario_id']);
|
||||
const DENTISTA_COLS = new Set(['id', 'clinica_id', 'nome', 'email', 'telefone', 'coragenda', 'especialidade', 'especialidades', 'dias_atendimento', 'ativo', 'ordem', 'usuario_id', 'setor_id']);
|
||||
function sanitizeDentista(d) {
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(d || {})) {
|
||||
@@ -1998,6 +2184,14 @@ app.post('/api/dentistas', tenantGuard, requireCapability('dentistas'), async (r
|
||||
try {
|
||||
const d = req.body;
|
||||
let tempPassword = null, contaCriada = false;
|
||||
// E-mail OBRIGATÓRIO: todo dentista precisa de conta para ter agenda e identidade
|
||||
// global — sem ela o anti-overbooking entre clínicas não reconhece a mesma pessoa.
|
||||
// (O protético interno "sem conta" usa outro fluxo e NÃO passa por aqui.)
|
||||
const emailLimpo = (d.email || '').trim();
|
||||
if (!emailLimpo || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(emailLimpo)) {
|
||||
return res.status(400).json({ success: false, message: 'E-MAIL VÁLIDO É OBRIGATÓRIO PARA CADASTRAR UM DENTISTA (ele precisa de conta para ter agenda).' });
|
||||
}
|
||||
d.email = emailLimpo;
|
||||
// Dentista com e-mail: provisiona a conta (se não existir) e vincula à clínica,
|
||||
// pra ele ter acesso à agenda. Conta "free" — só vínculo com a clínica.
|
||||
if (d.email && !d.usuario_id) {
|
||||
@@ -2614,7 +2808,26 @@ app.get('/api/agendamentos', tenantGuard, async (req, res) => {
|
||||
try {
|
||||
const clinicaId = req.clinicaId;
|
||||
if (!clinicaId) return res.json([]); // sem clínica escopada → nada (isolamento entre unidades)
|
||||
const { rows } = await pool.query('SELECT * FROM agendamentos WHERE clinica_id = $1', [clinicaId]);
|
||||
// Janela de datas OPCIONAL (?from&to): quando enviada, limita a varredura à janela
|
||||
// visível do calendário em vez de carregar a agenda histórica inteira. Compatível
|
||||
// com clientes antigos (sem from/to → comportamento anterior).
|
||||
const { from, to } = req.query;
|
||||
const params = [clinicaId];
|
||||
let janela = '';
|
||||
if (from) { params.push(from); janela += ` AND COALESCE(end_time, start_time) >= $${params.length}`; }
|
||||
if (to) { params.push(to); janela += ` AND start_time <= $${params.length}`; }
|
||||
// Isolamento por setor (opt-in). Dono/admin e membro SEM setor → vê tudo
|
||||
// (compat). Membro COM setor → só o próprio setor + os "gerais" (setor_id
|
||||
// NULL). Não se aplica a salas (req.isSala não tem vínculo/setor → vê tudo).
|
||||
if (!req.isSala) {
|
||||
const { ehDono, setorId } = await resolverSetorUsuario(pool, req.authUser?.userId, clinicaId);
|
||||
if (!ehDono && setorId) {
|
||||
params.push(setorId);
|
||||
janela += ` AND (setor_id = $${params.length} OR setor_id IS NULL)`;
|
||||
}
|
||||
}
|
||||
const { rows } = await pool.query(
|
||||
`SELECT * FROM agendamentos WHERE clinica_id = $1${janela} ORDER BY start_time`, params);
|
||||
res.json(rows.map(r => ({
|
||||
...r,
|
||||
dentistaId: r.dentistaid,
|
||||
@@ -2634,11 +2847,19 @@ app.get('/api/agendamentos', tenantGuard, async (req, res) => {
|
||||
async function findOverbookingConflict(db, dentistaId, start, end, ignoreId) {
|
||||
if (!dentistaId || !start) return null;
|
||||
let dentistaIds = [dentistaId];
|
||||
const { rows: dr } = await db.query('SELECT usuario_id FROM dentistas WHERE id = $1', [dentistaId]);
|
||||
const { rows: dr } = await db.query('SELECT usuario_id, email FROM dentistas WHERE id = $1', [dentistaId]);
|
||||
const profUid = dr[0]?.usuario_id || null;
|
||||
const profEmail = (dr[0]?.email || '').trim().toLowerCase() || null;
|
||||
if (profUid) {
|
||||
const { rows: ids } = await db.query('SELECT id FROM dentistas WHERE usuario_id = $1', [profUid]);
|
||||
if (ids.length) dentistaIds = ids.map(r => r.id);
|
||||
} else if (profEmail) {
|
||||
// Profissional SEM conta (usuario_id NULL): agrupa os registros da MESMA pessoa
|
||||
// pelo email — a mesma chave usada no backfill de identidade (Fase 2). Garante o
|
||||
// anti-overbooking para o dentista que atende fixo numa clínica e avulso em outras,
|
||||
// mesmo sem login. Sem email não há como vincular → cai no próprio dentistaId.
|
||||
const { rows: ids } = await db.query('SELECT id FROM dentistas WHERE lower(trim(email)) = $1', [profEmail]);
|
||||
if (ids.length) dentistaIds = ids.map(r => r.id);
|
||||
}
|
||||
const fim = end || start;
|
||||
const params = [dentistaIds, start, fim];
|
||||
@@ -2656,6 +2877,14 @@ async function findOverbookingConflict(db, dentistaId, start, end, ignoreId) {
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
// Privacidade entre tenants: NUNCA devolver id/clinica_id do compromisso em conflito
|
||||
// (revelaria a clínica concorrente e o agendamento alheio a quem está agendando aqui).
|
||||
// Só o intervalo de tempo, que é o necessário para "escolha outro horário".
|
||||
function slotOcupadoPublico(conflito) {
|
||||
if (!conflito) return null;
|
||||
return { start: conflito.start_time, end: conflito.end_time };
|
||||
}
|
||||
|
||||
// POST with concurrency lock: prevents double-booking the same dentist+time slot
|
||||
app.post('/api/agendamentos', authGuard, requireCapability('agenda'), async (req, res) => {
|
||||
try {
|
||||
@@ -2675,6 +2904,23 @@ app.post('/api/agendamentos', authGuard, requireCapability('agenda'), async (req
|
||||
const conflito = await findOverbookingConflict(client, dentistaId, start, end);
|
||||
if (conflito) throw { code: 'CONF_OVERLAP', conflito };
|
||||
|
||||
// Setor do agendamento (isolamento opt-in): ancora no setor do DENTISTA —
|
||||
// primeiro um override explícito (dentistas.setor_id), senão o setor do
|
||||
// vínculo do próprio profissional na clínica (definido em Gestão de Equipe,
|
||||
// UI já existente). Sem isso, herda o setor de quem está agendando (recepção
|
||||
// do setor). NULL = geral, visível a todos.
|
||||
let setorAg = null;
|
||||
const { rows: ds } = await client.query(
|
||||
`SELECT d.setor_id AS d_setor, v.setor_id AS v_setor
|
||||
FROM dentistas d
|
||||
LEFT JOIN vinculos v ON v.usuario_id = d.usuario_id AND v.clinica_id = $2
|
||||
WHERE d.id = $1`, [dentistaId, rest.clinica_id]);
|
||||
setorAg = ds[0]?.d_setor || ds[0]?.v_setor || null;
|
||||
if (!setorAg) {
|
||||
const { rows: vs } = await client.query('SELECT setor_id FROM vinculos WHERE usuario_id = $1 AND clinica_id = $2', [actor, rest.clinica_id]);
|
||||
setorAg = vs[0]?.setor_id || null;
|
||||
}
|
||||
|
||||
// Map keys from camelCase/old attributes to correct lowercase PG columns if necessary
|
||||
const payload = {
|
||||
id,
|
||||
@@ -2688,6 +2934,7 @@ app.post('/api/agendamentos', authGuard, requireCapability('agenda'), async (req
|
||||
observacoes: rest.observacoes,
|
||||
status: rest.status || 'agendado',
|
||||
clinica_id: rest.clinica_id,
|
||||
setor_id: setorAg,
|
||||
created_by: actor,
|
||||
created_by_nome: actorNm,
|
||||
version: 1
|
||||
@@ -2727,7 +2974,7 @@ app.post('/api/agendamentos', authGuard, requireCapability('agenda'), async (req
|
||||
success: false,
|
||||
conflito: true,
|
||||
podeRegistrarInteresse: true, // gancho p/ Fase 4 (interesse/fila)
|
||||
ocupado: err.conflito, // { id, clinica_id, start_time, end_time }
|
||||
ocupado: slotOcupadoPublico(err.conflito), // só o horário — sem vazar a clínica alheia
|
||||
message: 'O PROFISSIONAL JÁ TEM COMPROMISSO NESSE HORÁRIO (EM QUALQUER CLÍNICA). REGISTRE INTERESSE OU ESCOLHA OUTRO HORÁRIO.'
|
||||
});
|
||||
}
|
||||
@@ -2763,13 +3010,6 @@ app.put('/api/agendamentos/:id', authGuard, requireCapabilityRow('agenda', 'agen
|
||||
const mudouTempoOuDent = (start && String(start) !== String(before.start_time))
|
||||
|| (end && String(end) !== String(before.end_time))
|
||||
|| (rest.dentistaId && rest.dentistaId !== before.dentistaid);
|
||||
if (mudouTempoOuDent) {
|
||||
const novoDent = rest.dentistaId || before.dentistaid;
|
||||
const conflito = await findOverbookingConflict(pool, novoDent, start || before.start_time, end || before.end_time, req.params.id);
|
||||
if (conflito) {
|
||||
return res.status(409).json({ success: false, conflito: true, podeRegistrarInteresse: true, ocupado: conflito, message: 'O PROFISSIONAL JÁ TEM COMPROMISSO NESSE HORÁRIO (EM QUALQUER CLÍNICA). ESCOLHA OUTRO HORÁRIO.' });
|
||||
}
|
||||
}
|
||||
|
||||
const updateData = {};
|
||||
if (start) updateData.start_time = start;
|
||||
@@ -2785,17 +3025,43 @@ app.put('/api/agendamentos/:id', authGuard, requireCapabilityRow('agenda', 'agen
|
||||
updateData.updated_at = new Date().toISOString();
|
||||
updateData.version = Number(before.version || 1) + 1;
|
||||
|
||||
const updateQuery = buildUpdate('agendamentos', updateData, 'id', req.params.id);
|
||||
if (updateQuery) await pool.query(updateQuery.text, updateQuery.values);
|
||||
|
||||
// remarcou (mudou horário) vs editou (outros campos)
|
||||
const movido = (start && String(start) !== String(before.start_time)) || (end && String(end) !== String(before.end_time));
|
||||
await registrarAudit(pool, {
|
||||
clinicaId: before.clinica_id, entidade: 'agendamento', entidadeId: req.params.id,
|
||||
acao: movido ? 'reagendou' : 'editou', actorId: actor, actorNome: actorNm,
|
||||
detalhes: movido
|
||||
? { de: { start: before.start_time, end: before.end_time }, para: { start: start || before.start_time, end: end || before.end_time } }
|
||||
: { campos: Object.keys(updateData).filter(k => !['updated_by', 'updated_by_nome', 'updated_at', 'version'].includes(k)) }
|
||||
|
||||
// Tudo numa única transação: a checagem de overbooking (FOR UPDATE) e o UPDATE
|
||||
// ficam atômicos — sem isso, dois reagendamentos simultâneos para o mesmo slot
|
||||
// passariam os dois pela verificação (o lock só vale dentro da transação).
|
||||
await withTransaction(async (client) => {
|
||||
if (mudouTempoOuDent) {
|
||||
const novoDent = rest.dentistaId || before.dentistaid;
|
||||
const conflito = await findOverbookingConflict(client, novoDent, start || before.start_time, end || before.end_time, req.params.id);
|
||||
if (conflito) throw { code: 'CONF_OVERLAP', conflito };
|
||||
}
|
||||
// Trocou o dentista → o setor segue o novo profissional (mesma derivação do
|
||||
// POST: override do dentista > setor do vínculo dele > setor de quem edita).
|
||||
// Sem isso, o agendamento ficaria no setor do dentista ANTERIOR.
|
||||
if (rest.dentistaId && rest.dentistaId !== before.dentistaid) {
|
||||
const { rows: dd } = await client.query(
|
||||
`SELECT d.setor_id AS d_setor, v.setor_id AS v_setor
|
||||
FROM dentistas d
|
||||
LEFT JOIN vinculos v ON v.usuario_id = d.usuario_id AND v.clinica_id = $2
|
||||
WHERE d.id = $1`, [rest.dentistaId, before.clinica_id]);
|
||||
let novoSetor = dd[0]?.d_setor || dd[0]?.v_setor || null;
|
||||
if (!novoSetor) {
|
||||
const { rows: av } = await client.query('SELECT setor_id FROM vinculos WHERE usuario_id = $1 AND clinica_id = $2', [actor, before.clinica_id]);
|
||||
novoSetor = av[0]?.setor_id || null;
|
||||
}
|
||||
updateData.setor_id = novoSetor;
|
||||
}
|
||||
const updateQuery = buildUpdate('agendamentos', updateData, 'id', req.params.id);
|
||||
if (updateQuery) await client.query(updateQuery.text, updateQuery.values);
|
||||
await registrarAudit(client, {
|
||||
clinicaId: before.clinica_id, entidade: 'agendamento', entidadeId: req.params.id,
|
||||
acao: movido ? 'reagendou' : 'editou', actorId: actor, actorNome: actorNm,
|
||||
detalhes: movido
|
||||
? { de: { start: before.start_time, end: before.end_time }, para: { start: start || before.start_time, end: end || before.end_time } }
|
||||
: { campos: Object.keys(updateData).filter(k => !['updated_by', 'updated_by_nome', 'updated_at', 'version'].includes(k)) }
|
||||
});
|
||||
});
|
||||
schedulePush('agendamentos');
|
||||
wsBroadcast(before.clinica_id, 'agenda', { acao: 'editou', id: req.params.id }); // pós-commit
|
||||
@@ -2810,6 +3076,13 @@ app.put('/api/agendamentos/:id', authGuard, requireCapabilityRow('agenda', 'agen
|
||||
});
|
||||
res.json({ success: true, version: updateData.version });
|
||||
} catch (err) {
|
||||
if (err.code === 'CONF_OVERLAP') {
|
||||
return res.status(409).json({
|
||||
success: false, conflito: true, podeRegistrarInteresse: true,
|
||||
ocupado: slotOcupadoPublico(err.conflito), // só o horário — sem vazar a clínica alheia
|
||||
message: 'O PROFISSIONAL JÁ TEM COMPROMISSO NESSE HORÁRIO (EM QUALQUER CLÍNICA). ESCOLHA OUTRO HORÁRIO.'
|
||||
});
|
||||
}
|
||||
if (err.code === '23505') {
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
@@ -2839,6 +3112,20 @@ async function userHasVinculo(userId, clinicaId) {
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
// Contexto de setor do usuário NA clínica (para isolamento opt-in da agenda):
|
||||
// { ehDono, setorId }. ehDono (dono/admin/superadmin) → vê tudo. setorId NULL
|
||||
// (membro sem setor) → vê tudo (compat: clínica que não usa setores). setorId
|
||||
// preenchido → vê só o próprio setor + os "gerais" (setor_id NULL).
|
||||
const DONO_ROLES = ['donoclinica', 'donoconsultorio', 'admin'];
|
||||
async function resolverSetorUsuario(db, userId, clinicaId) {
|
||||
if (!userId || !clinicaId) return { ehDono: false, setorId: null };
|
||||
const { rows: u } = await db.query('SELECT role FROM usuarios WHERE id = $1', [userId]);
|
||||
if (u[0]?.role === 'superadmin') return { ehDono: true, setorId: null };
|
||||
const { rows } = await db.query('SELECT role, setor_id FROM vinculos WHERE usuario_id = $1 AND clinica_id = $2', [userId, clinicaId]);
|
||||
if (!rows.length) return { ehDono: false, setorId: null };
|
||||
return { ehDono: DONO_ROLES.includes(rows[0].role), setorId: rows[0].setor_id || null };
|
||||
}
|
||||
|
||||
// Carrega um tratamento orto e valida o vínculo do usuário com a clínica dele (403 se não).
|
||||
async function assertOrtoScoped(req, res) {
|
||||
const { rows } = await pool.query('SELECT clinica_id FROM ortho_tratamento WHERE id = $1', [req.params.id]);
|
||||
@@ -3839,23 +4126,66 @@ app.get('/api/protese/laboratorios', tenantGuard, async (req, res) => {
|
||||
try {
|
||||
const clinicaId = req.clinicaId;
|
||||
const { rows } = await pool.query(
|
||||
`SELECT u.id, u.nome, u.email,
|
||||
BOOL_OR(v.clinica_id = $1) AS interno
|
||||
`SELECT u.id, u.nome, u.email, u.celular,
|
||||
BOOL_OR(v.clinica_id = $1) AS interno,
|
||||
BOOL_OR(pf.favorito) AS favorito,
|
||||
BOOL_OR(pf.padrao) AS padrao
|
||||
FROM usuarios u
|
||||
LEFT JOIN vinculos v ON v.usuario_id = u.id
|
||||
LEFT JOIN protese_protetico_pref pf ON pf.protetico_id = u.id AND pf.clinica_id = $1
|
||||
WHERE u.role = 'protetico'
|
||||
AND (v.clinica_id = $1 OR u.disponivel_diretorio = true)
|
||||
GROUP BY u.id, u.nome, u.email
|
||||
ORDER BY interno DESC, u.nome`, [clinicaId]);
|
||||
AND (v.clinica_id = $1 OR u.disponivel_diretorio = true OR pf.clinica_id = $1)
|
||||
GROUP BY u.id, u.nome, u.email, u.celular
|
||||
ORDER BY u.nome`, [clinicaId]);
|
||||
// Nota ScoreOdonto por protético (ranking no marketplace) — mesma fórmula da tela do lab.
|
||||
const notas = await notasScorePorProtetico(rows.map(r => r.id));
|
||||
const lista = rows.map(r => ({ ...r, interno: !!r.interno, nota: notas[r.id]?.nota ?? null, verificado: notas[r.id]?.verificado || false, avaliacoes: notas[r.id]?.avaliacoes || 0 }));
|
||||
// Ranking: internos primeiro, depois maior nota, depois nome.
|
||||
lista.sort((a, b) => (b.interno ? 1 : 0) - (a.interno ? 1 : 0) || (b.nota ?? -1) - (a.nota ?? -1) || a.nome.localeCompare(b.nome));
|
||||
const lista = rows.map(r => ({ ...r, interno: !!r.interno, favorito: !!r.favorito, padrao: !!r.padrao, nota: notas[r.id]?.nota ?? null, verificado: notas[r.id]?.verificado || false, avaliacoes: notas[r.id]?.avaliacoes || 0 }));
|
||||
// Ranking: padrão → favorito → interno → maior nota → nome.
|
||||
lista.sort((a, b) => (b.padrao ? 1 : 0) - (a.padrao ? 1 : 0) || (b.favorito ? 1 : 0) - (a.favorito ? 1 : 0) || (b.interno ? 1 : 0) - (a.interno ? 1 : 0) || (b.nota ?? -1) - (a.nota ?? -1) || a.nome.localeCompare(b.nome));
|
||||
res.json(lista);
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// Marcar protético como FAVORITO (★) e/ou PADRÃO (pré-selecionado) na clínica ativa.
|
||||
app.post('/api/protese/laboratorios/:proteticoId/pref', tenantGuard, async (req, res) => {
|
||||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||||
try {
|
||||
const protId = req.params.proteticoId;
|
||||
const { rows: pr } = await pool.query("SELECT 1 FROM usuarios WHERE id=$1 AND role='protetico'", [protId]);
|
||||
if (!pr.length) return res.status(404).json({ error: 'Protético não encontrado.' });
|
||||
const favorito = req.body?.favorito;
|
||||
const padrao = req.body?.padrao;
|
||||
// Só pode haver UM padrão por clínica.
|
||||
if (padrao === true) await pool.query('UPDATE protese_protetico_pref SET padrao=false WHERE clinica_id=$1', [req.clinicaId]);
|
||||
// COALESCE(EXCLUDED...) preserva o outro flag quando só um é enviado.
|
||||
await pool.query(
|
||||
`INSERT INTO protese_protetico_pref (clinica_id, protetico_id, favorito, padrao)
|
||||
VALUES ($1,$2,$3,$4)
|
||||
ON CONFLICT (clinica_id, protetico_id) DO UPDATE
|
||||
SET favorito = COALESCE(EXCLUDED.favorito, protese_protetico_pref.favorito),
|
||||
padrao = COALESCE(EXCLUDED.padrao, protese_protetico_pref.padrao)`,
|
||||
[req.clinicaId, protId, favorito ?? null, padrao ?? null]);
|
||||
res.json({ success: true });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// Vincular um protético EXISTENTE (com conta) como interno da clínica — resolve "não aparece no
|
||||
// dropdown" sem precisar do diretório público. Busca por e-mail exato.
|
||||
app.post('/api/protese/laboratorios/vincular', tenantGuard, async (req, res) => {
|
||||
if (!req.clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
|
||||
try {
|
||||
const email = (req.body?.email || '').toLowerCase().trim();
|
||||
if (!email) return res.status(400).json({ error: 'E-mail obrigatório.' });
|
||||
const { rows } = await pool.query("SELECT id, nome, email FROM usuarios WHERE lower(email)=$1 AND role='protetico'", [email]);
|
||||
if (!rows.length) return res.status(404).json({ error: 'Nenhum protético com este e-mail. Confira ou cadastre um protético interno.' });
|
||||
const p = rows[0];
|
||||
await pool.query(
|
||||
"INSERT INTO vinculos (id, usuario_id, clinica_id, role) VALUES ($1,$2,$3,'protetico') ON CONFLICT (usuario_id, clinica_id) DO NOTHING",
|
||||
[`v_${p.id}_${req.clinicaId}`, p.id, req.clinicaId]);
|
||||
res.json({ success: true, protetico: { id: p.id, nome: p.nome, email: p.email } });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// Protético INTERNO (sem conta): a clínica registra um protético que não usa a plataforma.
|
||||
// Cria um usuário "mudo" (e-mail placeholder, senha-fantasma) + vínculo com a clínica (interno,
|
||||
// fora do diretório). A partir daí já recebe OS e o link /p/TOKEN.
|
||||
@@ -6195,6 +6525,10 @@ async function runMigrations() {
|
||||
// Slot único só vale para status ATIVOS — assim cancelar/faltar libera o slot p/ reagendar.
|
||||
`ALTER TABLE agendamentos DROP CONSTRAINT IF EXISTS unique_dentista_slot`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS unique_dentista_slot_active ON agendamentos (dentistaid, start_time) WHERE lower(status) NOT IN ('cancelado','falta','remarcar')`,
|
||||
// Leitura da agenda por clínica + janela de datas (GET /api/agendamentos?from&to).
|
||||
`CREATE INDEX IF NOT EXISTS idx_agendamentos_clinica_start ON agendamentos (clinica_id, start_time)`,
|
||||
// Anti-overbooking entre clínicas p/ dentista sem conta: busca por dentistaid + janela.
|
||||
`CREATE INDEX IF NOT EXISTS idx_agendamentos_dentista_start ON agendamentos (dentistaid, start_time)`,
|
||||
`ALTER TABLE agendamentos ADD COLUMN IF NOT EXISTS pacienteid TEXT`,
|
||||
`ALTER TABLE agendamentos ADD COLUMN IF NOT EXISTS pacientecelular TEXT`,
|
||||
`ALTER TABLE agendamentos ADD COLUMN IF NOT EXISTS created_by TEXT`,
|
||||
@@ -6209,6 +6543,13 @@ async function runMigrations() {
|
||||
`ALTER TABLE agendamentos ADD COLUMN IF NOT EXISTS version INTEGER DEFAULT 1`,
|
||||
`ALTER TABLE agendamentos ADD COLUMN IF NOT EXISTS google_event_id TEXT`,
|
||||
`ALTER TABLE agendamentos ADD COLUMN IF NOT EXISTS cor TEXT`,
|
||||
// ── Isolamento por setor (opt-in) ──────────────────────────────────────
|
||||
// O agendamento carimba o setor a que pertence (do dentista, ou de quem o
|
||||
// criou). NULL = "geral" (visível a todos → retrocompat). dentistas.setor_id
|
||||
// é o reforço opcional para ancorar a agenda do profissional num setor.
|
||||
`ALTER TABLE agendamentos ADD COLUMN IF NOT EXISTS setor_id TEXT`,
|
||||
`ALTER TABLE dentistas ADD COLUMN IF NOT EXISTS setor_id TEXT`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_agendamentos_clinica_setor_start ON agendamentos (clinica_id, setor_id, start_time)`,
|
||||
`CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id TEXT PRIMARY KEY,
|
||||
clinica_id TEXT,
|
||||
@@ -6858,6 +7199,15 @@ async function runMigrations() {
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_protese_coleta_lab ON protese_coleta (protetico_id, data_hora)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_protese_coleta_os ON protese_coleta (os_id)`,
|
||||
// Preferência de protético por clínica: favorito (★) e padrão (pré-selecionado na nova OS/GTO).
|
||||
`CREATE TABLE IF NOT EXISTS protese_protetico_pref (
|
||||
clinica_id TEXT NOT NULL,
|
||||
protetico_id TEXT NOT NULL,
|
||||
favorito BOOLEAN DEFAULT false,
|
||||
padrao BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
PRIMARY KEY (clinica_id, protetico_id)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS protese_produtos (
|
||||
id TEXT PRIMARY KEY,
|
||||
protetico_id TEXT NOT NULL, -- dono (usuário protético)
|
||||
@@ -6870,6 +7220,26 @@ async function runMigrations() {
|
||||
deleted_at TIMESTAMPTZ
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_protese_produtos_prot ON protese_produtos (protetico_id, ativo)`,
|
||||
// Autorizações delegadas de sessão de WhatsApp (NewWhats), por sessão específica.
|
||||
// Dono do workspace (clinicas.owner_id / salas.owner_usuario_id) tem poder total
|
||||
// implícito e NÃO aparece aqui; esta tabela guarda apenas delegações a outras contas.
|
||||
`CREATE TABLE IF NOT EXISTS wa_session_authz (
|
||||
clinica_id TEXT NOT NULL, -- workspace (clínica ou sala) dono da sessão
|
||||
instance_id TEXT NOT NULL, -- id da instância/sessão no motor
|
||||
usuario_id TEXT NOT NULL, -- conta autorizada
|
||||
can_rescan BOOLEAN NOT NULL DEFAULT false, -- pode re-parear (QR) esta sessão
|
||||
can_delete BOOLEAN NOT NULL DEFAULT false, -- pode excluir esta sessão
|
||||
granted_by TEXT, -- quem concedeu (dono)
|
||||
granted_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
PRIMARY KEY (clinica_id, instance_id, usuario_id)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_wa_authz_lookup ON wa_session_authz (clinica_id, instance_id, usuario_id)`,
|
||||
// Acesso a áreas do WhatsApp (Inbox / Secretária) delegado pelo dono, por conta.
|
||||
// Armazenado na mesma tabela por conveniência (o painel do dono gerencia tudo junto).
|
||||
`ALTER TABLE wa_session_authz ADD COLUMN IF NOT EXISTS can_inbox BOOLEAN NOT NULL DEFAULT false`,
|
||||
`ALTER TABLE wa_session_authz ADD COLUMN IF NOT EXISTS can_secretaria BOOLEAN NOT NULL DEFAULT false`,
|
||||
// Apagar conversa/mensagem no inbox (ação destrutiva) — só dono e quem ele liberar.
|
||||
`ALTER TABLE wa_session_authz ADD COLUMN IF NOT EXISTS can_delete_msg BOOLEAN NOT NULL DEFAULT false`,
|
||||
];
|
||||
for (const sql of migrations) {
|
||||
try { await pool.query(sql); } catch (e) { console.error('[MIGRATION]', e.message); }
|
||||
@@ -9068,6 +9438,27 @@ app.get('/api/superadmin/login-log', superadminGuard, async (req, res) => {
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// Super admin: motivos de LIGAR/DESLIGAR a Secretária por sessão (histórico global,
|
||||
// vem do motor — sec_session_power_log). Requer integração newwhats configurada.
|
||||
app.get('/api/superadmin/secretaria-desligamentos', superadminGuard, async (req, res) => {
|
||||
try {
|
||||
const { getConfig } = await import('./newwhats/config.js');
|
||||
const cfg = getConfig();
|
||||
if (!cfg.motorUrl || !cfg.integrationKey) return res.json({ configurado: false, log: [] });
|
||||
// O log é GLOBAL no motor; qualquer conta de dono pareada serve para autenticar.
|
||||
const { rows } = await pool.query(`SELECT u.email FROM usuarios u JOIN vinculos v ON v.usuario_id = u.id WHERE v.role IN ('donoclinica','donoconsultorio') ORDER BY u.email LIMIT 1`);
|
||||
const email = rows[0]?.email;
|
||||
if (!email) return res.json({ configurado: true, log: [] });
|
||||
const limit = Math.min(500, Math.max(1, parseInt(String(req.query.limit || '150'), 10)));
|
||||
const r = await fetch(`${cfg.motorUrl}/api/ext/v1/secretaria/session-power/log?limit=${limit}`, {
|
||||
headers: { 'x-nw-key': cfg.integrationKey, 'x-nw-email': email },
|
||||
});
|
||||
if (!r.ok) return res.status(502).json({ error: 'Motor indisponível', status: r.status });
|
||||
const log = await r.json();
|
||||
res.json({ configurado: true, log: Array.isArray(log) ? log : [] });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// Defaults mesclados sob o JSON salvo: chaves novas (biomedico, add-ons de plugin)
|
||||
// aparecem na tabela mesmo p/ configs gravadas antes delas existirem.
|
||||
const PRICING_DEFAULTS = {
|
||||
@@ -9769,6 +10160,9 @@ app.post('/api/admin/repasses/:id/confirmar', adminGuard, async (req, res) => {
|
||||
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
|
||||
});
|
||||
|
||||
// ── Integração NewWhats (satélite WhatsApp/Secretária IA) — odonto ──────────
|
||||
registerNewwhats(app, pool, { superadminGuard });
|
||||
|
||||
const httpServer = app.listen(PORT, '0.0.0.0', async () => {
|
||||
console.log(`[SERVER] Running on http://0.0.0.0:${PORT}`);
|
||||
console.log(`[VERSION] ${BUILD_INFO.name} ${BUILD_INFO.version} · commit ${BUILD_INFO.commit} · build ${BUILD_INFO.build || 'n/a'} · ${BUILD_INFO.environment}`);
|
||||
@@ -9785,7 +10179,24 @@ const httpServer = app.listen(PORT, '0.0.0.0', async () => {
|
||||
// ── WebSocket server (tempo real) em /api/ws ────────────────────────────────
|
||||
// Auth via query string (?token=&clinicaId=), pois EventSource/WebSocket do
|
||||
// navegador não enviam header Authorization. Valida JWT + vínculo com a clínica.
|
||||
const wss = new WebSocketServer({ server: httpServer, path: '/api/ws' });
|
||||
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(pool);
|
||||
httpServer.on('upgrade', (req, socket, head) => {
|
||||
let pathname;
|
||||
try { pathname = new URL(req.url, 'http://localhost').pathname; }
|
||||
catch { socket.destroy(); return; }
|
||||
if (pathname === '/api/ws') {
|
||||
wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req));
|
||||
} else if (pathname === '/api/nw/v1/stream') {
|
||||
nwWsTunnel(req, socket, head);
|
||||
} else {
|
||||
socket.destroy();
|
||||
}
|
||||
});
|
||||
wss.on('connection', async (ws, req) => {
|
||||
try {
|
||||
const u = new URL(req.url, 'http://localhost');
|
||||
|
||||
+24
-10
@@ -1,11 +1,18 @@
|
||||
# ─── Dev override — NO docker compose build required ─────────────────────────
|
||||
# ─── Dev override — modo desenvolvimento com HMR ─────────────────────────────
|
||||
#
|
||||
# Usage:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
|
||||
# Uso:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
|
||||
#
|
||||
# What changes in dev:
|
||||
# - Backend: Runs node server.js with direct hot mount of backend/
|
||||
# - Frontend: Runs vite dev server on port 3013 with Hot Module Replacement (HMR)
|
||||
# O que muda em relação à produção:
|
||||
# - Backend: monta ./backend ao vivo (node server.js), preservando o
|
||||
# node_modules da imagem (node:20-slim) — código reflete sem rebuild.
|
||||
# - Frontend: builda o stage `builder` (node:20-alpine, que TEM vite +
|
||||
# node_modules no arch musl correto) e roda o vite dev server na 3013 com
|
||||
# Hot Module Replacement. O nginx (default.conf) já faz proxy de / → :3013
|
||||
# com headers de Upgrade, então o WS de HMR passa.
|
||||
#
|
||||
# ⚠️ Este box serve APENAS dev.scoreodonto.com. A produção (scoreodonto.com)
|
||||
# roda em outro servidor — flipar esta stack para dev NÃO afeta produção.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
services:
|
||||
@@ -15,13 +22,20 @@ services:
|
||||
- NODE_ENV=development
|
||||
volumes:
|
||||
- "/home/deploy/scoreodonto-sessions:/app/sessions"
|
||||
- "./backend:/app"
|
||||
- "/home/deploy/scoreodonto-media:/app/media"
|
||||
- "./backend:/app" # source ao vivo (server.js + newwhats/ do túnel)
|
||||
- "/app/node_modules" # preserva node_modules da imagem (arch correto)
|
||||
- "./plugins:/app/plugins"
|
||||
|
||||
scoreodonto-frontend:
|
||||
build:
|
||||
context: "."
|
||||
dockerfile: frontend/Dockerfile
|
||||
target: builder # node:20-alpine com vite + node_modules (musl)
|
||||
command: ["npx", "vite", "--port", "3013", "--host", "0.0.0.0"]
|
||||
environment:
|
||||
- PORT=3013
|
||||
- NODE_ENV=development
|
||||
- DEV_PUBLIC_HOST=dev.scoreodonto.com # libera host + roteia HMR por wss:443 (vite.config)
|
||||
volumes:
|
||||
- "./frontend:/app"
|
||||
- /app/node_modules
|
||||
- "./frontend:/app/frontend" # source ao vivo (HMR)
|
||||
- "/app/frontend/node_modules" # preserva node_modules da imagem (musl)
|
||||
|
||||
@@ -35,6 +35,7 @@ services:
|
||||
- "./backend/.env"
|
||||
environment:
|
||||
- BAILEYS_ENGINE=infinite
|
||||
- TZ=America/Sao_Paulo
|
||||
- PORT=8018
|
||||
- NODE_ENV=production
|
||||
# Senha do banco vem do ambiente (.env raiz em DEV; provisionado fora do git em PROD).
|
||||
|
||||
+71
-5
@@ -4,7 +4,7 @@ import { HybridBackend } from './services/backend.ts';
|
||||
import { Sidebar } from './components/Sidebar.tsx';
|
||||
import {
|
||||
Menu, LayoutDashboard, Users, Calendar as CalendarIcon,
|
||||
DollarSign, Activity, ClipboardList, Bell, Settings, MoreHorizontal, Eye, X
|
||||
DollarSign, Activity, ClipboardList, Bell, Settings, MoreHorizontal, Eye, X, PanelLeftOpen
|
||||
} from 'lucide-react';
|
||||
|
||||
import { ToastContainer } from './components/Toast.tsx';
|
||||
@@ -12,6 +12,11 @@ import { useAppVersion } from './buildInfo.ts';
|
||||
|
||||
import { Dashboard } from './views/Dashboard.tsx';
|
||||
import { LeadsView } from './views/LeadsView.tsx';
|
||||
import { WaInboxView } from './views/WaInboxView.tsx';
|
||||
import { SessionsView } from './views/newwhats/SessionsView.tsx';
|
||||
import { InboxView } from './views/newwhats/InboxView.tsx';
|
||||
import { SecretariaView } from './views/newwhats/SecretariaView.tsx';
|
||||
import { WaSecretariaView } from './views/WaSecretariaView.tsx';
|
||||
import { PublicContactForm } from './views/PublicContactForm.tsx';
|
||||
import { PatientsView } from './views/PatientsView.tsx';
|
||||
import { AgendaView } from './views/AgendaView.tsx';
|
||||
@@ -86,6 +91,9 @@ export type ViewKey =
|
||||
| 'contratos'
|
||||
| 'plugins'
|
||||
| 'rx-radiografias'
|
||||
| 'wa-inbox'
|
||||
| 'wa-sessions'
|
||||
| 'wa-secretaria'
|
||||
| 'salas'
|
||||
| 'minhas-salas'
|
||||
| 'alugar-salas'
|
||||
@@ -113,6 +121,7 @@ export type ViewKey =
|
||||
| 'superadmin-logs'
|
||||
| 'superadmin-financeiro'
|
||||
| 'superadmin-notificacoes'
|
||||
| 'superadmin-secretaria'
|
||||
| 'diretorio-profissionais';
|
||||
|
||||
// ------- URL <-> VIEW mapping -------
|
||||
@@ -144,6 +153,9 @@ const ROUTE_MAP: Record<string, ViewKey> = {
|
||||
'/contratos': 'contratos',
|
||||
'/plugins': 'plugins',
|
||||
'/rx-radiografias': 'rx-radiografias',
|
||||
'/wa-inbox': 'wa-inbox',
|
||||
'/wa-sessions': 'wa-sessions',
|
||||
'/wa-secretaria': 'wa-secretaria',
|
||||
'/salas': 'salas',
|
||||
'/minhas-salas': 'minhas-salas',
|
||||
'/alugar-salas': 'alugar-salas',
|
||||
@@ -170,6 +182,7 @@ const ROUTE_MAP: Record<string, ViewKey> = {
|
||||
'/superadmin/logs': 'superadmin-logs',
|
||||
'/superadmin/financeiro': 'superadmin-financeiro',
|
||||
'/superadmin/notificacoes': 'superadmin-notificacoes',
|
||||
'/superadmin/secretaria': 'superadmin-secretaria',
|
||||
'/aguardando-convite': 'aguardando-convite',
|
||||
'/diretorio-profissionais': 'diretorio-profissionais',
|
||||
'/proteses': 'proteses',
|
||||
@@ -207,6 +220,9 @@ const VIEW_TO_ROUTE: Record<ViewKey, string> = {
|
||||
contratos: '/contratos',
|
||||
plugins: '/plugins',
|
||||
'rx-radiografias': '/rx-radiografias',
|
||||
'wa-inbox': '/wa-inbox',
|
||||
'wa-sessions': '/wa-sessions',
|
||||
'wa-secretaria': '/wa-secretaria',
|
||||
salas: '/salas',
|
||||
'minhas-salas': '/minhas-salas',
|
||||
'alugar-salas': '/alugar-salas',
|
||||
@@ -234,6 +250,7 @@ const VIEW_TO_ROUTE: Record<ViewKey, string> = {
|
||||
'superadmin-logs': '/superadmin/logs',
|
||||
'superadmin-financeiro': '/superadmin/financeiro',
|
||||
'superadmin-notificacoes': '/superadmin/notificacoes',
|
||||
'superadmin-secretaria': '/superadmin/secretaria',
|
||||
'diretorio-profissionais': '/diretorio-profissionais',
|
||||
proteses: '/proteses',
|
||||
};
|
||||
@@ -301,6 +318,21 @@ 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; }
|
||||
});
|
||||
const toggleSidebarCollapsed = (v: boolean) => {
|
||||
setSidebarCollapsed(v);
|
||||
try { localStorage.setItem('scoreodonto:sidebar-collapsed', v ? '1' : '0'); } catch { /* ignore */ }
|
||||
};
|
||||
const [, forcePwRerender] = useState(0); // re-avalia o modal de troca obrigatória de senha
|
||||
const currentRole = HybridBackend.getCurrentRole();
|
||||
const activeWorkspace = HybridBackend.getActiveWorkspace();
|
||||
@@ -315,6 +347,9 @@ const App: React.FC = () => {
|
||||
// Locação de Salas: capability PESSOAL (add-on por conta), liberada a qualquer
|
||||
// usuário com o plugin ativo na conta — independe de ter clínica/cargo.
|
||||
if (view === 'salas' || view === 'minhas-salas' || view === 'alugar-salas') return isPluginActive('locacao-salas');
|
||||
// NewWhats: Inbox e Secretária liberadas a qualquer usuário com o plugin ativo
|
||||
// (a CONFIG do plugin é exclusiva do superadmin via view 'plugins').
|
||||
if (view === 'wa-inbox' || view === 'wa-sessions' || view === 'wa-secretaria') return isPluginActive('newwhats');
|
||||
// Contexto de SALA: isola da clínica — só painel da sala/salas/conta/notificações.
|
||||
if ((HybridBackend.getActiveWorkspace() as any)?.tipo === 'sala') return ['sala-home', 'configuracoes', 'notificacoes', 'clinicas'].includes(view);
|
||||
// Contexto de LABORATÓRIO: área do protético — painel do lab + bancada/conta/notificações.
|
||||
@@ -483,7 +518,7 @@ const App: React.FC = () => {
|
||||
case 'leads': return <LeadsView />;
|
||||
case 'public': return <PublicContactForm />;
|
||||
case 'pacientes': return <PatientsView />;
|
||||
case 'agenda': return <AgendaView onNavigate={handleNavigate} />;
|
||||
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 />;
|
||||
@@ -506,6 +541,9 @@ const App: React.FC = () => {
|
||||
case 'contratos': return <ContratosView />;
|
||||
case 'plugins': return <PluginsView />;
|
||||
case 'rx-radiografias': return <RXPlugin />;
|
||||
case 'wa-inbox': return <InboxView onNavigate={handleNavigate} />;
|
||||
case 'wa-sessions': return <SessionsView onNavigate={handleNavigate} />;
|
||||
case 'wa-secretaria': return <SecretariaView onNavigate={handleNavigate} />;
|
||||
case 'salas': return <SalasPlugin />;
|
||||
case 'minhas-salas': return <SalasPlugin view="minhas" />;
|
||||
case 'alugar-salas': return <SalasPlugin view="alugar" />;
|
||||
@@ -533,6 +571,7 @@ const App: React.FC = () => {
|
||||
case 'superadmin-logs': return <SuperAdminView tab="logs" />;
|
||||
case 'superadmin-financeiro': return <SuperAdminView tab="financeiro" />;
|
||||
case 'superadmin-notificacoes': return <SuperAdminView tab="notificacoes" />;
|
||||
case 'superadmin-secretaria': return <SuperAdminView tab="secretaria" />;
|
||||
// Plugin Profissionais ativo absorve o diretório antigo (mesma rota, view nova).
|
||||
case 'diretorio-profissionais': return isPluginActive('profissionais-marketplace') ? <ProfissionaisPlugin /> : <DiretorioProfissionaisView />;
|
||||
case 'login':
|
||||
@@ -541,7 +580,11 @@ const App: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const isStandaloneView = ['landing', 'login', 'public', 'update', 'cadastro-dentista', 'criar-clinica', 'aguardando-convite'].includes(currentView);
|
||||
// Views do plugin NewWhats rodam em tela cheia (sem sidebar/barra do
|
||||
// scoreodonto) — cada uma tem seu próprio "Voltar" (onNavigate) para sair.
|
||||
// /wa-sessions (gestão de sessões WhatsApp) roda COM a sidebar do scoreodonto
|
||||
// (é área de administração). wa-inbox/wa-secretaria seguem em tela cheia.
|
||||
const isStandaloneView = ['landing', 'login', 'public', 'update', 'cadastro-dentista', 'criar-clinica', 'aguardando-convite', 'wa-inbox', 'wa-secretaria'].includes(currentView);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -567,7 +610,7 @@ const App: React.FC = () => {
|
||||
></div>
|
||||
)}
|
||||
|
||||
<div className={`fixed inset-y-0 left-0 z-30 transform transition-transform duration-300 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)}
|
||||
@@ -576,6 +619,18 @@ const App: React.FC = () => {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Botão flutuante para reexibir a sidebar (desktop, quando recolhida).
|
||||
Na agenda NÃO aparece — ela já tem o próprio toggle ao lado do título. */}
|
||||
{!isStandaloneView && isSidebarCollapsed && currentView !== 'agenda' && (
|
||||
<button
|
||||
onClick={() => toggleSidebarCollapsed(false)}
|
||||
title="Mostrar menu"
|
||||
className="hidden md:flex fixed top-3 left-3 z-40 items-center justify-center w-9 h-9 bg-white border border-gray-200 rounded-lg text-gray-500 hover:text-gray-800 hover:border-gray-300 shadow-sm transition-colors"
|
||||
>
|
||||
<PanelLeftOpen size={18} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<main className={`flex-1 h-full relative flex flex-col min-w-0`}>
|
||||
{HybridBackend.isPreviewMode() && (
|
||||
<div className="bg-red-600 text-white px-4 py-2 flex items-center justify-between gap-3 shadow-md z-30">
|
||||
@@ -608,7 +663,18 @@ const App: React.FC = () => {
|
||||
</div>
|
||||
<span className="font-bold text-gray-800 text-sm tracking-tight uppercase">SCOREODONTO</span>
|
||||
</div>
|
||||
<div className="w-8"></div>
|
||||
{/* ⋯ controles da Agenda (só nesta view) — abre a gaveta; senão, espaçador */}
|
||||
{currentView === 'agenda' ? (
|
||||
<button
|
||||
onClick={() => setAgendaControlsOpen(true)}
|
||||
className="p-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
title="Controles da agenda"
|
||||
>
|
||||
<MoreHorizontal size={24} />
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-8"></div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { AvaliacaoEditor } from './AvaliacaoEditor.tsx';
|
||||
import {
|
||||
User, Stethoscope, Clock, CalendarRange, AlertCircle, CheckCircle2,
|
||||
History, RotateCcw, CalendarClock, Ban, Users, Pencil, ClipboardCheck, ShieldAlert,
|
||||
Search, UserPlus, Link2, Trash2, ClipboardList
|
||||
Search, UserPlus, Link2, Trash2, ClipboardList, MessageCircle
|
||||
} from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
@@ -17,6 +17,7 @@ interface Props {
|
||||
onChanged: () => void; // recarrega a agenda
|
||||
onEditar: () => void; // abre o modal de edição/reagendar (mover de horário)
|
||||
onAtender?: () => void;
|
||||
onVerWhats?: (info: { pacienteId: string; pacienteNome: string }) => void; // abre o drawer de WhatsApp do paciente
|
||||
podeAtender?: boolean;
|
||||
dentistaRestrito?: boolean; // dentista linkado por clínica: modal simples, sem dados sensíveis/agendamento
|
||||
}
|
||||
@@ -492,6 +493,13 @@ const DetailPage: React.FC<Props> = (p) => {
|
||||
|
||||
{/* Navegação / ações principais */}
|
||||
<div className="space-y-2">
|
||||
{p.onVerWhats && pacienteId && (
|
||||
<button onClick={() => p.onVerWhats?.({ pacienteId, pacienteNome: nome })}
|
||||
className="w-full flex items-center justify-between px-4 py-2.5 rounded-xl bg-emerald-50 hover:bg-emerald-100 transition-colors text-xs font-black uppercase text-emerald-700">
|
||||
<span className="flex items-center gap-2"><MessageCircle size={15} /> Ver WhatsApp</span>
|
||||
<span className="text-emerald-300">›</span>
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => push({ title: 'HISTÓRICO', render: () => <HistoryPage appointmentId={ag.id} /> })}
|
||||
className="w-full flex items-center justify-between px-4 py-2.5 rounded-xl bg-gray-50 hover:bg-gray-100 transition-colors text-xs font-black uppercase text-gray-600">
|
||||
<span className="flex items-center gap-2"><History size={15} /> Histórico</span>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { HorariosConfig } from './HorariosConfig.tsx';
|
||||
import { X, ChevronLeft, ChevronRight, Save, Mail, Calendar, Settings as GearIcon, CheckCircle2 } from 'lucide-react';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { Settings, Dentista } from '../types.ts';
|
||||
@@ -22,6 +23,7 @@ export const AgendaSettingsModal: React.FC<AgendaSettingsModalProps> = ({ isOpen
|
||||
});
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [connectedAccounts, setConnectedAccounts] = useState<any[]>([]);
|
||||
const [showHorarios, setShowHorarios] = useState(false);
|
||||
const toast = useToast();
|
||||
// Escopo: tudo (status Google, convites, settings) é da clínica/workspace ativa.
|
||||
const clinicaId = HybridBackend.getActiveWorkspace()?.id;
|
||||
@@ -84,6 +86,7 @@ export const AgendaSettingsModal: React.FC<AgendaSettingsModalProps> = ({ isOpen
|
||||
const adminAccount = connectedAccounts.find(a => a.owner_id && a.owner_id.includes('@'));
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 bg-black/60 z-[60] flex items-center justify-center backdrop-blur-md p-0">
|
||||
<div className="bg-white shadow-2xl w-full h-dvh md:w-[96vw] md:h-[98vh] rounded-none md:rounded-[2.5rem] flex flex-col overflow-hidden border border-gray-100 animate-in fade-in zoom-in-95 duration-300">
|
||||
|
||||
@@ -135,6 +138,15 @@ export const AgendaSettingsModal: React.FC<AgendaSettingsModalProps> = ({ isOpen
|
||||
/>
|
||||
<p className="text-[10px] text-gray-400 italic ml-1">* Este e-mail será usado para alertas de sistema e envio de comprovantes.</p>
|
||||
</div>
|
||||
|
||||
<button onClick={() => setShowHorarios(true)}
|
||||
className="w-full flex items-center justify-between bg-teal-50 hover:bg-teal-100 border-2 border-teal-100 rounded-2xl p-4 transition-colors group">
|
||||
<div className="text-left">
|
||||
<p className="text-sm font-black text-teal-800 uppercase tracking-tight">Horários de Funcionamento</p>
|
||||
<p className="text-[10px] font-bold text-teal-500 uppercase tracking-widest mt-0.5">Dias/horas da clínica · feriados · horários dos dentistas</p>
|
||||
</div>
|
||||
<span className="text-teal-600 font-black text-lg group-hover:translate-x-1 transition-transform">→</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -275,5 +287,7 @@ export const AgendaSettingsModal: React.FC<AgendaSettingsModalProps> = ({ isOpen
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
<HorariosConfig isOpen={showHorarios} onClose={() => setShowHorarios(false)} clinicaId={clinicaId} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
// Configuração de horários de funcionamento (clínica) + feriados/fechamentos e
|
||||
// horários dos dentistas. Consome /api/nw/agenda-config (o backend garante o
|
||||
// ownership: horário/feriado da clínica = dono; horário do dentista = ele ou dono).
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { X, Clock, CalendarOff, Plus, Trash2, Save, Loader2, User, Copy, AlertTriangle } from 'lucide-react';
|
||||
|
||||
const API = (import.meta as any).env?.VITE_API_URL || '/api';
|
||||
const BASE = `${API}/nw/agenda-config`;
|
||||
const DIAS = ['Domingo', 'Segunda', 'Terça', 'Quarta', 'Quinta', 'Sexta', 'Sábado'];
|
||||
|
||||
async function req(path: string, opts: RequestInit = {}) {
|
||||
const token = localStorage.getItem('SCOREODONTO_AUTH_TOKEN');
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
...opts,
|
||||
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(opts.headers || {}) },
|
||||
});
|
||||
const txt = await res.text();
|
||||
const data = txt ? JSON.parse(txt) : {};
|
||||
if (!res.ok) throw new Error(data.error || `Erro ${res.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
interface Faixa { dia_semana: number; hora_inicio: string; hora_fim: string; hora_inicio_flex?: string | null; hora_fim_flex?: string | null }
|
||||
interface Dentista { id: string; nome: string; usuario_id: string | null; eh_voce: boolean }
|
||||
|
||||
// Editor de grade semanal (7 dias, cada um com 0..n faixas). Reusado p/ clínica e dentista.
|
||||
const GradeSemanal: React.FC<{ faixas: Faixa[]; onChange: (f: Faixa[]) => void; disabled?: boolean; withFlex?: boolean }> = ({ faixas, onChange, disabled, withFlex }) => {
|
||||
const porDia = (d: number) => faixas.filter((f) => f.dia_semana === d);
|
||||
const setDia = (d: number, novas: Faixa[]) => onChange([...faixas.filter((f) => f.dia_semana !== d), ...novas]);
|
||||
// Copia as faixas do dia `d` para TODOS os outros dias.
|
||||
const duplicarParaTodos = (d: number) => {
|
||||
const base = porDia(d).map((f) => ({ hora_inicio: f.hora_inicio, hora_fim: f.hora_fim }));
|
||||
onChange(DIAS.flatMap((_, x) => base.map((f) => ({ dia_semana: x, ...f }))));
|
||||
};
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{DIAS.map((nome, d) => {
|
||||
const fs = porDia(d);
|
||||
return (
|
||||
<div key={d} className="flex items-start gap-3 py-2 border-b border-gray-50">
|
||||
<div className="w-24 shrink-0 pt-2 text-xs font-black text-gray-700 uppercase tracking-tight">{nome}</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
{fs.length === 0 && <span className="text-[11px] text-gray-400 italic">Fechado</span>}
|
||||
{fs.map((f, i) => {
|
||||
const upd = (patch: Partial<Faixa>) => { const n = [...fs]; n[i] = { ...f, ...patch }; setDia(d, n); };
|
||||
return (
|
||||
<div key={i} className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="time" disabled={disabled} value={f.hora_inicio} onChange={(e) => upd({ hora_inicio: e.target.value })}
|
||||
className="bg-gray-50 border-2 border-transparent focus:border-teal-600 rounded-xl px-2 py-1.5 text-sm font-bold text-gray-800 outline-none" />
|
||||
<span className="text-gray-400 text-xs font-bold">até</span>
|
||||
<input type="time" disabled={disabled} value={f.hora_fim} onChange={(e) => upd({ hora_fim: e.target.value })}
|
||||
className="bg-gray-50 border-2 border-transparent focus:border-teal-600 rounded-xl px-2 py-1.5 text-sm font-bold text-gray-800 outline-none" />
|
||||
{withFlex && <span className="text-[9px] font-black text-teal-500 uppercase tracking-wide">IA agenda livre</span>}
|
||||
{!disabled && <button onClick={() => setDia(d, fs.filter((_, j) => j !== i))} className="text-gray-300 hover:text-red-500 p-1"><Trash2 size={15} /></button>}
|
||||
</div>
|
||||
{withFlex && (
|
||||
<div className="flex items-center gap-2 flex-wrap pl-1 text-[11px] text-gray-500">
|
||||
<span className="font-bold text-amber-600">Aceito confirmar também:</span>
|
||||
<span>antes, a partir de</span>
|
||||
<input type="time" disabled={disabled} value={f.hora_inicio_flex ?? ''} onChange={(e) => upd({ hora_inicio_flex: e.target.value || null })}
|
||||
className="bg-amber-50 border-2 border-transparent focus:border-amber-500 rounded-lg px-2 py-1 text-xs font-bold text-gray-800 outline-none w-24" />
|
||||
<span>e depois, até</span>
|
||||
<input type="time" disabled={disabled} value={f.hora_fim_flex ?? ''} onChange={(e) => upd({ hora_fim_flex: e.target.value || null })}
|
||||
className="bg-amber-50 border-2 border-transparent focus:border-amber-500 rounded-lg px-2 py-1 text-xs font-bold text-gray-800 outline-none w-24" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!disabled && (
|
||||
<button onClick={() => setDia(d, [...fs, { dia_semana: d, hora_inicio: '08:00', hora_fim: '18:00' }])}
|
||||
className="text-[11px] font-black text-teal-600 hover:text-teal-800 uppercase tracking-wide flex items-center gap-1">
|
||||
<Plus size={13} /> {fs.length ? 'Outra faixa' : 'Abrir neste dia'}
|
||||
</button>
|
||||
)}
|
||||
{!disabled && fs.length > 0 && (
|
||||
<button onClick={() => duplicarParaTodos(d)} title="Aplicar este mesmo horário a todos os dias da semana"
|
||||
className="text-[10px] font-black text-gray-400 hover:text-teal-600 uppercase tracking-wide flex items-center gap-1">
|
||||
<Copy size={11} /> Copiar p/ todos os dias
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; clinicaId?: string; initialTab?: 'clinica' | 'dentistas' | 'situacoes'; soDentista?: boolean }> = ({ isOpen, onClose, clinicaId, initialTab, soDentista }) => {
|
||||
const [aba, setAba] = useState<'clinica' | 'dentistas' | 'situacoes'>(initialTab || 'clinica');
|
||||
useEffect(() => { if (isOpen && initialTab) setAba(initialTab); }, [isOpen, initialTab]);
|
||||
const [erro, setErro] = useState<string | null>(null);
|
||||
const [ok, setOk] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Clínica
|
||||
const [clinFaixas, setClinFaixas] = useState<Faixa[]>([]);
|
||||
const [feriados, setFeriados] = useState<{ excecoes: any[]; nacionais: any[] }>({ excecoes: [], nacionais: [] });
|
||||
const [novo, setNovo] = useState({ data: '', nome: '', fecha: true });
|
||||
|
||||
// Dentistas
|
||||
const [dentistas, setDentistas] = useState<Dentista[]>([]);
|
||||
const [dentSel, setDentSel] = useState<string>('');
|
||||
const [dentFaixas, setDentFaixas] = useState<Faixa[]>([]);
|
||||
const [folgas, setFolgas] = useState<any[]>([]);
|
||||
const [novaFolga, setNovaFolga] = useState({ data_inicio: '', data_fim: '', motivo: '' });
|
||||
|
||||
// Situações (regras que a Secretária IA segue)
|
||||
const [situacoes, setSituacoes] = useState<string[]>([]);
|
||||
|
||||
const flash = (setter: (v: string | null) => void, msg: string) => { setter(msg); setTimeout(() => setter(null), 3000); };
|
||||
|
||||
const carregarClinica = useCallback(async () => {
|
||||
if (!clinicaId) return;
|
||||
try {
|
||||
const h = await req(`/clinica/${clinicaId}/horarios`);
|
||||
setClinFaixas((h.horarios || []).map((r: any) => ({ dia_semana: r.dia_semana, hora_inicio: r.hora_inicio, hora_fim: r.hora_fim })));
|
||||
const f = await req(`/clinica/${clinicaId}/feriados?ano=${new Date().getFullYear()}`);
|
||||
setFeriados({ excecoes: f.excecoes || [], nacionais: f.nacionais || [] });
|
||||
} catch (e: any) { flash(setErro, e.message); }
|
||||
}, [clinicaId]);
|
||||
|
||||
const carregarDentistas = useCallback(async () => {
|
||||
if (!clinicaId) return;
|
||||
try {
|
||||
const d = await req(`/clinica/${clinicaId}/dentistas`);
|
||||
let list: Dentista[] = d.dentistas || [];
|
||||
if (soDentista) { const meus = list.filter((x) => x.eh_voce); if (meus.length) list = meus; } // dentista só vê o próprio
|
||||
setDentistas(list);
|
||||
const first = list.find((x: Dentista) => x.eh_voce) || list[0];
|
||||
if (first) setDentSel(first.id);
|
||||
} catch (e: any) { flash(setErro, e.message); }
|
||||
}, [clinicaId, soDentista]);
|
||||
|
||||
const carregarSituacoes = useCallback(async () => {
|
||||
if (!clinicaId) return;
|
||||
try { const s = await req(`/clinica/${clinicaId}/situacoes`); setSituacoes((s.situacoes || []).map((r: any) => r.texto)); }
|
||||
catch (e: any) { flash(setErro, e.message); }
|
||||
}, [clinicaId]);
|
||||
|
||||
useEffect(() => { if (isOpen) { carregarClinica(); carregarDentistas(); if (!soDentista) carregarSituacoes(); } }, [isOpen, carregarClinica, carregarDentistas, carregarSituacoes, soDentista]);
|
||||
const carregarFolgas = useCallback((id: string) => {
|
||||
req(`/dentista/${id}/folgas`).then((f) => setFolgas(f.folgas || [])).catch(() => setFolgas([]));
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!dentSel) { setDentFaixas([]); setFolgas([]); return; }
|
||||
req(`/dentista/${dentSel}/horarios`).then((h) =>
|
||||
setDentFaixas((h.horarios || []).map((r: any) => ({ dia_semana: r.dia_semana, hora_inicio: r.hora_inicio, hora_fim: r.hora_fim, hora_inicio_flex: r.hora_inicio_flex, hora_fim_flex: r.hora_fim_flex })))
|
||||
).catch((e) => flash(setErro, e.message));
|
||||
carregarFolgas(dentSel);
|
||||
}, [dentSel, carregarFolgas]);
|
||||
|
||||
const addFolga = async () => {
|
||||
if (!novaFolga.data_inicio) { flash(setErro, 'Informe a data.'); return; }
|
||||
try { await req(`/dentista/${dentSel}/folgas`, { method: 'POST', body: JSON.stringify(novaFolga) }); setNovaFolga({ data_inicio: '', data_fim: '', motivo: '' }); carregarFolgas(dentSel); flash(setOk, 'Folga adicionada!'); }
|
||||
catch (e: any) { flash(setErro, e.message); }
|
||||
};
|
||||
const delFolga = async (id: string) => {
|
||||
try { await req(`/dentista/${dentSel}/folgas/${id}`, { method: 'DELETE' }); carregarFolgas(dentSel); } catch (e: any) { flash(setErro, e.message); }
|
||||
};
|
||||
|
||||
const salvarClinica = async () => {
|
||||
setSaving(true); setErro(null);
|
||||
try { await req(`/clinica/${clinicaId}/horarios`, { method: 'PUT', body: JSON.stringify({ horarios: clinFaixas }) }); flash(setOk, 'Horário da clínica salvo!'); }
|
||||
catch (e: any) { flash(setErro, e.message); } finally { setSaving(false); }
|
||||
};
|
||||
const salvarDentista = async () => {
|
||||
setSaving(true); setErro(null);
|
||||
try { await req(`/dentista/${dentSel}/horarios`, { method: 'PUT', body: JSON.stringify({ horarios: dentFaixas }) }); flash(setOk, 'Horário do dentista salvo!'); }
|
||||
catch (e: any) { flash(setErro, e.message); } finally { setSaving(false); }
|
||||
};
|
||||
const addFeriado = async () => {
|
||||
if (!novo.data || !novo.nome) { flash(setErro, 'Informe data e nome.'); return; }
|
||||
try { await req(`/clinica/${clinicaId}/feriados`, { method: 'POST', body: JSON.stringify(novo) }); setNovo({ data: '', nome: '', fecha: true }); carregarClinica(); flash(setOk, 'Fechamento adicionado!'); }
|
||||
catch (e: any) { flash(setErro, e.message); }
|
||||
};
|
||||
const delFeriado = async (id: string) => {
|
||||
try { await req(`/clinica/${clinicaId}/feriados/${id}`, { method: 'DELETE' }); carregarClinica(); }
|
||||
catch (e: any) { flash(setErro, e.message); }
|
||||
};
|
||||
const salvarSituacoes = async () => {
|
||||
setSaving(true); setErro(null);
|
||||
try { await req(`/clinica/${clinicaId}/situacoes`, { method: 'PUT', body: JSON.stringify({ situacoes: situacoes.map((s) => s.trim()).filter(Boolean) }) }); flash(setOk, 'Situações salvas!'); }
|
||||
catch (e: any) { flash(setErro, e.message); } finally { setSaving(false); }
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 z-[70] flex items-center justify-center backdrop-blur-md p-0">
|
||||
<div className="bg-white shadow-2xl w-full h-dvh md:w-[92vw] md:max-w-4xl md:h-[94vh] rounded-none md:rounded-[2rem] flex flex-col overflow-hidden border border-gray-100">
|
||||
{/* Header */}
|
||||
<div className="px-8 py-6 border-b border-gray-50 flex justify-between items-center bg-gradient-to-br from-gray-50 to-white">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 bg-teal-600 rounded-2xl shadow-lg shadow-teal-200"><Clock size={22} className="text-white" /></div>
|
||||
<div>
|
||||
<h2 className="text-lg font-black text-gray-900 uppercase tracking-tighter">Horários de Funcionamento</h2>
|
||||
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mt-0.5">A Secretária IA usa isto para atender</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-xl text-gray-400"><X size={22} /></button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 px-8 pt-4 border-b border-gray-50">
|
||||
{(soDentista ? (['dentistas'] as const) : (['clinica', 'dentistas', 'situacoes'] as const)).map((t) => (
|
||||
<button key={t} onClick={() => setAba(t)}
|
||||
className={`px-4 py-2.5 text-xs font-black uppercase tracking-wide rounded-t-xl transition-colors ${aba === t ? 'bg-teal-600 text-white' : 'text-gray-400 hover:text-gray-600'}`}>
|
||||
{t === 'clinica' ? 'Clínica' : t === 'dentistas' ? 'Dentistas' : 'Situações'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{(erro || ok) && (
|
||||
<div className={`px-8 py-2 text-xs font-bold ${erro ? 'bg-red-50 text-red-600' : 'bg-green-50 text-green-600'}`}>{erro || ok}</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-8">
|
||||
{aba === 'clinica' ? (
|
||||
<div className="space-y-10">
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-black text-gray-800 uppercase tracking-tight flex items-center gap-2"><Clock size={16} className="text-teal-600" /> Dias e horários que a clínica funciona</h3>
|
||||
<button onClick={salvarClinica} disabled={saving} className="bg-teal-600 hover:bg-teal-700 text-white text-xs font-black uppercase tracking-wide px-4 py-2 rounded-xl flex items-center gap-2 disabled:opacity-50">
|
||||
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />} Salvar
|
||||
</button>
|
||||
</div>
|
||||
<GradeSemanal faixas={clinFaixas} onChange={setClinFaixas} />
|
||||
<p className="text-[11px] text-gray-400 italic mt-3">Sem horário configurado, assume-se seg–sex 08h–18h. Só o dono edita.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="text-sm font-black text-gray-800 uppercase tracking-tight flex items-center gap-2 mb-4"><CalendarOff size={16} className="text-teal-600" /> Feriados e fechamentos</h3>
|
||||
<div className="flex flex-wrap items-end gap-2 mb-4 bg-gray-50 p-4 rounded-2xl">
|
||||
<div><label className="text-[9px] font-black text-gray-400 uppercase block mb-1">Data</label>
|
||||
<input type="date" value={novo.data} onChange={(e) => setNovo({ ...novo, data: e.target.value })} className="bg-white border-2 border-transparent focus:border-teal-600 rounded-xl px-3 py-2 text-sm font-bold outline-none" /></div>
|
||||
<div className="flex-1 min-w-[160px]"><label className="text-[9px] font-black text-gray-400 uppercase block mb-1">Motivo</label>
|
||||
<input type="text" placeholder="Ex: Recesso, ponte" value={novo.nome} onChange={(e) => setNovo({ ...novo, nome: e.target.value })} className="w-full bg-white border-2 border-transparent focus:border-teal-600 rounded-xl px-3 py-2 text-sm font-bold outline-none" /></div>
|
||||
<button onClick={addFeriado} className="bg-gray-900 hover:bg-black text-white text-xs font-black uppercase px-4 py-2.5 rounded-xl flex items-center gap-1"><Plus size={14} /> Adicionar</button>
|
||||
</div>
|
||||
{feriados.excecoes.length > 0 && (
|
||||
<div className="space-y-1 mb-4">
|
||||
{feriados.excecoes.map((f) => (
|
||||
<div key={f.id} className="flex items-center justify-between bg-red-50 rounded-xl px-3 py-2">
|
||||
<span className="text-xs font-bold text-red-700">{f.data.split('-').reverse().join('/')} · {f.nome}</span>
|
||||
<button onClick={() => delFeriado(f.id)} className="text-red-300 hover:text-red-600"><Trash2 size={15} /></button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<details className="text-xs">
|
||||
<summary className="cursor-pointer font-black text-gray-400 uppercase tracking-wide">Feriados nacionais (automáticos)</summary>
|
||||
<div className="mt-2 grid grid-cols-2 gap-1">
|
||||
{feriados.nacionais.map((f) => <div key={f.data} className="text-[11px] text-gray-500">{f.data.split('-').reverse().join('/')} · {f.nome}</div>)}
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
</div>
|
||||
) : aba === 'dentistas' ? (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<User size={16} className="text-teal-600" />
|
||||
<select value={dentSel} onChange={(e) => setDentSel(e.target.value)} className="bg-gray-50 border-2 border-transparent focus:border-teal-600 rounded-xl px-3 py-2 text-sm font-black text-gray-800 outline-none">
|
||||
{dentistas.map((d) => <option key={d.id} value={d.id}>{d.nome}{d.eh_voce ? ' (você)' : ''}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<button onClick={salvarDentista} disabled={saving || !dentSel} className="bg-teal-600 hover:bg-teal-700 text-white text-xs font-black uppercase tracking-wide px-4 py-2 rounded-xl flex items-center gap-2 disabled:opacity-50">
|
||||
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />} Salvar
|
||||
</button>
|
||||
</div>
|
||||
{dentSel ? <GradeSemanal faixas={dentFaixas} onChange={setDentFaixas} withFlex /> : <p className="text-xs text-gray-400">Nenhum dentista.</p>}
|
||||
<p className="text-[11px] text-gray-400 italic">A IA agenda LIVRE na janela principal. Na janela "aceito confirmar" (mais cedo/mais tarde), ela não confirma sozinha — cria um pedido para a secretária humana decidir.</p>
|
||||
|
||||
{dentSel && (
|
||||
<section className="pt-4 border-t border-gray-100">
|
||||
<h3 className="text-sm font-black text-gray-800 uppercase tracking-tight flex items-center gap-2 mb-3"><CalendarOff size={16} className="text-teal-600" /> Folgas e férias (não atende nestas datas)</h3>
|
||||
<div className="flex flex-wrap items-end gap-2 mb-3 bg-gray-50 p-4 rounded-2xl">
|
||||
<div><label className="text-[9px] font-black text-gray-400 uppercase block mb-1">De</label>
|
||||
<input type="date" value={novaFolga.data_inicio} onChange={(e) => setNovaFolga({ ...novaFolga, data_inicio: e.target.value })} className="bg-white border-2 border-transparent focus:border-teal-600 rounded-xl px-3 py-2 text-sm font-bold outline-none" /></div>
|
||||
<div><label className="text-[9px] font-black text-gray-400 uppercase block mb-1">Até (opcional)</label>
|
||||
<input type="date" value={novaFolga.data_fim} onChange={(e) => setNovaFolga({ ...novaFolga, data_fim: e.target.value })} className="bg-white border-2 border-transparent focus:border-teal-600 rounded-xl px-3 py-2 text-sm font-bold outline-none" /></div>
|
||||
<div className="flex-1 min-w-[140px]"><label className="text-[9px] font-black text-gray-400 uppercase block mb-1">Motivo</label>
|
||||
<input type="text" placeholder="Ex: Férias, congresso" value={novaFolga.motivo} onChange={(e) => setNovaFolga({ ...novaFolga, motivo: e.target.value })} className="w-full bg-white border-2 border-transparent focus:border-teal-600 rounded-xl px-3 py-2 text-sm font-bold outline-none" /></div>
|
||||
<button onClick={addFolga} className="bg-gray-900 hover:bg-black text-white text-xs font-black uppercase px-4 py-2.5 rounded-xl flex items-center gap-1"><Plus size={14} /> Adicionar</button>
|
||||
</div>
|
||||
{folgas.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{folgas.map((f) => (
|
||||
<div key={f.id} className="flex items-center justify-between bg-amber-50 rounded-xl px-3 py-2">
|
||||
<span className="text-xs font-bold text-amber-700">
|
||||
{f.data_inicio.split('-').reverse().join('/')}{f.data_fim !== f.data_inicio ? ` até ${f.data_fim.split('-').reverse().join('/')}` : ''}{f.motivo ? ` · ${f.motivo}` : ''}
|
||||
</span>
|
||||
<button onClick={() => delFolga(f.id)} className="text-amber-300 hover:text-amber-600"><Trash2 size={15} /></button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : <p className="text-[11px] text-gray-400 italic">Nenhuma folga futura cadastrada.</p>}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-black text-gray-800 uppercase tracking-tight flex items-center gap-2"><AlertTriangle size={16} className="text-teal-600" /> Situações e regras da Secretária IA</h3>
|
||||
<button onClick={salvarSituacoes} disabled={saving} className="bg-teal-600 hover:bg-teal-700 text-white text-xs font-black uppercase tracking-wide px-4 py-2 rounded-xl flex items-center gap-2 disabled:opacity-50">
|
||||
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />} Salvar
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[11px] text-gray-500 leading-relaxed bg-amber-50 rounded-2xl p-4">
|
||||
Escreva regras que a Secretária IA deve seguir ao atender — uma por caixa. Ex.: <span className="italic">"Pelo plano X não temos profissional para canal em dente posterior; não agende direto — ofereça uma avaliação para o dentista examinar melhor o dente."</span> Quando uma situação exigir decisão humana, a IA encaminha o caso e pede para o paciente aguardar (aparece em Pedidos pendentes).
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{situacoes.map((s, i) => (
|
||||
<div key={i} className="flex items-start gap-2">
|
||||
<textarea value={s} rows={2} onChange={(e) => setSituacoes(situacoes.map((x, j) => j === i ? e.target.value : x))}
|
||||
placeholder="Descreva a situação e o que a IA deve fazer…"
|
||||
className="flex-1 bg-gray-50 border-2 border-transparent focus:border-teal-600 rounded-xl px-3 py-2 text-sm font-medium text-gray-800 outline-none resize-y" />
|
||||
<button onClick={() => setSituacoes(situacoes.filter((_, j) => j !== i))} className="mt-2 text-gray-300 hover:text-red-600"><Trash2 size={16} /></button>
|
||||
</div>
|
||||
))}
|
||||
<button onClick={() => setSituacoes([...situacoes, ''])} className="bg-gray-900 hover:bg-black text-white text-xs font-black uppercase px-4 py-2.5 rounded-xl flex items-center gap-1"><Plus size={14} /> Nova situação</button>
|
||||
</div>
|
||||
{situacoes.length === 0 && <p className="text-[11px] text-gray-400 italic">Nenhuma situação cadastrada. A IA seguirá apenas o roteiro padrão.</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,141 @@
|
||||
// Fila da secretária humana: pedidos de horário da ZONA CINZA que a Secretária IA
|
||||
// registrou ("vou confirmar e retorno"). A humana confirma (vira agendamento) ou
|
||||
// recusa. Consome /api/nw/agenda-config/clinica/:id/pedidos*.
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { X, CalendarClock, Check, Ban, Loader2, Phone, User, HelpCircle } from 'lucide-react';
|
||||
|
||||
const API = (import.meta as any).env?.VITE_API_URL || '/api';
|
||||
const BASE = `${API}/nw/agenda-config`;
|
||||
|
||||
async function req(path: string, opts: RequestInit = {}) {
|
||||
const token = localStorage.getItem('SCOREODONTO_AUTH_TOKEN');
|
||||
const res = await fetch(`${BASE}${path}`, { ...opts, headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(opts.headers || {}) } });
|
||||
const t = await res.text(); const d = t ? JSON.parse(t) : {};
|
||||
if (!res.ok) throw new Error(d.error || `Erro ${res.status}`);
|
||||
return d;
|
||||
}
|
||||
|
||||
const dm = (s: string) => s ? s.split('-').reverse().join('/') : s;
|
||||
|
||||
export const PedidosPendentes: React.FC<{ isOpen: boolean; onClose: () => void; clinicaId?: string; onChange?: () => void }> = ({ isOpen, onClose, clinicaId, onChange }) => {
|
||||
const [pedidos, setPedidos] = useState<any[]>([]);
|
||||
const [dentistas, setDentistas] = useState<any[]>([]);
|
||||
const [sel, setSel] = useState<Record<string, string>>({}); // pedido_id → dentista_id escolhido
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [erro, setErro] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const carregar = useCallback(async () => {
|
||||
if (!clinicaId) return;
|
||||
setLoading(true); setErro(null);
|
||||
try {
|
||||
const p = await req(`/clinica/${clinicaId}/pedidos?status=pendente`);
|
||||
setPedidos(p.pedidos || []);
|
||||
const d = await req(`/clinica/${clinicaId}/dentistas`);
|
||||
setDentistas(d.dentistas || []);
|
||||
} catch (e: any) { setErro(e.message); } finally { setLoading(false); }
|
||||
}, [clinicaId]);
|
||||
|
||||
useEffect(() => { if (isOpen) carregar(); }, [isOpen, carregar]);
|
||||
|
||||
const confirmar = async (p: any) => {
|
||||
const dentistaId = p.dentista_id || sel[p.id];
|
||||
if (!dentistaId) { setErro('Escolha o dentista para confirmar.'); return; }
|
||||
setBusy(p.id); setErro(null);
|
||||
try { await req(`/clinica/${clinicaId}/pedidos/${p.id}/confirmar`, { method: 'POST', body: JSON.stringify({ dentista_id: dentistaId }) }); await carregar(); onChange?.(); }
|
||||
catch (e: any) { setErro(e.message); } finally { setBusy(null); }
|
||||
};
|
||||
const recusar = async (p: any) => {
|
||||
setBusy(p.id); setErro(null);
|
||||
try { await req(`/clinica/${clinicaId}/pedidos/${p.id}/recusar`, { method: 'POST' }); await carregar(); onChange?.(); }
|
||||
catch (e: any) { setErro(e.message); } finally { setBusy(null); }
|
||||
};
|
||||
const resolver = async (p: any) => {
|
||||
setBusy(p.id); setErro(null);
|
||||
try { await req(`/clinica/${clinicaId}/pedidos/${p.id}/resolver`, { method: 'POST' }); await carregar(); onChange?.(); }
|
||||
catch (e: any) { setErro(e.message); } finally { setBusy(null); }
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 z-[70] flex items-center justify-center backdrop-blur-md p-0">
|
||||
<div className="bg-white shadow-2xl w-full h-dvh md:w-[92vw] md:max-w-3xl md:h-[90vh] rounded-none md:rounded-[2rem] flex flex-col overflow-hidden border border-gray-100">
|
||||
<div className="px-8 py-6 border-b border-gray-50 flex justify-between items-center bg-gradient-to-br from-gray-50 to-white">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 bg-amber-500 rounded-2xl shadow-lg shadow-amber-200"><CalendarClock size={22} className="text-white" /></div>
|
||||
<div>
|
||||
<h2 className="text-lg font-black text-gray-900 uppercase tracking-tighter">Pedidos aguardando confirmação</h2>
|
||||
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mt-0.5">Horários fora do padrão que a Secretária IA encaminhou</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-xl text-gray-400"><X size={22} /></button>
|
||||
</div>
|
||||
|
||||
{erro && <div className="px-8 py-2 text-xs font-bold bg-red-50 text-red-600">{erro}</div>}
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-3">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16"><Loader2 size={22} className="animate-spin text-gray-400" /></div>
|
||||
) : pedidos.length === 0 ? (
|
||||
<p className="text-center text-sm text-gray-400 py-16">Nenhum pedido pendente. 🎉</p>
|
||||
) : pedidos.map((p) => p.tipo === 'duvida' ? (
|
||||
<div key={p.id} className="border-2 border-indigo-100 rounded-2xl p-4 bg-indigo-50/40">
|
||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div className="space-y-1.5 max-w-[75%]">
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-black text-indigo-600 uppercase tracking-widest"><HelpCircle size={13} /> Dúvida p/ decidir</div>
|
||||
<div className="text-sm font-bold text-gray-800 leading-snug whitespace-pre-wrap">{p.resumo}</div>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-600 font-bold"><User size={13} className="text-gray-400" /> {p.paciente_nome || 'Cliente'}
|
||||
{p.paciente_telefone && <span className="flex items-center gap-1 text-gray-400"><Phone size={12} /> {p.paciente_telefone}</span>}</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => resolver(p)} disabled={busy === p.id}
|
||||
className="bg-indigo-600 hover:bg-indigo-700 text-white text-[11px] font-black uppercase px-3 py-1.5 rounded-xl flex items-center gap-1 disabled:opacity-50">
|
||||
{busy === p.id ? <Loader2 size={13} className="animate-spin" /> : <Check size={13} />} Resolvida
|
||||
</button>
|
||||
<button onClick={() => recusar(p)} disabled={busy === p.id}
|
||||
className="bg-gray-100 hover:bg-red-50 text-gray-500 hover:text-red-600 text-[11px] font-black uppercase px-3 py-1.5 rounded-xl flex items-center gap-1">
|
||||
<Ban size={13} /> Descartar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div key={p.id} className="border border-gray-200 rounded-2xl p-4 bg-white">
|
||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-black text-gray-800">
|
||||
{dm(p.data)} às {p.hora_inicio}{p.hora_fim ? `–${p.hora_fim}` : ''}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-600 font-bold"><User size={13} className="text-gray-400" /> {p.paciente_nome || 'Cliente'}
|
||||
{p.paciente_telefone && <span className="flex items-center gap-1 text-gray-400"><Phone size={12} /> {p.paciente_telefone}</span>}</div>
|
||||
{p.procedimento && <div className="text-[11px] text-gray-500">{p.procedimento}</div>}
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
{p.dentista_id ? (
|
||||
<span className="text-[11px] font-bold text-teal-600">{p.dentista_nome}</span>
|
||||
) : (
|
||||
<select value={sel[p.id] || ''} onChange={(e) => setSel({ ...sel, [p.id]: e.target.value })}
|
||||
className="bg-gray-50 border-2 border-transparent focus:border-teal-600 rounded-xl px-2 py-1.5 text-xs font-bold text-gray-800 outline-none">
|
||||
<option value="">Escolher dentista…</option>
|
||||
{dentistas.map((d) => <option key={d.id} value={d.id}>{d.nome}</option>)}
|
||||
</select>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => confirmar(p)} disabled={busy === p.id}
|
||||
className="bg-emerald-600 hover:bg-emerald-700 text-white text-[11px] font-black uppercase px-3 py-1.5 rounded-xl flex items-center gap-1 disabled:opacity-50">
|
||||
{busy === p.id ? <Loader2 size={13} className="animate-spin" /> : <Check size={13} />} Confirmar
|
||||
</button>
|
||||
<button onClick={() => recusar(p)} disabled={busy === p.id}
|
||||
className="bg-gray-100 hover:bg-red-50 text-gray-500 hover:text-red-600 text-[11px] font-black uppercase px-3 py-1.5 rounded-xl flex items-center gap-1">
|
||||
<Ban size={13} /> Recusar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,165 @@
|
||||
// Área de PENDÊNCIAS DO DONO do workspace (só o dono vê). Engloba tudo:
|
||||
// 1) Checklist da clínica — perguntas Sim/Não que o dono responde;
|
||||
// 2) Pendências de configuração — quando a resposta não bate com o banco
|
||||
// (ex.: marcou "mais de um dentista" mas só há 1 cadastrado);
|
||||
// 3) Fila operacional — resumo dos pedidos/dúvidas aguardando a secretária humana.
|
||||
// Consome /api/nw/agenda-config/clinica/:id/{checklist,pendencias}.
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { X, ClipboardCheck, AlertTriangle, Check, Ban, Loader2, Save, CalendarClock, HelpCircle, ChevronRight } from 'lucide-react';
|
||||
|
||||
const API = (import.meta as any).env?.VITE_API_URL || '/api';
|
||||
const BASE = `${API}/nw/agenda-config`;
|
||||
|
||||
async function req(path: string, opts: RequestInit = {}) {
|
||||
const token = localStorage.getItem('SCOREODONTO_AUTH_TOKEN');
|
||||
const res = await fetch(`${BASE}${path}`, { ...opts, headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(opts.headers || {}) } });
|
||||
const t = await res.text(); const d = t ? JSON.parse(t) : {};
|
||||
if (!res.ok) throw new Error(d.error || `Erro ${res.status}`);
|
||||
return d;
|
||||
}
|
||||
|
||||
type Questao = { chave: string; pergunta: string; ajuda?: string; resposta: number | null };
|
||||
type PendConfig = { chave: string; severidade: string; titulo: string; detalhe: string };
|
||||
|
||||
export const PendenciasDono: React.FC<{ isOpen: boolean; onClose: () => void; clinicaId?: string; onOpenFila?: () => void }> = ({ isOpen, onClose, clinicaId, onOpenFila }) => {
|
||||
const [questoes, setQuestoes] = useState<Questao[]>([]);
|
||||
const [config, setConfig] = useState<PendConfig[]>([]);
|
||||
const [oper, setOper] = useState<{ horarios: number; duvidas: number; total: number }>({ horarios: 0, duvidas: 0, total: 0 });
|
||||
const [contagens, setContagens] = useState<{ dentistas: number; pacientes: number }>({ dentistas: 0, pacientes: 0 });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [erro, setErro] = useState<string | null>(null);
|
||||
const [ok, setOk] = useState<string | null>(null);
|
||||
|
||||
const flash = (setter: (v: string | null) => void, m: string) => { setter(m); setTimeout(() => setter(null), 3000); };
|
||||
|
||||
const carregar = useCallback(async () => {
|
||||
if (!clinicaId) return;
|
||||
setLoading(true); setErro(null);
|
||||
try {
|
||||
const c = await req(`/clinica/${clinicaId}/checklist`);
|
||||
setQuestoes(c.questoes || []);
|
||||
const p = await req(`/clinica/${clinicaId}/pendencias`);
|
||||
setConfig(p.config || []);
|
||||
setOper(p.operacionais || { horarios: 0, duvidas: 0, total: 0 });
|
||||
setContagens(p.contagens || { dentistas: 0, pacientes: 0 });
|
||||
} catch (e: any) { setErro(e.message); } finally { setLoading(false); }
|
||||
}, [clinicaId]);
|
||||
|
||||
useEffect(() => { if (isOpen) carregar(); }, [isOpen, carregar]);
|
||||
|
||||
const setResposta = (chave: string, resposta: number | null) =>
|
||||
setQuestoes((qs) => qs.map((q) => q.chave === chave ? { ...q, resposta } : q));
|
||||
|
||||
const salvar = async () => {
|
||||
setSaving(true); setErro(null);
|
||||
try {
|
||||
const respostas: Record<string, number | null> = {};
|
||||
questoes.forEach((q) => { respostas[q.chave] = q.resposta; });
|
||||
await req(`/clinica/${clinicaId}/checklist`, { method: 'PUT', body: JSON.stringify({ respostas }) });
|
||||
flash(setOk, 'Checklist salvo!');
|
||||
await carregar();
|
||||
} catch (e: any) { setErro(e.message); } finally { setSaving(false); }
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
const sevColor = (s: string) => s === 'alta' ? 'border-red-200 bg-red-50' : 'border-amber-200 bg-amber-50';
|
||||
const sevText = (s: string) => s === 'alta' ? 'text-red-700' : 'text-amber-700';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 z-[70] flex items-center justify-center backdrop-blur-md p-0">
|
||||
<div className="bg-white shadow-2xl w-full h-dvh md:w-[92vw] md:max-w-3xl md:h-[92vh] rounded-none md:rounded-[2rem] flex flex-col overflow-hidden border border-gray-100">
|
||||
<div className="px-8 py-6 border-b border-gray-50 flex justify-between items-center bg-gradient-to-br from-gray-50 to-white">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 bg-indigo-600 rounded-2xl shadow-lg shadow-indigo-200"><ClipboardCheck size={22} className="text-white" /></div>
|
||||
<div>
|
||||
<h2 className="text-lg font-black text-gray-900 uppercase tracking-tighter">Pendências do workspace</h2>
|
||||
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mt-0.5">Config da clínica + o que a Secretária precisa que você resolva</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-xl text-gray-400"><X size={22} /></button>
|
||||
</div>
|
||||
|
||||
{(erro || ok) && <div className={`px-8 py-2 text-xs font-bold ${erro ? 'bg-red-50 text-red-600' : 'bg-green-50 text-green-600'}`}>{erro || ok}</div>}
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-8">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16"><Loader2 size={22} className="animate-spin text-gray-400" /></div>
|
||||
) : (
|
||||
<>
|
||||
{/* Fila operacional */}
|
||||
<section>
|
||||
<h3 className="text-xs font-black text-gray-500 uppercase tracking-widest mb-3">Aguardando a secretária humana</h3>
|
||||
<button onClick={() => onOpenFila?.()} disabled={!onOpenFila}
|
||||
className="w-full flex items-center justify-between gap-3 border-2 border-amber-100 bg-amber-50/50 rounded-2xl px-4 py-3.5 text-left hover:bg-amber-50 disabled:cursor-default disabled:hover:bg-amber-50/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<CalendarClock size={20} className="text-amber-600" />
|
||||
<div>
|
||||
<div className="text-sm font-black text-gray-800">{oper.total} pedido(s) na fila</div>
|
||||
<div className="text-[11px] text-gray-500 font-bold">{oper.horarios} horário(s) · {oper.duvidas} dúvida(s)</div>
|
||||
</div>
|
||||
</div>
|
||||
{onOpenFila && <ChevronRight size={18} className="text-amber-400" />}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{/* Pendências de configuração */}
|
||||
<section>
|
||||
<h3 className="text-xs font-black text-gray-500 uppercase tracking-widest mb-3">Configuração a resolver</h3>
|
||||
{config.length === 0 ? (
|
||||
<p className="text-sm text-gray-400 flex items-center gap-2 py-2"><Check size={16} className="text-emerald-500" /> Nenhuma pendência de configuração. 🎉</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{config.map((p, i) => (
|
||||
<div key={i} className={`border-2 rounded-2xl p-4 ${sevColor(p.severidade)}`}>
|
||||
<div className={`flex items-center gap-1.5 text-[10px] font-black uppercase tracking-widest ${sevText(p.severidade)}`}><AlertTriangle size={13} /> {p.severidade === 'alta' ? 'Importante' : 'Atenção'}</div>
|
||||
<div className="text-sm font-black text-gray-800 mt-1">{p.titulo}</div>
|
||||
<div className="text-[12px] text-gray-600 mt-0.5 leading-snug">{p.detalhe}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[10px] text-gray-400 mt-2">No sistema: {contagens.dentistas} dentista(s) · {contagens.pacientes} paciente(s) cadastrado(s).</p>
|
||||
</section>
|
||||
|
||||
{/* Checklist */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-xs font-black text-gray-500 uppercase tracking-widest">Checklist da clínica</h3>
|
||||
<button onClick={salvar} disabled={saving} className="bg-indigo-600 hover:bg-indigo-700 text-white text-[11px] font-black uppercase tracking-wide px-4 py-2 rounded-xl flex items-center gap-2 disabled:opacity-50">
|
||||
{saving ? <Loader2 size={13} className="animate-spin" /> : <Save size={13} />} Salvar
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[11px] text-gray-500 bg-gray-50 rounded-2xl p-3 mb-3 leading-relaxed">
|
||||
Responda sobre a clínica. A Secretária usa isto para saber quando pode agendar sozinha — quando algo não bate com o cadastro, ela evita agendar e encaminha ao humano, e aparece aqui em cima como pendência.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{questoes.map((q) => (
|
||||
<div key={q.chave} className="border border-gray-200 rounded-2xl p-4">
|
||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<div className="text-sm font-black text-gray-800 flex items-center gap-1.5"><HelpCircle size={14} className="text-gray-300" /> {q.pergunta}</div>
|
||||
{q.ajuda && <div className="text-[11px] text-gray-400 mt-1 leading-snug">{q.ajuda}</div>}
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<button onClick={() => setResposta(q.chave, q.resposta === 1 ? null : 1)}
|
||||
className={`text-[11px] font-black uppercase px-3 py-1.5 rounded-xl flex items-center gap-1 transition-colors ${q.resposta === 1 ? 'bg-emerald-600 text-white' : 'bg-gray-100 text-gray-500 hover:bg-emerald-50'}`}>
|
||||
<Check size={13} /> Sim
|
||||
</button>
|
||||
<button onClick={() => setResposta(q.chave, q.resposta === 0 ? null : 0)}
|
||||
className={`text-[11px] font-black uppercase px-3 py-1.5 rounded-xl flex items-center gap-1 transition-colors ${q.resposta === 0 ? 'bg-red-600 text-white' : 'bg-gray-100 text-gray-500 hover:bg-red-50'}`}>
|
||||
<Ban size={13} /> Não
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,127 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Search, X, Star, Check, ShieldCheck, UserPlus, Building2 } from 'lucide-react';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { useToast } from '../contexts/ToastContext.tsx';
|
||||
|
||||
// Seletor de protético em modal: busca por nome/e-mail, favorito (★), padrão (pré-selecionado)
|
||||
// e "adicionar por e-mail" (vincula como interno — resolve o protético que não está no diretório).
|
||||
export const ProteticoPicker: React.FC<{ value: string; onChange: (id: string, lab?: any) => void; className?: string }> = ({ value, onChange, className }) => {
|
||||
const toast = useToast();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [labs, setLabs] = useState<any[]>([]);
|
||||
const [busca, setBusca] = useState('');
|
||||
const [addEmail, setAddEmail] = useState('');
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const preselected = useRef(false);
|
||||
|
||||
const carregar = useCallback(async (): Promise<any[]> => {
|
||||
try { const r = await HybridBackend.getProteseLaboratorios(); const arr = Array.isArray(r) ? r : []; setLabs(arr); return arr; }
|
||||
catch { setLabs([]); return []; }
|
||||
}, []);
|
||||
useEffect(() => { carregar(); }, [carregar]);
|
||||
|
||||
// Pré-seleciona o protético PADRÃO uma única vez, quando nada foi escolhido ainda.
|
||||
useEffect(() => {
|
||||
if (preselected.current || value) return;
|
||||
const pad = labs.find(l => l.padrao);
|
||||
if (pad) { preselected.current = true; onChange(pad.id, pad); }
|
||||
}, [labs, value]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const sel = labs.find(l => l.id === value);
|
||||
const q = busca.toLowerCase();
|
||||
const filtrados = labs.filter(l => !q || (l.nome || '').toLowerCase().includes(q) || (l.email || '').toLowerCase().includes(q));
|
||||
|
||||
const toggleFav = async (l: any, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
try { await HybridBackend.setProteticoPref(l.id, { favorito: !l.favorito }); await carregar(); }
|
||||
catch { toast.error('ERRO'); }
|
||||
};
|
||||
const tornarPadrao = async (l: any, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
try { await HybridBackend.setProteticoPref(l.id, { padrao: !l.padrao }); await carregar(); toast.success(l.padrao ? 'PADRÃO REMOVIDO.' : 'DEFINIDO COMO PADRÃO.'); }
|
||||
catch { toast.error('ERRO'); }
|
||||
};
|
||||
const selecionar = (l: any) => { onChange(l.id, l); setOpen(false); setBusca(''); };
|
||||
const adicionar = async () => {
|
||||
if (!addEmail.trim()) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await HybridBackend.vincularProteticoEmail(addEmail.trim());
|
||||
if (r?.error) throw new Error(r.error);
|
||||
toast.success('PROTÉTICO VINCULADO.');
|
||||
setAddEmail(''); setShowAdd(false);
|
||||
const novos = await carregar();
|
||||
const novo = novos.find((x: any) => x.id === r.protetico?.id);
|
||||
if (novo) selecionar(novo);
|
||||
} catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setOpen(true)}
|
||||
className={className || 'w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl font-black text-gray-700 text-sm text-left flex items-center justify-between gap-2 uppercase focus:border-teal-400 outline-none transition-all'}>
|
||||
<span className="truncate flex items-center gap-2">
|
||||
{sel ? (<><Building2 size={16} className="text-teal-600 shrink-0" /> {sel.nome}{sel.padrao ? ' · PADRÃO' : ''}</>) : 'Escolha o laboratório / protético...'}
|
||||
</span>
|
||||
<Search size={16} className="text-gray-400 shrink-0" />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="fixed inset-0 bg-black/40 z-[60] flex items-center justify-center p-4" onClick={() => setOpen(false)}>
|
||||
<div className="bg-white rounded-2xl w-full max-w-md max-h-[88vh] flex flex-col" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-100">
|
||||
<h3 className="font-black text-gray-800 uppercase text-sm">Escolher protético</h3>
|
||||
<button onClick={() => setOpen(false)}><X size={20} className="text-gray-400" /></button>
|
||||
</div>
|
||||
<div className="p-4 border-b border-gray-50">
|
||||
<div className="relative">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-300" />
|
||||
<input autoFocus value={busca} onChange={e => setBusca(e.target.value)} placeholder="Buscar por nome ou e-mail…"
|
||||
className="w-full pl-9 pr-3 py-2.5 rounded-xl border border-gray-200 text-sm outline-none focus:border-teal-400" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{filtrados.length === 0 ? (
|
||||
<p className="text-center text-gray-400 text-sm font-bold uppercase py-10">Nenhum protético encontrado.</p>
|
||||
) : filtrados.map(l => (
|
||||
<div key={l.id} onClick={() => selecionar(l)}
|
||||
className={`flex items-center gap-2 px-3 py-2.5 rounded-xl cursor-pointer ${l.id === value ? 'bg-teal-50 border border-teal-200' : 'hover:bg-gray-50'}`}>
|
||||
<button onClick={e => toggleFav(l, e)} title="Favorito" className="shrink-0">
|
||||
<Star size={16} className={l.favorito ? 'text-amber-400 fill-amber-400' : 'text-gray-300'} />
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-black text-gray-800 text-sm truncate uppercase flex items-center gap-1.5">
|
||||
{l.nome}{l.verificado && <ShieldCheck size={13} className="text-blue-500 shrink-0" />}
|
||||
</p>
|
||||
<p className="text-[10px] font-bold text-gray-400 uppercase truncate flex items-center gap-1.5">
|
||||
<span className={l.interno ? 'text-teal-600' : ''}>{l.interno ? 'Interno' : 'Diretório'}</span>
|
||||
{l.nota != null && <span>· ★ {l.nota}</span>}
|
||||
{l.padrao && <span className="text-violet-600">· Padrão</span>}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={e => tornarPadrao(l, e)} title="Definir como padrão"
|
||||
className={`shrink-0 text-[9px] font-black uppercase px-2 py-1 rounded-lg border ${l.padrao ? 'border-violet-300 bg-violet-50 text-violet-700' : 'border-gray-200 text-gray-400'}`}>Padrão</button>
|
||||
{l.id === value && <Check size={16} className="text-teal-600 shrink-0" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="p-4 border-t border-gray-100">
|
||||
{showAdd ? (
|
||||
<div className="flex gap-2">
|
||||
<input value={addEmail} onChange={e => setAddEmail(e.target.value)} type="email" placeholder="e-mail do protético"
|
||||
className="flex-1 px-3 py-2 rounded-lg border border-gray-200 text-sm outline-none focus:border-teal-400" />
|
||||
<button disabled={busy} onClick={adicionar} className="px-3 rounded-lg bg-teal-600 text-white text-xs font-black uppercase disabled:opacity-50">Vincular</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setShowAdd(true)} className="w-full py-2.5 rounded-xl border border-dashed border-gray-300 text-gray-500 text-xs font-black uppercase flex items-center justify-center gap-2">
|
||||
<UserPlus size={14} /> Não encontrou? Adicionar por e-mail
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Search, X, Check, ChevronDown } from 'lucide-react';
|
||||
|
||||
export interface SelectOption { value: string; label: string; hint?: string }
|
||||
|
||||
// Dropdown genérico que abre um modal com busca — substitui <select> em listas longas.
|
||||
export const SearchSelect: React.FC<{
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: SelectOption[];
|
||||
placeholder?: string;
|
||||
title?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}> = ({ value, onChange, options, placeholder = 'Selecione...', title, className, disabled }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [busca, setBusca] = useState('');
|
||||
const sel = options.find(o => o.value === value);
|
||||
const q = busca.trim().toLowerCase();
|
||||
const filtrados = q ? options.filter(o => o.label.toLowerCase().includes(q) || (o.hint || '').toLowerCase().includes(q)) : options;
|
||||
const pick = (v: string) => { onChange(v); setOpen(false); setBusca(''); };
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" disabled={disabled} onClick={() => setOpen(true)}
|
||||
className={className || 'w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl font-black text-gray-700 text-sm focus:border-teal-400 outline-none transition-all uppercase disabled:opacity-50'}>
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<span className={`truncate ${sel ? '' : 'text-gray-400'}`}>{sel ? sel.label : placeholder}</span>
|
||||
<ChevronDown size={16} className="text-gray-400 shrink-0" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="fixed inset-0 bg-black/40 z-[60] flex items-center justify-center p-4" onClick={() => setOpen(false)}>
|
||||
<div className="bg-white rounded-2xl w-full max-w-md max-h-[85vh] flex flex-col" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-100">
|
||||
<h3 className="font-black text-gray-800 uppercase text-sm">{title || placeholder}</h3>
|
||||
<button onClick={() => setOpen(false)}><X size={20} className="text-gray-400" /></button>
|
||||
</div>
|
||||
<div className="p-3 border-b border-gray-50">
|
||||
<div className="relative">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-300" />
|
||||
<input autoFocus value={busca} onChange={e => setBusca(e.target.value)} placeholder="Buscar…"
|
||||
className="w-full pl-9 pr-3 py-2.5 rounded-xl border border-gray-200 text-sm outline-none focus:border-teal-400" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{filtrados.length === 0 ? (
|
||||
<p className="text-center text-gray-400 text-sm font-bold uppercase py-10">Nada encontrado.</p>
|
||||
) : filtrados.map(o => (
|
||||
<button key={o.value} onClick={() => pick(o.value)}
|
||||
className={`w-full text-left flex items-center gap-2 px-3 py-2.5 rounded-xl ${o.value === value ? 'bg-teal-50 border border-teal-200' : 'hover:bg-gray-50'}`}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-bold text-gray-800 text-sm truncate uppercase">{o.label}</p>
|
||||
{o.hint && <p className="text-[11px] text-gray-400 truncate">{o.hint}</p>}
|
||||
</div>
|
||||
{o.value === value && <Check size={16} className="text-teal-600 shrink-0" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,12 +1,12 @@
|
||||
import React from 'react';
|
||||
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
|
||||
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';
|
||||
import { getActivePlugins } from '../views/plugins/pluginRegistry.ts';
|
||||
import { getActivePlugins, isPluginActive } from '../views/plugins/pluginRegistry.ts';
|
||||
|
||||
interface SidebarProps {
|
||||
activeTab: string;
|
||||
@@ -25,6 +25,19 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
|
||||
try { return JSON.parse(localStorage.getItem('SCOREODONTO_USER_DATA') || '{}').is_tutor === true; } catch { return false; }
|
||||
})();
|
||||
|
||||
// Acesso do usuário às áreas do WhatsApp (Inbox/Secretária). null = ainda carregando
|
||||
// (fail-open: mostra até saber). Dono e autorizados veem; os demais não.
|
||||
const [waAccess, setWaAccess] = useState<{ isOwner: boolean; inbox: boolean; secretaria: boolean; delete_msg: boolean } | null>(null);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
if (isPluginActive('newwhats')) {
|
||||
HybridBackend.getWaAccess().then(a => { if (alive) setWaAccess(a); }).catch(() => {});
|
||||
}
|
||||
return () => { alive = false; };
|
||||
}, [activeWorkspace?.id]);
|
||||
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')) {
|
||||
// Radiografias são ODONTOLÓGICAS: só papéis odonto (dentista + donos/funcionário).
|
||||
@@ -37,6 +50,14 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
|
||||
pluginMenuItems.push({ id: 'rx-config', label: 'DISPOSITIVOS RX', icon: Monitor, color: '#7c3aed' });
|
||||
}
|
||||
}
|
||||
// NewWhats: Inbox + Secretária IA — para TODOS os usuários quando o plugin está
|
||||
// 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' });
|
||||
}
|
||||
// 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
|
||||
// a paciente. Funcionário, quando muito, opera a AGENDA da sala (workspace tipo='sala'), não
|
||||
@@ -62,6 +83,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
|
||||
{ id: 'superadmin-logs', label: 'ACESSOS', icon: Activity },
|
||||
{ id: 'superadmin-financeiro', label: 'FINANCEIRO', icon: DollarSign },
|
||||
{ id: 'superadmin-notificacoes', label: 'NOTIFICAÇÕES', icon: Bell },
|
||||
{ id: 'superadmin-secretaria', label: 'SECRETÁRIA IA', icon: Power },
|
||||
{ id: 'sala-home', label: 'PAINEL DA SALA', icon: DoorOpen },
|
||||
{ id: 'lab-home', label: 'PAINEL DO LAB', icon: LayoutDashboard },
|
||||
{ id: 'lab-bancada', label: 'BANCADA', icon: FlaskConical },
|
||||
|
||||
@@ -1,5 +1,46 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* ── Tema do motor NewWhats (plugin newwhats) — tokens Tailwind v4 ──────────── */
|
||||
/* Portados do tailwind.config.js do motor para as páginas inbox/sessions/ */
|
||||
/* secretária renderizarem idênticas ao motor. */
|
||||
@theme {
|
||||
--color-brand-50: #eff6ff;
|
||||
--color-brand-100: #dbeafe;
|
||||
--color-brand-200: #bfdbfe;
|
||||
--color-brand-300: #93c5fd;
|
||||
--color-brand-400: #60a5fa;
|
||||
--color-brand-500: #3b82f6;
|
||||
--color-brand-600: #2563eb;
|
||||
--color-brand-700: #1d4ed8;
|
||||
--color-brand-800: #1e40af;
|
||||
--color-brand-900: #1e3a8a;
|
||||
--color-brand-950: #172554;
|
||||
|
||||
--color-surface: #070b14;
|
||||
--color-surface-raised: #0c1220;
|
||||
--color-surface-overlay: #111827;
|
||||
|
||||
--radius-4xl: 2rem;
|
||||
|
||||
--shadow-glow-brand: 0 0 24px rgba(59, 130, 246, 0.15);
|
||||
--shadow-glow-sm: 0 0 12px rgba(59, 130, 246, 0.1);
|
||||
--shadow-premium: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
|
||||
--animate-shimmer: shimmer 3s linear infinite;
|
||||
--animate-pulse-slow: pulse 4s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
--animate-float: float 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(100%); }
|
||||
}
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-10px); }
|
||||
}
|
||||
|
||||
/* ScoreOdonto Base Styles */
|
||||
|
||||
body {
|
||||
|
||||
Generated
+239
-19
@@ -15,6 +15,9 @@
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"date-fns": "^4.1.0",
|
||||
"framer-motion": "^12.42.2",
|
||||
"immer": "^11.1.8",
|
||||
"jspdf": "^2.5.2",
|
||||
"lucide-react": "^0.563.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
@@ -734,15 +737,6 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@fullcalendar/core": {
|
||||
"version": "6.1.20",
|
||||
"resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.20.tgz",
|
||||
"integrity": "sha512-1cukXLlePFiJ8YKXn/4tMKsy0etxYLCkXk8nUCFi11nRONF2Ba2CD5b21/ovtOO2tL6afTJfwmc1ed3HG7eB1g==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"preact": "~10.12.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@fullcalendar/daygrid": {
|
||||
"version": "6.1.20",
|
||||
"resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.20.tgz",
|
||||
@@ -1522,11 +1516,17 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/raf": {
|
||||
"version": "3.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
|
||||
"integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -1565,6 +1565,17 @@
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/atob": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz",
|
||||
"integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==",
|
||||
"bin": {
|
||||
"atob": "bin/atob.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 4.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/autoprefixer": {
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz",
|
||||
@@ -1601,6 +1612,15 @@
|
||||
"postcss": "^8.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-arraybuffer": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
|
||||
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.29",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz",
|
||||
@@ -1646,6 +1666,17 @@
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/btoa": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/btoa/-/btoa-1.2.1.tgz",
|
||||
"integrity": "sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==",
|
||||
"bin": {
|
||||
"btoa": "bin/btoa.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001792",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz",
|
||||
@@ -1666,12 +1697,42 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"node_modules/canvg": {
|
||||
"version": "3.0.11",
|
||||
"resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
|
||||
"integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@types/raf": "^3.4.0",
|
||||
"core-js": "^3.8.3",
|
||||
"raf": "^3.4.1",
|
||||
"regenerator-runtime": "^0.13.7",
|
||||
"rgbcolor": "^1.0.1",
|
||||
"stackblur-canvas": "^2.0.0",
|
||||
"svg-pathdata": "^6.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/core-js": {
|
||||
"version": "3.49.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
|
||||
"integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
|
||||
"hasInstallScript": true,
|
||||
"optional": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/core-js"
|
||||
}
|
||||
},
|
||||
"node_modules/css-box-model": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz",
|
||||
@@ -1680,11 +1741,20 @@
|
||||
"tiny-invariant": "^1.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/css-line-break": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
|
||||
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"utrie": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"devOptional": true
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "4.1.0",
|
||||
@@ -1721,6 +1791,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "2.5.9",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.5.9.tgz",
|
||||
"integrity": "sha512-i6mvVmWN4xo9LrhCOZrDgSs9noW6nOahbrmzjRbPF36YPyj5Ue5lgok0MHDWkG7xzpWFO2OYttXdzM7rJxHvNA==",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.355",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.355.tgz",
|
||||
@@ -1807,6 +1883,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="
|
||||
},
|
||||
"node_modules/fraction.js": {
|
||||
"version": "5.3.4",
|
||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
||||
@@ -1820,6 +1901,32 @@
|
||||
"url": "https://github.com/sponsors/rawify"
|
||||
}
|
||||
},
|
||||
"node_modules/framer-motion": {
|
||||
"version": "12.42.2",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.42.2.tgz",
|
||||
"integrity": "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw==",
|
||||
"dependencies": {
|
||||
"motion-dom": "^12.42.2",
|
||||
"motion-utils": "^12.39.0",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/is-prop-valid": "*",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@emotion/is-prop-valid": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -1849,6 +1956,28 @@
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/html2canvas": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
|
||||
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"css-line-break": "^2.1.0",
|
||||
"text-segmentation": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "11.1.8",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz",
|
||||
"integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
||||
@@ -1888,6 +2017,23 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jspdf": {
|
||||
"version": "2.5.2",
|
||||
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-2.5.2.tgz",
|
||||
"integrity": "sha512-myeX9c+p7znDWPk0eTrujCzNjT+CXdXyk7YmJq5nD5V7uLLKmSXnlQ/Jn/kuo3X09Op70Apm0rQSnFWyGK8uEQ==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.23.2",
|
||||
"atob": "^2.1.2",
|
||||
"btoa": "^1.2.1",
|
||||
"fflate": "^0.8.1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"canvg": "^3.0.6",
|
||||
"core-js": "^3.6.0",
|
||||
"dompurify": "^2.5.4",
|
||||
"html2canvas": "^1.0.0-rc.5"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||
@@ -2163,6 +2309,19 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-dom": {
|
||||
"version": "12.42.2",
|
||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.42.2.tgz",
|
||||
"integrity": "sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA==",
|
||||
"dependencies": {
|
||||
"motion-utils": "^12.39.0"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-utils": {
|
||||
"version": "12.39.0",
|
||||
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz",
|
||||
"integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -2193,6 +2352,12 @@
|
||||
"integrity": "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/performance-now": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
|
||||
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -2245,14 +2410,13 @@
|
||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/preact": {
|
||||
"version": "10.12.1",
|
||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz",
|
||||
"integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/preact"
|
||||
"node_modules/raf": {
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
|
||||
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"performance-now": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/raf-schd": {
|
||||
@@ -2315,6 +2479,21 @@
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="
|
||||
},
|
||||
"node_modules/regenerator-runtime": {
|
||||
"version": "0.13.11",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
||||
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/rgbcolor": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
|
||||
"integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 0.8.15"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.60.3",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz",
|
||||
@@ -2382,6 +2561,24 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stackblur-canvas": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
|
||||
"integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=0.1.14"
|
||||
}
|
||||
},
|
||||
"node_modules/svg-pathdata": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
|
||||
"integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz",
|
||||
@@ -2401,6 +2598,15 @@
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/text-segmentation": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
||||
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"utrie": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
@@ -2422,6 +2628,11 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.8.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
||||
@@ -2479,6 +2690,15 @@
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/utrie": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
|
||||
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"base64-arraybuffer": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "6.4.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"date-fns": "^4.1.0",
|
||||
"framer-motion": "^12.42.2",
|
||||
"immer": "^11.1.8",
|
||||
"jspdf": "^2.5.2",
|
||||
"lucide-react": "^0.563.0",
|
||||
"react": "^19.2.4",
|
||||
|
||||
@@ -45,6 +45,15 @@ const apiFetch = async (url: string, options: RequestInit = {}) => {
|
||||
return res;
|
||||
};
|
||||
|
||||
// Permissões de WhatsApp por conta (sessão): reconectar, excluir, acesso Inbox/Secretária.
|
||||
export interface WaPerms {
|
||||
can_rescan: boolean;
|
||||
can_delete: boolean;
|
||||
can_inbox: boolean;
|
||||
can_secretaria: boolean;
|
||||
can_delete_msg: boolean; // apagar conversa/mensagem no inbox
|
||||
}
|
||||
|
||||
export const HybridBackend = {
|
||||
dbConfig: {
|
||||
database: 'scoreodonto'
|
||||
@@ -748,6 +757,18 @@ export const HybridBackend = {
|
||||
const res = await apiFetch(`${API_URL}/protese/laboratorios?clinicaId=${encodeURIComponent(clinicaId)}`);
|
||||
return await res.json();
|
||||
},
|
||||
// Favorito (★) / padrão do protético na clínica ativa.
|
||||
setProteticoPref: async (proteticoId: string, body: { favorito?: boolean; padrao?: boolean }) => {
|
||||
const clinicaId = HybridBackend.getActiveWorkspace()?.id || '';
|
||||
const res = await apiFetch(`${API_URL}/protese/laboratorios/${proteticoId}/pref?clinicaId=${encodeURIComponent(clinicaId)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
return await res.json().catch(() => ({ error: 'falha' }));
|
||||
},
|
||||
// Vincular um protético existente (com conta) como interno, por e-mail.
|
||||
vincularProteticoEmail: async (email: string) => {
|
||||
const clinicaId = HybridBackend.getActiveWorkspace()?.id || '';
|
||||
const res = await apiFetch(`${API_URL}/protese/laboratorios/vincular?clinicaId=${encodeURIComponent(clinicaId)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }) });
|
||||
return await res.json().catch(() => ({ error: 'falha' }));
|
||||
},
|
||||
getProteseOS: async (opts: { status?: string; paciente_id?: string } = {}) => {
|
||||
const clinicaId = HybridBackend.getActiveWorkspace()?.id || '';
|
||||
const qs = new URLSearchParams({ clinicaId });
|
||||
@@ -1460,6 +1481,52 @@ export const HybridBackend = {
|
||||
return { grupo: j.grupo || null, membros: j.membros || [] };
|
||||
},
|
||||
|
||||
// ── 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<{
|
||||
available: boolean; // false = endpoint indisponível (backend antigo/erro) → UI não deve bloquear
|
||||
isOwner: boolean; ownerId: string | null; ownerNome: string | null;
|
||||
me: WaPerms;
|
||||
members: Array<{ usuarioId: string; nome: string; email: string; role: string } & WaPerms>;
|
||||
}> => {
|
||||
const clinicaId = HybridBackend.getActiveWorkspace()?.id || '';
|
||||
const params = new URLSearchParams({ clinicaId, instanceId });
|
||||
const openPerms: WaPerms = { can_rescan: true, can_delete: true, can_inbox: true, can_secretaria: true, can_delete_msg: true };
|
||||
try {
|
||||
const res = await apiFetch(`${API_URL}/nw/authz?${params}`, {});
|
||||
// Endpoint ausente (backend sem a feature ainda) → indisponível, não bloqueia a UI.
|
||||
// A autoridade real é o proxy do backend (403); aqui é só dica visual.
|
||||
if (!res.ok) return { available: false, isOwner: false, ownerId: null, ownerNome: null, me: openPerms, members: [] };
|
||||
const j = await res.json();
|
||||
return { available: true, ...j };
|
||||
} catch {
|
||||
return { available: false, isOwner: false, ownerId: null, ownerNome: null, me: openPerms, members: [] };
|
||||
}
|
||||
},
|
||||
|
||||
// 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 }> => {
|
||||
const clinicaId = HybridBackend.getActiveWorkspace()?.id || '';
|
||||
const open = { isOwner: false, inbox: true, secretaria: true, delete_msg: true };
|
||||
if (!clinicaId) return open;
|
||||
try {
|
||||
const res = await apiFetch(`${API_URL}/nw/access?clinicaId=${encodeURIComponent(clinicaId)}`, {});
|
||||
if (!res.ok) return open;
|
||||
return await res.json();
|
||||
} catch { return open; }
|
||||
},
|
||||
|
||||
setWaSessionAuthz: async (instanceId: string, usuarioId: string, perms: WaPerms): Promise<{ ok: boolean; error?: string }> => {
|
||||
const clinicaId = HybridBackend.getActiveWorkspace()?.id || '';
|
||||
const res = await apiFetch(`${API_URL}/nw/authz`, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ clinicaId, instanceId, usuarioId, ...perms }),
|
||||
});
|
||||
const j = await res.json().catch(() => ({}));
|
||||
return res.ok ? { ok: true } : { ok: false, error: j.error || 'Erro ao salvar autorização.' };
|
||||
},
|
||||
|
||||
// Importa agendamentos do Google p/ dentro do sistema (admin/dono). Idempotente.
|
||||
importarGoogleAgenda: async (clinicaId?: string, dias?: number): Promise<{ success: boolean; criados?: number; pulados?: number; total?: number; message?: string }> => {
|
||||
const cid = clinicaId || HybridBackend.getActiveWorkspace()?.id || '';
|
||||
|
||||
@@ -44,11 +44,30 @@ function connect() {
|
||||
sock.onerror = () => { try { sock.close(); } catch { /* ignore */ } };
|
||||
}
|
||||
|
||||
// Fecha um socket que NÓS abrimos. Se ainda está em CONNECTING, chamar close()
|
||||
// agora dispara "WebSocket is closed before the connection is established"
|
||||
// (comum no double-mount do React StrictMode em DEV) → adiamos o close para o
|
||||
// onopen. Em todos os casos neutralizamos os handlers para o socket morto não
|
||||
// disparar reconexão (a reconexão automática por queda inesperada continua no
|
||||
// onclose original definido em connect()).
|
||||
function closeSocket(sock: WebSocket | null) {
|
||||
if (!sock) return;
|
||||
sock.onmessage = null;
|
||||
sock.onerror = null;
|
||||
sock.onclose = null;
|
||||
if (sock.readyState === WebSocket.CONNECTING) {
|
||||
sock.onopen = () => { try { sock.close(); } catch { /* ignore */ } };
|
||||
} else {
|
||||
sock.onopen = null;
|
||||
try { sock.close(); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
function ensureConnected() {
|
||||
const url = buildUrl();
|
||||
if (!url) return;
|
||||
// Clínica/token mudou → reconecta no canal certo.
|
||||
if (ws && url !== currentUrl) { try { ws.close(); } catch { /* ignore */ } ws = null; }
|
||||
if (ws && url !== currentUrl) { closeSocket(ws); ws = null; }
|
||||
if (!ws || ws.readyState === WebSocket.CLOSING || ws.readyState === WebSocket.CLOSED) connect();
|
||||
}
|
||||
|
||||
@@ -61,13 +80,13 @@ export const Realtime = {
|
||||
handlers.delete(handler);
|
||||
if (handlers.size === 0) {
|
||||
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
||||
if (ws) { try { ws.close(); } catch { /* ignore */ } ws = null; }
|
||||
if (ws) { closeSocket(ws); ws = null; }
|
||||
}
|
||||
};
|
||||
},
|
||||
/** Força reconexão (ex.: ao trocar de workspace). */
|
||||
reconnect() {
|
||||
if (ws) { try { ws.close(); } catch { /* ignore */ } ws = null; }
|
||||
if (ws) { closeSocket(ws); ws = null; }
|
||||
backoff = 1000;
|
||||
ensureConnected();
|
||||
},
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// Desambiguação de homônimos na bancada do protético.
|
||||
// O protético identifica o trabalho pelo NOME do paciente (sem CPF — princípio de privacidade).
|
||||
// Quando dois trabalhos têm o mesmo nome+sobrenome, diferenciamos sem expor dado sensível:
|
||||
// 1) pela ORIGEM (dentista, senão clínica) — "João Silva · Dr. Carlos";
|
||||
// 2) se ainda empatar (mesma origem), por nº sequencial estável — "João Silva · Dr. Carlos (2)".
|
||||
// Etiqueta livre, nunca um cadastro/atendimento de paciente.
|
||||
|
||||
const DIACRITICOS = new RegExp('[' + String.fromCharCode(0x300) + '-' + String.fromCharCode(0x36f) + ']', 'g');
|
||||
const norm = (s?: string) =>
|
||||
(s || '').normalize('NFD').replace(DIACRITICOS, '').toLowerCase().replace(/\s+/g, ' ').trim();
|
||||
|
||||
const origemDe = (o: any) => (o?.dentista_nome || o?.clinica_nome || o?.cliente_nome || '').trim();
|
||||
|
||||
// Recebe a lista de OS e devolve um Map id → rótulo de exibição já desambiguado.
|
||||
export function rotulosPaciente(lista: any[]): Map<string, string> {
|
||||
const grupos = new Map<string, any[]>();
|
||||
for (const o of lista || []) {
|
||||
const k = norm(o?.paciente_nome);
|
||||
if (!k) continue;
|
||||
const g = grupos.get(k);
|
||||
if (g) g.push(o); else grupos.set(k, [o]);
|
||||
}
|
||||
const out = new Map<string, string>();
|
||||
for (const o of lista || []) {
|
||||
const base = (o?.paciente_nome || '').trim() || 'Sem identificação';
|
||||
const k = norm(o?.paciente_nome);
|
||||
const grupo = k ? (grupos.get(k) || [o]) : [o];
|
||||
if (grupo.length <= 1) { out.set(o.id, base); continue; }
|
||||
const origem = origemDe(o);
|
||||
const mesmaOrigem = grupo.filter(g => norm(origemDe(g)) === norm(origem));
|
||||
if (origem && mesmaOrigem.length === 1) {
|
||||
out.set(o.id, `${base} · ${origem}`);
|
||||
} else {
|
||||
// mesma origem (ou origem ausente) → sequencial estável por data de criação
|
||||
const ord = [...mesmaOrigem].sort((a, b) => String(a.created_at || '').localeCompare(String(b.created_at || '')));
|
||||
const idx = ord.findIndex(g => g.id === o.id) + 1;
|
||||
out.set(o.id, origem ? `${base} · ${origem} (${idx})` : `${base} (${idx})`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Há outro trabalho ATIVO com o mesmo nome+sobrenome? (usado no aviso da entrada do lab)
|
||||
// Retorna a origem do homônimo encontrado, ou null.
|
||||
export function homonimoAtivo(nomeCompleto: string, lista: any[]): string | null {
|
||||
const k = norm(nomeCompleto);
|
||||
if (!k) return null;
|
||||
const hit = (lista || []).find(o =>
|
||||
norm(o?.paciente_nome) === k && !['entregue', 'cancelado'].includes(o?.status));
|
||||
return hit ? (origemDe(hit) || 'outro cliente') : null;
|
||||
}
|
||||
@@ -110,6 +110,7 @@ export const DentistasView: React.FC = () => {
|
||||
const [isHoursModalOpen, setIsHoursModalOpen] = useState(false);
|
||||
const [selectedDentist, setSelectedDentist] = useState<Dentista | null>(null);
|
||||
const [connectedAccounts, setConnectedAccounts] = useState<any[]>([]);
|
||||
const [setores, setSetores] = useState<{ id: string; nome: string }[]>([]);
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
|
||||
@@ -125,6 +126,13 @@ export const DentistasView: React.FC = () => {
|
||||
|
||||
useEffect(() => {
|
||||
fetchGoogleStatus();
|
||||
// Setores da clínica (para lotar o dentista num setor → isolamento da agenda).
|
||||
(async () => {
|
||||
try {
|
||||
const r = await HybridBackend.getSetores(activeWorkspace?.id || '');
|
||||
setSetores(r?.setores || []);
|
||||
} catch { /* clínica sem setores → seletor não aparece */ }
|
||||
})();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -261,6 +269,17 @@ export const DentistasView: React.FC = () => {
|
||||
<form onSubmit={handleSave} className="space-y-4 max-w-2xl mx-auto">
|
||||
<input id="dentista-nome" type="text" name="nome" placeholder="NOME COMPLETO" defaultValue={editingDentista?.nome} className="w-full border border-gray-300 rounded-lg p-2 uppercase-input" required />
|
||||
<input id="dentista-email" type="email" name="email" placeholder="EMAIL" defaultValue={editingDentista?.email} className="w-full border border-gray-300 rounded-lg p-2" required />
|
||||
{/* Setor do dentista → isola a agenda dele para a equipe do setor.
|
||||
Só aparece se a clínica usa setores. Vazio = geral (visível a todos). */}
|
||||
{setores.length > 0 && (
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase block mb-1.5">Setor (opcional)</label>
|
||||
<select id="dentista-setor" name="setor_id" defaultValue={(editingDentista as any)?.setor_id || ''} className="w-full border border-gray-300 rounded-lg p-2">
|
||||
<option value="">SEM SETOR (geral — visível a todos)</option>
|
||||
{setores.map(s => <option key={s.id} value={s.id}>{s.nome}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{/* Especialidades que o dentista atende (várias) */}
|
||||
<div>
|
||||
<label className="text-xs font-bold text-gray-500 uppercase block mb-1.5">Especialidades que atende *</label>
|
||||
|
||||
+160
-54
@@ -3,9 +3,12 @@ import FullCalendar from '@fullcalendar/react';
|
||||
import dayGridPlugin from '@fullcalendar/daygrid';
|
||||
import timeGridPlugin from '@fullcalendar/timegrid';
|
||||
import interactionPlugin from '@fullcalendar/interaction';
|
||||
import { Calendar as CalendarIcon, Plus, X, Loader2, GripVertical, Settings as GearIcon, Edit, Trash2, CalendarRange, Clock, User, Stethoscope, AlertCircle, CheckCircle2, Bell, CalendarClock, History, Download } from 'lucide-react';
|
||||
import { Calendar as CalendarIcon, Plus, X, Loader2, GripVertical, Settings as GearIcon, Edit, Trash2, CalendarRange, Clock, User, Stethoscope, AlertCircle, CheckCircle2, Bell, CalendarClock, History, Download, PanelLeftClose, PanelLeftOpen } from 'lucide-react';
|
||||
import { AgendaSettingsModal } from '../components/AgendaSettingsModal.tsx';
|
||||
import { HorariosConfig } from '../components/HorariosConfig.tsx';
|
||||
import { PedidosPendentes } from '../components/PedidosPendentes.tsx';
|
||||
import { AgendaDetailModal, ScoreBadge, FamiliaChips } from '../components/AgendaDetailModal.tsx';
|
||||
import { WhatsChatDrawer } from './newwhats/WhatsChatDrawer.tsx';
|
||||
import { AgendaPresence } from '../components/AgendaPresence.tsx';
|
||||
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
@@ -14,7 +17,6 @@ import { Dentista, Agendamento, Paciente, DentistaHorario } from '../types.ts';
|
||||
import { useGTOStore } from './LancarGTO.tsx';
|
||||
import { useHybridBackend } from '../hooks/useHybridBackend.ts';
|
||||
import { useToast } from '../contexts/ToastContext.tsx';
|
||||
import { PageHeader } from '../components/PageHeader.tsx';
|
||||
|
||||
// --- Reusable Debounce Hook ---
|
||||
function useDebounce(value: string, delay: number) {
|
||||
@@ -343,16 +345,37 @@ const AppointmentModal: React.FC<{
|
||||
);
|
||||
};
|
||||
|
||||
export const AgendaView: React.FC<{ onNavigate?: (view: string) => void }> = ({ onNavigate }) => {
|
||||
export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebarCollapsed?: boolean; onToggleSidebar?: () => void; controlsOpen?: boolean; onCloseControls?: () => void }> = ({ onNavigate, sidebarCollapsed, onToggleSidebar, controlsOpen = false, onCloseControls }) => {
|
||||
const { data: agendamentos, refresh } = useHybridBackend<Agendamento[]>(HybridBackend.getAgendamentos);
|
||||
const { data: dentists, refresh: refreshDentists } = useHybridBackend<Dentista[]>(HybridBackend.getDentistas);
|
||||
const { data: googleEvents, refresh: refreshGoogle } = useHybridBackend<any[]>(HybridBackend.getGoogleEvents);
|
||||
const [events, setEvents] = useState<any[]>([]);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||
const [showMeusHorarios, setShowMeusHorarios] = useState(false);
|
||||
const [showPedidos, setShowPedidos] = useState(false);
|
||||
const [pedidosCount, setPedidosCount] = useState(0);
|
||||
// Pedidos da zona cinza aguardando a secretária humana — badge + poll a cada 30 min.
|
||||
useEffect(() => {
|
||||
const cid = (HybridBackend.getActiveWorkspace() as any)?.id;
|
||||
const role = HybridBackend.getCurrentRole();
|
||||
if (!cid || !['admin', 'donoclinica', 'donoconsultorio', 'funcionario'].includes(role)) return;
|
||||
let alive = true;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('SCOREODONTO_AUTH_TOKEN');
|
||||
const r = await fetch(`/api/nw/agenda-config/clinica/${cid}/pedidos?status=pendente`, { headers: token ? { Authorization: `Bearer ${token}` } : {} });
|
||||
if (r.ok && alive) { const j = await r.json(); setPedidosCount(j.total || (j.pedidos || []).length || 0); }
|
||||
} catch { /* silencioso */ }
|
||||
};
|
||||
poll();
|
||||
const t = setInterval(poll, 30 * 60 * 1000); // 30 min
|
||||
return () => { alive = false; clearInterval(t); };
|
||||
}, []);
|
||||
const [selectedDateInfo, setSelectedDateInfo] = useState<any>(null);
|
||||
const [selectedAppointment, setSelectedAppointment] = useState<Agendamento | null>(null);
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const [whatsTarget, setWhatsTarget] = useState<{ pacienteId: string; pacienteNome: string } | null>(null);
|
||||
const [interesses, setInteresses] = useState<any[]>([]);
|
||||
const [showInteresses, setShowInteresses] = useState(false);
|
||||
const [pendencias, setPendencias] = useState<any[]>([]);
|
||||
@@ -457,14 +480,15 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void }> = ({
|
||||
const inativo = st === 'cancelado' || st === 'remarcar';
|
||||
// Prioridade: status (falta/cancelado) > cor própria do agendamento (ex.: importada do Google) > cor do dentista.
|
||||
const cor = faltou ? '#ef4444' : inativo ? '#cbd5e1' : ((ag as any).cor || (dentist ? dentist.corAgenda : '#94a3b8'));
|
||||
const nomePac = (ag as any).pacienteNome || (ag as any).pacientenome || '';
|
||||
return {
|
||||
id: ag.id,
|
||||
title: `${ag.pacienteNome} - ${ag.procedimento}`,
|
||||
title: `${nomePac} - ${ag.procedimento}`,
|
||||
start: ag.start,
|
||||
end: ag.end,
|
||||
backgroundColor: cor,
|
||||
borderColor: cor,
|
||||
extendedProps: { ...ag, isGoogle: false, dentistaNome: dentist?.nome }
|
||||
extendedProps: { ...ag, pacienteNome: nomePac, isGoogle: false, dentistaNome: dentist?.nome }
|
||||
};
|
||||
});
|
||||
|
||||
@@ -597,13 +621,107 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void }> = ({
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="AGENDA CLÍNICA">
|
||||
<div className="flex gap-2 sm:gap-3 items-center flex-wrap justify-end">
|
||||
<div className="flex flex-col lg:flex-row lg:gap-4 h-full">
|
||||
{/* Backdrop da gaveta de controles (mobile) — fecha ao tocar fora. Como a sidebar. */}
|
||||
{controlsOpen && (
|
||||
<div className="fixed inset-0 bg-black/50 z-20 lg:hidden backdrop-blur-sm transition-opacity" onClick={onCloseControls} />
|
||||
)}
|
||||
{/* Coluna de controles — desktop: lateral esquerda fixa; mobile: gaveta deslizante
|
||||
(oculta por padrão p/ dar o máximo de espaço ao calendário; abre pelo ⋯ do header). */}
|
||||
<aside className={`
|
||||
fixed inset-y-0 right-0 z-30 w-72 max-w-[85%] bg-white shadow-2xl overflow-y-auto p-4 transform transition-transform duration-300
|
||||
${controlsOpen ? 'translate-x-0' : 'translate-x-full'}
|
||||
lg:static lg:inset-auto lg:z-auto lg:w-56 lg:max-w-none lg:translate-x-0 lg:transform-none lg:transition-none lg:shadow-none lg:bg-transparent lg:p-0 lg:overflow-y-auto lg:pr-3 lg:border-r lg:border-gray-100
|
||||
shrink-0 lg:mb-0`}>
|
||||
{/* Fechar a gaveta (só mobile) */}
|
||||
<button onClick={onCloseControls} title="Fechar"
|
||||
className="lg:hidden absolute top-3 right-3 p-1.5 rounded-lg text-gray-400 hover:text-gray-700 hover:bg-gray-100 transition-colors z-10">
|
||||
<X size={20} />
|
||||
</button>
|
||||
{/* Título + recolher menu (desktop, ao lado). No mobile o AGENDAR e as
|
||||
ações ficam empilhados abaixo (dentro da gaveta). */}
|
||||
<div className="flex items-center gap-2 mb-3 pr-8 lg:pr-0">
|
||||
{onToggleSidebar && (
|
||||
<button onClick={onToggleSidebar} title={sidebarCollapsed ? 'Mostrar menu lateral' : 'Recolher menu lateral'}
|
||||
className="hidden lg:flex items-center justify-center w-8 h-8 shrink-0 rounded-lg text-gray-400 hover:text-gray-700 hover:bg-gray-100 transition-colors">
|
||||
{sidebarCollapsed ? <PanelLeftOpen size={18} /> : <PanelLeftClose size={18} />}
|
||||
</button>
|
||||
)}
|
||||
<h2 className="text-xl lg:text-base font-bold text-gray-900 uppercase flex-1 min-w-0 leading-tight break-words">AGENDA CLÍNICA</h2>
|
||||
</div>
|
||||
<div className="flex gap-2 sm:gap-3 items-center flex-wrap lg:flex-col lg:items-stretch">
|
||||
{/* AGENDAR (full-width no topo da coluna/gaveta — mobile e desktop) */}
|
||||
{(currentRole !== 'paciente' && !souDentista) && (
|
||||
<button onClick={() => { setReagendarPatient(null); setSelectedAppointment(null); setSelectedDateInfo({ dateStr: new Date().toISOString() }); setIsModalOpen(true); onCloseControls?.(); }} title="Agendar"
|
||||
className="flex w-full bg-teal-600 text-white px-4 py-2.5 rounded-lg text-sm font-bold uppercase items-center justify-center gap-2 hover:bg-teal-700 shadow-sm transition-colors">
|
||||
<Plus size={18} /> AGENDAR
|
||||
</button>
|
||||
)}
|
||||
{/* Ações — mobile: empilhadas, ícone + nome, largura cheia; desktop: fileira de ícones */}
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
{(currentRole !== 'paciente') && (
|
||||
<button onClick={() => { setShowAtividade(true); loadAtividade(); onCloseControls?.(); }} title="Atividade da equipe hoje"
|
||||
className="w-full flex items-center justify-start gap-3 px-4 py-2.5 bg-white text-gray-500 hover:text-teal-600 border border-gray-200 rounded-xl transition-all hover:border-teal-200 hover:shadow-sm">
|
||||
<History size={20} className="shrink-0" />
|
||||
<span className="text-sm font-bold">Atividade da equipe</span>
|
||||
</button>
|
||||
)}
|
||||
{(currentRole === 'admin' || currentRole === 'donoclinica' || currentRole === 'donoconsultorio') && (
|
||||
<button onClick={handleImportarGoogle} disabled={importando} title="Importar agendamentos do Google para o sistema"
|
||||
className="w-full flex items-center justify-start gap-3 px-4 py-2.5 bg-white text-gray-500 hover:text-emerald-600 border border-gray-200 rounded-xl transition-all hover:border-emerald-200 hover:shadow-sm disabled:opacity-50">
|
||||
{importando ? <Loader2 size={20} className="animate-spin shrink-0" /> : <Download size={20} className="shrink-0" />}
|
||||
<span className="text-sm font-bold">Importar do Google</span>
|
||||
</button>
|
||||
)}
|
||||
{['admin', 'donoclinica', 'donoconsultorio', 'funcionario'].includes(currentRole) && (
|
||||
<button onClick={() => { setShowPedidos(true); onCloseControls?.(); }} title="Pedidos de horário aguardando confirmação (encaminhados pela Secretária IA)"
|
||||
className="relative w-full flex items-center justify-start gap-3 px-4 py-2.5 bg-white text-gray-500 hover:text-amber-600 border border-gray-200 rounded-xl transition-all hover:border-amber-200 hover:shadow-sm">
|
||||
<CalendarClock size={20} className="shrink-0" />
|
||||
<span className="text-sm font-bold">Pedidos pendentes</span>
|
||||
{pedidosCount > 0 && <span className="absolute -top-1 -right-1 bg-amber-500 text-white text-[10px] font-black rounded-full min-w-[18px] h-[18px] flex items-center justify-center px-1 shadow">{pedidosCount}</span>}
|
||||
</button>
|
||||
)}
|
||||
{(currentRole === 'admin' || currentRole === 'donoclinica' || currentRole === 'donoconsultorio') && (
|
||||
<button onClick={() => { setIsSettingsOpen(true); onCloseControls?.(); }} title="Configurações da agenda"
|
||||
className="w-full flex items-center justify-start gap-3 px-4 py-2.5 bg-white text-gray-500 hover:text-teal-600 border border-gray-200 rounded-xl transition-all hover:border-teal-200 hover:shadow-sm">
|
||||
<GearIcon size={20} className="shrink-0" />
|
||||
<span className="text-sm font-bold">Configurações</span>
|
||||
</button>
|
||||
)}
|
||||
{souDentista && (
|
||||
<button onClick={() => { setShowMeusHorarios(true); onCloseControls?.(); }} title="Configurar meus dias e horários de atendimento"
|
||||
className="w-full flex items-center justify-start gap-3 px-4 py-2.5 bg-white text-gray-500 hover:text-teal-600 border border-gray-200 rounded-xl transition-all hover:border-teal-200 hover:shadow-sm">
|
||||
<Clock size={20} className="shrink-0" />
|
||||
<span className="text-sm font-bold">Meus Horários</span>
|
||||
</button>
|
||||
)}
|
||||
{souDentista && (
|
||||
<button onClick={() => { setShowInteresses(true); loadInteresses(); onCloseControls?.(); }} title="Pedidos de interesse na sua agenda"
|
||||
className="relative w-full flex items-center justify-start gap-3 px-4 py-2.5 bg-white text-gray-500 hover:text-teal-600 border border-gray-200 rounded-xl transition-all hover:border-teal-200 hover:shadow-sm">
|
||||
<Bell size={20} className="shrink-0" />
|
||||
<span className="text-sm font-bold">Interesses na agenda</span>
|
||||
{interessesAguardando > 0 && (
|
||||
<span className="absolute top-1.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 flex items-center justify-start gap-3 px-4 py-2.5 bg-white text-gray-500 hover:text-amber-600 border border-gray-200 rounded-xl transition-all hover:border-amber-200 hover:shadow-sm">
|
||||
<CalendarClock size={20} className="shrink-0" />
|
||||
<span className="text-sm font-bold">Pacientes a reagendar</span>
|
||||
{pendencias.length > 0 && (
|
||||
<span className="absolute top-1.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>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full"><AgendaPresence /></div>
|
||||
{/* Legenda de dentistas (desktop, no rodapé da coluna) */}
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<Droppable droppableId="dentists-legend" direction="horizontal">
|
||||
{(provided) => (
|
||||
<div {...provided.droppableProps} ref={provided.innerRef} className="hidden lg:flex gap-2 items-center">
|
||||
<div {...provided.droppableProps} ref={provided.innerRef} className="hidden lg:flex lg:flex-wrap gap-2 items-center lg:w-full lg:mt-1">
|
||||
{filteredDentists?.map((d, index) => (
|
||||
<Draggable key={d.id} draggableId={d.id} index={index} isDragDisabled={currentRole === 'dentista'}>
|
||||
{(provided, snapshot) => (
|
||||
@@ -625,50 +743,9 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void }> = ({
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
<AgendaPresence />
|
||||
{(currentRole !== 'paciente') && (
|
||||
<button onClick={() => { setShowAtividade(true); loadAtividade(); }} title="Atividade da equipe hoje"
|
||||
className="p-2.5 bg-white text-gray-400 hover:text-teal-600 border border-gray-200 rounded-xl transition-all hover:border-teal-200 hover:shadow-sm">
|
||||
<History size={20} />
|
||||
</button>
|
||||
)}
|
||||
{(currentRole === 'admin' || currentRole === 'donoclinica' || currentRole === 'donoconsultorio') && (
|
||||
<button onClick={handleImportarGoogle} disabled={importando} title="Importar agendamentos do Google para o sistema"
|
||||
className="p-2.5 bg-white text-gray-400 hover:text-emerald-600 border border-gray-200 rounded-xl transition-all hover:border-emerald-200 hover:shadow-sm disabled:opacity-50">
|
||||
{importando ? <Loader2 size={20} className="animate-spin" /> : <Download size={20} />}
|
||||
</button>
|
||||
)}
|
||||
{(currentRole === 'admin' || currentRole === 'donoclinica') && (
|
||||
<button onClick={() => setIsSettingsOpen(true)} className="p-2.5 bg-white text-gray-400 hover:text-teal-600 border border-gray-200 rounded-xl transition-all hover:border-teal-200 hover:shadow-sm">
|
||||
<GearIcon size={20} />
|
||||
</button>
|
||||
)}
|
||||
{souDentista && (
|
||||
<button onClick={() => { setShowInteresses(true); loadInteresses(); }} title="Pedidos de interesse na sua agenda"
|
||||
className="relative p-2.5 bg-white text-gray-400 hover:text-teal-600 border border-gray-200 rounded-xl transition-all hover:border-teal-200 hover:shadow-sm">
|
||||
<Bell size={20} />
|
||||
{interessesAguardando > 0 && (
|
||||
<span className="absolute -top-1 -right-1 bg-red-500 text-white text-[9px] font-black rounded-full w-4 h-4 flex items-center justify-center">{interessesAguardando}</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{(currentRole !== 'paciente' && !souDentista) && (
|
||||
<button onClick={() => { setShowPendencias(true); loadPendencias(); }} title="Pacientes a reagendar"
|
||||
className="relative p-2.5 bg-white text-gray-400 hover:text-amber-600 border border-gray-200 rounded-xl transition-all hover:border-amber-200 hover:shadow-sm">
|
||||
<CalendarClock size={20} />
|
||||
{pendencias.length > 0 && (
|
||||
<span className="absolute -top-1 -right-1 bg-amber-500 text-white text-[9px] font-black rounded-full w-4 h-4 flex items-center justify-center">{pendencias.length}</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{(currentRole !== 'paciente' && !souDentista) && (
|
||||
<button onClick={() => { setReagendarPatient(null); setSelectedAppointment(null); setSelectedDateInfo({ dateStr: new Date().toISOString() }); setIsModalOpen(true); }} title="Novo agendamento" className="bg-teal-600 text-white px-3 sm:px-4 py-2 rounded-lg text-sm font-bold uppercase flex items-center gap-2 hover:bg-teal-700 shadow-sm transition-colors">
|
||||
<Plus size={18} /> <span className="hidden sm:inline">NOVO AGENDAMENTO</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</PageHeader>
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-2 sm:p-4 relative h-[calc(100dvh-220px)] sm:h-[750px] min-h-[420px]">
|
||||
</aside>
|
||||
<div className="flex-1 min-w-0 bg-white rounded-xl shadow-sm border border-gray-200 p-2 sm:p-4 relative min-h-[420px] lg:min-h-0">
|
||||
<FullCalendar
|
||||
key={isMobile ? 'mobile' : 'desktop'}
|
||||
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
|
||||
@@ -681,6 +758,8 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void }> = ({
|
||||
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}
|
||||
@@ -720,10 +799,23 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void }> = ({
|
||||
onChanged={() => { refresh(); refreshGoogle(); loadPendencias(); }}
|
||||
onEditar={() => { setShowDetails(false); setIsModalOpen(true); }}
|
||||
onAtender={handleAtender}
|
||||
podeAtender={!!onNavigate && currentRole !== 'paciente'}
|
||||
onVerWhats={(info) => setWhatsTarget(info)}
|
||||
// "Atender" (abre Lançar GTO) é ação CLÍNICA — atende quem trata: dentista,
|
||||
// biomédico, dono da clínica/consultório, dentista locatário, admin. A recepção
|
||||
// (funcionario) NÃO atende; ela só remarca/desmarca/agenda.
|
||||
podeAtender={!!onNavigate && currentRole !== 'paciente' && currentRole !== 'funcionario'}
|
||||
dentistaRestrito={souDentista}
|
||||
/>
|
||||
|
||||
{/* Drawer de WhatsApp do paciente — clone focado da área de mensagens */}
|
||||
<WhatsChatDrawer
|
||||
isOpen={!!whatsTarget}
|
||||
onClose={() => setWhatsTarget(null)}
|
||||
pacienteId={whatsTarget?.pacienteId}
|
||||
pacienteNome={whatsTarget?.pacienteNome}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
|
||||
{/* 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)}>
|
||||
@@ -797,6 +889,12 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void }> = ({
|
||||
.custom-scrollbar::-webkit-scrollbar { width: 4px; }
|
||||
.custom-scrollbar::-webkit-scrollbar-track { background: #f1f1f1; }
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 10px; }
|
||||
/* FullCalendar — toolbar/controles compactos (todas as telas) */
|
||||
.fc .fc-toolbar.fc-header-toolbar { margin-bottom: .6rem; }
|
||||
.fc .fc-toolbar-title { font-size: 1.05rem; font-weight: 700; }
|
||||
.fc .fc-button { padding: .3rem .65rem; font-size: .8rem; box-shadow: none !important; }
|
||||
.fc .fc-button-group { gap: 0; }
|
||||
.fc .fc-col-header-cell-cushion { font-size: .72rem; padding: 4px 2px; }
|
||||
/* FullCalendar — responsividade mobile */
|
||||
@media (max-width: 639px) {
|
||||
.fc .fc-toolbar.fc-header-toolbar { margin-bottom: .5rem; flex-wrap: wrap; gap: .4rem; }
|
||||
@@ -806,8 +904,13 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void }> = ({
|
||||
.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}
|
||||
@@ -819,6 +922,9 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void }> = ({
|
||||
initialPatient={reagendarPatient}
|
||||
/>
|
||||
<AgendaSettingsModal isOpen={isSettingsOpen} onClose={() => setIsSettingsOpen(false)} dentists={dentists} />
|
||||
<HorariosConfig isOpen={showMeusHorarios} onClose={() => setShowMeusHorarios(false)} clinicaId={(HybridBackend.getActiveWorkspace() as any)?.id} initialTab="dentistas" soDentista />
|
||||
<PedidosPendentes isOpen={showPedidos} onClose={() => setShowPedidos(false)} clinicaId={(HybridBackend.getActiveWorkspace() as any)?.id}
|
||||
onChange={() => { const cid = (HybridBackend.getActiveWorkspace() as any)?.id; const token = localStorage.getItem('SCOREODONTO_AUTH_TOKEN'); if (cid) fetch(`/api/nw/agenda-config/clinica/${cid}/pedidos?status=pendente`, { headers: token ? { Authorization: `Bearer ${token}` } : {} }).then(r => r.ok ? r.json() : { total: 0 }).then(j => setPedidosCount(j.total || 0)).catch(() => {}); }} />
|
||||
|
||||
{showInteresses && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center backdrop-blur-sm p-4" onClick={() => setShowInteresses(false)}>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { FlaskConical, Wallet, Clock, AlertTriangle, CheckCircle2, RefreshCw, Inbox, Plus, ArrowDownToLine, X, Stethoscope, Building2, Briefcase } from 'lucide-react';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { PageHeader } from '../components/PageHeader.tsx';
|
||||
import { useToast } from '../contexts/ToastContext.tsx';
|
||||
import { rotulosPaciente, homonimoAtivo } from '../utils/pacienteLabel.ts';
|
||||
|
||||
const fmtBRL = (v: number) => 'R$ ' + Number(v || 0).toLocaleString('pt-BR', { minimumFractionDigits: 2 });
|
||||
const CLI_TIPO = [
|
||||
@@ -40,6 +41,7 @@ export const LabHomeView: React.FC = () => {
|
||||
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setImportando(null); }
|
||||
};
|
||||
|
||||
const rotuloPac = useMemo(() => rotulosPaciente(os), [os]);
|
||||
const ativas = os.filter(o => o.status !== 'entregue' && o.status !== 'cancelado');
|
||||
const atrasadas = os.filter(o => o.atrasada).length;
|
||||
const ymMes = new Date().toISOString().slice(0, 7);
|
||||
@@ -148,7 +150,7 @@ export const LabHomeView: React.FC = () => {
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{ativas.filter(o => o.prazo_entrega).sort((a, b) => (a.prazo_entrega || '').localeCompare(b.prazo_entrega || '')).slice(0, 12).map(o => (
|
||||
<tr key={o.id} className={`hover:bg-gray-50/50 ${o.atrasada ? 'bg-red-50/40' : ''}`}>
|
||||
<td className="px-4 py-3 font-bold text-gray-700 uppercase">{o.paciente_nome || '—'}</td>
|
||||
<td className="px-4 py-3 font-bold text-gray-700 uppercase">{rotuloPac.get(o.id) || o.paciente_nome || '—'}</td>
|
||||
<td className="px-4 py-3 text-gray-500 uppercase">{o.tipo || '—'}</td>
|
||||
<td className={`px-4 py-3 whitespace-nowrap font-bold ${o.atrasada ? 'text-red-600' : 'text-gray-500'}`}>{(o.prazo_entrega || '').slice(0, 10).split('-').reverse().join('/')}</td>
|
||||
<td className="px-4 py-3"><span className="px-2 py-0.5 rounded-full text-[9px] font-black uppercase bg-teal-100 text-teal-700">{STATUS_LABEL[o.status] || o.status}</span></td>
|
||||
@@ -163,13 +165,13 @@ export const LabHomeView: React.FC = () => {
|
||||
</>
|
||||
)}
|
||||
|
||||
{showEntrada && <LabEntradaModal onClose={() => setShowEntrada(false)} onSaved={() => { setShowEntrada(false); carregar(); }} />}
|
||||
{showEntrada && <LabEntradaModal osAtivas={os} onClose={() => setShowEntrada(false)} onSaved={() => { setShowEntrada(false); carregar(); }} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Modal: dar entrada em OS pelo lado do laboratório ────────────────────────
|
||||
const LabEntradaModal: React.FC<{ onClose: () => void; onSaved: () => void }> = ({ onClose, onSaved }) => {
|
||||
const LabEntradaModal: React.FC<{ osAtivas: any[]; onClose: () => void; onSaved: () => void }> = ({ osAtivas, onClose, onSaved }) => {
|
||||
const toast = useToast();
|
||||
const [clientes, setClientes] = useState<any[]>([]);
|
||||
const [produtos, setProdutos] = useState<any[]>([]);
|
||||
@@ -182,9 +184,14 @@ const LabEntradaModal: React.FC<{ onClose: () => void; onSaved: () => void }> =
|
||||
const [tipo, setTipo] = useState('');
|
||||
const [custo, setCusto] = useState('');
|
||||
const [prazo, setPrazo] = useState('');
|
||||
const [paciente, setPaciente] = useState('');
|
||||
const [nome, setNome] = useState('');
|
||||
const [sobrenome, setSobrenome] = useState('');
|
||||
const [obs, setObs] = useState('');
|
||||
|
||||
// Identificação do trabalho = Nome + Sobrenome (etiqueta, sem CPF). Avisa sobre homônimos ativos.
|
||||
const pacienteNome = `${nome} ${sobrenome}`.replace(/\s+/g, ' ').trim();
|
||||
const homonimo = useMemo(() => pacienteNome ? homonimoAtivo(pacienteNome, osAtivas) : null, [pacienteNome, osAtivas]);
|
||||
|
||||
useEffect(() => {
|
||||
HybridBackend.getLabClientes().then(r => setClientes(Array.isArray(r) ? r : [])).catch(() => {});
|
||||
HybridBackend.getProteseProdutos().then(r => setProdutos(Array.isArray(r) ? r : [])).catch(() => {});
|
||||
@@ -195,7 +202,7 @@ const LabEntradaModal: React.FC<{ onClose: () => void; onSaved: () => void }> =
|
||||
if (!produtoId && !tipo.trim()) { toast.error('INFORME O PRODUTO OU TIPO.'); return; }
|
||||
setBusy(true);
|
||||
try {
|
||||
const body: any = { paciente_nome: paciente.trim() || undefined, observacoes: obs.trim() || undefined, prazo_entrega: prazo || undefined };
|
||||
const body: any = { paciente_nome: pacienteNome || undefined, observacoes: obs.trim() || undefined, prazo_entrega: prazo || undefined };
|
||||
if (clienteId) body.cliente_id = clienteId; else { body.cliente_nome = cliNome.trim(); body.cliente_tipo = cliTipo; }
|
||||
if (produtoId) body.produto_id = produtoId; else { body.tipo = tipo.trim(); body.custo = Number(custo) || 0; }
|
||||
const r = await HybridBackend.criarLabOs(body);
|
||||
@@ -248,10 +255,20 @@ const LabEntradaModal: React.FC<{ onClose: () => void; onSaved: () => void }> =
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><label className={lbl}>Prazo</label><input value={prazo} onChange={e => setPrazo(e.target.value)} type="date" className={inp} /></div>
|
||||
<div><label className={lbl}>Paciente (opcional)</label><input value={paciente} onChange={e => setPaciente(e.target.value)} placeholder="Nome" className={inp} /></div>
|
||||
{/* Identificação do trabalho: Nome + Sobrenome (etiqueta, sem CPF — o protético não atende paciente). */}
|
||||
<div>
|
||||
<label className={lbl}>Identificação do trabalho (paciente)</label>
|
||||
<div className="grid grid-cols-2 gap-3 mt-1">
|
||||
<input value={nome} onChange={e => setNome(e.target.value)} placeholder="Nome" className={inp.replace('mt-1', '')} />
|
||||
<input value={sobrenome} onChange={e => setSobrenome(e.target.value)} placeholder="Sobrenome(s)" className={inp.replace('mt-1', '')} />
|
||||
</div>
|
||||
{homonimo && (
|
||||
<p className="mt-1.5 text-[10px] font-bold text-amber-600 uppercase flex items-start gap-1">
|
||||
<AlertTriangle size={12} className="shrink-0 mt-px" /> Já existe “{pacienteNome}” ativo ({homonimo}). Acrescente outro sobrenome para diferenciar.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div><label className={lbl}>Prazo</label><input value={prazo} onChange={e => setPrazo(e.target.value)} type="date" className={inp} /></div>
|
||||
<div><label className={lbl}>Observações</label><textarea value={obs} onChange={e => setObs(e.target.value)} rows={2} className={inp} /></div>
|
||||
<button disabled={busy} onClick={salvar} className="w-full py-3 rounded-xl text-white font-black uppercase text-sm disabled:opacity-50" style={{ backgroundColor: '#2d6a4f' }}>{busy ? 'Salvando…' : 'Registrar entrada'}</button>
|
||||
<p className="text-[10px] text-gray-300 font-bold uppercase text-center">O trabalho entra já com o laboratório (custódia) e no seu financeiro.</p>
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
X
|
||||
} from 'lucide-react';
|
||||
import { PageHeader } from '../components/PageHeader.tsx';
|
||||
import { ProteticoPicker } from '../components/ProteticoPicker.tsx';
|
||||
import { SearchSelect } from '../components/SearchSelect.tsx';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { Paciente, Dentista, Procedimento, Especialidade } from '../types.ts';
|
||||
import { useToast } from '../contexts/ToastContext.tsx';
|
||||
@@ -123,7 +125,6 @@ export const LancarGTO: React.FC = () => {
|
||||
const [patientSearchTerm, setPatientSearchTerm] = useState('');
|
||||
|
||||
// --- PRÓTESE (Fase 1): catálogo do protético entra como "procedimentos" do GTO ---
|
||||
const [laboratorios, setLaboratorios] = useState<any[]>([]);
|
||||
const [selectedLab, setSelectedLab] = useState<string>('');
|
||||
const [labProdutos, setLabProdutos] = useState<any[]>([]);
|
||||
const [selectedProdutoId, setSelectedProdutoId] = useState<string>('');
|
||||
@@ -162,12 +163,6 @@ export const LancarGTO: React.FC = () => {
|
||||
[labProdutos, selectedProdutoId]
|
||||
);
|
||||
|
||||
// Carrega os laboratórios acessíveis assim que a especialidade de prótese é escolhida.
|
||||
useEffect(() => {
|
||||
if (!isProtese || laboratorios.length) return;
|
||||
HybridBackend.getProteseLaboratorios().then(l => setLaboratorios(Array.isArray(l) ? l : [])).catch(() => {});
|
||||
}, [isProtese, laboratorios.length]);
|
||||
|
||||
// Catálogo do laboratório selecionado.
|
||||
useEffect(() => {
|
||||
if (!selectedLab) { setLabProdutos([]); return; }
|
||||
@@ -463,10 +458,10 @@ export const LancarGTO: React.FC = () => {
|
||||
<Stethoscope size={20} strokeWidth={3} /> 02. Especialidade
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<select
|
||||
<SearchSelect
|
||||
value={selectedEsp}
|
||||
onChange={(e) => {
|
||||
setSelectedEsp(e.target.value);
|
||||
onChange={(v) => {
|
||||
setSelectedEsp(v);
|
||||
setSelectedProc(null);
|
||||
setSelectedDentes([]);
|
||||
setSelectedArco(null);
|
||||
@@ -474,22 +469,13 @@ export const LancarGTO: React.FC = () => {
|
||||
setProtValor('');
|
||||
setProtObs('');
|
||||
}}
|
||||
className="w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl font-black text-gray-700 text-sm focus:border-teal-400 outline-none transition-all uppercase"
|
||||
>
|
||||
<option value="">ESCOLHA A ÁREA...</option>
|
||||
{especialidades.map(es => <option key={es.id} value={es.id}>{es.nome}</option>)}
|
||||
</select>
|
||||
options={especialidades.map(es => ({ value: es.id, label: es.nome }))}
|
||||
placeholder="ESCOLHA A ÁREA..." title="Especialidade"
|
||||
/>
|
||||
|
||||
{selectedEsp && isProtese && (
|
||||
<div className="space-y-3">
|
||||
<select
|
||||
value={selectedLab}
|
||||
onChange={(e) => { setSelectedLab(e.target.value); setSelectedProdutoId(''); setProtValor(''); }}
|
||||
className="w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl font-black text-gray-700 text-sm focus:border-teal-400 outline-none transition-all uppercase"
|
||||
>
|
||||
<option value="">ESCOLHA O LABORATÓRIO / PROTÉTICO...</option>
|
||||
{laboratorios.map(l => <option key={l.id} value={l.id}>{l.nome}{l.interno ? ' (INTERNO)' : ''}{l.nota != null ? ` · ★ ${l.nota}` : ''}</option>)}
|
||||
</select>
|
||||
<ProteticoPicker value={selectedLab} onChange={(id) => { setSelectedLab(id); setSelectedProdutoId(''); setProtValor(''); }} />
|
||||
|
||||
{selectedLab && (labProdutos.length === 0 ? (
|
||||
<div className="bg-amber-50 border-2 border-amber-100 rounded-2xl p-4 text-center">
|
||||
@@ -497,18 +483,17 @@ export const LancarGTO: React.FC = () => {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<select
|
||||
<SearchSelect
|
||||
value={selectedProdutoId}
|
||||
onChange={(e) => {
|
||||
setSelectedProdutoId(e.target.value);
|
||||
const pr = labProdutos.find(p => p.id === e.target.value);
|
||||
onChange={(v) => {
|
||||
setSelectedProdutoId(v);
|
||||
const pr = labProdutos.find(p => p.id === v);
|
||||
setProtValor(pr ? String(pr.preco ?? '') : '');
|
||||
}}
|
||||
options={labProdutos.map(p => ({ value: p.id, label: p.nome, hint: `R$ ${Number(p.preco || 0).toFixed(2)}` }))}
|
||||
placeholder="ESCOLHA A PRÓTESE..." title="Produto do laboratório"
|
||||
className="w-full p-4 bg-white border-2 border-teal-100 rounded-2xl font-black text-gray-800 text-sm shadow-inner uppercase"
|
||||
>
|
||||
<option value="">ESCOLHA A PRÓTESE...</option>
|
||||
{labProdutos.map(p => <option key={p.id} value={p.id}>{p.nome} — R$ {Number(p.preco || 0).toFixed(2)}</option>)}
|
||||
</select>
|
||||
/>
|
||||
|
||||
{selectedProduto && (
|
||||
<>
|
||||
@@ -556,21 +541,20 @@ export const LancarGTO: React.FC = () => {
|
||||
<p className="text-[10px] font-bold text-amber-500 uppercase mt-1">Cadastre em Procedimentos para usá-lo aqui</p>
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
<SearchSelect
|
||||
value={selectedProc?.id || ''}
|
||||
onChange={(e) => {
|
||||
const pr = procedimentos.find(p => p.id === e.target.value);
|
||||
onChange={(v) => {
|
||||
const pr = procedimentos.find(p => p.id === v);
|
||||
setSelectedProc(pr || null);
|
||||
setSelectedDentes([]);
|
||||
setSelectedArco(null);
|
||||
setArcoFace('');
|
||||
setArcoObs('');
|
||||
}}
|
||||
options={filteredProcedimentos.map(p => ({ value: p.id, label: p.nome }))}
|
||||
placeholder="BUSCAR PROCEDIMENTO" title="Procedimento"
|
||||
className="w-full p-4 bg-white border-2 border-teal-100 rounded-2xl font-black text-gray-800 text-sm shadow-inner uppercase"
|
||||
>
|
||||
<option value="">BUSCAR PROCEDIMENTO</option>
|
||||
{filteredProcedimentos.map(p => <option key={p.id} value={p.id}>{p.nome}</option>)}
|
||||
</select>
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedProc && (
|
||||
@@ -601,28 +585,23 @@ export const LancarGTO: React.FC = () => {
|
||||
<div className="flex items-center gap-3 text-[#2d6a4f] font-black text-base uppercase tracking-wider">
|
||||
<Check size={20} strokeWidth={3} className="bg-green-100 p-1 rounded-full text-green-700" /> 03. Dentista Executor
|
||||
</div>
|
||||
<select
|
||||
<SearchSelect
|
||||
value={store.dentista?.id || ''}
|
||||
onChange={(e) => {
|
||||
const d = dentistas.find(d => d.id === e.target.value);
|
||||
store.setDentista(d || null);
|
||||
}}
|
||||
onChange={(v) => { const d = dentistas.find(d => d.id === v); store.setDentista(d || null); }}
|
||||
options={dentistas.map(d => ({ value: d.id, label: d.nome }))}
|
||||
placeholder="SELECIONE O PROFISSIONAL" title="Dentista executor"
|
||||
className="w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl font-black text-gray-800 text-sm focus:border-green-500 outline-none transition-all uppercase"
|
||||
>
|
||||
<option value="">SELECIONE O PROFISSIONAL</option>
|
||||
{dentistas.map(d => <option key={d.id} value={d.id}>{d.nome}</option>)}
|
||||
</select>
|
||||
/>
|
||||
|
||||
<div className="pt-1">
|
||||
<label className="text-[11px] font-black text-gray-400 uppercase tracking-widest mb-1.5 block">Convênio / Credenciada</label>
|
||||
<select
|
||||
<SearchSelect
|
||||
value={store.credenciada || ''}
|
||||
onChange={(e) => store.setCredenciada(e.target.value)}
|
||||
onChange={(v) => store.setCredenciada(v)}
|
||||
options={convenios.map(c => ({ value: c.id, label: c.nome }))}
|
||||
placeholder="SELECIONE O CONVÊNIO" title="Convênio / Credenciada"
|
||||
className="w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl font-black text-gray-800 text-sm focus:border-green-500 outline-none transition-all uppercase"
|
||||
>
|
||||
<option value="">SELECIONE O CONVÊNIO</option>
|
||||
{convenios.map(c => <option key={c.id} value={c.id}>{c.nome}</option>)}
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -29,6 +29,31 @@ interface PluginDef {
|
||||
}
|
||||
|
||||
const PLUGINS: PluginDef[] = [
|
||||
{
|
||||
id: 'newwhats',
|
||||
name: 'NewWhats — WhatsApp & Secretária IA',
|
||||
tagline: 'Inbox WhatsApp + Atendente virtual',
|
||||
description: 'Integração com o motor NewWhats: Inbox de WhatsApp em tempo real e Secretária IA. Inbox e Secretária ficam disponíveis para todos os usuários; esta configuração é exclusiva do super-admin.',
|
||||
version: '1.0.0',
|
||||
author: 'ScoreOdonto',
|
||||
category: 'COMUNICAÇÃO',
|
||||
color: '#16a34a',
|
||||
sidebarItems: [
|
||||
{ id: 'wa-inbox', label: 'WHATSAPP' },
|
||||
{ id: 'wa-secretaria', label: 'SECRETÁRIA IA' },
|
||||
],
|
||||
features: [
|
||||
'Inbox WhatsApp (conversas, mensagens, envio)',
|
||||
'Secretária IA (atendente virtual do motor)',
|
||||
'Base de conhecimento da clínica sincronizada com o motor',
|
||||
],
|
||||
configFields: [
|
||||
{ key: 'NEWWHATS_URL', label: 'URL DO MOTOR', type: 'text', placeholder: 'https://newwhats.clube67.com', defaultValue: '' },
|
||||
{ key: 'NEWWHATS_INTEGRATION_KEY', label: 'CHAVE DE INTEGRAÇÃO', type: 'password', placeholder: 'nw_...', defaultValue: '' },
|
||||
{ key: 'NEWWHATS_WEBHOOK_SECRET', label: 'SEGREDO DO WEBHOOK', type: 'password', placeholder: '(HMAC)', defaultValue: '' },
|
||||
{ key: 'NEWWHATS_CLINICA_ID', label: 'CLÍNICA (ID)', type: 'text', placeholder: 'c_...', defaultValue: '' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'rx-scoreodonto',
|
||||
name: 'RX ScoreOdonto',
|
||||
@@ -171,6 +196,161 @@ const ConfigModal: React.FC<{ plugin: PluginDef; onClose: () => void }> = ({ plu
|
||||
};
|
||||
|
||||
// ─── Plugin Card ──────────────────────────────────────────────────────────────
|
||||
// ─── Config dedicada do NewWhats (dropdown de clínicas + backend + testar) ──────
|
||||
const NW_API = (import.meta as any).env?.VITE_API_URL || '/api';
|
||||
const nwAuthHeaders = () => {
|
||||
const t = localStorage.getItem('SCOREODONTO_AUTH_TOKEN');
|
||||
return { 'Content-Type': 'application/json', ...(t ? { Authorization: `Bearer ${t}` } : {}) };
|
||||
};
|
||||
|
||||
const NewwhatsConfigModal: React.FC<{ plugin: PluginDef; onClose: () => void }> = ({ plugin, onClose }) => {
|
||||
const toast = useToast();
|
||||
const [motorUrl, setMotorUrl] = useState('');
|
||||
const [clinicaId, setClinicaId] = useState('');
|
||||
const [integrationKey, setIntegrationKey] = useState('');
|
||||
const [webhookSecret, setWebhookSecret] = useState('');
|
||||
const [hasKey, setHasKey] = useState(false);
|
||||
const [hasSecret, setHasSecret] = useState(false);
|
||||
const [clinics, setClinics] = useState<Array<{ id: string; nome_fantasia: string }>>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [test, setTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||
const [token, setToken] = useState('');
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [advanced, setAdvanced] = useState(false);
|
||||
|
||||
const loadCfg = async () => {
|
||||
try {
|
||||
const cfgR = await fetch(`${NW_API}/nw/config`, { headers: nwAuthHeaders() });
|
||||
if (cfgR.ok) {
|
||||
const c = await cfgR.json();
|
||||
setMotorUrl(c.motorUrl || ''); setClinicaId(c.clinicaId || '');
|
||||
setHasKey(!!c.hasIntegrationKey); setHasSecret(!!c.hasWebhookSecret);
|
||||
}
|
||||
} catch { /* noop */ }
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const clR = await fetch(`${NW_API}/nw/clinicas`, { headers: nwAuthHeaders() });
|
||||
if (clR.ok) setClinics(await clR.json());
|
||||
await loadCfg();
|
||||
} catch { /* noop */ }
|
||||
finally { setLoading(false); }
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const applyToken = async () => {
|
||||
if (!token.trim()) return;
|
||||
setApplying(true); setTest(null);
|
||||
try {
|
||||
const r = await fetch(`${NW_API}/nw/config/token`, { method: 'POST', headers: nwAuthHeaders(), body: JSON.stringify({ token: token.trim() }) });
|
||||
const j = await r.json();
|
||||
if (!r.ok) throw new Error(j.error || 'Falha ao aplicar token');
|
||||
await loadCfg();
|
||||
setToken('');
|
||||
setTest({ ok: true, msg: 'Token aplicado — ' + (j.detail || 'configurado') });
|
||||
toast.success('TOKEN APLICADO! Conexão configurada.');
|
||||
} catch (e: any) { setTest({ ok: false, msg: String(e.message).slice(0, 120) }); }
|
||||
finally { setApplying(false); }
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const body: any = { motorUrl, clinicaId };
|
||||
if (integrationKey) body.integrationKey = integrationKey;
|
||||
if (webhookSecret) body.webhookSecret = webhookSecret;
|
||||
const r = await fetch(`${NW_API}/nw/config`, { method: 'PUT', headers: nwAuthHeaders(), body: JSON.stringify(body) });
|
||||
if (!r.ok) throw new Error(await r.text());
|
||||
toast.success('CONFIGURAÇÕES SALVAS!');
|
||||
onClose();
|
||||
} catch (e: any) { toast.error('FALHA AO SALVAR: ' + String(e.message).slice(0, 80)); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const handleTest = async () => {
|
||||
setTest(null);
|
||||
try {
|
||||
const r = await fetch(`${NW_API}/nw/status`, { headers: nwAuthHeaders() });
|
||||
const s = await r.json();
|
||||
setTest({ ok: !!s.motorOk, msg: s.detail || (s.motorOk ? 'OK' : 'Falha') });
|
||||
} catch (e: any) { setTest({ ok: false, msg: String(e.message).slice(0, 80) }); }
|
||||
};
|
||||
|
||||
const inputCls = "w-full border border-gray-200 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:border-green-400 focus:ring-2 focus:ring-green-100 bg-white";
|
||||
|
||||
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">
|
||||
<div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between" style={{ background: `linear-gradient(to right, ${plugin.color}10, transparent)` }}>
|
||||
<div>
|
||||
<h3 className="text-sm font-black text-gray-900 uppercase">CONFIGURAR {plugin.name}</h3>
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest" style={{ color: plugin.color }}>CONEXÃO COM O MOTOR</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">
|
||||
{loading ? <div className="text-sm text-gray-500">Carregando…</div> : <>
|
||||
{/* Caminho principal: colar o token de pareamento → auto-configura */}
|
||||
<div>
|
||||
<label className="text-[9px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">TOKEN DE PAREAMENTO</label>
|
||||
<textarea className={inputCls + ' font-mono text-xs h-20 resize-none'} value={token} onChange={e => setToken(e.target.value)} placeholder="Cole aqui o token do motor (nwpair_…). URL, chave e webhook são configurados automaticamente." />
|
||||
<button onClick={applyToken} disabled={applying || !token.trim()} className="mt-2 w-full py-2.5 text-white rounded-xl text-xs font-black uppercase flex items-center justify-center gap-2 disabled:opacity-50" style={{ backgroundColor: plugin.color }}>
|
||||
{applying ? 'APLICANDO…' : 'APLICAR TOKEN'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Estado atual da conexão */}
|
||||
<div className="text-[11px] text-gray-500 bg-gray-50 rounded-xl px-3 py-2">
|
||||
Motor: <b>{motorUrl || '—'}</b> · Chave: <b className={hasKey ? 'text-green-600' : ''}>{hasKey ? 'definida' : '—'}</b> · Webhook: <b className={hasSecret ? 'text-green-600' : ''}>{hasSecret ? 'definido' : '—'}</b>
|
||||
</div>
|
||||
{test && <div className={`text-xs font-bold ${test.ok ? 'text-green-600' : 'text-red-600'}`}>{test.ok ? '✓ ' : '✗ '}{test.msg}</div>}
|
||||
|
||||
{/* Clínica — local (o motor não conhece os IDs daqui) */}
|
||||
<div>
|
||||
<label className="text-[9px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">CLÍNICA</label>
|
||||
<select className={inputCls} value={clinicaId} onChange={e => setClinicaId(e.target.value)}>
|
||||
<option value="">— selecione a clínica —</option>
|
||||
{clinics.map(c => <option key={c.id} value={c.id}>{c.nome_fantasia || c.id}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Configuração manual (avançado) */}
|
||||
<button onClick={() => setAdvanced(a => !a)} className="text-[10px] font-black uppercase tracking-widest text-gray-400 hover:text-gray-600">
|
||||
{advanced ? '▾ ocultar configuração manual' : '▸ configuração manual'}
|
||||
</button>
|
||||
{advanced && <div className="space-y-4 border-t border-gray-100 pt-4">
|
||||
<div>
|
||||
<label className="text-[9px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">URL DO MOTOR</label>
|
||||
<input className={inputCls} value={motorUrl} onChange={e => setMotorUrl(e.target.value)} placeholder="https://newwhats.clube67.com" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[9px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">CHAVE DE INTEGRAÇÃO {hasKey && <span className="text-green-600">(definida)</span>}</label>
|
||||
<input className={inputCls} type="password" value={integrationKey} onChange={e => setIntegrationKey(e.target.value)} placeholder={hasKey ? '•••• (deixe em branco p/ manter)' : 'nw_...'} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[9px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">SEGREDO DO WEBHOOK {hasSecret && <span className="text-green-600">(definido)</span>}</label>
|
||||
<input className={inputCls} type="password" value={webhookSecret} onChange={e => setWebhookSecret(e.target.value)} placeholder={hasSecret ? '•••• (deixe em branco p/ manter)' : '(HMAC)'} />
|
||||
</div>
|
||||
<button onClick={handleTest} className="text-xs font-black uppercase text-gray-600 border border-gray-200 rounded-xl px-4 py-2 hover:bg-gray-50">Testar conexão</button>
|
||||
</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={handleSave} 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: plugin.color }}>
|
||||
<Save size={14} /> {saving ? 'SALVANDO…' : 'SALVAR'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PluginCard: React.FC<{ plugin: PluginDef; isActive: boolean; alwaysOn?: boolean; price?: number; onToggle: () => void; onConfigure: () => void }> = ({ plugin, isActive, alwaysOn, price, onToggle, onConfigure }) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
@@ -366,7 +546,10 @@ export const PluginsView: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Config modal */}
|
||||
{configuringPlugin && (
|
||||
{configuringPlugin && configuringPlugin.id === 'newwhats' && (
|
||||
<NewwhatsConfigModal plugin={configuringPlugin} onClose={() => setConfiguringPlugin(null)} />
|
||||
)}
|
||||
{configuringPlugin && configuringPlugin.id !== 'newwhats' && (
|
||||
<ConfigModal plugin={configuringPlugin} onClose={() => setConfiguringPlugin(null)} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { FlaskConical, Plus, X, Search, Clock, AlertTriangle, ImagePlus, ChevronRight, Trash2, RefreshCw, ShieldCheck, Tags, Pencil, Check, Package, Wallet, Camera, CheckCircle2, Circle, History, Printer, MapPin, Building2, Share2, FileText, Star } 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 { rotulosPaciente } from '../utils/pacienteLabel.ts';
|
||||
import { ProteticoPicker } from '../components/ProteticoPicker.tsx';
|
||||
import { SearchSelect } from '../components/SearchSelect.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('/');
|
||||
@@ -62,7 +65,7 @@ const ProteticoInternoInline: React.FC<{ onCriado: (id: string) => void }> = ({
|
||||
// ── Modal: Nova OS (lado clínica) ───────────────────────────────────────────
|
||||
const NovaOSModal: React.FC<{ onClose: () => void; onSaved: () => void }> = ({ onClose, onSaved }) => {
|
||||
const toast = useToast();
|
||||
const [labs, setLabs] = useState<any[]>([]);
|
||||
const [pickerKey, setPickerKey] = useState(0);
|
||||
const [dentistas, setDentistas] = useState<any[]>([]);
|
||||
const [busca, setBusca] = useState('');
|
||||
const [pacientes, setPacientes] = useState<any[]>([]);
|
||||
@@ -73,7 +76,6 @@ const NovaOSModal: React.FC<{ onClose: () => void; onSaved: () => void }> = ({ o
|
||||
const ws = HybridBackend.getActiveWorkspace();
|
||||
|
||||
useEffect(() => {
|
||||
HybridBackend.getProteseLaboratorios().then(r => setLabs(Array.isArray(r) ? r : [])).catch(() => setLabs([]));
|
||||
HybridBackend.getDentistas(ws?.id).then(r => setDentistas(Array.isArray(r) ? r : [])).catch(() => setDentistas([]));
|
||||
}, [ws?.id]);
|
||||
|
||||
@@ -151,20 +153,17 @@ const NovaOSModal: React.FC<{ onClose: () => void; onSaved: () => void }> = ({ o
|
||||
{/* Laboratório */}
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-gray-500 uppercase">Laboratório / Protético *</label>
|
||||
<select value={form.protetico_id} onChange={e => set('protetico_id', e.target.value)} className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm">
|
||||
<option value="">Selecione...</option>
|
||||
{labs.map(l => <option key={l.id} value={l.id}>{l.nome}{l.interno ? ' (interno)' : ''}{l.nota != null ? ` · ★ ${l.nota}` : ''}</option>)}
|
||||
</select>
|
||||
<ProteticoInternoInline onCriado={(id) => { HybridBackend.getProteseLaboratorios().then(r => { setLabs(Array.isArray(r) ? r : []); set('protetico_id', id); }); }} />
|
||||
<div className="mt-1"><ProteticoPicker key={pickerKey} value={form.protetico_id} onChange={(id) => set('protetico_id', id)} /></div>
|
||||
<ProteticoInternoInline onCriado={(id) => { set('protetico_id', id); setPickerKey(k => k + 1); }} />
|
||||
</div>
|
||||
{/* Produto (catálogo do laboratório — preço fixo) */}
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-gray-500 uppercase">Produto / Trabalho *</label>
|
||||
{form.protetico_id && produtos.length > 0 ? (
|
||||
<select value={form.produto_id} onChange={e => set('produto_id', e.target.value)} className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm">
|
||||
<option value="">Selecione...</option>
|
||||
{produtos.map(p => <option key={p.id} value={p.id}>{p.nome} — {fmtBRL(p.preco)}</option>)}
|
||||
</select>
|
||||
<div className="mt-1"><SearchSelect value={form.produto_id} onChange={(v) => set('produto_id', v)}
|
||||
options={produtos.map(p => ({ value: p.id, label: p.nome, hint: fmtBRL(p.preco) }))}
|
||||
placeholder="Selecione..." title="Produto do laboratório"
|
||||
className="w-full px-3 py-2 rounded-xl border border-gray-200 text-sm" /></div>
|
||||
) : (
|
||||
<input value={form.tipo} onChange={e => set('tipo', e.target.value)} placeholder={form.protetico_id ? 'Sem catálogo — descreva o trabalho' : 'Selecione o laboratório primeiro'} disabled={!form.protetico_id}
|
||||
className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm disabled:bg-gray-50" />
|
||||
@@ -176,10 +175,10 @@ const NovaOSModal: React.FC<{ onClose: () => void; onSaved: () => void }> = ({ o
|
||||
{/* Dentista */}
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-gray-500 uppercase">Dentista</label>
|
||||
<select value={form.dentista_id} onChange={e => set('dentista_id', e.target.value)} className="w-full mt-1 px-3 py-2 rounded-xl border border-gray-200 text-sm">
|
||||
<option value="">—</option>
|
||||
{dentistas.map(d => <option key={d.id} value={d.id}>{d.nome}</option>)}
|
||||
</select>
|
||||
<div className="mt-1"><SearchSelect value={form.dentista_id} onChange={(v) => set('dentista_id', v)}
|
||||
options={dentistas.map(d => ({ value: d.id, label: d.nome }))}
|
||||
placeholder="—" title="Dentista"
|
||||
className="w-full px-3 py-2 rounded-xl border border-gray-200 text-sm" /></div>
|
||||
</div>
|
||||
{/* Material + cor + dentes */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
@@ -1010,6 +1009,7 @@ const CotacoesClinicaModal: React.FC<{ onClose: () => void; onEscolhida: () => v
|
||||
const [dentes, setDentes] = useState('');
|
||||
const [prazo, setPrazo] = useState('');
|
||||
const [sel, setSel] = useState<string[]>([]);
|
||||
const [buscaLab, setBuscaLab] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const carregar = useCallback(() => HybridBackend.getRfqs().then(r => setRfqs(Array.isArray(r) ? r : [])).catch(() => setRfqs([])), []);
|
||||
@@ -1052,8 +1052,12 @@ const CotacoesClinicaModal: React.FC<{ onClose: () => void; onEscolhida: () => v
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-gray-400 uppercase mb-2">Laboratórios a convidar</p>
|
||||
<div className="relative mb-2">
|
||||
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-300" />
|
||||
<input value={buscaLab} onChange={e => setBuscaLab(e.target.value)} placeholder="Buscar laboratório…" className="w-full pl-8 pr-3 py-2 bg-gray-50 border border-gray-100 rounded-xl text-sm outline-none focus:border-violet-400" />
|
||||
</div>
|
||||
<div className="space-y-1.5 max-h-56 overflow-y-auto">
|
||||
{labs.map(l => (
|
||||
{labs.filter(l => !buscaLab.trim() || (l.nome || '').toLowerCase().includes(buscaLab.toLowerCase()) || (l.email || '').toLowerCase().includes(buscaLab.toLowerCase())).map(l => (
|
||||
<button key={l.id} onClick={() => toggleLab(l.id)} className={`w-full flex items-center gap-2 px-3 py-2 rounded-xl border text-left ${sel.includes(l.id) ? 'border-violet-400 bg-violet-50' : 'border-gray-100'}`}>
|
||||
<span className={`w-4 h-4 rounded border flex items-center justify-center ${sel.includes(l.id) ? 'bg-violet-600 border-violet-600' : 'border-gray-300'}`}>{sel.includes(l.id) && <Check size={11} className="text-white" />}</span>
|
||||
<span className="text-sm font-bold text-gray-700 flex-1 truncate">{l.nome}</span>
|
||||
@@ -1102,6 +1106,8 @@ export const ProteseView: React.FC = () => {
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
const [lista, setLista] = useState<any[]>([]);
|
||||
// Rótulos desambiguados de homônimos (só faz sentido na bancada, onde o protético lê o nome sem CPF).
|
||||
const rotuloPac = useMemo(() => modo === 'bancada' ? rotulosPaciente(lista) : new Map<string, string>(), [lista, modo]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filtro, setFiltro] = useState('');
|
||||
const [novaOS, setNovaOS] = useState(false);
|
||||
@@ -1175,7 +1181,7 @@ export const ProteseView: React.FC = () => {
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-bold text-gray-800 text-sm truncate">{os.tipo} {os.dentes ? <span className="text-gray-400 font-medium">· {os.dentes}</span> : ''}</p>
|
||||
<p className="text-xs text-gray-400 truncate">
|
||||
{modo === 'bancada' ? (os.paciente_nome || 'Sem paciente') : (os.protetico_nome || 'Laboratório')}
|
||||
{modo === 'bancada' ? (rotuloPac.get(os.id) || os.paciente_nome || 'Sem paciente') : (os.protetico_nome || 'Laboratório')}
|
||||
{os.prazo_entrega ? ` · entrega ${dataBR(os.prazo_entrega)}` : ''}
|
||||
</p>
|
||||
{/* Clínica de origem: essencial quando o lab atende várias clínicas. */}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
CheckCircle2, XCircle, Clock, Search, ChevronLeft, ChevronRight,
|
||||
TrendingUp, Stethoscope, FlaskConical, User, ToggleLeft, ToggleRight,
|
||||
Save, AlertTriangle, Wifi, WifiOff, Briefcase, Trash2, Bell, Send,
|
||||
MapPin, Phone, MoreVertical, X, ArrowLeft, UserX, Sparkles, DoorOpen, UserSearch
|
||||
MapPin, Phone, MoreVertical, X, ArrowLeft, UserX, Sparkles, DoorOpen, UserSearch, Power
|
||||
} from 'lucide-react';
|
||||
|
||||
const apiFetch = (url: string, opts: RequestInit = {}) => {
|
||||
@@ -74,7 +74,7 @@ const KPI: React.FC<{ icon: React.ElementType; label: string; value: string | nu
|
||||
);
|
||||
};
|
||||
|
||||
type SuperAdminTab = 'overview' | 'users' | 'clinicas' | 'logs' | 'financeiro' | 'notificacoes';
|
||||
type SuperAdminTab = 'overview' | 'users' | 'clinicas' | 'logs' | 'financeiro' | 'notificacoes' | 'secretaria';
|
||||
|
||||
/* ═══════════════════════════════════════════════════ MAIN VIEW */
|
||||
export const SuperAdminView: React.FC<{ tab?: SuperAdminTab }> = ({ tab = 'overview' }) => {
|
||||
@@ -86,6 +86,74 @@ export const SuperAdminView: React.FC<{ tab?: SuperAdminTab }> = ({ tab = 'overv
|
||||
{tab === 'logs' && <TabLogs />}
|
||||
{tab === 'financeiro' && <TabFinanceiro />}
|
||||
{tab === 'notificacoes' && <TabNotificacoes />}
|
||||
{tab === 'secretaria' && <TabSecretaria />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ═══════════════════════════════════════════════════ TAB SECRETÁRIA (liga/desliga por sessão) */
|
||||
const TabSecretaria: React.FC = () => {
|
||||
const [log, setLog] = useState<any[]>([]);
|
||||
const [configurado, setConfigurado] = useState(true);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
apiFetch('/api/superadmin/secretaria-desligamentos')
|
||||
.then(r => r.json())
|
||||
.then(d => { setLog(Array.isArray(d.log) ? d.log : []); setConfigurado(d.configurado !== false); })
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const totalOff = log.filter(l => !l.enabled).length;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div>
|
||||
<h2 className="text-lg font-black text-gray-900 uppercase tracking-tight flex items-center gap-2"><Power size={18} className="text-red-600" /> Secretária IA — liga/desliga por sessão</h2>
|
||||
<p className="text-xs text-gray-400 font-medium">Quem ligou/desligou a Secretária de cada número, e o motivo do desligamento. {totalOff > 0 && `${totalOff} desligamento(s).`}</p>
|
||||
</div>
|
||||
<button onClick={load} className="p-2 rounded-xl border border-gray-200 text-gray-400 hover:text-gray-700"><RefreshCw size={16} /></button>
|
||||
</div>
|
||||
{!configurado ? (
|
||||
<div className="bg-amber-50 text-amber-700 text-sm rounded-2xl p-4 flex items-center gap-2"><AlertTriangle size={16} /> Integração com o motor da Secretária não está configurada neste ambiente.</div>
|
||||
) : loading ? (
|
||||
<div className="text-center text-gray-400 py-16">Carregando…</div>
|
||||
) : log.length === 0 ? (
|
||||
<div className="text-center text-gray-400 py-16">Nenhum registro de liga/desliga ainda.</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-x-auto">
|
||||
<table className="w-full text-sm min-w-[640px]">
|
||||
<thead className="bg-gray-50 text-[10px] font-black text-gray-400 uppercase tracking-widest">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3">Ação</th>
|
||||
<th className="text-left px-4 py-3">Número</th>
|
||||
<th className="text-left px-4 py-3">Quem</th>
|
||||
<th className="text-left px-4 py-3">Quando</th>
|
||||
<th className="text-left px-4 py-3">Motivo</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{log.map((l, i) => (
|
||||
<tr key={i} className={`border-t border-gray-50 ${!l.enabled ? 'bg-red-50/30' : ''}`}>
|
||||
<td className="px-4 py-3">
|
||||
{l.enabled
|
||||
? <span className="inline-flex items-center gap-1 text-emerald-700 text-[11px] font-black uppercase"><ToggleRight size={14} /> Ligou</span>
|
||||
: <span className="inline-flex items-center gap-1 text-red-600 text-[11px] font-black uppercase"><ToggleLeft size={14} /> Desligou</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-700 font-bold whitespace-nowrap">{l.phone || l.name || l.instance_id}</td>
|
||||
<td className="px-4 py-3 text-gray-600 whitespace-nowrap">{l.by || '—'}</td>
|
||||
<td className="px-4 py-3 text-gray-500 whitespace-nowrap">{fmt(l.at)}</td>
|
||||
<td className="px-4 py-3 text-gray-600">{l.reason || <span className="text-gray-300">—</span>}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
// WaInboxView — Inbox WhatsApp do scoreodonto, consumindo o motor NewWhats via
|
||||
// proxy do backend (/api/nw/v1/* → motor /api/ext/v1/*). Estágio 1: lista de
|
||||
// conversas + thread + envio. Visual aproximado do motor (WhatsApp-like).
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { PageHeader } from '../components/PageHeader.tsx';
|
||||
|
||||
const API = (import.meta as any).env?.VITE_API_URL || '/api';
|
||||
|
||||
async function nw(path: string, opts: RequestInit = {}) {
|
||||
const token = localStorage.getItem('SCOREODONTO_AUTH_TOKEN');
|
||||
const res = await fetch(`${API}/nw/v1${path}`, {
|
||||
...opts,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(opts.headers || {}),
|
||||
},
|
||||
});
|
||||
if (!res.ok) throw new Error(`${res.status} ${await res.text().catch(() => '')}`.slice(0, 200));
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// URL do túnel WS (/api/nw/v1/stream → motor). Deriva o host do API (relativo ou
|
||||
// absoluto) e troca http(s)→ws(s). O JWT vai no query porque o browser não envia
|
||||
// Authorization no handshake de WebSocket.
|
||||
function streamUrl(token: string) {
|
||||
const base = API.startsWith('http') ? API : `${window.location.origin}${API}`;
|
||||
const u = new URL(`${base}/nw/v1/stream`);
|
||||
u.protocol = u.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
u.searchParams.set('token', token);
|
||||
return u.toString();
|
||||
}
|
||||
|
||||
interface Chat { id: string | number; remote_jid?: string; displayName?: string; phone?: string; last_message_text?: string | null; unread_count?: number }
|
||||
interface Msg { id: string | number; text?: string; direction?: 'in' | 'out'; from_me?: number; timestamp?: number; type?: string }
|
||||
|
||||
// Normaliza a mensagem do stream (shape do motor) para o Msg do inbox.
|
||||
function normalizeWsMsg(m: any): Msg {
|
||||
return {
|
||||
id: m?.id ?? `ws-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||
text: m?.body ?? m?.text,
|
||||
from_me: (m?.fromMe === true || m?.fromMe === 1) ? 1 : 0,
|
||||
direction: m?.fromMe ? 'out' : 'in',
|
||||
type: m?.type,
|
||||
timestamp: m?.timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
// Atualiza a lista de conversas com uma mensagem nova: preview, badge de unread
|
||||
// (só quando não é a conversa aberta e não é enviada por nós) e sobe pro topo.
|
||||
function bumpChat(list: Chat[], chatId: any, m: Msg, countUnread: boolean): Chat[] {
|
||||
const i = list.findIndex((c) => String(c.id) === String(chatId));
|
||||
if (i === -1) return list; // conversa ainda não carregada — aparece no próximo refresh
|
||||
const updated: Chat = { ...list[i], last_message_text: m.text || `[${m.type || 'mídia'}]` };
|
||||
if (countUnread && m.from_me !== 1) updated.unread_count = (updated.unread_count || 0) + 1;
|
||||
return [updated, ...list.filter((_, idx) => idx !== i)];
|
||||
}
|
||||
|
||||
export const WaInboxView: React.FC = () => {
|
||||
const [sessions, setSessions] = useState<any[]>([]);
|
||||
const [session, setSession] = useState<string>('');
|
||||
const [chats, setChats] = useState<Chat[]>([]);
|
||||
const [active, setActive] = useState<Chat | null>(null);
|
||||
const [msgs, setMsgs] = useState<Msg[]>([]);
|
||||
const [text, setText] = useState('');
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [wsUp, setWsUp] = useState(false);
|
||||
const [qr, setQr] = useState<string | null>(null);
|
||||
const endRef = useRef<HTMLDivElement>(null);
|
||||
// Ref com a conversa aberta para o handler do WS ler sempre o valor atual
|
||||
// (evita closure velha dentro do onmessage).
|
||||
const activeRef = useRef<Chat | null>(null);
|
||||
useEffect(() => { activeRef.current = active; }, [active]);
|
||||
|
||||
// Tempo real: conecta ao túnel WS e reflete eventos do motor sem refetch.
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('SCOREODONTO_AUTH_TOKEN');
|
||||
if (!token) return;
|
||||
let ws: WebSocket | null = null;
|
||||
let retry: ReturnType<typeof setTimeout> | null = null;
|
||||
let closed = false;
|
||||
|
||||
const connect = () => {
|
||||
try { ws = new WebSocket(streamUrl(token)); } catch { return; }
|
||||
ws.onopen = () => setWsUp(true);
|
||||
ws.onerror = () => { try { ws?.close(); } catch { /* ignore */ } };
|
||||
ws.onclose = () => {
|
||||
setWsUp(false);
|
||||
if (!closed) retry = setTimeout(connect, 3000); // reconexão simples
|
||||
};
|
||||
ws.onmessage = (ev) => {
|
||||
let env: any;
|
||||
try { env = JSON.parse(ev.data); } catch { return; }
|
||||
const { event, data } = env || {};
|
||||
if (event === 'message.new') {
|
||||
const norm = normalizeWsMsg(data?.message);
|
||||
const isActive = !!activeRef.current && String(data?.chatId) === String(activeRef.current.id);
|
||||
if (isActive) {
|
||||
setMsgs((p) => p.some((x) => String(x.id) === String(norm.id)) ? p : [...p, norm]);
|
||||
setTimeout(() => endRef.current?.scrollIntoView(), 50);
|
||||
}
|
||||
setChats((prev) => bumpChat(prev, data?.chatId, norm, !isActive));
|
||||
} else if (event === 'session.status') {
|
||||
setSessions((prev) => prev.map((s) =>
|
||||
String(s.id || s.instance) === String(data?.instanceId) ? { ...s, status: data?.status } : s));
|
||||
if (data?.status === 'connected') setQr(null);
|
||||
} else if (event === 'session.qr') {
|
||||
setQr(data?.qrBase64 || null);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
connect();
|
||||
return () => {
|
||||
closed = true;
|
||||
if (retry) clearTimeout(retry);
|
||||
try { ws?.close(); } catch { /* ignore */ }
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
nw('/sessions').then((s) => {
|
||||
const list = Array.isArray(s) ? s : [];
|
||||
setSessions(list);
|
||||
const conn = list.find((i: any) => String(i.status).toLowerCase() === 'connected') || list[0];
|
||||
if (conn) setSession(conn.id || conn.instance);
|
||||
}).catch((e) => setErr(String(e.message)));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session) return;
|
||||
setLoading(true);
|
||||
nw(`/inbox?session=${encodeURIComponent(session)}&limit=50`)
|
||||
.then((c) => setChats(Array.isArray(c) ? c : (c.chats ?? [])))
|
||||
.catch((e) => setErr(String(e.message)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [session]);
|
||||
|
||||
const openChat = async (c: Chat) => {
|
||||
setActive(c); setMsgs([]);
|
||||
setChats((prev) => prev.map((x) => String(x.id) === String(c.id) ? { ...x, unread_count: 0 } : x));
|
||||
try {
|
||||
const m = await nw(`/inbox/${encodeURIComponent(String(c.id))}/messages?limit=50`);
|
||||
setMsgs(Array.isArray(m) ? m : (m.messages ?? []));
|
||||
setTimeout(() => endRef.current?.scrollIntoView(), 50);
|
||||
} catch (e: any) { setErr(String(e.message)); }
|
||||
};
|
||||
|
||||
const send = async () => {
|
||||
if (!active || !text.trim()) return;
|
||||
const body = text.trim(); setText('');
|
||||
try {
|
||||
await nw(`/inbox/${encodeURIComponent(String(active.id))}/send`, { method: 'POST', body: JSON.stringify({ text: body }) });
|
||||
setMsgs((p) => [...p, { id: `tmp-${Date.now()}`, text: body, from_me: 1, direction: 'out', timestamp: Date.now() }]);
|
||||
setTimeout(() => endRef.current?.scrollIntoView(), 50);
|
||||
} catch (e: any) { setErr(String(e.message)); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<PageHeader title="WhatsApp" description="Inbox integrado ao motor NewWhats" />
|
||||
{err && <div style={{ background: '#fee2e2', color: '#991b1b', padding: '8px 12px', fontSize: 13 }}>Erro: {err}. Verifique a configuração do plugin (super-admin) e o pareamento com o motor.</div>}
|
||||
<div style={{ display: 'flex', flex: 1, minHeight: 0, border: '1px solid #e5e7eb', borderRadius: 8, overflow: 'hidden', background: '#fff' }}>
|
||||
{/* Lista de conversas */}
|
||||
<div style={{ width: 320, borderRight: '1px solid #e5e7eb', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ padding: 8, borderBottom: '1px solid #e5e7eb' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6, fontSize: 11, color: wsUp ? '#16a34a' : '#9ca3af' }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: wsUp ? '#22c55e' : '#d1d5db', display: 'inline-block' }} />
|
||||
{wsUp ? 'Tempo real ativo' : 'Conectando tempo real…'}
|
||||
</div>
|
||||
<select value={session} onChange={(e) => setSession(e.target.value)} style={{ width: '100%', padding: 6, fontSize: 13 }}>
|
||||
<option value="">Selecione a sessão…</option>
|
||||
{sessions.map((s) => <option key={s.id || s.instance} value={s.id || s.instance}>{(s.nome || s.name || s.phone || s.id)} ({s.status})</option>)}
|
||||
</select>
|
||||
{qr && (
|
||||
<div style={{ marginTop: 8, textAlign: 'center', background: '#fff', border: '1px solid #e5e7eb', borderRadius: 8, padding: 8 }}>
|
||||
<div style={{ fontSize: 12, color: '#6b7280', marginBottom: 6 }}>Escaneie para conectar</div>
|
||||
<img src={qr.startsWith('data:') ? qr : `data:image/png;base64,${qr}`} alt="QR Code" style={{ width: '100%', maxWidth: 220 }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
{loading && <div style={{ padding: 12, color: '#6b7280', fontSize: 13 }}>Carregando…</div>}
|
||||
{chats.map((c) => (
|
||||
<div key={c.id} onClick={() => openChat(c)}
|
||||
style={{ padding: '10px 12px', cursor: 'pointer', borderBottom: '1px solid #f3f4f6', background: active?.id === c.id ? '#f0fdf4' : 'transparent' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<strong style={{ fontSize: 14 }}>{c.displayName || c.phone || c.remote_jid}</strong>
|
||||
{!!c.unread_count && <span style={{ background: '#22c55e', color: '#fff', borderRadius: 10, fontSize: 11, padding: '0 6px' }}>{c.unread_count}</span>}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#6b7280', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{c.last_message_text || ''}</div>
|
||||
</div>
|
||||
))}
|
||||
{!loading && !chats.length && <div style={{ padding: 12, color: '#9ca3af', fontSize: 13 }}>Nenhuma conversa.</div>}
|
||||
</div>
|
||||
</div>
|
||||
{/* Thread */}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', background: '#efeae2' }}>
|
||||
{!active ? (
|
||||
<div style={{ margin: 'auto', color: '#6b7280' }}>Selecione uma conversa</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ padding: '10px 14px', background: '#f0f2f5', borderBottom: '1px solid #e5e7eb', fontWeight: 600 }}>
|
||||
{active.displayName || active.phone || active.remote_jid}
|
||||
</div>
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: 14 }}>
|
||||
{msgs.map((m) => {
|
||||
const out = m.from_me === 1 || m.direction === 'out';
|
||||
return (
|
||||
<div key={m.id} style={{ display: 'flex', justifyContent: out ? 'flex-end' : 'flex-start', marginBottom: 6 }}>
|
||||
<div style={{ maxWidth: '70%', padding: '6px 10px', borderRadius: 8, fontSize: 14, background: out ? '#d9fdd3' : '#fff', boxShadow: '0 1px 0.5px rgba(0,0,0,.13)' }}>
|
||||
{m.text || <em style={{ color: '#6b7280' }}>[{m.type || 'mídia'}]</em>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div ref={endRef} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, padding: 10, background: '#f0f2f5' }}>
|
||||
<input value={text} onChange={(e) => setText(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && send()}
|
||||
placeholder="Mensagem" style={{ flex: 1, padding: '8px 12px', borderRadius: 20, border: '1px solid #d1d5db' }} />
|
||||
<button onClick={send} style={{ background: '#22c55e', color: '#fff', border: 'none', borderRadius: 20, padding: '8px 16px', cursor: 'pointer' }}>Enviar</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
// WaSecretariaView — painel da Secretária IA (motor) no scoreodonto. Estágio 1:
|
||||
// chat de teste via proxy POST /api/nw/v1/secretaria/ask. A configuração do
|
||||
// cérebro/agente vive no motor (acessível ao super-admin).
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { PageHeader } from '../components/PageHeader.tsx';
|
||||
|
||||
const API = (import.meta as any).env?.VITE_API_URL || '/api';
|
||||
|
||||
async function ask(chatId: string, message: string) {
|
||||
const token = localStorage.getItem('SCOREODONTO_AUTH_TOKEN');
|
||||
const res = await fetch(`${API}/nw/v1/secretaria/ask`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
||||
body: JSON.stringify({ chatId, message, contactName: 'Teste (painel)' }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`${res.status} ${await res.text().catch(() => '')}`.slice(0, 200));
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export const WaSecretariaView: React.FC = () => {
|
||||
const [turns, setTurns] = useState<Array<{ q: string; a: string }>>([]);
|
||||
const [text, setText] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const chatId = useRef(`painel-${Date.now()}`);
|
||||
const endRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const send = async () => {
|
||||
if (!text.trim() || busy) return;
|
||||
const q = text.trim(); setText(''); setBusy(true); setErr(null);
|
||||
try {
|
||||
const r = await ask(chatId.current, q);
|
||||
setTurns((p) => [...p, { q, a: r.reply ?? JSON.stringify(r) }]);
|
||||
setTimeout(() => endRef.current?.scrollIntoView(), 50);
|
||||
} catch (e: any) { setErr(String(e.message)); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<PageHeader title="Secretária IA" description="Atendente virtual (motor NewWhats) — painel de teste" />
|
||||
{err && <div style={{ background: '#fee2e2', color: '#991b1b', padding: '8px 12px', fontSize: 13 }}>Erro: {err}</div>}
|
||||
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', border: '1px solid #e5e7eb', borderRadius: 8, background: '#fff', overflow: 'hidden' }}>
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: 16 }}>
|
||||
{!turns.length && <div style={{ color: '#9ca3af', fontSize: 14 }}>Faça uma pergunta para testar a Secretária (ex.: "Quais procedimentos vocês fazem e os valores?").</div>}
|
||||
{turns.map((t, i) => (
|
||||
<div key={i} style={{ marginBottom: 14 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 4 }}>
|
||||
<div style={{ background: '#dbeafe', padding: '8px 12px', borderRadius: 10, fontSize: 14, maxWidth: '75%' }}>{t.q}</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-start' }}>
|
||||
<div style={{ background: '#f3f4f6', padding: '8px 12px', borderRadius: 10, fontSize: 14, maxWidth: '75%', whiteSpace: 'pre-wrap' }}>{t.a}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{busy && <div style={{ color: '#6b7280', fontSize: 13 }}>Secretária digitando…</div>}
|
||||
<div ref={endRef} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, padding: 10, borderTop: '1px solid #e5e7eb' }}>
|
||||
<input value={text} onChange={(e) => setText(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && send()}
|
||||
placeholder="Pergunte algo…" style={{ flex: 1, padding: '8px 12px', borderRadius: 8, border: '1px solid #d1d5db' }} />
|
||||
<button onClick={send} disabled={busy} style={{ background: '#2563eb', color: '#fff', border: 'none', borderRadius: 8, padding: '8px 16px', cursor: 'pointer', opacity: busy ? 0.6 : 1 }}>Perguntar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,484 @@
|
||||
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import {
|
||||
Search,
|
||||
MoreVertical,
|
||||
User,
|
||||
MessageSquare,
|
||||
ChevronDown,
|
||||
RefreshCw,
|
||||
MessageSquarePlus,
|
||||
RotateCw,
|
||||
ArrowLeft,
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { format } from 'date-fns';
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
import type { NewWhatsChat } from './types/inboxTypes';
|
||||
import { getAvatarProxyUrl, formatPhone } from './utils/inboxUtils';
|
||||
import { useNewInboxBridge } from './hooks/inbox/useNewInboxBridge';
|
||||
import { useInstanceSelector } from './hooks/inbox/useInstanceSelector';
|
||||
import { useChatFilter } from './hooks/inbox/useChatFilter';
|
||||
import { useChatWindow } from './hooks/inbox/useChatWindow';
|
||||
import { useMessageInput } from './hooks/inbox/useMessageInput';
|
||||
import { nw } from './services/nwClient';
|
||||
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 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';
|
||||
|
||||
type Chat = NewWhatsChat;
|
||||
|
||||
export function InboxView({ onNavigate }: { onNavigate?: (view: string) => void }) {
|
||||
|
||||
// ── Seleção de instância (hook dedicado) ─────────────────────────────────
|
||||
const {
|
||||
instances,
|
||||
activeInstance,
|
||||
mappedActiveInstance,
|
||||
connectedInstances,
|
||||
isConnected,
|
||||
dropdownRef,
|
||||
selectInstance,
|
||||
refreshInstances,
|
||||
handleConnect: handleConnectInstance,
|
||||
} = useInstanceSelector()
|
||||
|
||||
// ── Inbox bridge (chats + mensagens) ──────────────────────────────────────
|
||||
const {
|
||||
chats: chatsStore,
|
||||
selectedChat,
|
||||
messages,
|
||||
loadingChats: loading,
|
||||
loadingMessages,
|
||||
presenceByJid,
|
||||
handleSelectChat: handleSelectChatBridge,
|
||||
handleSendText,
|
||||
handleLoadMoreHistory,
|
||||
handleMenuAction,
|
||||
} = useNewInboxBridge()
|
||||
|
||||
// ── Filtros de chat (hook dedicado) ───────────────────────────────────────
|
||||
const {
|
||||
searchTerm,
|
||||
activeFilter,
|
||||
chatScope,
|
||||
chatsByScope,
|
||||
isFavoriteChat,
|
||||
setSearchTerm,
|
||||
setActiveFilter,
|
||||
setChatScope,
|
||||
toggleFavorite,
|
||||
} = useChatFilter(chatsStore)
|
||||
|
||||
// ── Chat window (hook dedicado) ───────────────────────────────────────────
|
||||
const {
|
||||
scrollRef,
|
||||
isTyping,
|
||||
replyingTo,
|
||||
reactionPickerFor,
|
||||
hasMoreHistory,
|
||||
loadingMore,
|
||||
isProfileOpen,
|
||||
isSyncingAvatar,
|
||||
isChatMenuOpen,
|
||||
setReplyingTo,
|
||||
setIsProfileOpen,
|
||||
setIsChatMenuOpen,
|
||||
handleTyping,
|
||||
handleScroll,
|
||||
handleSelectChat: onChatWindowSelect,
|
||||
handleReactToMessage,
|
||||
handleSendMedia,
|
||||
handleSendAudio,
|
||||
handleSyncAvatar,
|
||||
toggleReactionPicker,
|
||||
} = useChatWindow({
|
||||
selectedChat,
|
||||
activeInstanceId: activeInstance?.instance ?? null,
|
||||
onLoadMoreHistory: handleLoadMoreHistory,
|
||||
})
|
||||
|
||||
// ── Mensagem de entrada (hook dedicado) ───────────────────────────────────
|
||||
const { newMessage, setNewMessage, mentions, setMentions, handleSendMessage } = useMessageInput({
|
||||
selectedChat,
|
||||
replyingTo,
|
||||
onClearReply: () => setReplyingTo(null),
|
||||
onSendText: handleSendText,
|
||||
})
|
||||
|
||||
// ── Painel de Protocolo ───────────────────────────────────────────────────
|
||||
const [isProtocolOpen, setIsProtocolOpen] = useState(false)
|
||||
const protocolPanel = useProtocolPanel(selectedChat ? String(selectedChat.id) : null)
|
||||
|
||||
// ── Estado local de UI ────────────────────────────────────────────────────
|
||||
const [activeTab, setActiveTab] = useState<TabType>('chats')
|
||||
const [isInstanceDropdownOpen, setIsInstanceDropdownOpen] = useState(false)
|
||||
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)
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
HybridBackend.getWaAccess().then(a => { if (alive) setCanDeleteMsg(a.isOwner || a.delete_msg) }).catch(() => {})
|
||||
return () => { alive = false }
|
||||
}, [])
|
||||
|
||||
// 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'
|
||||
// 2. ausência do flag sessionStorage 'nw:inbox-loaded' (primeira visita na sessão)
|
||||
// sessionStorage sobrevive a navegações client-side mas é zerado em nova aba.
|
||||
const [pageReady, setPageReady] = useState<boolean>(() => {
|
||||
if (typeof window === 'undefined') return false
|
||||
const navEntry = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming | undefined
|
||||
const isHardReload = navEntry ? navEntry.type === 'reload' : (performance.navigation?.type === 1)
|
||||
if (isHardReload) return false
|
||||
return sessionStorage.getItem('nw:inbox-loaded') === '1'
|
||||
})
|
||||
const prevLoadingRef = useRef(false)
|
||||
const readyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
useEffect(() => {
|
||||
if (prevLoadingRef.current && !loading) {
|
||||
readyTimerRef.current = setTimeout(() => {
|
||||
setPageReady(true)
|
||||
sessionStorage.setItem('nw:inbox-loaded', '1')
|
||||
}, 400)
|
||||
}
|
||||
prevLoadingRef.current = loading
|
||||
return () => { if (readyTimerRef.current) clearTimeout(readyTimerRef.current) }
|
||||
}, [loading])
|
||||
|
||||
const optionsRef = useRef<HTMLDivElement>(null)
|
||||
const initialized = useRef(false)
|
||||
|
||||
// ── Setup mobile + click-outside ─────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (initialized.current) return
|
||||
initialized.current = true
|
||||
const resize = () => setIsMobile(window.innerWidth < 768)
|
||||
window.addEventListener('resize', resize)
|
||||
resize()
|
||||
const click = (e: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) setIsInstanceDropdownOpen(false)
|
||||
if (optionsRef.current && !optionsRef.current.contains(e.target as Node)) setIsOptionsMenuOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', click)
|
||||
return () => { window.removeEventListener('resize', resize); document.removeEventListener('mousedown', click) }
|
||||
}, [])
|
||||
|
||||
// ── Handlers ─────────────────────────────────────────────────────────────
|
||||
|
||||
const handleSelectChat = useCallback((chat: Chat | null) => {
|
||||
handleSelectChatBridge(chat)
|
||||
onChatWindowSelect()
|
||||
setIsProtocolOpen(false)
|
||||
}, [handleSelectChatBridge, onChatWindowSelect])
|
||||
|
||||
const handleChatMenuAction = useCallback((action: string, chat: Chat) => {
|
||||
handleMenuAction(action, chat, toggleFavorite)
|
||||
}, [handleMenuAction, toggleFavorite])
|
||||
|
||||
|
||||
const handleFetchChats = useCallback(async () => {
|
||||
await refreshInstances()
|
||||
setIsOptionsMenuOpen(false)
|
||||
}, [refreshInstances])
|
||||
|
||||
// Voltar: se há chat aberto, fecha-o; senão, página anterior
|
||||
const handleBack = useCallback(() => {
|
||||
if (selectedChat) {
|
||||
handleSelectChat(null)
|
||||
} else {
|
||||
onNavigate?.('dashboard')
|
||||
}
|
||||
}, [selectedChat, handleSelectChat, onNavigate])
|
||||
|
||||
// ESC fecha chat ou vai para página anterior
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') handleBack()
|
||||
}
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
return () => document.removeEventListener('keydown', onKeyDown)
|
||||
}, [handleBack])
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="flex h-full w-full bg-[#f0f2f5] overflow-hidden select-none font-sans text-[#111b21]">
|
||||
{/* Preloader global – visível até o primeiro carregamento de chats completar */}
|
||||
<AnimatePresence>
|
||||
{!pageReady && (
|
||||
<motion.div
|
||||
key="inbox-preloader"
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center bg-[#070b14]"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-5">
|
||||
<motion.div
|
||||
animate={{ opacity: [1, 0.2, 1] }}
|
||||
transition={{ duration: 1.6, repeat: Infinity, ease: 'easeInOut' }}
|
||||
>
|
||||
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" className="w-20 h-20">
|
||||
<defs>
|
||||
<linearGradient id="wpp-grad" x1="50" y1="100" x2="50" y2="0" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stopColor="#20b038"/>
|
||||
<stop offset="1" stopColor="#60d66a"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="50" cy="50" r="50" fill="url(#wpp-grad)"/>
|
||||
<path d="M50 15.5c-19.05 0-34.5 15.45-34.5 34.5 0 6.36 1.72 12.3 4.73 17.4L15.5 84.5l17.6-4.62a34.36 34.36 0 0 0 16.9 4.42c19.05 0 34.5-15.45 34.5-34.5S69.05 15.5 50 15.5z" fill="rgba(0,0,0,0.12)"/>
|
||||
<path d="M68.5 60.3c-1.1-.57-6.53-3.22-7.54-3.59-.99-.37-1.72-.55-2.44.55-.73 1.1-2.82 3.59-3.46 4.32-.64.73-1.28.82-2.38.27-6.53-3.26-10.81-5.83-15.1-13.22-1.14-1.97.24-1.83.65-3.05.34-1 .17-1.84-.1-2.39-.27-.55-2.44-5.99-3.4-8.2-.9-2.16-1.81-1.86-2.49-1.89-.64-.03-1.38-.04-2.12-.04s-1.93.28-2.94 1.38c-1.01 1.1-3.87 3.78-3.87 9.22s3.96 10.69 4.51 11.43c.55.73 7.8 11.9 18.9 16.7 7.02 3.03 9.76 3.28 13.28 2.76 2.13-.32 6.53-2.67 7.45-5.25.92-2.58.92-4.79.64-5.25-.27-.46-1-.73-2.09-1.27z" fill="#fff"/>
|
||||
</svg>
|
||||
</motion.div>
|
||||
<span className="text-slate-500 text-sm">Carregando conversas…</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden h-full">
|
||||
<ThinSidebar activeTab={activeTab} setActiveTab={setActiveTab} activeInstance={activeInstance} onNavigate={onNavigate} />
|
||||
|
||||
{activeTab === 'chats' && (
|
||||
<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">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={handleBack}
|
||||
title={selectedChat ? 'Voltar aos chats' : 'Voltar'}
|
||||
className="p-2 hover:bg-black/5 rounded-full text-[#54656f] shrink-0"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<div onClick={() => setIsInstanceDropdownOpen(!isInstanceDropdownOpen)} className="flex items-center gap-3 cursor-pointer p-1">
|
||||
<div className="w-10 h-10 rounded-full bg-white border border-black/5 overflow-hidden flex items-center justify-center">
|
||||
{activeInstance?.instance
|
||||
? <img
|
||||
src={getAvatarProxyUrl({
|
||||
instance_name: activeInstance.instance,
|
||||
remote_jid: activeInstance.phone ? `${activeInstance.phone}@s.whatsapp.net` : null,
|
||||
}) || activeInstance.avatar || undefined}
|
||||
className="w-full h-full object-cover" alt=""
|
||||
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none' }}
|
||||
/>
|
||||
: <User className="text-gray-400" />}
|
||||
</div>
|
||||
<span className="font-bold text-sm truncate max-w-[120px]">{activeInstance?.nome || 'Instância'}</span>
|
||||
<ChevronDown className={`w-4 h-4 transition-all ${isInstanceDropdownOpen ? 'rotate-180' : ''}`} />
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{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 => (
|
||||
<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">
|
||||
<img
|
||||
src={getAvatarProxyUrl({
|
||||
instance_name: i.instance,
|
||||
remote_jid: i.phone ? `${i.phone}@s.whatsapp.net` : null,
|
||||
}) || i.avatar || undefined}
|
||||
alt="" className="w-full h-full object-cover"
|
||||
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>
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>{/* end left flex */}
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="relative" ref={optionsRef}>
|
||||
<button onClick={() => setIsOptionsMenuOpen(!isOptionsMenuOpen)} className="p-2 hover:bg-black/5 rounded-full">
|
||||
<MoreVertical className="w-5 h-5 text-[#54656f]" />
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{isOptionsMenuOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="absolute right-0 top-full mt-1 bg-white shadow-2xl rounded-xl border border-gray-100 w-56 z-50 py-2"
|
||||
>
|
||||
<button
|
||||
onClick={handleFetchChats}
|
||||
disabled={loading}
|
||||
className="w-full flex items-center gap-3 px-4 py-2.5 text-[14px] text-[#111b21] hover:bg-[#f5f6f6] transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
Atualizar chats
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Busca */}
|
||||
<div className="px-4 py-2 flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-2.5 w-4 h-4 text-gray-400" />
|
||||
<input type="text" placeholder="Pesquisar..."
|
||||
className="w-full bg-[#f0f2f5] h-[35px] pl-10 rounded-lg text-sm border-none focus:ring-0"
|
||||
value={searchTerm} onChange={e => setSearchTerm(e.target.value)} />
|
||||
</div>
|
||||
<button onClick={() => setIsNewConvoModalOpen(true)}
|
||||
className="w-9 h-9 flex items-center justify-center rounded-full text-gray-500 hover:bg-black/5">
|
||||
<MessageSquarePlus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Lista de chats */}
|
||||
<ChatSidebar
|
||||
chats={chatsByScope}
|
||||
loading={loading}
|
||||
selectedChat={selectedChat}
|
||||
searchTerm={searchTerm}
|
||||
onSelectChat={handleSelectChat}
|
||||
onMenuAction={handleChatMenuAction}
|
||||
activeFilter={activeFilter}
|
||||
selectedLabelFilter={null}
|
||||
chatScope={chatScope}
|
||||
activeCustomListName={null}
|
||||
presenceByJid={presenceByJid}
|
||||
isFavoriteChat={isFavoriteChat}
|
||||
drafts={drafts}
|
||||
canDelete={canDeleteMsg}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Área principal */}
|
||||
<div
|
||||
className={`${isMobile ? ((selectedChat || activeTab === 'settings') ? 'w-full' : 'hidden') : 'flex-1'} flex h-full relative overflow-hidden`}
|
||||
style={{ backgroundImage: "url('/chat-bg.png')", backgroundRepeat: 'repeat', backgroundColor: '#b5bdb5' }}
|
||||
>
|
||||
{/* Overlay branqueador sobre o padrão */}
|
||||
<div className="absolute inset-0 bg-white/75 pointer-events-none z-0" />
|
||||
{activeTab === 'settings' ? (
|
||||
<SettingsPanel instanceId={activeInstance?.instance ?? null} />
|
||||
) : selectedChat ? (
|
||||
<>
|
||||
<div className="flex-1 flex flex-col h-full relative z-[1] overflow-hidden border-r border-[#d1d7db]">
|
||||
<InboxHeader
|
||||
selectedChat={selectedChat}
|
||||
isMobile={isMobile}
|
||||
isTyping={isTyping}
|
||||
onAvatarClick={() => { setIsProfileOpen(!isProfileOpen); setIsProtocolOpen(false) }}
|
||||
onBack={() => handleSelectChat(null)}
|
||||
onSyncAvatar={handleSyncAvatar}
|
||||
isChatMenuOpen={isChatMenuOpen}
|
||||
isSyncingAvatar={isSyncingAvatar}
|
||||
isProtocolOpen={isProtocolOpen}
|
||||
hasActiveProtocol={protocolPanel.hasActive}
|
||||
onSearchClick={() => {}}
|
||||
onToggleMenu={() => setIsChatMenuOpen(!isChatMenuOpen)}
|
||||
onProtocolClick={() => { setIsProtocolOpen(!isProtocolOpen); setIsProfileOpen(false) }}
|
||||
/>
|
||||
<MessageArea
|
||||
messages={messages as any}
|
||||
selectedChat={selectedChat}
|
||||
scrollRef={scrollRef}
|
||||
msgLoading={loadingMessages}
|
||||
onScroll={handleScroll}
|
||||
historyLoadingMore={loadingMore}
|
||||
hasMoreHistory={hasMoreHistory}
|
||||
reactionPickerFor={reactionPickerFor}
|
||||
onReply={setReplyingTo}
|
||||
onToggleReactionPicker={toggleReactionPicker}
|
||||
onReact={handleReactToMessage}
|
||||
onForward={() => {}}
|
||||
onDelete={() => {}}
|
||||
canDelete={canDeleteMsg}
|
||||
onEdit={() => {}}
|
||||
quickReactions={['👍', '❤️', '😂', '😮', '😢', '🙏']}
|
||||
getGroupSenderLabel={(msg: any) => {
|
||||
if (msg.direction === 'out') return 'Você'
|
||||
if (msg.participant) return msg.participant
|
||||
if (msg.senderJid) {
|
||||
if (msg.senderJid.endsWith('@lid')) return msg.participant || ''
|
||||
const phone = msg.senderJid.split('@')[0].split(':')[0]
|
||||
return formatPhone(phone, msg.senderJid)
|
||||
}
|
||||
return ''
|
||||
}}
|
||||
/>
|
||||
<InputBar
|
||||
newMessage={newMessage}
|
||||
onNewMessageChange={setNewMessage}
|
||||
onSend={handleSendMessage}
|
||||
onPresence={handleTyping}
|
||||
onCancelReply={() => setReplyingTo(null)}
|
||||
replyingTo={replyingTo}
|
||||
selectedChat={selectedChat}
|
||||
isMobile={isMobile}
|
||||
onSendMedia={handleSendMedia}
|
||||
onSendAudio={handleSendAudio}
|
||||
instanceId={activeInstance?.instance ?? null}
|
||||
mentions={mentions}
|
||||
onMentionsChange={setMentions}
|
||||
/>
|
||||
</div>
|
||||
<ContactProfile
|
||||
isOpen={isProfileOpen && !isProtocolOpen && !isMobile}
|
||||
chat={selectedChat}
|
||||
messages={messages as any}
|
||||
onClose={() => setIsProfileOpen(false)}
|
||||
/>
|
||||
<ProtocolPanel
|
||||
isOpen={isProtocolOpen && !isMobile}
|
||||
chatId={selectedChat ? String(selectedChat.id) : null}
|
||||
onClose={() => setIsProtocolOpen(false)}
|
||||
{...protocolPanel}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-10 bg-[#f0f2f5]">
|
||||
<MessageSquare className="w-20 h-20 opacity-10 mb-8" />
|
||||
<h1 className="text-2xl font-light text-[#41525d] mb-4">NewWhats</h1>
|
||||
<p className="text-sm text-[#667781]">Selecione uma conversa para começar.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NewConversationModal
|
||||
isOpen={isNewConvoModalOpen}
|
||||
onClose={() => setIsNewConvoModalOpen(false)}
|
||||
activeInstance={mappedActiveInstance as any}
|
||||
connectedInstances={connectedInstances as any}
|
||||
sendMessage={async (instanceId: string, phone: string, text: string) => {
|
||||
try {
|
||||
// ext: inicia nova conversa por telefone (resolve/cria o chat).
|
||||
const res = await nw.post('/conversations', { sessionId: instanceId, phone, text })
|
||||
return { ok: true, messageId: res?.messageId }
|
||||
} catch {
|
||||
return { ok: false }
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,907 @@
|
||||
// SessionsView — página /sessions do motor NewWhats, portada para o satélite
|
||||
// scoreodonto. Dados/realtime via shims (ext API + túnel WS). Adaptações: sem
|
||||
// next/link (navegação via onNavigate), export nomeado, wrapper de fundo escuro.
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Wifi, Plus, RefreshCw, Loader2, QrCode, X,
|
||||
CheckCircle2, AlertCircle, Trash2, LogOut, Phone,
|
||||
ArrowLeft, Zap, WifiOff, Clock, Terminal, Download,
|
||||
ChevronDown, ChevronRight, Crown, Lock, ShieldCheck, UserCog,
|
||||
} from 'lucide-react';
|
||||
import { Button } from './components/ui';
|
||||
import { useSessionsLogic } from './hooks/useSessionsLogic';
|
||||
import { socketService } from './services/socketService';
|
||||
import type { Instance } from './store/instanceStore';
|
||||
import { HybridBackend, type WaPerms } from '../../services/backend';
|
||||
|
||||
// Permissões da conta logada sobre uma sessão específica.
|
||||
interface SessionPerms {
|
||||
isOwner: boolean;
|
||||
canRescan: boolean;
|
||||
canDelete: boolean;
|
||||
ownerNome: string | null;
|
||||
}
|
||||
const OWNER_PERMS: SessionPerms = { isOwner: true, canRescan: true, canDelete: true, ownerNome: null };
|
||||
|
||||
// ─── Status config ────────────────────────────────────────────────────────────
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
CONNECTED: {
|
||||
label: 'Conectado',
|
||||
dot: 'bg-emerald-400',
|
||||
badge: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
|
||||
icon: <Wifi className="w-4 h-4" />,
|
||||
},
|
||||
QR_PENDING: {
|
||||
label: 'Aguardando QR',
|
||||
dot: 'bg-amber-400 animate-pulse',
|
||||
badge: 'bg-amber-500/10 text-amber-400 border-amber-500/20',
|
||||
icon: <QrCode className="w-4 h-4" />,
|
||||
},
|
||||
CONNECTING: {
|
||||
label: 'Conectando...',
|
||||
dot: 'bg-blue-400 animate-pulse',
|
||||
badge: 'bg-blue-500/10 text-blue-400 border-blue-500/20',
|
||||
icon: <Loader2 className="w-4 h-4 animate-spin" />,
|
||||
},
|
||||
DISCONNECTED: {
|
||||
label: 'Desconectado',
|
||||
dot: 'bg-gray-400',
|
||||
badge: 'bg-gray-100 text-gray-600 border-gray-300',
|
||||
icon: <WifiOff className="w-4 h-4" />,
|
||||
},
|
||||
BANNED: {
|
||||
label: 'Banido',
|
||||
dot: 'bg-red-500',
|
||||
badge: 'bg-red-500/10 text-red-400 border-red-500/20',
|
||||
icon: <AlertCircle className="w-4 h-4" />,
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Baileys Log Types ────────────────────────────────────────────────────────
|
||||
|
||||
interface BaileysLogEntry {
|
||||
id: string;
|
||||
ts: number;
|
||||
event: string;
|
||||
data: any;
|
||||
}
|
||||
|
||||
const EVENT_COLOR: Record<string, string> = {
|
||||
'connection.update': 'text-emerald-400 bg-emerald-500/15',
|
||||
'messages.upsert': 'text-blue-400 bg-blue-500/15',
|
||||
'contacts.upsert': 'text-purple-400 bg-purple-500/15',
|
||||
'contacts.update': 'text-purple-300 bg-purple-500/10',
|
||||
'chats.upsert': 'text-yellow-400 bg-yellow-500/15',
|
||||
'chats.update': 'text-yellow-300 bg-yellow-500/10',
|
||||
'groups.upsert': 'text-orange-400 bg-orange-500/15',
|
||||
'groups.update': 'text-orange-300 bg-orange-500/10',
|
||||
'messaging-history.set': 'text-cyan-400 bg-cyan-500/15',
|
||||
'message': 'text-blue-300 bg-blue-500/10',
|
||||
};
|
||||
|
||||
function eventColor(event: string): string {
|
||||
return EVENT_COLOR[event] ?? 'text-gray-600 bg-gray-100';
|
||||
}
|
||||
|
||||
function logSummary(entry: BaileysLogEntry): string {
|
||||
const d = entry.data;
|
||||
if (!d) return '—';
|
||||
if (entry.event === 'connection.update') {
|
||||
if (d.connection) return `→ ${d.connection}`;
|
||||
if (d.qr) return 'QR gerado';
|
||||
}
|
||||
if (entry.event === 'message' || entry.event === 'messages.upsert') {
|
||||
const body = d.body ?? d.message?.conversation ?? d.message?.extendedTextMessage?.text;
|
||||
if (body) return String(body).slice(0, 60);
|
||||
const jid = d.remoteJid ?? d.key?.remoteJid;
|
||||
if (jid) return jid;
|
||||
}
|
||||
if (typeof d === 'object') {
|
||||
const count = Array.isArray(d) ? d.length : (d.count ?? d.chats ?? d.contacts ?? d.messages);
|
||||
if (count !== undefined) return `${count} item(s)`;
|
||||
const keys = Object.keys(d).slice(0, 3).join(', ');
|
||||
return keys || '{}';
|
||||
}
|
||||
return String(d).slice(0, 60);
|
||||
}
|
||||
|
||||
// ─── Log Row (expandable) ─────────────────────────────────────────────────────
|
||||
|
||||
function LogRow({ entry, index }: { entry: BaileysLogEntry; index: number }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const time = new Date(entry.ts).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
|
||||
return (
|
||||
<div className="border-b border-white/[0.04] last:border-0">
|
||||
<button
|
||||
onClick={() => setOpen(o => !o)}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 hover:bg-gray-100 text-left group transition-colors"
|
||||
>
|
||||
<span className="text-[9px] font-mono text-gray-400 w-5 shrink-0 text-right">{index + 1}</span>
|
||||
<span className={`text-[9px] font-black uppercase tracking-wider px-1.5 py-0.5 rounded-full shrink-0 whitespace-nowrap ${eventColor(entry.event)}`}>
|
||||
{entry.event}
|
||||
</span>
|
||||
<span className="text-[11px] text-gray-600 truncate flex-1 font-mono">{logSummary(entry)}</span>
|
||||
<span className="text-[9px] text-gray-400 shrink-0 font-mono">{time}</span>
|
||||
<span className="shrink-0 text-gray-400 group-hover:text-gray-500">
|
||||
{open ? <ChevronDown size={11} /> : <ChevronRight size={11} />}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<pre className="mx-3 mb-2 p-2 bg-gray-100 rounded-lg text-[10px] font-mono text-gray-700 overflow-x-auto whitespace-pre-wrap max-h-48 border border-gray-100">
|
||||
{JSON.stringify(entry.data, null, 2)}
|
||||
</pre>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── QR Modal ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function QrModal({
|
||||
instanceId,
|
||||
instanceName,
|
||||
qrBase64,
|
||||
onClose,
|
||||
onNewQr,
|
||||
}: {
|
||||
instanceId: string;
|
||||
instanceName: string;
|
||||
qrBase64: string | null;
|
||||
onClose: () => void;
|
||||
onNewQr: (id: string) => Promise<void>;
|
||||
}) {
|
||||
const [expired, setExpired] = useState(false);
|
||||
const [logs, setLogs] = useState<BaileysLogEntry[]>([]);
|
||||
const logEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!qrBase64) return;
|
||||
setExpired(false);
|
||||
const t = setTimeout(() => setExpired(true), 60_000);
|
||||
return () => clearTimeout(t);
|
||||
}, [qrBase64]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (log: any) => {
|
||||
if (log.sessionId !== instanceId) return;
|
||||
setLogs(prev => {
|
||||
const entry: BaileysLogEntry = {
|
||||
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
ts: log.timestamp ? new Date(log.timestamp).getTime() : Date.now(),
|
||||
event: log.event ?? 'unknown',
|
||||
data: log.data,
|
||||
};
|
||||
return [...prev, entry].slice(-300);
|
||||
});
|
||||
};
|
||||
socketService.on('baileys:log', handler);
|
||||
return () => socketService.off('baileys:log', handler);
|
||||
}, [instanceId]);
|
||||
|
||||
useEffect(() => {
|
||||
logEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [logs.length]);
|
||||
|
||||
const saveJson = useCallback(() => {
|
||||
const blob = new Blob([JSON.stringify(logs, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `baileys-${instanceId}-${Date.now()}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}, [logs, instanceId]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[120] flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
className="absolute inset-0 bg-black/80 backdrop-blur-sm cursor-pointer"
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||
className="relative bg-white border border-gray-200 rounded-[2rem] shadow-2xl w-full max-w-3xl max-h-[90vh] flex flex-col overflow-hidden"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100 shrink-0">
|
||||
<div>
|
||||
<h2 className="text-lg font-black text-gray-800">Conectar WhatsApp</h2>
|
||||
<p className="text-gray-500 text-xs mt-0.5">{instanceName}</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 text-gray-500 hover:text-gray-800 bg-gray-50 rounded-xl transition-colors">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<div className="w-72 shrink-0 flex flex-col items-center justify-center gap-5 p-6 border-r border-gray-100">
|
||||
<div className="bg-gray-50 border border-gray-100 p-5 rounded-3xl w-full flex flex-col items-center justify-center min-h-[260px]">
|
||||
{expired ? (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Clock className="w-10 h-10 text-gray-500" />
|
||||
<p className="font-bold text-gray-800 text-sm">QR expirado</p>
|
||||
<Button variant="primary" size="sm" onClick={async () => {
|
||||
setExpired(false);
|
||||
await onNewQr(instanceId);
|
||||
}}>
|
||||
Novo QR
|
||||
</Button>
|
||||
</div>
|
||||
) : qrBase64 ? (
|
||||
<div className="bg-white p-2.5 rounded-2xl shadow-xl">
|
||||
<img src={qrBase64.startsWith('data:') ? qrBase64 : `data:image/png;base64,${qrBase64}`} alt="QR Code" className="w-48 h-48 rounded-xl" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="w-10 h-10 animate-spin text-emerald-500" />
|
||||
<p className="text-xs text-gray-500 font-semibold uppercase tracking-widest">Gerando QR...</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!expired && (
|
||||
<div className="flex items-center gap-2 text-emerald-400 text-[10px] font-bold uppercase tracking-widest animate-pulse">
|
||||
<RefreshCw className="w-3.5 h-3.5 animate-spin" /> Aguardando pareamento...
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button onClick={onClose} className="w-full py-2.5 text-gray-500 hover:text-gray-800 font-bold transition-colors text-sm">
|
||||
Fechar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-gray-100 bg-gray-50 shrink-0">
|
||||
<Terminal className="w-3.5 h-3.5 text-gray-500" />
|
||||
<span className="text-[10px] font-black uppercase tracking-widest text-gray-500">Console Baileys</span>
|
||||
<span className="ml-1 text-[9px] font-mono text-gray-400 bg-gray-50 px-1.5 py-0.5 rounded-full">
|
||||
{logs.length} eventos
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setLogs([])}
|
||||
className="text-[9px] font-bold uppercase tracking-wider text-gray-500 hover:text-gray-600 transition-colors px-2 py-0.5 rounded hover:bg-gray-50"
|
||||
>
|
||||
Limpar
|
||||
</button>
|
||||
<button
|
||||
onClick={saveJson}
|
||||
disabled={logs.length === 0}
|
||||
className="flex items-center gap-1 text-[9px] font-bold uppercase tracking-wider text-emerald-500 hover:text-emerald-400 disabled:opacity-30 disabled:cursor-not-allowed transition-colors px-2 py-0.5 rounded hover:bg-emerald-500/10"
|
||||
>
|
||||
<Download size={10} /> Salvar JSON
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{logs.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-gray-400 gap-2 py-16">
|
||||
<Terminal size={28} className="opacity-30" />
|
||||
<p className="text-xs">Aguardando eventos do Baileys...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{logs.map((entry, i) => (
|
||||
<LogRow key={entry.id} entry={entry} index={i} />
|
||||
))}
|
||||
<div ref={logEndRef} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-2 border-t border-gray-100 bg-gray-50 shrink-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
||||
<span className="text-[9px] font-mono text-gray-500">
|
||||
{instanceId.slice(0, 8)}... · live
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Session Card ─────────────────────────────────────────────────────────────
|
||||
|
||||
const SessionCard = React.forwardRef<HTMLDivElement, {
|
||||
inst: Instance;
|
||||
perms: SessionPerms;
|
||||
onConnect: () => void;
|
||||
onDisconnect: () => void;
|
||||
onDelete: () => void;
|
||||
onShowQr: () => void;
|
||||
onManagePerms: () => void;
|
||||
}>(function SessionCard({
|
||||
inst,
|
||||
perms,
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
onDelete,
|
||||
onShowQr,
|
||||
onManagePerms,
|
||||
}, ref) {
|
||||
const cfg = STATUS_CONFIG[inst.status] ?? STATUS_CONFIG.DISCONNECTED;
|
||||
const isConnected = inst.status === 'CONNECTED';
|
||||
const isQrPending = inst.status === 'QR_PENDING';
|
||||
const { isOwner, canRescan, canDelete, ownerNome } = perms;
|
||||
const rescanLocked = !isOwner && !canRescan;
|
||||
const deleteLocked = !isOwner && !canDelete;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
initial={{ opacity: 0, y: 15 }} animate={{ opacity: 1, y: 0 }}
|
||||
className="bg-gray-100 backdrop-blur-xl p-6 rounded-[2rem] shadow-2xl border border-gray-100 flex flex-col gap-5 hover:border-gray-200 transition-all group relative overflow-hidden"
|
||||
>
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-brand-500/5 blur-[60px] rounded-full translate-x-1/2 -translate-y-1/2 pointer-events-none" />
|
||||
|
||||
<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'}`}>
|
||||
{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}
|
||||
<span className={`w-full h-full items-center justify-center ${inst.avatar ? 'hidden' : 'flex'}`}>
|
||||
<Phone className="w-6 h-6" />
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-black text-gray-800 text-lg tracking-tight">{inst.name}</h3>
|
||||
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
||||
{inst.phone && (
|
||||
<span className="text-xs font-mono text-gray-500 bg-gray-50 px-2 py-0.5 rounded border border-gray-100">{inst.phone}</span>
|
||||
)}
|
||||
<span className={`inline-flex items-center gap-1.5 text-[10px] font-black uppercase tracking-wider px-2 py-0.5 rounded-full border ${cfg.badge}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${cfg.dot}`} />
|
||||
{cfg.label}
|
||||
</span>
|
||||
{isOwner ? (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-black uppercase tracking-wider px-2 py-0.5 rounded-full border bg-amber-500/10 text-amber-500 border-amber-500/20" title="Você é o dono desta conta">
|
||||
<Crown className="w-3 h-3" /> Dono
|
||||
</span>
|
||||
) : ownerNome ? (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded-full border bg-gray-500/10 text-gray-500 border-gray-200" title={`Dono: ${ownerNome}`}>
|
||||
<Crown className="w-3 h-3" /> {ownerNome}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{deleteLocked ? (
|
||||
<button disabled title="Somente o dono pode excluir esta sessão — peça autorização de exclusão."
|
||||
className="p-2 text-gray-300 rounded-xl shrink-0 cursor-not-allowed"
|
||||
>
|
||||
<Lock className="w-4 h-4" />
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={onDelete} title="Deletar instância"
|
||||
className="p-2 text-gray-500 hover:text-red-400 hover:bg-red-500/10 rounded-xl transition-colors shrink-0"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isQrPending && (
|
||||
rescanLocked ? (
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-500/5 border border-gray-200 rounded-2xl text-gray-400 text-sm font-semibold w-full"
|
||||
title="Somente o dono pode parear esta sessão — peça autorização de re-scan."
|
||||
>
|
||||
<Lock className="w-5 h-5 shrink-0" />
|
||||
<span className="text-left">QR disponível — somente o dono pode escanear</span>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={onShowQr}
|
||||
className="flex items-center gap-3 p-3 bg-amber-500/10 border border-amber-500/20 rounded-2xl text-amber-300 text-sm font-semibold hover:bg-amber-500/15 transition-colors w-full"
|
||||
>
|
||||
<QrCode className="w-5 h-5 shrink-0" />
|
||||
<span className="text-left">QR aguardando escaneamento — clique para ver</span>
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 mt-auto relative z-10 pt-2 border-t border-gray-100">
|
||||
{isConnected ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2 text-emerald-400 text-xs font-bold uppercase tracking-widest flex-1">
|
||||
<RefreshCw className="w-3.5 h-3.5 animate-spin" /> Sessão ativa
|
||||
</div>
|
||||
{isOwner && (
|
||||
<button onClick={onManagePerms} title="Gerenciar quem pode reconectar/excluir"
|
||||
className="flex items-center gap-1.5 px-3 py-2 text-xs font-semibold text-brand-600 hover:text-brand-700 bg-brand-500/10 hover:bg-brand-500/15 rounded-xl transition-colors border border-brand-500/20"
|
||||
>
|
||||
<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>
|
||||
</>
|
||||
) : 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"
|
||||
title="Somente o dono pode reconectar esta sessão — peça autorização de re-scan."
|
||||
>
|
||||
<Lock className="w-3.5 h-3.5" /> Conectar
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<button onClick={onConnect}
|
||||
className="flex-1 flex items-center justify-center gap-2 bg-brand-500 text-white font-black py-3 rounded-xl hover:bg-brand-600 transition-all text-[11px] uppercase tracking-widest"
|
||||
>
|
||||
<Zap className="w-3.5 h-3.5" fill="currentColor" /> Conectar
|
||||
</button>
|
||||
{isOwner && (
|
||||
<button onClick={onManagePerms} title="Gerenciar quem pode reconectar/excluir"
|
||||
className="flex items-center gap-1.5 px-3 py-2 text-xs font-semibold text-brand-600 hover:text-brand-700 bg-brand-500/10 hover:bg-brand-500/15 rounded-xl transition-colors border border-brand-500/20"
|
||||
>
|
||||
<UserCog className="w-3.5 h-3.5" /> Acesso
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
});
|
||||
|
||||
// ─── Confirm Delete Modal ─────────────────────────────────────────────────────
|
||||
|
||||
function ConfirmDeleteModal({
|
||||
inst,
|
||||
onClose,
|
||||
onConfirm,
|
||||
loading,
|
||||
}: {
|
||||
inst: Instance;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
loading: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[130] flex items-center justify-center p-4">
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||
onClick={onClose} className="absolute inset-0 bg-black/80 backdrop-blur-sm cursor-pointer" />
|
||||
<motion.div initial={{ opacity: 0, scale: 0.92, y: 20 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.9 }}
|
||||
className="relative bg-white backdrop-blur-2xl border border-red-500/20 border-t-[3px] border-t-red-500 rounded-[28px] p-7 w-full max-w-sm shadow-2xl"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-4 mt-2">
|
||||
<div className="w-11 h-11 rounded-xl bg-red-500/15 border border-red-500/30 flex items-center justify-center">
|
||||
<Trash2 className="w-5 h-5 text-red-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-800">Deletar instância</h3>
|
||||
<p className="text-xs text-gray-500">{inst.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-6 leading-relaxed">
|
||||
Isso removerá permanentemente a instância, seus chats e sessão Baileys.<br />
|
||||
<strong className="text-red-400">Esta ação é irreversível.</strong>
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="ghost" onClick={onClose} className="flex-1">Cancelar</Button>
|
||||
<button onClick={onConfirm} disabled={loading}
|
||||
className="flex-1 py-3 bg-red-500 hover:bg-red-600 text-white font-bold rounded-xl transition-colors disabled:opacity-50 flex items-center justify-center gap-2 text-sm"
|
||||
>
|
||||
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
|
||||
Deletar
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main View ────────────────────────────────────────────────────────────────
|
||||
|
||||
// ─── Modal: gestão de acesso (autorizações por sessão) — só dono ───────────────
|
||||
|
||||
type PermMember = { usuarioId: string; nome: string; email: string; role: string } & WaPerms;
|
||||
type PermField = keyof WaPerms;
|
||||
|
||||
function ManagePermsModal({ inst, onClose }: { inst: Instance; onClose: () => void }) {
|
||||
const [members, setMembers] = useState<PermMember[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [savingId, setSavingId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
HybridBackend.getWaSessionAuthz(inst.id).then((r) => {
|
||||
if (alive) { setMembers((r.members as PermMember[]) || []); setLoading(false); }
|
||||
}).catch(() => alive && setLoading(false));
|
||||
return () => { alive = false; };
|
||||
}, [inst.id]);
|
||||
|
||||
const toggle = useCallback(async (usuarioId: string, field: PermField, value: boolean) => {
|
||||
const target = members.find((m) => m.usuarioId === usuarioId);
|
||||
if (!target) return;
|
||||
const next: PermMember = { ...target, [field]: value };
|
||||
setSavingId(usuarioId);
|
||||
// Otimista
|
||||
setMembers((prev) => prev.map((m) => m.usuarioId === usuarioId ? next : m));
|
||||
const res = await HybridBackend.setWaSessionAuthz(inst.id, usuarioId, {
|
||||
can_rescan: next.can_rescan, can_delete: next.can_delete, can_inbox: next.can_inbox, can_secretaria: next.can_secretaria, can_delete_msg: next.can_delete_msg,
|
||||
});
|
||||
if (!res.ok) {
|
||||
// Reverte
|
||||
setMembers((prev) => prev.map((m) => m.usuarioId === usuarioId ? target : m));
|
||||
}
|
||||
setSavingId(null);
|
||||
}, [members, inst.id]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[130] flex items-center justify-center p-4">
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||
onClick={onClose} className="absolute inset-0 bg-black/80 backdrop-blur-sm cursor-pointer" />
|
||||
<motion.div initial={{ opacity: 0, scale: 0.92, y: 20 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.9 }}
|
||||
className="relative bg-white border border-brand-500/20 border-t-[3px] border-t-brand-500 rounded-[28px] p-7 w-full max-w-lg shadow-2xl max-h-[85vh] flex flex-col"
|
||||
>
|
||||
<button onClick={onClose} className="absolute top-5 right-5 p-1.5 text-gray-400 hover:text-gray-700 hover:bg-gray-100 rounded-lg"><X className="w-4 h-4" /></button>
|
||||
<div className="flex items-center gap-3 mb-1 mt-1">
|
||||
<div className="w-11 h-11 rounded-xl bg-brand-500/15 border border-brand-500/30 flex items-center justify-center">
|
||||
<ShieldCheck className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-black text-gray-800">Gerenciar acesso</h3>
|
||||
<p className="text-xs text-gray-500">{inst.name} · o que cada conta pode fazer</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[11px] text-gray-500 leading-snug bg-gray-50 rounded-xl p-3 mt-3 mb-4">
|
||||
Você é o <b>dono</b> desta conta — só você faz o pareamento inicial. Autorize contas da equipe a acessar o
|
||||
<b> Inbox</b> e a <b>Secretária</b>, <b>reconectar</b>, <b>apagar conversas/mensagens</b> e/ou <b>excluir</b> a sessão.
|
||||
O scan inicial permanece exclusivo seu.
|
||||
</p>
|
||||
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar -mx-1 px-1 space-y-2">
|
||||
{loading ? (
|
||||
<div className="py-10 flex items-center justify-center text-gray-400"><Loader2 className="w-6 h-6 animate-spin" /></div>
|
||||
) : members.length === 0 ? (
|
||||
<p className="text-center text-xs text-gray-400 font-semibold py-10 uppercase">Nenhuma outra conta neste workspace.</p>
|
||||
) : members.map((m) => (
|
||||
<div key={m.usuarioId} className="flex items-center gap-3 p-3 rounded-2xl border border-gray-100 bg-gray-50/60">
|
||||
<div className="w-9 h-9 rounded-full bg-brand-500/10 flex items-center justify-center text-brand-600 shrink-0 text-xs font-black uppercase">
|
||||
{(m.nome || m.email || '?').slice(0, 2)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-bold text-gray-800 truncate">{m.nome || m.email}</p>
|
||||
<p className="text-[10px] text-gray-400 font-semibold truncate">{m.email}{m.role ? ` · ${m.role}` : ''}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0 flex-wrap justify-end max-w-[190px]">
|
||||
<PermToggle label="Inbox" active={m.can_inbox} busy={savingId === m.usuarioId}
|
||||
onClick={() => toggle(m.usuarioId, 'can_inbox', !m.can_inbox)} />
|
||||
<PermToggle label="Secretária" active={m.can_secretaria} busy={savingId === m.usuarioId}
|
||||
onClick={() => toggle(m.usuarioId, 'can_secretaria', !m.can_secretaria)} />
|
||||
<PermToggle label="Re-scan" active={m.can_rescan} busy={savingId === m.usuarioId}
|
||||
onClick={() => toggle(m.usuarioId, 'can_rescan', !m.can_rescan)} />
|
||||
<PermToggle label="Apagar msg" danger active={m.can_delete_msg} busy={savingId === m.usuarioId}
|
||||
onClick={() => toggle(m.usuarioId, 'can_delete_msg', !m.can_delete_msg)} />
|
||||
<PermToggle label="Excluir sessão" danger active={m.can_delete} busy={savingId === m.usuarioId}
|
||||
onClick={() => toggle(m.usuarioId, 'can_delete', !m.can_delete)} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PermToggle({ label, active, danger, busy, onClick }: { label: string; active: boolean; danger?: boolean; busy?: boolean; onClick: () => void }) {
|
||||
const on = danger
|
||||
? 'bg-red-500/15 text-red-600 border-red-500/30'
|
||||
: 'bg-emerald-500/15 text-emerald-600 border-emerald-500/30';
|
||||
const off = 'bg-white text-gray-400 border-gray-200 hover:border-gray-300';
|
||||
return (
|
||||
<button onClick={onClick} disabled={busy}
|
||||
className={`px-2.5 py-1.5 rounded-lg text-[10px] font-black uppercase tracking-wider border transition-all disabled:opacity-60 ${active ? on : off}`}
|
||||
>
|
||||
{active ? '✓ ' : ''}{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function SessionsView({ onNavigate }: { onNavigate?: (view: string) => void }) {
|
||||
const {
|
||||
instances,
|
||||
loading,
|
||||
toast,
|
||||
addOpen,
|
||||
newName,
|
||||
creating,
|
||||
setNewName,
|
||||
openAddModal,
|
||||
closeAddModal,
|
||||
handleCreate,
|
||||
qrModal,
|
||||
closeQrModal,
|
||||
handleNewQr,
|
||||
deleteTarget,
|
||||
deleting,
|
||||
setDeleteTarget,
|
||||
closeDeleteModal,
|
||||
handleDelete,
|
||||
handleConnect,
|
||||
handleDisconnect,
|
||||
handleShowQr,
|
||||
refreshInstances,
|
||||
} = useSessionsLogic();
|
||||
|
||||
// ── 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);
|
||||
const [permsModalFor, setPermsModalFor] = useState<Instance | null>(null);
|
||||
const instanceIdsKey = instances.map((i) => i.id).join(',');
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
(async () => {
|
||||
if (instances.length === 0) {
|
||||
const r = await HybridBackend.getWaSessionAuthz('').catch(() => null);
|
||||
if (!alive) return;
|
||||
setAuthzAvailable(!!r?.available);
|
||||
if (r) setWorkspaceOwner({ isOwner: r.isOwner, ownerNome: r.ownerNome });
|
||||
return;
|
||||
}
|
||||
const results = await Promise.all(instances.map(async (inst) => {
|
||||
const r = await HybridBackend.getWaSessionAuthz(inst.id).catch(() => null);
|
||||
return [inst.id, r] as const;
|
||||
}));
|
||||
if (!alive) return;
|
||||
const map: Record<string, SessionPerms> = {};
|
||||
let owner = { isOwner: false, ownerNome: null as string | null };
|
||||
let available = false;
|
||||
for (const [id, r] of results) {
|
||||
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 };
|
||||
if (r?.available && r.isOwner) owner = { isOwner: true, ownerNome: r.ownerNome };
|
||||
else if (r?.available && !owner.ownerNome && r.ownerNome) owner.ownerNome = r.ownerNome;
|
||||
}
|
||||
setAuthzAvailable(available);
|
||||
setAuthzByInstance(map);
|
||||
setWorkspaceOwner(owner);
|
||||
})();
|
||||
return () => { alive = false; };
|
||||
}, [instanceIdsKey]);
|
||||
|
||||
// Permissões efetivas de uma instância.
|
||||
// Sem authz disponível → permissivo (não bloqueia; backend é a autoridade).
|
||||
const permsFor = (inst: Instance): SessionPerms => {
|
||||
if (!authzAvailable) return { isOwner: false, canRescan: true, canDelete: true, 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;
|
||||
|
||||
// Já existe uma sessão ativa no motor para esta conta → mostra mensagem de
|
||||
// sucesso em vez de sugerir novo pareamento.
|
||||
const hasActiveSession = instances.some((i) => i.status === 'CONNECTED');
|
||||
|
||||
return (
|
||||
<div className="bg-gray-50 min-h-full text-gray-800">
|
||||
<div className="max-w-5xl mx-auto p-6 lg:p-10 pb-20 font-sans space-y-10">
|
||||
|
||||
<AnimatePresence>
|
||||
{toast && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -20 }}
|
||||
className={`fixed top-6 left-1/2 -translate-x-1/2 z-[200] flex items-center gap-2.5 px-5 py-3 rounded-2xl shadow-2xl border text-sm font-semibold ${toast.ok ? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-300' : 'bg-red-500/10 border-red-500/20 text-red-300'}`}
|
||||
>
|
||||
{toast.ok ? <CheckCircle2 className="w-4 h-4" /> : <AlertCircle className="w-4 h-4" />}
|
||||
{toast.msg}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<header className="flex flex-col sm:flex-row sm:items-center justify-between gap-6 relative">
|
||||
<div className="absolute -top-16 -left-16 w-56 h-56 bg-brand-500/10 blur-[100px] rounded-full pointer-events-none" />
|
||||
<div className="relative z-10 flex items-start gap-4">
|
||||
<button onClick={() => onNavigate?.('dashboard')} className="p-2 hover:bg-gray-50 rounded-xl transition-colors text-gray-600 hover:text-gray-800 mt-1">
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-brand-500 to-brand-700 rounded-xl flex items-center justify-center text-white shadow-glow-brand shrink-0">
|
||||
<Wifi className="w-6 h-6" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-black text-gray-800 tracking-tight">Instâncias</h1>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 font-medium mt-1 ml-[60px]">WhatsApp Multi-Device · Baileys</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 relative z-10">
|
||||
<button onClick={refreshInstances} title="Recarregar"
|
||||
className="p-3 bg-gray-50 border border-gray-200 rounded-xl text-gray-600 hover:text-gray-800 transition-all hover:bg-gray-100"
|
||||
>
|
||||
<RefreshCw className={`w-5 h-5 ${loading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
{canCreateInstance ? (
|
||||
<button onClick={openAddModal}
|
||||
className="flex items-center gap-2 px-5 py-3 bg-brand-500 hover:bg-brand-600 text-white font-bold rounded-xl transition-all shadow-glow-brand text-sm"
|
||||
>
|
||||
<Plus className="w-5 h-5" /> Nova Instância
|
||||
</button>
|
||||
) : (
|
||||
<button disabled title="Somente o dono do workspace pode conectar um novo WhatsApp."
|
||||
className="flex items-center gap-2 px-5 py-3 bg-gray-100 text-gray-400 font-bold rounded-xl text-sm cursor-not-allowed border border-gray-200"
|
||||
>
|
||||
<Lock className="w-5 h-5" /> Nova Instância
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{hasActiveSession && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }}
|
||||
className="flex items-center gap-4 p-5 rounded-2xl bg-emerald-500/10 border border-emerald-500/20"
|
||||
>
|
||||
<div className="w-11 h-11 rounded-xl bg-emerald-500/15 flex items-center justify-center text-emerald-400 shrink-0">
|
||||
<CheckCircle2 className="w-6 h-6" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-base font-black text-emerald-300">Você já possui acesso ao WhatsApp</h2>
|
||||
<p className="text-sm text-emerald-400/70 font-medium">Sua sessão está ativa e conectada — não é preciso parear novamente.</p>
|
||||
</div>
|
||||
{onNavigate && (
|
||||
<button onClick={() => onNavigate('wa-inbox')}
|
||||
className="shrink-0 flex items-center gap-2 px-4 py-2.5 bg-emerald-500 hover:bg-emerald-600 text-white font-bold rounded-xl transition-all text-sm"
|
||||
>
|
||||
Abrir conversas
|
||||
</button>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{loading ? (
|
||||
Array(3).fill(0).map((_, i) => (
|
||||
<div key={i} className="h-56 rounded-[2rem] animate-pulse bg-gray-50" />
|
||||
))
|
||||
) : instances.length === 0 ? (
|
||||
<motion.div key="empty" initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }}
|
||||
className="col-span-full py-24 flex flex-col items-center justify-center text-center gap-6"
|
||||
>
|
||||
<div className="w-20 h-20 bg-gray-100 rounded-full flex items-center justify-center text-gray-500">
|
||||
<Phone className="w-10 h-10" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-800">Nenhuma instância</h2>
|
||||
<p className="text-gray-500 mt-1 text-sm">
|
||||
{canCreateInstance
|
||||
? 'Crie uma para começar a usar o WhatsApp.'
|
||||
: 'O dono do workspace ainda não conectou um WhatsApp.'}
|
||||
</p>
|
||||
</div>
|
||||
{canCreateInstance && (
|
||||
<button onClick={openAddModal}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-gray-50 hover:bg-gray-100 text-gray-800 font-bold rounded-2xl border border-gray-200 transition-all"
|
||||
>
|
||||
<Plus className="w-5 h-5" /> Criar instância
|
||||
</button>
|
||||
)}
|
||||
</motion.div>
|
||||
) : (
|
||||
instances.map((inst) => (
|
||||
<SessionCard
|
||||
key={inst.id}
|
||||
inst={inst}
|
||||
perms={permsFor(inst)}
|
||||
onConnect={() => handleConnect(inst)}
|
||||
onDisconnect={() => handleDisconnect(inst)}
|
||||
onDelete={() => setDeleteTarget(inst)}
|
||||
onShowQr={() => handleShowQr(inst)}
|
||||
onManagePerms={() => setPermsModalFor(inst)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{addOpen && (
|
||||
<div className="fixed inset-0 z-[110] flex items-center justify-center p-4">
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||
onClick={closeAddModal} className="absolute inset-0 bg-black/80 backdrop-blur-sm cursor-pointer" />
|
||||
<motion.div initial={{ opacity: 0, scale: 0.9, y: 20 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||
className="relative bg-gray-100 backdrop-blur-2xl p-8 rounded-[3rem] border border-gray-200 max-w-sm w-full space-y-6 shadow-2xl"
|
||||
>
|
||||
<div>
|
||||
<h2 className="text-2xl font-black text-gray-800">Nova Instância</h2>
|
||||
<p className="text-gray-500 text-sm mt-1">Dê um nome para identificar esta conexão.</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">Nome</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleCreate(); }}
|
||||
placeholder="ex: principal, comercial, suporte..."
|
||||
autoFocus
|
||||
className="w-full bg-gray-50 border border-gray-200 rounded-2xl px-4 py-3 text-gray-800 placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-brand-500/40 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="ghost" onClick={closeAddModal} className="flex-1">
|
||||
Cancelar
|
||||
</Button>
|
||||
<button onClick={handleCreate} disabled={creating || !newName.trim()}
|
||||
className="flex-1 py-3 bg-emerald-500 hover:bg-emerald-600 text-white font-bold rounded-2xl transition-all disabled:opacity-50 flex items-center justify-center gap-2 text-sm"
|
||||
>
|
||||
{creating ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
|
||||
Criar e conectar
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
{qrModal && (
|
||||
<QrModal
|
||||
instanceId={qrModal.instanceId}
|
||||
instanceName={qrModal.instanceName}
|
||||
qrBase64={qrModal.qrBase64}
|
||||
onClose={closeQrModal}
|
||||
onNewQr={handleNewQr}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
{deleteTarget && (
|
||||
<ConfirmDeleteModal
|
||||
inst={deleteTarget}
|
||||
onClose={closeDeleteModal}
|
||||
onConfirm={handleDelete}
|
||||
loading={deleting}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
{permsModalFor && (
|
||||
<ManagePermsModal inst={permsModalFor} onClose={() => setPermsModalFor(null)} />
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SessionsView;
|
||||
@@ -0,0 +1,452 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* WhatsChatDrawer — painel slide-over (direita) com um clone FOCADO da área de
|
||||
* mensagens do NewWhats, aberto a partir de um agendamento da agenda.
|
||||
*
|
||||
* Diferente do InboxView (rota /wa-inbox), NÃO mostra a lista lateral de chats:
|
||||
* abre direto na conversa do paciente, resolvida pelo telefone do cadastro.
|
||||
* Um dropdown permite trocar entre o número do paciente e os do grupo familiar.
|
||||
* Se o paciente ainda não tem conversa, oferece iniciar uma (POST /conversations,
|
||||
* mesmo fluxo do NewConversationModal, que já funciona no satélite via proxy nw).
|
||||
*
|
||||
* Reaproveita a MESMA fiação de hooks do InboxView (stores zustand globais +
|
||||
* socket com guard de conexão), então montar este drawer não duplica a conexão.
|
||||
*/
|
||||
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 { HybridBackend } from '../../services/backend';
|
||||
import { useChatStore } from './store/chatStore';
|
||||
import { useInstanceStore } from './store/instanceStore';
|
||||
import { formatPhone } from './utils/inboxUtils';
|
||||
import { nw } from './services/nwClient';
|
||||
|
||||
import { useNewInboxBridge } from './hooks/inbox/useNewInboxBridge';
|
||||
import { useInstanceSelector } from './hooks/inbox/useInstanceSelector';
|
||||
import { useChatWindow } from './hooks/inbox/useChatWindow';
|
||||
import { useMessageInput } from './hooks/inbox/useMessageInput';
|
||||
|
||||
import InboxHeader from './components/InboxHeader';
|
||||
import MessageArea from './components/MessageArea';
|
||||
import InputBar from './components/InputBar';
|
||||
import ContactProfile from './components/ContactProfile';
|
||||
|
||||
// ─── Tipos ──────────────────────────────────────────────────────────────────
|
||||
export interface WhatsChatDrawerProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
pacienteId?: string | null;
|
||||
pacienteNome?: string | null;
|
||||
onNavigate?: (view: string) => void;
|
||||
}
|
||||
|
||||
interface NumeroOpcao {
|
||||
phone: string; // dígitos crus do cadastro
|
||||
nome: string;
|
||||
relacao: string; // 'Paciente' | grupofamiliarrelacao
|
||||
}
|
||||
|
||||
// Normaliza para o formato de envio brasileiro (55 + DDD + número).
|
||||
function normalizeSendPhone(input: string): string {
|
||||
const digits = String(input || '').replace(/\D/g, '');
|
||||
if (digits.startsWith('55') && (digits.length === 12 || digits.length === 13)) return digits;
|
||||
if (digits.length === 10 || digits.length === 11) return '55' + digits;
|
||||
return digits;
|
||||
}
|
||||
|
||||
// Compara dois telefones de forma tolerante (Brasil: 9º dígito, DDI opcional):
|
||||
// casa pelos últimos 8 dígitos, que são estáveis.
|
||||
function samePhone(a: string, b: string): boolean {
|
||||
const da = String(a || '').replace(/\D/g, '');
|
||||
const db = String(b || '').replace(/\D/g, '');
|
||||
if (!da || !db) return false;
|
||||
return da.slice(-8) === db.slice(-8);
|
||||
}
|
||||
|
||||
// ─── Conteúdo interno (só monta quando aberto → hooks/socket sob demanda) ──────
|
||||
const DrawerInner: React.FC<Omit<WhatsChatDrawerProps, 'isOpen'>> = ({ onClose, pacienteId, pacienteNome, onNavigate }) => {
|
||||
const { activeInstance, instances, connectedInstances } = useInstanceSelector();
|
||||
const loadingInstances = useInstanceStore((s) => s.loading);
|
||||
// Há WhatsApp utilizável? (pelo menos uma instância conectada)
|
||||
const hasConnected = connectedInstances.length > 0;
|
||||
const {
|
||||
selectedChat,
|
||||
messages,
|
||||
loadingMessages,
|
||||
handleSendText,
|
||||
handleLoadMoreHistory,
|
||||
activeInstanceId,
|
||||
} = useNewInboxBridge();
|
||||
|
||||
const {
|
||||
scrollRef, isTyping, replyingTo, reactionPickerFor, hasMoreHistory, loadingMore,
|
||||
isProfileOpen, isSyncingAvatar, isChatMenuOpen,
|
||||
setReplyingTo, setIsProfileOpen, setIsChatMenuOpen,
|
||||
handleTyping, handleScroll, handleSelectChat: onChatWindowSelect,
|
||||
handleReactToMessage, handleSendMedia, handleSendAudio, handleSyncAvatar, toggleReactionPicker,
|
||||
} = useChatWindow({
|
||||
selectedChat,
|
||||
activeInstanceId: activeInstance?.instance ?? null,
|
||||
onLoadMoreHistory: handleLoadMoreHistory,
|
||||
});
|
||||
|
||||
const { newMessage, setNewMessage, mentions, setMentions, handleSendMessage } = useMessageInput({
|
||||
selectedChat,
|
||||
replyingTo,
|
||||
onClearReply: () => setReplyingTo(null),
|
||||
onSendText: handleSendText,
|
||||
});
|
||||
|
||||
// ── Números disponíveis (paciente + grupo familiar) ──────────────────────
|
||||
const [numeros, setNumeros] = useState<NumeroOpcao[]>([]);
|
||||
const [selectedPhone, setSelectedPhone] = useState<string>('');
|
||||
const [numDropdownOpen, setNumDropdownOpen] = useState(false);
|
||||
|
||||
// ── Estado de resolução da conversa ──────────────────────────────────────
|
||||
const [resolving, setResolving] = useState(false);
|
||||
const [notFound, setNotFound] = useState(false); // número válido, mas sem conversa
|
||||
const [initText, setInitText] = useState('');
|
||||
const [initSending, setInitSending] = useState(false);
|
||||
const [initError, setInitError] = useState('');
|
||||
|
||||
// Carrega telefone do paciente + membros da família ao abrir.
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
(async () => {
|
||||
const opts: NumeroOpcao[] = [];
|
||||
const seen = new Set<string>();
|
||||
const push = (phone: string, nome: string, relacao: string) => {
|
||||
const d = String(phone || '').replace(/\D/g, '');
|
||||
if (!d) return;
|
||||
const key = d.slice(-8);
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
opts.push({ phone: d, nome, relacao });
|
||||
};
|
||||
|
||||
// 1) Telefone do próprio paciente — via cadastro (desambigua por id).
|
||||
if (pacienteNome) {
|
||||
try {
|
||||
const r = await HybridBackend.getPacientes(pacienteNome);
|
||||
const self = (r?.data || []).find((p: any) => p.id === pacienteId)
|
||||
?? (r?.data || []).find((p: any) => (p.nome || '').toUpperCase() === pacienteNome.toUpperCase());
|
||||
if (self?.telefone) push(self.telefone, self.nome || pacienteNome, 'Paciente');
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
// 2) Grupo familiar / dependentes.
|
||||
if (pacienteId) {
|
||||
try {
|
||||
const { membros } = await HybridBackend.getFamiliaPaciente(pacienteId);
|
||||
for (const m of membros || []) {
|
||||
if (m.telefone) push(m.telefone, m.nome, m.grupofamiliarrelacao || (m.id === pacienteId ? 'Paciente' : 'Familiar'));
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
if (!alive) return;
|
||||
setNumeros(opts);
|
||||
setSelectedPhone(opts[0]?.phone || '');
|
||||
})();
|
||||
return () => { alive = false; };
|
||||
}, [pacienteId, pacienteNome]);
|
||||
|
||||
// Resolve a conversa (chatId) a partir do telefone selecionado, sem enviar nada.
|
||||
const resolveChat = useCallback(async (phoneDigits: string) => {
|
||||
const store = useChatStore.getState();
|
||||
if (!activeInstanceId || !phoneDigits) { setNotFound(true); return; }
|
||||
setResolving(true);
|
||||
setNotFound(false);
|
||||
store.setActiveChat(null);
|
||||
try {
|
||||
const send = normalizeSendPhone(phoneDigits);
|
||||
// Busca pelos ÚLTIMOS 8 dígitos (estáveis): o WhatsApp grava o jid do BR sem o
|
||||
// 9º dígito (ex.: 556799591687), então buscar o número completo (13 dígitos)
|
||||
// não casaria no `jid contains` do backend. O samePhone (últimos 8) filtra.
|
||||
const tail8 = send.replace(/\D/g, '').slice(-8);
|
||||
await store.loadChats(activeInstanceId, { search: tail8 });
|
||||
const chats = useChatStore.getState().chats;
|
||||
const hit = chats.find((c) =>
|
||||
samePhone(c.contactPhone || c.jid.split('@')[0], send)) ?? null;
|
||||
if (hit) {
|
||||
await store.selectChat(hit.id);
|
||||
onChatWindowSelect();
|
||||
setNotFound(false);
|
||||
} else {
|
||||
setNotFound(true);
|
||||
}
|
||||
} catch {
|
||||
setNotFound(true);
|
||||
} finally {
|
||||
setResolving(false);
|
||||
}
|
||||
}, [activeInstanceId, onChatWindowSelect]);
|
||||
|
||||
// (Re)resolve quando muda o número selecionado ou a instância fica pronta.
|
||||
useEffect(() => {
|
||||
if (selectedPhone && activeInstanceId) resolveChat(selectedPhone);
|
||||
}, [selectedPhone, activeInstanceId, resolveChat]);
|
||||
|
||||
// Inicia conversa (paciente sem chat): mesmo fluxo do NewConversationModal.
|
||||
const handleIniciarConversa = useCallback(async () => {
|
||||
if (!activeInstanceId || !selectedPhone) return;
|
||||
if (!initText.trim()) { setInitError('Escreva uma mensagem para iniciar a conversa.'); return; }
|
||||
setInitSending(true);
|
||||
setInitError('');
|
||||
try {
|
||||
const phone = normalizeSendPhone(selectedPhone);
|
||||
await nw.post('/conversations', { sessionId: activeInstanceId, phone, text: initText.trim() });
|
||||
setInitText('');
|
||||
// Dá um respiro para o motor registrar o chat e então re-resolve.
|
||||
await new Promise((r) => setTimeout(r, 900));
|
||||
await resolveChat(selectedPhone);
|
||||
} catch (e: any) {
|
||||
setInitError(e?.message || 'Erro ao enviar. Verifique o número e tente novamente.');
|
||||
} finally {
|
||||
setInitSending(false);
|
||||
}
|
||||
}, [activeInstanceId, selectedPhone, initText, resolveChat]);
|
||||
|
||||
const selectedNumero = useMemo(
|
||||
() => numeros.find((n) => n.phone === selectedPhone) ?? null,
|
||||
[numeros, selectedPhone]
|
||||
);
|
||||
|
||||
const tituloContato = selectedNumero?.nome || pacienteNome || 'Paciente';
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
onClick={onClose}
|
||||
className="fixed inset-0 z-[190] bg-black/40 backdrop-blur-[2px]"
|
||||
/>
|
||||
{/* Painel */}
|
||||
<motion.aside
|
||||
initial={{ x: '100%' }} animate={{ x: 0 }} exit={{ x: '100%' }}
|
||||
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">
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1.5 hover:bg-white/20 rounded-full shrink-0" title="Fechar">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Corpo */}
|
||||
<div
|
||||
className="flex-1 flex flex-col overflow-hidden relative"
|
||||
style={{ backgroundImage: "url('/chat-bg.png')", backgroundRepeat: 'repeat', backgroundColor: '#b5bdb5' }}
|
||||
>
|
||||
<div className="absolute inset-0 bg-white/75 pointer-events-none z-0" />
|
||||
|
||||
{(loadingInstances && instances.length === 0) ? (
|
||||
// ── Carregando estado das sessões ──────────────────────────────
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-3 relative z-[1] text-[#667781]">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-[#008069]" />
|
||||
<span className="text-sm">Verificando conexão do WhatsApp…</span>
|
||||
</div>
|
||||
) : !hasConnected ? (
|
||||
// ── Nenhuma sessão de WhatsApp conectada ────────────────────────
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-5 relative z-[1] p-8 text-center">
|
||||
<div className="relative">
|
||||
<div className="w-20 h-20 rounded-full bg-[#008069]/10 flex items-center justify-center">
|
||||
<WifiOff className="w-9 h-9 text-[#008069]" />
|
||||
</div>
|
||||
<span className="absolute -bottom-1 -right-1 w-7 h-7 rounded-full bg-white shadow-md flex items-center justify-center">
|
||||
<MessageCircle className="w-4 h-4 text-[#008069]" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="max-w-xs">
|
||||
<h3 className="text-lg font-bold text-[#111b21]">
|
||||
{instances.length === 0 ? 'WhatsApp não conectado' : 'Sessão desconectada'}
|
||||
</h3>
|
||||
<p className="text-[13px] text-[#667781] leading-relaxed mt-1.5">
|
||||
{instances.length === 0
|
||||
? 'Nenhuma conta de WhatsApp está vinculada a esta clínica. Conecte um número para conversar com os pacientes direto da agenda.'
|
||||
: 'A sessão do WhatsApp está fora do ar no momento. Reconecte o número lendo o QR Code para retomar as conversas.'}
|
||||
</p>
|
||||
</div>
|
||||
{onNavigate && (
|
||||
<button
|
||||
onClick={() => { onClose(); onNavigate('wa-sessions'); }}
|
||||
className="flex items-center justify-center gap-2 px-5 py-2.5 rounded-xl text-sm font-semibold text-white bg-[#008069] hover:bg-[#017259] transition-all active:scale-[0.98] shadow-sm"
|
||||
>
|
||||
<QrCode className="w-4 h-4" />
|
||||
{instances.length === 0 ? 'Conectar WhatsApp' : 'Reconectar sessão'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : resolving ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-3 relative z-[1] text-[#667781]">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-[#008069]" />
|
||||
<span className="text-sm">Abrindo conversa…</span>
|
||||
</div>
|
||||
) : !selectedPhone ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-3 relative z-[1] p-8 text-center">
|
||||
<Phone className="w-12 h-12 opacity-10" />
|
||||
<p className="text-sm text-[#667781]">Este paciente não tem telefone no cadastro.</p>
|
||||
</div>
|
||||
) : notFound ? (
|
||||
// ── Sem conversa: iniciar uma nova ──────────────────────────────
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-4 relative z-[1] p-6">
|
||||
<div className="w-14 h-14 rounded-full bg-[#008069]/10 flex items-center justify-center">
|
||||
<MessageCircle className="w-7 h-7 text-[#008069]" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-bold text-[#111b21]">Sem conversa com {tituloContato}</p>
|
||||
<p className="text-[12px] text-[#667781] mt-0.5">{formatPhone(normalizeSendPhone(selectedPhone))}</p>
|
||||
</div>
|
||||
<div className="w-full max-w-sm space-y-2">
|
||||
<textarea
|
||||
rows={3}
|
||||
value={initText}
|
||||
onChange={(e) => { setInitText(e.target.value); setInitError(''); }}
|
||||
placeholder="Olá! Tudo bem? Passando para confirmar seu horário…"
|
||||
disabled={initSending}
|
||||
className="w-full bg-white rounded-xl px-4 py-3 text-sm text-[#111b21] placeholder:text-[#8696a0] focus:outline-none focus:ring-2 focus:ring-[#008069]/40 resize-none disabled:opacity-60 border border-gray-100"
|
||||
/>
|
||||
{initError && <p className="text-[12px] text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-2">{initError}</p>}
|
||||
<button
|
||||
onClick={handleIniciarConversa}
|
||||
disabled={initSending || !initText.trim()}
|
||||
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-xl text-sm font-semibold text-white bg-[#008069] hover:bg-[#017259] transition-all active:scale-[0.98] disabled:opacity-50"
|
||||
>
|
||||
{initSending
|
||||
? <><Loader2 className="w-4 h-4 animate-spin" /> Enviando…</>
|
||||
: <><Send className="w-4 h-4" /> Iniciar conversa</>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : selectedChat ? (
|
||||
// ── Conversa ativa ──────────────────────────────────────────────
|
||||
<div className="flex-1 flex h-full relative z-[1] overflow-hidden">
|
||||
<div className="flex-1 flex flex-col h-full overflow-hidden">
|
||||
<InboxHeader
|
||||
selectedChat={selectedChat}
|
||||
isMobile={true}
|
||||
isTyping={isTyping}
|
||||
onAvatarClick={() => setIsProfileOpen(!isProfileOpen)}
|
||||
onBack={onClose}
|
||||
onSyncAvatar={handleSyncAvatar}
|
||||
isChatMenuOpen={isChatMenuOpen}
|
||||
isSyncingAvatar={isSyncingAvatar}
|
||||
onSearchClick={() => {}}
|
||||
onToggleMenu={() => setIsChatMenuOpen(!isChatMenuOpen)}
|
||||
/>
|
||||
<MessageArea
|
||||
messages={messages as any}
|
||||
selectedChat={selectedChat}
|
||||
scrollRef={scrollRef}
|
||||
msgLoading={loadingMessages}
|
||||
onScroll={handleScroll}
|
||||
historyLoadingMore={loadingMore}
|
||||
hasMoreHistory={hasMoreHistory}
|
||||
reactionPickerFor={reactionPickerFor}
|
||||
onReply={setReplyingTo}
|
||||
onToggleReactionPicker={toggleReactionPicker}
|
||||
onReact={handleReactToMessage}
|
||||
onForward={() => {}}
|
||||
onDelete={() => {}}
|
||||
onEdit={() => {}}
|
||||
quickReactions={['👍', '❤️', '😂', '😮', '😢', '🙏']}
|
||||
getGroupSenderLabel={(msg: any) => {
|
||||
if (msg.direction === 'out') return 'Você';
|
||||
if (msg.participant) return msg.participant;
|
||||
if (msg.senderJid) {
|
||||
if (msg.senderJid.endsWith('@lid')) return msg.participant || '';
|
||||
const phone = msg.senderJid.split('@')[0].split(':')[0];
|
||||
return formatPhone(phone, msg.senderJid);
|
||||
}
|
||||
return '';
|
||||
}}
|
||||
/>
|
||||
<InputBar
|
||||
newMessage={newMessage}
|
||||
onNewMessageChange={setNewMessage}
|
||||
onSend={handleSendMessage}
|
||||
onPresence={handleTyping}
|
||||
onCancelReply={() => setReplyingTo(null)}
|
||||
replyingTo={replyingTo}
|
||||
selectedChat={selectedChat}
|
||||
isMobile={true}
|
||||
onSendMedia={handleSendMedia}
|
||||
onSendAudio={handleSendAudio}
|
||||
instanceId={activeInstance?.instance ?? null}
|
||||
mentions={mentions}
|
||||
onMentionsChange={setMentions}
|
||||
/>
|
||||
</div>
|
||||
<ContactProfile
|
||||
isOpen={isProfileOpen}
|
||||
chat={selectedChat}
|
||||
messages={messages as any}
|
||||
onClose={() => setIsProfileOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-3 relative z-[1] text-[#667781]">
|
||||
<Loader2 className="w-6 h-6 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.aside>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Wrapper: só monta o conteúdo (e os hooks) quando aberto ──────────────────
|
||||
export const WhatsChatDrawer: React.FC<WhatsChatDrawerProps> = ({ isOpen, ...rest }) => (
|
||||
<AnimatePresence>
|
||||
{isOpen && <DrawerInner {...rest} />}
|
||||
</AnimatePresence>
|
||||
);
|
||||
|
||||
export default WhatsChatDrawer;
|
||||
@@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { MessageCircle } from 'lucide-react';
|
||||
import type { NewWhatsChat, PresenceState } from '../types/inboxTypes';
|
||||
import ChatListItem from './ChatListItem';
|
||||
|
||||
interface ChatListProps {
|
||||
chats: NewWhatsChat[];
|
||||
loading: boolean;
|
||||
selectedChat: NewWhatsChat | null;
|
||||
onSelectChat: (chat: NewWhatsChat) => void;
|
||||
onMenuAction: (action: any, chat: NewWhatsChat) => void;
|
||||
presenceByJid: Record<string, PresenceState>;
|
||||
isFavoriteChat: (chat: NewWhatsChat) => boolean;
|
||||
drafts?: Record<string, string>;
|
||||
canDelete?: boolean;
|
||||
}
|
||||
|
||||
export default function ChatList({
|
||||
chats,
|
||||
loading,
|
||||
selectedChat,
|
||||
onSelectChat,
|
||||
onMenuAction,
|
||||
presenceByJid,
|
||||
isFavoriteChat,
|
||||
drafts = {},
|
||||
canDelete = true,
|
||||
}: ChatListProps) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-40">
|
||||
<div className="w-8 h-8 border-3 border-[#00a884] border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (chats.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-64 px-10 text-center">
|
||||
<div className="w-16 h-16 bg-[#f0f2f5] rounded-full flex items-center justify-center mb-4">
|
||||
<MessageCircle className="w-8 h-8 text-[#8696a0]" />
|
||||
</div>
|
||||
<p className="text-[#667781] text-sm font-normal">Nenhuma conversa encontrada</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto bg-white custom-scrollbar">
|
||||
{chats.map((chat) => (
|
||||
<ChatListItem
|
||||
key={`${chat.instance_name}-${chat.remote_jid}`}
|
||||
chat={chat}
|
||||
isActive={selectedChat?.id === chat.id}
|
||||
onSelect={onSelectChat}
|
||||
onMenuAction={onMenuAction}
|
||||
presenceByJid={presenceByJid}
|
||||
isFavorite={isFavoriteChat(chat)}
|
||||
draft={drafts[chat.remote_jid]}
|
||||
canDelete={canDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
'use client';
|
||||
|
||||
import React, { useMemo, useRef, useState, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import {
|
||||
Pin, Check, CheckCheck, Clock, ChevronDown,
|
||||
VolumeX, Star, StarOff, Archive, ArchiveRestore,
|
||||
Trash2, MailOpen, Volume2, PinOff,
|
||||
Mic, FileText, Video, Image as ImageIcon,
|
||||
AlertCircle, Bot,
|
||||
} from 'lucide-react';
|
||||
import type { NewWhatsChat, PresenceState } from '../types/inboxTypes';
|
||||
import {
|
||||
normalizeJid,
|
||||
} from '../utils/inboxUtils';
|
||||
import Avatar from './ui/Avatar';
|
||||
|
||||
interface ChatListItemProps {
|
||||
chat: NewWhatsChat;
|
||||
isActive: boolean;
|
||||
onSelect: (chat: NewWhatsChat) => void;
|
||||
onMenuAction: (action: any, chat: NewWhatsChat) => void;
|
||||
presenceByJid: Record<string, PresenceState>;
|
||||
isFavorite: boolean;
|
||||
draft?: string;
|
||||
canDelete?: boolean; // false esconde "Apagar conversa" (sem permissão)
|
||||
}
|
||||
|
||||
const renderLastStatus = (chat: NewWhatsChat) => {
|
||||
if (chat.last_message_from_me !== 1) return null;
|
||||
switch (chat.last_message_status) {
|
||||
case 'pending':
|
||||
case 'sending':
|
||||
return <Clock className="w-3.5 h-3.5 text-[#8696a0] flex-shrink-0" />;
|
||||
case 'sent':
|
||||
return <Check className="w-3.5 h-3.5 text-[#8696a0] flex-shrink-0" />;
|
||||
case 'delivered':
|
||||
return <CheckCheck className="w-3.5 h-3.5 text-[#8696a0] flex-shrink-0" />;
|
||||
case 'read':
|
||||
return <CheckCheck className="w-3.5 h-3.5 text-[#53bdeb] flex-shrink-0" />;
|
||||
case 'failed':
|
||||
return <AlertCircle className="w-3.5 h-3.5 text-red-500 flex-shrink-0" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getLastMessageSnippet = (chat: NewWhatsChat, draft?: string): React.ReactNode => {
|
||||
if (draft) return <span className="text-[#00a884]">Rascunho: {draft}</span>;
|
||||
|
||||
const truncate = (value: string, limit = 35) => value.length > limit ? `${value.slice(0, limit - 1)}...` : value;
|
||||
|
||||
// Group Sender Prefix — WhatsApp exibe "Você:" ou primeiro nome do remetente
|
||||
const isGroup = chat.remote_jid.endsWith('@g.us');
|
||||
const senderPrefix = isGroup
|
||||
? (chat.last_message_from_me === 1
|
||||
? 'Você: '
|
||||
: (chat.last_message_sender ? `${chat.last_message_sender.split(' ')[0]}: ` : ''))
|
||||
: '';
|
||||
|
||||
const IconLabel = ({ icon: Icon, label }: { icon: any, label: string }) => (
|
||||
<span className="flex items-center gap-1">
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{senderPrefix}{label}
|
||||
</span>
|
||||
);
|
||||
|
||||
// Use last_message_type (pipeline do chatStore) para ícones de mídia
|
||||
const msgType = (chat.last_message_type ?? '').toLowerCase();
|
||||
|
||||
if (msgType === 'audio' || msgType === 'ptt') return <IconLabel icon={Mic} label="Áudio" />;
|
||||
if (msgType === 'sticker') return <IconLabel icon={Star} label="Figurinha" />;
|
||||
if (msgType === 'image') {
|
||||
const caption = chat.last_message_text?.trim();
|
||||
return caption
|
||||
? <IconLabel icon={ImageIcon} label={truncate(caption)} />
|
||||
: <IconLabel icon={ImageIcon} label="Imagem" />;
|
||||
}
|
||||
if (msgType === 'video') {
|
||||
const caption = chat.last_message_text?.trim();
|
||||
return caption
|
||||
? <IconLabel icon={Video} label={truncate(caption)} />
|
||||
: <IconLabel icon={Video} label="Vídeo" />;
|
||||
}
|
||||
if (msgType === 'document') return <IconLabel icon={FileText} label={chat.last_message_text?.trim() || 'Documento'} />;
|
||||
|
||||
// Texto puro (text, extendedtext, etc.)
|
||||
const text = chat.last_message_text ?? '';
|
||||
if (text.trim()) return `${senderPrefix}${truncate(text.trim())}`;
|
||||
|
||||
return senderPrefix || '';
|
||||
};
|
||||
|
||||
export default function ChatListItem({
|
||||
chat,
|
||||
isActive,
|
||||
onSelect,
|
||||
onMenuAction,
|
||||
presenceByJid,
|
||||
isFavorite,
|
||||
draft,
|
||||
canDelete = true
|
||||
}: ChatListItemProps) {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [menuPos, setMenuPos] = useState<{ top: number; left: number } | null>(null);
|
||||
const menuBtnRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const relativeTime = useMemo(() => {
|
||||
if (!chat.last_message_time) return '';
|
||||
const date = new Date(chat.last_message_time);
|
||||
if (isNaN(date.getTime())) return '';
|
||||
|
||||
// Comparação por data de calendário (não por 24h corridas)
|
||||
const today = new Date(); today.setHours(0, 0, 0, 0);
|
||||
const msgDay = new Date(date); msgDay.setHours(0, 0, 0, 0);
|
||||
const diffDays = Math.round((today.getTime() - msgDay.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) {
|
||||
return date.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
if (diffDays === 1) return 'Ontem';
|
||||
if (diffDays < 7) {
|
||||
// "Seg", "Ter", "Qua", "Qui", "Sex", "Sáb", "Dom"
|
||||
const short = date.toLocaleDateString('pt-BR', { weekday: 'short' });
|
||||
return short.replace('.', '').replace(/^\w/, (c) => c.toUpperCase());
|
||||
}
|
||||
return date.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: '2-digit' });
|
||||
}, [chat.last_message_time]);
|
||||
|
||||
const isMuted = useMemo(() => {
|
||||
return Boolean(chat.muted_until && new Date(chat.muted_until).getTime() > Date.now());
|
||||
}, [chat.muted_until]);
|
||||
|
||||
const menuItems = useMemo(() => {
|
||||
const isPinned = Boolean(chat.is_pinned);
|
||||
const isArchived = Boolean(chat.is_archived);
|
||||
const isGroup = chat.remote_jid.endsWith('@g.us');
|
||||
|
||||
return [
|
||||
{ key: 'favorite', label: isFavorite ? 'Remover favorito' : 'Favoritar', icon: isFavorite ? <StarOff className="w-4 h-4" /> : <Star className="w-4 h-4" /> },
|
||||
{ key: 'archive', label: isArchived ? 'Desarquivar' : 'Arquivar', icon: isArchived ? <ArchiveRestore className="w-4 h-4" /> : <Archive className="w-4 h-4" /> },
|
||||
{ key: 'mute', label: isMuted ? 'Desativar silêncio' : 'Silenciar', icon: isMuted ? <Volume2 className="w-4 h-4" /> : <VolumeX className="w-4 h-4" /> },
|
||||
{ key: 'pin', label: isPinned ? 'Desafixar' : 'Fixar conversa', icon: isPinned ? <PinOff className="w-4 h-4" /> : <Pin className="w-4 h-4" /> },
|
||||
{ key: 'mark_read', label: 'Marcar como lida', icon: <MailOpen className="w-4 h-4" /> },
|
||||
...(canDelete ? [{ key: 'delete', label: 'Apagar conversa', icon: <Trash2 className="w-4 h-4" />, danger: true }] : []),
|
||||
];
|
||||
}, [chat, isFavorite, isMuted, canDelete]);
|
||||
|
||||
const MENU_H = 252; // 6 itens × ~40px + 12px padding container
|
||||
const MENU_W = 224; // w-56
|
||||
|
||||
const openMenu = (anchorX: number, anchorY: number, anchorBottom?: number) => {
|
||||
const triggerBottom = anchorBottom ?? anchorY;
|
||||
const spaceBelow = window.innerHeight - triggerBottom;
|
||||
// Abre abaixo se couber; senão ancora o BOTTOM do menu no TOP do trigger
|
||||
const top = spaceBelow >= MENU_H + 8
|
||||
? triggerBottom + 4
|
||||
: anchorY - MENU_H;
|
||||
const left = Math.min(anchorX, window.innerWidth - MENU_W - 8);
|
||||
setMenuPos({ top: Math.max(8, top), left: Math.max(8, left) });
|
||||
setMenuOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Usamos div+role="button" em vez de <button> porque dentro há outro
|
||||
<button> (o menu chevron) — aninhar botões é inválido em HTML e
|
||||
dispara validateDOMNesting warning no React. */}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onSelect(chat)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onSelect(chat);
|
||||
}
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
// Abre à direita do cursor se couber, senão à esquerda
|
||||
const spaceRight = window.innerWidth - e.clientX;
|
||||
const x = spaceRight >= MENU_W + 8 ? e.clientX : e.clientX - MENU_W;
|
||||
// Passa anchorY = cursor Y, anchorBottom = cursor Y (sem item height)
|
||||
openMenu(Math.max(8, x), e.clientY, e.clientY);
|
||||
}}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 transition-all text-[#111b21] group border-b border-[#f0f2f5] last:border-none cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-[#00a884]/40 ${isActive ? 'bg-[#f0f2f5]' : 'hover:bg-[#f5f6f6]'}`}
|
||||
>
|
||||
{/* Avatar Section */}
|
||||
<Avatar chat={chat} className="flex-shrink-0" />
|
||||
|
||||
{/* Content Section */}
|
||||
<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}
|
||||
</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>
|
||||
|
||||
<div className="flex items-center justify-between mt-1 h-5">
|
||||
<div className="flex items-center gap-1 flex-1 min-w-0">
|
||||
{(() => {
|
||||
const jid = normalizeJid(chat.remote_jid);
|
||||
const p = presenceByJid[jid];
|
||||
const isPresenceLive = p && (Date.now() - p.ts) <= 6000;
|
||||
|
||||
if (isPresenceLive && (p.state === 'composing' || p.state === 'recording')) {
|
||||
return (
|
||||
<p className="text-[13px] text-[#00a884] font-normal truncate">
|
||||
{p.state === 'recording' ? 'gravando áudio...' : 'digitando...'}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
{!draft && renderLastStatus(chat)}
|
||||
<div className={`text-[13px] leading-[20px] truncate max-w-full ${draft ? 'text-[#00a884]' : 'text-[#667781]'}`}>
|
||||
{getLastMessageSnippet(chat, draft)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-shrink-0 ml-1">
|
||||
{chat.bot_paused && (
|
||||
<span title="Bot pausado — aguardando atendente">
|
||||
<Bot className="w-4 h-4 text-amber-500" />
|
||||
</span>
|
||||
)}
|
||||
{isMuted && <VolumeX className="w-4 h-4 text-[#8696a0]" />}
|
||||
{Boolean(chat.is_pinned) && <Pin className="w-4 h-4 text-[#8696a0]" />}
|
||||
{chat.unread_count > 0 && (
|
||||
<span className="bg-[#00a884] text-white text-[12px] font-bold px-1.5 py-0.5 rounded-full min-w-[20px] h-5 flex items-center justify-center">
|
||||
{chat.unread_count > 99 ? '99+' : chat.unread_count}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Hover Chevron for Menu */}
|
||||
<div className="relative group-hover:block hidden">
|
||||
<button
|
||||
ref={menuBtnRef}
|
||||
className="p-1 rounded-full hover:bg-black/5"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const rect = menuBtnRef.current?.getBoundingClientRect();
|
||||
if (rect) {
|
||||
openMenu(rect.right - MENU_W, rect.top, rect.bottom);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ChevronDown className="w-4 h-4 text-[#8696a0]" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{/* Context Menu Portal (fora do item pra não ficar dentro do role=button) */}
|
||||
{menuOpen && menuPos && typeof window !== 'undefined' && createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 z-[1000]" onClick={(e) => { e.stopPropagation(); setMenuOpen(false); }} />
|
||||
<div
|
||||
className="fixed bg-white py-1.5 rounded-lg shadow-xl border border-gray-100 z-[1001] w-56 flex flex-col"
|
||||
style={{ top: menuPos.top, left: menuPos.left }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{menuItems.map((item: any) => (
|
||||
<button
|
||||
key={item.key}
|
||||
onClick={() => {
|
||||
onMenuAction(item.key, chat);
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
className={`flex items-center gap-3 px-4 py-2.5 text-sm transition-colors text-left ${
|
||||
item.danger ? 'text-red-600 hover:bg-red-50' : 'text-[#3b4a54] hover:bg-[#f5f6f6]'
|
||||
}`}
|
||||
>
|
||||
<span className={item.danger ? 'text-red-500' : 'text-[#8696a0]'}>{item.icon}</span>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
'use client';
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import type { NewWhatsChat, PresenceState } from '../types/inboxTypes';
|
||||
import ChatList from './ChatList';
|
||||
|
||||
interface ChatSidebarProps {
|
||||
chats: NewWhatsChat[];
|
||||
loading: boolean;
|
||||
selectedChat: NewWhatsChat | null;
|
||||
searchTerm: string;
|
||||
selectedLabelFilter: string | null;
|
||||
activeFilter: string;
|
||||
chatScope: 'inbox' | 'favorites' | 'archived' | 'custom_list';
|
||||
activeCustomListName?: string | null;
|
||||
presenceByJid: Record<string, PresenceState>;
|
||||
isFavoriteChat: (chat: NewWhatsChat) => boolean;
|
||||
onSelectChat: (chat: NewWhatsChat) => void;
|
||||
onMenuAction: (action: any, chat: NewWhatsChat) => void;
|
||||
drafts?: Record<string, string>;
|
||||
canDelete?: boolean;
|
||||
}
|
||||
|
||||
export default function ChatSidebar({
|
||||
chats,
|
||||
loading,
|
||||
selectedChat,
|
||||
searchTerm,
|
||||
selectedLabelFilter,
|
||||
activeFilter,
|
||||
chatScope,
|
||||
activeCustomListName,
|
||||
presenceByJid,
|
||||
isFavoriteChat,
|
||||
onSelectChat,
|
||||
onMenuAction,
|
||||
drafts = {},
|
||||
canDelete = true,
|
||||
}: ChatSidebarProps) {
|
||||
// UI Pills (Simplified, for now we keep layout consistent)
|
||||
const [pillFilter, setPillFilter] = useState<'all' | 'unread' | 'favorites' | 'groups'>('all');
|
||||
|
||||
const filteredChats = useMemo(() => chats.filter((chat) => {
|
||||
const isGroup = chat.remote_jid.endsWith('@g.us');
|
||||
|
||||
// Filter by pill
|
||||
if (pillFilter === 'unread' && chat.unread_count === 0) return false;
|
||||
if (pillFilter === 'groups' && !isGroup) return false;
|
||||
if (pillFilter === 'favorites' && !isFavoriteChat(chat)) return false;
|
||||
|
||||
const displayName = chat.displayName || chat.subject || chat.phone || chat.remote_jid;
|
||||
const matchesSearch = displayName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(chat.last_message_text || '').toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
const matchesLabel = !selectedLabelFilter ||
|
||||
(chat.labels && chat.labels.some((l) => l.label_id === selectedLabelFilter));
|
||||
|
||||
return matchesSearch && matchesLabel;
|
||||
}), [chats, searchTerm, selectedLabelFilter, pillFilter, isFavoriteChat]);
|
||||
|
||||
const sortedFilteredChats = useMemo(
|
||||
() => [...filteredChats].sort((a, b) => {
|
||||
const aPinned = Boolean(a.is_pinned);
|
||||
const bPinned = Boolean(b.is_pinned);
|
||||
if (aPinned !== bPinned) return aPinned ? -1 : 1;
|
||||
const tA = a.last_message_time ? new Date(a.last_message_time).getTime() : 0;
|
||||
const tB = b.last_message_time ? new Date(b.last_message_time).getTime() : 0;
|
||||
return tB - tA;
|
||||
}),
|
||||
[filteredChats]
|
||||
);
|
||||
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-white border-r border-[#e9edef] overflow-hidden">
|
||||
{/* Filter Pills */}
|
||||
<div className="px-3 py-2 flex items-center gap-2 overflow-x-auto no-scrollbar flex-shrink-0 border-b border-[#f0f2f5]">
|
||||
{(['all', 'unread', 'favorites', 'groups'] as const).map((filter) => (
|
||||
<button
|
||||
key={filter}
|
||||
onClick={() => setPillFilter(filter)}
|
||||
className={`px-3 py-1 rounded-full text-sm whitespace-nowrap transition-colors ${
|
||||
pillFilter === filter
|
||||
? 'bg-[#00a884] text-white'
|
||||
: 'bg-[#f0f2f5] text-[#54656f] hover:bg-[#e9edef]'
|
||||
}`}
|
||||
>
|
||||
{filter === 'all' && 'Tudo'}
|
||||
{filter === 'unread' && 'Não lidas'}
|
||||
{filter === 'favorites' && 'Favoritos'}
|
||||
{filter === 'groups' && 'Grupos'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Scrollable Chat List */}
|
||||
<ChatList
|
||||
chats={sortedFilteredChats}
|
||||
loading={loading}
|
||||
selectedChat={selectedChat}
|
||||
onSelectChat={onSelectChat}
|
||||
onMenuAction={onMenuAction}
|
||||
presenceByJid={presenceByJid}
|
||||
isFavoriteChat={isFavoriteChat}
|
||||
drafts={drafts}
|
||||
canDelete={canDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
'use client';
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
X,
|
||||
ChevronRight,
|
||||
Image as ImageIcon,
|
||||
Video as VideoIcon,
|
||||
FileText,
|
||||
Mic,
|
||||
Bell,
|
||||
Star,
|
||||
Clock,
|
||||
Ban,
|
||||
ThumbsDown,
|
||||
Trash2
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
formatPhone,
|
||||
getMediaUrl
|
||||
} from '../utils/inboxUtils';
|
||||
import Avatar from './ui/Avatar';
|
||||
import { Message } from '../types/inboxTypes';
|
||||
|
||||
interface ContactProfileProps {
|
||||
isOpen: boolean;
|
||||
chat: any;
|
||||
messages: Message[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const ContactProfile: React.FC<ContactProfileProps> = ({
|
||||
isOpen,
|
||||
chat,
|
||||
messages,
|
||||
onClose,
|
||||
}) => {
|
||||
// Filter media messages
|
||||
const mediaMessages = useMemo(() => {
|
||||
return messages.filter(m => m.type === 'image' || m.type === 'video' || m.type === 'document' || m.type === 'audio');
|
||||
}, [messages]);
|
||||
|
||||
const photoVideos = mediaMessages.filter(m => m.type === 'image' || m.type === 'video');
|
||||
const documents = mediaMessages.filter(m => m.type === 'document');
|
||||
const audios = mediaMessages.filter(m => m.type === 'audio');
|
||||
|
||||
if (!chat) return null;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ x: '100%' }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: '100%' }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
|
||||
className="w-[400px] h-full bg-[#f0f2f5] border-l border-[#d1d7db] flex flex-col z-40 relative flex-shrink-0"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="h-[60px] px-4 bg-white flex items-center gap-6 border-b border-[#d1d7db] shrink-0">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-black/5 rounded-full transition-colors"
|
||||
>
|
||||
<X className="w-6 h-6 text-[#54656f]" />
|
||||
</button>
|
||||
<h2 className="text-[16px] font-normal text-[#111b21]">Dados do contato</h2>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden custom-scrollbar pb-10">
|
||||
{/* Profile Section */}
|
||||
<div className="bg-white px-8 py-8 flex flex-col items-center mb-2 shadow-sm">
|
||||
{/* 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)}
|
||||
</h1>
|
||||
<p className="text-[16px] text-[#667781] font-normal mb-1">
|
||||
{formatPhone(chat.phone, chat.remote_jid)}
|
||||
</p>
|
||||
{chat.bio && (
|
||||
<p className="text-[14px] text-[#667781] font-normal mt-2 text-center max-w-xs">
|
||||
{chat.bio}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status (Bio) */}
|
||||
<div className="bg-white px-8 py-4 mb-2 shadow-sm">
|
||||
<h3 className="text-[14px] text-[#667781] font-normal mb-3">Recado</h3>
|
||||
<p className="text-[16px] text-[#111b21] font-normal leading-relaxed">
|
||||
{chat.bio || 'Sem recado disponível'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Media Section */}
|
||||
<div className="bg-white mb-2 shadow-sm">
|
||||
<button className="w-full px-8 py-4 flex items-center justify-between hover:bg-[#f5f6f6] transition-colors group">
|
||||
<span className="text-[14px] text-[#667781] font-normal">Mídia, links e documentos</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[14px] text-[#667781] font-normal">{mediaMessages.length}</span>
|
||||
<ChevronRight className="w-5 h-5 text-[#8696a0] group-hover:translate-x-0.5 transition-transform" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{mediaMessages.length > 0 ? (
|
||||
<div className="px-8 pb-4 grid grid-cols-3 gap-1">
|
||||
{photoVideos.slice(0, 3).map((m, i) => (
|
||||
<div
|
||||
key={m.id || i}
|
||||
className="aspect-square bg-gray-100 rounded-sm overflow-hidden cursor-pointer hover:opacity-90 transition-opacity"
|
||||
onClick={() => {
|
||||
const url = getMediaUrl(m.media_url);
|
||||
if (url) window.open(url, '_blank');
|
||||
}}
|
||||
>
|
||||
<div className="w-full h-full flex items-center justify-center bg-black/10">
|
||||
{m.type === 'video' ? <VideoIcon className="w-6 h-6 text-white" /> : <ImageIcon className="w-6 h-6 text-gray-400" />}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{mediaMessages.length > 3 && (
|
||||
<div className="aspect-square flex items-center justify-center bg-gray-50 border border-dashed border-gray-200 text-gray-400 text-xs rounded-sm">
|
||||
+{mediaMessages.length - 3}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-8 pb-6 flex flex-col items-center justify-center text-center">
|
||||
<p className="text-[14px] text-[#667781] italic">Nenhuma mídia compartilhada</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions Section */}
|
||||
<div className="bg-white mb-2 shadow-sm py-2">
|
||||
{[
|
||||
{ icon: Bell, label: 'Silenciar notificações', color: '#111b21' },
|
||||
{ icon: Star, label: 'Mensagens favoritas', color: '#111b21' },
|
||||
{ icon: Clock, label: 'Mensagens temporárias', sub: 'Desativadas', color: '#111b21' },
|
||||
].map((item, idx) => (
|
||||
<button key={idx} className="w-full px-8 py-3.5 flex items-center gap-6 hover:bg-[#f5f6f6] transition-colors text-left">
|
||||
<item.icon className="w-5 h-5 text-[#8696a0]" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[16px] text-[#111b21] font-normal leading-none">{item.label}</span>
|
||||
{item.sub && <span className="text-[12px] text-[#667781] mt-1">{item.sub}</span>}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="bg-white mb-2 shadow-sm py-2">
|
||||
{[
|
||||
{ icon: Ban, label: `Bloquear ${chat.lead_name || 'Contato'}`, color: '#ea0038' },
|
||||
{ icon: ThumbsDown, label: `Denunciar ${chat.lead_name || 'Contato'}`, color: '#ea0038' },
|
||||
{ icon: Trash2, label: 'Apagar conversa', color: '#ea0038' }
|
||||
].map((item, idx) => (
|
||||
<button key={idx} className="w-full px-8 py-3.5 flex items-center gap-6 hover:bg-[#f5f6f6] transition-colors text-left">
|
||||
<item.icon className="w-5 h-5" style={{ color: item.color }} />
|
||||
<span className="text-[16px] font-normal" style={{ color: item.color }}>{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContactProfile;
|
||||
@@ -0,0 +1,244 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, Send, FileText, Film, Music, Archive, File, ZoomIn, ZoomOut } from 'lucide-react';
|
||||
|
||||
interface FilePreviewModalProps {
|
||||
file: File | null;
|
||||
onConfirm: (file: File, caption: string) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function getFileIcon(type: string) {
|
||||
if (type.startsWith('video/')) return <Film className="w-16 h-16 text-blue-400" />;
|
||||
if (type.startsWith('audio/')) return <Music className="w-16 h-16 text-purple-400" />;
|
||||
if (type.includes('pdf') || type.includes('document') || type.includes('word'))
|
||||
return <FileText className="w-16 h-16 text-red-400" />;
|
||||
if (type.includes('zip') || type.includes('compressed') || type.includes('tar'))
|
||||
return <Archive className="w-16 h-16 text-yellow-400" />;
|
||||
return <File className="w-16 h-16 text-[#8696a0]" />;
|
||||
}
|
||||
|
||||
function getFileLabel(type: string): string {
|
||||
if (type.startsWith('image/')) return 'Imagem';
|
||||
if (type.startsWith('video/')) return 'Vídeo';
|
||||
if (type.startsWith('audio/')) return 'Áudio';
|
||||
if (type.includes('pdf')) return 'PDF';
|
||||
return 'Arquivo';
|
||||
}
|
||||
|
||||
function ModalContent({ file, onConfirm, onCancel }: FilePreviewModalProps & { file: File }) {
|
||||
const [caption, setCaption] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const [objectUrl, setObjectUrl] = useState<string | null>(null);
|
||||
const [zoomed, setZoomed] = useState(false);
|
||||
// Posição do mouse como % dentro da área da imagem (para o zoom seguir o cursor)
|
||||
const [mouseOrigin, setMouseOrigin] = useState({ x: 50, y: 50 });
|
||||
const captionRef = useRef<HTMLInputElement>(null);
|
||||
const previewAreaRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const isImage = file.type.startsWith('image/');
|
||||
const isVideo = file.type.startsWith('video/');
|
||||
|
||||
useEffect(() => {
|
||||
const url = URL.createObjectURL(file);
|
||||
setObjectUrl(url);
|
||||
setCaption('');
|
||||
setSending(false);
|
||||
setZoomed(false);
|
||||
setMouseOrigin({ x: 50, y: 50 });
|
||||
setTimeout(() => captionRef.current?.focus(), 250);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [file]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onCancel();
|
||||
};
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => document.removeEventListener('keydown', onKey);
|
||||
}, [onCancel]);
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
if (sending) return;
|
||||
setSending(true);
|
||||
try { await onConfirm(file, caption.trim()); }
|
||||
finally { setSending(false); }
|
||||
}, [file, caption, sending, onConfirm]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); handleSend(); }
|
||||
};
|
||||
|
||||
// Atualiza a origem do zoom conforme o mouse se move na área de preview
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!isImage) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const x = ((e.clientX - rect.left) / rect.width) * 100;
|
||||
const y = ((e.clientY - rect.top) / rect.height) * 100;
|
||||
setMouseOrigin({ x, y });
|
||||
}, [isImage]);
|
||||
|
||||
const toggleZoom = useCallback(() => setZoomed(z => !z), []);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key="file-preview-overlay"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.18 }}
|
||||
className="fixed inset-0 flex flex-col bg-[#0b141a]"
|
||||
style={{ zIndex: 99999 }}
|
||||
>
|
||||
{/* ── Top bar ── */}
|
||||
<div className="flex items-center gap-3 px-4 py-3 bg-[#202c33] shrink-0 shadow-md">
|
||||
{/* Info do arquivo — lado esquerdo */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[#e9edef] text-sm font-medium truncate">{file.name}</p>
|
||||
<p className="text-[#8696a0] text-xs">{getFileLabel(file.type)} · {formatFileSize(file.size)}</p>
|
||||
</div>
|
||||
|
||||
{/* Botões — lado direito: zoom (se imagem) → fechar */}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{isImage && (
|
||||
<button
|
||||
onClick={toggleZoom}
|
||||
className="p-2 rounded-full hover:bg-white/10 text-[#aebac1] transition-colors"
|
||||
title={zoomed ? 'Zoom normal' : 'Ampliar'}
|
||||
>
|
||||
{zoomed ? <ZoomOut className="w-5 h-5" /> : <ZoomIn className="w-5 h-5" />}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="p-2 rounded-full hover:bg-white/10 text-[#aebac1] transition-colors"
|
||||
title="Fechar"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Preview area ── */}
|
||||
<div
|
||||
ref={previewAreaRef}
|
||||
className="flex-1 flex items-center justify-center min-h-0 overflow-hidden p-4"
|
||||
onMouseMove={handleMouseMove}
|
||||
onClick={isImage ? toggleZoom : undefined}
|
||||
style={{ cursor: isImage ? (zoomed ? 'zoom-out' : 'zoom-in') : 'default' }}
|
||||
>
|
||||
{isImage && objectUrl && (
|
||||
<motion.img
|
||||
src={objectUrl}
|
||||
alt="preview"
|
||||
initial={{ scale: 0.94, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ duration: 0.22 }}
|
||||
draggable={false}
|
||||
className="select-none rounded-lg shadow-2xl"
|
||||
style={zoomed ? {
|
||||
// Zoom 2.5× centrado no cursor
|
||||
transform: `scale(2.5)`,
|
||||
transformOrigin: `${mouseOrigin.x}% ${mouseOrigin.y}%`,
|
||||
transition: 'transform-origin 0s', // origin segue o mouse instantaneamente
|
||||
maxWidth: '100%',
|
||||
maxHeight: 'calc(100vh - 160px)',
|
||||
objectFit: 'contain',
|
||||
cursor: 'zoom-out',
|
||||
} : {
|
||||
maxWidth: '100%',
|
||||
maxHeight: 'calc(100vh - 160px)',
|
||||
objectFit: 'contain',
|
||||
cursor: 'zoom-in',
|
||||
transition: 'transform 0.2s ease',
|
||||
transform: 'scale(1)',
|
||||
transformOrigin: 'center center',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isVideo && objectUrl && (
|
||||
<motion.video
|
||||
src={objectUrl}
|
||||
controls
|
||||
initial={{ scale: 0.94, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ duration: 0.22 }}
|
||||
className="max-w-full max-h-full rounded-lg shadow-2xl"
|
||||
style={{ maxHeight: 'calc(100vh - 160px)' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isImage && !isVideo && (
|
||||
<motion.div
|
||||
initial={{ scale: 0.92, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ duration: 0.22 }}
|
||||
className="flex flex-col items-center gap-5 bg-[#202c33] rounded-2xl px-14 py-12 shadow-2xl max-w-sm w-full"
|
||||
>
|
||||
<div className="w-24 h-24 rounded-2xl bg-[#2a3942] flex items-center justify-center">
|
||||
{getFileIcon(file.type)}
|
||||
</div>
|
||||
<div className="text-center space-y-1">
|
||||
<p className="text-[#e9edef] text-sm font-medium break-all">{file.name}</p>
|
||||
<p className="text-[#8696a0] text-xs">{formatFileSize(file.size)}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Caption + Send bar ── */}
|
||||
<div className="shrink-0 bg-[#202c33] px-4 py-3 flex items-center gap-3 shadow-[0_-1px_0_rgba(255,255,255,0.06)]">
|
||||
<input
|
||||
ref={captionRef}
|
||||
type="text"
|
||||
placeholder="Adicionar legenda…"
|
||||
value={caption}
|
||||
onChange={(e) => setCaption(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={sending}
|
||||
maxLength={1024}
|
||||
className="flex-1 bg-[#2a3942] text-[#e9edef] placeholder:text-[#8696a0] text-sm rounded-full px-5 py-2.5 outline-none border-none focus:ring-0 disabled:opacity-50 transition-opacity"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={sending}
|
||||
className="w-12 h-12 bg-[#00a884] hover:bg-[#00c49a] disabled:opacity-60 rounded-full flex items-center justify-center transition-all shadow-lg active:scale-95 shrink-0"
|
||||
>
|
||||
{sending
|
||||
? <div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
: <Send className="w-5 h-5 text-white translate-x-px" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FilePreviewModal({ file, onConfirm, onCancel }: FilePreviewModalProps) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => { setMounted(true); }, []);
|
||||
if (!mounted) return null;
|
||||
|
||||
return createPortal(
|
||||
<AnimatePresence>
|
||||
{file && (
|
||||
<ModalContent
|
||||
file={file}
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Search,
|
||||
MoreVertical,
|
||||
RefreshCw,
|
||||
Tag,
|
||||
X,
|
||||
ClipboardList,
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
formatPhone,
|
||||
} from '../utils/inboxUtils';
|
||||
import Avatar from './ui/Avatar';
|
||||
|
||||
interface InboxHeaderProps {
|
||||
selectedChat: any;
|
||||
isMobile: boolean;
|
||||
isTyping: boolean;
|
||||
isChatMenuOpen: boolean;
|
||||
isSyncingAvatar: boolean;
|
||||
isProtocolOpen?: boolean;
|
||||
hasActiveProtocol?: boolean;
|
||||
onBack: () => void;
|
||||
onSearchClick: () => void;
|
||||
onToggleMenu: () => void;
|
||||
onSyncAvatar: () => void;
|
||||
onAvatarClick?: () => void;
|
||||
onProtocolClick?: () => void;
|
||||
}
|
||||
|
||||
const InboxHeader: React.FC<InboxHeaderProps> = ({
|
||||
selectedChat,
|
||||
isMobile,
|
||||
isTyping,
|
||||
isChatMenuOpen,
|
||||
isSyncingAvatar,
|
||||
isProtocolOpen,
|
||||
hasActiveProtocol,
|
||||
onBack,
|
||||
onSearchClick,
|
||||
onToggleMenu,
|
||||
onSyncAvatar,
|
||||
onAvatarClick,
|
||||
onProtocolClick,
|
||||
}) => {
|
||||
return (
|
||||
<header className="h-[60px] px-4 bg-[#f0f2f5] flex items-center justify-between border-b border-[#d1d7db] z-30 relative shrink-0">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
{isMobile && (
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="p-1 -ml-1 mr-1 hover:bg-black/5 rounded-full transition-colors active:scale-90"
|
||||
>
|
||||
<ArrowLeft className="w-6 h-6 text-[#54656f]" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Identity Area - Clickable */}
|
||||
<div
|
||||
onClick={onAvatarClick}
|
||||
className="flex items-center gap-3 min-w-0 cursor-pointer group/header hover:bg-black/5 px-2 py-1 -ml-2 rounded-md transition-all active:scale-[0.98]"
|
||||
>
|
||||
{/* Avatar */}
|
||||
<Avatar chat={selectedChat} size={40} className="flex-shrink-0" />
|
||||
|
||||
<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)}
|
||||
</h3>
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
{isTyping ? (
|
||||
<span className="text-[13px] text-[#00a884] font-medium animate-pulse">
|
||||
digitando...
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[12.5px] text-[#667781] truncate max-w-[300px] font-normal">
|
||||
{selectedChat.bio || (
|
||||
selectedChat.remote_jid?.endsWith('@g.us')
|
||||
? `Clique para dados do grupo`
|
||||
: 'Clique para informações do contato'
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{onProtocolClick && (
|
||||
<button
|
||||
onClick={onProtocolClick}
|
||||
className={`p-2 rounded-full transition-all relative ${isProtocolOpen ? 'bg-black/10 text-[#111b21]' : 'hover:bg-black/5 text-[#54656f] hover:text-[#111b21]'}`}
|
||||
title="Protocolos de atendimento"
|
||||
>
|
||||
<ClipboardList className="w-5 h-5" />
|
||||
{hasActiveProtocol && !isProtocolOpen && (
|
||||
<span className="absolute top-1.5 right-1.5 w-2 h-2 rounded-full bg-brand-500 border border-white" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onSearchClick}
|
||||
className="p-2 hover:bg-black/5 rounded-full transition-all text-[#54656f] hover:text-[#111b21]"
|
||||
title="Procurar na conversa"
|
||||
>
|
||||
<Search className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={onToggleMenu}
|
||||
className={`p-2 rounded-full transition-all ${isChatMenuOpen ? 'bg-black/10 text-[#111b21]' : 'hover:bg-black/5 text-[#54656f]'}`}
|
||||
>
|
||||
<MoreVertical className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{isChatMenuOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||
className="absolute right-0 top-12 w-56 bg-white rounded-lg shadow-2xl border border-gray-100 py-2 z-50 overflow-hidden"
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
onSyncAvatar();
|
||||
onToggleMenu();
|
||||
}}
|
||||
disabled={isSyncingAvatar}
|
||||
className="w-full flex items-center gap-3 px-4 py-2.5 text-[14px] text-[#111b21] hover:bg-[#f5f6f6] transition-colors disabled:opacity-50"
|
||||
>
|
||||
<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">
|
||||
<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>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default InboxHeader;
|
||||
@@ -0,0 +1,722 @@
|
||||
'use client';
|
||||
|
||||
import React, { useRef, useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Smile,
|
||||
Paperclip,
|
||||
Mic,
|
||||
Send,
|
||||
X,
|
||||
StopCircle,
|
||||
Pause,
|
||||
Play,
|
||||
LayoutTemplate,
|
||||
BookOpen,
|
||||
Plus,
|
||||
Sticker,
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
// Satélite: emoji picker simples (evita a dep @emoji-mart, incompatível com react 19).
|
||||
const EMOJI_SET = ['😀','😁','😂','🤣','😊','😍','😘','😎','🤔','😐','😴','😭','😡','👍','👎','🙏','👏','🙌','💪','🔥','🎉','❤️','💔','✅','❌','⚠️','📌','📎','📞','💬','⏰','🥳','😇','🤝','👀','💯'];
|
||||
import { groupApi, type GroupParticipant, type MessageTemplate, type TemplateType } from '../services/chatApiService';
|
||||
import RichMessageComposer from './RichMessageComposer';
|
||||
import TemplateLibrary from './TemplateLibrary';
|
||||
import ScheduleMessageModal from './ScheduleMessageModal';
|
||||
import FilePreviewModal from './FilePreviewModal';
|
||||
import StickerPanel from './StickerPanel';
|
||||
import { mediaApi } from '../services/chatApiService';
|
||||
|
||||
interface Message {
|
||||
id: number;
|
||||
chat_id: number;
|
||||
message_id: string;
|
||||
text: string;
|
||||
direction: 'in' | 'out';
|
||||
}
|
||||
|
||||
interface InputBarProps {
|
||||
newMessage: string;
|
||||
onNewMessageChange: (val: string) => void;
|
||||
onSend: (e?: React.FormEvent) => void;
|
||||
onPresence: () => void;
|
||||
onSendMedia: (file: File, caption?: string) => Promise<void>;
|
||||
onSendAudio: (blob: Blob) => Promise<void>;
|
||||
replyingTo: Message | null;
|
||||
onCancelReply: () => void;
|
||||
isMobile: boolean;
|
||||
selectedChat: any;
|
||||
instanceId?: string | null;
|
||||
mentions?: string[];
|
||||
onMentionsChange?: (mentions: string[]) => void;
|
||||
}
|
||||
|
||||
const InputBar: React.FC<InputBarProps> = ({
|
||||
newMessage,
|
||||
onNewMessageChange,
|
||||
onSend,
|
||||
onPresence,
|
||||
onSendMedia,
|
||||
onSendAudio,
|
||||
replyingTo,
|
||||
onCancelReply,
|
||||
isMobile,
|
||||
selectedChat,
|
||||
instanceId,
|
||||
mentions = [],
|
||||
onMentionsChange,
|
||||
}) => {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
||||
const [showStickerPanel, setShowStickerPanel] = useState(false);
|
||||
const [showAttachMenu, setShowAttachMenu] = useState(false);
|
||||
const stickerPanelRef = useRef<HTMLDivElement>(null);
|
||||
const [previewFile, setPreviewFile] = useState<File | null>(null);
|
||||
const attachMenuRef = useRef<HTMLDivElement>(null);
|
||||
const [showRichComposer, setShowRichComposer] = useState(false);
|
||||
const [showTemplateLibrary, setShowTemplateLibrary] = useState(false);
|
||||
const [richInitialPayload, setRichInitialPayload] = useState<Record<string, unknown> | undefined>();
|
||||
const [richInitialType, setRichInitialType] = useState<TemplateType | undefined>();
|
||||
const [showScheduleModal, setShowScheduleModal] = useState(false);
|
||||
const [schedulingPayload, setSchedulingPayload] = useState<Record<string, unknown>>({});
|
||||
const [schedulingType, setSchedulingType] = useState<TemplateType>('TEXT');
|
||||
const emojiPickerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// ── Mention autocomplete state ──────────────────────────────────────
|
||||
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
|
||||
const [mentionIndex, setMentionIndex] = useState(0);
|
||||
const [participants, setParticipants] = useState<GroupParticipant[]>([]);
|
||||
const participantsCache = useRef<Record<string, GroupParticipant[]>>({});
|
||||
const mentionDropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const isGroupChat = selectedChat?.remote_jid?.endsWith('@g.us') || selectedChat?.jid?.endsWith('@g.us');
|
||||
const groupJid = selectedChat?.remote_jid || selectedChat?.jid;
|
||||
|
||||
// Fetch group participants when group chat is selected
|
||||
useEffect(() => {
|
||||
if (!isGroupChat || !instanceId || !groupJid) {
|
||||
setParticipants([]);
|
||||
return;
|
||||
}
|
||||
if (participantsCache.current[groupJid]) {
|
||||
setParticipants(participantsCache.current[groupJid]);
|
||||
return;
|
||||
}
|
||||
groupApi.getParticipants(instanceId, groupJid)
|
||||
.then((data) => {
|
||||
participantsCache.current[groupJid] = data.participants;
|
||||
setParticipants(data.participants);
|
||||
})
|
||||
.catch(() => setParticipants([]));
|
||||
}, [isGroupChat, instanceId, groupJid]);
|
||||
|
||||
// Filter participants by mention query
|
||||
const filteredParticipants = useMemo(() => {
|
||||
if (mentionQuery === null || !isGroupChat) return [];
|
||||
const q = mentionQuery.toLowerCase();
|
||||
// Only show participants that have a displayable name or phone
|
||||
return participants.filter((p) => {
|
||||
const label = p.name || p.phone || '';
|
||||
if (!label) return false;
|
||||
return q === '' || label.toLowerCase().includes(q);
|
||||
}).slice(0, 8);
|
||||
}, [mentionQuery, participants, isGroupChat]);
|
||||
|
||||
// Detect @ trigger in text
|
||||
const detectMentionQuery = useCallback((text: string, cursorPos: number) => {
|
||||
if (!isGroupChat) { setMentionQuery(null); return; }
|
||||
const before = text.slice(0, cursorPos);
|
||||
const atMatch = before.match(/@([^\s@]*)$/);
|
||||
if (atMatch) {
|
||||
setMentionQuery(atMatch[1]);
|
||||
setMentionIndex(0);
|
||||
} else {
|
||||
setMentionQuery(null);
|
||||
}
|
||||
}, [isGroupChat]);
|
||||
|
||||
const selectMention = useCallback((participant: GroupParticipant) => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
const cursorPos = textarea.selectionStart;
|
||||
const before = newMessage.slice(0, cursorPos);
|
||||
const after = newMessage.slice(cursorPos);
|
||||
const atIdx = before.lastIndexOf('@');
|
||||
if (atIdx === -1) return;
|
||||
|
||||
const displayName = participant.name || participant.phone;
|
||||
const replacement = `@${displayName} `;
|
||||
const newText = before.slice(0, atIdx) + replacement + after;
|
||||
|
||||
onNewMessageChange(newText);
|
||||
setMentionQuery(null);
|
||||
|
||||
// Add JID to mentions list
|
||||
if (onMentionsChange && !mentions.includes(participant.jid)) {
|
||||
onMentionsChange([...mentions, participant.jid]);
|
||||
}
|
||||
|
||||
// Restore cursor
|
||||
requestAnimationFrame(() => {
|
||||
if (textareaRef.current) {
|
||||
const newCursorPos = atIdx + replacement.length;
|
||||
textareaRef.current.selectionStart = newCursorPos;
|
||||
textareaRef.current.selectionEnd = newCursorPos;
|
||||
textareaRef.current.focus();
|
||||
}
|
||||
});
|
||||
}, [newMessage, onNewMessageChange, mentions, onMentionsChange]);
|
||||
|
||||
// Audio recording states
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const [recordingDuration, setRecordingDuration] = useState(0);
|
||||
const [micError, setMicError] = useState<string | null>(null);
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const audioChunksRef = useRef<Blob[]>([]);
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// navigator.mediaDevices só existe em contextos seguros (HTTPS ou localhost)
|
||||
const micAvailable = typeof window !== 'undefined' && !!window.isSecureContext && !!navigator.mediaDevices?.getUserMedia;
|
||||
|
||||
// Auto-expand textarea
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 150)}px`;
|
||||
}
|
||||
}, [newMessage]);
|
||||
|
||||
// Close emoji picker, sticker panel and attach menu on click outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (emojiPickerRef.current && !emojiPickerRef.current.contains(event.target as Node)) {
|
||||
setShowEmojiPicker(false);
|
||||
}
|
||||
if (stickerPanelRef.current && !stickerPanelRef.current.contains(event.target as Node)) {
|
||||
setShowStickerPanel(false);
|
||||
}
|
||||
if (attachMenuRef.current && !attachMenuRef.current.contains(event.target as Node)) {
|
||||
setShowAttachMenu(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleSendSticker = useCallback(async (blob: Blob) => {
|
||||
if (!instanceId || !selectedChat?.remote_jid) return;
|
||||
setShowStickerPanel(false);
|
||||
await mediaApi.sendSticker(instanceId, selectedChat.remote_jid, blob);
|
||||
}, [instanceId, selectedChat]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
// Mention dropdown navigation
|
||||
if (mentionQuery !== null && filteredParticipants.length > 0) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setMentionIndex((prev) => (prev + 1) % filteredParticipants.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setMentionIndex((prev) => (prev - 1 + filteredParticipants.length) % filteredParticipants.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
selectMention(filteredParticipants[mentionIndex]);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
setMentionQuery(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
onSend();
|
||||
}
|
||||
};
|
||||
|
||||
const onEmojiSelect = (emoji: any) => {
|
||||
onNewMessageChange(newMessage + (emoji.native ?? ''));
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setPreviewFile(file);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreviewConfirm = async (file: File, caption: string) => {
|
||||
await onSendMedia(file, caption);
|
||||
setPreviewFile(null);
|
||||
};
|
||||
|
||||
const handlePreviewCancel = () => setPreviewFile(null);
|
||||
|
||||
const startRecording = async () => {
|
||||
if (!micAvailable) {
|
||||
setMicError('Áudio requer HTTPS. Acesse via https:// para gravar.');
|
||||
setTimeout(() => setMicError(null), 4000);
|
||||
return;
|
||||
}
|
||||
setMicError(null);
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const mediaRecorder = new MediaRecorder(stream);
|
||||
mediaRecorderRef.current = mediaRecorder;
|
||||
audioChunksRef.current = [];
|
||||
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
audioChunksRef.current.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorder.onstop = async () => {
|
||||
const mimeType = mediaRecorder.mimeType || 'audio/webm'
|
||||
const audioBlob = new Blob(audioChunksRef.current, { type: mimeType });
|
||||
await onSendAudio(audioBlob);
|
||||
stream.getTracks().forEach(track => track.stop());
|
||||
};
|
||||
|
||||
mediaRecorder.start();
|
||||
setIsRecording(true);
|
||||
setIsPaused(false);
|
||||
setRecordingDuration(0);
|
||||
timerRef.current = setInterval(() => {
|
||||
setRecordingDuration(prev => prev + 1);
|
||||
}, 1000);
|
||||
} catch (err: any) {
|
||||
const denied = err?.name === 'NotAllowedError' || err?.name === 'PermissionDeniedError';
|
||||
const msg = denied
|
||||
? 'Permissão de microfone negada.'
|
||||
: 'Não foi possível acessar o microfone.';
|
||||
setMicError(msg);
|
||||
setTimeout(() => setMicError(null), 4000);
|
||||
}
|
||||
};
|
||||
|
||||
const togglePause = () => {
|
||||
const mr = mediaRecorderRef.current
|
||||
if (!mr) return
|
||||
if (isPaused) {
|
||||
mr.resume()
|
||||
setIsPaused(false)
|
||||
timerRef.current = setInterval(() => setRecordingDuration(prev => prev + 1), 1000)
|
||||
} else {
|
||||
mr.pause()
|
||||
setIsPaused(true)
|
||||
if (timerRef.current) clearInterval(timerRef.current)
|
||||
}
|
||||
}
|
||||
|
||||
const stopRecording = () => {
|
||||
if (mediaRecorderRef.current && isRecording) {
|
||||
// Se estava pausado, retoma antes de parar para capturar o último chunk
|
||||
if (isPaused && mediaRecorderRef.current.state === 'paused') {
|
||||
mediaRecorderRef.current.resume()
|
||||
}
|
||||
mediaRecorderRef.current.stop();
|
||||
setIsRecording(false);
|
||||
setIsPaused(false);
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTemplateSelect = (template: MessageTemplate) => {
|
||||
setRichInitialPayload(template.payload)
|
||||
setRichInitialType(template.type)
|
||||
setShowRichComposer(true)
|
||||
}
|
||||
|
||||
const handleScheduleFromComposer = (type: TemplateType, payload: Record<string, unknown>) => {
|
||||
setSchedulingType(type)
|
||||
setSchedulingPayload(payload)
|
||||
setShowRichComposer(false)
|
||||
setShowScheduleModal(true)
|
||||
}
|
||||
|
||||
const formatDuration = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative z-20">
|
||||
{/* Hidden File Input */}
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
|
||||
{/* Emoji Picker Popover */}
|
||||
<AnimatePresence>
|
||||
{showEmojiPicker && (
|
||||
<motion.div
|
||||
ref={emojiPickerRef}
|
||||
initial={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
className="absolute bottom-20 left-4 z-50 shadow-2xl rounded-2xl overflow-hidden border border-gray-100"
|
||||
>
|
||||
<div className="bg-white w-[280px] p-2 grid grid-cols-8 gap-1">
|
||||
{EMOJI_SET.map((e) => (
|
||||
<button
|
||||
key={e}
|
||||
type="button"
|
||||
onClick={() => onEmojiSelect({ native: e })}
|
||||
className="text-xl leading-none p-1 rounded-lg hover:bg-gray-100"
|
||||
>
|
||||
{e}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Sticker Panel Popover */}
|
||||
<AnimatePresence>
|
||||
{showStickerPanel && instanceId && (
|
||||
<motion.div
|
||||
ref={stickerPanelRef}
|
||||
initial={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
className="absolute bottom-20 left-16 z-50"
|
||||
>
|
||||
<StickerPanel
|
||||
instanceId={instanceId}
|
||||
jid={selectedChat?.remote_jid ?? ''}
|
||||
onSend={handleSendSticker}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Reply Preview */}
|
||||
<AnimatePresence>
|
||||
{replyingTo && (
|
||||
<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-[#00a884] 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-[#00a884] mb-1">
|
||||
{replyingTo.direction === 'out' ? 'Respondendo a você' : 'Respondendo ao contato'}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 font-medium truncate italic">
|
||||
{replyingTo.text || 'Anexo/Mídia'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="bg-gray-100 hover:bg-gray-200 text-gray-500 p-1 rounded-full transition-colors"
|
||||
onClick={onCancelReply}
|
||||
>
|
||||
<X className="w-3.5 h-3.5 stroke-[3px]" />
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Mic error banner */}
|
||||
<AnimatePresence>
|
||||
{micError && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 6 }}
|
||||
transition={{ duration: 0.18 }}
|
||||
className="mx-4 mb-1 px-3 py-2 bg-amber-50 border border-amber-200 rounded-lg flex items-center gap-2"
|
||||
>
|
||||
<Mic className="w-3.5 h-3.5 text-amber-500 flex-shrink-0" />
|
||||
<span className="text-xs text-amber-700">{micError}</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Main Input Bar */}
|
||||
<div className="min-h-[62px] px-4 py-2 bg-transparent flex items-center gap-2 shrink-0">
|
||||
{!isRecording ? (
|
||||
<>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Grupo flutuante: + expande Anexar / Templates / Mensagem Rica */}
|
||||
<div className="relative" ref={attachMenuRef}>
|
||||
<button
|
||||
onClick={() => setShowAttachMenu(v => !v)}
|
||||
className={`p-2 rounded-full transition-all active:scale-95 ${showAttachMenu ? 'bg-[#00a884] text-white' : 'hover:bg-black/10 text-[#54656f]'}`}
|
||||
title="Mais opções"
|
||||
>
|
||||
<motion.div
|
||||
animate={{ rotate: showAttachMenu ? 45 : 0 }}
|
||||
transition={{ duration: 0.18 }}
|
||||
>
|
||||
<Plus className="w-[26px] h-[26px]" />
|
||||
</motion.div>
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{showAttachMenu && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8, scale: 0.92 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 8, scale: 0.92 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute bottom-full left-0 mb-2 flex flex-col items-center gap-1 bg-white rounded-2xl shadow-2xl border border-gray-100 py-2 px-1.5"
|
||||
>
|
||||
{/* Anexar arquivo */}
|
||||
<button
|
||||
onClick={() => { fileInputRef.current?.click(); setShowAttachMenu(false); }}
|
||||
className="p-2.5 hover:bg-[#f0f2f5] rounded-xl transition-all text-[#54656f] active:scale-95 group relative"
|
||||
title="Anexar arquivo"
|
||||
>
|
||||
<Paperclip className="w-[22px] h-[22px] rotate-45" />
|
||||
<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">
|
||||
Anexar
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Biblioteca de templates */}
|
||||
<button
|
||||
onClick={() => { setShowTemplateLibrary(true); setShowAttachMenu(false); }}
|
||||
className="p-2.5 hover:bg-[#f0f2f5] rounded-xl transition-all text-[#54656f] active:scale-95 group relative"
|
||||
title="Templates salvos"
|
||||
>
|
||||
<BookOpen 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">
|
||||
Templates
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Mensagem rica */}
|
||||
<button
|
||||
onClick={() => { setRichInitialPayload(undefined); setRichInitialType(undefined); setShowRichComposer(true); setShowAttachMenu(false); }}
|
||||
className="p-2.5 hover:bg-[#f0f2f5] rounded-xl transition-all text-[#54656f] active:scale-95 group relative"
|
||||
title="Mensagem rica"
|
||||
>
|
||||
<LayoutTemplate 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">
|
||||
Mensagem Rica
|
||||
</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">
|
||||
{/* Mention Autocomplete Dropdown */}
|
||||
<AnimatePresence>
|
||||
{mentionQuery !== null && filteredParticipants.length > 0 && (
|
||||
<motion.div
|
||||
ref={mentionDropdownRef}
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 6 }}
|
||||
transition={{ duration: 0.12 }}
|
||||
className="absolute bottom-full left-0 right-0 mb-1 bg-white rounded-lg shadow-xl border border-gray-200 max-h-[240px] overflow-y-auto z-50"
|
||||
>
|
||||
{filteredParticipants.map((p, i) => (
|
||||
<button
|
||||
key={p.jid}
|
||||
type="button"
|
||||
className={`w-full px-3 py-2 flex items-center gap-2 text-left text-sm hover:bg-[#f0f2f5] transition-colors ${
|
||||
i === mentionIndex ? 'bg-[#e9edef]' : ''
|
||||
}`}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
selectMention(p);
|
||||
}}
|
||||
onMouseEnter={() => setMentionIndex(i)}
|
||||
>
|
||||
<div className="w-7 h-7 rounded-full bg-[#dfe5e7] flex items-center justify-center text-xs font-bold text-[#54656f] shrink-0">
|
||||
{(p.name || p.phone || '?')[0].toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-[#111b21] truncate">{p.name || p.phone}</div>
|
||||
{p.name && <div className="text-[11px] text-[#667781] truncate">+{p.phone}</div>}
|
||||
</div>
|
||||
{p.admin && (
|
||||
<span className="ml-auto text-[10px] bg-[#e7f8f2] text-[#00a884] px-1.5 py-0.5 rounded font-medium shrink-0">
|
||||
{p.admin === 'superadmin' ? 'Admin' : 'Admin'}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<label htmlFor="message-textarea" className="sr-only">Digite sua mensagem</label>
|
||||
<textarea
|
||||
id="message-textarea"
|
||||
name="message-textarea"
|
||||
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]"
|
||||
value={newMessage}
|
||||
onChange={(e) => {
|
||||
onNewMessageChange(e.target.value);
|
||||
onPresence();
|
||||
detectMentionQuery(e.target.value, e.target.selectionStart);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="transition-all flex items-center">
|
||||
{newMessage.trim() ? (
|
||||
<motion.button
|
||||
initial={{ scale: 0.5, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
onClick={() => onSend()}
|
||||
className="p-2 text-[#54656f] hover:bg-black/10 rounded-full transition-all active:scale-90"
|
||||
>
|
||||
<Send className="w-[26px] h-[26px]" />
|
||||
</motion.button>
|
||||
) : (
|
||||
<button
|
||||
onClick={startRecording}
|
||||
title={!micAvailable ? 'Requer HTTPS para gravar áudio' : 'Gravar áudio'}
|
||||
className={`p-2 rounded-full transition-all active:scale-90 ${
|
||||
!micAvailable
|
||||
? 'text-[#aab7bf] cursor-not-allowed opacity-50'
|
||||
: 'hover:bg-black/10 text-[#54656f]'
|
||||
}`}
|
||||
>
|
||||
<Mic className="w-[26px] h-[26px]" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className={`flex-1 flex items-center justify-between rounded-2xl px-4 py-2 border shadow-inner ${isPaused ? 'bg-amber-50/70 border-amber-200' : 'bg-red-50/50 border-red-100'}`}>
|
||||
{/* Indicador + tempo */}
|
||||
<div className="flex items-center gap-3">
|
||||
{isPaused ? (
|
||||
<div className="w-3 h-3 bg-amber-400 rounded-full" />
|
||||
) : (
|
||||
<div className="w-3 h-3 bg-red-500 rounded-full animate-ping" />
|
||||
)}
|
||||
<span className={`text-sm font-bold tracking-wider ${isPaused ? 'text-amber-600' : 'text-red-600'}`}>
|
||||
{isPaused ? 'PAUSADO' : 'GRAVANDO'} {formatDuration(recordingDuration)}
|
||||
</span>
|
||||
</div>
|
||||
{/* Controles */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* Cancelar */}
|
||||
<button
|
||||
onClick={() => {
|
||||
const mr = mediaRecorderRef.current
|
||||
if (mr) {
|
||||
mr.onstop = null;
|
||||
mr.stream?.getTracks().forEach(t => t.stop());
|
||||
if (mr.state !== 'inactive') mr.stop();
|
||||
setIsRecording(false);
|
||||
setIsPaused(false);
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
}
|
||||
}}
|
||||
className="p-2 hover:bg-red-100 rounded-full text-red-400 transition-colors"
|
||||
title="Cancelar"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
{/* Pause / Resume */}
|
||||
<button
|
||||
onClick={togglePause}
|
||||
className="p-2 hover:bg-black/10 rounded-full text-[#54656f] transition-colors"
|
||||
title={isPaused ? 'Continuar gravação' : 'Pausar gravação'}
|
||||
>
|
||||
{isPaused
|
||||
? <Play className="w-5 h-5 fill-current text-amber-500" />
|
||||
: <Pause className="w-5 h-5 fill-current" />
|
||||
}
|
||||
</button>
|
||||
{/* Parar e Enviar */}
|
||||
<button
|
||||
onClick={stopRecording}
|
||||
className="p-2 bg-red-500 hover:bg-red-600 text-white rounded-full transition-all shadow-md active:scale-90"
|
||||
title="Parar e Enviar"
|
||||
>
|
||||
<StopCircle className="w-5 h-5 fill-current" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Modal de preview de arquivo ─────────────────────── */}
|
||||
<FilePreviewModal
|
||||
file={previewFile}
|
||||
onConfirm={handlePreviewConfirm}
|
||||
onCancel={handlePreviewCancel}
|
||||
/>
|
||||
|
||||
{/* ── Modais de mensagem rica ────────────────────────────── */}
|
||||
<RichMessageComposer
|
||||
isOpen={showRichComposer}
|
||||
onClose={() => setShowRichComposer(false)}
|
||||
instanceId={instanceId ?? null}
|
||||
jid={selectedChat?.remote_jid ?? selectedChat?.jid ?? ''}
|
||||
onSent={() => setShowRichComposer(false)}
|
||||
onSchedule={handleScheduleFromComposer}
|
||||
initialPayload={richInitialPayload}
|
||||
initialType={richInitialType}
|
||||
/>
|
||||
|
||||
<TemplateLibrary
|
||||
isOpen={showTemplateLibrary}
|
||||
onClose={() => setShowTemplateLibrary(false)}
|
||||
onSelect={handleTemplateSelect}
|
||||
/>
|
||||
|
||||
<ScheduleMessageModal
|
||||
isOpen={showScheduleModal}
|
||||
onClose={() => setShowScheduleModal(false)}
|
||||
payload={schedulingPayload}
|
||||
messageType={schedulingType}
|
||||
onSaved={() => setShowScheduleModal(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default InputBar;
|
||||
@@ -0,0 +1,143 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Globe } from 'lucide-react';
|
||||
// Satélite: a ext API não expõe /link-preview — stub que rejeita (o componente
|
||||
// cai para "sem preview" no catch, sem quebrar a renderização).
|
||||
const api = { get: (_url: string): Promise<any> => Promise.reject(new Error('no link-preview')) };
|
||||
|
||||
interface PreviewData {
|
||||
title: string;
|
||||
description: string;
|
||||
image: string;
|
||||
url: string;
|
||||
domain: string;
|
||||
}
|
||||
|
||||
// Simple in-memory cache so the same URL is only fetched once per page load
|
||||
const cache = new Map<string, PreviewData | null>();
|
||||
|
||||
interface LinkPreviewProps {
|
||||
url: string;
|
||||
isOut: boolean;
|
||||
}
|
||||
|
||||
const LinkPreview: React.FC<LinkPreviewProps> = ({ url, isOut }) => {
|
||||
const [data, setData] = useState<PreviewData | null>(cache.has(url) ? cache.get(url)! : undefined as any);
|
||||
const [loading, setLoading] = useState(!cache.has(url));
|
||||
|
||||
useEffect(() => {
|
||||
if (cache.has(url)) {
|
||||
setData(cache.get(url) ?? null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
|
||||
api.get(`/link-preview?url=${encodeURIComponent(url)}`)
|
||||
.then((d: PreviewData) => {
|
||||
if (cancelled) return;
|
||||
const value = d?.title ? d : null;
|
||||
cache.set(url, value);
|
||||
setData(value);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
cache.set(url, null);
|
||||
setData(null);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [url]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={`mt-2 rounded-xl overflow-hidden border animate-pulse ${isOut ? 'border-green-200' : 'border-gray-100'}`}>
|
||||
<div className={`h-24 ${isOut ? 'bg-green-100' : 'bg-gray-100'}`} />
|
||||
<div className="p-2.5 space-y-1.5">
|
||||
<div className={`h-2 rounded w-1/3 ${isOut ? 'bg-green-200' : 'bg-gray-200'}`} />
|
||||
<div className={`h-3 rounded w-4/5 ${isOut ? 'bg-green-200' : 'bg-gray-200'}`} />
|
||||
<div className={`h-2 rounded w-3/5 ${isOut ? 'bg-green-100' : 'bg-gray-100'}`} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={`mt-2 block rounded-xl overflow-hidden border transition-all hover:shadow-md active:scale-[0.98] no-underline ${isOut
|
||||
? 'border-green-200 bg-white/70 hover:bg-white/90'
|
||||
: 'border-gray-200 bg-gray-50 hover:bg-white'
|
||||
}`}
|
||||
>
|
||||
{data.image && (
|
||||
<img
|
||||
src={data.image}
|
||||
alt={data.title}
|
||||
className="w-full max-h-[160px] object-cover"
|
||||
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
)}
|
||||
<div className="px-3 py-2.5">
|
||||
<div className="flex items-center gap-1 text-[10px] text-gray-400 font-bold uppercase tracking-wider mb-1">
|
||||
<Globe className="w-3 h-3 shrink-0" />
|
||||
<span className="truncate">{data.domain}</span>
|
||||
</div>
|
||||
{data.title && (
|
||||
<p className="text-[13px] font-bold text-gray-800 leading-tight line-clamp-2">
|
||||
{data.title}
|
||||
</p>
|
||||
)}
|
||||
{data.description && (
|
||||
<p className="text-[11px] text-gray-500 mt-0.5 leading-snug line-clamp-2">
|
||||
{data.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
// URL extraction helper (exported for use in MessageBubble)
|
||||
const URL_RE = /https?:\/\/[^\s<>"{}|\\^`[\]]{4,}/;
|
||||
export const extractFirstUrl = (text: string | null | undefined): string | null => {
|
||||
if (!text) return null;
|
||||
const m = text.match(URL_RE);
|
||||
return m ? m[0] : null;
|
||||
};
|
||||
|
||||
// Renders plain text with URLs converted to clickable <a> tags
|
||||
export const renderTextWithLinks = (text: string): React.ReactNode[] => {
|
||||
const parts = text.split(/(https?:\/\/[^\s<>"{}|\\^`[\]]{4,})/g);
|
||||
return parts.map((part, i) => {
|
||||
if (/^https?:\/\//.test(part)) {
|
||||
return (
|
||||
<a
|
||||
key={i}
|
||||
href={part}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-blue-600 underline break-all hover:text-blue-700"
|
||||
>
|
||||
{part}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return <span key={i}>{part}</span>;
|
||||
});
|
||||
};
|
||||
|
||||
export default LinkPreview;
|
||||
@@ -0,0 +1,260 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import {
|
||||
X,
|
||||
Download,
|
||||
Reply,
|
||||
Star,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Video,
|
||||
} from 'lucide-react';
|
||||
import { Message } from '../types/inboxTypes';
|
||||
import { getMediaUrl } from '../utils/inboxUtils';
|
||||
import { format } from 'date-fns';
|
||||
import { parseSafeDate } from '../utils/inboxUtils';
|
||||
|
||||
interface MediaViewerProps {
|
||||
msgs: Message[]
|
||||
currentMsgId: string
|
||||
onClose: () => void
|
||||
onReply?: (msg: Message) => void
|
||||
onStar?: (msg: Message) => void
|
||||
}
|
||||
|
||||
const MediaViewer: React.FC<MediaViewerProps> = ({ msgs, currentMsgId, onClose, onReply, onStar }) => {
|
||||
const [currentIdx, setCurrentIdx] = useState(() => {
|
||||
const idx = msgs.findIndex(m => m.message_id === currentMsgId)
|
||||
return idx >= 0 ? idx : 0
|
||||
})
|
||||
const [zoom, setZoom] = useState(1)
|
||||
const filmstripRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const currentMsg = msgs[currentIdx]
|
||||
const currentUrl = getMediaUrl(currentMsg?.media_url)
|
||||
|
||||
const goNext = useCallback(() => {
|
||||
setCurrentIdx(i => Math.min(i + 1, msgs.length - 1))
|
||||
setZoom(1)
|
||||
}, [msgs.length])
|
||||
|
||||
const goPrev = useCallback(() => {
|
||||
setCurrentIdx(i => Math.max(i - 1, 0))
|
||||
setZoom(1)
|
||||
}, [])
|
||||
|
||||
// Keyboard: ← → Esc
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
if (e.key === 'ArrowRight') goNext()
|
||||
if (e.key === 'ArrowLeft') goPrev()
|
||||
}
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [onClose, goNext, goPrev])
|
||||
|
||||
// Scroll filmstrip to keep current thumbnail visible
|
||||
useEffect(() => {
|
||||
const el = filmstripRef.current
|
||||
if (!el) return
|
||||
const thumb = el.children[currentIdx] as HTMLElement
|
||||
if (thumb) thumb.scrollIntoView({ inline: 'center', block: 'nearest', behavior: 'smooth' })
|
||||
}, [currentIdx])
|
||||
|
||||
if (!currentMsg) return null
|
||||
|
||||
const isVideo = currentMsg.type === 'video'
|
||||
const isImage = currentMsg.type === 'image'
|
||||
const caption = currentMsg.text?.trim() || null
|
||||
const timeStr = currentMsg.timestamp ? format(parseSafeDate(currentMsg.timestamp), 'dd/MM/yyyy HH:mm') : ''
|
||||
const senderLabel = currentMsg.direction === 'out' ? 'Você' : 'Contato'
|
||||
|
||||
const viewer = (
|
||||
<div className="fixed inset-0 z-[9999] flex flex-col bg-[#111b21]">
|
||||
{/* ── Top bar ─────────────────────────────────────────── */}
|
||||
<div className="flex items-center justify-between px-2 py-1.5 bg-[#202c33] shrink-0">
|
||||
{/* Left: back + sender info */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-[#aebac1] hover:text-white rounded-full hover:bg-white/10 transition-colors"
|
||||
title="Fechar"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<div className="leading-tight">
|
||||
<div className="text-white text-[13.5px] font-medium">{senderLabel}</div>
|
||||
<div className="text-[#aebac1] text-[11px]">{timeStr}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: actions */}
|
||||
<div className="flex items-center">
|
||||
{isImage && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setZoom(z => Math.min(z + 0.3, 4))}
|
||||
className="p-2 text-[#aebac1] hover:text-white rounded-full hover:bg-white/10 transition-colors"
|
||||
title="Aumentar zoom"
|
||||
>
|
||||
<ZoomIn className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setZoom(z => Math.max(z - 0.3, 0.4))}
|
||||
className="p-2 text-[#aebac1] hover:text-white rounded-full hover:bg-white/10 transition-colors"
|
||||
title="Diminuir zoom"
|
||||
>
|
||||
<ZoomOut className="w-5 h-5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { onReply?.(currentMsg); onClose(); }}
|
||||
className="p-2 text-[#aebac1] hover:text-white rounded-full hover:bg-white/10 transition-colors"
|
||||
title="Responder"
|
||||
>
|
||||
<Reply className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onStar?.(currentMsg)}
|
||||
className="p-2 text-[#aebac1] hover:text-white rounded-full hover:bg-white/10 transition-colors"
|
||||
title="Favoritar"
|
||||
>
|
||||
<Star className="w-5 h-5" />
|
||||
</button>
|
||||
{currentUrl && (
|
||||
<a
|
||||
href={currentUrl}
|
||||
download
|
||||
className="p-2 text-[#aebac1] hover:text-white rounded-full hover:bg-white/10 transition-colors"
|
||||
title="Baixar"
|
||||
>
|
||||
<Download className="w-5 h-5" />
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-[#aebac1] hover:text-white rounded-full hover:bg-white/10 transition-colors"
|
||||
title="Fechar"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Main content ─────────────────────────────────────── */}
|
||||
<div
|
||||
className="flex-1 flex items-center justify-center relative overflow-hidden"
|
||||
onClick={onClose}
|
||||
>
|
||||
{/* Left arrow */}
|
||||
{currentIdx > 0 && (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); goPrev() }}
|
||||
className="absolute left-4 z-10 w-11 h-11 bg-black/50 hover:bg-black/80 text-white rounded-full flex items-center justify-center transition-colors shadow-lg"
|
||||
>
|
||||
<ChevronLeft className="w-6 h-6" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Media */}
|
||||
<div className="max-w-full max-h-full flex items-center justify-center" onClick={e => e.stopPropagation()}>
|
||||
{!currentUrl ? (
|
||||
<div className="text-[#aebac1] text-sm">Mídia não disponível</div>
|
||||
) : isVideo ? (
|
||||
<video
|
||||
src={currentUrl}
|
||||
controls
|
||||
autoPlay
|
||||
className="max-w-full rounded"
|
||||
style={{ maxHeight: 'calc(100vh - 160px)' }}
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={currentUrl}
|
||||
alt={caption || 'Imagem'}
|
||||
draggable={false}
|
||||
className="object-contain select-none rounded"
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: 'calc(100vh - 160px)',
|
||||
transform: `scale(${zoom})`,
|
||||
transition: 'transform 0.18s ease',
|
||||
cursor: zoom > 1 ? 'zoom-out' : 'default',
|
||||
}}
|
||||
onClick={e => { e.stopPropagation(); if (zoom > 1) setZoom(1) }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right arrow */}
|
||||
{currentIdx < msgs.length - 1 && (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); goNext() }}
|
||||
className="absolute right-4 z-10 w-11 h-11 bg-black/50 hover:bg-black/80 text-white rounded-full flex items-center justify-center transition-colors shadow-lg"
|
||||
>
|
||||
<ChevronRight className="w-6 h-6" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Caption */}
|
||||
{caption && (
|
||||
<div className="absolute bottom-2 inset-x-0 flex justify-center pointer-events-none">
|
||||
<span className="bg-black/60 text-white text-[13px] px-4 py-1.5 rounded-lg max-w-[70%] text-center leading-snug">
|
||||
{caption}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Bottom filmstrip ─────────────────────────────────── */}
|
||||
{msgs.length > 1 && (
|
||||
<div className="shrink-0 bg-[#202c33] py-2 px-3">
|
||||
<div
|
||||
ref={filmstripRef}
|
||||
className="flex gap-1.5 overflow-x-auto items-center"
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
>
|
||||
{msgs.map((m, i) => {
|
||||
const url = getMediaUrl(m.media_url)
|
||||
const isVid = m.type === 'video'
|
||||
const isActive = i === currentIdx
|
||||
return (
|
||||
<button
|
||||
key={m.message_id}
|
||||
onClick={() => { setCurrentIdx(i); setZoom(1) }}
|
||||
className={`relative shrink-0 w-[58px] h-[58px] rounded overflow-hidden border-2 transition-all ${
|
||||
isActive
|
||||
? 'border-white opacity-100'
|
||||
: 'border-transparent opacity-50 hover:opacity-80'
|
||||
}`}
|
||||
>
|
||||
{url ? (
|
||||
isVid ? (
|
||||
<div className="w-full h-full bg-black flex items-center justify-center">
|
||||
<Video className="w-5 h-5 text-white fill-white" />
|
||||
</div>
|
||||
) : (
|
||||
<img src={url} alt="" className="w-full h-full object-cover" />
|
||||
)
|
||||
) : (
|
||||
<div className="w-full h-full bg-[#2a3942]" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
return typeof document !== 'undefined' ? createPortal(viewer, document.body) : null
|
||||
}
|
||||
|
||||
export default MediaViewer
|
||||
@@ -0,0 +1,277 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { ChevronDown, ArrowDown } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import MessageBubble from './MessageBubble';
|
||||
import MediaViewer from './MediaViewer';
|
||||
import { isSameDay, format, isToday, isYesterday } from 'date-fns';
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
import { normalizeJid, parseSafeDate } from '../utils/inboxUtils';
|
||||
import { Message } from '../types/inboxTypes';
|
||||
import { mediaApi } from '../services/chatApiService';
|
||||
import { useChatStore } from '../store/chatStore';
|
||||
|
||||
// Message interface removed and moved to types.ts
|
||||
|
||||
interface MessageAreaProps {
|
||||
messages: Message[];
|
||||
selectedChat: any;
|
||||
scrollRef: React.RefObject<HTMLDivElement>;
|
||||
onScroll: () => void;
|
||||
msgLoading: boolean;
|
||||
historyLoadingMore: boolean;
|
||||
hasMoreHistory: boolean;
|
||||
reactionPickerFor: string | null;
|
||||
onReply: (msg: Message) => void;
|
||||
onToggleReactionPicker: (msgId: string) => void;
|
||||
onReact: (msg: Message, emoji: string) => void;
|
||||
onForward?: (msg: Message) => void;
|
||||
onDelete?: (msg: Message) => void;
|
||||
onStar?: (msg: Message) => void;
|
||||
onEdit?: (msg: Message) => void;
|
||||
quickReactions: string[];
|
||||
getGroupSenderLabel: (msg: Message) => string;
|
||||
canDelete?: boolean;
|
||||
}
|
||||
|
||||
const MessageArea: React.FC<MessageAreaProps> = ({
|
||||
messages,
|
||||
selectedChat,
|
||||
scrollRef,
|
||||
onScroll,
|
||||
msgLoading,
|
||||
historyLoadingMore,
|
||||
hasMoreHistory,
|
||||
reactionPickerFor,
|
||||
onReply,
|
||||
onToggleReactionPicker,
|
||||
onReact,
|
||||
onForward,
|
||||
onDelete,
|
||||
onStar,
|
||||
onEdit,
|
||||
quickReactions,
|
||||
getGroupSenderLabel,
|
||||
canDelete = true,
|
||||
}) => {
|
||||
const [showScrollBottom, setShowScrollBottom] = useState(false);
|
||||
const lastScrollTop = useRef(0);
|
||||
const needsInitialScroll = useRef(false);
|
||||
|
||||
// ── Media viewer state ────────────────────────────────────────────────────
|
||||
const [viewerMsgId, setViewerMsgId] = useState<string | null>(null)
|
||||
|
||||
// ── Re-download de mídia não baixada ─────────────────────────────────────
|
||||
// ~95% das mídias vêm com mediaUrl NULL (lazy). O motor re-baixa do WhatsApp
|
||||
// e devolve { mediaUrl }; atualizamos o store direto pelo retorno (o WS
|
||||
// message:media_ready não trafega pela ponte ext do satélite).
|
||||
const updateMediaReady = useChatStore((s) => s.updateMediaReady)
|
||||
const handleRedownloadMedia = useCallback(async (msg: Message) => {
|
||||
// selectedChat no formato legado usa instance_name (não instanceId)
|
||||
const instanceId = selectedChat?.instance_name ?? selectedChat?.instanceId
|
||||
if (!instanceId || !msg.id) return
|
||||
const { mediaUrl } = await mediaApi.redownload(instanceId, String(msg.id))
|
||||
if (mediaUrl) updateMediaReady(String(msg.id), mediaUrl)
|
||||
}, [selectedChat?.instance_name, selectedChat?.instanceId, updateMediaReady])
|
||||
const mediaMessages = messages.filter(m => m.type === 'image' || m.type === 'video')
|
||||
|
||||
const handleInternalScroll = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 100;
|
||||
setShowScrollBottom(!isAtBottom);
|
||||
|
||||
// Trigger parent onScroll (for infinite loading etc)
|
||||
onScroll();
|
||||
lastScrollTop.current = el.scrollTop;
|
||||
}, [onScroll, scrollRef]);
|
||||
|
||||
const scrollToBottom = (smooth = true) => {
|
||||
const el = scrollRef.current;
|
||||
if (el) {
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: smooth ? 'smooth' : 'instant' });
|
||||
}
|
||||
};
|
||||
|
||||
const formatDateSeparator = (date: Date) => {
|
||||
try {
|
||||
if (isToday(date)) return 'Hoje';
|
||||
if (isYesterday(date)) return 'Ontem';
|
||||
return format(date, "d 'de' MMMM 'de' yyyy", { locale: ptBR });
|
||||
} catch (e) {
|
||||
return 'Histórico';
|
||||
}
|
||||
};
|
||||
|
||||
const isGroupChat = Boolean(selectedChat?.remote_jid?.endsWith('@g.us'));
|
||||
|
||||
const chatId = selectedChat?.remote_jid ?? null;
|
||||
|
||||
// Marca que precisa scroll ao fundo quando trocar de chat
|
||||
useEffect(() => {
|
||||
needsInitialScroll.current = true;
|
||||
}, [chatId]);
|
||||
|
||||
// Auto-scroll: roda sempre que messages muda
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el || messages.length === 0) return;
|
||||
|
||||
if (needsInitialScroll.current) {
|
||||
// Carga inicial após selectChat → scroll instantâneo ao fundo
|
||||
needsInitialScroll.current = false;
|
||||
// Triplo rAF: aguarda React render + layout + paint
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// Nova mensagem em tempo real → só rola se já estiver perto do fundo
|
||||
const isNearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 250;
|
||||
if (isNearBottom) {
|
||||
requestAnimationFrame(() => {
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [messages, scrollRef]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex-1 relative flex flex-col min-h-0 bg-transparent overflow-hidden">
|
||||
|
||||
<div
|
||||
className="flex-1 overflow-y-auto px-4 md:px-14 py-6 flex flex-col custom-scrollbar relative z-10"
|
||||
ref={scrollRef}
|
||||
onScroll={handleInternalScroll}
|
||||
>
|
||||
{/* Loader for history */}
|
||||
{historyLoadingMore && (
|
||||
<div className="flex justify-center py-4">
|
||||
<div className="w-5 h-5 border-2 border-blue-500/20 border-t-blue-500 rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(() => {
|
||||
const seen = new Set<string>();
|
||||
return messages
|
||||
.filter(m => {
|
||||
// Reactions não são mensagens visuais — são metadados
|
||||
if (m.type === 'reaction') return false;
|
||||
const key = m.message_id || m.id?.toString();
|
||||
if (!key || seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
})
|
||||
.map((msg, idx) => {
|
||||
const msgDate = parseSafeDate(msg.timestamp);
|
||||
const prevMsg = messages[idx - 1];
|
||||
const prevDate = prevMsg ? parseSafeDate(prevMsg.timestamp) : null;
|
||||
const showDateSeparator = !prevDate || !isSameDay(prevDate, msgDate);
|
||||
|
||||
// Authentic WA Tail Logic: Show tail on the first message of a sequence from the same sender
|
||||
const isSameSenderAsPrev = prevMsg && (prevMsg.direction === msg.direction);
|
||||
const showTail = showDateSeparator || !isSameSenderAsPrev;
|
||||
|
||||
const senderLabel = isGroupChat ? getGroupSenderLabel(msg) : '';
|
||||
|
||||
// Resolve Quoted Sender
|
||||
let quotedSenderLabel = 'Mensagem';
|
||||
if (msg.quoted_participant) {
|
||||
const qp = normalizeJid(msg.quoted_participant);
|
||||
const chatJid = normalizeJid(selectedChat?.remote_jid || '');
|
||||
if (qp === chatJid) quotedSenderLabel = selectedChat?.subject || 'Contato';
|
||||
else quotedSenderLabel = 'Você';
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment key={msg.message_id || msg.id}>
|
||||
{showDateSeparator && (
|
||||
<div className="flex justify-center my-6">
|
||||
<span className="px-3 py-1 bg-white text-[#54656f] text-[12.5px] font-normal uppercase tracking-tight rounded-lg shadow-sm border border-[#d1d7db]">
|
||||
{formatDateSeparator(msgDate)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MessageBubble
|
||||
msg={msg}
|
||||
isGroupChat={isGroupChat}
|
||||
senderLabel={senderLabel}
|
||||
showTail={showTail}
|
||||
quotedSenderLabel={quotedSenderLabel}
|
||||
quotedPreviewText={msg.quoted_message_text || 'Mídia'}
|
||||
reactionPickerFor={reactionPickerFor}
|
||||
onReply={onReply}
|
||||
onToggleReactionPicker={onToggleReactionPicker}
|
||||
onReact={onReact}
|
||||
onForward={onForward}
|
||||
onDelete={onDelete}
|
||||
canDelete={canDelete}
|
||||
onStar={onStar}
|
||||
onEdit={onEdit}
|
||||
quickReactions={quickReactions}
|
||||
selectedChat={selectedChat}
|
||||
onOpenMedia={(m) => setViewerMsgId(m.message_id)}
|
||||
onRedownloadMedia={handleRedownloadMedia}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
|
||||
{/* Bottom Anchor */}
|
||||
<div className="h-2 w-full shrink-0" />
|
||||
</div>
|
||||
|
||||
{/* Scroll to Bottom Button */}
|
||||
<AnimatePresence>
|
||||
{showScrollBottom && (
|
||||
<motion.button
|
||||
initial={{ opacity: 0, scale: 0.8, y: 10 }}
|
||||
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"
|
||||
>
|
||||
<ArrowDown className="w-5 h-5" />
|
||||
{selectedChat?.unread_count > 0 && (
|
||||
<span className="absolute -top-1 -right-1 bg-[#00a884] text-white text-[10px] min-w-[20px] h-5 px-1 flex items-center justify-center rounded-full font-bold border-2 border-white shadow-sm">
|
||||
{selectedChat.unread_count}
|
||||
</span>
|
||||
)}
|
||||
</motion.button>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Initial Loading Overlay */}
|
||||
{msgLoading && messages.length === 0 && (
|
||||
<div className="absolute inset-0 bg-white/50 backdrop-blur-sm z-50 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-10 h-10 border-[3px] border-[#00a884]/20 border-t-[#00a884] rounded-full animate-spin" />
|
||||
<span className="text-[12px] font-medium text-[#667781]">Carregando mensagens...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Media viewer portal ───────────────────────────────────────────── */}
|
||||
{viewerMsgId && mediaMessages.length > 0 && (
|
||||
<MediaViewer
|
||||
msgs={mediaMessages}
|
||||
currentMsgId={viewerMsgId}
|
||||
onClose={() => setViewerMsgId(null)}
|
||||
onReply={onReply}
|
||||
onStar={onStar}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessageArea;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,281 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { X, Phone, Send, ChevronDown, Check } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
interface Instance {
|
||||
id: string;
|
||||
nome: string;
|
||||
avatar: string | null;
|
||||
phone: string | null;
|
||||
}
|
||||
|
||||
interface NewConversationModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSent?: (phone: string, instanceId: string) => void;
|
||||
activeInstance: Instance | null;
|
||||
connectedInstances: Instance[];
|
||||
sendMessage: (instanceId: string, phone: string, text: string) => Promise<{ ok: boolean; messageId?: string }>;
|
||||
}
|
||||
|
||||
function normalizePhoneForSend(input: string): string {
|
||||
const digits = input.replace(/\D/g, '');
|
||||
// Already has Brazil country code: 55 + DDD (2) + number (8-9) = 12-13 digits
|
||||
if (digits.startsWith('55') && (digits.length === 12 || digits.length === 13)) return digits;
|
||||
// Local Brazilian number: DDD (2) + number (8-9) = 10-11 digits
|
||||
if (digits.length === 10 || digits.length === 11) return '55' + digits;
|
||||
// Return as-is (international or unusual)
|
||||
return digits;
|
||||
}
|
||||
|
||||
export default function NewConversationModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSent,
|
||||
activeInstance,
|
||||
connectedInstances,
|
||||
sendMessage,
|
||||
}: NewConversationModalProps) {
|
||||
const [phone, setPhone] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [selectedInstanceId, setSelectedInstanceId] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const [sent, setSent] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const phoneInputRef = useRef<HTMLInputElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Reset state when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setPhone('');
|
||||
setMessage('');
|
||||
setError('');
|
||||
setSent(false);
|
||||
setSending(false);
|
||||
setSelectedInstanceId(activeInstance?.id || connectedInstances[0]?.id || '');
|
||||
setTimeout(() => phoneInputRef.current?.focus(), 120);
|
||||
}
|
||||
}, [isOpen, activeInstance?.id, connectedInstances]);
|
||||
|
||||
const handlePhoneChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPhone(e.target.value.replace(/[^\d\s\-()+]/g, ''));
|
||||
setError('');
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const normalized = normalizePhoneForSend(phone);
|
||||
const digits = normalized.replace(/\D/g, '');
|
||||
|
||||
if (digits.length < 10) {
|
||||
setError('Número inválido. Informe DDD + número (ex: 67 99922-2377)');
|
||||
phoneInputRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
if (!message.trim()) {
|
||||
setError('Escreva uma mensagem para iniciar a conversa');
|
||||
textareaRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
if (!selectedInstanceId) {
|
||||
setError('Nenhuma instância conectada');
|
||||
return;
|
||||
}
|
||||
|
||||
setSending(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const result = await sendMessage(selectedInstanceId, normalized, message.trim());
|
||||
if (!result.ok) throw new Error('Envio falhou');
|
||||
setSent(true);
|
||||
// Brief success state, then close and notify parent
|
||||
setTimeout(() => {
|
||||
onSent?.(normalized, selectedInstanceId);
|
||||
onClose();
|
||||
}, 1300);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || 'Erro ao enviar. Verifique o número e tente novamente.');
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTextareaKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePhoneKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') textareaRef.current?.focus();
|
||||
};
|
||||
|
||||
const canSend = !!phone.trim() && !!message.trim() && !!selectedInstanceId && !sending && !sent;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4">
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="absolute inset-0 bg-black/40 backdrop-blur-sm"
|
||||
onClick={!sending ? onClose : undefined}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<motion.div
|
||||
initial={{ scale: 0.92, opacity: 0, y: 16 }}
|
||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||
exit={{ scale: 0.92, opacity: 0, y: 16 }}
|
||||
transition={{ type: 'spring', damping: 26, stiffness: 320 }}
|
||||
className="relative w-full max-w-md bg-white rounded-2xl shadow-2xl flex flex-col overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 bg-[#008069] text-white">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 bg-white/20 rounded-full flex items-center justify-center">
|
||||
<Phone className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold leading-tight">Nova Conversa</h2>
|
||||
<p className="text-[11px] text-white/70 leading-tight">Enviar primeira mensagem</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={sending}
|
||||
className="p-1.5 hover:bg-white/20 rounded-full transition-colors disabled:opacity-50"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-5">
|
||||
{/* Instance selector — only if multiple instances connected */}
|
||||
{connectedInstances.length > 1 && (
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-gray-400 uppercase tracking-wide mb-1.5">
|
||||
Enviar como
|
||||
</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={selectedInstanceId}
|
||||
onChange={(e) => setSelectedInstanceId(e.target.value)}
|
||||
disabled={sending || sent}
|
||||
className="w-full appearance-none bg-[#f0f2f5] rounded-xl px-4 py-2.5 pr-10 text-sm text-[#111b21] font-medium focus:outline-none focus:ring-2 focus:ring-[#008069]/40 disabled:opacity-60"
|
||||
>
|
||||
{connectedInstances.map(inst => (
|
||||
<option key={inst.id} value={inst.id}>
|
||||
{inst.nome}{inst.phone ? ` (+${inst.phone})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Phone input */}
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-gray-400 uppercase tracking-wide mb-1.5">
|
||||
Número do WhatsApp
|
||||
</label>
|
||||
<div className="flex items-center gap-2 bg-[#f0f2f5] rounded-xl px-4 py-2.5 focus-within:ring-2 focus-within:ring-[#008069]/40 transition-all">
|
||||
<span className="text-gray-400 text-sm select-none shrink-0">🇧🇷 +55</span>
|
||||
<div className="w-px h-5 bg-gray-300 shrink-0" />
|
||||
<input
|
||||
ref={phoneInputRef}
|
||||
type="tel"
|
||||
placeholder="(67) 99922-2377"
|
||||
value={phone}
|
||||
onChange={handlePhoneChange}
|
||||
onKeyDown={handlePhoneKeyDown}
|
||||
disabled={sending || sent}
|
||||
className="flex-1 bg-transparent text-sm text-[#111b21] placeholder:text-[#8696a0] focus:outline-none disabled:opacity-60 min-w-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message textarea */}
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-gray-400 uppercase tracking-wide mb-1.5">
|
||||
Mensagem
|
||||
</label>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
rows={3}
|
||||
placeholder="Olá! Tudo bem?"
|
||||
value={message}
|
||||
onChange={(e) => { setMessage(e.target.value); setError(''); }}
|
||||
onKeyDown={handleTextareaKeyDown}
|
||||
disabled={sending || sent}
|
||||
className="w-full bg-[#f0f2f5] rounded-xl px-4 py-3 text-sm text-[#111b21] placeholder:text-[#8696a0] focus:outline-none focus:ring-2 focus:ring-[#008069]/40 resize-none transition-all disabled:opacity-60"
|
||||
/>
|
||||
<p className="text-[11px] text-gray-400 mt-1 text-right">Ctrl+Enter para enviar</p>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
<AnimatePresence>
|
||||
{error && (
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
className="bg-red-50 border border-red-200 text-red-600 text-sm rounded-xl px-4 py-2.5"
|
||||
>
|
||||
{error}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 pb-6 flex items-center justify-between">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={sending}
|
||||
className="px-5 py-2.5 text-sm font-semibold text-gray-500 hover:bg-gray-100 rounded-xl transition-colors disabled:opacity-50"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSend}
|
||||
className={`flex items-center gap-2 px-6 py-2.5 rounded-xl text-sm font-semibold text-white transition-all active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
sent
|
||||
? 'bg-green-500'
|
||||
: sending
|
||||
? 'bg-[#008069]/80'
|
||||
: 'bg-[#008069] hover:bg-[#017259]'
|
||||
}`}
|
||||
>
|
||||
{sent ? (
|
||||
<>
|
||||
<Check className="w-4 h-4" />
|
||||
Enviado!
|
||||
</>
|
||||
) : sending ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
Enviando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="w-4 h-4" />
|
||||
Enviar
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
X, Plus, ClipboardList, ChevronDown, Loader2,
|
||||
CheckCircle2, Clock, AlertCircle, Ban, Users, Layers,
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { format } from 'date-fns';
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
import type { Protocol, ProtocolStatus } from '../hooks/useProtocolPanel';
|
||||
import Toast from './ui/Toast';
|
||||
|
||||
// ─── Status config ────────────────────────────────────────────────────────────
|
||||
|
||||
type StatusConfig = {
|
||||
label: string
|
||||
color: string
|
||||
bg: string
|
||||
icon: React.ReactNode
|
||||
}
|
||||
|
||||
const STATUS: Record<ProtocolStatus, StatusConfig> = {
|
||||
OPEN: { label: 'Aberto', color: 'text-blue-400', bg: 'bg-blue-500/10 border-blue-500/20', icon: <Clock className="w-3 h-3" /> },
|
||||
IN_PROGRESS: { label: 'Em Andamento', color: 'text-brand-400', bg: 'bg-brand-500/10 border-brand-500/20', icon: <AlertCircle className="w-3 h-3" /> },
|
||||
WAITING_CLIENT: { label: 'Aguardando Cliente', color: 'text-amber-400', bg: 'bg-amber-500/10 border-amber-500/20', icon: <Clock className="w-3 h-3" /> },
|
||||
WAITING_AGENT: { label: 'Aguardando Agente', color: 'text-orange-400', bg: 'bg-orange-500/10 border-orange-500/20', icon: <Clock className="w-3 h-3" /> },
|
||||
RESOLVED: { label: 'Resolvido', color: 'text-emerald-400', bg: 'bg-emerald-500/10 border-emerald-500/20', icon: <CheckCircle2 className="w-3 h-3" /> },
|
||||
CANCELLED: { label: 'Cancelado', color: 'text-slate-400', bg: 'bg-slate-500/10 border-slate-500/20', icon: <Ban className="w-3 h-3" /> },
|
||||
}
|
||||
|
||||
const TRANSITIONS: Record<ProtocolStatus, ProtocolStatus[]> = {
|
||||
OPEN: ['IN_PROGRESS', 'CANCELLED'],
|
||||
IN_PROGRESS: ['WAITING_CLIENT', 'WAITING_AGENT', 'RESOLVED', 'CANCELLED'],
|
||||
WAITING_CLIENT: ['IN_PROGRESS', 'RESOLVED', 'CANCELLED'],
|
||||
WAITING_AGENT: ['IN_PROGRESS', 'RESOLVED', 'CANCELLED'],
|
||||
RESOLVED: [],
|
||||
CANCELLED: [],
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: ProtocolStatus }) {
|
||||
const cfg = STATUS[status]
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-bold border ${cfg.color} ${cfg.bg}`}>
|
||||
{cfg.icon}
|
||||
{cfg.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Protocol card ────────────────────────────────────────────────────────────
|
||||
|
||||
interface ProtocolCardProps {
|
||||
protocol: Protocol
|
||||
isActive: boolean
|
||||
updatingId: string | null
|
||||
onStatusChange: (protocol: Protocol, status: ProtocolStatus) => void
|
||||
}
|
||||
|
||||
function ProtocolCard({ protocol, isActive, updatingId, onStatusChange }: ProtocolCardProps) {
|
||||
const [showActions, setShowActions] = React.useState(false)
|
||||
const transitions = TRANSITIONS[protocol.status]
|
||||
const isUpdating = updatingId === protocol.id
|
||||
|
||||
return (
|
||||
<div className={`rounded-xl border p-4 space-y-3 ${isActive ? 'border-brand-500/30 bg-brand-500/5' : 'border-white/5 bg-white/[0.02]'}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-white font-bold text-sm">{protocol.number}</span>
|
||||
{isActive && (
|
||||
<span className="text-[10px] font-bold text-brand-400 bg-brand-500/10 px-1.5 py-0.5 rounded-full border border-brand-500/20">
|
||||
ATIVO
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mt-0.5">
|
||||
{format(new Date(protocol.createdAt), "dd/MM/yyyy 'às' HH:mm", { locale: ptBR })}
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge status={protocol.status} />
|
||||
</div>
|
||||
|
||||
{/* Sector / Team */}
|
||||
{(protocol.sector || protocol.team) && (
|
||||
<div className="flex items-center gap-3 text-xs text-slate-400">
|
||||
{protocol.sector && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Layers className="w-3 h-3" />
|
||||
{protocol.sector.name}
|
||||
</span>
|
||||
)}
|
||||
{protocol.team && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="w-3 h-3" />
|
||||
{protocol.team.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary */}
|
||||
{protocol.summary && (
|
||||
<p className="text-xs text-slate-400 italic border-l-2 border-white/10 pl-3">{protocol.summary}</p>
|
||||
)}
|
||||
|
||||
{/* Status actions */}
|
||||
{transitions.length > 0 && (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowActions(!showActions)}
|
||||
disabled={isUpdating}
|
||||
className="flex items-center gap-1.5 text-xs text-slate-400 hover:text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isUpdating
|
||||
? <Loader2 className="w-3 h-3 animate-spin" />
|
||||
: <ChevronDown className={`w-3 h-3 transition-transform ${showActions ? 'rotate-180' : ''}`} />}
|
||||
Alterar status
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{showActions && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
className="absolute left-0 top-full mt-1 bg-[#1a1f2e] border border-white/10 rounded-xl shadow-2xl z-10 py-1 min-w-[180px]"
|
||||
>
|
||||
{transitions.map((s) => {
|
||||
const cfg = STATUS[s]
|
||||
return (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => { onStatusChange(protocol, s); setShowActions(false) }}
|
||||
className={`w-full text-left px-3 py-2 text-xs flex items-center gap-2 hover:bg-white/5 transition-colors ${cfg.color}`}
|
||||
>
|
||||
{cfg.icon}
|
||||
{cfg.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Resolved at */}
|
||||
{protocol.resolvedAt && (
|
||||
<p className="text-xs text-emerald-400 flex items-center gap-1">
|
||||
<CheckCircle2 className="w-3 h-3" />
|
||||
Resolvido em {format(new Date(protocol.resolvedAt), "dd/MM/yyyy 'às' HH:mm", { locale: ptBR })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Panel ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ProtocolPanelProps {
|
||||
isOpen: boolean
|
||||
chatId: string | null
|
||||
onClose: () => void
|
||||
// injected from useProtocolPanel
|
||||
protocols: Protocol[]
|
||||
loading: boolean
|
||||
activeProtocol: Protocol | null
|
||||
hasActive: boolean
|
||||
showOpenForm: boolean
|
||||
openForm: () => void
|
||||
setShowOpenForm: (v: boolean) => void
|
||||
sectors: any[]
|
||||
teams: any[]
|
||||
selectedSectorId: string
|
||||
setSelectedSectorId: (v: string) => void
|
||||
selectedTeamId: string
|
||||
setSelectedTeamId: (v: string) => void
|
||||
loadingSectors: boolean
|
||||
loadingTeams: boolean
|
||||
isOpening: boolean
|
||||
openProtocol: () => void
|
||||
updatingId: string | null
|
||||
updateStatus: (protocol: Protocol, status: ProtocolStatus) => void
|
||||
toast: { message: string; type: 'success' | 'error' } | null
|
||||
}
|
||||
|
||||
export default function ProtocolPanel({
|
||||
isOpen, onClose,
|
||||
protocols, loading,
|
||||
activeProtocol, hasActive,
|
||||
showOpenForm, openForm, setShowOpenForm,
|
||||
sectors, teams,
|
||||
selectedSectorId, setSelectedSectorId,
|
||||
selectedTeamId, setSelectedTeamId,
|
||||
loadingSectors, loadingTeams,
|
||||
isOpening, openProtocol,
|
||||
updatingId, updateStatus,
|
||||
toast,
|
||||
}: ProtocolPanelProps) {
|
||||
const selectClass = "w-full bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-sm text-white focus:outline-none focus:border-brand-500/50 transition-colors appearance-none"
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ x: '100%' }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: '100%' }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
|
||||
className="w-[360px] h-full bg-[#111b21] border-l border-[#d1d7db] flex flex-col z-40 flex-shrink-0"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="h-[60px] bg-[#f0f2f5] px-4 flex items-center justify-between shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardList className="w-5 h-5 text-[#54656f]" />
|
||||
<span className="font-bold text-[#111b21] text-sm">Protocolos de Atendimento</span>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1.5 hover:bg-black/5 rounded-full transition-colors">
|
||||
<X className="w-5 h-5 text-[#54656f]" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
|
||||
{/* Open protocol form */}
|
||||
<AnimatePresence>
|
||||
{showOpenForm ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="bg-white/[0.03] border border-white/10 rounded-xl p-4 space-y-3">
|
||||
<p className="text-white text-sm font-semibold">Novo Protocolo</p>
|
||||
|
||||
{/* Sector */}
|
||||
<div>
|
||||
<label className="block text-xs text-slate-400 mb-1">Setor (opcional)</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={selectedSectorId}
|
||||
onChange={(e) => setSelectedSectorId(e.target.value)}
|
||||
className={selectClass}
|
||||
disabled={loadingSectors}
|
||||
>
|
||||
<option value="">Sem setor</option>
|
||||
{sectors.map((s) => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
{loadingSectors && (
|
||||
<Loader2 className="absolute right-3 top-2.5 w-3.5 h-3.5 text-slate-400 animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Team */}
|
||||
{selectedSectorId && (
|
||||
<div>
|
||||
<label className="block text-xs text-slate-400 mb-1">Equipe (opcional)</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={selectedTeamId}
|
||||
onChange={(e) => setSelectedTeamId(e.target.value)}
|
||||
className={selectClass}
|
||||
disabled={loadingTeams}
|
||||
>
|
||||
<option value="">Sem equipe</option>
|
||||
{teams.map((t) => (
|
||||
<option key={t.id} value={t.id}>{t.name}</option>
|
||||
))}
|
||||
</select>
|
||||
{loadingTeams && (
|
||||
<Loader2 className="absolute right-3 top-2.5 w-3.5 h-3.5 text-slate-400 animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setShowOpenForm(false)}
|
||||
className="flex-1 py-2 text-xs font-semibold text-slate-400 hover:text-white bg-white/5 hover:bg-white/10 rounded-lg transition-colors"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
onClick={openProtocol}
|
||||
disabled={isOpening}
|
||||
className="flex-1 py-2 text-xs font-bold text-white bg-brand-500 hover:bg-brand-600 rounded-lg transition-colors disabled:opacity-50 flex items-center justify-center gap-1.5"
|
||||
>
|
||||
{isOpening
|
||||
? <><Loader2 className="w-3 h-3 animate-spin" /> Abrindo…</>
|
||||
: 'Abrir Protocolo'
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
!hasActive && (
|
||||
<button
|
||||
onClick={openForm}
|
||||
className="w-full flex items-center justify-center gap-2 py-3 border-2 border-dashed border-white/10 rounded-xl text-sm text-slate-400 hover:text-brand-400 hover:border-brand-500/40 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Abrir Protocolo
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Active protocol */}
|
||||
{activeProtocol && !showOpenForm && (
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 uppercase tracking-widest font-bold mb-2">Protocolo Ativo</p>
|
||||
<ProtocolCard
|
||||
protocol={activeProtocol}
|
||||
isActive
|
||||
updatingId={updatingId}
|
||||
onStatusChange={updateStatus}
|
||||
/>
|
||||
{!hasActive && null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Open new when active exists */}
|
||||
{hasActive && !showOpenForm && (
|
||||
<button
|
||||
onClick={openForm}
|
||||
className="w-full flex items-center justify-center gap-2 py-2.5 border border-dashed border-white/10 rounded-xl text-xs text-slate-500 hover:text-brand-400 hover:border-brand-500/30 transition-colors"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Abrir novo protocolo
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* History */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<Loader2 className="w-5 h-5 text-brand-400 animate-spin" />
|
||||
</div>
|
||||
) : protocols.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-10 gap-2">
|
||||
<ClipboardList className="w-8 h-8 text-slate-700" />
|
||||
<p className="text-slate-500 text-xs text-center">Nenhum protocolo para este chat.</p>
|
||||
</div>
|
||||
) : (
|
||||
(() => {
|
||||
const history = protocols.filter(
|
||||
(p) => p.status === 'RESOLVED' || p.status === 'CANCELLED'
|
||||
)
|
||||
return history.length > 0 ? (
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 uppercase tracking-widest font-bold mb-2">Histórico</p>
|
||||
<div className="space-y-2">
|
||||
{history.map((p) => (
|
||||
<ProtocolCard
|
||||
key={p.id}
|
||||
protocol={p}
|
||||
isActive={false}
|
||||
updatingId={updatingId}
|
||||
onStatusChange={updateStatus}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,719 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* RichMessageComposer — modal completo para compor e enviar mensagens ricas.
|
||||
*
|
||||
* Suporta 6 tipos de mensagem WhatsApp via InfiniteAPI/Baileys:
|
||||
* TEXT — texto simples (padrão atual)
|
||||
* BUTTONS — até 3 botões de resposta rápida (nativeButtons reply)
|
||||
* INTERACTIVE — botões CTA: URL / Copiar / Ligar (nativeButtons url/copy/call)
|
||||
* LIST — lista dropdown com seções e itens (nativeList)
|
||||
* POLL — enquete nativa do WhatsApp (poll)
|
||||
* CAROUSEL — cards deslizáveis com imagem e botões (nativeCarousel)
|
||||
*
|
||||
* Props:
|
||||
* isOpen — controla visibilidade
|
||||
* onClose — callback ao fechar
|
||||
* instanceId — instância WhatsApp ativa
|
||||
* jid — JID destino (pré-preenchido quando aberto do chat)
|
||||
* onSent — callback após envio bem-sucedido
|
||||
* initialPayload — pré-preenche o form (ao usar um template)
|
||||
* initialType — tipo inicial selecionado
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useEffect, useId } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import {
|
||||
X, Send, Plus, Trash2, ChevronDown, ChevronUp,
|
||||
Type, MousePointerClick, List, BarChart2, LayoutGrid, Link,
|
||||
BookmarkPlus, Calendar,
|
||||
} from 'lucide-react'
|
||||
import { richMessageApi, templateApi, type TemplateType, type RichPayload } from '../services/chatApiService'
|
||||
|
||||
// ─── Tipos locais ────────────────────────────────────────────────────────────
|
||||
|
||||
type MessageType = TemplateType
|
||||
|
||||
interface BtnItem { id: string; text: string }
|
||||
interface CtaItem { type: 'url' | 'copy' | 'call'; text: string; extra: string }
|
||||
interface ListRow { id: string; title: string; description: string }
|
||||
interface ListSection { title: string; rows: ListRow[] }
|
||||
interface CarouselBtn { id: string; text: string }
|
||||
interface CarouselCard { title: string; body: string; footer: string; imageUrl: string; buttons: CarouselBtn[] }
|
||||
|
||||
interface FormState {
|
||||
// TEXT
|
||||
text: string
|
||||
// BUTTONS
|
||||
btnText: string
|
||||
btnFooter: string
|
||||
buttons: BtnItem[]
|
||||
// INTERACTIVE (CTA)
|
||||
ctaText: string
|
||||
ctaFooter: string
|
||||
ctaButtons: CtaItem[]
|
||||
// LIST
|
||||
listText: string
|
||||
listButtonText: string
|
||||
listFooter: string
|
||||
listSections: ListSection[]
|
||||
// POLL
|
||||
pollName: string
|
||||
pollSelectable: number
|
||||
pollOptions: string[]
|
||||
// CAROUSEL
|
||||
carouselText: string
|
||||
carouselFooter: string
|
||||
cards: CarouselCard[]
|
||||
}
|
||||
|
||||
const defaultForm: FormState = {
|
||||
text: '',
|
||||
btnText: '', btnFooter: '', buttons: [{ id: 'btn1', text: '' }],
|
||||
ctaText: '', ctaFooter: '', ctaButtons: [{ type: 'url', text: '', extra: '' }],
|
||||
listText: '', listButtonText: 'Ver opções', listFooter: '',
|
||||
listSections: [{ title: 'Seção 1', rows: [{ id: 'r1', title: '', description: '' }] }],
|
||||
pollName: '', pollSelectable: 1, pollOptions: ['', ''],
|
||||
carouselText: '', carouselFooter: '',
|
||||
cards: [{ title: '', body: '', footer: '', imageUrl: '', buttons: [{ id: 'cb1', text: '' }] }],
|
||||
}
|
||||
|
||||
// ─── Tipo selector ────────────────────────────────────────────────────────────
|
||||
|
||||
const TYPE_TABS: Array<{ type: MessageType; label: string; icon: React.ReactNode; color: string }> = [
|
||||
{ type: 'TEXT', label: 'Texto', icon: <Type className="w-4 h-4" />, color: '#667781' },
|
||||
{ type: 'BUTTONS', label: 'Botões', icon: <MousePointerClick className="w-4 h-4" />, color: '#0ea5e9' },
|
||||
{ type: 'INTERACTIVE', label: 'CTA', icon: <Link className="w-4 h-4" />, color: '#8b5cf6' },
|
||||
{ type: 'LIST', label: 'Lista', icon: <List className="w-4 h-4" />, color: '#f59e0b' },
|
||||
{ type: 'POLL', label: 'Enquete', icon: <BarChart2 className="w-4 h-4" />, color: '#10b981' },
|
||||
{ type: 'CAROUSEL', label: 'Carrossel', icon: <LayoutGrid className="w-4 h-4" />, color: '#ef4444' },
|
||||
]
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function uid() { return `id_${Date.now()}_${Math.random().toString(36).slice(2, 7)}` }
|
||||
|
||||
function buildPayload(type: MessageType, form: FormState): RichPayload {
|
||||
switch (type) {
|
||||
case 'TEXT':
|
||||
return { text: form.text }
|
||||
|
||||
case 'BUTTONS':
|
||||
return {
|
||||
text: form.btnText || undefined,
|
||||
footer: form.btnFooter || undefined,
|
||||
nativeButtons: form.buttons.filter(b => b.id && b.text).map(b => ({
|
||||
type: 'reply' as const, id: b.id, text: b.text,
|
||||
})),
|
||||
}
|
||||
|
||||
case 'INTERACTIVE':
|
||||
return {
|
||||
text: form.ctaText || undefined,
|
||||
nativeButtons: form.ctaButtons.filter(b => b.text && b.extra).map(b => {
|
||||
if (b.type === 'url') return { type: 'url' as const, text: b.text, url: b.extra }
|
||||
if (b.type === 'copy') return { type: 'copy' as const, text: b.text, copyText: b.extra }
|
||||
return { type: 'call' as const, text: b.text, phoneNumber: b.extra }
|
||||
}),
|
||||
}
|
||||
|
||||
case 'LIST': {
|
||||
const sections = form.listSections
|
||||
.map(s => ({ title: s.title, rows: s.rows.filter(r => r.id && r.title) }))
|
||||
.filter(s => s.rows.length > 0)
|
||||
return {
|
||||
text: form.listText || undefined,
|
||||
nativeList: {
|
||||
buttonText: form.listButtonText || 'Ver opções',
|
||||
sections,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
case 'POLL':
|
||||
return {
|
||||
poll: {
|
||||
name: form.pollName || 'Enquete',
|
||||
values: form.pollOptions.filter(Boolean),
|
||||
selectableCount: form.pollSelectable,
|
||||
},
|
||||
}
|
||||
|
||||
case 'CAROUSEL': {
|
||||
const cards = form.cards.map(c => ({
|
||||
title: c.title || undefined,
|
||||
body: c.body || undefined,
|
||||
footer: c.footer || undefined,
|
||||
image: c.imageUrl ? { url: c.imageUrl } : undefined,
|
||||
buttons: c.buttons.filter(b => b.id && b.text).map(b => ({ type: 'reply' as const, id: b.id, text: b.text })),
|
||||
}))
|
||||
return {
|
||||
text: form.carouselText || undefined,
|
||||
nativeCarousel: { cards },
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Sub-forms ────────────────────────────────────────────────────────────────
|
||||
|
||||
function TextForm({ form, set }: { form: FormState; set: (p: Partial<FormState>) => void }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Mensagem</label>
|
||||
<textarea
|
||||
rows={5}
|
||||
className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-[#00a884]/30 resize-none"
|
||||
placeholder="Digite o texto da mensagem..."
|
||||
value={form.text}
|
||||
onChange={e => set({ text: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ButtonsForm({ form, set }: { form: FormState; set: (p: Partial<FormState>) => void }) {
|
||||
const add = () => {
|
||||
if (form.buttons.length >= 3) return
|
||||
set({ buttons: [...form.buttons, { id: uid(), text: '' }] })
|
||||
}
|
||||
const remove = (i: number) => set({ buttons: form.buttons.filter((_, idx) => idx !== i) })
|
||||
const update = (i: number, field: keyof BtnItem, val: string) => {
|
||||
const btns = [...form.buttons]; btns[i] = { ...btns[i], [field]: val }; set({ buttons: btns })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Texto</label>
|
||||
<textarea rows={2} className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#0ea5e9]/30 resize-none"
|
||||
placeholder="Corpo da mensagem" value={form.btnText} onChange={e => set({ btnText: e.target.value })} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Rodapé</label>
|
||||
<input className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#0ea5e9]/30"
|
||||
placeholder="Texto de rodapé (opcional)" value={form.btnFooter} onChange={e => set({ btnFooter: e.target.value })} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Botões (máx. 3)</label>
|
||||
{form.buttons.map((btn, i) => (
|
||||
<div key={btn.id} className="flex gap-2">
|
||||
<input className="flex-1 rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#0ea5e9]/30"
|
||||
placeholder="ID do botão" value={btn.id} onChange={e => update(i, 'id', e.target.value)} />
|
||||
<input className="flex-[2] rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#0ea5e9]/30"
|
||||
placeholder="Texto visível" value={btn.text} onChange={e => update(i, 'text', e.target.value)} />
|
||||
<button onClick={() => remove(i)} disabled={form.buttons.length <= 1}
|
||||
className="p-2 rounded-full hover:bg-red-50 text-gray-400 hover:text-red-500 disabled:opacity-30 transition-colors">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{form.buttons.length < 3 && (
|
||||
<button onClick={add} className="flex items-center gap-1 text-xs text-[#0ea5e9] hover:underline font-medium">
|
||||
<Plus className="w-3.5 h-3.5" /> Adicionar botão
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InteractiveForm({ form, set }: { form: FormState; set: (p: Partial<FormState>) => void }) {
|
||||
const add = () => {
|
||||
if (form.ctaButtons.length >= 3) return
|
||||
set({ ctaButtons: [...form.ctaButtons, { type: 'url', text: '', extra: '' }] })
|
||||
}
|
||||
const remove = (i: number) => set({ ctaButtons: form.ctaButtons.filter((_, idx) => idx !== i) })
|
||||
const update = (i: number, field: keyof CtaItem, val: string) => {
|
||||
const btns = [...form.ctaButtons]; btns[i] = { ...btns[i], [field]: val as any }; set({ ctaButtons: btns })
|
||||
}
|
||||
|
||||
const placeholders: Record<string, string> = { url: 'https://...', copy: 'Código do cupom', call: '5511999999999' }
|
||||
const labels: Record<string, string> = { url: 'URL', copy: 'Código a copiar', call: 'Número de telefone' }
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Texto</label>
|
||||
<textarea rows={2} className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#8b5cf6]/30 resize-none"
|
||||
placeholder="Corpo da mensagem" value={form.ctaText} onChange={e => set({ ctaText: e.target.value })} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Botões CTA (máx. 3)</label>
|
||||
{form.ctaButtons.map((btn, i) => (
|
||||
<div key={i} className="rounded-xl border border-gray-200 bg-[#f8f9fa] p-3 space-y-2">
|
||||
<div className="flex gap-2 items-start">
|
||||
<select value={btn.type} onChange={e => update(i, 'type', e.target.value)}
|
||||
className="rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-[#8b5cf6]/30">
|
||||
<option value="url">🌐 URL</option>
|
||||
<option value="copy">📋 Copiar</option>
|
||||
<option value="call">📞 Ligar</option>
|
||||
</select>
|
||||
<input className="flex-1 rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-[#8b5cf6]/30"
|
||||
placeholder="Texto do botão" value={btn.text} onChange={e => update(i, 'text', e.target.value)} />
|
||||
<button onClick={() => remove(i)} disabled={form.ctaButtons.length <= 1}
|
||||
className="p-1.5 rounded-full hover:bg-red-50 text-gray-400 hover:text-red-500 disabled:opacity-30 transition-colors">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<input className="w-full rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-[#8b5cf6]/30"
|
||||
placeholder={placeholders[btn.type]} value={btn.extra} onChange={e => update(i, 'extra', e.target.value)} />
|
||||
<span className="text-[10px] text-gray-400">{labels[btn.type]}</span>
|
||||
</div>
|
||||
))}
|
||||
{form.ctaButtons.length < 3 && (
|
||||
<button onClick={add} className="flex items-center gap-1 text-xs text-[#8b5cf6] hover:underline font-medium">
|
||||
<Plus className="w-3.5 h-3.5" /> Adicionar botão CTA
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ListForm({ form, set }: { form: FormState; set: (p: Partial<FormState>) => void }) {
|
||||
const addSection = () => {
|
||||
set({ listSections: [...form.listSections, { title: `Seção ${form.listSections.length + 1}`, rows: [{ id: uid(), title: '', description: '' }] }] })
|
||||
}
|
||||
const removeSection = (si: number) => set({ listSections: form.listSections.filter((_, i) => i !== si) })
|
||||
const updateSection = (si: number, title: string) => {
|
||||
const ss = [...form.listSections]; ss[si] = { ...ss[si], title }; set({ listSections: ss })
|
||||
}
|
||||
const addRow = (si: number) => {
|
||||
const ss = [...form.listSections]
|
||||
ss[si] = { ...ss[si], rows: [...ss[si].rows, { id: uid(), title: '', description: '' }] }
|
||||
set({ listSections: ss })
|
||||
}
|
||||
const removeRow = (si: number, ri: number) => {
|
||||
const ss = [...form.listSections]
|
||||
ss[si] = { ...ss[si], rows: ss[si].rows.filter((_, i) => i !== ri) }
|
||||
set({ listSections: ss })
|
||||
}
|
||||
const updateRow = (si: number, ri: number, field: keyof ListRow, val: string) => {
|
||||
const ss = [...form.listSections]
|
||||
const rows = [...ss[si].rows]; rows[ri] = { ...rows[ri], [field]: val }
|
||||
ss[si] = { ...ss[si], rows }; set({ listSections: ss })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Texto</label>
|
||||
<textarea rows={2} className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none resize-none"
|
||||
placeholder="Corpo da mensagem" value={form.listText} onChange={e => set({ listText: e.target.value })} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Texto do Botão</label>
|
||||
<input className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none"
|
||||
placeholder="Ver opções" value={form.listButtonText} onChange={e => set({ listButtonText: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{form.listSections.map((sec, si) => (
|
||||
<div key={si} className="rounded-xl border border-amber-100 bg-amber-50/50 p-3 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<input className="flex-1 rounded-lg border border-amber-200 bg-white px-2 py-1.5 text-xs font-medium focus:outline-none"
|
||||
placeholder="Título da seção" value={sec.title} onChange={e => updateSection(si, e.target.value)} />
|
||||
<button onClick={() => removeSection(si)} disabled={form.listSections.length <= 1}
|
||||
className="p-1 rounded text-gray-400 hover:text-red-500 disabled:opacity-30">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{sec.rows.map((row, ri) => (
|
||||
<div key={ri} className="flex gap-2">
|
||||
<input className="w-20 rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs focus:outline-none"
|
||||
placeholder="ID" value={row.id} onChange={e => updateRow(si, ri, 'id', e.target.value)} />
|
||||
<input className="flex-1 rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs focus:outline-none"
|
||||
placeholder="Título" value={row.title} onChange={e => updateRow(si, ri, 'title', e.target.value)} />
|
||||
<input className="flex-[2] rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs focus:outline-none"
|
||||
placeholder="Descrição" value={row.description} onChange={e => updateRow(si, ri, 'description', e.target.value)} />
|
||||
<button onClick={() => removeRow(si, ri)} disabled={sec.rows.length <= 1}
|
||||
className="p-1 rounded text-gray-400 hover:text-red-500 disabled:opacity-30">
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button onClick={() => addRow(si)} className="flex items-center gap-1 text-xs text-amber-600 hover:underline font-medium">
|
||||
<Plus className="w-3 h-3" /> Adicionar item
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button onClick={addSection} className="flex items-center gap-1 text-xs text-amber-600 hover:underline font-medium">
|
||||
<Plus className="w-3.5 h-3.5" /> Adicionar seção
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PollForm({ form, set }: { form: FormState; set: (p: Partial<FormState>) => void }) {
|
||||
const addOption = () => set({ pollOptions: [...form.pollOptions, ''] })
|
||||
const removeOption = (i: number) => {
|
||||
if (form.pollOptions.length <= 2) return
|
||||
set({ pollOptions: form.pollOptions.filter((_, idx) => idx !== i) })
|
||||
}
|
||||
const updateOption = (i: number, val: string) => {
|
||||
const opts = [...form.pollOptions]; opts[i] = val; set({ pollOptions: opts })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Pergunta</label>
|
||||
<input className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#10b981]/30"
|
||||
placeholder="Qual a sua preferência?" value={form.pollName} onChange={e => set({ pollName: e.target.value })} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Máx. seleções</label>
|
||||
<input type="number" min={1} max={form.pollOptions.filter(Boolean).length || 1}
|
||||
className="w-24 rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#10b981]/30"
|
||||
value={form.pollSelectable} onChange={e => set({ pollSelectable: parseInt(e.target.value) || 1 })} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Opções (mín. 2)</label>
|
||||
{form.pollOptions.map((opt, i) => (
|
||||
<div key={i} className="flex gap-2">
|
||||
<input className="flex-1 rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#10b981]/30"
|
||||
placeholder={`Opção ${i + 1}`} value={opt} onChange={e => updateOption(i, e.target.value)} />
|
||||
<button onClick={() => removeOption(i)} disabled={form.pollOptions.length <= 2}
|
||||
className="p-2 rounded-full hover:bg-red-50 text-gray-400 hover:text-red-500 disabled:opacity-30 transition-colors">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{form.pollOptions.length < 12 && (
|
||||
<button onClick={addOption} className="flex items-center gap-1 text-xs text-[#10b981] hover:underline font-medium">
|
||||
<Plus className="w-3.5 h-3.5" /> Adicionar opção
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CarouselForm({ form, set }: { form: FormState; set: (p: Partial<FormState>) => void }) {
|
||||
const addCard = () => {
|
||||
if (form.cards.length >= 10) return
|
||||
set({ cards: [...form.cards, { title: '', body: '', footer: '', imageUrl: '', buttons: [{ id: uid(), text: '' }] }] })
|
||||
}
|
||||
const removeCard = (ci: number) => set({ cards: form.cards.filter((_, i) => i !== ci) })
|
||||
const updateCard = (ci: number, field: keyof CarouselCard, val: any) => {
|
||||
const cards = [...form.cards]; cards[ci] = { ...cards[ci], [field]: val }; set({ cards })
|
||||
}
|
||||
const addCardBtn = (ci: number) => {
|
||||
const cards = [...form.cards]; cards[ci].buttons.push({ id: uid(), text: '' }); set({ cards })
|
||||
}
|
||||
const removeCardBtn = (ci: number, bi: number) => {
|
||||
const cards = [...form.cards]; cards[ci].buttons = cards[ci].buttons.filter((_, i) => i !== bi); set({ cards })
|
||||
}
|
||||
const updateCardBtn = (ci: number, bi: number, field: keyof CarouselBtn, val: string) => {
|
||||
const cards = [...form.cards]; cards[ci].buttons[bi] = { ...cards[ci].buttons[bi], [field]: val }; set({ cards })
|
||||
}
|
||||
|
||||
const [expanded, setExpanded] = useState<number[]>([0])
|
||||
const toggle = (i: number) => setExpanded(prev => prev.includes(i) ? prev.filter(x => x !== i) : [...prev, i])
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Texto acima</label>
|
||||
<input className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none"
|
||||
placeholder="Ofertas especiais" value={form.carouselText} onChange={e => set({ carouselText: e.target.value })} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Rodapé</label>
|
||||
<input className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none"
|
||||
placeholder="Rodapé geral" value={form.carouselFooter} onChange={e => set({ carouselFooter: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 max-h-[320px] overflow-y-auto pr-1">
|
||||
{form.cards.map((card, ci) => (
|
||||
<div key={ci} className="rounded-xl border border-gray-200 bg-[#f8f9fa] overflow-hidden">
|
||||
<button onClick={() => toggle(ci)}
|
||||
className="w-full flex items-center justify-between px-3 py-2.5 text-sm font-medium text-[#111b21] hover:bg-gray-100 transition-colors">
|
||||
<span>Card {ci + 1}{card.title ? ` — ${card.title}` : ''}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{form.cards.length > 1 && (
|
||||
<button onClick={(e) => { e.stopPropagation(); removeCard(ci) }}
|
||||
className="p-1 rounded-full hover:bg-red-50 text-gray-400 hover:text-red-500 transition-colors">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{expanded.includes(ci) ? <ChevronUp className="w-4 h-4 text-gray-400" /> : <ChevronDown className="w-4 h-4 text-gray-400" />}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{expanded.includes(ci) && (
|
||||
<div className="px-3 pb-3 space-y-2 border-t border-gray-100 pt-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<input className="rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs focus:outline-none"
|
||||
placeholder="Título" value={card.title} onChange={e => updateCard(ci, 'title', e.target.value)} />
|
||||
<input className="rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs focus:outline-none"
|
||||
placeholder="Rodapé" value={card.footer} onChange={e => updateCard(ci, 'footer', e.target.value)} />
|
||||
</div>
|
||||
<textarea rows={2} className="w-full rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs focus:outline-none resize-none"
|
||||
placeholder="Descrição/corpo" value={card.body} onChange={e => updateCard(ci, 'body', e.target.value)} />
|
||||
<input className="w-full rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs focus:outline-none"
|
||||
placeholder="URL da imagem (opcional)" value={card.imageUrl} onChange={e => updateCard(ci, 'imageUrl', e.target.value)} />
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[10px] font-semibold text-gray-400 uppercase">Botões do card</span>
|
||||
{card.buttons.map((btn, bi) => (
|
||||
<div key={bi} className="flex gap-1.5">
|
||||
<input className="w-20 rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] focus:outline-none"
|
||||
placeholder="ID" value={btn.id} onChange={e => updateCardBtn(ci, bi, 'id', e.target.value)} />
|
||||
<input className="flex-1 rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] focus:outline-none"
|
||||
placeholder="Texto" value={btn.text} onChange={e => updateCardBtn(ci, bi, 'text', e.target.value)} />
|
||||
<button onClick={() => removeCardBtn(ci, bi)} disabled={card.buttons.length <= 1}
|
||||
className="p-1 rounded text-gray-400 hover:text-red-500 disabled:opacity-30">
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button onClick={() => addCardBtn(ci)} className="flex items-center gap-1 text-[11px] text-red-500 hover:underline font-medium">
|
||||
<Plus className="w-3 h-3" /> Botão no card
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{form.cards.length < 10 && (
|
||||
<button onClick={addCard} className="flex items-center gap-1 text-xs text-[#ef4444] hover:underline font-medium">
|
||||
<Plus className="w-3.5 h-3.5" /> Adicionar card
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Modal principal ──────────────────────────────────────────────────────────
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
instanceId: string | null
|
||||
jid: string
|
||||
onSent?: () => void
|
||||
onSaveTemplate?: (type: TemplateType, payload: Record<string, unknown>, name?: string) => void
|
||||
onSchedule?: (type: TemplateType, payload: Record<string, unknown>) => void
|
||||
initialPayload?: Record<string, unknown>
|
||||
initialType?: TemplateType
|
||||
}
|
||||
|
||||
export default function RichMessageComposer({
|
||||
isOpen, onClose, instanceId, jid, onSent, onSaveTemplate, onSchedule,
|
||||
initialPayload, initialType = 'TEXT',
|
||||
}: Props) {
|
||||
const [type, setType] = useState<MessageType>(initialType)
|
||||
const [form, setFormState] = useState<FormState>(defaultForm)
|
||||
const [sending, setSending] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [savedName, setSavedName] = useState('')
|
||||
const [showSaveName, setShowSaveName] = useState(false)
|
||||
|
||||
const set = useCallback((partial: Partial<FormState>) => {
|
||||
setFormState(prev => ({ ...prev, ...partial }))
|
||||
}, [])
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!instanceId || !jid) return
|
||||
// Validações de frontend antes de enviar
|
||||
if (type === 'CAROUSEL' && form.cards.length < 2) {
|
||||
setError('Carrossel requer no mínimo 2 cards')
|
||||
return
|
||||
}
|
||||
if (type === 'CAROUSEL' && form.cards.some(c => c.buttons.filter(b => b.id && b.text).length === 0)) {
|
||||
setError('Cada card do carrossel precisa ter pelo menos 1 botão')
|
||||
return
|
||||
}
|
||||
setError(null)
|
||||
setSending(true)
|
||||
try {
|
||||
const payload = buildPayload(type, form)
|
||||
await richMessageApi.send(instanceId, jid, payload)
|
||||
onSent?.()
|
||||
onClose()
|
||||
} catch (e: any) {
|
||||
setError(e?.response?.data?.error ?? e?.message ?? 'Erro ao enviar')
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!savedName.trim()) { setShowSaveName(true); return }
|
||||
setSaving(true)
|
||||
try {
|
||||
const payload = buildPayload(type, form) as Record<string, unknown>
|
||||
await templateApi.create({ name: savedName.trim(), type, payload, tags: [] })
|
||||
setShowSaveName(false)
|
||||
setSavedName('')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSchedule = () => {
|
||||
const payload = buildPayload(type, form) as Record<string, unknown>
|
||||
onSchedule?.(type, payload)
|
||||
}
|
||||
|
||||
const activeTab = TYPE_TABS.find(t => t.type === type)!
|
||||
|
||||
const [mounted, setMounted] = useState(false)
|
||||
useEffect(() => { setMounted(true) }, [])
|
||||
if (!mounted) return null
|
||||
|
||||
return createPortal(
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-50 flex items-end sm:items-center justify-center p-4"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 40, scale: 0.97 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 40, scale: 0.97 }}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
||||
className="bg-white rounded-2xl shadow-2xl w-full max-w-xl max-h-[92vh] flex flex-col overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100 shrink-0">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-[#111b21]">Mensagem Rica</h2>
|
||||
<p className="text-xs text-[#667781] mt-0.5">Compose um tipo avançado de mensagem WhatsApp</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 rounded-full hover:bg-gray-100 text-gray-400 transition-colors">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Type tabs */}
|
||||
<div className="px-5 pt-3 pb-2 shrink-0">
|
||||
<div className="flex gap-1 bg-[#f0f2f5] rounded-xl p-1">
|
||||
{TYPE_TABS.map(tab => (
|
||||
<button
|
||||
key={tab.type}
|
||||
onClick={() => setType(tab.type)}
|
||||
className={`flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-lg text-[11px] font-semibold transition-all ${
|
||||
type === tab.type
|
||||
? 'bg-white shadow-sm text-[#111b21]'
|
||||
: 'text-[#667781] hover:text-[#111b21]'
|
||||
}`}
|
||||
style={type === tab.type ? { color: tab.color } : {}}
|
||||
>
|
||||
{tab.icon}
|
||||
<span className="hidden sm:block">{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form area */}
|
||||
<div className="flex-1 overflow-y-auto px-5 py-3">
|
||||
{type === 'TEXT' && <TextForm form={form} set={set} />}
|
||||
{type === 'BUTTONS' && <ButtonsForm form={form} set={set} />}
|
||||
{type === 'INTERACTIVE' && <InteractiveForm form={form} set={set} />}
|
||||
{type === 'LIST' && <ListForm form={form} set={set} />}
|
||||
{type === 'POLL' && <PollForm form={form} set={set} />}
|
||||
{type === 'CAROUSEL' && <CarouselForm form={form} set={set} />}
|
||||
</div>
|
||||
|
||||
{/* Save name input */}
|
||||
<AnimatePresence>
|
||||
{showSaveName && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="px-5 py-2 border-t border-gray-100 bg-[#f8f9fa] overflow-hidden shrink-0"
|
||||
>
|
||||
<div className="flex gap-2 items-center">
|
||||
<input
|
||||
autoFocus
|
||||
className="flex-1 rounded-xl border border-gray-200 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#00a884]/30"
|
||||
placeholder="Nome do template..."
|
||||
value={savedName}
|
||||
onChange={e => setSavedName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleSave(); if (e.key === 'Escape') setShowSaveName(false) }}
|
||||
/>
|
||||
<button onClick={handleSave} disabled={!savedName.trim() || saving}
|
||||
className="px-3 py-2 bg-[#00a884] text-white rounded-xl text-sm font-medium disabled:opacity-50 hover:bg-[#008f72] transition-colors">
|
||||
{saving ? '...' : 'Salvar'}
|
||||
</button>
|
||||
<button onClick={() => setShowSaveName(false)} className="p-2 rounded-full hover:bg-gray-200 text-gray-400">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="mx-5 mb-2 px-3 py-2 bg-red-50 border border-red-200 rounded-xl text-xs text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer actions */}
|
||||
<div className="px-5 py-3 border-t border-gray-100 flex items-center justify-between shrink-0">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setShowSaveName(!showSaveName)}
|
||||
title="Salvar como template"
|
||||
className="flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs font-medium text-[#667781] hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<BookmarkPlus className="w-4 h-4" />
|
||||
<span className="hidden sm:block">Salvar template</span>
|
||||
</button>
|
||||
{onSchedule && (
|
||||
<button
|
||||
onClick={handleSchedule}
|
||||
title="Agendar envio"
|
||||
className="flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs font-medium text-[#667781] hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<Calendar className="w-4 h-4" />
|
||||
<span className="hidden sm:block">Agendar</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={onClose}
|
||||
className="px-4 py-2 rounded-xl text-sm font-medium text-[#667781] hover:bg-gray-100 transition-colors">
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={sending || !instanceId || !jid}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-semibold text-white disabled:opacity-50 transition-all active:scale-95"
|
||||
style={{ backgroundColor: activeTab.color }}
|
||||
>
|
||||
{sending ? (
|
||||
<span className="w-4 h-4 border-2 border-white/40 border-t-white rounded-full animate-spin" />
|
||||
) : (
|
||||
<Send className="w-4 h-4" />
|
||||
)}
|
||||
Enviar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* ScheduleMessageModal — modal completo de agendamentos de mensagem.
|
||||
*
|
||||
* Modos:
|
||||
* ONCE — data/hora específica (datetime-local)
|
||||
* RECURRING — expressão cron com presets visuais
|
||||
* EVENT — BIRTHDAY / ANNIVERSARY / SIGNUP_DAYS
|
||||
* vinculado a dados de recipients (birthday, anniversary, signupDate)
|
||||
*
|
||||
* Suporte a variáveis de personalização: {{name}}, {{birthday}}, etc.
|
||||
* Destinatários: lista manual de JIDs com nome e dados de evento.
|
||||
* Variáveis substituídas em runtime pelo SchedulerService.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import {
|
||||
X, Calendar, RotateCw, Gift, Plus, Trash2, Clock,
|
||||
CheckCircle2, AlertTriangle, ChevronRight, Users,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
scheduleApi, instanceApi,
|
||||
type ScheduleType, type EventType, type ScheduleRecipient, type TemplateType,
|
||||
} from '../services/chatApiService'
|
||||
|
||||
// ─── CRON presets ─────────────────────────────────────────────────────────────
|
||||
|
||||
const CRON_PRESETS = [
|
||||
{ label: 'Diariamente às 8h', expr: '0 8 * * *' },
|
||||
{ label: 'Seg a Sex às 9h', expr: '0 9 * * 1-5' },
|
||||
{ label: 'Toda segunda às 10h', expr: '0 10 * * 1' },
|
||||
{ label: 'Quinzenal (dia 1/15)', expr: '0 9 1,15 * *' },
|
||||
{ label: 'Mensal (dia 1)', expr: '0 9 1 * *' },
|
||||
{ label: 'Semanal domingo 11h', expr: '0 11 * * 0' },
|
||||
]
|
||||
|
||||
// ─── Tipos locais ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface RecipientRow extends ScheduleRecipient {
|
||||
_key: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
payload: Record<string, unknown>
|
||||
messageType: TemplateType
|
||||
onSaved?: () => void
|
||||
}
|
||||
|
||||
function uid() { return `k_${Date.now()}_${Math.random().toString(36).slice(2, 6)}` }
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ScheduleMessageModal({ isOpen, onClose, payload, messageType, onSaved }: Props) {
|
||||
const [step, setStep] = useState<1 | 2 | 3>(1) // 1=tipo, 2=destinatários, 3=confirmação
|
||||
|
||||
// Step 1 — configuração de schedule
|
||||
const [scheduleType, setScheduleType] = useState<ScheduleType>('ONCE')
|
||||
const [scheduledAt, setScheduledAt] = useState('')
|
||||
const [cronExpr, setCronExpr] = useState('0 9 * * 1-5')
|
||||
const [eventType, setEventType] = useState<EventType>('BIRTHDAY')
|
||||
const [daysAfter, setDaysAfter] = useState('30')
|
||||
const [name, setName] = useState('')
|
||||
const [timezone] = useState('America/Sao_Paulo')
|
||||
|
||||
// Step 2 — destinatários
|
||||
const [recipients, setRecipients] = useState<RecipientRow[]>([
|
||||
{ _key: uid(), jid: '', name: '', birthday: '', anniversary: '', signupDate: '' },
|
||||
])
|
||||
|
||||
// Step 3 — resultado
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [instanceId, setInstanceId] = useState('')
|
||||
const [instances, setInstances] = useState<Array<{ id: string; name: string; status: string }>>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
instanceApi.list().then((list: any[]) => setInstances(list))
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
const addRecipient = () => {
|
||||
setRecipients(prev => [...prev, { _key: uid(), jid: '', name: '', birthday: '', anniversary: '', signupDate: '' }])
|
||||
}
|
||||
const removeRecipient = (key: string) => {
|
||||
setRecipients(prev => prev.filter(r => r._key !== key))
|
||||
}
|
||||
const updateRecipient = (key: string, field: keyof ScheduleRecipient, val: string) => {
|
||||
setRecipients(prev => prev.map(r => r._key === key ? { ...r, [field]: val } : r))
|
||||
}
|
||||
|
||||
const isStep1Valid = () => {
|
||||
if (!name.trim() || !instanceId) return false
|
||||
if (scheduleType === 'ONCE' && !scheduledAt) return false
|
||||
if (scheduleType === 'RECURRING' && !cronExpr.trim()) return false
|
||||
return true
|
||||
}
|
||||
|
||||
const validRecipients = recipients.filter(r => r.jid.trim())
|
||||
|
||||
const buildScheduleData = () => ({
|
||||
instanceId,
|
||||
name: name.trim(),
|
||||
payload,
|
||||
recipients: validRecipients.map(({ _key, ...r }) => ({
|
||||
...r,
|
||||
jid: r.jid.includes('@') ? r.jid : `${r.jid.replace(/\D/g, '')}@s.whatsapp.net`,
|
||||
})),
|
||||
scheduleType,
|
||||
scheduledAt: scheduleType === 'ONCE' ? scheduledAt : undefined,
|
||||
cronExpr: scheduleType === 'RECURRING' ? cronExpr
|
||||
: scheduleType === 'EVENT' && eventType === 'SIGNUP_DAYS' ? daysAfter
|
||||
: undefined,
|
||||
eventType: scheduleType === 'EVENT' ? eventType : undefined,
|
||||
timezone,
|
||||
})
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
await scheduleApi.create(buildScheduleData())
|
||||
setSaved(true)
|
||||
onSaved?.()
|
||||
setTimeout(() => { setSaved(false); onClose(); setStep(1) }, 2000)
|
||||
} catch (e: any) {
|
||||
setError(e?.response?.data?.error ?? e?.message ?? 'Erro ao salvar')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Rótulo do tipo de mensagem
|
||||
const typeLabels: Record<TemplateType, string> = {
|
||||
TEXT: 'Texto', BUTTONS: 'Botões', INTERACTIVE: 'CTA', LIST: 'Lista', POLL: 'Enquete', CAROUSEL: 'Carrossel',
|
||||
}
|
||||
|
||||
const minDateTime = new Date(Date.now() + 60_000).toISOString().slice(0, 16)
|
||||
|
||||
const [mounted, setMounted] = useState(false)
|
||||
useEffect(() => { setMounted(true) }, [])
|
||||
if (!mounted) return null
|
||||
|
||||
return createPortal(
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[60] flex items-center justify-center p-4"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30, scale: 0.97 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 30, scale: 0.97 }}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
||||
className="bg-white rounded-2xl shadow-2xl w-full max-w-lg max-h-[90vh] flex flex-col overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100 shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-[#fef3c7] flex items-center justify-center">
|
||||
<Calendar className="w-5 h-5 text-[#f59e0b]" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-[#111b21]">Agendar Mensagem</h2>
|
||||
<p className="text-xs text-[#667781]">Tipo: {typeLabels[messageType]}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 rounded-full hover:bg-gray-100 text-gray-400 transition-colors">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Step indicator */}
|
||||
<div className="px-5 py-3 flex items-center gap-2 shrink-0 border-b border-gray-100">
|
||||
{[1, 2, 3].map(s => (
|
||||
<React.Fragment key={s}>
|
||||
<button
|
||||
onClick={() => s < step ? setStep(s as any) : undefined}
|
||||
className={`w-7 h-7 rounded-full text-xs font-bold flex items-center justify-center transition-all ${
|
||||
step === s ? 'bg-[#f59e0b] text-white shadow-md scale-105' :
|
||||
step > s ? 'bg-[#10b981] text-white' : 'bg-gray-100 text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{step > s ? <CheckCircle2 className="w-4 h-4" /> : s}
|
||||
</button>
|
||||
{s < 3 && <ChevronRight className="w-4 h-4 text-gray-300" />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
<span className="ml-2 text-xs text-[#667781]">
|
||||
{step === 1 ? 'Configurar disparo' : step === 2 ? 'Destinatários' : 'Confirmar'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4">
|
||||
|
||||
{/* ── STEP 1: schedule config ─────────────────────────────── */}
|
||||
{step === 1 && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Nome do agendamento</label>
|
||||
<input
|
||||
className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-[#f59e0b]/30"
|
||||
placeholder="Ex: Aniversários de Abril"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Instância WhatsApp</label>
|
||||
<select
|
||||
className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-[#f59e0b]/30"
|
||||
value={instanceId}
|
||||
onChange={e => setInstanceId(e.target.value)}
|
||||
>
|
||||
<option value="">Selecionar instância...</option>
|
||||
{instances.map((inst: any) => (
|
||||
<option key={inst.id} value={inst.id} disabled={inst.status !== 'CONNECTED'}>
|
||||
{inst.name} {inst.status !== 'CONNECTED' ? '(desconectada)' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Schedule type selector */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Tipo de agendamento</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{[
|
||||
{ type: 'ONCE' as ScheduleType, label: 'Uma vez', icon: <Clock className="w-4 h-4" />, color: '#0ea5e9' },
|
||||
{ type: 'RECURRING' as ScheduleType, label: 'Recorrente', icon: <RotateCw className="w-4 h-4" />, color: '#8b5cf6' },
|
||||
{ type: 'EVENT' as ScheduleType, label: 'Evento', icon: <Gift className="w-4 h-4" />, color: '#ef4444' },
|
||||
].map(opt => (
|
||||
<button
|
||||
key={opt.type}
|
||||
onClick={() => setScheduleType(opt.type)}
|
||||
className={`flex flex-col items-center gap-1.5 p-3 rounded-xl border-2 transition-all text-sm font-semibold ${
|
||||
scheduleType === opt.type
|
||||
? 'border-current shadow-sm'
|
||||
: 'border-gray-200 text-gray-400 hover:border-gray-300'
|
||||
}`}
|
||||
style={scheduleType === opt.type ? { borderColor: opt.color, color: opt.color, backgroundColor: opt.color + '10' } : {}}
|
||||
>
|
||||
{opt.icon}
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ONCE — datetime */}
|
||||
{scheduleType === 'ONCE' && (
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Data e hora</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
min={minDateTime}
|
||||
className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-[#0ea5e9]/30"
|
||||
value={scheduledAt}
|
||||
onChange={e => setScheduledAt(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* RECURRING — cron */}
|
||||
{scheduleType === 'RECURRING' && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Expressão Cron</label>
|
||||
<input
|
||||
className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2.5 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-[#8b5cf6]/30"
|
||||
placeholder="0 9 * * 1-5"
|
||||
value={cronExpr}
|
||||
onChange={e => setCronExpr(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-[#667781] font-medium">Presets:</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{CRON_PRESETS.map(p => (
|
||||
<button
|
||||
key={p.expr}
|
||||
onClick={() => setCronExpr(p.expr)}
|
||||
className={`px-2.5 py-1 rounded-lg text-xs font-medium transition-all ${
|
||||
cronExpr === p.expr
|
||||
? 'bg-[#8b5cf6] text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* EVENT — tipo */}
|
||||
{scheduleType === 'EVENT' && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Tipo de evento</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{[
|
||||
{ type: 'BIRTHDAY' as EventType, label: '🎂 Aniversário' },
|
||||
{ type: 'ANNIVERSARY' as EventType, label: '💍 Aniversariante' },
|
||||
{ type: 'SIGNUP_DAYS' as EventType, label: '📅 X dias após cadastro' },
|
||||
].map(opt => (
|
||||
<button
|
||||
key={opt.type}
|
||||
onClick={() => setEventType(opt.type)}
|
||||
className={`py-2.5 px-2 rounded-xl border-2 text-xs font-semibold transition-all ${
|
||||
eventType === opt.type
|
||||
? 'border-[#ef4444] bg-[#fee2e2] text-[#ef4444]'
|
||||
: 'border-gray-200 text-gray-500 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{eventType === 'SIGNUP_DAYS' && (
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-xs font-semibold text-[#667781] uppercase tracking-wider whitespace-nowrap">Dias após cadastro</label>
|
||||
<input
|
||||
type="number" min="1" max="365"
|
||||
className="w-24 rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#ef4444]/30"
|
||||
value={daysAfter}
|
||||
onChange={e => setDaysAfter(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-xl bg-amber-50 border border-amber-200 px-3 py-2.5 text-xs text-amber-700">
|
||||
<strong>Variáveis disponíveis:</strong> {'{{name}}, {{birthday}}, {{anniversary}}'}<br />
|
||||
São substituídas automaticamente por contato no envio.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── STEP 2: recipients ─────────────────────────────────── */}
|
||||
{step === 2 && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="w-4 h-4 text-[#667781]" />
|
||||
<span className="text-sm font-semibold text-[#111b21]">Destinatários</span>
|
||||
<span className="text-xs text-[#667781]">({validRecipients.length} válido{validRecipients.length !== 1 ? 's' : ''})</span>
|
||||
</div>
|
||||
<button onClick={addRecipient} className="flex items-center gap-1 text-xs text-[#00a884] font-medium hover:underline">
|
||||
<Plus className="w-3.5 h-3.5" /> Adicionar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 max-h-[380px] overflow-y-auto pr-1">
|
||||
{recipients.map((r) => (
|
||||
<div key={r._key} className="rounded-xl border border-gray-200 bg-[#f8f9fa] p-3 space-y-2">
|
||||
<div className="flex gap-2 items-start">
|
||||
<input
|
||||
className="flex-[2] rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs focus:outline-none focus:ring-1 focus:ring-[#00a884]"
|
||||
placeholder="Número ou JID ex: 5511999999999"
|
||||
value={r.jid}
|
||||
onChange={e => updateRecipient(r._key, 'jid', e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="flex-1 rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs focus:outline-none focus:ring-1 focus:ring-[#00a884]"
|
||||
placeholder="Nome ({{name}})"
|
||||
value={r.name ?? ''}
|
||||
onChange={e => updateRecipient(r._key, 'name', e.target.value)}
|
||||
/>
|
||||
<button onClick={() => removeRecipient(r._key)} disabled={recipients.length <= 1}
|
||||
className="p-1.5 rounded-full hover:bg-red-50 text-gray-400 hover:text-red-500 disabled:opacity-30 transition-colors">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{scheduleType === 'EVENT' && (
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
{(eventType === 'BIRTHDAY' || eventType === 'ANNIVERSARY') && (
|
||||
<>
|
||||
<input
|
||||
type="date"
|
||||
className="rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] focus:outline-none"
|
||||
placeholder="Aniversário"
|
||||
value={eventType === 'BIRTHDAY' ? (r.birthday ?? '') : (r.anniversary ?? '')}
|
||||
onChange={e => updateRecipient(r._key, eventType === 'BIRTHDAY' ? 'birthday' : 'anniversary', e.target.value)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{eventType === 'SIGNUP_DAYS' && (
|
||||
<input
|
||||
type="date"
|
||||
className="col-span-2 rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] focus:outline-none"
|
||||
placeholder="Data de cadastro"
|
||||
value={r.signupDate ?? ''}
|
||||
onChange={e => updateRecipient(r._key, 'signupDate', e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{validRecipients.length === 0 && (
|
||||
<div className="flex items-center gap-2 text-xs text-amber-600 bg-amber-50 border border-amber-200 rounded-xl px-3 py-2">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0" />
|
||||
Adicione ao menos um destinatário com número válido.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── STEP 3: confirm ─────────────────────────────────────── */}
|
||||
{step === 3 && (
|
||||
<div className="space-y-4">
|
||||
{saved ? (
|
||||
<div className="flex flex-col items-center justify-center py-10 gap-3">
|
||||
<CheckCircle2 className="w-14 h-14 text-[#10b981]" />
|
||||
<p className="text-base font-semibold text-[#111b21]">Agendamento criado!</p>
|
||||
<p className="text-xs text-[#667781] text-center">O disparo será executado conforme configurado.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-xl bg-[#f8f9fa] border border-gray-200 p-4 space-y-3 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-[#667781]">Nome</span>
|
||||
<span className="font-medium text-[#111b21]">{name}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-[#667781]">Tipo de mensagem</span>
|
||||
<span className="font-medium text-[#111b21]">{typeLabels[messageType]}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-[#667781]">Disparo</span>
|
||||
<span className="font-medium text-[#111b21]">
|
||||
{scheduleType === 'ONCE' && `Uma vez — ${scheduledAt.replace('T', ' ')}`}
|
||||
{scheduleType === 'RECURRING' && `Recorrente — ${cronExpr}`}
|
||||
{scheduleType === 'EVENT' && `Evento: ${eventType}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-[#667781]">Destinatários</span>
|
||||
<span className="font-medium text-[#111b21]">{validRecipients.length} contato{validRecipients.length !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 text-xs text-red-600 bg-red-50 border border-red-200 rounded-xl px-3 py-2">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{!saved && (
|
||||
<div className="px-5 py-3 border-t border-gray-100 flex items-center justify-between shrink-0">
|
||||
<button
|
||||
onClick={() => step > 1 ? setStep(prev => (prev - 1) as any) : onClose()}
|
||||
className="px-4 py-2 rounded-xl text-sm font-medium text-[#667781] hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
{step > 1 ? 'Voltar' : 'Cancelar'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (step === 1) setStep(2)
|
||||
else if (step === 2) setStep(3)
|
||||
else handleSave()
|
||||
}}
|
||||
disabled={
|
||||
(step === 1 && !isStep1Valid()) ||
|
||||
(step === 2 && validRecipients.length === 0) ||
|
||||
saving
|
||||
}
|
||||
className="flex items-center gap-2 px-5 py-2 rounded-xl text-sm font-semibold text-white bg-[#f59e0b] hover:bg-[#d97706] disabled:opacity-50 transition-all active:scale-95"
|
||||
>
|
||||
{saving && <span className="w-4 h-4 border-2 border-white/40 border-t-white rounded-full animate-spin" />}
|
||||
{step < 3 ? 'Continuar' : 'Confirmar e Salvar'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// Liga/desliga a Secretária IA APENAS desta sessão (instância aberta no /wa-inbox).
|
||||
// Rate-limit 1x/hora, motivo obrigatório ao desligar, histórico de quem apertou.
|
||||
// Fica na thin sidebar; abre um modal com o textarea de motivo + histórico.
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Power, X, Loader2, History, AlertTriangle } from 'lucide-react';
|
||||
import { secSessionPowerApi, SessionPower } from '../services/secretariaApi';
|
||||
|
||||
const userName = (() => {
|
||||
try { return JSON.parse(localStorage.getItem('SCOREODONTO_USER_DATA') || '{}')?.nome || 'Usuário'; }
|
||||
catch { return 'Usuário'; }
|
||||
})();
|
||||
|
||||
const fmt = (iso?: string | null) => {
|
||||
if (!iso) return '';
|
||||
try { return new Date(iso).toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }); }
|
||||
catch { return ''; }
|
||||
};
|
||||
|
||||
export const SessaoSecretariaPower: React.FC<{ instanceId: string | null }> = ({ instanceId }) => {
|
||||
const [sp, setSp] = useState<SessionPower | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [reason, setReason] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [erro, setErro] = useState<string | null>(null);
|
||||
|
||||
const carregar = useCallback(async () => {
|
||||
if (!instanceId) return;
|
||||
try { setSp(await secSessionPowerApi.get(instanceId)); } catch { /* silencioso */ }
|
||||
}, [instanceId]);
|
||||
|
||||
useEffect(() => { carregar(); }, [carregar]);
|
||||
|
||||
if (!instanceId) return null;
|
||||
const enabled = sp ? sp.enabled : true;
|
||||
|
||||
const toggle = async () => {
|
||||
if (!sp?.can_toggle) return;
|
||||
const proximo = !enabled;
|
||||
if (proximo === false && !reason.trim()) { setErro('Escreva o motivo para desligar.'); return; }
|
||||
setBusy(true); setErro(null);
|
||||
try {
|
||||
await secSessionPowerApi.set(instanceId, proximo, reason.trim(), userName);
|
||||
setReason('');
|
||||
await carregar();
|
||||
} catch (e: any) {
|
||||
setErro(e?.message || 'Não foi possível alterar.');
|
||||
await carregar();
|
||||
} finally { setBusy(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
title={enabled ? 'Secretária IA ligada nesta sessão — clique para gerenciar' : 'Secretária IA DESLIGADA nesta sessão'}
|
||||
onClick={() => { setErro(null); setOpen(true); }}
|
||||
className={`w-10 h-10 mb-2 rounded-full flex items-center justify-center transition-colors ${
|
||||
enabled ? 'text-emerald-600 hover:bg-[#d9dbdf]/60' : 'text-white bg-red-600 hover:bg-red-700 animate-pulse'
|
||||
}`}
|
||||
>
|
||||
<Power className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="fixed inset-0 bg-black/60 z-[80] flex items-center justify-center backdrop-blur-md p-4" onClick={() => setOpen(false)}>
|
||||
<div className="bg-white w-full max-w-md rounded-[1.5rem] shadow-2xl border border-gray-100 flex flex-col max-h-[90vh] overflow-hidden" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="px-6 py-5 border-b border-gray-50 flex items-center justify-between bg-gradient-to-br from-gray-50 to-white">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-xl ${enabled ? 'bg-emerald-500' : 'bg-red-600'} shadow`}><Power size={18} className="text-white" /></div>
|
||||
<div>
|
||||
<h2 className="text-base font-black text-gray-900 uppercase tracking-tighter leading-none">Secretária desta sessão</h2>
|
||||
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mt-1">{enabled ? 'Ligada' : 'Desligada'} · só este número</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => setOpen(false)} className="p-2 hover:bg-gray-100 rounded-xl text-gray-400"><X size={20} /></button>
|
||||
</div>
|
||||
|
||||
{erro && <div className="px-6 py-2 text-xs font-bold bg-red-50 text-red-600">{erro}</div>}
|
||||
|
||||
<div className="p-6 space-y-4 overflow-y-auto">
|
||||
{!enabled && sp?.reason && (
|
||||
<div className="text-xs bg-red-50 text-red-700 rounded-xl p-3">
|
||||
<span className="font-black">Desligada.</span> Motivo: {sp.reason}
|
||||
{sp.changed_by && <div className="text-red-400 mt-1">por {sp.changed_by} · {fmt(sp.changed_at)}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Motivo (só ao desligar) */}
|
||||
{enabled && (
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">Motivo para desligar</label>
|
||||
<textarea value={reason} onChange={(e) => setReason(e.target.value)} rows={3}
|
||||
placeholder="Ex.: revisando as respostas da IA; horário de pico; treinamento…"
|
||||
className="w-full bg-gray-50 border-2 border-transparent focus:border-red-500 rounded-xl px-3 py-2 text-sm outline-none resize-y" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rate-limit */}
|
||||
{!sp?.can_toggle && (
|
||||
<p className="text-[11px] text-amber-600 font-bold flex items-center gap-1.5"><AlertTriangle size={13} /> Só é possível alternar 1x por hora. Disponível às {fmt(sp?.next_toggle_at)}.</p>
|
||||
)}
|
||||
|
||||
<button onClick={toggle} disabled={busy || !sp?.can_toggle}
|
||||
className={`w-full py-2.5 rounded-xl text-sm font-black uppercase tracking-wide flex items-center justify-center gap-2 disabled:opacity-40 ${
|
||||
enabled ? 'bg-red-600 hover:bg-red-700 text-white' : 'bg-emerald-600 hover:bg-emerald-700 text-white'
|
||||
}`}>
|
||||
{busy ? <Loader2 size={16} className="animate-spin" /> : <Power size={16} />}
|
||||
{enabled ? 'Desligar nesta sessão' : 'Ligar nesta sessão'}
|
||||
</button>
|
||||
|
||||
{/* Histórico */}
|
||||
{!!sp?.history?.length && (
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest flex items-center gap-1.5 mb-2"><History size={12} /> Histórico</p>
|
||||
<div className="space-y-1.5 max-h-40 overflow-y-auto">
|
||||
{sp.history.map((h, i) => (
|
||||
<div key={i} className="text-[11px] flex items-start gap-2">
|
||||
<span className={`mt-1 w-1.5 h-1.5 rounded-full shrink-0 ${h.enabled ? 'bg-emerald-500' : 'bg-red-500'}`} />
|
||||
<div className="min-w-0">
|
||||
<span className="font-bold text-gray-700">{h.enabled ? 'Ligou' : 'Desligou'}</span>
|
||||
<span className="text-gray-400"> · {h.by || '—'} · {fmt(h.at)}</span>
|
||||
{!h.enabled && h.reason && <div className="text-gray-500 leading-snug">{h.reason}</div>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,198 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { ShieldCheck, LayoutGrid, Check, Loader2, Tag, AlertTriangle, Briefcase } from 'lucide-react';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
|
||||
interface SettingsPanelProps {
|
||||
instanceId: string | null;
|
||||
}
|
||||
|
||||
// Engines oferecidas no painel. A 'official' (Baileys 6, que dá 401) fica de fora.
|
||||
// baileys7 → Baileys 7 oficial (a que conecta) → rótulo "API estável"
|
||||
// infinite → InfinityAPI (com botões)
|
||||
// titulo = rótulo genérico (todos os usuários). tituloTec = rótulo técnico
|
||||
// (Baileys/Infinity), exibido apenas para admin/ruibto@gmail.com.
|
||||
const ENGINES: {
|
||||
value: string; titulo: string; tituloTec?: string; desc: string; icon: any;
|
||||
iconCls: string; activeBorder: string; activeBg: string; checkCls: string; btnCls: string;
|
||||
}[] = [
|
||||
{
|
||||
value: 'baileys7', titulo: 'API estável', tituloTec: 'API estável (Baileys 7)',
|
||||
desc: 'Conexão recomendada do WhatsApp',
|
||||
icon: ShieldCheck, iconCls: 'text-emerald-500',
|
||||
activeBorder: 'border-emerald-400', activeBg: 'bg-emerald-50', checkCls: 'text-emerald-600',
|
||||
btnCls: 'bg-emerald-500 hover:bg-emerald-600',
|
||||
},
|
||||
{
|
||||
value: 'infinite', titulo: 'API com botões', tituloTec: 'InfinityAPI',
|
||||
desc: 'Com botões interativos (experimental)',
|
||||
icon: LayoutGrid, iconCls: 'text-amber-500',
|
||||
activeBorder: 'border-amber-400', activeBg: 'bg-amber-50', checkCls: 'text-amber-600',
|
||||
btnCls: 'bg-amber-500 hover:bg-amber-600',
|
||||
},
|
||||
];
|
||||
|
||||
export default function SettingsPanel({ instanceId }: SettingsPanelProps) {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
// Termos técnicos (Baileys/Infinity) só para admin ou ruibto@gmail.com.
|
||||
const canSeeTech = user?.role === 'ADMIN' || user?.email === 'ruibto@gmail.com';
|
||||
const [engine, setEngine] = useState<string | null>(null);
|
||||
const [isBusiness, setIsBusiness] = useState<boolean | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState<string | null>(null);
|
||||
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';
|
||||
const authHeader = (): Record<string, string> => {
|
||||
const t = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
|
||||
return t ? { Authorization: `Bearer ${t}` } : {};
|
||||
};
|
||||
|
||||
// Versão do sistema (servida em /version.json)
|
||||
useEffect(() => {
|
||||
fetch('/version.json')
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then(setVersion)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Engine atual da instância
|
||||
useEffect(() => {
|
||||
if (!instanceId) return;
|
||||
setLoading(true);
|
||||
fetch(`${API_URL}/api/instances`, { headers: authHeader() })
|
||||
.then((r) => (r.ok ? r.json() : []))
|
||||
.then((list) => {
|
||||
const inst = Array.isArray(list) ? list.find((i: any) => i.id === instanceId) : null;
|
||||
setEngine(inst?.engine ?? null);
|
||||
setIsBusiness(inst ? !!inst.isBusiness : null);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [instanceId]);
|
||||
|
||||
const selectEngine = useCallback(async (value: string) => {
|
||||
if (!instanceId || saving || value === engine) return;
|
||||
// Trocar de engine invalida a sessão (credenciais não migram entre APIs):
|
||||
// confirma porque exige escanear o QR Code novamente.
|
||||
if (typeof window !== 'undefined' && !window.confirm(
|
||||
'Trocar de API vai DESCONECTAR a sessão atual.\n\n' +
|
||||
'Você precisará escanear o QR Code novamente para reconectar. Continuar?'
|
||||
)) return;
|
||||
setSaving(value);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/instances/${instanceId}/engine`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeader() },
|
||||
body: JSON.stringify({ engine: value }),
|
||||
});
|
||||
if (res.status === 429) {
|
||||
const d = await res.json().catch(() => ({}));
|
||||
throw new Error(d.error ?? 'Aguarde o intervalo entre trocas.');
|
||||
}
|
||||
if (!res.ok) throw new Error('Falha ao trocar a API.');
|
||||
setEngine(value);
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? 'Erro ao trocar a API.');
|
||||
} finally {
|
||||
setSaving(null);
|
||||
}
|
||||
}, [instanceId, saving, engine]);
|
||||
|
||||
return (
|
||||
<div className="relative z-[1] flex-1 h-full overflow-y-auto bg-[#f0f2f5] p-6 md:p-10">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<h1 className="text-3xl font-light text-[#41525d] mb-2">Configurações</h1>
|
||||
<p className="text-base text-[#667781] mb-4">Escolha a API de conexão do WhatsApp para esta instância.</p>
|
||||
|
||||
<div className="flex items-start gap-3 rounded-xl bg-amber-50 border border-amber-200 p-4 mb-6">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-500 shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-[#5b4b1f]">
|
||||
Trocar de API <strong>desconecta a sessão</strong> — será necessário <strong>escanear o QR Code novamente</strong> para reconectar.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!instanceId && (
|
||||
<p className="text-base text-[#667781] mb-6">Selecione uma instância primeiro.</p>
|
||||
)}
|
||||
|
||||
{/* ── Engines ─────────────────────────────────────────────── */}
|
||||
<div className="flex flex-col gap-4 mb-6">
|
||||
{ENGINES.map((opt) => {
|
||||
const Icon = opt.icon;
|
||||
const ativa = engine === opt.value;
|
||||
const salvando = saving === opt.value;
|
||||
return (
|
||||
<div
|
||||
key={opt.value}
|
||||
className={`rounded-2xl border-2 bg-white p-8 transition-colors ${ativa ? `${opt.activeBorder} ${opt.activeBg}` : 'border-[#e9edef]'}`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`w-14 h-14 rounded-2xl flex items-center justify-center ${ativa ? 'bg-white' : 'bg-[#f0f2f5]'}`}>
|
||||
<Icon className={`w-8 h-8 ${opt.iconCls}`} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-2xl font-semibold text-[#111b21]">{canSeeTech && opt.tituloTec ? opt.tituloTec : opt.titulo}</h2>
|
||||
<p className="text-base text-[#667781]">{opt.desc}</p>
|
||||
</div>
|
||||
{ativa ? (
|
||||
<span className={`flex items-center gap-1 font-medium text-lg ${opt.checkCls}`}>
|
||||
<Check className="w-6 h-6" /> Ativa
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => selectEngine(opt.value)}
|
||||
disabled={!instanceId || !!saving}
|
||||
className={`inline-flex items-center gap-2 rounded-xl text-white text-lg font-medium px-6 py-3 disabled:opacity-60 ${opt.btnCls}`}
|
||||
>
|
||||
{salvando ? <Loader2 className="w-5 h-5 animate-spin" /> : <Icon className="w-5 h-5" />}
|
||||
Ativar
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{loading && <p className="text-sm text-[#8696a0] mb-4">Carregando configuração atual…</p>}
|
||||
{error && <p className="text-sm text-red-500 mb-4">{error}</p>}
|
||||
|
||||
{/* ── Conta Business ──────────────────────────────────────── */}
|
||||
<div className="rounded-2xl border-2 border-[#e9edef] bg-white p-8 mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-14 h-14 rounded-2xl bg-[#f0f2f5] flex items-center justify-center">
|
||||
<Briefcase className="w-8 h-8 text-[#54656f]" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-2xl font-semibold text-[#111b21]">Conta Business</h2>
|
||||
<p className="text-xl font-medium text-[#41525d] mt-1">
|
||||
{isBusiness === null ? '—' : isBusiness ? 'Sim (WhatsApp Business)' : 'Não (conta pessoal)'}
|
||||
</p>
|
||||
<p className="text-xs text-[#8696a0] mt-1">
|
||||
Business App ≠ Business API — botões só são garantidos na Business API oficial.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Versão ──────────────────────────────────────────────── */}
|
||||
<div className="rounded-2xl border-2 border-[#e9edef] bg-white p-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-14 h-14 rounded-2xl bg-[#f0f2f5] flex items-center justify-center">
|
||||
<Tag className="w-8 h-8 text-[#54656f]" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-2xl font-semibold text-[#111b21]">Versão</h2>
|
||||
<p className="text-3xl font-light text-[#41525d] mt-1">{version?.version ?? '—'}</p>
|
||||
{version?.buildCount != null && (
|
||||
<p className="text-base text-[#667781]">build {version.buildCount}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
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 {
|
||||
instanceId: string;
|
||||
jid: string;
|
||||
onSend: (blob: Blob) => void;
|
||||
}
|
||||
|
||||
// Converte URL Wasabi → URL do proxy do backend
|
||||
function stickerSrcUrl(wasabiPath: string): string {
|
||||
const clean = wasabiPath.startsWith('/') ? wasabiPath.slice(1) : wasabiPath;
|
||||
return `${API}/api/storage/view/${clean}`;
|
||||
}
|
||||
|
||||
// Carrega sticker: tenta IndexedDB, depois proxy, depois persiste no cache
|
||||
function useStickerBlob(sticker: StickerRecord) {
|
||||
const [dataUrl, setDataUrl] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getStickerFromCache(sticker.sha256).then((cached) => {
|
||||
if (cancelled) return;
|
||||
if (cached) { setDataUrl(cached); return; }
|
||||
|
||||
// Carrega do proxy e converte para dataURL via canvas
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => {
|
||||
if (cancelled) return;
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = img.naturalWidth || 96;
|
||||
canvas.height = img.naturalHeight || 96;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) { setDataUrl(img.src); return; }
|
||||
ctx.drawImage(img, 0, 0);
|
||||
const url = canvas.toDataURL('image/webp', 0.9);
|
||||
setDataUrl(url);
|
||||
setStickerInCache(sticker.sha256, url);
|
||||
} catch {
|
||||
setDataUrl(img.src);
|
||||
}
|
||||
};
|
||||
img.onerror = () => { if (!cancelled) setDataUrl(null); };
|
||||
img.src = stickerSrcUrl(sticker.wasabiPath);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [sticker.sha256, sticker.wasabiPath]);
|
||||
|
||||
return dataUrl;
|
||||
}
|
||||
|
||||
// Item individual de sticker
|
||||
function StickerItem({
|
||||
sticker,
|
||||
onSend,
|
||||
onToggleFavorite,
|
||||
}: {
|
||||
sticker: StickerRecord;
|
||||
onSend: (s: StickerRecord) => void;
|
||||
onToggleFavorite: (s: StickerRecord) => void;
|
||||
}) {
|
||||
const dataUrl = useStickerBlob(sticker);
|
||||
const [hovering, setHovering] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative group cursor-pointer"
|
||||
onMouseEnter={() => setHovering(true)}
|
||||
onMouseLeave={() => setHovering(false)}
|
||||
onClick={() => dataUrl && onSend(sticker)}
|
||||
>
|
||||
<div className="w-16 h-16 flex items-center justify-center rounded-xl hover:bg-black/5 transition-all active:scale-90 p-1">
|
||||
{dataUrl ? (
|
||||
<img
|
||||
src={dataUrl}
|
||||
alt=""
|
||||
className="w-full h-full object-contain"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-10 bg-gray-100 rounded-lg animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Botão de favoritar — aparece no hover */}
|
||||
{hovering && (
|
||||
<button
|
||||
className={`absolute top-0.5 right-0.5 p-0.5 rounded-full transition-all z-10 ${
|
||||
sticker.isFavorite
|
||||
? 'text-red-400 bg-white/90'
|
||||
: 'text-gray-300 bg-white/80 hover:text-red-400'
|
||||
}`}
|
||||
onClick={(e) => { e.stopPropagation(); onToggleFavorite(sticker); }}
|
||||
title={sticker.isFavorite ? 'Remover dos favoritos' : 'Favoritar'}
|
||||
>
|
||||
<Heart className="w-3 h-3" fill={sticker.isFavorite ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StickerPanel({ instanceId, jid, onSend }: StickerPanelProps) {
|
||||
const [tab, setTab] = useState<'recent' | 'favorites'>('recent');
|
||||
const [stickers, setStickers] = useState<StickerRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const fetchedRef = useRef(false);
|
||||
|
||||
const load = useCallback(async (force = false) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { listVersion, stickers: list } = await stickerApi.list();
|
||||
|
||||
if (!force) {
|
||||
const cached = localStorage.getItem(LIST_VERSION_KEY);
|
||||
if (cached === listVersion) {
|
||||
// versão igual — sem novidades, mas ainda mostra o que temos
|
||||
}
|
||||
localStorage.setItem(LIST_VERSION_KEY, listVersion);
|
||||
}
|
||||
setStickers(list);
|
||||
} catch {
|
||||
// silencioso — sem stickers ainda
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (fetchedRef.current) return;
|
||||
fetchedRef.current = true;
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const handleSend = useCallback(async (sticker: StickerRecord) => {
|
||||
const dataUrl = await getStickerFromCache(sticker.sha256);
|
||||
if (!dataUrl) return;
|
||||
|
||||
// Converte dataURL → Blob → envia
|
||||
const res = await fetch(dataUrl);
|
||||
const blob = await res.blob();
|
||||
onSend(blob);
|
||||
}, [onSend]);
|
||||
|
||||
const handleToggleFavorite = useCallback(async (sticker: StickerRecord) => {
|
||||
const result = await stickerApi.toggleFavorite(sticker.id);
|
||||
setStickers((prev) =>
|
||||
prev.map((s) => s.id === sticker.id ? { ...s, isFavorite: result.isFavorite } : s)
|
||||
);
|
||||
}, []);
|
||||
|
||||
const displayed =
|
||||
tab === 'favorites'
|
||||
? stickers.filter((s) => s.isFavorite)
|
||||
: stickers; // recent = all sorted by lastSeenAt (backend already sorts)
|
||||
|
||||
return (
|
||||
<div className="w-72 bg-white rounded-2xl shadow-2xl border border-gray-100 overflow-hidden">
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-gray-100">
|
||||
<button
|
||||
onClick={() => setTab('recent')}
|
||||
className={`flex-1 flex items-center justify-center gap-1.5 py-2.5 text-xs font-medium transition-colors ${
|
||||
tab === 'recent' ? 'text-[#00a884] border-b-2 border-[#00a884]' : 'text-[#54656f] hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
Recentes
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTab('favorites')}
|
||||
className={`flex-1 flex items-center justify-center gap-1.5 py-2.5 text-xs font-medium transition-colors ${
|
||||
tab === 'favorites' ? 'text-[#00a884] border-b-2 border-[#00a884]' : 'text-[#54656f] hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<Heart className="w-3.5 h-3.5" />
|
||||
Favoritos
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="h-[220px] overflow-y-auto p-2">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full text-gray-300">
|
||||
<Loader2 className="w-6 h-6 animate-spin" />
|
||||
</div>
|
||||
) : displayed.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-2 text-gray-300">
|
||||
{tab === 'favorites' ? (
|
||||
<>
|
||||
<Heart className="w-8 h-8" />
|
||||
<span className="text-xs">Nenhum favorito ainda</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-3xl">🙂</span>
|
||||
<span className="text-xs text-center">
|
||||
As figurinhas que você receber<br />aparecem aqui automaticamente
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-4 gap-0.5">
|
||||
{displayed.map((s) => (
|
||||
<StickerItem
|
||||
key={s.id}
|
||||
sticker={s}
|
||||
onSend={handleSend}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* TemplateLibrary — modal de biblioteca de templates de mensagens.
|
||||
*
|
||||
* Permite:
|
||||
* - Navegar templates por tipo
|
||||
* - Pesquisar por nome
|
||||
* - Selecionar um template para usar (retorna o payload ao pai)
|
||||
* - Deletar templates
|
||||
* - Ver contagem de uso
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import {
|
||||
X, Search, Trash2, Clock, Type, MousePointerClick,
|
||||
Link, List, BarChart2, LayoutGrid, BookOpen, CheckCircle2,
|
||||
} from 'lucide-react'
|
||||
import { templateApi, type MessageTemplate, type TemplateType } from '../services/chatApiService'
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const TYPE_META: Record<TemplateType, { label: string; icon: React.ReactNode; color: string; bg: string }> = {
|
||||
TEXT: { label: 'Texto', icon: <Type className="w-3.5 h-3.5" />, color: '#667781', bg: '#f0f2f5' },
|
||||
BUTTONS: { label: 'Botões', icon: <MousePointerClick className="w-3.5 h-3.5" />, color: '#0ea5e9', bg: '#e0f2fe' },
|
||||
INTERACTIVE: { label: 'CTA', icon: <Link className="w-3.5 h-3.5" />, color: '#8b5cf6', bg: '#ede9fe' },
|
||||
LIST: { label: 'Lista', icon: <List className="w-3.5 h-3.5" />, color: '#f59e0b', bg: '#fef3c7' },
|
||||
POLL: { label: 'Enquete', icon: <BarChart2 className="w-3.5 h-3.5" />, color: '#10b981', bg: '#d1fae5' },
|
||||
CAROUSEL: { label: 'Carrossel', icon: <LayoutGrid className="w-3.5 h-3.5" />, color: '#ef4444', bg: '#fee2e2' },
|
||||
}
|
||||
|
||||
function TypeBadge({ type }: { type: TemplateType }) {
|
||||
const meta = TYPE_META[type]
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-semibold"
|
||||
style={{ color: meta.color, backgroundColor: meta.bg }}>
|
||||
{meta.icon}
|
||||
{meta.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function PayloadPreview({ template }: { template: MessageTemplate }) {
|
||||
const p = template.payload
|
||||
if (template.type === 'TEXT') return <span className="line-clamp-2">{(p as any).text || '—'}</span>
|
||||
if (template.type === 'BUTTONS') return <span className="line-clamp-1">{(p as any).text || ''} [{((p as any).nativeButtons ?? []).length} botões]</span>
|
||||
if (template.type === 'INTERACTIVE') return <span className="line-clamp-1">{(p as any).text || ''} [{((p as any).nativeButtons ?? []).length} CTAs]</span>
|
||||
if (template.type === 'LIST') return <span className="line-clamp-1">{(p as any).text || ''} — {(p as any).nativeList?.buttonText}</span>
|
||||
if (template.type === 'POLL') return <span className="line-clamp-1">📊 {(p as any).poll?.name} ({((p as any).poll?.values ?? []).length} opções)</span>
|
||||
if (template.type === 'CAROUSEL') return <span className="line-clamp-1">🎠 {((p as any).nativeCarousel?.cards ?? []).length} cards</span>
|
||||
return null
|
||||
}
|
||||
|
||||
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSelect: (template: MessageTemplate) => void
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
const ALL = 'ALL'
|
||||
type FilterType = TemplateType | typeof ALL
|
||||
|
||||
export default function TemplateLibrary({ isOpen, onClose, onSelect }: Props) {
|
||||
const [templates, setTemplates] = useState<MessageTemplate[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
const [filter, setFilter] = useState<FilterType>(ALL)
|
||||
const [deleting, setDeleting] = useState<string | null>(null)
|
||||
const [selected, setSelected] = useState<string | null>(null)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
useEffect(() => { setMounted(true) }, [])
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await templateApi.list({
|
||||
...(filter !== ALL ? { type: filter } : {}),
|
||||
...(search ? { search } : {}),
|
||||
})
|
||||
setTemplates(data)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [filter, search])
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) load()
|
||||
}, [isOpen, load])
|
||||
|
||||
const handleDelete = async (e: React.MouseEvent, id: string) => {
|
||||
e.stopPropagation()
|
||||
if (!confirm('Remover este template?')) return
|
||||
setDeleting(id)
|
||||
try {
|
||||
await templateApi.delete(id)
|
||||
setTemplates(prev => prev.filter(t => t.id !== id))
|
||||
} finally {
|
||||
setDeleting(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelect = (template: MessageTemplate) => {
|
||||
setSelected(template.id)
|
||||
setTimeout(() => {
|
||||
onSelect(template)
|
||||
setSelected(null)
|
||||
onClose()
|
||||
}, 200)
|
||||
}
|
||||
|
||||
const filterTabs: Array<{ key: FilterType; label: string }> = [
|
||||
{ key: ALL, label: 'Todos' },
|
||||
...Object.entries(TYPE_META).map(([k, v]) => ({ key: k as TemplateType, label: v.label })),
|
||||
]
|
||||
|
||||
if (!mounted) return null
|
||||
|
||||
return createPortal(
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30, scale: 0.97 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 30, scale: 0.97 }}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
||||
className="bg-white rounded-2xl shadow-2xl w-full max-w-2xl max-h-[85vh] flex flex-col overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100 shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-[#e0f2fe] flex items-center justify-center">
|
||||
<BookOpen className="w-5 h-5 text-[#0ea5e9]" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-[#111b21]">Biblioteca de Templates</h2>
|
||||
<p className="text-xs text-[#667781]">{templates.length} template{templates.length !== 1 ? 's' : ''} salvos</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 rounded-full hover:bg-gray-100 text-gray-400 transition-colors">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="px-5 py-3 border-b border-gray-100 shrink-0">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-2.5 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
className="w-full pl-9 pr-4 py-2 rounded-xl bg-[#f0f2f5] border-none text-sm focus:outline-none focus:ring-2 focus:ring-[#00a884]/30"
|
||||
placeholder="Pesquisar templates..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter tabs */}
|
||||
<div className="px-5 py-2 flex gap-1.5 overflow-x-auto shrink-0 border-b border-gray-100 pb-2">
|
||||
{filterTabs.map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setFilter(tab.key)}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-semibold whitespace-nowrap transition-all ${
|
||||
filter === tab.key
|
||||
? 'bg-[#111b21] text-white'
|
||||
: 'bg-[#f0f2f5] text-[#667781] hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Template list */}
|
||||
<div className="flex-1 overflow-y-auto p-5">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<span className="w-6 h-6 border-2 border-gray-200 border-t-[#00a884] rounded-full animate-spin" />
|
||||
</div>
|
||||
) : templates.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<BookOpen className="w-12 h-12 text-gray-200 mb-3" />
|
||||
<p className="text-sm font-medium text-[#667781]">Nenhum template encontrado</p>
|
||||
<p className="text-xs text-gray-400 mt-1">Crie um template no compositor de mensagens</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{templates.map(tmpl => (
|
||||
<motion.button
|
||||
key={tmpl.id}
|
||||
layout
|
||||
onClick={() => handleSelect(tmpl)}
|
||||
className={`w-full text-left rounded-xl border p-4 transition-all hover:shadow-md active:scale-[0.99] group ${
|
||||
selected === tmpl.id
|
||||
? 'border-[#00a884] bg-[#e7f8f2]'
|
||||
: 'border-gray-200 hover:border-gray-300 bg-white'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<TypeBadge type={tmpl.type} />
|
||||
{tmpl.usageCount > 0 && (
|
||||
<span className="flex items-center gap-1 text-[10px] text-gray-400">
|
||||
<Clock className="w-3 h-3" />
|
||||
{tmpl.usageCount}x usado
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-[#111b21] truncate">{tmpl.name}</p>
|
||||
{tmpl.description && (
|
||||
<p className="text-xs text-[#667781] mt-0.5 line-clamp-1">{tmpl.description}</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-400 mt-1.5 line-clamp-2">
|
||||
<PayloadPreview template={tmpl} />
|
||||
</p>
|
||||
{tmpl.tags.length > 0 && (
|
||||
<div className="flex gap-1 mt-2 flex-wrap">
|
||||
{tmpl.tags.map(tag => (
|
||||
<span key={tag} className="px-1.5 py-0.5 bg-gray-100 text-[10px] text-gray-500 rounded">
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{selected === tmpl.id ? (
|
||||
<CheckCircle2 className="w-5 h-5 text-[#00a884]" />
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={(e) => handleDelete(e, tmpl.id)}
|
||||
disabled={deleting === tmpl.id}
|
||||
className="p-1.5 rounded-full opacity-0 group-hover:opacity-100 hover:bg-red-50 text-gray-400 hover:text-red-500 transition-all"
|
||||
>
|
||||
{deleting === tmpl.id
|
||||
? <span className="w-3.5 h-3.5 border border-gray-300 border-t-gray-500 rounded-full animate-spin block" />
|
||||
: <Trash2 className="w-3.5 h-3.5" />
|
||||
}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
MessageCircle,
|
||||
Megaphone,
|
||||
Users,
|
||||
Settings,
|
||||
BrainCircuit,
|
||||
} from 'lucide-react';
|
||||
import { getAvatarProxyUrl } from '../utils/inboxUtils';
|
||||
import { SessaoSecretariaPower } from './SessaoSecretariaPower';
|
||||
|
||||
export type TabType = 'chats' | 'broadcasts' | 'groups' | 'settings';
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export default function ThinSidebar({ activeTab, setActiveTab, activeInstance, onNavigate }: ThinSidebarProps) {
|
||||
const isConnected = activeInstance?.status === 'connected' || activeInstance?.status === 'open';
|
||||
|
||||
const renderIcon = (id: TabType, IconComponent: any, title: string) => {
|
||||
const isActive = activeTab === id;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
title={title}
|
||||
onClick={() => {
|
||||
if (id === 'groups') {
|
||||
onNavigate?.('wa-sessions');
|
||||
} else {
|
||||
setActiveTab(id);
|
||||
}
|
||||
}}
|
||||
className={`w-10 h-10 mb-2 rounded-full flex items-center justify-center transition-colors ${
|
||||
isActive ? 'bg-[#d9dbdf]' : 'hover:bg-[#d9dbdf]/60'
|
||||
}`}
|
||||
>
|
||||
<IconComponent
|
||||
className={`w-6 h-6 ${isActive ? 'text-[#111b21]' : 'text-[#54656f]'}`}
|
||||
fill={isActive ? 'currentColor' : 'none'}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-[60px] h-full bg-[#f0f2f5] border-r border-[#d1d7db] flex flex-col items-center py-4 flex-shrink-0 z-30">
|
||||
{/* Top Icons */}
|
||||
<div className="flex-1 w-full flex flex-col items-center">
|
||||
{renderIcon('chats', MessageCircle, 'Conversas')}
|
||||
{renderIcon('broadcasts', Megaphone, 'Transmissões')}
|
||||
{renderIcon('groups', Users, 'Sessões')}
|
||||
{/* Secretária IA — abre a tela de configuração da secretária virtual */}
|
||||
<button
|
||||
title="Secretária IA"
|
||||
onClick={() => onNavigate?.('wa-secretaria')}
|
||||
className="w-10 h-10 mb-2 rounded-full flex items-center justify-center transition-colors hover:bg-[#d9dbdf]/60"
|
||||
>
|
||||
<BrainCircuit className="w-6 h-6 text-[#54656f]" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Bottom Icons */}
|
||||
<div className="w-full flex flex-col items-center pb-2">
|
||||
{/* Liga/desliga a Secretária IA APENAS desta sessão (número atual) */}
|
||||
<SessaoSecretariaPower instanceId={activeInstance?.instance ?? null} />
|
||||
{/* Configurações — abre o painel na área central (aba 'settings') */}
|
||||
{renderIcon('settings', Settings, 'Configurações')}
|
||||
|
||||
<div className="relative mt-2 p-1 cursor-pointer">
|
||||
<div className="w-8 h-8 rounded-full overflow-hidden bg-gray-200 border border-gray-300">
|
||||
{(() => {
|
||||
// Avatar da instância SEMPRE via proxy do backend. Nunca usar a URL
|
||||
// crua do CDN do WhatsApp (pps.whatsapp.net) como fallback: ela expira
|
||||
// e o navegador recebe 403 (hotlink). Sem proxy resolvível → ícone.
|
||||
const avatarSrc = activeInstance?.instance
|
||||
? getAvatarProxyUrl({
|
||||
instance_name: activeInstance.instance,
|
||||
remote_jid: activeInstance.phone ? `${activeInstance.phone}@s.whatsapp.net` : null,
|
||||
})
|
||||
: null;
|
||||
return avatarSrc ? (
|
||||
<img
|
||||
src={avatarSrc}
|
||||
alt="Status"
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => { e.currentTarget.style.display = 'none'; }}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-[#dfe5e7] text-[#54656f]">
|
||||
<Users className="w-5 h-5" />
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
{/* Status indicator badge */}
|
||||
<div
|
||||
className={`absolute bottom-0.5 right-0.5 w-3 h-3 rounded-full border-2 border-[#f0f2f5] ${
|
||||
isConnected ? 'bg-emerald-500' : 'bg-red-500'
|
||||
}`}
|
||||
title={isConnected ? 'Conectado' : 'Desconectado'}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// Button do motor NewWhats (de @/components/ui) — portado para o satélite.
|
||||
import React from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger' | 'premium';
|
||||
type ButtonSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
loading?: boolean;
|
||||
icon?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const variantClasses: Record<ButtonVariant, string> = {
|
||||
primary: 'bg-brand-500 text-white font-bold shadow-lg shadow-brand-500/20 hover:bg-brand-600 active:scale-[0.98]',
|
||||
secondary: 'bg-white/5 border border-white/10 text-white font-semibold hover:bg-white/10 backdrop-blur-sm',
|
||||
ghost: 'text-slate-400 hover:text-white hover:bg-white/5',
|
||||
danger: 'bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20',
|
||||
premium: 'bg-gradient-to-r from-brand-500 to-blue-500 text-white font-bold shadow-glow-brand hover:shadow-lg active:scale-[0.98] relative overflow-hidden',
|
||||
};
|
||||
|
||||
const sizeClasses: Record<ButtonSize, string> = {
|
||||
sm: 'px-3 py-1.5 text-xs rounded-lg gap-1.5',
|
||||
md: 'px-4 py-2.5 text-sm rounded-xl gap-2',
|
||||
lg: 'px-6 py-3.5 text-sm rounded-xl gap-2',
|
||||
};
|
||||
|
||||
export function Button({
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
loading,
|
||||
icon,
|
||||
children,
|
||||
className = '',
|
||||
disabled,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
disabled={disabled || loading}
|
||||
className={`
|
||||
inline-flex items-center justify-center transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed
|
||||
${variantClasses[variant]} ${sizeClasses[size]} ${className}
|
||||
`}
|
||||
{...props}
|
||||
>
|
||||
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : icon ? icon : null}
|
||||
{children}
|
||||
{variant === 'premium' && (
|
||||
<div className="absolute inset-0 -translate-x-full animate-shimmer bg-gradient-to-r from-transparent via-white/10 to-transparent pointer-events-none" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default Button;
|
||||
@@ -0,0 +1,147 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { getAvatarInitials, getAvatarColor } from '../../utils/inboxUtils';
|
||||
import { getAvatarFromCache, setAvatarInCache } from '../../hooks/useAvatarCache';
|
||||
import { avatarApi } from '../../services/chatApiService';
|
||||
|
||||
interface AvatarProps {
|
||||
chat: any;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Avatar({ chat, size = 46, className = '' }: AvatarProps) {
|
||||
const [displayUrl, setDisplayUrl] = useState<string | null>(null);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const [showInitials, setShowInitials] = useState(false);
|
||||
const cacheAttempted = useRef(false);
|
||||
const refreshTried = useRef(false);
|
||||
|
||||
const imageUrl = chat?.avatar_url || null;
|
||||
const version = chat?.avatar_version || null;
|
||||
const remoteJid = chat?.remote_jid || '';
|
||||
const instanceName = chat?.instance_name || '';
|
||||
|
||||
// Quando jid ou version muda, tenta servir do cache IndexedDB antes de fazer request HTTP.
|
||||
// Se não houver cache (primeira vez), usa a URL do proxy normalmente.
|
||||
useEffect(() => {
|
||||
cacheAttempted.current = false;
|
||||
refreshTried.current = false;
|
||||
setIsLoaded(false);
|
||||
|
||||
if (!imageUrl) {
|
||||
setShowInitials(true);
|
||||
setDisplayUrl(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setShowInitials(false);
|
||||
|
||||
// Sem versão (ex.: satélite, cujo ext /inbox não envia avatar_version) →
|
||||
// usa a URL do CDN direto, sem cache IndexedDB (o cache depende da versão
|
||||
// para invalidar). A imagem ainda carrega normalmente.
|
||||
if (!version) {
|
||||
setDisplayUrl(imageUrl);
|
||||
cacheAttempted.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Tenta cache IndexedDB — resultado síncrono do ponto de vista do usuário
|
||||
getAvatarFromCache(remoteJid, version).then((cached) => {
|
||||
if (cached) {
|
||||
setDisplayUrl(cached);
|
||||
setIsLoaded(true); // já temos os bytes — sem skeleton
|
||||
} else {
|
||||
setDisplayUrl(imageUrl); // busca pelo proxy normalmente
|
||||
}
|
||||
cacheAttempted.current = true;
|
||||
}).catch(() => {
|
||||
setDisplayUrl(imageUrl);
|
||||
cacheAttempted.current = true;
|
||||
});
|
||||
}, [remoteJid, version, imageUrl]);
|
||||
|
||||
const handleLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
|
||||
setIsLoaded(true);
|
||||
|
||||
// Salva no IndexedDB após primeira carga bem-sucedida do proxy.
|
||||
// Só salva se a versão é conhecida e o displayUrl não é já um dataUrl (evita re-cache).
|
||||
if (!version || !remoteJid || !displayUrl || displayUrl.startsWith('data:')) return;
|
||||
try {
|
||||
const img = e.currentTarget;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = img.naturalWidth || size;
|
||||
canvas.height = img.naturalHeight || size;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.drawImage(img, 0, 0);
|
||||
const dataUrl = canvas.toDataURL('image/jpeg', 0.85);
|
||||
setAvatarInCache(remoteJid, version, dataUrl);
|
||||
} catch { /* CORS ou canvas tainted — ignora, cache não é crítico */ }
|
||||
};
|
||||
|
||||
const handleError = async () => {
|
||||
// Self-heal: a URL do CDN do WhatsApp pode ter expirado (404). Tenta UMA
|
||||
// vez re-buscar a foto atual pela conexão viva do motor antes de desistir.
|
||||
if (!refreshTried.current && instanceName && remoteJid) {
|
||||
refreshTried.current = true;
|
||||
const fresh = await avatarApi.refresh(instanceName, remoteJid);
|
||||
if (fresh && fresh !== displayUrl) {
|
||||
setIsLoaded(false);
|
||||
setDisplayUrl(fresh);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setShowInitials(true);
|
||||
setDisplayUrl(null);
|
||||
};
|
||||
|
||||
const initials = useMemo(() => getAvatarInitials(chat), [chat]);
|
||||
const bgColor = useMemo(
|
||||
() => getAvatarColor(chat?.remote_jid || chat?.phone || chat?.displayName || 'anon'),
|
||||
[chat]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative flex-shrink-0 rounded-full overflow-hidden bg-gray-100 flex items-center justify-center border border-black/5 ${className}`}
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
<AnimatePresence mode="wait">
|
||||
{!showInitials && displayUrl ? (
|
||||
<motion.img
|
||||
key={displayUrl}
|
||||
src={displayUrl}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
crossOrigin="anonymous"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: isLoaded ? 1 : 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
onLoad={handleLoad}
|
||||
onError={handleError}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<motion.div
|
||||
key="initials"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="w-full h-full flex items-center justify-center text-white font-semibold"
|
||||
style={{ backgroundColor: bgColor, fontSize: size * 0.4 }}
|
||||
>
|
||||
{initials}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Skeleton apenas quando buscando pelo proxy (não quando servindo do cache) */}
|
||||
{!isLoaded && !showInitials && displayUrl && !displayUrl.startsWith('data:') && (
|
||||
<div className="absolute inset-0 bg-gray-200 animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Check, AlertCircle } from 'lucide-react';
|
||||
|
||||
interface ToastProps {
|
||||
message: string;
|
||||
type: 'success' | 'error';
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
export default function Toast({ message, type, visible }: ToastProps) {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{visible && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 40 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 40 }}
|
||||
className={`
|
||||
fixed top-6 right-6 z-[300] flex items-center gap-3 px-5 py-3.5 rounded-2xl
|
||||
glass-elevated shadow-premium text-sm font-semibold text-white
|
||||
${type === 'success' ? 'border-l-4 border-l-emerald-500' : 'border-l-4 border-l-red-500'}
|
||||
`}
|
||||
>
|
||||
{type === 'success' ? (
|
||||
<Check className="w-4 h-4 text-emerald-400" />
|
||||
) : (
|
||||
<AlertCircle className="w-4 h-4 text-red-400" />
|
||||
)}
|
||||
{message}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* useChatFilter — gerencia toda a lógica de filtro/escopo de chats no inbox.
|
||||
*
|
||||
* Extrai de inbox/index.tsx:
|
||||
* - searchTerm, activeFilter, chatScope, favoriteJids
|
||||
* - derivados: favoriteSet, filterCounts, chatsByScope, isFavoriteChat
|
||||
*/
|
||||
import { useState, useMemo, useCallback } from 'react'
|
||||
import { normalizeJid } from '../../utils/inboxUtils'
|
||||
import type { NewWhatsChat } from '../../types/inboxTypes'
|
||||
|
||||
export type ChatScope = 'inbox' | 'favorites' | 'archived'
|
||||
|
||||
export interface CustomChatList { id: string; name: string; jids: string[] }
|
||||
|
||||
export function useChatFilter(chatsStore: NewWhatsChat[]) {
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [activeFilter, setActiveFilter] = useState('all')
|
||||
const [chatScope, setChatScope] = useState<ChatScope>('inbox')
|
||||
const [favoriteJids, setFavoriteJids] = useState<string[]>([])
|
||||
const [customLists, setCustomLists] = useState<CustomChatList[]>([])
|
||||
const [activeCustomListId, setActiveCustomListId] = useState<string | null>(null)
|
||||
|
||||
// ── Derivados ──────────────────────────────────────────────────────────────
|
||||
|
||||
const favoriteSet = useMemo(() => new Set(favoriteJids), [favoriteJids])
|
||||
|
||||
const filterCounts = useMemo(() => ({
|
||||
all: chatsStore.length,
|
||||
unread: chatsStore.filter(c => c.unread_count > 0).length,
|
||||
groups: chatsStore.filter(c => c.remote_jid.endsWith('@g.us')).length,
|
||||
pinned: chatsStore.filter(c => c.is_pinned).length,
|
||||
archived: chatsStore.filter(c => c.is_archived).length,
|
||||
}), [chatsStore])
|
||||
|
||||
const chatsByScope = useMemo(() => {
|
||||
let base = chatsStore
|
||||
if (chatScope === 'favorites') {
|
||||
base = base.filter(c => favoriteSet.has(normalizeJid(c.remote_jid) || c.remote_jid))
|
||||
} else if (chatScope === 'archived') {
|
||||
base = base.filter(c => Boolean(c.is_archived))
|
||||
} else {
|
||||
base = base.filter(c => !Boolean(c.is_archived))
|
||||
}
|
||||
|
||||
if (activeFilter === 'unread') return base.filter(c => c.unread_count > 0)
|
||||
if (activeFilter === 'groups') return base.filter(c => c.remote_jid.endsWith('@g.us'))
|
||||
if (activeFilter === 'pinned') return base.filter(c => c.is_pinned)
|
||||
return base
|
||||
}, [chatScope, chatsStore, favoriteSet, activeFilter])
|
||||
|
||||
const isFavoriteChat = useCallback(
|
||||
(chat: NewWhatsChat) => favoriteSet.has(normalizeJid(chat.remote_jid) || chat.remote_jid),
|
||||
[favoriteSet]
|
||||
)
|
||||
|
||||
const toggleFavorite = useCallback((jid: string) => {
|
||||
const normalized = normalizeJid(jid) || jid
|
||||
setFavoriteJids(prev =>
|
||||
prev.includes(normalized) ? prev.filter(j => j !== normalized) : [...prev, normalized]
|
||||
)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
// Estado
|
||||
searchTerm,
|
||||
activeFilter,
|
||||
chatScope,
|
||||
favoriteJids,
|
||||
customLists,
|
||||
activeCustomListId,
|
||||
|
||||
// Setters
|
||||
setSearchTerm,
|
||||
setActiveFilter,
|
||||
setChatScope,
|
||||
setCustomLists,
|
||||
setActiveCustomListId,
|
||||
|
||||
// Derivados
|
||||
favoriteSet,
|
||||
filterCounts,
|
||||
chatsByScope,
|
||||
isFavoriteChat,
|
||||
toggleFavorite,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* useChatWindow — gerencia o estado e handlers da área de chat ativa.
|
||||
*
|
||||
* Extrai de inbox/index.tsx:
|
||||
* - scroll + carregamento de histórico
|
||||
* - indicador de digitação
|
||||
* - reply / reaction
|
||||
* - envio de mídia e áudio
|
||||
* - sync de avatar
|
||||
* - abertura de perfil / menu de chat
|
||||
*/
|
||||
import { useState, useRef, useCallback } from 'react'
|
||||
import { mediaApi } from '../../services/chatApiService'
|
||||
import type { NewWhatsChat } from '../../types/inboxTypes'
|
||||
import type { Message } from '../../types/inboxTypes'
|
||||
|
||||
interface UseChatWindowOptions {
|
||||
selectedChat: NewWhatsChat | null
|
||||
activeInstanceId: string | null
|
||||
onLoadMoreHistory: () => Promise<{ hasMore: boolean }>
|
||||
}
|
||||
|
||||
export function useChatWindow({
|
||||
selectedChat,
|
||||
activeInstanceId,
|
||||
onLoadMoreHistory,
|
||||
}: UseChatWindowOptions) {
|
||||
// ── Refs ────────────────────────────────────────────────────────────────────
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const isTypingRef = useRef(false)
|
||||
const typingTimeout = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
// ── Estado ──────────────────────────────────────────────────────────────────
|
||||
const [isTyping, setIsTyping] = useState(false)
|
||||
const [replyingTo, setReplyingTo] = useState<Message | null>(null)
|
||||
const [reactionPickerFor, setReactionPickerFor] = useState<string | null>(null)
|
||||
const [hasMoreHistory, setHasMoreHistory] = useState(true)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [isProfileOpen, setIsProfileOpen] = useState(false)
|
||||
const [isSyncingAvatar, setIsSyncingAvatar] = useState(false)
|
||||
const [isChatMenuOpen, setIsChatMenuOpen] = useState(false)
|
||||
|
||||
// ── Handlers ────────────────────────────────────────────────────────────────
|
||||
|
||||
const handleTyping = useCallback(() => {
|
||||
if (!selectedChat) return
|
||||
if (!isTypingRef.current) {
|
||||
isTypingRef.current = true
|
||||
setIsTyping(true)
|
||||
}
|
||||
if (typingTimeout.current) clearTimeout(typingTimeout.current)
|
||||
typingTimeout.current = setTimeout(() => {
|
||||
isTypingRef.current = false
|
||||
setIsTyping(false)
|
||||
}, 3000)
|
||||
}, [selectedChat])
|
||||
|
||||
const handleScroll = useCallback(async () => {
|
||||
const el = scrollRef.current
|
||||
if (!el || !hasMoreHistory || loadingMore) return
|
||||
if (el.scrollTop < 100) {
|
||||
setLoadingMore(true)
|
||||
const { hasMore } = await onLoadMoreHistory()
|
||||
setHasMoreHistory(hasMore)
|
||||
setLoadingMore(false)
|
||||
}
|
||||
}, [hasMoreHistory, loadingMore, onLoadMoreHistory])
|
||||
|
||||
const handleSelectChat = useCallback(() => {
|
||||
setHasMoreHistory(true)
|
||||
}, [])
|
||||
|
||||
const handleReactToMessage = useCallback((_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
|
||||
try {
|
||||
await mediaApi.sendMedia(activeInstanceId, selectedChat.remote_jid, file, caption || undefined, undefined)
|
||||
} catch (err) {
|
||||
console.error('Erro ao enviar mídia:', err)
|
||||
}
|
||||
}, [selectedChat, activeInstanceId])
|
||||
|
||||
const handleSendAudio = useCallback(async (blob: Blob) => {
|
||||
if (!selectedChat || !activeInstanceId) return
|
||||
try {
|
||||
await mediaApi.sendAudio(activeInstanceId, selectedChat.remote_jid, blob)
|
||||
} catch (err) {
|
||||
console.error('Erro ao enviar áudio:', err)
|
||||
}
|
||||
}, [selectedChat, activeInstanceId])
|
||||
|
||||
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}` } }
|
||||
)
|
||||
} finally {
|
||||
setIsSyncingAvatar(false)
|
||||
}
|
||||
}, [selectedChat, activeInstanceId])
|
||||
|
||||
const toggleReactionPicker = useCallback((id: string) => {
|
||||
setReactionPickerFor(prev => prev === id ? null : id)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
// Refs
|
||||
scrollRef,
|
||||
|
||||
// Estado
|
||||
isTyping,
|
||||
replyingTo,
|
||||
reactionPickerFor,
|
||||
hasMoreHistory,
|
||||
loadingMore,
|
||||
isProfileOpen,
|
||||
isSyncingAvatar,
|
||||
isChatMenuOpen,
|
||||
|
||||
// Setters simples
|
||||
setReplyingTo,
|
||||
setIsProfileOpen,
|
||||
setIsChatMenuOpen,
|
||||
|
||||
// Handlers
|
||||
handleTyping,
|
||||
handleScroll,
|
||||
handleSelectChat,
|
||||
handleReactToMessage,
|
||||
handleSendMedia,
|
||||
handleSendAudio,
|
||||
handleSyncAvatar,
|
||||
toggleReactionPicker,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* useInstanceSelector — gerencia seleção e troca de instâncias WhatsApp.
|
||||
*
|
||||
* Extrai toda a lógica de instâncias do inbox/index.tsx.
|
||||
* O componente visual consome apenas as variáveis retornadas.
|
||||
*/
|
||||
import { useRef, useCallback, useMemo, useEffect } from 'react'
|
||||
import { useInstanceStore } from '../../store/instanceStore'
|
||||
import { useChatStore } from '../../store/chatStore'
|
||||
import { socketService } from '../../services/socketService'
|
||||
|
||||
// ─── Tipos ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface LegacyInstance {
|
||||
instance: string
|
||||
nome: string
|
||||
status: string
|
||||
phone: string | null
|
||||
avatar: string | null
|
||||
profilePictureUrl: string | null
|
||||
}
|
||||
|
||||
export interface MappedInstance {
|
||||
id: string
|
||||
nome: string
|
||||
avatar: string | null
|
||||
phone: string | null
|
||||
}
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useInstanceSelector() {
|
||||
const instances = useInstanceStore((s) => s.instances)
|
||||
const activeId = useInstanceStore((s) => s.activeInstanceId)
|
||||
const setActiveId = useInstanceStore((s) => s.setActiveInstance)
|
||||
const loadInstances = useInstanceStore((s) => s.loadInstances)
|
||||
const connectInstance = useInstanceStore((s) => s.connectInstance)
|
||||
const qrByInstance = useInstanceStore((s) => s.qrByInstance)
|
||||
|
||||
const resetChats = useChatStore((s) => s.setChats)
|
||||
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// ── Formato legado esperado pelo JSX do inbox ──────────────────────────────
|
||||
|
||||
const instancesLegacy = useMemo<LegacyInstance[]>(() =>
|
||||
instances.map((i) => ({
|
||||
instance: i.id,
|
||||
nome: i.name,
|
||||
status: i.status === 'CONNECTED' ? 'connected' : i.status.toLowerCase(),
|
||||
phone: i.phone,
|
||||
avatar: i.avatar ?? null,
|
||||
profilePictureUrl: i.avatar ?? null,
|
||||
})),
|
||||
[instances]
|
||||
)
|
||||
|
||||
const activeInstance = useMemo<LegacyInstance | null>(
|
||||
() => instancesLegacy.find((i) => i.instance === activeId) ?? null,
|
||||
[instancesLegacy, activeId]
|
||||
)
|
||||
|
||||
// Versão com `id` usada por alguns componentes internos
|
||||
const mappedActiveInstance = useMemo<MappedInstance | null>(
|
||||
() => activeInstance
|
||||
? { id: activeInstance.instance, nome: activeInstance.nome, avatar: activeInstance.avatar ?? null, phone: activeInstance.phone }
|
||||
: null,
|
||||
[activeInstance]
|
||||
)
|
||||
|
||||
// Apenas instâncias conectadas (para o dropdown de troca rápida)
|
||||
const connectedInstances = useMemo<MappedInstance[]>(() =>
|
||||
instances
|
||||
.filter((i) => i.status === 'CONNECTED')
|
||||
.map((i) => ({ id: i.id, nome: i.name, avatar: i.avatar ?? null, phone: i.phone })),
|
||||
[instances]
|
||||
)
|
||||
|
||||
const isConnected = activeInstance?.status === 'connected'
|
||||
|
||||
// ── Auto-select primeira instância disponível ──────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeId && instances.length > 0) {
|
||||
const first = instances[0]
|
||||
setActiveId(first.id)
|
||||
socketService.joinInstance(first.id)
|
||||
}
|
||||
}, [instances, activeId, setActiveId])
|
||||
|
||||
// ── Troca de instância ────────────────────────────────────────────────────
|
||||
|
||||
const selectInstance = useCallback((legacyInstance: LegacyInstance | { instance: string }) => {
|
||||
const id = legacyInstance.instance
|
||||
if (id === activeId) return
|
||||
resetChats([])
|
||||
setActiveId(id)
|
||||
socketService.joinInstance(id)
|
||||
}, [activeId, setActiveId, resetChats])
|
||||
|
||||
// ── Refresh ────────────────────────────────────────────────────────────────
|
||||
|
||||
const refreshInstances = useCallback(async () => {
|
||||
await loadInstances()
|
||||
}, [loadInstances])
|
||||
|
||||
const handleConnect = useCallback(async (instanceId: string) => {
|
||||
await connectInstance(instanceId)
|
||||
}, [connectInstance])
|
||||
|
||||
return {
|
||||
// Estado
|
||||
instances: instancesLegacy,
|
||||
activeInstance,
|
||||
mappedActiveInstance,
|
||||
connectedInstances,
|
||||
isConnected,
|
||||
qrByInstance,
|
||||
activeInstanceId: activeId,
|
||||
|
||||
// Refs (para click-outside no dropdown)
|
||||
dropdownRef,
|
||||
|
||||
// Handlers
|
||||
selectInstance,
|
||||
refreshInstances,
|
||||
handleConnect,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* useMessageInput — gerencia o estado e envio da barra de mensagem.
|
||||
*
|
||||
* Extrai de inbox/index.tsx:
|
||||
* - newMessage + setNewMessage
|
||||
* - handleSendMessage
|
||||
*/
|
||||
import { useState, useCallback } from 'react'
|
||||
import type { NewWhatsChat } from '../../types/inboxTypes'
|
||||
|
||||
interface UseMessageInputOptions {
|
||||
selectedChat: NewWhatsChat | null
|
||||
replyingTo?: { message_id: string } | null
|
||||
onClearReply?: () => void
|
||||
onSendText: (text: string, mentions?: string[], replyToMessageId?: string) => Promise<void>
|
||||
}
|
||||
|
||||
export function useMessageInput({ selectedChat, replyingTo, onClearReply, onSendText }: UseMessageInputOptions) {
|
||||
const [newMessage, setNewMessage] = useState('')
|
||||
const [mentions, setMentions] = useState<string[]>([])
|
||||
|
||||
const handleSendMessage = useCallback(async (e?: React.FormEvent) => {
|
||||
e?.preventDefault()
|
||||
if (!selectedChat || !newMessage.trim()) return
|
||||
const text = newMessage
|
||||
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])
|
||||
|
||||
return {
|
||||
newMessage,
|
||||
setNewMessage,
|
||||
mentions,
|
||||
setMentions,
|
||||
handleSendMessage,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* useNewInboxBridge — adapta chatStore + instanceStore para o formato
|
||||
* que os componentes de display legados já consomem.
|
||||
* Nenhum componente visual precisa ser alterado.
|
||||
*/
|
||||
import { useMemo, useCallback, useEffect, useRef } from 'react'
|
||||
import { useChatStore } from '../../store/chatStore'
|
||||
import { useInstanceStore } from '../../store/instanceStore'
|
||||
import { chatApi, messageApi, reconcileApi } from '../../services/chatApiService'
|
||||
import { socketService } from '../../services/socketService'
|
||||
import { formatPhone } from '../../utils/inboxUtils'
|
||||
import type { NewWhatsChat } from '../../types/inboxTypes'
|
||||
|
||||
// ─── Mapeamentos legado ───────────────────────────────────────────────────────
|
||||
|
||||
function chatToLegacy(c: ReturnType<typeof useChatStore.getState>['chats'][0]): NewWhatsChat {
|
||||
const isGroup = c.jid.endsWith('@g.us')
|
||||
const jidUser = c.jid.split('@')[0]
|
||||
|
||||
// Telefone formatado — sempre passa por formatPhone para normalizar E.164 → exibição
|
||||
// c.contactPhone é o número cru do DB (ex: "5511999887766"); formatPhone aplica máscara
|
||||
const formattedPhone = isGroup ? jidUser : formatPhone(c.contactPhone ?? jidUser, c.jid)
|
||||
|
||||
// 1. Nome de Exibição
|
||||
// Regra estrita: grupos usam SEMPRE o subject (c.name) — nunca o
|
||||
// pushName/contactName do último participante que enviou mensagem.
|
||||
let displayName = isGroup
|
||||
? (c.name || 'Grupo')
|
||||
: (c.contactName || c.name || formattedPhone || jidUser)
|
||||
|
||||
// Limpeza final: se por algum motivo ainda vier o JID completo, removemos o sufixo
|
||||
if (displayName.includes('@')) {
|
||||
displayName = displayName.split('@')[0]
|
||||
}
|
||||
|
||||
// 2. Avatar — o motor (ext /inbox) já devolve a URL pública do CDN do
|
||||
// WhatsApp (pps.whatsapp.net) em contactAvatarUrl; usamos direto (o <img>
|
||||
// carrega cross-origin sem CORS). Quando é null, Avatar.tsx mostra iniciais.
|
||||
const avatar_url: string | null = c.contactAvatarUrl ?? null
|
||||
|
||||
const avatar_version: string | null = c.contactAvatarVersion ?? null
|
||||
|
||||
return {
|
||||
id: c.id as any,
|
||||
instance_name: c.instanceId,
|
||||
remote_jid: c.jid,
|
||||
phone: formattedPhone,
|
||||
lead_name: c.contactName,
|
||||
avatar_url,
|
||||
avatar_version,
|
||||
bio: isGroup ? 'Detalhes do Grupo' : (formattedPhone || 'Clique para informações'),
|
||||
displayName,
|
||||
subject: c.protocolNumber ? `#${c.protocolNumber}` : (isGroup ? null : formattedPhone),
|
||||
last_message_text: c.lastMessageBody || null,
|
||||
last_message_time: c.lastMessageAt || c.createdAt,
|
||||
last_message_from_me: c.lastMessageFromMe ? 1 : 0,
|
||||
last_message_status: c.lastMessageStatus?.toLowerCase() ?? null,
|
||||
last_message_type: c.lastMessageType?.toLowerCase() ?? null,
|
||||
last_message_sender: c.lastMessageSender ?? null,
|
||||
unread_count: c.unreadCount,
|
||||
is_pinned: c.isPinned,
|
||||
is_archived: c.isArchived,
|
||||
bot_paused: c.botPaused ?? null,
|
||||
labels: [],
|
||||
}
|
||||
}
|
||||
|
||||
function messagesToLegacy(msgs: ReturnType<typeof useChatStore.getState>['messages'][string]) {
|
||||
return (msgs ?? []).map((m) => ({
|
||||
id: m.id as any,
|
||||
chat_id: m.chatId as any,
|
||||
message_id: m.messageId,
|
||||
text: m.body ?? '',
|
||||
direction: (m.fromMe ? 'out' : 'in') as 'in' | 'out',
|
||||
timestamp: new Date(m.timestamp).getTime(),
|
||||
status: m.status.toLowerCase(),
|
||||
type: m.type.toLowerCase(),
|
||||
media_url: m.mediaUrl,
|
||||
// false só para mídias legadas sem WAMessage salvo (fromMe irrecuperável); default
|
||||
// true para não bloquear mensagens vindas por outros caminhos (socket, etc.).
|
||||
media_recoverable: (m as any).mediaRecoverable ?? true,
|
||||
transcription: (m as any).transcription ?? null,
|
||||
mime_type: m.mimeType,
|
||||
reply_to_id: m.replyToId,
|
||||
// Quoted message — mapeia replyTo do Prisma para o formato do MessageBubble
|
||||
quoted_id: m.replyTo?.id ?? null,
|
||||
quoted_message_id: m.replyTo?.messageId ?? null,
|
||||
quoted_message_text: m.replyTo?.body ?? null,
|
||||
quoted_participant: m.replyTo ? (m.replyTo.fromMe ? 'me' : null) : null,
|
||||
participant: m.pushName ?? null,
|
||||
senderJid: m.senderJid ?? null,
|
||||
sender_jid: m.senderJid ?? null,
|
||||
remote_jid: m.chatId,
|
||||
from_me: m.fromMe ? 1 : 0,
|
||||
rich_payload: (m as any).richPayload ?? null,
|
||||
}))
|
||||
}
|
||||
|
||||
// ─── Hook principal ───────────────────────────────────────────────────────────
|
||||
|
||||
export function useNewInboxBridge() {
|
||||
const initialized = useRef(false)
|
||||
const chatStore = useChatStore()
|
||||
const instanceStore = useInstanceStore()
|
||||
|
||||
// ── Boot: socket + instâncias ─────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (initialized.current) return
|
||||
initialized.current = true
|
||||
|
||||
// Satélite: o socketService lê o token do scoreodonto internamente.
|
||||
socketService.connect()
|
||||
chatStore.initSocketListeners()
|
||||
instanceStore.initSocketListeners()
|
||||
instanceStore.loadInstances()
|
||||
}, [])
|
||||
|
||||
// ── Carregar chats quando instância ativa muda ─────────────────────────────
|
||||
useEffect(() => {
|
||||
const { activeInstanceId } = instanceStore
|
||||
if (!activeInstanceId) return
|
||||
chatStore.loadChats(activeInstanceId)
|
||||
socketService.joinInstance(activeInstanceId)
|
||||
// Reconcilia nomes em background (cache 30min — leve em escala)
|
||||
reconcileApi.reconcileNames(activeInstanceId).catch(() => {})
|
||||
}, [instanceStore.activeInstanceId])
|
||||
|
||||
// ── Adaptação: instâncias → legado ────────────────────────────────────────
|
||||
const instancesLegacy = useMemo(
|
||||
() =>
|
||||
instanceStore.instances.map((i) => ({
|
||||
instance: i.id,
|
||||
nome: i.name,
|
||||
status: i.status === 'CONNECTED' ? 'connected' : i.status.toLowerCase(),
|
||||
phone: i.phone,
|
||||
avatar: null,
|
||||
profilePictureUrl: null,
|
||||
})),
|
||||
[instanceStore.instances]
|
||||
)
|
||||
|
||||
const activeInstanceLegacy = useMemo(
|
||||
() => instancesLegacy.find((i) => i.instance === instanceStore.activeInstanceId) ?? null,
|
||||
[instancesLegacy, instanceStore.activeInstanceId]
|
||||
)
|
||||
|
||||
// ── Adaptação: chats → legado ─────────────────────────────────────────────
|
||||
const chatsLegacy = useMemo(() => chatStore.chats.map(chatToLegacy), [chatStore.chats])
|
||||
|
||||
const selectedChatLegacy = useMemo(() => {
|
||||
if (!chatStore.activeChatId) return null
|
||||
const c = chatStore.chats.find((c) => c.id === chatStore.activeChatId)
|
||||
return c ? chatToLegacy(c) : null
|
||||
}, [chatStore.activeChatId, chatStore.chats])
|
||||
|
||||
const messagesLegacy = useMemo(() => {
|
||||
if (!chatStore.activeChatId) return []
|
||||
const raw = chatStore.messages[chatStore.activeChatId] ?? []
|
||||
const mapped = messagesToLegacy(raw)
|
||||
console.log('[bridge] messagesLegacy recomputed:', { activeChatId: chatStore.activeChatId, rawCount: raw.length, mappedCount: mapped.length })
|
||||
return mapped
|
||||
}, [chatStore.activeChatId, chatStore.messages])
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────────────────
|
||||
|
||||
const handleSelectChat = useCallback(async (chat: NewWhatsChat | null) => {
|
||||
if (!chat) { useChatStore.getState().setActiveChat(null); return }
|
||||
await chatStore.selectChat(String(chat.id))
|
||||
// Assina a presença do contato (composing/recording) — só assim o motor emite.
|
||||
chatApi.subscribePresence(String(chat.id)).catch(() => {})
|
||||
}, [chatStore])
|
||||
|
||||
const handleSendText = useCallback(async (text: string, mentions?: string[], replyToMessageId?: string) => {
|
||||
if (!chatStore.activeChatId || !instanceStore.activeInstanceId) return
|
||||
const chat = chatStore.chats.find((c) => c.id === chatStore.activeChatId)
|
||||
if (!chat) return
|
||||
await chatStore.sendMessage(instanceStore.activeInstanceId, chat.jid, text, replyToMessageId, mentions)
|
||||
}, [chatStore, instanceStore.activeInstanceId])
|
||||
|
||||
const handleLoadMoreHistory = useCallback(async () => {
|
||||
if (!chatStore.activeChatId) return { hasMore: false }
|
||||
const oldest = chatStore.messages[chatStore.activeChatId]?.[0]
|
||||
if (!oldest) return { hasMore: false }
|
||||
return chatStore.loadMessages(chatStore.activeChatId, { before: oldest.timestamp })
|
||||
}, [chatStore])
|
||||
|
||||
const handleSetActiveInstance = useCallback((legacyInstance: any) => {
|
||||
instanceStore.setActiveInstance(legacyInstance?.instance ?? null)
|
||||
}, [instanceStore])
|
||||
|
||||
const handleRefreshInstances = useCallback(async () => {
|
||||
await instanceStore.loadInstances()
|
||||
}, [instanceStore])
|
||||
|
||||
const handleConnectInstance = useCallback(async (instanceId: string) => {
|
||||
await instanceStore.connectInstance(instanceId)
|
||||
}, [instanceStore])
|
||||
|
||||
const handleArchiveChat = useCallback(async (chatId: string, archived: boolean) => {
|
||||
await chatApi.archive(chatId, archived)
|
||||
useChatStore.getState().patchChat(chatId, { isArchived: archived })
|
||||
}, [])
|
||||
|
||||
const handlePinChat = useCallback(async (chatId: string, pinned: boolean) => {
|
||||
await chatApi.pin(chatId, pinned)
|
||||
useChatStore.getState().patchChat(chatId, { isPinned: pinned })
|
||||
}, [])
|
||||
|
||||
const handleDeleteChat = useCallback(async (chatId: string) => {
|
||||
await chatApi.delete(chatId)
|
||||
useChatStore.getState().setChats(
|
||||
useChatStore.getState().chats.filter((c) => c.id !== chatId)
|
||||
)
|
||||
if (useChatStore.getState().activeChatId === chatId) {
|
||||
useChatStore.getState().setActiveChat(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleMarkRead = useCallback(async (chatId: string) => {
|
||||
await chatApi.markRead(chatId)
|
||||
useChatStore.getState().patchChat(chatId, { unreadCount: 0 })
|
||||
}, [])
|
||||
|
||||
// Apaga a mensagem PARA TODOS (revoke no WhatsApp). Confirma antes; em erro (ex.:
|
||||
// mensagem não é sua / instância offline) mostra o motivo. messageId = id interno.
|
||||
const handleDeleteMessage = useCallback(async (chatId: string, messageId: string) => {
|
||||
if (!chatId || !messageId) return
|
||||
if (typeof window !== 'undefined' && !window.confirm('Apagar esta mensagem para todos? Esta ação não pode ser desfeita.')) return
|
||||
try {
|
||||
await useChatStore.getState().deleteMessage(chatId, messageId)
|
||||
} catch (e: any) {
|
||||
const m = e?.response?.data?.error || e?.message || 'Não foi possível apagar a mensagem.'
|
||||
if (typeof window !== 'undefined') window.alert(m)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSearchMessages = useCallback(async (q: string) => {
|
||||
if (!instanceStore.activeInstanceId || q.length < 2) return []
|
||||
return messageApi.search(instanceStore.activeInstanceId, q)
|
||||
}, [instanceStore.activeInstanceId])
|
||||
|
||||
// action é a string-key emitida por ChatListItem (ex: 'archive', 'pin', ...)
|
||||
// onFavoriteToggle é injetado pelo componente pai (inbox page) pois favoritos são estado local
|
||||
const handleMenuAction = useCallback((
|
||||
action: string,
|
||||
chat: NewWhatsChat,
|
||||
onFavoriteToggle?: (jid: string) => void
|
||||
) => {
|
||||
const chatId = String(chat.id)
|
||||
switch (action) {
|
||||
case 'archive': handleArchiveChat(chatId, !chat.is_archived); break
|
||||
case 'pin': handlePinChat(chatId, !chat.is_pinned); break
|
||||
case 'mark_read': handleMarkRead(chatId); break
|
||||
case 'delete': handleDeleteChat(chatId); break
|
||||
case 'favorite': onFavoriteToggle?.(chat.remote_jid); break
|
||||
}
|
||||
}, [handleArchiveChat, handlePinChat, handleMarkRead, handleDeleteChat])
|
||||
|
||||
return {
|
||||
instances: instancesLegacy,
|
||||
activeInstance: activeInstanceLegacy,
|
||||
chats: chatsLegacy,
|
||||
selectedChat: selectedChatLegacy,
|
||||
messages: messagesLegacy,
|
||||
loadingChats: chatStore.chatsLoading,
|
||||
loadingMessages: chatStore.messagesLoading,
|
||||
activeInstanceId: instanceStore.activeInstanceId,
|
||||
qrByInstance: instanceStore.qrByInstance,
|
||||
|
||||
setActiveInstance: handleSetActiveInstance,
|
||||
refreshInstances: handleRefreshInstances,
|
||||
handleSelectChat,
|
||||
handleSendText,
|
||||
handleLoadMoreHistory,
|
||||
handleConnectInstance,
|
||||
handleArchiveChat,
|
||||
handlePinChat,
|
||||
handleDeleteChat,
|
||||
handleDeleteMessage,
|
||||
handleMarkRead,
|
||||
handleSearchMessages,
|
||||
handleMenuAction,
|
||||
|
||||
presenceByJid: chatStore.presenceByJid as Record<string, any>,
|
||||
isFavoriteChat: (_: NewWhatsChat) => false,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* useAvatarCache — cache de avatares em IndexedDB.
|
||||
*
|
||||
* Chave de cache: `${jid}:${version}` (version = hash curto do avatarUrl no banco).
|
||||
* Quando a versão muda (novo avatar no banco), a chave muda e o cache invalida
|
||||
* automaticamente — sem TTL fixo, sem stale data visível ao usuário.
|
||||
*
|
||||
* Armazenamento: dataURL base64 do JPEG/WebP (compatível com <img src>).
|
||||
* Limpeza: entradas com mais de 30 dias são removidas na abertura do DB.
|
||||
*/
|
||||
|
||||
const DB_NAME = 'nw-avatars'
|
||||
const STORE_NAME = 'blobs'
|
||||
const DB_VERSION = 1
|
||||
const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000 // 30 dias
|
||||
|
||||
interface CacheEntry {
|
||||
key: string // `${jid}:${version}`
|
||||
dataUrl: string
|
||||
savedAt: number
|
||||
}
|
||||
|
||||
let dbPromise: Promise<IDBDatabase> | null = null
|
||||
|
||||
function openDB(): Promise<IDBDatabase> {
|
||||
if (dbPromise) return dbPromise
|
||||
dbPromise = new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, DB_VERSION)
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME, { keyPath: 'key' })
|
||||
}
|
||||
}
|
||||
req.onsuccess = () => {
|
||||
const db = req.result
|
||||
pruneOldEntries(db)
|
||||
resolve(db)
|
||||
}
|
||||
req.onerror = () => { dbPromise = null; reject(req.error) }
|
||||
})
|
||||
return dbPromise
|
||||
}
|
||||
|
||||
function pruneOldEntries(db: IDBDatabase): void {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite')
|
||||
const store = tx.objectStore(STORE_NAME)
|
||||
const cutoff = Date.now() - MAX_AGE_MS
|
||||
const req = store.openCursor()
|
||||
req.onsuccess = () => {
|
||||
const cursor = req.result
|
||||
if (!cursor) return
|
||||
if ((cursor.value as CacheEntry).savedAt < cutoff) cursor.delete()
|
||||
cursor.continue()
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAvatarFromCache(jid: string, version: string): Promise<string | null> {
|
||||
if (typeof window === 'undefined') return null
|
||||
try {
|
||||
const db = await openDB()
|
||||
const key = `${jid}:${version}`
|
||||
return new Promise((resolve) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readonly')
|
||||
const req = tx.objectStore(STORE_NAME).get(key)
|
||||
req.onsuccess = () => resolve((req.result as CacheEntry | undefined)?.dataUrl ?? null)
|
||||
req.onerror = () => resolve(null)
|
||||
})
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function setAvatarInCache(jid: string, version: string, dataUrl: string): Promise<void> {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
const db = await openDB()
|
||||
const key = `${jid}:${version}`
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite')
|
||||
const entry: CacheEntry = { key, dataUrl, savedAt: Date.now() }
|
||||
const req = tx.objectStore(STORE_NAME).put(entry)
|
||||
req.onsuccess = () => resolve()
|
||||
req.onerror = () => reject(req.error)
|
||||
})
|
||||
} catch { /* quota exceeded ou IDB indisponível — ignora silenciosamente */ }
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { protocolApi, sectorApi, type Sector, type Team } from '../services/chatApiService'
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type ProtocolStatus =
|
||||
| 'OPEN'
|
||||
| 'IN_PROGRESS'
|
||||
| 'WAITING_CLIENT'
|
||||
| 'WAITING_AGENT'
|
||||
| 'RESOLVED'
|
||||
| 'CANCELLED'
|
||||
|
||||
export interface Protocol {
|
||||
id: string
|
||||
number: string
|
||||
chatId: string
|
||||
contactId: string
|
||||
sectorId: string | null
|
||||
teamId: string | null
|
||||
agentId: string | null
|
||||
status: ProtocolStatus
|
||||
summary: string | null
|
||||
deadline: string | null
|
||||
resolvedAt: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
sector?: { name: string } | null
|
||||
team?: { name: string } | null
|
||||
}
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useProtocolPanel(chatId: string | null) {
|
||||
const [protocols, setProtocols] = useState<Protocol[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
// Sectors & teams for the "open protocol" form
|
||||
const [sectors, setSectors] = useState<Sector[]>([])
|
||||
const [teams, setTeams] = useState<Team[]>([])
|
||||
const [selectedSectorId, setSelectedSectorId] = useState<string>('')
|
||||
const [selectedTeamId, setSelectedTeamId] = useState<string>('')
|
||||
const [loadingSectors, setLoadingSectors] = useState(false)
|
||||
const [loadingTeams, setLoadingTeams] = useState(false)
|
||||
|
||||
// Opening a new protocol
|
||||
const [isOpening, setIsOpening] = useState(false)
|
||||
const [showOpenForm, setShowOpenForm] = useState(false)
|
||||
|
||||
// Updating status
|
||||
const [updatingId, setUpdatingId] = useState<string | null>(null)
|
||||
|
||||
// Toast
|
||||
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null)
|
||||
|
||||
const showToast = useCallback((message: string, type: 'success' | 'error') => {
|
||||
setToast({ message, type })
|
||||
setTimeout(() => setToast(null), 3000)
|
||||
}, [])
|
||||
|
||||
// ── Load protocols ────────────────────────────────────────────────────────
|
||||
const load = useCallback(async () => {
|
||||
if (!chatId) { setProtocols([]); return }
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await protocolApi.list(chatId)
|
||||
setProtocols(data as Protocol[])
|
||||
} catch {
|
||||
// silently ignore
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [chatId])
|
||||
|
||||
useEffect(() => { load() }, [chatId])
|
||||
|
||||
// ── Load sectors (lazy, once) ─────────────────────────────────────────────
|
||||
const loadSectors = useCallback(async () => {
|
||||
if (sectors.length > 0) return
|
||||
setLoadingSectors(true)
|
||||
try {
|
||||
const data = await sectorApi.list()
|
||||
setSectors(data.filter((s) => s.isActive))
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoadingSectors(false)
|
||||
}
|
||||
}, [sectors.length])
|
||||
|
||||
// ── Load teams when sector changes ────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!selectedSectorId) { setTeams([]); setSelectedTeamId(''); return }
|
||||
setLoadingTeams(true)
|
||||
sectorApi.listTeams(selectedSectorId)
|
||||
.then((data) => { setTeams(data.filter((t) => t.isActive)); setSelectedTeamId('') })
|
||||
.catch(() => {})
|
||||
.finally(() => setLoadingTeams(false))
|
||||
}, [selectedSectorId])
|
||||
|
||||
const openForm = useCallback(() => {
|
||||
setShowOpenForm(true)
|
||||
setSelectedSectorId('')
|
||||
setSelectedTeamId('')
|
||||
loadSectors()
|
||||
}, [loadSectors])
|
||||
|
||||
// ── Open new protocol ─────────────────────────────────────────────────────
|
||||
const openProtocol = useCallback(async () => {
|
||||
if (!chatId) return
|
||||
setIsOpening(true)
|
||||
try {
|
||||
const protocol = await protocolApi.open(chatId, {
|
||||
sectorId: selectedSectorId || undefined,
|
||||
teamId: selectedTeamId || undefined,
|
||||
})
|
||||
setProtocols((prev) => [protocol as Protocol, ...prev])
|
||||
setShowOpenForm(false)
|
||||
showToast('Protocolo aberto.', 'success')
|
||||
} catch (err: any) {
|
||||
showToast(err?.response?.data?.error ?? 'Erro ao abrir protocolo.', 'error')
|
||||
} finally {
|
||||
setIsOpening(false)
|
||||
}
|
||||
}, [chatId, selectedSectorId, selectedTeamId, showToast])
|
||||
|
||||
// ── Update status ─────────────────────────────────────────────────────────
|
||||
const updateStatus = useCallback(async (protocol: Protocol, status: ProtocolStatus) => {
|
||||
if (!chatId) return
|
||||
setUpdatingId(protocol.id)
|
||||
try {
|
||||
await protocolApi.update(chatId, protocol.id, { status })
|
||||
setProtocols((prev) =>
|
||||
prev.map((p) =>
|
||||
p.id === protocol.id
|
||||
? { ...p, status, resolvedAt: status === 'RESOLVED' ? new Date().toISOString() : p.resolvedAt }
|
||||
: p
|
||||
)
|
||||
)
|
||||
showToast('Status atualizado.', 'success')
|
||||
} catch {
|
||||
showToast('Erro ao atualizar status.', 'error')
|
||||
} finally {
|
||||
setUpdatingId(null)
|
||||
}
|
||||
}, [chatId, showToast])
|
||||
|
||||
// ── Derived ───────────────────────────────────────────────────────────────
|
||||
const activeProtocol = protocols.find(
|
||||
(p) => p.status !== 'RESOLVED' && p.status !== 'CANCELLED'
|
||||
) ?? null
|
||||
|
||||
const hasActive = activeProtocol !== null
|
||||
|
||||
return {
|
||||
protocols, loading, load,
|
||||
activeProtocol, hasActive,
|
||||
|
||||
// open form
|
||||
showOpenForm, setShowOpenForm,
|
||||
openForm,
|
||||
sectors, teams,
|
||||
selectedSectorId, setSelectedSectorId,
|
||||
selectedTeamId, setSelectedTeamId,
|
||||
loadingSectors, loadingTeams,
|
||||
isOpening, openProtocol,
|
||||
|
||||
// update
|
||||
updatingId, updateStatus,
|
||||
|
||||
toast,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* useSessionsLogic — toda a lógica da página /sessions. Portado do motor.
|
||||
* Adaptações no satélite: imports apontam p/ os shims; o bootstrap chama
|
||||
* socketService.connect() sem depender de localStorage['token'] (o shim lê o
|
||||
* token do scoreodonto internamente).
|
||||
*/
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useInstanceStore, type Instance } from '../store/instanceStore'
|
||||
import { socketService } from '../services/socketService'
|
||||
|
||||
interface QrModalState {
|
||||
instanceId: string
|
||||
instanceName: string
|
||||
qrBase64: string | null
|
||||
}
|
||||
|
||||
interface Toast {
|
||||
msg: string
|
||||
ok: boolean
|
||||
}
|
||||
|
||||
export function useSessionsLogic() {
|
||||
const storeInstances = useInstanceStore((s) => s.instances)
|
||||
const storeLoading = useInstanceStore((s) => s.loading)
|
||||
const loadInstances = useInstanceStore((s) => s.loadInstances)
|
||||
const createInstance = useInstanceStore((s) => s.createInstance)
|
||||
const connectInst = useInstanceStore((s) => s.connectInstance)
|
||||
const disconnectInst = useInstanceStore((s) => s.disconnectInstance)
|
||||
const deleteInst = useInstanceStore((s) => s.deleteInstance)
|
||||
const patchInstance = useInstanceStore((s) => s.patchInstance)
|
||||
const setQr = useInstanceStore((s) => s.setQr)
|
||||
const clearQr = useInstanceStore((s) => s.clearQr)
|
||||
const qrByInstance = useInstanceStore((s) => s.qrByInstance)
|
||||
const initListeners = useInstanceStore((s) => s.initSocketListeners)
|
||||
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null)
|
||||
const [addOpen, setAddOpen] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [qrModal, setQrModal] = useState<QrModalState | null>(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState<Instance | null>(null)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [toast, setToast] = useState<Toast | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
socketService.connect() // shim lê o token do scoreodonto internamente
|
||||
initListeners()
|
||||
loadInstances()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const onStatus = ({ instanceId, status, qrBase64 }: { instanceId: string; status: Instance['status']; qrBase64?: string }) => {
|
||||
patchInstance(instanceId, { status })
|
||||
if (qrBase64) {
|
||||
setQr(instanceId, qrBase64)
|
||||
setQrModal((prev) => (prev?.instanceId === instanceId ? { ...prev, qrBase64 } : prev))
|
||||
}
|
||||
if (status === 'CONNECTED') {
|
||||
clearQr(instanceId)
|
||||
setQrModal((prev) => (prev?.instanceId === instanceId ? null : prev))
|
||||
showToast('WhatsApp conectado!')
|
||||
}
|
||||
}
|
||||
|
||||
const onQr = ({ instanceId, qrBase64 }: { instanceId: string; qrBase64: string }) => {
|
||||
setQr(instanceId, qrBase64)
|
||||
patchInstance(instanceId, { status: 'QR_PENDING' })
|
||||
setQrModal((prev) => (prev?.instanceId === instanceId ? { ...prev, qrBase64 } : prev))
|
||||
}
|
||||
|
||||
socketService.on('instance:status', onStatus)
|
||||
socketService.on('qr', onQr)
|
||||
return () => {
|
||||
socketService.off('instance:status', onStatus)
|
||||
socketService.off('qr', onQr)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const showToast = (msg: string, ok = true) => {
|
||||
setToast({ msg, ok })
|
||||
setTimeout(() => setToast(null), 3000)
|
||||
}
|
||||
|
||||
const openAddModal = useCallback(() => setAddOpen(true), [])
|
||||
const closeAddModal = useCallback(() => { setAddOpen(false); setNewName('') }, [])
|
||||
|
||||
const handleCreate = useCallback(async () => {
|
||||
const name = newName.trim()
|
||||
if (!name) return
|
||||
setCreating(true)
|
||||
try {
|
||||
const created = await createInstance(name)
|
||||
closeAddModal()
|
||||
if (created?.id) {
|
||||
socketService.joinInstance(created.id)
|
||||
await connectInst(created.id)
|
||||
setQrModal({ instanceId: created.id, instanceName: name, qrBase64: null })
|
||||
}
|
||||
} catch (err: any) {
|
||||
showToast(err.response?.data?.error ?? 'Erro ao criar instância', false)
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}, [newName, createInstance, connectInst, closeAddModal])
|
||||
|
||||
const handleConnect = useCallback(async (inst: Instance) => {
|
||||
setActionLoading(`connect-${inst.id}`)
|
||||
try {
|
||||
socketService.joinInstance(inst.id)
|
||||
await connectInst(inst.id)
|
||||
setQrModal({ instanceId: inst.id, instanceName: inst.name, qrBase64: null })
|
||||
} catch (err: any) {
|
||||
showToast(err.response?.data?.error ?? 'Erro ao conectar', false)
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
}, [connectInst])
|
||||
|
||||
const handleDisconnect = useCallback(async (inst: Instance) => {
|
||||
setActionLoading(`disconnect-${inst.id}`)
|
||||
try {
|
||||
await disconnectInst(inst.id)
|
||||
showToast('Instância desconectada')
|
||||
} catch (err: any) {
|
||||
showToast(err.response?.data?.error ?? 'Erro ao desconectar', false)
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
}, [disconnectInst])
|
||||
|
||||
const handleShowQr = useCallback(async (inst: Instance) => {
|
||||
socketService.joinInstance(inst.id)
|
||||
const cached = qrByInstance[inst.id]
|
||||
if (cached) {
|
||||
setQrModal({ instanceId: inst.id, instanceName: inst.name, qrBase64: cached })
|
||||
return
|
||||
}
|
||||
setQrModal({ instanceId: inst.id, instanceName: inst.name, qrBase64: null })
|
||||
}, [qrByInstance])
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!deleteTarget) return
|
||||
setDeleting(true)
|
||||
try {
|
||||
await deleteInst(deleteTarget.id)
|
||||
setDeleteTarget(null)
|
||||
showToast('Instância removida')
|
||||
} catch (err: any) {
|
||||
showToast(err.response?.data?.error ?? 'Erro ao deletar', false)
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}, [deleteTarget, deleteInst])
|
||||
|
||||
const handleNewQr = useCallback(async (instanceId: string) => {
|
||||
socketService.joinInstance(instanceId)
|
||||
await connectInst(instanceId)
|
||||
}, [connectInst])
|
||||
|
||||
return {
|
||||
instances: storeInstances,
|
||||
loading: storeLoading,
|
||||
actionLoading,
|
||||
toast,
|
||||
addOpen,
|
||||
newName,
|
||||
creating,
|
||||
setNewName,
|
||||
openAddModal,
|
||||
closeAddModal,
|
||||
handleCreate,
|
||||
qrModal,
|
||||
closeQrModal: () => setQrModal(null),
|
||||
handleNewQr,
|
||||
deleteTarget,
|
||||
deleting,
|
||||
setDeleteTarget,
|
||||
closeDeleteModal: () => setDeleteTarget(null),
|
||||
handleDelete,
|
||||
handleConnect,
|
||||
handleDisconnect,
|
||||
handleShowQr,
|
||||
refreshInstances: loadInstances,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* useStickerCache — cache de figurinhas em IndexedDB.
|
||||
*
|
||||
* Chave de cache: sha256 hex do arquivo (conteúdo permanente — nunca muda).
|
||||
* Sem TTL: stickers são imutáveis, então o cache é eterno.
|
||||
* O único motivo para uma entrada sumir é o usuário limpar dados do navegador.
|
||||
*
|
||||
* Armazenamento: dataURL base64 do WebP (compatível com <img src>).
|
||||
*/
|
||||
|
||||
const DB_NAME = 'nw-stickers'
|
||||
const STORE_NAME = 'blobs'
|
||||
const DB_VERSION = 1
|
||||
|
||||
interface CacheEntry {
|
||||
sha256: string
|
||||
dataUrl: string
|
||||
}
|
||||
|
||||
let dbPromise: Promise<IDBDatabase> | null = null
|
||||
|
||||
function openDB(): Promise<IDBDatabase> {
|
||||
if (dbPromise) return dbPromise
|
||||
dbPromise = new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, DB_VERSION)
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME, { keyPath: 'sha256' })
|
||||
}
|
||||
}
|
||||
req.onsuccess = () => resolve(req.result)
|
||||
req.onerror = () => { dbPromise = null; reject(req.error) }
|
||||
})
|
||||
return dbPromise
|
||||
}
|
||||
|
||||
export async function getStickerFromCache(sha256: string): Promise<string | null> {
|
||||
if (typeof window === 'undefined') return null
|
||||
try {
|
||||
const db = await openDB()
|
||||
return new Promise((resolve) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readonly')
|
||||
const req = tx.objectStore(STORE_NAME).get(sha256)
|
||||
req.onsuccess = () => resolve((req.result as CacheEntry | undefined)?.dataUrl ?? null)
|
||||
req.onerror = () => resolve(null)
|
||||
})
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function setStickerInCache(sha256: string, dataUrl: string): Promise<void> {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
const db = await openDB()
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite')
|
||||
const entry: CacheEntry = { sha256, dataUrl }
|
||||
const req = tx.objectStore(STORE_NAME).put(entry)
|
||||
req.onsuccess = () => resolve()
|
||||
req.onerror = () => reject(req.error)
|
||||
})
|
||||
} catch { /* quota exceeded — ignora */ }
|
||||
}
|
||||
|
||||
export async function hasStickerInCache(sha256: string): Promise<boolean> {
|
||||
if (typeof window === 'undefined') return false
|
||||
try {
|
||||
const db = await openDB()
|
||||
return new Promise((resolve) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readonly')
|
||||
const req = tx.objectStore(STORE_NAME).count(sha256)
|
||||
req.onsuccess = () => resolve(req.result > 0)
|
||||
req.onerror = () => resolve(false)
|
||||
})
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
// 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';
|
||||
|
||||
// ─── Instâncias ── (ext /sessions) ──────────────────────────────────────────
|
||||
export const instanceApi = {
|
||||
list: () => nw.get('/sessions'),
|
||||
create: (name: string) => nw.post('/sessions', { name }),
|
||||
connect: (id: string) => nw.post(`/sessions/${id}/connect`),
|
||||
disconnect: (id: string) => nw.post(`/sessions/${id}/disconnect`),
|
||||
getQr: (id: string) => nw.get(`/sessions/${id}/qr`),
|
||||
delete: (id: string) => nw.delete(`/sessions/${id}`),
|
||||
};
|
||||
|
||||
// ─── 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.
|
||||
export const avatarApi = {
|
||||
refresh: async (instanceId: string, jid: string): Promise<string | null> => {
|
||||
try {
|
||||
const r = await nw.get(`/contacts/${encodeURIComponent(jid)}/avatar/refresh?session=${encodeURIComponent(instanceId)}`);
|
||||
return r?.avatar ?? null;
|
||||
} catch { return null; }
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Chats ── (ext /inbox → shape achatado; normaliza p/ mapBackendChat) ────
|
||||
export const chatApi = {
|
||||
list: async (instanceId: string, opts: { search?: string; archived?: boolean; limit?: number } = {}) => {
|
||||
const q = new URLSearchParams({ session: instanceId });
|
||||
if (opts.search) q.set('search', opts.search);
|
||||
if (opts.limit) q.set('limit', String(opts.limit));
|
||||
const raw = await nw.get(`/inbox?${q.toString()}`);
|
||||
const arr = Array.isArray(raw) ? raw : [];
|
||||
// ext: { id, jid, displayName, avatar, phone, unreadCount, lastMessageAt,
|
||||
// isPinned, isArchived, lastMessage } → shape esperado pelo chatStore.
|
||||
return arr.map((c: any) => ({
|
||||
id: c.id,
|
||||
jid: c.jid,
|
||||
instanceId,
|
||||
name: c.displayName ?? null,
|
||||
contactName: c.displayName ?? null,
|
||||
contactPhone: c.phone ?? null,
|
||||
contactAvatarUrl: c.avatar ?? null,
|
||||
unreadCount: c.unreadCount ?? 0,
|
||||
lastMessageAt: c.lastMessageAt ?? null,
|
||||
createdAt: c.lastMessageAt ?? null,
|
||||
isPinned: c.isPinned ?? false,
|
||||
isArchived: c.isArchived ?? false,
|
||||
lastMessage: c.lastMessage ?? null,
|
||||
}));
|
||||
},
|
||||
markRead: (chatId: string) => nw.post(`/inbox/${chatId}/read`),
|
||||
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}`),
|
||||
};
|
||||
|
||||
// ─── Mensagens ── (ext devolve array; envolve em {messages, hasMore}) ───────
|
||||
export const messageApi = {
|
||||
list: async (chatId: string, opts: { before?: string; after?: string; limit?: number } = {}) => {
|
||||
const q = new URLSearchParams();
|
||||
if (opts.before) q.set('before', opts.before);
|
||||
if (opts.limit) q.set('limit', String(opts.limit));
|
||||
const qs = q.toString();
|
||||
const raw = await nw.get(`/inbox/${chatId}/messages${qs ? `?${qs}` : ''}`);
|
||||
const arr = Array.isArray(raw) ? raw : (raw?.messages ?? []);
|
||||
const limit = opts.limit ?? 50;
|
||||
return {
|
||||
messages: arr.map((m: any) => ({ ...m, chatId, replyToId: m.replyToId ?? null, replyTo: m.replyTo ?? null })),
|
||||
hasMore: arr.length >= limit,
|
||||
};
|
||||
},
|
||||
search: (instanceId: string, q: string, limit?: number) => {
|
||||
const p = new URLSearchParams({ session: instanceId, q });
|
||||
if (limit) p.set('limit', String(limit));
|
||||
return nw.get(`/inbox/messages/search?${p.toString()}`);
|
||||
},
|
||||
// Envio por chatId (a ext resolve jid/instância). O motor chamava por
|
||||
// (instanceId, jid, ...); o chatStore foi adaptado p/ passar (chatId, text, replyTo).
|
||||
send: (chatId: string, text: string, replyToMessageId?: string) =>
|
||||
nw.post(`/inbox/${chatId}/send`, { text, ...(replyToMessageId ? { replyToMessageId } : {}) }),
|
||||
};
|
||||
|
||||
// ─── Mídia ── (ext /inbox/:chatId/send-media | send-audio) ──────────────────
|
||||
export const mediaApi = {
|
||||
sendMedia: (chatId: string, file: File, caption?: string, replyToMessageId?: 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);
|
||||
},
|
||||
// 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' }));
|
||||
return nw.post(`/inbox/${chatId}/send-media`, fd);
|
||||
},
|
||||
// Re-download sob demanda: ~95% das mídias vêm com mediaUrl NULL (lazy). O motor
|
||||
// re-baixa do WhatsApp a partir do WAMessage salvo e devolve { mediaUrl }.
|
||||
redownload: (_instanceId: string, messageDbId: string): Promise<{ mediaUrl: string }> =>
|
||||
nw.post(`/media/${encodeURIComponent(messageDbId)}/download`),
|
||||
};
|
||||
|
||||
// ─── Grupos (ext não expõe participantes — stub gracioso) ───────────────────
|
||||
export const groupApi = {
|
||||
getParticipants: (_instanceId: string, groupJid: string) =>
|
||||
Promise.resolve({ groupJid, subject: '', participants: [] as any[] }),
|
||||
};
|
||||
|
||||
// ─── Protocolo (ext ainda não expõe CRUD por chat — stub; task follow-up) ───
|
||||
export const protocolApi = {
|
||||
list: (_chatId: string) => Promise.resolve([] as any[]),
|
||||
open: (_chatId: string, _data: { sectorId?: string; teamId?: string }) => Promise.resolve(null as any),
|
||||
update: (_chatId: string, _protocolId: string, _data: { status?: string; summary?: string }) => Promise.resolve(null as any),
|
||||
};
|
||||
|
||||
// ─── Reconciliação (ext não expõe — no-op) ──────────────────────────────────
|
||||
export const reconcileApi = {
|
||||
reconcileNames: (_instanceId: string) => Promise.resolve({ updated: 0 }),
|
||||
};
|
||||
|
||||
// ─── Tipos (compat com o motor; usados como type-only pelos componentes) ────
|
||||
export interface GroupParticipant { jid: string; admin: string | null; name: string | null; phone: string }
|
||||
export interface Sector { id: string; tenantId: string; name: string; isActive: boolean; createdAt: string; updatedAt: string; _count?: { teams: number; protocols: number } }
|
||||
export interface Team { id: string; tenantId: string; sectorId: string; name: string; isActive: boolean; createdAt: string; updatedAt: string; _count?: { protocols: number } }
|
||||
export type TemplateType = 'TEXT' | 'BUTTONS' | 'INTERACTIVE' | 'LIST' | 'POLL' | 'CAROUSEL'
|
||||
export interface MessageTemplate { id: string; tenantId: string; name: string; description: string | null; type: TemplateType; payload: Record<string, unknown>; tags: string[]; usageCount: number; createdAt: string; updatedAt: string }
|
||||
export interface StickerRecord { id: string; sha256: string; wasabiPath: string; isAnimated: boolean; isFavorite: boolean; useCount?: number; lastSeenAt?: string }
|
||||
export interface RichPayload { text?: string; footer?: string; nativeButtons?: any[]; nativeList?: any; nativeCarousel?: any; poll?: any }
|
||||
|
||||
// ─── Setores/Equipes (ext não expõe protocolo/setores — stubs) ──────────────
|
||||
export const sectorApi = {
|
||||
list: () => Promise.resolve([] as Sector[]),
|
||||
create: (_name: string) => Promise.resolve(null as any),
|
||||
update: (_id: string, _d: any) => Promise.resolve(null as any),
|
||||
delete: (_id: string) => Promise.resolve({} as any),
|
||||
listTeams: (_sectorId: string) => Promise.resolve([] as Team[]),
|
||||
createTeam: (_sectorId: string, _name: string) => Promise.resolve(null as any),
|
||||
updateTeam: (_sectorId: string, _teamId: string, _d: any) => Promise.resolve(null as any),
|
||||
deleteTeam: (_sectorId: string, _teamId: string) => Promise.resolve({} as any),
|
||||
};
|
||||
|
||||
// ─── Templates (stub simples) ───────────────────────────────────────────────
|
||||
export const templateApi = {
|
||||
list: (_opts: any = {}) => Promise.resolve([] as MessageTemplate[]),
|
||||
get: (_id: string) => Promise.resolve(null as any),
|
||||
create: (_d: any) => Promise.resolve(null as any),
|
||||
update: (_id: string, _d: any) => Promise.resolve(null as any),
|
||||
delete: (_id: string) => Promise.resolve({} as any),
|
||||
markUsed: (_id: string) => Promise.resolve({} as any),
|
||||
};
|
||||
|
||||
// ─── Stickers (ext não expõe catálogo — stub) ───────────────────────────────
|
||||
export const stickerApi = {
|
||||
list: () => Promise.resolve({ listVersion: '', stickers: [] as StickerRecord[] }),
|
||||
recent: () => Promise.resolve({ stickers: [] as StickerRecord[] }),
|
||||
toggleFavorite: (id: string) => Promise.resolve({ id, isFavorite: false }),
|
||||
};
|
||||
|
||||
// ─── Rich message (composer passa instanceId/jid; sem chatId não mapeia na
|
||||
// ext → stub que rejeita graciosamente) ─────────────────────────────────────
|
||||
export const richMessageApi = {
|
||||
send: (_instanceId: string, _jid: string, _payload: RichPayload) =>
|
||||
Promise.reject(new Error('Envio rico indisponível no satélite')),
|
||||
};
|
||||
|
||||
// ─── Demais exports do chatApiService do motor (stubs — a ext não os expõe;
|
||||
// mantidos para os named imports da árvore não quebrarem em runtime) ─────────
|
||||
export interface ApiKeyRecord { id: string; name: string; keyPreview: string; isActive: boolean; createdAt: string; expiresAt: string | null }
|
||||
export interface BackendContact { id: string; instanceId: string; jid: string; name: string | null; verifiedName: string | null; notify: string | null; phone: string | null; avatarUrl: string | null; scoreReputacao: number; flagRestricao: boolean; isBlocked: boolean; createdAt: string; updatedAt: string }
|
||||
export type EventType = 'BIRTHDAY' | 'ANNIVERSARY' | 'SIGNUP_DAYS'
|
||||
export type RecipientMode = 'MANUAL' | 'ALL_CONTACTS' | 'TAG_FILTER'
|
||||
export type ScheduleType = 'ONCE' | 'RECURRING' | 'EVENT'
|
||||
export type ScheduleStatus = 'ACTIVE' | 'PAUSED' | 'DONE' | 'CANCELLED'
|
||||
export interface ScheduleRecipient { jid: string; name?: string; birthday?: string; anniversary?: string; signupDate?: string }
|
||||
export interface ScheduledMessage { id: string; tenantId: string; instanceId: string; name: string; templateId: string | null; payload: Record<string, unknown>; recipientMode: RecipientMode; recipients: ScheduleRecipient[]; scheduleType: ScheduleType; cronExpr: string | null; scheduledAt: string | null; eventType: EventType | null; timezone: string; status: ScheduleStatus; lastRunAt: string | null; nextRunAt: string | null; runCount: number; createdAt: string; updatedAt: string; template?: any; _count?: { logs: number } }
|
||||
export interface ScheduleLog { id: string; scheduleId: string; status: string; sentCount: number; failedCount: number; error: string | null; runAt: string }
|
||||
|
||||
// Cliente axios-like genérico sobre o nw (alguns arquivos importam `api` direto).
|
||||
export const api = {
|
||||
get: (u: string) => nw.get(u).then((data: any) => ({ data })),
|
||||
post: (u: string, body?: any) => nw.post(u, body).then((data: any) => ({ data })),
|
||||
put: (u: string, body?: any) => nw.put(u, body).then((data: any) => ({ data })),
|
||||
patch: (u: string, body?: any) => nw.patch(u, body).then((data: any) => ({ data })),
|
||||
delete: (u: string) => nw.delete(u).then((data: any) => ({ data })),
|
||||
};
|
||||
|
||||
export const authApi = {
|
||||
register: (_d: any) => Promise.resolve({} as any),
|
||||
login: (_d: any) => Promise.resolve({} as any),
|
||||
logout: () => Promise.resolve({} as any),
|
||||
me: () => Promise.resolve({} as any),
|
||||
changePassword: (_d: any) => Promise.resolve({} as any),
|
||||
};
|
||||
|
||||
export const apiKeyApi = {
|
||||
list: () => Promise.resolve([] as ApiKeyRecord[]),
|
||||
create: (_name: string, _e?: string) => Promise.resolve({} as any),
|
||||
toggle: (_id: string, _a: boolean) => Promise.resolve({} as any),
|
||||
delete: (_id: string) => Promise.resolve({} as any),
|
||||
};
|
||||
|
||||
export const contactApi = {
|
||||
list: (_opts: any = {}) => Promise.resolve({ total: 0, contacts: [] as BackendContact[] }),
|
||||
get: (_id: string) => Promise.resolve(null as any),
|
||||
update: (_id: string, _d: any) => Promise.resolve(null as any),
|
||||
delete: (_id: string) => Promise.resolve({} as any),
|
||||
stats: (_instanceId?: string) => Promise.resolve({ total: 0, blocked: 0, flagged: 0, active: 0 }),
|
||||
};
|
||||
|
||||
export const broadcastApi = {
|
||||
create: (_instanceId: string, _d: any) => Promise.resolve({ jobId: '', total: 0 }),
|
||||
list: (_instanceId: string, _limit?: number) => Promise.resolve([] as any[]),
|
||||
get: (_instanceId: string, _broadcastId: string) => Promise.resolve(null as any),
|
||||
};
|
||||
|
||||
export const planApi = {
|
||||
status: () => Promise.resolve({ daysRemaining: null as number | null, planName: '' }),
|
||||
};
|
||||
|
||||
export const chatbotApi = {
|
||||
getCredentials: (_instanceId: string) => Promise.resolve([] as any[]),
|
||||
createCredential: (_instanceId: string, _d: any) => Promise.resolve({} as any),
|
||||
deleteCredential: (_instanceId: string, _credId: string) => Promise.resolve({} as any),
|
||||
getBots: (_instanceId: string) => Promise.resolve([] as any[]),
|
||||
createBot: (_instanceId: string, _d: any) => Promise.resolve({} as any),
|
||||
updateBot: (_instanceId: string, _botId: string, _d: any) => Promise.resolve({} as any),
|
||||
deleteBot: (_instanceId: string, _botId: string) => Promise.resolve({} as any),
|
||||
pauseChat: (_instanceId: string, _chatId: string) => Promise.resolve({} as any),
|
||||
resumeChat: (_instanceId: string, _chatId: string) => Promise.resolve({} as any),
|
||||
};
|
||||
|
||||
export const scheduleApi = {
|
||||
list: (_opts: any = {}) => Promise.resolve([] as ScheduledMessage[]),
|
||||
get: (_id: string) => Promise.resolve(null as any),
|
||||
create: (_d: any) => Promise.resolve(null as any),
|
||||
update: (_id: string, _d: any) => Promise.resolve(null as any),
|
||||
delete: (_id: string) => Promise.resolve({} as any),
|
||||
pause: (_id: string) => Promise.resolve({} as any),
|
||||
resume: (_id: string) => Promise.resolve({} as any),
|
||||
runNow: (_id: string) => Promise.resolve({} as any),
|
||||
logs: (_id: string) => Promise.resolve([] as ScheduleLog[]),
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
// Cliente base do satélite NewWhats → /api/nw/v1/* (proxy do backend → motor
|
||||
// /api/ext/v1/*). Auth: Bearer do scoreodonto (localStorage SCOREODONTO_AUTH_TOKEN).
|
||||
// Retorna JSON já parseado; em erro, lança Error com `.response = { status, data }`
|
||||
// (mesmo shape do axios, que o código portado do motor espera).
|
||||
const API: string = (import.meta as any).env?.VITE_API_URL || '/api';
|
||||
const TOKEN_KEY = 'SCOREODONTO_AUTH_TOKEN';
|
||||
|
||||
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 {
|
||||
try { return JSON.parse(localStorage.getItem('SCOREODONTO_ACTIVE_WORKSPACE') || '{}')?.id || null; } catch { return null; }
|
||||
}
|
||||
|
||||
function authHeaders(extra?: Record<string, string>): Record<string, string> {
|
||||
const t = nwToken();
|
||||
const c = activeClinicaId();
|
||||
return {
|
||||
...(t ? { Authorization: `Bearer ${t}` } : {}),
|
||||
...(c ? { 'x-nw-clinica': c } : {}),
|
||||
...(extra || {}),
|
||||
};
|
||||
}
|
||||
|
||||
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)),
|
||||
body: body == null ? undefined : (isForm ? body : JSON.stringify(body)),
|
||||
});
|
||||
const text = await res.text();
|
||||
let data: any = null;
|
||||
try { data = text ? JSON.parse(text) : null; } catch { data = text; }
|
||||
if (!res.ok) {
|
||||
const err: any = new Error((data && data.error) || `${res.status}`);
|
||||
err.response = { status: res.status, data };
|
||||
throw err;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export const nw = {
|
||||
get: (p: string) => req('GET', p),
|
||||
post: (p: string, body?: any) => req('POST', p, body),
|
||||
put: (p: string, body?: any) => req('PUT', p, body),
|
||||
patch: (p: string, body?: any) => req('PATCH', p, body),
|
||||
delete: (p: string) => req('DELETE', p),
|
||||
// URL base ('/api' relativo ou absoluta) — usada pelo socketService p/ o WS.
|
||||
base: () => API,
|
||||
};
|
||||
@@ -0,0 +1,187 @@
|
||||
// Satélite: `api` axios-like sobre o nwClient. As rotas internas da secretária
|
||||
// foram montadas sob a ext em /api/ext/v1/secretaria (proxy /api/nw/v1/secretaria).
|
||||
import { nw } from './nwClient'
|
||||
|
||||
const _qs = (params?: Record<string, any>) => {
|
||||
if (!params) return ''
|
||||
const p = new URLSearchParams()
|
||||
Object.entries(params).forEach(([k, v]) => { if (v != null) p.set(k, String(v)) })
|
||||
const s = p.toString()
|
||||
return s ? `?${s}` : ''
|
||||
}
|
||||
|
||||
const api = {
|
||||
get: <T = any>(u: string, cfg?: { params?: Record<string, any> }) => nw.get(u + _qs(cfg?.params)).then((data: any) => ({ data: data as T })),
|
||||
post: <T = any>(u: string, body?: any) => nw.post(u, body).then((data: any) => ({ data: data as T })),
|
||||
put: <T = any>(u: string, body?: any) => nw.put(u, body).then((data: any) => ({ data: data as T })),
|
||||
patch: <T = any>(u: string, body?: any) => nw.patch(u, body).then((data: any) => ({ data: data as T })),
|
||||
delete:<T = any>(u: string) => nw.delete(u).then((data: any) => ({ data: data as T })),
|
||||
}
|
||||
|
||||
const BASE = '/secretaria'
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface SecAgent {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
model: string
|
||||
provider: string
|
||||
temperature: number
|
||||
context_window: number
|
||||
active: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type BrainNodeType = 'persona' | 'knowledge' | 'rules' | 'calendar' | 'escalation'
|
||||
|
||||
export interface BrainNode {
|
||||
id: string
|
||||
agent_id: string
|
||||
type: BrainNodeType
|
||||
title: string
|
||||
content: string
|
||||
active: boolean
|
||||
sort_order: number
|
||||
node_model: string | null // modelo específico para este nó (opcional)
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface SecConversation {
|
||||
id: string
|
||||
agent_id: string
|
||||
contact_name: string
|
||||
protocol_number: string
|
||||
status: 'active' | 'closed' | 'escalated'
|
||||
summary: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface SecMessage {
|
||||
id: string
|
||||
conversation_id: string
|
||||
role: 'user' | 'assistant' | 'system'
|
||||
content: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface CalendarSlot {
|
||||
id: string
|
||||
title: string
|
||||
date: string
|
||||
time_start: string
|
||||
time_end: string
|
||||
instance_id?: string | null // número/sessão dona do slot (null = global)
|
||||
attendee_name: string | null
|
||||
attendee_phone: string | null
|
||||
status: 'available' | 'booked' | 'cancelled'
|
||||
notes: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// ─── Power (liga/desliga global — kill-switch auto_reply) ──────────────────────
|
||||
|
||||
export const secPowerApi = {
|
||||
get: () => api.get<{ enabled: boolean }>(`${BASE}/power`).then((r) => r.data),
|
||||
set: (enabled: boolean) => api.post<{ enabled: boolean }>(`${BASE}/power`, { enabled }).then((r) => r.data),
|
||||
}
|
||||
|
||||
// Liga/desliga a Secretária POR SESSÃO (instância atual do /wa-inbox).
|
||||
export interface SessionPower {
|
||||
enabled: boolean
|
||||
reason: string | null
|
||||
changed_by: string | null
|
||||
changed_at: string | null
|
||||
can_toggle: boolean
|
||||
next_toggle_at: string | null
|
||||
history: { enabled: boolean; reason: string | null; by: string | null; at: string }[]
|
||||
}
|
||||
export const secSessionPowerApi = {
|
||||
get: (instanceId: string) => api.get<SessionPower>(`${BASE}/session-power`, { params: { instance_id: instanceId } }).then((r) => r.data),
|
||||
set: (instanceId: string, enabled: boolean, reason: string, by: string) =>
|
||||
api.post<{ ok: boolean; enabled: boolean }>(`${BASE}/session-power`, { instance_id: instanceId, enabled, reason, by }).then((r) => r.data),
|
||||
}
|
||||
|
||||
// ─── Agents ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const secAgentApi = {
|
||||
list: () => api.get<SecAgent[]>(`${BASE}/agents`).then((r) => r.data),
|
||||
create: (d: Partial<SecAgent>) => api.post<SecAgent>(`${BASE}/agents`, d).then((r) => r.data),
|
||||
update: (id: string, d: Partial<SecAgent>) => api.put<SecAgent>(`${BASE}/agents/${id}`, d).then((r) => r.data),
|
||||
delete: (id: string) => api.delete(`${BASE}/agents/${id}`).then((r) => r.data),
|
||||
}
|
||||
|
||||
// ─── Brain Nodes ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const secNodeApi = {
|
||||
list: (agentId: string) => api.get<BrainNode[]>(`${BASE}/agents/${agentId}/nodes`).then((r) => r.data),
|
||||
create: (agentId: string, d: Partial<BrainNode>) =>
|
||||
api.post<BrainNode>(`${BASE}/agents/${agentId}/nodes`, d).then((r) => r.data),
|
||||
update: (id: string, d: Partial<BrainNode>) => api.put<BrainNode>(`${BASE}/nodes/${id}`, d).then((r) => r.data),
|
||||
delete: (id: string) => api.delete(`${BASE}/nodes/${id}`).then((r) => r.data),
|
||||
}
|
||||
|
||||
// ─── Conversations ────────────────────────────────────────────────────────────
|
||||
|
||||
export const secConvApi = {
|
||||
list: (agentId?: string) =>
|
||||
api.get<SecConversation[]>(`${BASE}/conversations`, { params: agentId ? { agent_id: agentId } : {} }).then((r) => r.data),
|
||||
create: (agentId: string, contactName: string) =>
|
||||
api.post<SecConversation>(`${BASE}/conversations`, { agent_id: agentId, contact_name: contactName }).then((r) => r.data),
|
||||
patch: (id: string, d: Partial<SecConversation>) =>
|
||||
api.patch<SecConversation>(`${BASE}/conversations/${id}`, d).then((r) => r.data),
|
||||
delete: (id: string) => api.delete(`${BASE}/conversations/${id}`).then((r) => r.data),
|
||||
messages: (id: string) => api.get<SecMessage[]>(`${BASE}/conversations/${id}/messages`).then((r) => r.data),
|
||||
chat: (id: string, message: string) =>
|
||||
api.post<{ reply: string }>(`${BASE}/conversations/${id}/chat`, { message }).then((r) => r.data),
|
||||
finalize: (id: string) =>
|
||||
api.post<{ summary: string; protocol_number: string }>(`${BASE}/conversations/${id}/finalize`).then((r) => r.data),
|
||||
}
|
||||
|
||||
// ─── Numbers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export type NumberRole =
|
||||
| 'secretary_virtual'
|
||||
| 'clinic'
|
||||
| 'doctor'
|
||||
| 'specialist'
|
||||
| 'manager'
|
||||
| 'reserve'
|
||||
| 'human_secretary'
|
||||
|
||||
export interface SecNumber {
|
||||
id: string
|
||||
instance_id: string
|
||||
clinica_id?: string | null
|
||||
agent_id: string | null // agente/cérebro deste número (separação por sessão)
|
||||
label: string
|
||||
role: NumberRole
|
||||
area: string | null
|
||||
priority: number
|
||||
active: boolean
|
||||
notes: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export const secNumberApi = {
|
||||
list: () => api.get<SecNumber[]>(`${BASE}/numbers`).then((r) => r.data),
|
||||
// upsert por instance_id — cria ou atualiza o papel da instância
|
||||
assign: (d: Partial<SecNumber>) => api.post<SecNumber>(`${BASE}/numbers`, d).then((r) => r.data),
|
||||
update: (id: string, d: Partial<SecNumber>) => api.put<SecNumber>(`${BASE}/numbers/${id}`, d).then((r) => r.data),
|
||||
delete: (id: string) => api.delete(`${BASE}/numbers/${id}`).then((r) => r.data),
|
||||
}
|
||||
|
||||
// ─── Calendar ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export const secCalApi = {
|
||||
list: (params?: { from?: string; to?: string; status?: string; instance_id?: string }) =>
|
||||
api.get<CalendarSlot[]>(`${BASE}/calendar`, { params }).then((r) => r.data),
|
||||
create: (d: Partial<CalendarSlot>) => api.post<CalendarSlot>(`${BASE}/calendar`, d).then((r) => r.data),
|
||||
update: (id: string, d: Partial<CalendarSlot>) => api.put<CalendarSlot>(`${BASE}/calendar/${id}`, d).then((r) => r.data),
|
||||
delete: (id: string) => api.delete(`${BASE}/calendar/${id}`).then((r) => r.data),
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// Shim do socketService do motor sobre o túnel WS do satélite
|
||||
// (/api/nw/v1/stream?token=). Traduz os eventos do ext stream para os nomes que
|
||||
// as páginas do motor escutam. joinInstance/joinChat/leaveChat são no-ops: o
|
||||
// stream já é do tenant inteiro; as páginas filtram por instanceId/chatId no
|
||||
// cliente. Mantém a MESMA interface do services/socketService.ts do motor.
|
||||
import { nw, nwToken } from './nwClient';
|
||||
|
||||
type Listener = (data: any) => void;
|
||||
|
||||
// ext status (minúsculo) → enum do motor (maiúsculo).
|
||||
function normStatus(s: string): string {
|
||||
const m: Record<string, string> = {
|
||||
connected: 'CONNECTED', disconnected: 'DISCONNECTED',
|
||||
connecting: 'CONNECTING', qr: 'QR_PENDING', banned: 'BANNED',
|
||||
};
|
||||
return m[String(s).toLowerCase()] ?? String(s).toUpperCase();
|
||||
}
|
||||
|
||||
class SocketShim {
|
||||
private ws: WebSocket | null = null;
|
||||
private listeners = new Map<string, Set<Listener>>();
|
||||
private retry: ReturnType<typeof setTimeout> | null = null;
|
||||
private closedByUser = false;
|
||||
|
||||
connect(_token?: string) {
|
||||
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) return;
|
||||
this.closedByUser = false;
|
||||
const token = nwToken();
|
||||
if (!token) return;
|
||||
const base = nw.base();
|
||||
const abs = base.startsWith('http') ? base : `${window.location.origin}${base}`;
|
||||
let u: URL;
|
||||
try { u = new URL(`${abs}/nw/v1/stream`); } catch { return; }
|
||||
u.protocol = u.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
u.searchParams.set('token', token);
|
||||
|
||||
let ws: WebSocket;
|
||||
try { ws = new WebSocket(u.toString()); } catch { return; }
|
||||
this.ws = ws;
|
||||
|
||||
ws.onopen = () => this._emit('connected', { id: 'nw' });
|
||||
ws.onerror = () => { try { ws.close(); } catch { /* ignore */ } };
|
||||
ws.onclose = () => {
|
||||
this._emit('disconnected', {});
|
||||
if (!this.closedByUser) this.retry = setTimeout(() => this.connect(), 3000);
|
||||
};
|
||||
ws.onmessage = (ev) => {
|
||||
let env: any;
|
||||
try { env = JSON.parse(ev.data); } catch { return; }
|
||||
const { event, data } = env || {};
|
||||
if (!event) return;
|
||||
switch (event) {
|
||||
case 'session.status':
|
||||
this._emit('instance:status', { instanceId: data?.instanceId, status: normStatus(data?.status) });
|
||||
break;
|
||||
case 'session.qr':
|
||||
this._emit('qr', { instanceId: data?.instanceId, qrBase64: data?.qrBase64 });
|
||||
break;
|
||||
case 'message.new': {
|
||||
// ext envia { instanceId, chatId, message, timestamp }; o chatStore
|
||||
// espera o objeto de mensagem direto (com chatId).
|
||||
// OBS: o motor também emite ext:message.new numa variante SEM `message`
|
||||
// (só { jid, text }) para acionar a Secretária IA — ignoramos aqui,
|
||||
// senão o chatStore sobrescreve o preview do chat com undefined.
|
||||
if (!data?.message) break;
|
||||
const m = { ...data.message, chatId: data.message.chatId ?? data.chatId };
|
||||
this._emit('message:new', m);
|
||||
break;
|
||||
}
|
||||
case 'message.update': {
|
||||
const m = data?.message ?? data;
|
||||
if (m?.messageId && m?.status) this._emit('message:status', { messageId: m.messageId, status: m.status });
|
||||
this._emit('message:update', m);
|
||||
break;
|
||||
}
|
||||
case 'conversation.handoff': this._emit('handoff', data); break;
|
||||
case 'conversation.escalated': this._emit('escalated', data); break;
|
||||
default: break;
|
||||
}
|
||||
this._emit('*', { event, data });
|
||||
};
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.closedByUser = true;
|
||||
if (this.retry) clearTimeout(this.retry);
|
||||
try { this.ws?.close(); } catch { /* ignore */ }
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
// 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 */ }
|
||||
joinChat(_chatId: string) { /* no-op */ }
|
||||
leaveChat(_chatId: string) { /* no-op */ }
|
||||
|
||||
on(event: string, cb: Listener) {
|
||||
if (!this.listeners.has(event)) this.listeners.set(event, new Set());
|
||||
this.listeners.get(event)!.add(cb);
|
||||
}
|
||||
off(event: string, cb: Listener) { this.listeners.get(event)?.delete(cb); }
|
||||
|
||||
get connected() { return this.ws?.readyState === WebSocket.OPEN; }
|
||||
getSocket() { return this.ws; }
|
||||
|
||||
private _emit(event: string, data: any) {
|
||||
this.listeners.get(event)?.forEach((cb) => { try { cb(data); } catch { /* ignore */ } });
|
||||
}
|
||||
}
|
||||
|
||||
export const socketService = new SocketShim();
|
||||
@@ -0,0 +1,60 @@
|
||||
// Stub do authStore do motor para o satélite. Os componentes portados leem
|
||||
// `user`/`token` daqui. No satélite a auth é do scoreodonto (SCOREODONTO_AUTH_TOKEN);
|
||||
// decodificamos o mínimo do JWT para exibição. As ações de auth são no-op (o
|
||||
// scoreodonto cuida do login/logout).
|
||||
import { create } from 'zustand';
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
role: 'USER' | 'ADMIN';
|
||||
planId: string | null;
|
||||
daysRemaining: number | null;
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
user: AuthUser | null;
|
||||
token: string | null;
|
||||
refreshToken: string | null;
|
||||
isHydrated: boolean;
|
||||
setAuth: (token: string, refreshToken: string, user: AuthUser) => void;
|
||||
clearAuth: () => void;
|
||||
refreshSession: () => Promise<boolean>;
|
||||
fetchMe: () => Promise<void>;
|
||||
}
|
||||
|
||||
function readScoreToken(): string | null {
|
||||
try { return localStorage.getItem('SCOREODONTO_AUTH_TOKEN'); } catch { return null; }
|
||||
}
|
||||
|
||||
function decodeUser(token: string | null): AuthUser | null {
|
||||
if (!token) return null;
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split('.')[1] || ''));
|
||||
return {
|
||||
id: payload.userId || payload.id || 'me',
|
||||
email: payload.email || '',
|
||||
name: payload.name || payload.nome || null,
|
||||
role: 'USER',
|
||||
planId: null,
|
||||
daysRemaining: null,
|
||||
};
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
const token = readScoreToken();
|
||||
|
||||
export const useAuthStore = create<AuthState>()((set) => ({
|
||||
user: decodeUser(token),
|
||||
token,
|
||||
refreshToken: null,
|
||||
isHydrated: true,
|
||||
setAuth: () => {},
|
||||
clearAuth: () => {},
|
||||
refreshSession: async () => false,
|
||||
fetchMe: async () => {
|
||||
const t = readScoreToken();
|
||||
set({ token: t, user: decodeUser(t) });
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,458 @@
|
||||
/**
|
||||
* chatStore.ts — Store de Inbox: chats e mensagens.
|
||||
* Instâncias foram extraídas para instanceStore.ts.
|
||||
* Usa Zustand + Immer para mutações diretas e legíveis.
|
||||
*/
|
||||
import { create } from 'zustand'
|
||||
import { immer } from 'zustand/middleware/immer'
|
||||
import { socketService } from '../services/socketService'
|
||||
import { chatApi, messageApi } from '../services/chatApiService'
|
||||
|
||||
// ─── Mapper: resposta aninhada do backend → Chat plano do store ────────────────
|
||||
|
||||
function mapBackendChat(raw: any): Chat {
|
||||
return {
|
||||
id: raw.id,
|
||||
tenantId: raw.tenantId ?? '',
|
||||
instanceId: raw.instanceId,
|
||||
jid: raw.jid,
|
||||
name: raw.name ?? null,
|
||||
unreadCount: raw.unreadCount ?? 0,
|
||||
lastMessageAt: raw.lastMessageAt ?? null,
|
||||
createdAt: raw.createdAt ?? null,
|
||||
isPinned: raw.isPinned ?? false,
|
||||
isArchived: raw.isArchived ?? false,
|
||||
// Prioridade do nome: agenda (contact.name) → nome do WhatsApp
|
||||
// (verifiedName de Business → notify/pushName) → pushName da última msg.
|
||||
contactName: raw.contact?.name
|
||||
?? raw.contact?.verifiedName
|
||||
?? raw.contact?.notify
|
||||
?? raw.contactName
|
||||
?? raw.lastMessage?.pushName
|
||||
?? null,
|
||||
contactAvatarUrl: raw.contact?.avatarUrl ?? raw.contactAvatarUrl ?? null,
|
||||
contactAvatarVersion: raw.contact?.avatarVersion ?? raw.contactAvatarVersion ?? null,
|
||||
contactPhone: raw.contact?.phone ?? raw.contactPhone ?? null,
|
||||
scoreReputacao: raw.contact?.scoreReputacao ?? raw.scoreReputacao ?? 0,
|
||||
flagRestricao: raw.contact?.flagRestricao ?? raw.flagRestricao ?? false,
|
||||
lastMessageBody: raw.lastMessage?.body ?? raw.lastMessageBody ?? null,
|
||||
lastMessageFromMe: raw.lastMessage?.fromMe ?? raw.lastMessageFromMe ?? false,
|
||||
lastMessageStatus: raw.lastMessage?.status ?? raw.lastMessageStatus ?? null,
|
||||
lastMessageType: raw.lastMessage?.type ?? raw.lastMessageType ?? null,
|
||||
lastMessageSender: raw.lastMessage?.pushName ?? raw.lastMessageSender ?? null,
|
||||
protocolNumber: raw.protocol?.number ?? raw.protocolNumber ?? null,
|
||||
botPaused: raw.botPaused ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tipos ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface Chat {
|
||||
id: string
|
||||
tenantId: string
|
||||
instanceId: string
|
||||
jid: string
|
||||
name: string | null // subject do grupo; null para individuais
|
||||
unreadCount: number
|
||||
lastMessageAt: string | null
|
||||
createdAt: string | null // quando o chat foi registrado (fallback de ordenação)
|
||||
isPinned: boolean
|
||||
isArchived: boolean
|
||||
contactName: string | null
|
||||
contactAvatarUrl: string | null
|
||||
contactAvatarVersion: string | null
|
||||
contactPhone: string | null
|
||||
scoreReputacao: number
|
||||
flagRestricao: boolean
|
||||
lastMessageBody: string | null
|
||||
lastMessageFromMe: boolean
|
||||
lastMessageStatus: string | null
|
||||
lastMessageType: string | null
|
||||
lastMessageSender: string | null // pushName do remetente (grupos: quem enviou a última msg)
|
||||
protocolNumber: string | null
|
||||
botPaused: boolean
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string
|
||||
chatId: string
|
||||
messageId: string
|
||||
fromMe: boolean
|
||||
type: string
|
||||
body: string | null
|
||||
mediaUrl: string | null
|
||||
transcription?: string | null
|
||||
mimeType: string | null
|
||||
fileName: string | null
|
||||
replyToId: string | null
|
||||
replyTo?: {
|
||||
id: string
|
||||
messageId: string
|
||||
body: string | null
|
||||
fromMe: boolean
|
||||
type: string
|
||||
mediaUrl: string | null
|
||||
} | null
|
||||
status: 'PENDING' | 'SENT' | 'DELIVERED' | 'READ' | 'FAILED'
|
||||
timestamp: string
|
||||
pushName?: string | null
|
||||
senderJid?: string | null
|
||||
}
|
||||
|
||||
// ─── Estado ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ChatState {
|
||||
chats: Chat[]
|
||||
chatsLoading: boolean
|
||||
activeChatId: string | null
|
||||
messages: Record<string, Message[]>
|
||||
messagesLoading: boolean
|
||||
|
||||
// Sync
|
||||
setChats: (chats: Chat[]) => void
|
||||
setChatsLoading: (v: boolean) => void
|
||||
upsertChat: (chat: Chat) => void
|
||||
patchChat: (chatId: string, patch: Partial<Chat>) => void
|
||||
setActiveChat: (chatId: string | null) => void
|
||||
clearUnread: (chatId: string) => void
|
||||
setMessages: (chatId: string, msgs: Message[]) => void
|
||||
setMessagesLoading: (v: boolean) => void
|
||||
appendMessage: (msg: Message) => void
|
||||
patchMessage: (msgId: string, patch: Partial<Message>) => void
|
||||
updateMediaReady: (msgId: string, mediaUrl: 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>
|
||||
|
||||
// Socket
|
||||
initSocketListeners: () => void
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function chatSortKey(c: Chat): number {
|
||||
// WhatsApp ordena por: timestamp da última msg; se não houver msg, usa hora de criação do chat
|
||||
if (c.lastMessageAt) return new Date(c.lastMessageAt).getTime()
|
||||
if (c.createdAt) return new Date(c.createdAt).getTime()
|
||||
return 0
|
||||
}
|
||||
|
||||
function sortChats(chats: Chat[]): Chat[] {
|
||||
return [...chats].sort((a, b) => {
|
||||
// Fixados sempre primeiro (mesma lógica do WhatsApp oficial)
|
||||
if (a.isPinned !== b.isPinned) return a.isPinned ? -1 : 1
|
||||
return chatSortKey(b) - chatSortKey(a)
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Store ────────────────────────────────────────────────────────────────────
|
||||
|
||||
// ─── Cache local (localStorage) — UX instantâneo no reload ─────────────────────
|
||||
// Persiste os primeiros N chats por instância para hidratar a UI antes do fetch.
|
||||
// Chave: `inboxCache:v1:${instanceId}:${archived ? '1' : '0'}`
|
||||
// TTL implícito: validamos pela data salva e descartamos se > 7 dias.
|
||||
const INBOX_CACHE_PREFIX = 'inboxCache:v1'
|
||||
const INBOX_CACHE_LIMIT = 10
|
||||
const INBOX_CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
|
||||
|
||||
interface InboxCacheEntry {
|
||||
savedAt: number
|
||||
chats: Chat[]
|
||||
}
|
||||
|
||||
function inboxCacheKey(instanceId: string, archived: boolean): string {
|
||||
return `${INBOX_CACHE_PREFIX}:${instanceId}:${archived ? '1' : '0'}`
|
||||
}
|
||||
|
||||
function readInboxCache(instanceId: string, archived: boolean): Chat[] | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
try {
|
||||
const raw = localStorage.getItem(inboxCacheKey(instanceId, archived))
|
||||
if (!raw) return null
|
||||
const parsed = JSON.parse(raw) as InboxCacheEntry
|
||||
if (!parsed || !Array.isArray(parsed.chats)) return null
|
||||
if (Date.now() - parsed.savedAt > INBOX_CACHE_MAX_AGE_MS) return null
|
||||
return parsed.chats
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
function writeInboxCache(instanceId: string, archived: boolean, chats: Chat[]): void {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
const slim = chats.slice(0, INBOX_CACHE_LIMIT)
|
||||
const entry: InboxCacheEntry = { savedAt: Date.now(), chats: slim }
|
||||
localStorage.setItem(inboxCacheKey(instanceId, archived), JSON.stringify(entry))
|
||||
} catch { /* quota exceeded — ignora */ }
|
||||
}
|
||||
|
||||
export const useChatStore = create<ChatState>()(
|
||||
immer((set, get) => ({
|
||||
chats: [],
|
||||
chatsLoading: false,
|
||||
activeChatId: null,
|
||||
messages: {},
|
||||
messagesLoading: false,
|
||||
|
||||
// ── Chats ─────────────────────────────────────────────────────────────────
|
||||
|
||||
setChats: (chats) =>
|
||||
set((s) => { s.chats = sortChats(chats) }),
|
||||
|
||||
setChatsLoading: (v) =>
|
||||
set((s) => { s.chatsLoading = v }),
|
||||
|
||||
upsertChat: (chat) =>
|
||||
set((s) => {
|
||||
const idx = s.chats.findIndex((c) => c.id === chat.id)
|
||||
if (idx !== -1) s.chats[idx] = chat
|
||||
else s.chats.unshift(chat)
|
||||
s.chats = sortChats(s.chats as Chat[])
|
||||
}),
|
||||
|
||||
patchChat: (chatId, patch) =>
|
||||
set((s) => {
|
||||
const idx = s.chats.findIndex((c) => c.id === chatId)
|
||||
if (idx !== -1) {
|
||||
Object.assign(s.chats[idx], patch)
|
||||
s.chats = sortChats(s.chats as Chat[])
|
||||
}
|
||||
}),
|
||||
|
||||
setActiveChat: (chatId) =>
|
||||
set((s) => { s.activeChatId = chatId }),
|
||||
|
||||
clearUnread: (chatId) =>
|
||||
set((s) => {
|
||||
const c = s.chats.find((c) => c.id === chatId)
|
||||
if (c) c.unreadCount = 0
|
||||
}),
|
||||
|
||||
// ── Mensagens ─────────────────────────────────────────────────────────────
|
||||
|
||||
setMessages: (chatId, msgs) =>
|
||||
set((s) => { s.messages[chatId] = msgs }),
|
||||
|
||||
setMessagesLoading: (v) =>
|
||||
set((s) => { s.messagesLoading = v }),
|
||||
|
||||
appendMessage: (msg) =>
|
||||
set((s) => {
|
||||
const list = s.messages[msg.chatId]
|
||||
if (!list) { s.messages[msg.chatId] = [msg]; return }
|
||||
const exists = list.some((m) => m.id === msg.id || m.messageId === msg.messageId)
|
||||
if (!exists) list.push(msg)
|
||||
}),
|
||||
|
||||
patchMessage: (msgId, patch) =>
|
||||
set((s) => {
|
||||
for (const list of Object.values(s.messages)) {
|
||||
const idx = list.findIndex((m) => m.id === msgId || m.messageId === msgId)
|
||||
if (idx !== -1) { Object.assign(list[idx], patch); break }
|
||||
}
|
||||
}),
|
||||
|
||||
updateMediaReady: (msgId, mediaUrl) =>
|
||||
set((s) => {
|
||||
for (const list of Object.values(s.messages)) {
|
||||
const msg = list.find((m) => m.id === msgId)
|
||||
if (msg) { msg.mediaUrl = mediaUrl; break }
|
||||
}
|
||||
}),
|
||||
|
||||
// ── Async ─────────────────────────────────────────────────────────────────
|
||||
|
||||
loadChats: async (instanceId, opts = {}) => {
|
||||
// Hidratação instantânea: se temos cache local válido para esta instância,
|
||||
// mostra os 10 chats salvos imediatamente enquanto a request real roda.
|
||||
// Evita tela em branco ou skeleton de 15s+ em redes lentas.
|
||||
const archivedFlag = opts.archived ?? false
|
||||
const hasSearch = !!opts.search
|
||||
const current = get().chats
|
||||
if (!hasSearch) {
|
||||
const sameInstance = current.length > 0 && current[0]?.instanceId === instanceId
|
||||
if (!sameInstance) {
|
||||
const cached = readInboxCache(instanceId, archivedFlag)
|
||||
if (cached && cached.length > 0) {
|
||||
set((s) => { s.chats = sortChats(cached); s.chatsLoading = true })
|
||||
} else {
|
||||
set((s) => { s.chatsLoading = true })
|
||||
}
|
||||
} else {
|
||||
set((s) => { s.chatsLoading = true })
|
||||
}
|
||||
} else {
|
||||
set((s) => { s.chatsLoading = true })
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = await chatApi.list(instanceId, opts)
|
||||
const data: Chat[] = (Array.isArray(raw) ? raw : []).map(mapBackendChat)
|
||||
const sorted = sortChats(data)
|
||||
set((s) => { s.chats = sorted; s.chatsLoading = false })
|
||||
if (!hasSearch) writeInboxCache(instanceId, archivedFlag, sorted)
|
||||
} catch {
|
||||
set((s) => { s.chatsLoading = false })
|
||||
}
|
||||
},
|
||||
|
||||
loadMessages: async (chatId, opts = {}) => {
|
||||
console.log('[chatStore] loadMessages chamado:', chatId, opts)
|
||||
set((s) => { s.messagesLoading = true })
|
||||
try {
|
||||
const res = await messageApi.list(chatId, opts)
|
||||
console.log('[chatStore] API retornou:', { hasMore: res.hasMore, count: res.messages?.length, raw: typeof res })
|
||||
const { messages, hasMore } = res
|
||||
if (!Array.isArray(messages)) {
|
||||
console.error('[chatStore] messages NÃO é array!', messages)
|
||||
set((s) => { s.messagesLoading = false })
|
||||
return { hasMore: false }
|
||||
}
|
||||
set((s) => {
|
||||
if (opts.before) {
|
||||
const existing = s.messages[chatId] ?? []
|
||||
const ids = new Set(existing.map((m) => m.id))
|
||||
s.messages[chatId] = [...messages.filter((m: Message) => !ids.has(m.id)), ...existing]
|
||||
} else {
|
||||
s.messages[chatId] = messages
|
||||
}
|
||||
s.messagesLoading = false
|
||||
})
|
||||
console.log('[chatStore] messages armazenadas:', get().messages[chatId]?.length)
|
||||
return { hasMore }
|
||||
} catch (err) {
|
||||
console.error('[chatStore] loadMessages ERRO:', err)
|
||||
set((s) => { s.messagesLoading = false })
|
||||
return { hasMore: false }
|
||||
}
|
||||
},
|
||||
|
||||
selectChat: async (chatId) => {
|
||||
console.log('[chatStore] selectChat:', chatId)
|
||||
const prev = get().activeChatId
|
||||
if (prev && prev !== chatId) socketService.leaveChat(prev)
|
||||
set((s) => { s.activeChatId = chatId })
|
||||
socketService.joinChat(chatId)
|
||||
get().clearUnread(chatId)
|
||||
chatApi.markRead(chatId).catch(() => {})
|
||||
await get().loadMessages(chatId)
|
||||
console.log('[chatStore] selectChat completo. Messages:', get().messages[chatId]?.length)
|
||||
},
|
||||
|
||||
sendMessage: async (instanceId, jid, text, replyToMessageId, mentions) => {
|
||||
const chatId = get().chats.find((c) => c.jid === jid && c.instanceId === instanceId)?.id
|
||||
if (!chatId) return
|
||||
|
||||
// Resolve replyTo a partir das mensagens já no store
|
||||
const replyToMsg = replyToMessageId
|
||||
? get().messages[chatId]?.find((m) => m.messageId === replyToMessageId) ?? null
|
||||
: null
|
||||
|
||||
const optimistic: Message = {
|
||||
id: `opt-${Date.now()}`,
|
||||
chatId,
|
||||
messageId: `opt-${Date.now()}`,
|
||||
fromMe: true,
|
||||
type: 'TEXT',
|
||||
body: text,
|
||||
mediaUrl: null,
|
||||
mimeType: null,
|
||||
fileName: null,
|
||||
replyToId: replyToMsg?.id ?? null,
|
||||
replyTo: replyToMsg
|
||||
? { id: replyToMsg.id, messageId: replyToMsg.messageId, body: replyToMsg.body, fromMe: replyToMsg.fromMe, type: replyToMsg.type, mediaUrl: replyToMsg.mediaUrl }
|
||||
: null,
|
||||
status: 'PENDING',
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
get().appendMessage(optimistic)
|
||||
|
||||
try {
|
||||
// Satélite: envia por chatId (a ext resolve jid/instância). A ext devolve
|
||||
// { ok, messageId } — mantemos o id otimista (key React estável) e só
|
||||
// fixamos o messageId real (dedup com o echo do WS message.new).
|
||||
const saved = await messageApi.send(chatId, text, replyToMessageId)
|
||||
get().patchMessage(optimistic.id, { ...(saved?.id ? { id: saved.id } : {}), messageId: saved?.messageId ?? optimistic.messageId, status: 'SENT' })
|
||||
get().patchChat(chatId, {
|
||||
lastMessageBody: text,
|
||||
lastMessageFromMe: true,
|
||||
lastMessageAt: saved.timestamp,
|
||||
lastMessageStatus: 'SENT',
|
||||
lastMessageType: 'TEXT',
|
||||
})
|
||||
} catch {
|
||||
get().patchMessage(optimistic.id, { status: 'FAILED' })
|
||||
}
|
||||
},
|
||||
|
||||
// ── Socket ────────────────────────────────────────────────────────────────
|
||||
|
||||
initSocketListeners: () => {
|
||||
// IMPORTANTE: sempre usar get() dentro dos callbacks para ler estado atual.
|
||||
// Capturar `const store = get()` fora criaria uma closure stale.
|
||||
|
||||
socketService.on('message:new', (msg: Message) => {
|
||||
console.log('[socket] message:new recebida:', { chatId: msg.chatId, body: msg.body?.slice(0, 40), fromMe: msg.fromMe })
|
||||
get().appendMessage(msg)
|
||||
const current = get()
|
||||
const isActive = current.activeChatId === msg.chatId
|
||||
current.patchChat(msg.chatId, {
|
||||
lastMessageBody: msg.body,
|
||||
lastMessageFromMe: msg.fromMe,
|
||||
lastMessageAt: msg.timestamp,
|
||||
lastMessageStatus: msg.status,
|
||||
lastMessageType: msg.type,
|
||||
lastMessageSender: msg.fromMe ? null : (msg.pushName ?? null),
|
||||
unreadCount: msg.fromMe || isActive
|
||||
? 0
|
||||
: (current.chats.find((c) => c.id === msg.chatId)?.unreadCount ?? 0) + 1,
|
||||
})
|
||||
})
|
||||
|
||||
socketService.on('message:media_ready', ({ messageId, mediaUrl }: { messageId: string; mediaUrl: string }) => {
|
||||
get().updateMediaReady(messageId, mediaUrl)
|
||||
})
|
||||
|
||||
socketService.on('message:transcription', ({ messageId, transcription }: { messageId: string; transcription: string }) => {
|
||||
get().patchMessage(messageId, { transcription })
|
||||
})
|
||||
|
||||
socketService.on('message:status', ({ messageId, status }: { messageId: string; status: Message['status'] }) => {
|
||||
get().patchMessage(messageId, { status })
|
||||
})
|
||||
|
||||
socketService.on('chat:upsert', (raw: any) => {
|
||||
get().upsertChat(mapBackendChat(raw))
|
||||
})
|
||||
|
||||
socketService.on('bot:escalated', ({ chatId }: { chatId: string }) => {
|
||||
get().patchChat(chatId, { botPaused: true })
|
||||
})
|
||||
|
||||
socketService.on('contact:avatar', ({ instanceId, jid, avatarUrl }: { instanceId: string; jid: string; avatarUrl: string }) => {
|
||||
set((s) => {
|
||||
const chat = s.chats.find((c) => c.instanceId === instanceId && c.jid === jid)
|
||||
if (chat) chat.contactAvatarUrl = avatarUrl
|
||||
})
|
||||
})
|
||||
|
||||
// Reconciliação de nomes concluída — re-busca lista de chats
|
||||
socketService.on('chats:refresh', ({ instanceId }: { instanceId: string }) => {
|
||||
const chats = get().chats
|
||||
if (chats.length > 0 && chats[0]?.instanceId === instanceId) {
|
||||
get().loadChats(instanceId)
|
||||
}
|
||||
})
|
||||
|
||||
// Reconciliação via ContactHandler (pós-sync)
|
||||
socketService.on('contacts:reconciled', ({ instanceId }: { instanceId: string }) => {
|
||||
const chats = get().chats
|
||||
if (chats.length > 0 && chats[0]?.instanceId === instanceId) {
|
||||
get().loadChats(instanceId)
|
||||
}
|
||||
})
|
||||
},
|
||||
}))
|
||||
)
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* instanceStore.ts — Gerencia instâncias WhatsApp e QR Codes.
|
||||
* Separado do chatStore para responsabilidade única.
|
||||
*/
|
||||
import { create } from 'zustand'
|
||||
import { immer } from 'zustand/middleware/immer'
|
||||
import { instanceApi } from '../services/chatApiService'
|
||||
import { socketService } from '../services/socketService'
|
||||
import { notify } from './notificationStore'
|
||||
|
||||
export type InstanceStatus = 'DISCONNECTED' | 'CONNECTING' | 'QR_PENDING' | 'CONNECTED' | 'BANNED'
|
||||
|
||||
export interface Instance {
|
||||
id: string
|
||||
name: string
|
||||
status: InstanceStatus
|
||||
phone: string | null
|
||||
avatar: string | null
|
||||
}
|
||||
|
||||
interface InstanceState {
|
||||
instances: Instance[]
|
||||
activeInstanceId: string | null
|
||||
qrByInstance: Record<string, string>
|
||||
loading: boolean
|
||||
|
||||
// Sync actions
|
||||
setInstances: (instances: Instance[]) => void
|
||||
setActiveInstance: (id: string | null) => void
|
||||
patchInstance: (id: string, patch: Partial<Instance>) => void
|
||||
setQr: (instanceId: string, qrBase64: string) => void
|
||||
clearQr: (instanceId: string) => void
|
||||
removeInstance: (id: string) => void
|
||||
|
||||
// Async actions
|
||||
loadInstances: () => Promise<void>
|
||||
connectInstance: (id: string) => Promise<void>
|
||||
disconnectInstance: (id: string) => Promise<void>
|
||||
createInstance: (name: string) => Promise<Instance>
|
||||
deleteInstance: (id: string) => Promise<void>
|
||||
|
||||
// Socket
|
||||
initSocketListeners: () => void
|
||||
}
|
||||
|
||||
export const useInstanceStore = create<InstanceState>()(
|
||||
immer((set, get) => ({
|
||||
instances: [],
|
||||
// Persiste a seleção ativa no localStorage para sobreviver a reloads
|
||||
activeInstanceId: typeof window !== 'undefined' ? localStorage.getItem('activeInstanceId') : null,
|
||||
qrByInstance: {},
|
||||
loading: false,
|
||||
|
||||
// ── Sync ─────────────────────────────────────────────────────────────────
|
||||
|
||||
setInstances: (instances) =>
|
||||
set((s) => { s.instances = instances }),
|
||||
|
||||
setActiveInstance: (id) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
if (id) localStorage.setItem('activeInstanceId', id)
|
||||
else localStorage.removeItem('activeInstanceId')
|
||||
}
|
||||
set((s) => { s.activeInstanceId = id })
|
||||
},
|
||||
|
||||
patchInstance: (id, patch) =>
|
||||
set((s) => {
|
||||
const idx = s.instances.findIndex((i) => i.id === id)
|
||||
if (idx !== -1) Object.assign(s.instances[idx], patch)
|
||||
else s.instances.push({ id, name: '', phone: null, status: 'CONNECTING', ...patch } as Instance)
|
||||
}),
|
||||
|
||||
setQr: (instanceId, qrBase64) =>
|
||||
set((s) => { s.qrByInstance[instanceId] = qrBase64 }),
|
||||
|
||||
clearQr: (instanceId) =>
|
||||
set((s) => { delete s.qrByInstance[instanceId] }),
|
||||
|
||||
removeInstance: (id) =>
|
||||
set((s) => { s.instances = s.instances.filter((i) => i.id !== id) }),
|
||||
|
||||
// ── Async ─────────────────────────────────────────────────────────────────
|
||||
|
||||
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,
|
||||
}))
|
||||
set((s) => {
|
||||
s.loading = false
|
||||
// Mescla em vez de substituir: preserva status CONNECTED que veio via socket
|
||||
// (socket events são mais recentes que leituras REST — o banco pode estar
|
||||
// desatualizado se a resposta da API chegou antes do DB ser gravado pelo backend)
|
||||
const memMap = new Map(s.instances.map((i) => [i.id, i]))
|
||||
s.instances = list.map((fromDb) => {
|
||||
const inMem = memMap.get(fromDb.id)
|
||||
if (inMem?.status === 'CONNECTED' && fromDb.status !== 'CONNECTED') {
|
||||
// Socket já confirmou CONNECTED — mantém, não deixa REST stale sobrescrever
|
||||
return { ...fromDb, status: 'CONNECTED' as InstanceStatus }
|
||||
}
|
||||
return fromDb
|
||||
})
|
||||
})
|
||||
} catch {
|
||||
set((s) => { s.loading = false })
|
||||
}
|
||||
},
|
||||
|
||||
connectInstance: async (id) => {
|
||||
get().patchInstance(id, { status: 'CONNECTING' })
|
||||
socketService.joinInstance(id)
|
||||
await instanceApi.connect(id)
|
||||
},
|
||||
|
||||
disconnectInstance: async (id) => {
|
||||
await instanceApi.disconnect(id)
|
||||
get().patchInstance(id, { status: 'DISCONNECTED' })
|
||||
get().clearQr(id)
|
||||
},
|
||||
|
||||
createInstance: async (name) => {
|
||||
const created: Instance = await instanceApi.create(name)
|
||||
set((s) => { s.instances.push(created) })
|
||||
return created
|
||||
},
|
||||
|
||||
deleteInstance: async (id) => {
|
||||
await instanceApi.delete(id)
|
||||
get().removeInstance(id)
|
||||
if (get().activeInstanceId === id) {
|
||||
set((s) => { s.activeInstanceId = null })
|
||||
}
|
||||
},
|
||||
|
||||
// ── Socket ────────────────────────────────────────────────────────────────
|
||||
|
||||
initSocketListeners: () => {
|
||||
socketService.on('instance:status', ({
|
||||
instanceId,
|
||||
status,
|
||||
qrBase64,
|
||||
}: {
|
||||
instanceId: string
|
||||
status: InstanceStatus
|
||||
qrBase64?: string
|
||||
}) => {
|
||||
get().patchInstance(instanceId, { status })
|
||||
if (qrBase64) get().setQr(instanceId, qrBase64)
|
||||
if (status === 'CONNECTED') get().clearQr(instanceId)
|
||||
})
|
||||
|
||||
socketService.on('qr', ({ instanceId, qrBase64 }: { instanceId: string; qrBase64: string }) => {
|
||||
get().setQr(instanceId, qrBase64)
|
||||
get().patchInstance(instanceId, { status: 'QR_PENDING' })
|
||||
})
|
||||
|
||||
socketService.on('instance:avatar', ({ instanceId, avatar }: { instanceId: string; avatar: string }) => {
|
||||
get().patchInstance(instanceId, { avatar })
|
||||
})
|
||||
|
||||
socketService.on('instance:syncing', ({ instanceId, gapMinutes }: { instanceId: string; gapMinutes: number }) => {
|
||||
const inst = get().instances.find((i) => i.id === instanceId)
|
||||
const label = inst?.name ?? instanceId.slice(0, 8)
|
||||
if (gapMinutes >= 60) {
|
||||
notify(
|
||||
`📥 ${label}: sincronizando mensagens dos últimos ${Math.round(gapMinutes / 60)}h`,
|
||||
'warning',
|
||||
8000,
|
||||
'syncing',
|
||||
)
|
||||
} else if (gapMinutes > 0) {
|
||||
notify(
|
||||
`📥 ${label}: buscando mensagens perdidas (${gapMinutes} min offline)`,
|
||||
'info',
|
||||
6000,
|
||||
'syncing',
|
||||
)
|
||||
}
|
||||
})
|
||||
},
|
||||
}))
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
// Stub do notificationStore do motor. As notificações "syncing" que o
|
||||
// instanceStore dispara não são essenciais no satélite — no-op evita portar todo
|
||||
// o sistema de toasts do motor. (As páginas têm toasts locais próprios.)
|
||||
export type NotificationType = 'info' | 'success' | 'warning' | 'error';
|
||||
export const notify = (_msg: string, _type?: NotificationType, _durationMs?: number, _tag?: string) => {};
|
||||
@@ -0,0 +1,279 @@
|
||||
import { create } from 'zustand'
|
||||
import {
|
||||
secAgentApi, secNodeApi, secConvApi, secCalApi, secNumberApi,
|
||||
type SecAgent, type BrainNode, type SecConversation, type SecMessage, type CalendarSlot, type SecNumber,
|
||||
} from '../services/secretariaApi'
|
||||
|
||||
interface SecretariaState {
|
||||
// Data
|
||||
agents: SecAgent[]
|
||||
selectedAgent: SecAgent | null
|
||||
nodes: BrainNode[]
|
||||
conversations: SecConversation[]
|
||||
selectedConversation: SecConversation | null
|
||||
messages: SecMessage[]
|
||||
calendarSlots: CalendarSlot[]
|
||||
|
||||
// Loading flags
|
||||
loadingAgents: boolean
|
||||
loadingNodes: boolean
|
||||
loadingConversations: boolean
|
||||
loadingMessages: boolean
|
||||
sendingMessage: boolean
|
||||
loadingCalendar: boolean
|
||||
|
||||
// ── Agents ────────────────────────────────────────────────────────────────
|
||||
loadAgents: () => Promise<void>
|
||||
selectAgent: (a: SecAgent | null) => void
|
||||
createAgent: (d: Partial<SecAgent>) => Promise<SecAgent>
|
||||
updateAgent: (id: string, d: Partial<SecAgent>) => Promise<void>
|
||||
deleteAgent: (id: string) => Promise<void>
|
||||
|
||||
// ── Brain Nodes ───────────────────────────────────────────────────────────
|
||||
loadNodes: (agentId: string) => Promise<void>
|
||||
createNode: (agentId: string, d: Partial<BrainNode>) => Promise<void>
|
||||
updateNode: (id: string, d: Partial<BrainNode>) => Promise<void>
|
||||
deleteNode: (id: string) => Promise<void>
|
||||
|
||||
// ── Conversations ─────────────────────────────────────────────────────────
|
||||
loadConversations: (agentId?: string) => Promise<void>
|
||||
selectConversation: (c: SecConversation | null) => void
|
||||
createConversation: (agentId: string, contactName: string) => Promise<SecConversation>
|
||||
deleteConversation: (id: string) => Promise<void>
|
||||
updateConversationStatus: (id: string, status: SecConversation['status']) => Promise<void>
|
||||
|
||||
// ── Messages ──────────────────────────────────────────────────────────────
|
||||
loadMessages: (convId: string) => Promise<void>
|
||||
sendMessage: (convId: string, text: string) => Promise<void>
|
||||
|
||||
// ── Calendar ──────────────────────────────────────────────────────────────
|
||||
loadCalendar: (params?: { from?: string; to?: string; status?: string }) => Promise<void>
|
||||
createCalendarSlot: (d: Partial<CalendarSlot>) => Promise<void>
|
||||
updateCalendarSlot: (id: string, d: Partial<CalendarSlot>) => Promise<void>
|
||||
deleteCalendarSlot: (id: string) => Promise<void>
|
||||
|
||||
// ── Numbers ───────────────────────────────────────────────────────────────
|
||||
numbers: SecNumber[]
|
||||
loadingNumbers: boolean
|
||||
loadNumbers: () => Promise<void>
|
||||
createNumber: (d: Partial<SecNumber>) => Promise<void>
|
||||
updateNumber: (id: string, d: Partial<SecNumber>) => Promise<void>
|
||||
deleteNumber: (id: string) => Promise<void>
|
||||
}
|
||||
|
||||
export const useSecretariaStore = create<SecretariaState>((set, get) => ({
|
||||
agents: [], selectedAgent: null, nodes: [],
|
||||
conversations: [], selectedConversation: null, messages: [],
|
||||
calendarSlots: [], numbers: [],
|
||||
loadingAgents: false, loadingNodes: false, loadingConversations: false,
|
||||
loadingMessages: false, sendingMessage: false, loadingCalendar: false, loadingNumbers: false,
|
||||
|
||||
// ── Agents ────────────────────────────────────────────────────────────────
|
||||
|
||||
loadAgents: async () => {
|
||||
set({ loadingAgents: true })
|
||||
try {
|
||||
const agents = await secAgentApi.list()
|
||||
set({ agents })
|
||||
// Auto-seleciona o primeiro agente se nenhum selecionado
|
||||
if (!get().selectedAgent && agents.length > 0) {
|
||||
get().selectAgent(agents[0])
|
||||
}
|
||||
} finally {
|
||||
set({ loadingAgents: false })
|
||||
}
|
||||
},
|
||||
|
||||
selectAgent: (a) => {
|
||||
set({ selectedAgent: a, nodes: [], conversations: [], selectedConversation: null, messages: [] })
|
||||
if (a) {
|
||||
get().loadNodes(a.id)
|
||||
get().loadConversations(a.id)
|
||||
}
|
||||
},
|
||||
|
||||
createAgent: async (d) => {
|
||||
const agent = await secAgentApi.create(d)
|
||||
set((s) => ({ agents: [...s.agents, agent] }))
|
||||
return agent
|
||||
},
|
||||
|
||||
updateAgent: async (id, d) => {
|
||||
const updated = await secAgentApi.update(id, d)
|
||||
set((s) => ({
|
||||
agents: s.agents.map((a) => (a.id === id ? updated : a)),
|
||||
selectedAgent: s.selectedAgent?.id === id ? updated : s.selectedAgent,
|
||||
}))
|
||||
},
|
||||
|
||||
deleteAgent: async (id) => {
|
||||
await secAgentApi.delete(id)
|
||||
set((s) => ({
|
||||
agents: s.agents.filter((a) => a.id !== id),
|
||||
selectedAgent: s.selectedAgent?.id === id ? null : s.selectedAgent,
|
||||
}))
|
||||
},
|
||||
|
||||
// ── Brain Nodes ───────────────────────────────────────────────────────────
|
||||
|
||||
loadNodes: async (agentId) => {
|
||||
set({ loadingNodes: true })
|
||||
try {
|
||||
const nodes = await secNodeApi.list(agentId)
|
||||
set({ nodes })
|
||||
} finally {
|
||||
set({ loadingNodes: false })
|
||||
}
|
||||
},
|
||||
|
||||
createNode: async (agentId, d) => {
|
||||
const node = await secNodeApi.create(agentId, d)
|
||||
set((s) => ({ nodes: [...s.nodes, node] }))
|
||||
},
|
||||
|
||||
updateNode: async (id, d) => {
|
||||
const updated = await secNodeApi.update(id, d)
|
||||
set((s) => ({ nodes: s.nodes.map((n) => (n.id === id ? updated : n)) }))
|
||||
},
|
||||
|
||||
deleteNode: async (id) => {
|
||||
await secNodeApi.delete(id)
|
||||
set((s) => ({ nodes: s.nodes.filter((n) => n.id !== id) }))
|
||||
},
|
||||
|
||||
// ── Conversations ─────────────────────────────────────────────────────────
|
||||
|
||||
loadConversations: async (agentId) => {
|
||||
set({ loadingConversations: true })
|
||||
try {
|
||||
const conversations = await secConvApi.list(agentId)
|
||||
set({ conversations })
|
||||
} finally {
|
||||
set({ loadingConversations: false })
|
||||
}
|
||||
},
|
||||
|
||||
selectConversation: (c) => {
|
||||
set({ selectedConversation: c, messages: [] })
|
||||
if (c) get().loadMessages(c.id)
|
||||
},
|
||||
|
||||
createConversation: async (agentId, contactName) => {
|
||||
const conv = await secConvApi.create(agentId, contactName)
|
||||
set((s) => ({ conversations: [conv, ...s.conversations] }))
|
||||
return conv
|
||||
},
|
||||
|
||||
deleteConversation: async (id) => {
|
||||
await secConvApi.delete(id)
|
||||
set((s) => ({
|
||||
conversations: s.conversations.filter((c) => c.id !== id),
|
||||
selectedConversation: s.selectedConversation?.id === id ? null : s.selectedConversation,
|
||||
}))
|
||||
},
|
||||
|
||||
updateConversationStatus: async (id, status) => {
|
||||
const updated = await secConvApi.patch(id, { status })
|
||||
set((s) => ({
|
||||
conversations: s.conversations.map((c) => (c.id === id ? updated : c)),
|
||||
selectedConversation: s.selectedConversation?.id === id ? updated : s.selectedConversation,
|
||||
}))
|
||||
},
|
||||
|
||||
// ── Messages ──────────────────────────────────────────────────────────────
|
||||
|
||||
loadMessages: async (convId) => {
|
||||
set({ loadingMessages: true })
|
||||
try {
|
||||
const messages = await secConvApi.messages(convId)
|
||||
set({ messages })
|
||||
} finally {
|
||||
set({ loadingMessages: false })
|
||||
}
|
||||
},
|
||||
|
||||
sendMessage: async (convId, text) => {
|
||||
// Adiciona mensagem do usuário imediatamente (optimistic)
|
||||
const tempId = `temp-${Date.now()}`
|
||||
const userMsg: SecMessage = {
|
||||
id: tempId, conversation_id: convId, role: 'user', content: text, created_at: new Date().toISOString(),
|
||||
}
|
||||
set((s) => ({ messages: [...s.messages, userMsg], sendingMessage: true }))
|
||||
|
||||
try {
|
||||
const { reply } = await secConvApi.chat(convId, text)
|
||||
const aiMsg: SecMessage = {
|
||||
id: `ai-${Date.now()}`, conversation_id: convId, role: 'assistant', content: reply,
|
||||
created_at: new Date().toISOString(),
|
||||
}
|
||||
set((s) => ({ messages: [...s.messages, aiMsg] }))
|
||||
|
||||
// Atualiza updated_at da conversa na lista
|
||||
set((s) => ({
|
||||
conversations: s.conversations.map((c) =>
|
||||
c.id === convId ? { ...c, updated_at: new Date().toISOString() } : c,
|
||||
),
|
||||
}))
|
||||
} finally {
|
||||
set({ sendingMessage: false })
|
||||
}
|
||||
},
|
||||
|
||||
// ── Calendar ──────────────────────────────────────────────────────────────
|
||||
|
||||
loadCalendar: async (params) => {
|
||||
set({ loadingCalendar: true })
|
||||
try {
|
||||
const calendarSlots = await secCalApi.list(params)
|
||||
set({ calendarSlots })
|
||||
} finally {
|
||||
set({ loadingCalendar: false })
|
||||
}
|
||||
},
|
||||
|
||||
createCalendarSlot: async (d) => {
|
||||
const slot = await secCalApi.create(d)
|
||||
set((s) => ({ calendarSlots: [...s.calendarSlots, slot].sort((a, b) => a.date.localeCompare(b.date)) }))
|
||||
},
|
||||
|
||||
updateCalendarSlot: async (id, d) => {
|
||||
const updated = await secCalApi.update(id, d)
|
||||
set((s) => ({ calendarSlots: s.calendarSlots.map((sl) => (sl.id === id ? updated : sl)) }))
|
||||
},
|
||||
|
||||
deleteCalendarSlot: async (id) => {
|
||||
await secCalApi.delete(id)
|
||||
set((s) => ({ calendarSlots: s.calendarSlots.filter((sl) => sl.id !== id) }))
|
||||
},
|
||||
|
||||
// ── Numbers ───────────────────────────────────────────────────────────────
|
||||
|
||||
loadNumbers: async () => {
|
||||
set({ loadingNumbers: true })
|
||||
try {
|
||||
const numbers = await secNumberApi.list()
|
||||
set({ numbers })
|
||||
} finally {
|
||||
set({ loadingNumbers: false })
|
||||
}
|
||||
},
|
||||
|
||||
createNumber: async (d) => {
|
||||
const num = await secNumberApi.assign(d)
|
||||
set((s) => ({
|
||||
// upsert: substitui se já existia o mesmo instance_id
|
||||
numbers: [...s.numbers.filter((n) => n.instance_id !== num.instance_id), num]
|
||||
.sort((a, b) => a.priority - b.priority),
|
||||
}))
|
||||
},
|
||||
|
||||
updateNumber: async (id, d) => {
|
||||
const updated = await secNumberApi.update(id, d)
|
||||
set((s) => ({ numbers: s.numbers.map((n) => (n.id === id ? updated : n)).sort((a, b) => a.priority - b.priority) }))
|
||||
},
|
||||
|
||||
deleteNumber: async (id) => {
|
||||
await secNumberApi.delete(id)
|
||||
set((s) => ({ numbers: s.numbers.filter((n) => n.id !== id) }))
|
||||
},
|
||||
}))
|
||||
@@ -0,0 +1,117 @@
|
||||
// ─── Rich payload types ───────────────────────────────────────────────────────
|
||||
export type NativeBtn =
|
||||
| { type: 'reply'; id: string; text: string }
|
||||
| { type: 'url'; text: string; url: string }
|
||||
| { type: 'copy'; text: string; copyText: string }
|
||||
| { type: 'call'; text: string; phoneNumber: string }
|
||||
|
||||
export interface RichPayload {
|
||||
text?: string
|
||||
footer?: string
|
||||
nativeButtons?: NativeBtn[]
|
||||
nativeList?: {
|
||||
buttonText: string
|
||||
sections: Array<{ title: string; rows: Array<{ id: string; title: string; description?: string }> }>
|
||||
}
|
||||
nativeCarousel?: {
|
||||
cards: Array<{
|
||||
title?: string; body?: string; footer?: string
|
||||
image?: { url: string }
|
||||
buttons: Array<{ type?: string; id?: string; text: string }>
|
||||
}>
|
||||
}
|
||||
poll?: { name: string; values: string[]; selectableCount?: number }
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: number;
|
||||
chat_id: number;
|
||||
message_id: string;
|
||||
text: string;
|
||||
type: string;
|
||||
direction: 'in' | 'out';
|
||||
status: 'sending' | 'pending' | 'sent' | 'delivered' | 'read' | 'deleted' | 'failed';
|
||||
timestamp: string;
|
||||
media_url?: string;
|
||||
media_type?: string;
|
||||
media_size?: number;
|
||||
quoted_id?: string | null;
|
||||
quoted_message_id?: string | null;
|
||||
quoted_message_text?: string | null;
|
||||
quoted_participant?: string | null;
|
||||
reactions?: Record<string, string> | null;
|
||||
participant?: string | null;
|
||||
instance_name?: string;
|
||||
sender_jid?: string;
|
||||
rich_payload?: RichPayload | null;
|
||||
transcription?: string | null;
|
||||
}
|
||||
|
||||
export interface WhatsAppInstance {
|
||||
instance: string;
|
||||
/** New Whats API status: open, connected, disconnected, connecting, etc. */
|
||||
status: 'connecting' | 'qr' | 'connected' | 'disconnected' | 'open' | 'close';
|
||||
phone?: string;
|
||||
hasQr: boolean;
|
||||
createdAt: string;
|
||||
profilePictureUrl?: string;
|
||||
photoUpdatesRemaining?: number;
|
||||
partner_id?: number | null;
|
||||
partner_name?: string | null;
|
||||
/** For UI display primarily from the Console/Admin View */
|
||||
id?: string;
|
||||
nome?: string;
|
||||
avatar?: string | null;
|
||||
webhook_url?: string;
|
||||
metrics?: {
|
||||
sent: number;
|
||||
failed: number;
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Inbox / Chat UI types ────────────────────────────────────────────────────
|
||||
// Formato legado consumido por ChatList, ChatListItem, ChatSidebar e hooks de inbox.
|
||||
// Produzido pelo mapper chatToLegacy() em useNewInboxBridge.
|
||||
|
||||
export interface NewWhatsChat {
|
||||
id: any
|
||||
instance_name: string
|
||||
remote_jid: string
|
||||
phone: string
|
||||
lead_name: string | null
|
||||
avatar_url: string | null
|
||||
avatar_version: string | null
|
||||
bio: string | null
|
||||
displayName: string | null
|
||||
subject: string | null
|
||||
last_message_text: string | null
|
||||
last_message_time: string | null
|
||||
last_message_from_me: 0 | 1
|
||||
last_message_status: string | null
|
||||
last_message_type?: string | null
|
||||
last_message_sender?: string | null
|
||||
unread_count: number
|
||||
is_pinned: boolean | null
|
||||
is_archived: boolean | null
|
||||
muted_until?: string | null
|
||||
bot_paused?: boolean | null
|
||||
labels?: Array<{ label_id: string; name?: string }>
|
||||
}
|
||||
|
||||
export interface PresenceState {
|
||||
state: 'composing' | 'recording' | 'paused' | 'available' | 'unavailable'
|
||||
ts: number
|
||||
}
|
||||
|
||||
export type PurgeMode = 'all' | 'data_only';
|
||||
export type StepStatus = 'waiting' | 'running' | 'ok' | 'error' | 'skip';
|
||||
|
||||
export interface PurgeStep {
|
||||
id: string;
|
||||
label: string;
|
||||
detail: string;
|
||||
icon?: any;
|
||||
status: StepStatus;
|
||||
}
|
||||
|
||||
export default function TypesPage() { return null; }
|
||||
@@ -0,0 +1,100 @@
|
||||
const getAccessToken = () => {
|
||||
if (typeof window !== 'undefined') return localStorage.getItem('token') || '';
|
||||
return '';
|
||||
};
|
||||
|
||||
export const normalizeJid = (jid: string) => {
|
||||
if (!jid) return '';
|
||||
const [user] = jid.split('@');
|
||||
const [cleanUser] = user.split(':');
|
||||
return cleanUser;
|
||||
};
|
||||
|
||||
export const formatPhone = (phone: string, remoteJid?: string): string => {
|
||||
const raw = String(phone || '').trim();
|
||||
const jid = String(remoteJid || '');
|
||||
|
||||
const cleaned = raw.replace(/\D/g, '');
|
||||
|
||||
// ── @lid — identificador interno do WhatsApp: exibe como +número ──────────
|
||||
if (jid.endsWith('@lid')) {
|
||||
const id = (normalizeJid(jid) || cleaned).replace(/\D/g, '');
|
||||
return id ? `+${id}` : raw;
|
||||
}
|
||||
|
||||
// ── Brasil (55) — 12 ou 13 dígitos ───────────────────────────────────────
|
||||
if ((cleaned.length === 12 || cleaned.length === 13) && cleaned.startsWith('55')) {
|
||||
return cleaned.replace(/^(\d{2})(\d{2})(\d{4,5})(\d{4})$/, '+$1 $2 $3-$4');
|
||||
}
|
||||
|
||||
// ── EUA / Canadá (1) — 11 dígitos ────────────────────────────────────────
|
||||
if (cleaned.length === 11 && cleaned.startsWith('1')) {
|
||||
return cleaned.replace(/^(\d{1})(\d{3})(\d{3})(\d{4})$/, '+$1 ($2) $3-$4');
|
||||
}
|
||||
|
||||
// ── Outros países — formato E.164 genérico ────────────────────────────────
|
||||
if (cleaned.length >= 7 && cleaned.length <= 15) {
|
||||
return `+${cleaned}`;
|
||||
}
|
||||
|
||||
return raw || cleaned;
|
||||
};
|
||||
|
||||
export const getAvatarInitials = (chat: any) => {
|
||||
const name = chat.displayName || chat.subject || chat.phone || '';
|
||||
return name.slice(0, 2).toUpperCase();
|
||||
};
|
||||
|
||||
export const getAvatarColor = (id: string): string => {
|
||||
let hash = 0;
|
||||
const cleanId = String(id || 'anon');
|
||||
for (let i = 0; i < cleanId.length; i++) {
|
||||
hash = cleanId.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
const h = Math.abs(hash % 360);
|
||||
// HSL: S=65%, L=45% for a vibrant, accessible look with white text
|
||||
return `hsl(${h}, 65%, 45%)`;
|
||||
};
|
||||
|
||||
export const getLabelColor = (index: number) => {
|
||||
const colors = [
|
||||
'bg-gray-500', 'bg-red-500', 'bg-orange-500', 'bg-yellow-500',
|
||||
'bg-green-500', 'bg-teal-500', 'bg-blue-500', 'bg-indigo-500',
|
||||
'bg-purple-500', 'bg-pink-500'
|
||||
];
|
||||
return colors[index % colors.length] || 'bg-gray-500';
|
||||
};
|
||||
|
||||
export const getMediaUrl = (url: string | null | undefined): string | null => {
|
||||
if (!url) return null;
|
||||
// URL absoluta (ex.: Wasabi/CDN público) → usa direto, como o motor exibe.
|
||||
if (url.startsWith('http')) return url;
|
||||
// Mídia do WhatsApp vive no MOTOR (path /media/<instância>/<arquivo>, servido
|
||||
// do Wasabi/local). O satélite proxia em /api/nw/media/* (same-origin, sem auth
|
||||
// no browser) — assim exibe igual ao motor, direto do Wasabi.
|
||||
if (url.startsWith('/media/')) return `/api/nw/media/${url.slice('/media/'.length)}`;
|
||||
const clean = url.startsWith('/') ? url.slice(1) : url;
|
||||
return `/api/nw/media/${clean}`;
|
||||
};
|
||||
|
||||
export const getAvatarProxyUrl = (chat: any, _type: 'instance' | 'contact' = 'contact'): string | null => {
|
||||
// Satélite: não há proxy de avatar exposto na ext API — retorna null e o
|
||||
// componente Avatar cai para as iniciais (fallback limpo, sem 404s).
|
||||
return null;
|
||||
};
|
||||
|
||||
export const parseSafeDate = (timestamp: any): Date => {
|
||||
if (!timestamp) return new Date();
|
||||
let d = new Date(timestamp);
|
||||
// If invalid and a number (or numeric string)
|
||||
if (isNaN(d.getTime())) {
|
||||
const num = Number(timestamp);
|
||||
if (!isNaN(num)) {
|
||||
// Unix seconds (Whats APIs often use this) vs ms
|
||||
d = new Date(num > 10000000000 ? num : num * 1000);
|
||||
}
|
||||
}
|
||||
return isNaN(d.getTime()) ? new Date() : d;
|
||||
};
|
||||
|
||||
export default function UtilsPage() { return null; }
|
||||
@@ -13,7 +13,11 @@ export const getActivePlugins = (): string[] => {
|
||||
};
|
||||
|
||||
// Plugins sempre ligados (capability de plataforma, sem opção de desativar).
|
||||
export const ALWAYS_ON_PLUGINS = ['locacao-salas'];
|
||||
// 'newwhats': Inbox/Secretária precisam aparecer para TODOS os usuários, mas a
|
||||
// ativação por plugin é local (localStorage/navegador) e não propaga entre contas.
|
||||
// Por isso fica ALWAYS_ON; a CONFIG continua exclusiva do superadmin (view 'plugins').
|
||||
// (Futuro: gate por /api/nw/status quando o backend expuser "configurado".)
|
||||
export const ALWAYS_ON_PLUGINS = ['locacao-salas', 'newwhats'];
|
||||
|
||||
export const isPluginActive = (id: string): boolean => ALWAYS_ON_PLUGINS.includes(id) || getActivePlugins().includes(id);
|
||||
|
||||
|
||||
@@ -5,10 +5,18 @@ import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, '.', '');
|
||||
// Servido atrás de um proxy TLS (ex.: dev.scoreodonto.com via nginx/traefik):
|
||||
// libera o host e roteia o websocket de HMR por wss:443. Só ativa quando
|
||||
// DEV_PUBLIC_HOST está definido (container dev) — `npm run dev` local não muda.
|
||||
const proxiedHost = process.env.DEV_PUBLIC_HOST;
|
||||
return {
|
||||
server: {
|
||||
port: 3000,
|
||||
host: 'localhost',
|
||||
...(proxiedHost ? {
|
||||
allowedHosts: [proxiedHost],
|
||||
hmr: { protocol: 'wss', host: proxiedHost, clientPort: 443 },
|
||||
} : {}),
|
||||
},
|
||||
plugins: [
|
||||
react(),
|
||||
|
||||
@@ -36,6 +36,18 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Proxy /nw/* para o Backend — a Secretária IA do motor NewWhats consulta
|
||||
# os dados desta clínica (rotas /nw/* do plugin satélite).
|
||||
location /nw/ {
|
||||
set $backend_upstream http://scoreodonto-backend:8018;
|
||||
proxy_pass $backend_upstream;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Proxy para o Frontend (Vite React SPA)
|
||||
location / {
|
||||
set $frontend_upstream http://scoreodonto-frontend:3013;
|
||||
|
||||
Reference in New Issue
Block a user