Compare commits
91 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b67fa15c55 | |||
| cfeac891fb | |||
| f30b0ab732 | |||
| ddcc639ab6 | |||
| 882af16458 | |||
| 4dea9ab28a | |||
| 26f39a60f3 | |||
| f806835ae3 | |||
| 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 | |||
| 7c8c8fc0d2 | |||
| 7566c01159 | |||
| 87411e1566 | |||
| 29b5b38678 | |||
| 361f3809a7 | |||
| 4673f3c606 | |||
| dc5a2d89ae | |||
| 7671199420 | |||
| 6cd8c1f9e1 | |||
| c084ad553b | |||
| cd89ca245c | |||
| 07d798c370 | |||
| 6a88995146 | |||
| f5a6dc7ea0 | |||
| b5d093d2ce | |||
| 489d2753ee | |||
| d357f3a2e8 | |||
| 2e90a2ba5f | |||
| 53cf2c52e6 | |||
| 8040e70310 | |||
| 8552e87665 | |||
| 28dc27a289 | |||
| f0b7a45d86 | |||
| 672c3b01e5 | |||
| 5f4460e37d | |||
| e05231663f | |||
| 84f4a797d2 | |||
| ae48feaa8a | |||
| c8d679eb70 | |||
| 3ffaff9d7d | |||
| 7ae6993c4b | |||
| 9f103be397 | |||
| f4f433b14c | |||
| 8cf4a83161 | |||
| 623c9990d6 | |||
| b80d061a6f | |||
| 064f36d614 | |||
| 50e1483ad5 | |||
| a1c203ced5 | |||
| 9ed8bc25bd | |||
| 8676308325 | |||
| 983582f066 | |||
| 718a7886e1 | |||
| 9b0ba65eca | |||
| 553efb003b | |||
| 321ca7e127 | |||
| d005fe77a9 | |||
| 0ade8027f5 | |||
| 8f9177a1c1 |
@@ -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
|
||||
}
|
||||
+1766
-79
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
||||
# Agenda & Coleta do laboratório
|
||||
|
||||
> Desenho + implementação (24/jun/2026). Gap apontado na auditoria: o lab não tem agenda de
|
||||
> produção nem coleta logística (só "próximas entregas" por prazo e custódia de posse).
|
||||
|
||||
## Conceito
|
||||
- **Coleta** = o lab **busca** o trabalho na clínica (trabalho vai p/ o laboratório).
|
||||
- **Entrega** = o lab **leva** o trabalho de volta (trabalho volta p/ a clínica).
|
||||
- Cada agendamento tem data/hora, endereço, responsável, observação e status
|
||||
(`agendada | realizada | cancelada`). Ligado a uma OS.
|
||||
- **Integra com a custódia:** ao marcar `realizada`, a custódia muda automaticamente
|
||||
(coleta→`laboratorio`, entrega→`clinica`) e entra no histórico da OS. Sem dado duplicado.
|
||||
|
||||
## Modelo
|
||||
```sql
|
||||
CREATE TABLE protese_coleta (
|
||||
id TEXT PRIMARY KEY,
|
||||
os_id TEXT NOT NULL,
|
||||
clinica_id TEXT,
|
||||
laboratorio_id TEXT,
|
||||
protetico_id TEXT,
|
||||
tipo TEXT NOT NULL, -- coleta | entrega
|
||||
data_hora TIMESTAMPTZ,
|
||||
endereco TEXT,
|
||||
responsavel TEXT,
|
||||
observacao TEXT,
|
||||
status TEXT DEFAULT 'agendada', -- agendada | realizada | cancelada
|
||||
created_by TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
## Endpoints
|
||||
- `POST /api/protese/os/:id/coleta` — agenda (clínica ou lab).
|
||||
- `GET /api/protese/lab/agenda` — coletas/entregas do lab (cronológica) + próximas entregas por prazo.
|
||||
- `POST /api/protese/coleta/:id/status` — `realizada` (move a custódia) | `cancelada`.
|
||||
- GET detalhe da OS passa a trazer `coletas[]`.
|
||||
|
||||
## UI
|
||||
- **Lab — tela Agenda** (`lab-agenda`, novo item de menu): linha do tempo de coletas/entregas
|
||||
agendadas + entregas previstas (prazo da OS), com ação "marcar realizada".
|
||||
- **OS (aba Monitoramento)** — agendar coleta/entrega ao lado da custódia (mesmo contexto físico).
|
||||
|
||||
## Decisões
|
||||
- 1 coleta por OS (MVP). Agrupar várias OS numa rota/motoboy fica como evolução.
|
||||
- `realizada` é a fonte única do movimento físico (atualiza custódia) — evita registrar custódia
|
||||
e coleta em separado.
|
||||
@@ -0,0 +1,158 @@
|
||||
# Análise — Área da Recepção / Coordenação de Tratamentos (Treatment Coordinator)
|
||||
|
||||
> Gatilho (23/jun/2026): `recep.consulttclinic@gmail.com` lançou uma prótese mas **não tinha
|
||||
> a aba para acompanhá-la**. Análise do gap + recomendação fundamentada em modelos validados
|
||||
> de software odontológico. Conecta com `FASE2-PROTETICO-AREA.md` (o outro lado do fluxo).
|
||||
|
||||
---
|
||||
|
||||
## 1. Diagnóstico (DEV `scoreodonto_dev`)
|
||||
|
||||
| Entidade | Achado |
|
||||
|---|---|
|
||||
| Dono | `consulttclinic@gmail.com` → *Rui Cesar*, `donoclinica` |
|
||||
| Funcionária | `recep.consulttclinic@gmail.com` (`u_1780962424310_cqsxn`), role `funcionario`, vínculo na **CONSULTT CLINIC LTDA** (`c_1780871001549_koqa3o`) |
|
||||
| Cargo | função "Recepcionista" (`fnc_1780962382528_m28go`) |
|
||||
| Permissões (antes) | `["agenda","ortodontia","contratos","tratamentos","pacientes","lancar-gto"]` |
|
||||
| Prótese lançada | OS `pos_1782228267898_zgfzn` — Prótese Total, paciente ABIGAIR, R$600, protético "Zeca Andrade Fake", status `solicitado`, **via GTO** (`gto_item_id=QOJWR6D`) |
|
||||
|
||||
**Gap exato:** o cargo tinha `lancar-gto` (lançar) mas **não** `proteses` (acompanhar). O RBAC da
|
||||
`Sidebar.tsx` é literal (`return wsPerms.includes(item.id)`), então a OS criada por ela ficava
|
||||
**invisível** para ela própria. A Fase 1 (GTO→OS) funcionou; o problema era de **configuração de
|
||||
cargo**, não de arquitetura.
|
||||
|
||||
---
|
||||
|
||||
## 2. Modelos validados (referência externa)
|
||||
|
||||
- **Papel = "Treatment Coordinator" + Front Desk.** É a ponte entre paciente e equipe clínica:
|
||||
sequencia planos multi-etapa, conduz o aceite do caso (case acceptance), arranjo financeiro e
|
||||
follow-up até a conclusão — exatamente "gerir tratamentos" de ponta a ponta.
|
||||
- **RBAC por papel é o padrão.** Permissões mapeadas a papéis, não a indivíduos; o front desk
|
||||
recebe agenda/cadastro/cobrança, tipicamente **sem notas clínicas**. O ScoreOdonto já faz isso
|
||||
via `funcoes.permissoes[]`.
|
||||
- **Prótese = "Lab Case", acompanhada pelo front desk.** No Dentrix Lab Case Manager: clínico cria
|
||||
a prescrição → status *Sent* → **o front desk muda para *Received* quando volta do lab** →
|
||||
*Finished* ao assentar. Ou seja: acompanhar a OS de prótese é, por modelo consagrado, **atribuição
|
||||
da recepção** — o acesso que faltava.
|
||||
|
||||
Fontes:
|
||||
- The Role of a Treatment Coordinator — https://www.dentalmanagers.com/blog/role-of-treatment-coordinator-in-dental-practice/
|
||||
- What Does a Treatment Plan Coordinator Do? — https://www.teero.com/blog/treatment-plan-coordinator
|
||||
- Dental Software User Permissions (Curve) — https://www.curvedental.com/blog/dental-software-user-permissions
|
||||
- RBAC in Healthcare — https://www.accountablehq.com/post/role-based-access-control-rbac-in-healthcare-benefits-examples-and-best-practices
|
||||
- Manage Your Lab Cases in Dentrix — https://magazine.dentrix.com/manage-your-lab-cases-in-dentrix/
|
||||
- Open Dental — Lab Cases — https://www.opendental.com/manual/labcasemanage.html
|
||||
|
||||
---
|
||||
|
||||
## 3. Ação aplicada — Nível 1 (configuração, sem código)
|
||||
|
||||
✅ **Aplicado no DEV em 23/jun/2026.** Função "Recepcionista" da CONSULTT CLINIC LTDA atualizada:
|
||||
|
||||
```sql
|
||||
UPDATE funcoes
|
||||
SET permissoes = '["agenda","pacientes","tratamentos","ortodontia","proteses","lancar-gto","orcamentos","contratos"]'
|
||||
WHERE id = 'fnc_1780962382528_m28go'; -- UPDATE 1
|
||||
```
|
||||
|
||||
Acréscimos: **`proteses`** (acompanhar a OS que ela lança) e **`orcamentos`** (case acceptance —
|
||||
coração do papel TC). Efeito imediato: a OS `pos_1782228267898_zgfzn` passa a aparecer para ela.
|
||||
|
||||
> Escopo: alteração **só no DEV**. Não há mudança de código nem migração — é dado de configuração.
|
||||
> Para refletir em outras clínicas, ajustar o cargo equivalente de cada uma (ou rever o template
|
||||
> de cargos padrão, se existir).
|
||||
|
||||
---
|
||||
|
||||
## 3b. Causa raiz (investigação 23/jun) — `proteses` não é concedível pela UI
|
||||
|
||||
A correção de dado acima trata o sintoma. A **causa raiz** é um bug de configuração de RBAC:
|
||||
|
||||
- Cargos **não** têm seed no backend (signup não cria funções). São criados manualmente pelo
|
||||
dono em Gestão de Equipe (`POST → INSERT INTO funcoes`, `server.js:806`), partindo de 4
|
||||
templates do frontend (`GestaoEquipeView.tsx:96`).
|
||||
- **Nenhum** template inclui `proteses`: RECEPÇÃO (`dashboard,agenda,pacientes,leads,orcamentos`),
|
||||
AUXILIAR (`...,ortodontia,tratamentos`), FINANCEIRO (`...,financeiro,relatorios,lancar-gto`),
|
||||
GERENTE (`[...TODAS_PAGINAS]`).
|
||||
- **Raiz:** `proteses` **não está em `PAGINAS_RBAC`** (`GestaoEquipeView.tsx:57`), a lista mestre
|
||||
de páginas atribuíveis. Como `TODAS_PAGINAS = PAGINAS_RBAC.flatMap(...)`, **nem o GERENTE /
|
||||
"MARCAR TODAS"** concede prótese. ⇒ **É impossível um dono dar `proteses` a um funcionário
|
||||
pela interface** — só via UPDATE direto no banco (o que foi feito na §3).
|
||||
- Também ausentes da lista atribuível, embora em uso: **`contratos`** (a função tinha — legado),
|
||||
`comissoes`, `glosas`.
|
||||
|
||||
**Resposta à pergunta "o cargo Recepcionista padrão nasce sem proteses?":** Sim — e mais,
|
||||
*nenhum* cargo consegue tê-la pela UI hoje.
|
||||
|
||||
### Fix de raiz (código) — ✅ aplicado e validado no DEV (23/jun)
|
||||
Em `frontend/views/GestaoEquipeView.tsx`:
|
||||
- `PAGINAS_RBAC`, grupo `ATENDIMENTO`: + `{ key: 'proteses', label: 'PRÓTESES' }` e
|
||||
`{ key: 'contratos', label: 'CONTRATOS' }` → agora concedíveis pela UI (e entram em
|
||||
`TODAS_PAGINAS`/GERENTE/"MARCAR TODAS").
|
||||
- Templates: RECEPÇÃO ganhou `contratos, lancar-gto, proteses`; AUXILIAR ganhou `proteses`
|
||||
(alinhado ao papel TC).
|
||||
|
||||
**Validação:** `vite build` transformou 2382 módulos OK; container DEV rebuildado e no ar; o
|
||||
bundle servido pelo nginx DEV (`index-*.js`, alvo de `dev.scoreodonto.com`) contém
|
||||
`key:"proteses",label:"PRÓTESES"` e a string `proteses` nos 3 pontos editados. Cargos padrão
|
||||
passam a nascer coerentes — sem UPDATE manual por clínica.
|
||||
|
||||
> ⚠️ Pendente de commit + deploy à produção (a mudança está só no DEV). Os fixes de **dado**
|
||||
> (§3, função da CONSULTT CLINIC) continuam sendo só-DEV e não se propagam por este fix de código.
|
||||
|
||||
## 4. Recomendação — Nível 2 (estrutural, "Fase 3")
|
||||
|
||||
Em vez de abas soltas (`tratamentos`, `proteses`, `ortodontia`), um **hub de Coordenação de
|
||||
Tratamentos por paciente**: pipeline único com o status de cada frente (clínico / prótese-lab /
|
||||
orto), espelhando o "lab case visível para toda a equipe" do Dentrix. View nova que **agrega o que
|
||||
já existe** (`tratamentos` + `protese_os` + ortodontia por paciente) — sem dado novo.
|
||||
|
||||
## 5. Conexão com a Fase 2
|
||||
Dois lados do mesmo fluxo, unidos pela mesma `protese_os`:
|
||||
- **Fase 2** → casa do **protético** (quem produz; avança status na Bancada).
|
||||
- **Esta análise** → **recepção/TC** (quem encomenda e acompanha; lê o status no hub).
|
||||
O status que o lab avança é o mesmo que a recepção lê — o "Lab Case Manager" repartido entre os papéis.
|
||||
|
||||
---
|
||||
|
||||
## 6. Salas: acesso do funcionário corrigido + bloqueio de manutenção (23/jun)
|
||||
|
||||
Gatilho: a mesma recepcionista aparecia com **MINHAS SALAS** e **ALUGAR SALAS** (gestão de
|
||||
locação, de titular) e deveria ter apenas a **agenda da sala** e o **bloqueio para manutenção**.
|
||||
|
||||
### 6a. Bug — vazamento dos menus de locação ✅ corrigido (`Sidebar.tsx`)
|
||||
`MINHAS SALAS` (locador) e `ALUGAR SALAS` (locatário) eram empurrados a "todos menos
|
||||
superadmin" (`Sidebar.tsx:42`), vazando para **funcionário e paciente** — papéis que não são
|
||||
titulares de sala. Passaram a exibir apenas a `PAPEIS_LOCACAO`
|
||||
(`donosala/donoclinica/donoconsultorio/admin/dentista/biomedico/protetico`). O backend já era
|
||||
seguro por ownership; era vazamento só de UI.
|
||||
|
||||
### 6b. Feature — acesso operacional + manutenção ✅ implementada e validada no DEV
|
||||
Não existia caminho para "funcionário vê só a agenda" nem "bloqueio para manutenção" — foram
|
||||
criados (modelos de mercado: *Dentrix/Open Dental Lab... não; aqui é gestão de espaço* — o
|
||||
padrão é **front desk opera a agenda da sala sem tocar no financeiro do locador**).
|
||||
|
||||
- **Acesso operacional** (`server.js` `listarWorkspaces`): sala cuja `clinica_id` é uma clínica
|
||||
onde o usuário tem vínculo (e ele **não** é o dono) entra como workspace `tipo='sala'` com
|
||||
`papel_sala='operador'`. O `SalaHomeView` detecta o modo operador e **esconde recebíveis,
|
||||
relatório e baixa** — mostra só a **agenda** + botão de bloqueio.
|
||||
- **Bloqueio de manutenção** (`POST /api/salas/:id/manutencao`): reserva `tipo='manutencao'`
|
||||
(coluna nova, default `'locacao'`), `valor=0`, nasce `'confirmada'` (sem aprovação) e **não
|
||||
gera recebível**. Acesso: dono **ou** membro com vínculo na clínica da sala. Reaproveita o
|
||||
anti-conflito de horário (409) e o cancelamento por solicitante/unidade. A agenda renderiza
|
||||
o bloqueio em **cinza** (legenda "Manutenção").
|
||||
|
||||
**Validação ponta a ponta (DEV, JWT real da recepcionista, sala de teste vinculada à clínica):**
|
||||
| Caso | Resultado |
|
||||
|---|---|
|
||||
| Sala da clínica vira workspace operador dela | ✅ query retorna a sala |
|
||||
| POST bloqueio | ✅ `status:confirmada, tipo:manutencao` |
|
||||
| GET agenda mostra bloqueio + motivo | ✅ `valor:0, motivo:"Conserto do equipo"` |
|
||||
| Conflito de horário | ✅ HTTP 409 |
|
||||
| Não gera financeiro | ✅ 0 lançamentos |
|
||||
|
||||
Commits: `fix(salas)` (menu) + `feat(salas)` (operador + manutenção). Backend e frontend no ar
|
||||
no DEV; dados de teste limpos.
|
||||
|
||||
> ⚠️ Pendente de push + deploy (produção segue em v1.0.19).
|
||||
@@ -0,0 +1,217 @@
|
||||
# Fase 2 — Detalhamento de implementação: passos 2.0 e 2.1
|
||||
|
||||
> ✅ **IMPLEMENTADO e validado em DEV (23/jun/2026).** Commits `feat(protese): Fase 2 backend`
|
||||
> + `frontend`. Validações: registro de protético nasce `laboratorio`; backfill preenche
|
||||
> `laboratorio_id`; bancada roteia por lab (fallback por pessoa); entrega gera **par espelhado**
|
||||
> DESPESA(clínica)+RECEITA(lab) R$600, idempotente (409 na re-entrega); menu do lab no bundle.
|
||||
>
|
||||
> ⚠️ **Bug pré-existente achado e corrigido junto:** o INSERT da DESPESA de prótese (Fase 1, já
|
||||
> em produção v1.0.20) usava `paciente_nome` — coluna inexistente em `financeiro` (é
|
||||
> `pacientenome`). A entrega de OS com `custo>0` **abortava** em produção. Corrigido no mesmo
|
||||
> commit de backend. **Recomenda-se priorizar o deploy** por causa disso.
|
||||
|
||||
|
||||
> Companheiro de `FASE2-PROTETICO-AREA.md` (o modelo). Aqui está o **como**, a nível de
|
||||
> arquivo/função, fiel ao código atual de `backend/server.js` e `frontend/`.
|
||||
> Decisões aplicadas: **1 lab por protético**, **só terceirizado**, **receita espelhada na 2.1**.
|
||||
> Disciplina herdada da Fase 1: migrações aditivas/idempotentes; blocos novos em `try/catch`
|
||||
> que nunca derrubam o fluxo principal; `protetico_id` sempre preservado (fallback).
|
||||
|
||||
---
|
||||
|
||||
# PASSO 2.0 — Dados invisíveis (backfill `laboratorio_id`)
|
||||
|
||||
Nenhuma mudança de UI. Objetivo: todo protético tem um workspace `tipo='laboratorio'` e toda
|
||||
OS/produto carrega `laboratorio_id`. Validar 100% em DEV (`pgdev`) antes de seguir.
|
||||
|
||||
## 2.0.a — Migrações (em `runMigrations()`, junto às da Fase 1, ~linha 5663)
|
||||
|
||||
```js
|
||||
// ── FASE 2.0: Laboratório como workspace de 1ª classe ──────────────────────
|
||||
`ALTER TABLE protese_os ADD COLUMN IF NOT EXISTS financeiro_lab_id TEXT`, // RECEITA espelhada (2.1)
|
||||
`ALTER TABLE protese_produtos ADD COLUMN IF NOT EXISTS laboratorio_id TEXT`,
|
||||
// laboratorio_id em protese_os JÁ EXISTE (coluna reservada na criação da tabela) — não recriar.
|
||||
|
||||
// re-tag: o workspace pessoal do protético passa a ser 'laboratorio'
|
||||
`UPDATE clinicas SET tipo='laboratorio'
|
||||
WHERE tipo='pessoal'
|
||||
AND owner_id IN (SELECT id FROM usuarios WHERE role='protetico')`,
|
||||
|
||||
// backfill: cada OS herda o laboratorio do workspace do seu protético
|
||||
`UPDATE protese_os o SET laboratorio_id = c.id
|
||||
FROM clinicas c
|
||||
WHERE o.laboratorio_id IS NULL AND c.tipo='laboratorio' AND c.owner_id = o.protetico_id`,
|
||||
|
||||
// backfill: catálogo passa a ser do laboratório (mantém protetico_id p/ compat)
|
||||
`UPDATE protese_produtos p SET laboratorio_id = c.id
|
||||
FROM clinicas c
|
||||
WHERE p.laboratorio_id IS NULL AND c.tipo='laboratorio' AND c.owner_id = p.protetico_id`,
|
||||
|
||||
// índice por lab (o de protetico_id já existe; adiciona o do lab)
|
||||
`CREATE INDEX IF NOT EXISTS idx_protese_os_labid ON protese_os (laboratorio_id, status)`,
|
||||
```
|
||||
> O `UPDATE especialidades … area='protese'` da Fase 1 já está logo acima — não duplicar.
|
||||
|
||||
## 2.0.b — Helper: resolver o laboratório de um protético
|
||||
|
||||
Novo helper perto de `proteseOsAccesso` (~linha 3772). 1 lab por protético ⇒ `LIMIT 1`.
|
||||
|
||||
```js
|
||||
// O workspace 'laboratorio' do protético (1 por protético nesta fase).
|
||||
async function labDoProtetico(proteticoId) {
|
||||
if (!proteticoId) return null;
|
||||
const { rows } = await pool.query(
|
||||
"SELECT id FROM clinicas WHERE owner_id=$1 AND tipo='laboratorio' AND COALESCE(deleted_at IS NULL, true) LIMIT 1",
|
||||
[proteticoId]);
|
||||
return rows[0]?.id || null;
|
||||
}
|
||||
```
|
||||
> Se `clinicas` não tiver `deleted_at`, usar só `WHERE owner_id=$1 AND tipo='laboratorio'`.
|
||||
|
||||
## 2.0.c — Gravar `laboratorio_id` na CRIAÇÃO da OS (2 pontos)
|
||||
|
||||
**(i) `POST /api/protese/os`** (~linha 3893): resolver o lab e incluí-lo no INSERT.
|
||||
```js
|
||||
const labId = await labDoProtetico(b.protetico_id); // antes do INSERT
|
||||
// no INSERT: acrescentar a coluna laboratorio_id e o valor labId
|
||||
// ... protetico_id, laboratorio_id, protetico_nome, ...
|
||||
// VALUES ($1,$2,$3,$LAB,$4,...)
|
||||
```
|
||||
|
||||
**(ii) Bloco GTO→protese_os (Fase 1)** em `PUT /api/gto-builder/:id/finalizar`
|
||||
(o INSERT em `protese_os` que você acabou de escrever): mesma adição.
|
||||
```js
|
||||
const labId = await labDoProtetico(it.protetico_id); // dentro do for, antes do INSERT
|
||||
// incluir laboratorio_id = labId no INSERT da OS gerada por GTO
|
||||
```
|
||||
> Assim, OS novas já nascem com `laboratorio_id`; as antigas foram cobertas pelo backfill.
|
||||
|
||||
## 2.0.d — Bancada roteia por laboratório (com fallback por pessoa)
|
||||
|
||||
`GET /api/protese/bancada` (~linha 3926). Hoje: `WHERE protetico_id=$1`. Passa a aceitar OS
|
||||
do(s) laboratório(s) do usuário, mantendo fallback para OS ainda sem `laboratorio_id`:
|
||||
|
||||
```js
|
||||
const uid = req.authUser.userId;
|
||||
const labId = await labDoProtetico(uid); // o lab do protético logado
|
||||
const vals = [uid, labId];
|
||||
let where = `( (laboratorio_id IS NOT NULL AND laboratorio_id=$2)
|
||||
OR (laboratorio_id IS NULL AND protetico_id=$1) ) AND deleted_at IS NULL`;
|
||||
if (req.query.status && PROTESE_STATUS.includes(req.query.status)) {
|
||||
vals.push(req.query.status); where += ` AND status=$${vals.length}`;
|
||||
}
|
||||
// resto idêntico (ORDER BY / LIMIT / map)
|
||||
```
|
||||
> `proteseOsAccesso` **não muda na 2.0** (1 lab por protético ⇒ `protetico_id===uid` ainda
|
||||
> identifica o lab). O `OR por vínculo no lab` entra só na 2.3 (equipe/`tecnico_protese`).
|
||||
|
||||
## 2.0.e — Novos protéticos já nascem com `tipo='laboratorio'`
|
||||
|
||||
No `POST /api/register` (~linha 625), o INSERT do workspace usa `tipo='pessoal'` para todos.
|
||||
Diferenciar o protético:
|
||||
```js
|
||||
const wsTipo = userRole === 'protetico' ? 'laboratorio' : 'pessoal';
|
||||
`INSERT INTO clinicas (id, nome_fantasia, tipo, owner_id) VALUES ($1,$2,$3,$4)` // $3 = wsTipo
|
||||
```
|
||||
> O seed de catálogo (`seedProteseProdutos(userId)`) continua por `protetico_id` (owner) —
|
||||
> sem mudança. (O `laboratorio_id` do catálogo é resolvido pelo backfill / por leitura.)
|
||||
|
||||
## ✅ Critérios de aceite 2.0 (validar em DEV)
|
||||
- `SELECT count(*) FROM protese_os WHERE laboratorio_id IS NULL` → **0** (após backfill).
|
||||
- Toda `clinicas` de protético tem `tipo='laboratorio'`.
|
||||
- Criar OS nova (via Nova OS **e** via finalizar GTO) → `laboratorio_id` preenchido.
|
||||
- Bancada continua mostrando exatamente as mesmas OS de antes (fallback cobre o legado).
|
||||
- **Zero** mudança visível na UI.
|
||||
|
||||
---
|
||||
|
||||
# PASSO 2.1 — Casa própria + receita espelhada
|
||||
|
||||
Agora o protético entra numa **área de Laboratório** (menu próprio) e a entrega passa a
|
||||
lançar a receita no workspace do lab.
|
||||
|
||||
## 2.1.a — BACKEND: receita espelhada na entrega
|
||||
|
||||
Em `POST /api/protese/os/:id/status`, dentro do `if (novo === 'entregue') { ... }`, logo após
|
||||
o bloco que cria a **DESPESA** (`finId`). Mesma condição (`custo>0 && tipo_os≠'garantia'`):
|
||||
|
||||
```js
|
||||
// Espelho: RECEITA no workspace do laboratório (clinica_id = laboratorio_id). Idempotente.
|
||||
let finLabId = os.financeiro_lab_id || null;
|
||||
try {
|
||||
const labId = os.laboratorio_id || await labDoProtetico(os.protetico_id);
|
||||
if (!finLabId && labId && Number(os.custo) > 0 && os.tipo_os !== 'garantia') {
|
||||
finLabId = `fin_lab_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
await pool.query(
|
||||
`INSERT INTO financeiro (id, clinica_id, descricao, valor, tipo, status, datavencimento, data_competencia, paciente_id, paciente_nome, origem, centro_custo, sem_comissao)
|
||||
VALUES ($1,$2,$3,$4,'RECEITA','Pendente',CURRENT_DATE,CURRENT_DATE,$5,$6,'protese','PRÓTESE',true)`,
|
||||
[finLabId, labId, `PRÓTESE: ${os.tipo} (cliente ${clinicaNome || ''})`.trim(), Number(os.custo),
|
||||
os.paciente_id || null, os.paciente_nome || null]);
|
||||
}
|
||||
} catch (e) { console.error('[protese entrega→receita lab]', e.message); }
|
||||
```
|
||||
E no `UPDATE protese_os` da entrega, persistir o id:
|
||||
```js
|
||||
// ...financeiro_id=COALESCE($3,financeiro_id), financeiro_lab_id=COALESCE($N,financeiro_lab_id)...
|
||||
```
|
||||
> `clinicaNome` = nome da clínica de origem (1 query opcional, ou usar `os.clinica_id`).
|
||||
> **Estorno** (cancelar entrega) é tarefa à parte: reverter os dois `financeiro` — fica para
|
||||
> a sub-fase de cancelamento; por ora a entrega é terminal (já é hoje: 409 se `entregue`).
|
||||
|
||||
## 2.1.b — FRONTEND: menu da área de Laboratório (`Sidebar.tsx`)
|
||||
|
||||
**(i)** Acrescentar os itens ao array `menuItems` (~linha 53):
|
||||
```js
|
||||
{ id: 'lab-home', label: 'PAINEL DO LAB', icon: LayoutDashboard },
|
||||
{ id: 'lab-bancada', label: 'BANCADA', icon: FlaskConical },
|
||||
```
|
||||
(catálogo/clientes/agenda/financeiro entram na 2.2/2.3 — não criar itens mortos agora.)
|
||||
|
||||
**(ii)** Isolar o menu quando o workspace ativo é laboratório — **espelhar** o bloco que já
|
||||
existe para `tipo==='sala'` (~linha 98). Inserir antes dele:
|
||||
```js
|
||||
// Contexto de LABORATÓRIO: área própria do protético (não é clínica).
|
||||
if ((activeWorkspace as any)?.tipo === 'laboratorio')
|
||||
return ['lab-home', 'lab-bancada', 'notificacoes', 'configuracoes'].includes(item.id);
|
||||
```
|
||||
> Como o protético tem **1** workspace e ele é `tipo='laboratorio'`, esse branch governa o
|
||||
> menu dele. O antigo filtro `currentRole==='protetico'` (linha ~131) deixa de ser alcançado
|
||||
> para o caso comum, mas **mantê-lo** como rede de segurança (protético sem workspace lab).
|
||||
|
||||
## 2.1.c — FRONTEND: roteamento das telas novas (`App.tsx`)
|
||||
|
||||
No switch/router de `view` do `App.tsx`, mapear:
|
||||
```jsx
|
||||
case 'lab-bancada': return <ProteseView />; // já renderiza modo 'bancada' p/ role protetico
|
||||
case 'lab-home': return <LabHomeView />; // novo painel (abaixo)
|
||||
```
|
||||
> `ProteseView` já decide `modo='bancada'` por `getCurrentRole()==='protetico'` — reaproveita
|
||||
> 100%. Só muda o **id de menu** que a abre.
|
||||
|
||||
## 2.1.d — FRONTEND: `LabHomeView` (painel mínimo)
|
||||
|
||||
Nova view `frontend/views/LabHomeView.tsx`. Cards a partir de dados que já existem:
|
||||
- **Fila por status** e **atrasadas**: derivar de `getProteseBancada()` (já retorna `atrasada`).
|
||||
- **Faturamento do mês**: somar `financeiro` RECEITA `origem='protese'` do workspace lab
|
||||
(rota de financeiro existente, escopada por `clinicaId` = id do lab).
|
||||
- **Prazo médio / entregues no mês**: agregação simples sobre a bancada.
|
||||
|
||||
Sem rota nova obrigatória na 2.1 (reusa `bancada` + `financeiro`); se quiser um resumo de 1
|
||||
chamada, criar `GET /api/protese/lab/resumo` depois.
|
||||
|
||||
## ✅ Critérios de aceite 2.1
|
||||
- Logado como protético, a Sidebar mostra **PAINEL DO LAB** + **BANCADA** (+ notificações/config).
|
||||
- A clínica continua com a tela PRÓTESES (modo `clinica`) **intacta**.
|
||||
- Entregar uma OS com `custo>0` (não-garantia) cria **2** lançamentos: DESPESA na clínica e
|
||||
RECEITA no lab (mesmo valor `custo`); reentregar/reprocessar **não** duplica (`COALESCE`).
|
||||
- OS de garantia entregue → **nenhum** lançamento (nos dois lados).
|
||||
- `LabHomeView` mostra fila + faturamento do mês do laboratório.
|
||||
|
||||
---
|
||||
|
||||
# Ordem de execução e deploy
|
||||
1. Branch a partir da `main` (que já tem a Fase 1 não-commitada — **commitar a Fase 1 antes**).
|
||||
2. 2.0 (backend só) → subir em DEV (`dev.scoreodonto.com`/`pgdev`), rodar os SELECTs de aceite.
|
||||
3. 2.1 (backend receita + front) → validar aceite em DEV.
|
||||
4. Commit por sub-fase; push → CI builda por commit; `git tag vX.Y.Z` promove à produção.
|
||||
> Migrações aditivas ⇒ rollback de código é seguro (padrão já validado no projeto).
|
||||
@@ -0,0 +1,220 @@
|
||||
# Fase 2 — Área do Protético (Laboratório como workspace de 1ª classe)
|
||||
|
||||
> Desenho de modelo. Continuação da **Fase 1 (GTO → Prótese)**, onde finalizar uma GTO
|
||||
> abre automaticamente uma OS na Bancada (`protese_os.gto_item_id`).
|
||||
> **Objetivo da Fase 2:** o protético deixa de ser "um papel que só vê a Bancada" e passa
|
||||
> a ter uma **área de gestão própria** — o Laboratório — equivalente ao que a Clínica é para
|
||||
> o dentista. Ele gere catálogo, equipe, clientes (clínicas), financeiro e agenda de entregas.
|
||||
|
||||
---
|
||||
|
||||
## 1. Onde estamos hoje (não reinventar)
|
||||
|
||||
O alicerce já existe — a Fase 2 **promove**, não cria do zero:
|
||||
|
||||
| Já existe | Onde |
|
||||
|---|---|
|
||||
| Role `protetico` (válido em `usuarios` e `vinculos`) | `server.js` VALID_ROLES / constraints |
|
||||
| Workspace pessoal "LABORATÓRIO DE …" criado no signup | `clinicas (tipo='pessoal')` + `vinculos (role='protetico')` |
|
||||
| Catálogo de produtos (preço fixo + garantia) | `protese_produtos (protetico_id)` |
|
||||
| Bancada (fila de OS do protético) | `GET /api/protese/bancada` (roteia por `protetico_id`) |
|
||||
| OS com eventos, fotos, status, garantia, financeiro | `protese_os`, `protese_os_evento`, `protese_os_foto` |
|
||||
| `ProteseView` com `modo: 'bancada' | 'clinica'` por role | `frontend/views/ProteseView.tsx` |
|
||||
| **`protese_os.laboratorio_id`** — reservado, hoje `null` | comentário: "workspace destino (futuro)" |
|
||||
| Menu filtrado por role (protético = subset) | `Sidebar.tsx` linha ~131 |
|
||||
|
||||
**Lacuna que a Fase 2 fecha:** o Laboratório não é uma *entidade gerenciável*. O protético
|
||||
roteia OS por `protetico_id` (a pessoa), não por um laboratório (a empresa). Não há painel,
|
||||
equipe, visão de clientes, nem financeiro do lado do laboratório. A OS conhece a *pessoa*,
|
||||
não a *bancada/empresa*.
|
||||
|
||||
---
|
||||
|
||||
## 2. Decisão de modelo: Laboratório = workspace `tipo='laboratorio'`
|
||||
|
||||
O sistema já trata **workspace** de forma polimórfica: `clinicas` é a tabela-base e `tipo`
|
||||
distingue (`clinica` | `pessoal` | `sala`). A Fase 2 introduz **`tipo='laboratorio'`** em vez
|
||||
de criar uma tabela nova. Isso reaproveita: vínculos, troca de workspace, RBAC por cargo,
|
||||
financeiro, notificações e o seletor de workspace da Sidebar.
|
||||
|
||||
```
|
||||
usuarios (role='protetico')
|
||||
│ vinculos (role='protetico' | 'tecnico_protese')
|
||||
▼
|
||||
clinicas (tipo='laboratorio') ◄── "LABORATÓRIO DE X" deixa de ser 'pessoal'
|
||||
│
|
||||
┌───────────┼─────────────────────────────┐
|
||||
▼ ▼ ▼
|
||||
protese_produtos protese_os (laboratorio_id) equipe (vinculos do lab)
|
||||
(catálogo) ▲ roteamento por LAB,
|
||||
│ não mais só por pessoa
|
||||
clínicas-cliente enviam OS ──┘
|
||||
```
|
||||
|
||||
### Migração do dado existente (aditiva, idempotente)
|
||||
```sql
|
||||
-- 1) re-tag do workspace pessoal do protético → laboratorio
|
||||
UPDATE clinicas SET tipo='laboratorio'
|
||||
WHERE tipo='pessoal'
|
||||
AND owner_id IN (SELECT id FROM usuarios WHERE role='protetico');
|
||||
|
||||
-- 2) backfill: toda OS legada ganha o laboratorio_id do workspace do seu protetico
|
||||
UPDATE protese_os o SET laboratorio_id = c.id
|
||||
FROM clinicas c
|
||||
WHERE o.laboratorio_id IS NULL
|
||||
AND c.tipo='laboratorio'
|
||||
AND c.owner_id = o.protetico_id;
|
||||
|
||||
-- 3) o catálogo passa a ser do laboratório (mantém protetico_id p/ compat)
|
||||
ALTER TABLE protese_produtos ADD COLUMN IF NOT EXISTS laboratorio_id TEXT;
|
||||
UPDATE protese_produtos p SET laboratorio_id = c.id
|
||||
FROM clinicas c
|
||||
WHERE p.laboratorio_id IS NULL AND c.tipo='laboratorio' AND c.owner_id = p.protetico_id;
|
||||
```
|
||||
> `protetico_id` **continua** populado em todas as tabelas (compatibilidade da Fase 1 e do
|
||||
> roteamento por pessoa). `laboratorio_id` passa a ser a **chave de escopo** da área.
|
||||
|
||||
---
|
||||
|
||||
## 3. A Área do Laboratório (navegação)
|
||||
|
||||
Quando o `activeWorkspace.tipo === 'laboratorio'`, a Sidebar troca para um menu próprio
|
||||
(análogo ao `tipo==='sala'` que já isola o menu). Itens:
|
||||
|
||||
| Item | Conteúdo | Reaproveita |
|
||||
|---|---|---|
|
||||
| `lab-home` | **Painel** do laboratório: OS por status, atrasadas, faturamento do mês, prazo médio | novo dashboard + agregações de `protese_os` |
|
||||
| `lab-bancada` | **Bancada** (fila de produção, kanban por status) | `ProteseView` modo bancada (já existe) |
|
||||
| `lab-catalogo` | **Catálogo** de produtos (preço/garantia) — vira tela, não modal | `ProdutosModal` → view |
|
||||
| `lab-clientes` | **Clínicas-cliente**: quem envia OS, volume, ticket, pendências | distinct de `protese_os.clinica_id` |
|
||||
| `lab-agenda` | **Agenda de entregas** por `prazo_entrega` | AgendaView adaptada / nova |
|
||||
| `lab-financeiro` | **A receber** das clínicas (a OS gera DESPESA na clínica = RECEITA no lab) | `protese_os.financeiro_id` (elo) |
|
||||
| `lab-equipe` | **Técnicos** do laboratório (RBAC por cargo já existe) | `vinculos` + permissões de workspace |
|
||||
| `notificacoes` / `configuracoes` | base | já existe |
|
||||
|
||||
A tela "PRÓTESES" da **clínica** (modo `clinica`) não muda — ela continua **enviando** OS.
|
||||
O que muda é o **lado de quem recebe**, que ganha uma casa própria.
|
||||
|
||||
### Roteamento de OS: pessoa → laboratório
|
||||
- Hoje: `bancada` filtra `WHERE protetico_id = me`.
|
||||
- Fase 2: `bancada` filtra `WHERE laboratorio_id = activeWorkspace.id` (com fallback
|
||||
`OR (laboratorio_id IS NULL AND protetico_id = me)` durante a transição).
|
||||
- Ganho imediato: **mais de um técnico** pode atender a mesma bancada (equipe), e o protético
|
||||
dono pode ter **mais de um laboratório**.
|
||||
|
||||
---
|
||||
|
||||
## 4. Modelo de dados — deltas da Fase 2
|
||||
|
||||
Tudo aditivo (migrações no padrão `ALTER TABLE … IF NOT EXISTS`, como a Fase 1):
|
||||
|
||||
```sql
|
||||
-- workspace
|
||||
clinicas.tipo -- novo valor possível: 'laboratorio'
|
||||
|
||||
-- escopo por laboratório (a empresa), além de protetico_id (a pessoa)
|
||||
protese_os ADD laboratorio_id TEXT -- (já reservado; passa a ser populado/backfill)
|
||||
protese_os ADD financeiro_lab_id TEXT -- RECEITA espelhada no lab (§9; idempotência)
|
||||
protese_produtos ADD laboratorio_id TEXT
|
||||
|
||||
-- equipe do laboratório: novo role de vínculo (técnico não-dono)
|
||||
vinculos.role -- incluir 'tecnico_protese' no CHECK constraint
|
||||
|
||||
-- relação clínica↔laboratório (opcional, p/ "meus laboratórios favoritos"/preços negociados)
|
||||
CREATE TABLE IF NOT EXISTS lab_clientes (
|
||||
id TEXT PRIMARY KEY,
|
||||
laboratorio_id TEXT NOT NULL,
|
||||
clinica_id TEXT NOT NULL,
|
||||
apelido TEXT,
|
||||
desconto_pct NUMERIC DEFAULT 0, -- preço negociado (futuro)
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE (laboratorio_id, clinica_id)
|
||||
);
|
||||
```
|
||||
|
||||
Índices: `idx_protese_os_lab` passa a ser `(laboratorio_id, status)`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Permissões (RBAC)
|
||||
|
||||
Reaproveita o RBAC por cargo que já existe na Sidebar (`wsRole`/`wsPerms`):
|
||||
|
||||
| Papel no laboratório | Pode |
|
||||
|---|---|
|
||||
| `protetico` (dono do lab) | tudo: catálogo, equipe, financeiro, status de qualquer OS |
|
||||
| `tecnico_protese` (membro) | bancada + avançar status + fotos; **sem** financeiro/equipe/catálogo |
|
||||
| clínica-cliente (`isClinic` em `proteseOsAccesso`) | criar OS, ver suas OS, entregar/cancelar, **sem** ver custo/bancada de outros |
|
||||
|
||||
`proteseOsAccesso(os, userId)` ganha o caso "membro do laboratório dono da OS"
|
||||
(`vinculo em laboratorio_id`), além do dono atual.
|
||||
|
||||
---
|
||||
|
||||
## 6. Plano de implementação incremental
|
||||
|
||||
Cada passo é deployável e reversível (migrações aditivas → rollback de código é seguro).
|
||||
|
||||
- **2.0 — Dados (invisível):** migrações + backfill `laboratorio_id`; `bancada` lê por lab com
|
||||
fallback por pessoa. Nada muda na UI. *Risco mínimo, valida o backfill em DEV (`pgdev`).*
|
||||
- **2.1 — Casa própria + receita espelhada:** `tipo='laboratorio'` re-tagueado; Sidebar com
|
||||
menu do laboratório; `lab-home` (painel) + `lab-bancada`; **espelho de RECEITA na entrega**
|
||||
(§9) — o financeiro mínimo do lab já nesta fase, conforme decisão 3.
|
||||
- **2.2 — Catálogo e clientes:** `ProdutosModal` vira `lab-catalogo`; tela `lab-clientes`.
|
||||
- **2.3 — Financeiro completo e equipe:** `lab-financeiro` (extrato/a-receber por cliente,
|
||||
sobre as receitas já lançadas na 2.1) + `tecnico_protese` (convite de membro reusando o
|
||||
fluxo de equipe existente).
|
||||
- **2.4 — Agenda de entregas + multi-laboratório** (expansão; modelo já suporta, sem migração).
|
||||
|
||||
---
|
||||
|
||||
## 7. O que **não** muda (contrato preservado)
|
||||
- O lado **clínica** de `ProteseView` (modo `clinica`) e o envio de OS.
|
||||
- A **Fase 1**: GTO finalizada continua abrindo OS (`gto_item_id`), agora já com
|
||||
`laboratorio_id` preenchido na criação.
|
||||
- `protetico_id` permanece em todas as tabelas — roteamento por pessoa segue funcionando
|
||||
como fallback; nenhuma OS legada fica órfã.
|
||||
|
||||
---
|
||||
|
||||
## 8. Decisões de produto (fechadas em 23/jun/2026)
|
||||
1. **Um laboratório por protético.** A UI assume 1 lab. O modelo de dados já suporta N
|
||||
(`laboratorio_id` por OS/produto), então multi-lab é expansão futura **sem migração**.
|
||||
2. **Laboratório terceirizado** (empresa separada que cobra da clínica). A flag `interno`
|
||||
de `getProteseLaboratorios` fica como gancho futuro, mas **não** é tratada agora —
|
||||
todo lab gera receita/despesa cruzada entre tenants.
|
||||
3. **Financeiro espelhado já na 2.1.** A OS entregue, que hoje lança **DESPESA na clínica**
|
||||
(`protese_os.financeiro_id`), passa a lançar simultaneamente uma **RECEITA no workspace
|
||||
do laboratório**. Ver §9.
|
||||
|
||||
---
|
||||
|
||||
## 9. Financeiro espelhado (decisão 3) — DESPESA na clínica ↔ RECEITA no lab
|
||||
|
||||
Hoje a entrega da OS gera um lançamento de **DESPESA** na clínica de origem
|
||||
(`protese_os.financeiro_id`), no valor de **`os.custo`** (o preço de catálogo do lab — o que a
|
||||
clínica paga ao laboratório; `os.valor` é o preço ao paciente, markup da clínica, e **não**
|
||||
entra aqui). Como o laboratório agora é um workspace de 1ª classe, o mesmo evento de entrega
|
||||
lança um **par espelhado**, ambos sobre `os.custo`:
|
||||
|
||||
```
|
||||
entrega da OS (status='entregue', custo>0, tipo_os≠'garantia')
|
||||
│
|
||||
├─► financeiro (DESPESA) clinica_id = os.clinica_id valor = os.custo [já existe]
|
||||
└─► financeiro (RECEITA) clinica_id = os.laboratorio_id valor = os.custo [novo]
|
||||
```
|
||||
|
||||
- Mesma **condição** da despesa já existente: só lança se `custo > 0` e `tipo_os ≠ 'garantia'`
|
||||
(OS de garantia refaz sem cobrar). Sai no mesmo `if (novo === 'entregue')`.
|
||||
- Idempotente, no padrão da Fase 1: novo `protese_os.financeiro_lab_id` guarda o id do
|
||||
lançamento de receita; se já existe (`COALESCE`), não duplica.
|
||||
- Isolado em `try/catch` para **nunca** derrubar a transição de status (mesma disciplina do
|
||||
bloco GTO→protese_os da Fase 1).
|
||||
- Estorno: cancelar/estornar a entrega deve reverter **os dois** lançamentos.
|
||||
|
||||
Por isso o **passo 2.1 já inclui o financeiro mínimo** (apenas o espelho de receita na
|
||||
entrega); o item de menu `lab-financeiro` completo (extrato, a-receber por cliente) continua
|
||||
na 2.3.
|
||||
|
||||
> ⚠️ Pré-requisito: `laboratorio_id` precisa estar populado **antes** de ligar o espelho
|
||||
> (senão a receita nasce sem destino). Por isso a ordem **2.0 (backfill) → 2.1** é obrigatória.
|
||||
@@ -0,0 +1,58 @@
|
||||
# Marketplace transacional de Prótese
|
||||
|
||||
> Desenho (24/jun/2026). O último grande item do backlog do Provedor de Prótese.
|
||||
> Princípio: **transformar o diretório (descoberta) em jornada de compra** — descobrir → ver
|
||||
> serviços/preços → solicitar → cotar → contratar → vira OS. Reaproveita ao máximo o que já existe.
|
||||
|
||||
## O que já existe (não recriar)
|
||||
- **Descoberta:** `/api/profissionais/marketplace` (busca por proximidade/especialidade) **+ Nota
|
||||
ScoreOdonto** no card (feito).
|
||||
- **Catálogo:** `protese_produtos` (nome, preço, garantia) + `GET /api/protese/laboratorios/:id/produtos`
|
||||
— já acessível a quem está no **diretório** (`proteticoAcessivelPelaClinica` aceita `disponivel_diretorio`).
|
||||
- **Propostas:** `propostas_profissional` (clínica→profissional: mensagem, `pendente→aceita/recusada`).
|
||||
- **Contratação:** a clínica **já cria OS para qualquer protético do diretório** (Nova OS).
|
||||
- **Pós-venda:** OS completa (etapas, custódia, ocorrências), financeiro, score — tudo pronto.
|
||||
|
||||
**Conclusão:** o "transacional" não exige nova fundação — falta **costurar a jornada** dentro do
|
||||
marketplace (hoje a clínica vê nome/nota/distância, mas não os serviços, e contrata por outra tela).
|
||||
|
||||
## Fatias
|
||||
|
||||
### ✅ Fatia 1 — Vitrine (catálogo no marketplace) — IMPLEMENTADA
|
||||
No card do protético no marketplace, **"Ver catálogo"** expande os produtos (nome, preço, garantia)
|
||||
via `getLaboratorioProdutos(id)` (reuso — zero endpoint novo). A clínica passa a **decidir vendo
|
||||
serviços + preço + garantia + ★nota** antes de contratar. Botão de contato vira **"Solicitar
|
||||
prótese"** (proposta com contexto de prótese).
|
||||
|
||||
### ✅ Fatia 2 — Solicitação → OS direto — IMPLEMENTADA
|
||||
"Solicitar prótese" (card do protético) abre o `SolicitarProteseModal` — fluxo enxuto com o lab
|
||||
**pré-selecionado**: produto do catálogo (ou descrição livre) + paciente (busca) + dentista + dentes
|
||||
+ prazo + observações → **cria a OS** via `createProteseOS` (que já aceita lab do diretório, sem
|
||||
vínculo prévio). Tela de confirmação com CTA para a Bancada. Optei por um modal próprio e auto-contido
|
||||
(reusa getLaboratorioProdutos/getDentistas/getPacientes/createProteseOS) em vez de extrair a Nova OS —
|
||||
menor risco. Validado em DEV: OS criada para lab do diretório (Coroa de Zircônia, status solicitado).
|
||||
|
||||
### ✅ Fatia 3 — Cotação (RFQ) — IMPLEMENTADA
|
||||
Entidades novas `protese_rfq` + `protese_rfq_cotacao`. Fluxo: a clínica descreve o trabalho e
|
||||
**convida N labs** → cada lab **cota** (valor/prazo/obs) → a clínica **compara** (ordenado por preço)
|
||||
e **escolhe** → vira **OS** (custo = valor da cotação) e a RFQ fecha (demais cotações recusadas).
|
||||
Endpoints: `POST/GET /protese/rfq`, `GET /protese/rfq/recebidas`, `POST /protese/rfq/cotacao/:id`,
|
||||
`POST /protese/rfq/:id/escolher`. UI: lado lab = tela **Cotações** (`lab-cotacoes`, responde); lado
|
||||
clínica = botão **Cotações** na tela de Prótese (pedir multi-lab + comparar + escolher). Validado em
|
||||
DEV ponta a ponta (pedido → cotação R$420 → escolha → OS criada → RFQ fechada).
|
||||
|
||||
### ✅ Fatia 4 — Reputação (avaliação + selo) — IMPLEMENTADA
|
||||
A clínica avalia o laboratório (1-5★ + comentário) **após a entrega** (`protese_avaliacao`, 1 por OS,
|
||||
reavaliável). A satisfação **entra na Nota ScoreOdonto**: `0.6·operacional + 0.4·(estrelas/5·10)` (sem
|
||||
avaliações = só operacional). **Selo "Verificado"** com ≥ 5 entregas. Exposto no marketplace (★nota ·
|
||||
nº avaliações · Verificado) e nos indicadores do lab (KPI Satisfação). Endpoint `POST
|
||||
/protese/os/:id/avaliar`. Validado em DEV: 6 entregas + avaliações 5★/3★ → nota **9.2**, satisfação 4★,
|
||||
verificado, em marketplace e indicadores.
|
||||
|
||||
**Marketplace transacional concluído (Fatias 1-4).**
|
||||
|
||||
## Decisões de modelagem
|
||||
- **Catálogo público = catálogo atual** (`protese_produtos`); a vitrine só o exibe — sem duplicar.
|
||||
- **Solicitação reusa a OS** (não cria entidade paralela): toda contratação termina em `protese_os`,
|
||||
preservando rastreabilidade/financeiro/score.
|
||||
- **Cotação (Fatia 3)** é o único caso que precisa de entidade nova (pré-OS multi-lab).
|
||||
@@ -0,0 +1,117 @@
|
||||
# Modo 1 — Provedor de Prótese SEM conta (link seguro `/p/TOKEN`)
|
||||
|
||||
> Desenho (24/jun/2026). Evolução registrada como "fora de escopo" em `PROVEDOR-PROTESE-DECISAO.md`.
|
||||
> Princípio (decisão arquitetural): **a clínica opera 100% da OS; o protético não precisa ter conta.**
|
||||
> O link é a porta de entrada do ecossistema — acompanha hoje, vira conta amanhã.
|
||||
|
||||
## Objetivo
|
||||
A clínica cria/gere a OS normalmente. O protético recebe um **link seguro** (WhatsApp/e-mail) e,
|
||||
**sem login**, acompanha e atua na OS dele. Com CTA para criar conta grátis (conversão).
|
||||
|
||||
```
|
||||
Clínica cria OS ──► gera link /p/TOKEN ──► WhatsApp/e-mail ──► Protético abre (sem conta)
|
||||
├─ vê a OS (sem CPF/valores do paciente)
|
||||
├─ confirma recebimento/devolução
|
||||
├─ avança etapa / anexa foto
|
||||
└─ responde ocorrência
|
||||
"Crie sua conta grátis" ──► cadastro (card Protético)
|
||||
```
|
||||
|
||||
## 1. Escopo do token: **por OS** (não por protético)
|
||||
Escopo mínimo = menor superfície de risco. Um token dá acesso a **uma OS**. Vantagens: revogável
|
||||
individualmente, expira por trabalho, não expõe a carteira inteira do protético. (Evolução: um token
|
||||
"carteira" por protético-na-clínica que lista várias OS — fica para depois.)
|
||||
|
||||
## 2. Modelo de dados (revogável e auditável)
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS protese_os_link (
|
||||
token TEXT PRIMARY KEY, -- aleatório longo (≥ 32 bytes base62), NÃO sequencial
|
||||
os_id TEXT NOT NULL,
|
||||
protetico_id TEXT, -- a quem se destina (rastreio)
|
||||
created_by TEXT, -- quem na clínica gerou
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ, -- opcional (ex.: +30 dias); null = sem expirar
|
||||
revoked_at TIMESTAMPTZ, -- revogação manual
|
||||
last_access_at TIMESTAMPTZ,
|
||||
acessos INTEGER DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_protese_os_link_os ON protese_os_link (os_id);
|
||||
```
|
||||
> Preferir tabela a JWT puro: permite **revogar** e **auditar acessos**. O token nunca contém dados.
|
||||
|
||||
## 3. Rotas públicas (sem `authGuard`; o token é a credencial)
|
||||
Todas resolvem `os_id` pelo token (valida não-revogado, não-expirado) e aplicam a **mesma blindagem
|
||||
do provedor**: sem `paciente_id`/CPF, sem valores do paciente (reusa a lógica `soLab`).
|
||||
|
||||
| Rota | Ação |
|
||||
|---|---|
|
||||
| `GET /api/p/:token` | OS: nº, clínica, dentista, paciente (nome, conforme permissão), prazo, status, observações, fotos, componentes, ocorrências, custódia. **Sem CPF/valores do paciente.** |
|
||||
| `POST /api/p/:token/custodia` | confirmar recebimento / devolução (clínica↔lab) |
|
||||
| `POST /api/p/:token/status` | avançar a produção (recebido→…→finalizado) |
|
||||
| `POST /api/p/:token/foto` | anexar foto do trabalho (+ ocorrência) |
|
||||
| `POST /api/p/:token/ocorrencias/:id/resposta` | responder ocorrência (contraditório) |
|
||||
|
||||
Cada ação registra no histórico da OS um **actor "Protético (link)"** com o `protetico_id` do token —
|
||||
mantendo a auditoria. Mesmas validações de status/foto já existentes.
|
||||
|
||||
## 4. Geração e envio (lado clínica)
|
||||
Na OS (modal, aba Etapas ou Monitoramento): botão **"Enviar ao protético"** →
|
||||
`POST /api/protese/os/:id/link` gera/recupera o token e devolve a URL
|
||||
`https://scoreodonto.com/p/TOKEN`. Atalhos: **WhatsApp** (`https://wa.me/?text=…`) e **e-mail**
|
||||
(`mailto:`). Botão **"Revogar link"** para invalidar.
|
||||
|
||||
## 5. Frontend público `/p/:token`
|
||||
Rota **fora do app autenticado** (sem Sidebar/login). Página enxuta que reusa as seções do
|
||||
`OSDetailModal` em modo "público/lab" (etapas, fotos, componentes, custódia, ocorrências — sem
|
||||
financeiro/clientes). Topo com a marca + rodapé fixo:
|
||||
|
||||
> *"Gostou de acompanhar por aqui? Crie sua conta grátis e gerencie todos os seus laboratórios."*
|
||||
> → leva ao cadastro **já com o card Protético** (adicionado) e e-mail pré-preenchido.
|
||||
|
||||
## 6. Segurança (checklist)
|
||||
- Token aleatório ≥ 32 bytes, base62, **não sequencial**; comparação em tempo constante.
|
||||
- Escopo de **1 OS**; expiração opcional; **revogável**; rate-limit por token/IP.
|
||||
- **Sem CPF e sem valores do paciente** (mesma blindagem `soLab` da Fase 1).
|
||||
- Ações registram actor no histórico (auditoria); `last_access_at`/`acessos` para detectar abuso.
|
||||
- OS `entregue`/`cancelado` → link vira **somente leitura**.
|
||||
|
||||
## 7. Conversão (o "porquê de negócio")
|
||||
O link é aquisição: o protético experimenta o produto sem fricção e, ao criar conta, **herda o
|
||||
histórico** (as OS daquela clínica já estão ligadas ao `protetico_id` dele). Liga direto ao
|
||||
marketplace/score (Fase 5) — quanto mais clínicas o usam, mais OS e melhor o score do protético.
|
||||
|
||||
## 8. Faseamento sugerido
|
||||
- **✅ 1a (núcleo) — IMPLEMENTADA 24/jun:** tabela `protese_os_link` (token aleatório 24 bytes
|
||||
base64url, expira em 60 dias, revogável); `POST /api/protese/os/:id/link` (gera/recupera) +
|
||||
`POST …/link/revogar`; `GET /api/p/:token` (leitura pública curada — sem CPF, sem valor do
|
||||
paciente; conta acessos). Página pública `/p/:token` (`ProtesePublicView`, mobile-first 320px+,
|
||||
interceptada no `index.tsx` antes do app autenticado); botão "Gerar link / WhatsApp / Copiar" na
|
||||
aba Monitoramento (modo clínica). CTA "Criar conta grátis". Validado em DEV (leitura, blindagem,
|
||||
token inválido→404, revogação→404).
|
||||
- **✅ 1b (ação) — IMPLEMENTADA 24/jun:** o protético, pelo link, **confirma recebimento/devolução**
|
||||
(`POST /api/p/:token/custodia`), **avança a produção** (`/status`, só `recebido…finalizado` — nunca
|
||||
entregue/cancelado, que são da clínica) e **anexa foto** (`/foto`). Tudo auditado como
|
||||
`"<nome> (link)"`. Página pública ganha a seção "Atualizar status" (botões de custódia, etapa e foto)
|
||||
enquanto a OS não está finalizada. Validado em DEV: recebimento, avanço, foto, e `entregue` pelo link
|
||||
→ 400 (bloqueado).
|
||||
- **✅ 1c (gestão) — IMPLEMENTADA 24/jun:** UI da clínica (aba Monitoramento) gere o link:
|
||||
`GET /api/protese/os/:id/link/status` traz **link ativo, nº de acessos, último acesso e expiração**;
|
||||
botões **Copiar / WhatsApp / Revogar**. Validado em DEV: acessos contados, expiração 60 dias,
|
||||
revogação → status inativo + leitura pública 404.
|
||||
> Conversão por herança: nativa — a OS já aponta para o `protetico_id` do destinatário, então ao
|
||||
> entrar/criar conta ele já é dono do histórico. (Vincular um cadastro NOVO com e-mail diferente às
|
||||
> OS é caso de borda — fica para evolução, evitando duplicar usuário.)
|
||||
|
||||
**Modo 1 concluído (1a leitura + 1b ação + 1c gestão).**
|
||||
|
||||
### ✅ Extras (24/jun)
|
||||
- **Carteira pela link** (`GET /api/p/:token/carteira`): botão "Meus trabalhos" na página pública →
|
||||
trabalhos em andamento + finalizados + **mini financeiro mensal** (valores pagos ao protético).
|
||||
Escopo = a relação clínica↔protético da OS do token (não vaza outras clínicas; sem dados do paciente).
|
||||
- **Protético INTERNO (sem conta)** (`POST /api/protese/proteticos-internos`): a clínica cadastra um
|
||||
protético que não usa a plataforma — cria um usuário "mudo" (e-mail placeholder `..@interno.local`,
|
||||
senha-fantasma; `usuarios.email` é NOT NULL/UNIQUE) + vínculo interno (fora do diretório). Botão
|
||||
"Cadastrar protético interno" na Nova OS. A partir daí recebe OS e acompanha pelo link, sem conta.
|
||||
|
||||
> Nenhuma refatoração: reusa OS/eventos/custódia/ocorrências/blindagem já existentes. Só adiciona a
|
||||
> camada de token público.
|
||||
@@ -0,0 +1,121 @@
|
||||
# Decisão arquitetural — Provedor de Prótese (módulo de Prótese)
|
||||
|
||||
> Decisão de negócio/interface (24/jun/2026). Base: auditoria do código (`AUDITORIA-PROTESE.md`).
|
||||
> Princípio-guia: **a Clínica controla 100% do fluxo, mesmo que o Protético nunca crie conta.**
|
||||
> O login do Provedor é **alavanca de crescimento** (marketplace/ecossistema), não pré-requisito.
|
||||
|
||||
## Conceito
|
||||
"**Provedor de Prótese**" é rótulo de **negócio/interface**. Internamente **NÃO se renomeia** o
|
||||
role `protetico` (aparece 87× no backend + 15 arquivos no front — refatorar = risco alto, ganho
|
||||
zero). As três formas são variações do **mesmo** workspace `tipo='laboratorio'`, diferenciadas por
|
||||
metadados (futuros `pessoa_tipo`, `tem_equipe`), não por novos roles:
|
||||
|
||||
```
|
||||
PROVEDOR DE PRÓTESE (role=protetico, workspace tipo='laboratorio')
|
||||
├── Profissional Individual (PF)
|
||||
├── Laboratório (PJ)
|
||||
└── Laboratório com Equipe (PJ + técnicos)
|
||||
```
|
||||
|
||||
## Modos de operação
|
||||
- **Modo 1 — Protético SEM conta:** a Clínica opera tudo (OS, etapas, envio/recebimento/devolução,
|
||||
pagamentos, componentes, ocorrências, encerramento). O Protético acompanha por **link seguro**
|
||||
(`/p/TOKEN` via WhatsApp/e-mail), podendo confirmar recebimento/devolução, anexar fotos, responder
|
||||
ocorrências. CTA: "crie sua conta ScoreOdonto".
|
||||
- **Modo 2 — Protético COM conta:** Dashboard, Ordens, Produção, Entregas, Clientes, Financeiro,
|
||||
Catálogo, Notificações, Perfil.
|
||||
- **Modo 3 — Laboratório:** + Equipe, Relatórios, Marketplace, Configurações.
|
||||
|
||||
## Cadastro/Login
|
||||
Card próprio **[ PROTÉTICO / LABORATÓRIO ]** com opção PF (Protético Individual) / PJ (Laboratório).
|
||||
Campos futuros (não obrigatórios no MVP): Nome, Nome Fantasia, CPF, CNPJ, CROT, Responsável Técnico,
|
||||
Cidade, Estado, Especialidades, Telefone, E-mail. (Campo `clinicas.documento` já existe para CPF/CNPJ.)
|
||||
|
||||
## Rastreabilidade de componentes (modelo)
|
||||
Uma tabela (`protese_os_componente`) com eixo de direção/conferência, não duas:
|
||||
`enviado pela clínica → recebido pelo protético → devolvido pelo protético → conferido pela clínica`.
|
||||
A **ocorrência referencia o componente** (FK opcional).
|
||||
|
||||
## Ocorrências + Contraditório
|
||||
Tabela própria. Categorias: não devolvido, danificado, incompatível, parafuso substituído/desgastado,
|
||||
peça incorreta, ajuste inadequado, falha de fabricação, retrabalho, outro. Campos: autor, data,
|
||||
categoria, descrição, fotos, evidências, anexos, responsável, status.
|
||||
**Fluxo com contraditório** (proteção dos dois lados): `Aberta → Respondida → Em análise → Resolvida`
|
||||
ou `Contestada`. **Só ocorrências resolvidas** alimentam indicadores — nunca automático.
|
||||
|
||||
## Abas da OS (alvo)
|
||||
`Dados · Fotos · Componentes · Pagamentos · Custódia · Histórico · Ocorrências` (nova).
|
||||
|
||||
## Indicadores (POR ÚLTIMO)
|
||||
Capturar primeiro (eventos, componentes, ocorrências, evidências, histórico); só depois derivar
|
||||
Score/ranking/marketplace. Não codar indicador antes de existir dado.
|
||||
|
||||
---
|
||||
|
||||
## Roadmap (5 fases)
|
||||
|
||||
### ✅ FASE 1 — Origem da OS + LGPD (IMPLEMENTADA 24/jun)
|
||||
- **Clínica de origem** na bancada e no detalhe (`clinica_nome` via subquery) — visível ao protético
|
||||
(badge na lista e no modal). Destrava o cenário multi-clínica.
|
||||
- **Blindagem de CPF:** confirmado que `pacientes.id` **É o CPF**. O protético (lab) deixa de receber
|
||||
`paciente_id` (detalhe e bancada zeram), mantendo `paciente_nome`. A clínica continua vendo o CPF.
|
||||
- Validado em DEV: protético vê `clinica_nome` + `paciente_id:null`; clínica vê tudo.
|
||||
|
||||
### ✅ FASE 2 — Rastreabilidade & Ocorrências (IMPLEMENTADA 24/jun)
|
||||
- **Componentes** ganham `direcao` (enviado/devolvido) e `status_conferencia` (pendente/ok/divergente/
|
||||
ausente, com `conferido_nome/_em`). Endpoint `POST .../componentes/:id/conferir`.
|
||||
- **Ocorrências** (`protese_os_ocorrencia`): 10 categorias, descrição, responsável, autor, status.
|
||||
Evidências via `protese_os_foto.ocorrencia_id` (reusa a infra de mídia).
|
||||
- **Contraditório**: `aberta → respondida → em_analise → resolvida | contestada`; `resolvida` é final
|
||||
(409 ao remexer). Endpoints `/ocorrencias`, `/ocorrencias/:id/resposta`, `/ocorrencias/:id/status`.
|
||||
- **UI**: nova aba **Ocorrências** no modal (com contador) + conferência inline nos componentes.
|
||||
- Validado em DEV: componente `divergente`; ocorrência `parafuso_substituido` percorrendo todo o
|
||||
contraditório até `resolvida`; resolvida final (409). Indicadores ficam para a Fase 5.
|
||||
|
||||
### ✅ FASE 3 — Operação multi-clínica (IMPLEMENTADA 24/jun)
|
||||
- **Clientes** (tela `lab-clientes`): tabela das clínicas que enviam OS, com OS totais, ativas,
|
||||
atrasadas, entregues, faturado e a-receber por cliente. Endpoint `GET /protese/lab/clientes`.
|
||||
- **Financeiro do lab** (tela `lab-financeiro`): KPIs (faturado/recebido/a-receber) + extrato de
|
||||
recebimentos (pagamentos lado lab) com clínica/paciente/forma/data. Endpoint `GET /protese/lab/pagamentos`.
|
||||
Regra: faturado = custo das OS entregues (exceto garantia); a-receber = faturado − recebido.
|
||||
- Menu do laboratório agora: Painel · Bancada · **Clientes** · **Financeiro** · Notificações · Config.
|
||||
- Histórico completo: a Bancada já lista entregues/cancelados com filtro de status (mantida).
|
||||
- Validado em DEV: agregação por clínica (faturado 600 / recebido 250 / a-receber 350) + extrato.
|
||||
|
||||
### ✅ FASE 4 — Empresa de verdade (IMPLEMENTADA 24/jun)
|
||||
- **PF/PJ + documento**: `clinicas.pessoa_tipo` (PF=individual | PJ=laboratório); `documento` = CPF/CNPJ
|
||||
(já existia, agora editável). Tela **Laboratório & Equipe** (`lab-equipe`): GET/PATCH `/protese/lab`.
|
||||
- **Equipe/Técnicos**: novo `vinculos.role='tecnico_protese'`. O dono convida por e-mail
|
||||
(`ensureUsuarioPorEmail` → credencial temporária); endpoints `GET/POST/DELETE /protese/lab/equipe`.
|
||||
- **Acesso estendido**: `proteseOsAccesso` reconhece o técnico (vínculo no `laboratorio_id`); a bancada
|
||||
usa `labDoUsuario` (dono **ou** técnico). Técnico opera a produção; **não** vê clientes/financeiro
|
||||
(menu reduzido + endpoints retornam vazio/403). Gestão é exclusiva do dono.
|
||||
- Validado em DEV: dados PJ+CNPJ; técnico convidado acessa a bancada (vê clínica de origem), não vê
|
||||
clientes (0) e leva 403 ao tentar gerenciar equipe.
|
||||
|
||||
### ✅ FASE 5 — Indicadores & Score (IMPLEMENTADA 24/jun)
|
||||
- **Indicadores derivados** (`GET /protese/lab/indicadores`, só dono) calculados sobre a captura das
|
||||
fases 2-4: entregues, tempo médio (solicitado→entregue), atraso %, retrabalho % (garantia),
|
||||
ocorrências resolvidas %, componentes ausentes. Tela `lab-indicadores`.
|
||||
- **Nota ScoreOdonto** (0-10): `10 − (atraso%×3 + retrabalho%×4 + ocorrência%×3)`, só com **mínimo de
|
||||
3 entregas** (senão "score em formação"). Só ocorrências **resolvidas** entram (contraditório respeitado).
|
||||
- **Ranking no marketplace**: `getProteseLaboratorios` passa a devolver a `nota` por protético e ordena
|
||||
internos→maior nota→nome; a clínica vê `★ nota` no dropdown de Nova OS e do Lançar GTO.
|
||||
- Validado em DEV: 4 entregas (1 atrasada, 1 garantia) → atraso 25% / retrab 25% / **nota 8.3**; a
|
||||
clínica vê a nota no dropdown; "score em formação" abaixo do mínimo.
|
||||
|
||||
> Acoplamentos respeitados: PF/PJ veio com a equipe (Fase 4); Score só após a captura (Fases 2-4).
|
||||
|
||||
### Pós-roadmap (entregue depois das Fases 1-5)
|
||||
- **✅ Modo 1** (protético sem conta via link `/p/TOKEN`) — completo (1a leitura, 1b ação, 1c gestão).
|
||||
Ver `MODO1-LINK-SEGURO-PROTESE.md`.
|
||||
- **✅ Higiene** — menu legado do protético removido (Sidebar/App): o protético opera só no workspace
|
||||
laboratório; fallback mínimo fora dele.
|
||||
- **✅ Score público no marketplace** — `GET /api/profissionais/marketplace` traz a `nota` ScoreOdonto
|
||||
dos protéticos (helper `notasScorePorProtetico`, mesma fórmula da Fase 5), exibida como ★ no card.
|
||||
Ordem mantida por proximidade; a nota é critério de comparação visível.
|
||||
|
||||
### Ainda em aberto (futuro)
|
||||
Marketplace **transacional** (anúncio de serviços + solicitação/contratação/propostas dentro da
|
||||
plataforma); **agenda de produção/coleta**; **multi-unidade** do laboratório; envio automático do
|
||||
link (e-mail/WhatsApp API) e herança de cadastro novo com e-mail diferente.
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
# Qual é a verdade hoje? — Auditoria de infra ScoreOdonto
|
||||
|
||||
> Resposta direta às 6 perguntas essenciais, **verificada em 17/jun/2026**. Somente fatos do estado atual.
|
||||
> Resposta direta às 6 perguntas essenciais, **verificada em 23/jun/2026**. Somente fatos do estado atual.
|
||||
> **Documento principal: em caso de divergência entre docs, este prevalece.**
|
||||
> Como operar → `DEPLOY-PRODUCAO.md` (runbook). Para onde vamos → `deploy.md` (arquitetura-alvo).
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
### 1. Onde está o código?
|
||||
- **Fonte da verdade:** Gitea na **VPS3**, repo `ruicesar/scoreodonto.com`.
|
||||
- **Dev (trabalho):** VPS4 em `/home/deploy/stack/scoreodonto.com`.
|
||||
- **Produção:** VPS1 roda **imagens versionadas do registry** (não código-fonte). No ar: **v1.0.16 · commit `471c2c2`**.
|
||||
- **Produção:** VPS1 roda **imagens versionadas do registry** (não código-fonte). No ar: **v1.0.31 · commit `4673f3c`**.
|
||||
- **Documentação:** centralizada em `scoreodonto.com/doc/` (pasta única).
|
||||
|
||||
### 2. Onde está o banco?
|
||||
@@ -28,7 +28,7 @@
|
||||
- ✅ **Build Once → Promote (Passo 5, validado em produção):** push builda 1× e publica por **commit** (`:<sha>`); a **tag re-tagueia** esse artefato como `:vX.Y.Z` (sem rebuild — digest idêntico) e deploya. Provado em v1.0.16 (`:v1.0.16` == `:471c2c2`, mesmo digest).
|
||||
|
||||
### 5. Como versiona?
|
||||
- A versão exibida vem do **backend em runtime**: `GET /api/version` (de `process.env`), mostrada em **Configurações → "Sobre o sistema"** e nas telas públicas (o frontend consome via `useAppVersion()`, não embute mais a versão). No ar: **v1.0.16 · `471c2c2` · PROD**.
|
||||
- A versão exibida vem do **backend em runtime**: `GET /api/version` (de `process.env`), mostrada em **Configurações → "Sobre o sistema"** e nas telas públicas (o frontend consome via `useAppVersion()`, não embute mais a versão). No ar: **v1.0.31 · `4673f3c` · PROD**.
|
||||
- A versão **nasce da tag git** e é **injetada no deploy em runtime** (`APP_VERSION`=tag, `APP_ENV=PROD`); `commit`/`build` vêm carimbados na imagem.
|
||||
- ✅ **`constants.ts`/`update-version.js` foram removidos** (Passo 5) — não há mais versão manual.
|
||||
|
||||
@@ -39,4 +39,4 @@
|
||||
---
|
||||
|
||||
## Veredito de uma linha
|
||||
> O código vive no Gitea (VPS3); a **produção (VPS1) roda imagens versionadas do registry** (hoje **v1.0.16**), sem buildar; o **DEV está isolado no `pgdev`** (não toca o banco de produção). **CI/CD por tag ativo** no modelo **Build Once → Promote → Deploy** (push builda por commit; tag re-tagueia sem rebuild e deploya — validado em v1.0.16), com **versão visível em runtime** (`/api/version` → Configurações) nascendo da tag git.
|
||||
> O código vive no Gitea (VPS3); a **produção (VPS1) roda imagens versionadas do registry** (hoje **v1.0.31**), sem buildar; o **DEV está isolado no `pgdev`** (não toca o banco de produção). **CI/CD por tag ativo** no modelo **Build Once → Promote → Deploy** (push builda por commit; tag re-tagueia sem rebuild e deploya — validado em v1.0.16), com **versão visível em runtime** (`/api/version` → Configurações) nascendo da tag git.
|
||||
|
||||
+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).
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Status — Segurança & Produção (ScoreOdonto)
|
||||
|
||||
> Resumo do que foi feito e do que falta. Atualizado em **19/jun/2026**.
|
||||
> Produção no ar: **v1.0.18** (`fc00622`). **Todos os commits de código estão LOCAIS (sem `git push`)** —
|
||||
> exceto a rotação do `JWT_SECRET`, que **já foi aplicada e verificada em produção**.
|
||||
> Resumo do que foi feito e do que falta. Atualizado em **22/jun/2026**.
|
||||
> Produção no ar: **v1.0.19** (`715b2dc`) — fila **A** concluída (push → CI → tag → deploy). **Commits
|
||||
> publicados (`git push` feito).** A rotação do `JWT_SECRET` segue aplicada e verificada em produção.
|
||||
|
||||
---
|
||||
|
||||
@@ -48,20 +48,28 @@ do frontend, via 3 helpers: `requireCapability`, `requireCapabilityRow`, `requir
|
||||
### 5. Pré-requisito de produção
|
||||
- **`SCOREO_DB_PASS` provisionado na VPS1** (`/home/deploy/stack/scoreodonto.com/.env`, 0600), valores
|
||||
extraídos do container em execução, `.gitignore` da VPS1 ajustado, conexão validada (`SELECT 1` = ok).
|
||||
**Produção não foi tocada.**
|
||||
|
||||
### 6. Fila A concluída — **PRODUÇÃO EM `v1.0.19`** (22/jun/2026)
|
||||
- **`git push` do lote** (RBAC 99 rotas + segredos fora do compose/imagem + correções de frontend).
|
||||
- **CI desbloqueada:** o build falhou na 1ª vez (`SCOREO_DB_PASS` com `:?` quebrava a interpolação do
|
||||
compose no `docker compose build` do runner) → corrigido com placeholder runtime-only no `build.yml`
|
||||
(commit `715b2dc`). Build verde → imagens `:715b2dc` (back+front, atômicas) publicadas no registry.
|
||||
- **Tag `v1.0.19` → `715b2dc`:** job `promote` re-tagueou `:715b2dc` → `:v1.0.19` **sem rebuild**
|
||||
(digests idênticos conferidos) e rodou `deploy-prod.sh v1.0.19` na VPS1.
|
||||
- **Backup do banco antes do deploy:** `~/backups/scoreodonto_20260622_0005.dump` na VPS1 (PG18).
|
||||
- **Verificado em produção:** `/api/version` = `v1.0.19` (`715b2dc`, PROD); `/api/health` = ok
|
||||
(DB ok, cache ok). DEV (8020) realinhado ao mesmo commit via `./dev-build.sh` (mostra `v1.0.19`).
|
||||
- **Rollback trivial:** `./deploy-prod.sh v1.0.18` (+ dump do banco, se necessário).
|
||||
|
||||
---
|
||||
|
||||
## ⏳ O QUE FALTA
|
||||
|
||||
### A) Levar o trabalho a produção (fila)
|
||||
1. **Imagem limpa `v1.0.19`** (remove segredos embutidos na imagem):
|
||||
`git push` → CI builda `:<sha>` com `.dockerignore` → `git tag v1.0.19` → `deploy-prod.sh v1.0.19` →
|
||||
validar `/api/version` + `/api/health`. **Rollback trivial:** `deploy-prod.sh v1.0.18`.
|
||||
- Leva junto: RBAC (99 rotas), correções de frontend, etc.
|
||||
### A) Levar o trabalho a produção (fila) — ✅ CONCLUÍDA
|
||||
1. ~~**Imagem limpa `v1.0.19`**~~ → **FEITO** (ver "Fila A concluída" acima). Produção em `v1.0.19`/`715b2dc`.
|
||||
2. **Compose novo na VPS1 (opcional, passo 2b):** `deploy-prod.sh` **não faz `git pull`** → o
|
||||
`docker-compose.yml` da VPS1 segue o antigo. Para tirar a senha hardcoded do compose local, copiar o
|
||||
novo `docker-compose.yml` (scp) + recreate. O root `.env` já provisionado cobre isso.
|
||||
novo `docker-compose.yml` (scp) + recreate. O root `.env` já provisionado cobre isso. **Ainda pendente.**
|
||||
|
||||
### B) Rotação dos demais segredos (ainda vivos no histórico/artefatos antigos)
|
||||
3. **Senha do banco** (`scoreodonto_user`) — contida ao scoreodonto; exige refactor já feito no compose +
|
||||
@@ -88,11 +96,12 @@ do frontend, via 3 helpers: `requireCapability`, `requireCapabilityRow`, `requir
|
||||
|
||||
---
|
||||
|
||||
## Commits locais da sessão (nenhum `push`, exceto JWT já aplicado em prod)
|
||||
> **13 commits desta sessão** (incluindo este documento) ainda **locais**. O repositório está
|
||||
> `ahead 14` de `origin/main` — o 14º (`7c5b199 docs(LEIA)`) é anterior a esta sessão.
|
||||
## Commits da sessão — **PUBLICADOS** (`git push` feito; produção em `v1.0.19`)
|
||||
> Todos empurrados para `origin/main`. Promovidos a produção via tag `v1.0.19` (commit `715b2dc`).
|
||||
> A rotação do `JWT_SECRET` **não** está nesta lista (foi ação operacional na VPS, sem commit).
|
||||
```
|
||||
715b2dc ci(build): desbloqueia build — SCOREO_DB_PASS placeholder na interpolação
|
||||
8a27f3b docs+infra: corrige status (JWT/contagem) + versão em DEV (dev-build.sh)
|
||||
6adf353 docs: status geral de segurança & produção (este documento)
|
||||
f22e30e docs(skills): completa as 62 skills
|
||||
e83af04 docs(skills): biblioteca operacional
|
||||
@@ -110,7 +119,11 @@ fcf2fb1 security(infra): .env fora do git + .dockerignore
|
||||
```
|
||||
|
||||
## Como retomar
|
||||
- **Para publicar:** seguir a fila **A** (push → `v1.0.19` → deploy → validar health/version).
|
||||
- **Fila A:** ✅ concluída — produção em **`v1.0.19`** (`715b2dc`). Próximo release: `git push` → CI
|
||||
builda `:<sha>` → `git tag vX.Y.Z` → job `promote` deploya na VPS1.
|
||||
- **Antes de abrir a usuários reais:** concluir **B** (rotações) — sobretudo as API keys (você) e a senha do banco.
|
||||
- Tudo de código testado em **DEV** (`dev.scoreodonto.com`, banco `pgdev`); produção segue **v1.0.18** intacta.
|
||||
- **Pendência pontual:** passo **A.2** (compose novo na VPS1 via scp + recreate) para tirar a senha
|
||||
hardcoded do compose local.
|
||||
- Código rodando em **DEV** (`dev.scoreodonto.com`) e **PROD** no mesmo commit `715b2dc`; rollback de
|
||||
prod: `./deploy-prod.sh v1.0.18` (+ dump `~/backups/scoreodonto_20260622_0005.dump` se necessário).
|
||||
</content>
|
||||
|
||||
+114
-6
@@ -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';
|
||||
@@ -31,6 +36,12 @@ import { ReportsView } from './views/ReportsView.tsx';
|
||||
import { ComissoesView } from './views/ComissoesView.tsx';
|
||||
import { GlosasView } from './views/GlosasView.tsx';
|
||||
import { SalaHomeView } from './views/SalaHomeView.tsx';
|
||||
import { LabHomeView } from './views/LabHomeView.tsx';
|
||||
import { LabAgendaView } from './views/LabAgendaView.tsx';
|
||||
import { LabClientesView } from './views/LabClientesView.tsx';
|
||||
import { LabCotacoesView } from './views/LabCotacoesView.tsx';
|
||||
import { LabEquipeView } from './views/LabEquipeView.tsx';
|
||||
import { LabIndicadoresView } from './views/LabIndicadoresView.tsx';
|
||||
import { ConfiguracoesView } from './views/ConfiguracoesView.tsx';
|
||||
import { OrcamentosView } from './views/OrcamentosView.tsx';
|
||||
import { ContratosView } from './views/ContratosView.tsx';
|
||||
@@ -80,10 +91,21 @@ export type ViewKey =
|
||||
| 'contratos'
|
||||
| 'plugins'
|
||||
| 'rx-radiografias'
|
||||
| 'wa-inbox'
|
||||
| 'wa-sessions'
|
||||
| 'wa-secretaria'
|
||||
| 'salas'
|
||||
| 'minhas-salas'
|
||||
| 'alugar-salas'
|
||||
| 'sala-home'
|
||||
| 'lab-home'
|
||||
| 'lab-bancada'
|
||||
| 'lab-agenda'
|
||||
| 'lab-clientes'
|
||||
| 'lab-financeiro'
|
||||
| 'lab-cotacoes'
|
||||
| 'lab-equipe'
|
||||
| 'lab-indicadores'
|
||||
| 'marketplace-profissionais'
|
||||
| 'criar-clinica'
|
||||
| 'rx-config'
|
||||
@@ -99,6 +121,7 @@ export type ViewKey =
|
||||
| 'superadmin-logs'
|
||||
| 'superadmin-financeiro'
|
||||
| 'superadmin-notificacoes'
|
||||
| 'superadmin-secretaria'
|
||||
| 'diretorio-profissionais';
|
||||
|
||||
// ------- URL <-> VIEW mapping -------
|
||||
@@ -130,10 +153,21 @@ 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',
|
||||
'/sala-home': 'sala-home',
|
||||
'/lab-home': 'lab-home',
|
||||
'/lab-bancada': 'lab-bancada',
|
||||
'/lab-agenda': 'lab-agenda',
|
||||
'/lab-clientes': 'lab-clientes',
|
||||
'/lab-financeiro': 'lab-financeiro',
|
||||
'/lab-cotacoes': 'lab-cotacoes',
|
||||
'/lab-equipe': 'lab-equipe',
|
||||
'/lab-indicadores': 'lab-indicadores',
|
||||
'/marketplace-profissionais': 'marketplace-profissionais',
|
||||
'/criar-clinica': 'criar-clinica',
|
||||
'/rx-config': 'rx-config',
|
||||
@@ -148,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',
|
||||
@@ -185,10 +220,21 @@ 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',
|
||||
'sala-home': '/sala-home',
|
||||
'lab-home': '/lab-home',
|
||||
'lab-bancada': '/lab-bancada',
|
||||
'lab-agenda': '/lab-agenda',
|
||||
'lab-clientes': '/lab-clientes',
|
||||
'lab-financeiro': '/lab-financeiro',
|
||||
'lab-cotacoes': '/lab-cotacoes',
|
||||
'lab-equipe': '/lab-equipe',
|
||||
'lab-indicadores': '/lab-indicadores',
|
||||
'marketplace-profissionais': '/marketplace-profissionais',
|
||||
'criar-clinica': '/criar-clinica',
|
||||
'rx-config': '/rx-config',
|
||||
@@ -204,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',
|
||||
};
|
||||
@@ -271,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();
|
||||
@@ -285,8 +347,13 @@ 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.
|
||||
if ((HybridBackend.getActiveWorkspace() as any)?.tipo === 'laboratorio') return ['lab-home', 'lab-bancada', 'lab-agenda', 'lab-clientes', 'lab-cotacoes', 'lab-financeiro', 'lab-equipe', 'lab-indicadores', 'marketplace-profissionais', 'diretorio-profissionais', 'configuracoes', 'notificacoes', 'clinicas'].includes(view);
|
||||
// Catálogo de PLUGINS: EXCLUSIVO do superadmin (já retorna true acima); ninguém mais.
|
||||
if (view === 'plugins') return false;
|
||||
// Admin/donos têm acesso amplo, MAS as telas de superadmin são exclusivas do superadmin.
|
||||
@@ -305,7 +372,7 @@ const App: React.FC = () => {
|
||||
paciente: ['tratamentos', 'notificacoes', 'configuracoes'],
|
||||
dentista: ['dashboard', 'agenda', 'ortodontia', 'tratamentos', 'lancar-gto', 'proteses', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'rx-radiografias', 'salas', 'marketplace-profissionais', 'candidatura-tutor', 'tutoria-painel', 'diretorio-profissionais'],
|
||||
biomedico: ['dashboard', 'agenda', 'tratamentos', 'lancar-gto', 'especialidades', 'procedimentos', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'salas', 'marketplace-profissionais', 'diretorio-profissionais'],
|
||||
protetico: ['dashboard', 'agenda', 'tratamentos', 'lancar-gto', 'proteses', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'salas', 'marketplace-profissionais', 'diretorio-profissionais'],
|
||||
protetico: ['lab-home', 'lab-bancada', 'lab-agenda', 'lab-clientes', 'lab-cotacoes', 'lab-financeiro', 'lab-equipe', 'lab-indicadores', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'salas', 'marketplace-profissionais', 'diretorio-profissionais'],
|
||||
funcionario: ['dashboard', 'leads', 'pacientes', 'agenda', 'financeiro', 'tratamentos', 'lancar-gto', 'proteses', 'relatorios', 'notificacoes', 'clinicas', 'configuracoes', 'orcamentos', 'contratos', 'plugins', 'rx-radiografias', 'salas', 'marketplace-profissionais', 'candidatura-tutor', 'diretorio-profissionais'],
|
||||
};
|
||||
|
||||
@@ -316,6 +383,8 @@ const App: React.FC = () => {
|
||||
if (role === 'superadmin') return 'superadmin-overview';
|
||||
// Contexto de SALA (workspace tipo='sala') sempre cai no painel da sala.
|
||||
if ((HybridBackend.getActiveWorkspace() as any)?.tipo === 'sala') return 'sala-home';
|
||||
// Contexto de LABORATÓRIO (workspace tipo='laboratorio') cai no painel do lab.
|
||||
if ((HybridBackend.getActiveWorkspace() as any)?.tipo === 'laboratorio') return 'lab-home';
|
||||
if (role === 'paciente') return 'tratamentos';
|
||||
if (role === 'donosala') return 'minhas-salas';
|
||||
return 'dashboard';
|
||||
@@ -449,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 />;
|
||||
@@ -472,10 +541,21 @@ 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" />;
|
||||
case 'sala-home': return <SalaHomeView />;
|
||||
case 'lab-home': return <LabHomeView />;
|
||||
case 'lab-bancada': return <ProteseView />;
|
||||
case 'lab-agenda': return <LabAgendaView />;
|
||||
case 'lab-clientes': return <LabClientesView tab="clientes" />;
|
||||
case 'lab-financeiro': return <LabClientesView tab="financeiro" />;
|
||||
case 'lab-cotacoes': return <LabCotacoesView />;
|
||||
case 'lab-equipe': return <LabEquipeView />;
|
||||
case 'lab-indicadores': return <LabIndicadoresView />;
|
||||
case 'marketplace-profissionais': return <ProfissionaisPlugin />;
|
||||
case 'criar-clinica': return <CreateClinicView onCreated={() => { window.location.reload(); }} />;
|
||||
case 'rx-config': return <RxConfigView />;
|
||||
@@ -491,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':
|
||||
@@ -499,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 (
|
||||
<>
|
||||
@@ -525,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)}
|
||||
@@ -534,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">
|
||||
@@ -566,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,14 +50,27 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
|
||||
pluginMenuItems.push({ id: 'rx-config', label: 'DISPOSITIVOS RX', icon: Monitor, color: '#7c3aed' });
|
||||
}
|
||||
}
|
||||
// Locação de Salas: capability SEMPRE ativa (sem desativar) p/ todos menos superadmin.
|
||||
// Dois menus: administrar as próprias salas (locador) e alugar salas de terceiros (locatário).
|
||||
if (currentRole !== 'superadmin') {
|
||||
// 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
|
||||
// a locação. Por isso restringimos a papéis que podem ser titulares de sala.
|
||||
const PAPEIS_LOCACAO = ['donosala', 'donoclinica', 'donoconsultorio', 'admin', 'dentista', 'biomedico', 'protetico'];
|
||||
if (PAPEIS_LOCACAO.includes(currentRole)) {
|
||||
pluginMenuItems.push({ id: 'minhas-salas', label: 'MINHAS SALAS', icon: DoorOpen, color: '#0d9488' });
|
||||
pluginMenuItems.push({ id: 'alugar-salas', label: 'ALUGAR SALAS', icon: Building2, color: '#0d9488' });
|
||||
}
|
||||
// Marketplace de Profissionais: clínicas contratam, profissionais divulgam perfil/recebem propostas.
|
||||
if (activePluginIds.includes('profissionais-marketplace') && currentRole !== 'superadmin' && currentRole !== 'paciente') {
|
||||
// O protético DEPENDE desta tela para publicar o perfil no diretório e ser encontrado pelas clínicas
|
||||
// — por isso aparece sempre para ele, mesmo sem o plugin ativado.
|
||||
if ((activePluginIds.includes('profissionais-marketplace') || currentRole === 'protetico' || currentRole === 'biomedico' || currentRole === 'dentista') && currentRole !== 'superadmin' && currentRole !== 'paciente') {
|
||||
pluginMenuItems.push({ id: 'marketplace-profissionais', label: 'PROFISSIONAIS', icon: UserSearch, color: '#0d9488' });
|
||||
}
|
||||
// OBS: o catálogo de PLUGINS é EXCLUSIVO do superadmin (gerência/ativação).
|
||||
@@ -57,7 +83,16 @@ 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 },
|
||||
{ id: 'lab-agenda', label: 'AGENDA', icon: CalendarIcon },
|
||||
{ id: 'lab-clientes', label: 'CLIENTES', icon: Building2 },
|
||||
{ id: 'lab-cotacoes', label: 'COTAÇÕES', icon: FileText },
|
||||
{ id: 'lab-financeiro', label: 'FINANCEIRO', icon: DollarSign },
|
||||
{ id: 'lab-indicadores', label: 'INDICADORES', icon: BarChart3 },
|
||||
{ id: 'lab-equipe', label: 'EQUIPE', icon: UsersRound },
|
||||
{ id: 'dashboard', label: 'VISÃO GERAL', icon: LayoutDashboard },
|
||||
{ id: 'agenda', label: 'AGENDA', icon: CalendarIcon },
|
||||
{ id: 'pacientes', label: 'PACIENTES', icon: Users },
|
||||
@@ -94,11 +129,23 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
|
||||
|
||||
// PAINEL DA SALA só existe quando o workspace ativo é uma sala.
|
||||
if (item.id === 'sala-home') return (activeWorkspace as any)?.tipo === 'sala';
|
||||
// PAINEL DO LAB / BANCADA só existem no workspace de laboratório.
|
||||
if (['lab-home', 'lab-bancada', 'lab-agenda', 'lab-clientes', 'lab-cotacoes', 'lab-financeiro', 'lab-equipe', 'lab-indicadores'].includes(item.id)) return (activeWorkspace as any)?.tipo === 'laboratorio';
|
||||
|
||||
// Contexto de SALA (workspace tipo='sala'): isola da clínica — menu base só conta/notificações.
|
||||
// (MINHAS SALAS / ALUGAR SALAS são itens de plugin, mostrados à parte.)
|
||||
if ((activeWorkspace as any)?.tipo === 'sala') return ['sala-home', 'notificacoes', 'configuracoes'].includes(item.id);
|
||||
|
||||
// Contexto de LABORATÓRIO (workspace tipo='laboratorio'): área própria do protético —
|
||||
// painel do lab + bancada de produção, sem as telas clínicas da clínica.
|
||||
if ((activeWorkspace as any)?.tipo === 'laboratorio') {
|
||||
// Técnico (vínculo tecnico_protese) só opera a produção; gestão é do dono.
|
||||
const ehTecnico = (activeWorkspace as any)?.role === 'tecnico_protese';
|
||||
return ehTecnico
|
||||
? ['lab-home', 'lab-bancada', 'notificacoes', 'configuracoes'].includes(item.id)
|
||||
: ['lab-home', 'lab-bancada', 'lab-agenda', 'lab-clientes', 'lab-cotacoes', 'lab-financeiro', 'lab-indicadores', 'lab-equipe', 'notificacoes', 'configuracoes'].includes(item.id);
|
||||
}
|
||||
|
||||
// plugins e gestão de tutores: exclusivo superadmin
|
||||
if (item.id === 'plugins' || item.id === 'admin-gestao-tutores') return false;
|
||||
|
||||
@@ -128,7 +175,9 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
|
||||
// SEM itens odontológicos (ortodontia, RX, tutoria orto).
|
||||
if (currentRole === 'biomedico') return ['dashboard', 'agenda', 'tratamentos', 'especialidades', 'procedimentos', 'notificacoes', 'clinicas', 'configuracoes', 'diretorio-profissionais'].includes(item.id);
|
||||
// Protético: laboratório — agenda própria + GTO; sem ortodontia/RX/pacientes clínicos.
|
||||
if (currentRole === 'protetico') return ['dashboard', 'agenda', 'proteses', 'tratamentos', 'lancar-gto', 'notificacoes', 'clinicas', 'configuracoes', 'diretorio-profissionais'].includes(item.id);
|
||||
// Protético: opera dentro do workspace 'laboratorio' (menu governado pelo branch tipo acima).
|
||||
// Este é só o fallback fora desse contexto — sem telas clínicas (agenda/GTO/tratamentos).
|
||||
if (currentRole === 'protetico') return ['notificacoes', 'clinicas', 'configuracoes', 'diretorio-profissionais'].includes(item.id);
|
||||
if (currentRole === 'funcionario') return ['dashboard', 'leads', 'pacientes', 'tratamentos', 'lancar-gto', 'proteses', 'agenda', 'financeiro', 'relatorios', 'glosas', 'notificacoes', 'clinicas', 'configuracoes', 'orcamentos', 'contratos', 'diretorio-profissionais'].includes(item.id);
|
||||
return false;
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+4
-1
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
import { ProtesePublicView } from './views/ProtesePublicView.tsx';
|
||||
import { ErrorBoundary } from './components/ErrorBoundary.tsx';
|
||||
import { ToastProvider } from './contexts/ToastContext.tsx';
|
||||
import { ConfirmProvider } from './contexts/ConfirmContext.tsx';
|
||||
@@ -34,13 +35,15 @@ window.addEventListener('vite:preloadError', () => {
|
||||
const rootElement = document.getElementById('root');
|
||||
if (rootElement) {
|
||||
const root = createRoot(rootElement);
|
||||
// Modo 1 — link seguro: /p/TOKEN é página PÚBLICA (sem login), fora do app autenticado.
|
||||
const publicMatch = window.location.pathname.match(/^\/p\/(.+)$/);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ErrorBoundary>
|
||||
<ToastProvider>
|
||||
<ConfirmProvider>
|
||||
<App />
|
||||
{publicMatch ? <ProtesePublicView token={decodeURIComponent(publicMatch[1])} /> : <App />}
|
||||
</ConfirmProvider>
|
||||
</ToastProvider>
|
||||
</ErrorBoundary>
|
||||
|
||||
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 });
|
||||
@@ -778,13 +799,88 @@ export const HybridBackend = {
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao mudar status');
|
||||
return await res.json();
|
||||
},
|
||||
uploadProteseFoto: async (id: string, file: File) => {
|
||||
uploadProteseFoto: async (id: string, file: File, legenda?: string, ocorrenciaId?: string) => {
|
||||
const fd = new FormData();
|
||||
fd.append('foto', file);
|
||||
if (legenda) fd.append('legenda', legenda);
|
||||
if (ocorrenciaId) fd.append('ocorrencia_id', ocorrenciaId);
|
||||
const res = await apiFetch(`${API_URL}/protese/os/${id}/foto`, { method: 'POST', body: fd });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao anexar foto');
|
||||
return await res.json();
|
||||
},
|
||||
// Conferência de componente (status: ok | divergente | ausente | pendente)
|
||||
conferirProteseComponente: async (compId: string, status_conferencia: string) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/os/componentes/${compId}/conferir`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status_conferencia }) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao conferir');
|
||||
return await res.json();
|
||||
},
|
||||
// Ocorrências (contraditório)
|
||||
abrirProteseOcorrencia: async (osId: string, body: { categoria: string; descricao: string; responsavel?: string; componente_id?: string }) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/os/${osId}/ocorrencias`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao abrir ocorrência');
|
||||
return await res.json();
|
||||
},
|
||||
responderProteseOcorrencia: async (ocId: string, resposta: string) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/os/ocorrencias/${ocId}/resposta`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ resposta }) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao responder');
|
||||
return await res.json();
|
||||
},
|
||||
statusProteseOcorrencia: async (ocId: string, status: string) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/os/ocorrencias/${ocId}/status`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status }) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao mudar status');
|
||||
return await res.json();
|
||||
},
|
||||
deleteProteseFoto: async (fotoId: string) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/os/fotos/${fotoId}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao remover foto');
|
||||
return await res.json();
|
||||
},
|
||||
// Componentes enviados ao laboratório (direcao: 'enviado' clínica→lab | 'devolvido' lab→clínica)
|
||||
addProteseComponente: async (osId: string, body: { nome: string; quantidade?: number; observacao?: string; direcao?: 'enviado' | 'devolvido' }) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/os/${osId}/componentes`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao adicionar componente');
|
||||
return await res.json();
|
||||
},
|
||||
deleteProteseComponente: async (compId: string) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/os/componentes/${compId}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao remover componente');
|
||||
return await res.json();
|
||||
},
|
||||
// Pagamentos/recebimentos da OS (lado: 'lab' | 'clinica')
|
||||
addProtesePagamento: async (osId: string, body: { lado: 'lab' | 'clinica'; valor: number; forma?: string; observacao?: string; data?: string }) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/os/${osId}/pagamentos`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao registrar pagamento');
|
||||
return await res.json();
|
||||
},
|
||||
deleteProtesePagamento: async (pagId: string) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/os/pagamentos/${pagId}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao remover pagamento');
|
||||
return await res.json();
|
||||
},
|
||||
// Custódia: registra que o trabalho mudou de mãos (para_local: 'clinica' | 'laboratorio')
|
||||
moverProteseCustodia: async (osId: string, para_local: 'clinica' | 'laboratorio', observacao?: string) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/os/${osId}/custodia`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ para_local, observacao }) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao registrar movimentação');
|
||||
return await res.json();
|
||||
},
|
||||
// Editar campos do trabalho (cor, material, dentes, observações)
|
||||
editarProteseOS: async (osId: string, campos: { cor?: string; material?: string; dentes?: string; observacoes?: string }) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/os/${osId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(campos) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao editar');
|
||||
return await res.json();
|
||||
},
|
||||
// Ajuste do valor cobrado do paciente (delta + ou −)
|
||||
ajustarValorProtese: async (osId: string, delta: number, observacao?: string) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/os/${osId}/ajuste-valor`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ delta, observacao }) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao ajustar valor');
|
||||
return await res.json();
|
||||
},
|
||||
// Trocar o trabalho por outro (produto do catálogo ou tipo livre) + motivo
|
||||
trocarTrabalhoProtese: async (osId: string, body: { produto_id?: string; tipo?: string; motivo: string }) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/os/${osId}/trocar`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao trocar trabalho');
|
||||
return await res.json();
|
||||
},
|
||||
deleteProteseOS: async (id: string) => {
|
||||
const clinicaId = HybridBackend.getActiveWorkspace()?.id || '';
|
||||
await apiFetch(`${API_URL}/protese/os/${id}?clinicaId=${encodeURIComponent(clinicaId)}`, { method: 'DELETE' });
|
||||
@@ -1011,6 +1107,184 @@ export const HybridBackend = {
|
||||
return await res.json().catch(() => ({ feitas: [], recebidas: [] }));
|
||||
},
|
||||
|
||||
// Laboratório (Fase 3): clientes (clínicas) com métricas + extrato de recebimentos.
|
||||
getLabClientes: async (): Promise<any[]> => {
|
||||
const res = await apiFetch(`${API_URL}/protese/lab/clientes`);
|
||||
return await res.json().catch(() => []);
|
||||
},
|
||||
getLabPagamentos: async (): Promise<any[]> => {
|
||||
const res = await apiFetch(`${API_URL}/protese/lab/pagamentos`);
|
||||
return await res.json().catch(() => []);
|
||||
},
|
||||
getLabFechamento: async (): Promise<any[]> => {
|
||||
const res = await apiFetch(`${API_URL}/protese/lab/fechamento`);
|
||||
return await res.json().catch(() => []);
|
||||
},
|
||||
// Fase 6: OS indicadas (sem lab vinculado) + importar/vincular ao meu laboratório.
|
||||
getLabOsIndicadas: async (): Promise<any[]> => {
|
||||
const res = await apiFetch(`${API_URL}/protese/lab/os-indicadas`);
|
||||
return await res.json().catch(() => []);
|
||||
},
|
||||
vincularOsLab: async (osId: string) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/os/${osId}/vincular-lab`, { method: 'POST' });
|
||||
return await res.json().catch(() => ({ error: 'falha' }));
|
||||
},
|
||||
// Fase 6: solicitar pagamento (cobrar saldo) ao cliente.
|
||||
cobrarOs: async (osId: string, body: { valor?: number; observacao?: string } = {}) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/os/${osId}/cobrar`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
return await res.json().catch(() => ({ error: 'falha' }));
|
||||
},
|
||||
// Fase 6: dar entrada em OS pelo lado do laboratório (cliente existente ou avulso).
|
||||
criarLabOs: async (body: any) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/lab/os`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
return await res.json().catch(() => ({ error: 'falha' }));
|
||||
},
|
||||
// Fase 4: dados do laboratório (PF/PJ, CPF/CNPJ) + equipe de técnicos
|
||||
getLabDados: async (): Promise<any> => {
|
||||
const res = await apiFetch(`${API_URL}/protese/lab`);
|
||||
return await res.json().catch(() => ({}));
|
||||
},
|
||||
updateLabDados: async (body: { nome_fantasia: string; documento?: string; pessoa_tipo?: 'PF' | 'PJ' }) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/lab`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao salvar');
|
||||
return await res.json();
|
||||
},
|
||||
getLabEquipe: async (): Promise<any[]> => {
|
||||
const res = await apiFetch(`${API_URL}/protese/lab/equipe`);
|
||||
return await res.json().catch(() => []);
|
||||
},
|
||||
addLabTecnico: async (body: { email: string; nome?: string }) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/lab/equipe`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao adicionar técnico');
|
||||
return await res.json();
|
||||
},
|
||||
removeLabTecnico: async (userId: string) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/lab/equipe/${userId}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao remover');
|
||||
return await res.json();
|
||||
},
|
||||
// Fase 5: indicadores do laboratório (Nota ScoreOdonto + KPIs derivados)
|
||||
getLabIndicadores: async (): Promise<any> => {
|
||||
const res = await apiFetch(`${API_URL}/protese/lab/indicadores`);
|
||||
return await res.json().catch(() => ({}));
|
||||
},
|
||||
// Agenda & Coleta do laboratório
|
||||
agendarColeta: async (osId: string, body: { tipo: 'coleta' | 'entrega'; data_hora?: string; endereco?: string; responsavel?: string; observacao?: string }) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/os/${osId}/coleta`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao agendar');
|
||||
return await res.json();
|
||||
},
|
||||
getLabAgenda: async (): Promise<any[]> => {
|
||||
const res = await apiFetch(`${API_URL}/protese/lab/agenda`);
|
||||
return await res.json().catch(() => []);
|
||||
},
|
||||
statusColeta: async (coletaId: string, status: 'realizada' | 'cancelada') => {
|
||||
const res = await apiFetch(`${API_URL}/protese/coleta/${coletaId}/status`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status }) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao atualizar');
|
||||
return await res.json();
|
||||
},
|
||||
// Fatia 4: a clínica avalia o laboratório ao final da OS
|
||||
avaliarProteseOS: async (osId: string, body: { estrelas: number; comentario?: string }) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/os/${osId}/avaliar`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao avaliar');
|
||||
return await res.json();
|
||||
},
|
||||
// Modo 1: link seguro de acompanhamento da OS
|
||||
gerarProteseLink: async (osId: string) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/os/${osId}/link`, { method: 'POST' });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao gerar link');
|
||||
return await res.json();
|
||||
},
|
||||
getProteseLinkStatus: async (osId: string) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/os/${osId}/link/status`);
|
||||
return await res.json().catch(() => ({ ativo: false }));
|
||||
},
|
||||
// Cotação (RFQ) — Fatia 3
|
||||
criarRfq: async (body: any) => {
|
||||
const cid = HybridBackend.getActiveWorkspace()?.id || '';
|
||||
const res = await apiFetch(`${API_URL}/protese/rfq?clinicaId=${encodeURIComponent(cid)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao pedir cotação');
|
||||
return await res.json();
|
||||
},
|
||||
getRfqs: async (): Promise<any[]> => {
|
||||
const cid = HybridBackend.getActiveWorkspace()?.id || '';
|
||||
const res = await apiFetch(`${API_URL}/protese/rfq?clinicaId=${encodeURIComponent(cid)}`);
|
||||
return await res.json().catch(() => []);
|
||||
},
|
||||
getRfqRecebidas: async (): Promise<any[]> => {
|
||||
const res = await apiFetch(`${API_URL}/protese/rfq/recebidas`);
|
||||
return await res.json().catch(() => []);
|
||||
},
|
||||
responderCotacao: async (cotacaoId: string, body: { valor: number; prazo_dias?: number; observacao?: string }) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/rfq/cotacao/${cotacaoId}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao cotar');
|
||||
return await res.json();
|
||||
},
|
||||
escolherCotacao: async (rfqId: string, cotacaoId: string) => {
|
||||
const cid = HybridBackend.getActiveWorkspace()?.id || '';
|
||||
const res = await apiFetch(`${API_URL}/protese/rfq/${rfqId}/escolher?clinicaId=${encodeURIComponent(cid)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cotacao_id: cotacaoId }) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao escolher');
|
||||
return await res.json();
|
||||
},
|
||||
revogarProteseLink: async (osId: string) => {
|
||||
const res = await apiFetch(`${API_URL}/protese/os/${osId}/link/revogar`, { method: 'POST' });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao revogar');
|
||||
return await res.json();
|
||||
},
|
||||
// Cadastro de protético INTERNO (sem conta) pela clínica
|
||||
cadastrarProteticoInterno: async (body: { nome: string; telefone?: string; especialidade?: string }) => {
|
||||
const cid = HybridBackend.getActiveWorkspace()?.id || '';
|
||||
const res = await apiFetch(`${API_URL}/protese/proteticos-internos?clinicaId=${encodeURIComponent(cid)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao cadastrar');
|
||||
return await res.json();
|
||||
},
|
||||
// Leitura PÚBLICA via token (sem login — não usa apiFetch/Authorization)
|
||||
getProtesePublic: async (token: string) => {
|
||||
const res = await fetch(`${API_URL}/p/${encodeURIComponent(token)}`);
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Link inválido ou expirado.');
|
||||
return await res.json();
|
||||
},
|
||||
getCarteiraPublic: async (token: string) => {
|
||||
const res = await fetch(`${API_URL}/p/${encodeURIComponent(token)}/carteira`);
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao carregar.');
|
||||
return await res.json();
|
||||
},
|
||||
// Ações PÚBLICAS pelo link (Modo 1, fatia 1b) — sem login
|
||||
custodiaPublic: async (token: string, para_local: 'clinica' | 'laboratorio') => {
|
||||
const res = await fetch(`${API_URL}/p/${encodeURIComponent(token)}/custodia`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ para_local }) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao registrar.');
|
||||
return await res.json();
|
||||
},
|
||||
statusPublic: async (token: string, status: string) => {
|
||||
const res = await fetch(`${API_URL}/p/${encodeURIComponent(token)}/status`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status }) });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao avançar etapa.');
|
||||
return await res.json();
|
||||
},
|
||||
uploadFotoPublic: async (token: string, file: File) => {
|
||||
const fd = new FormData();
|
||||
fd.append('foto', file);
|
||||
const res = await fetch(`${API_URL}/p/${encodeURIComponent(token)}/foto`, { method: 'POST', body: fd });
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || 'Erro ao anexar foto.');
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
// Agenda de uma sala (reservas + bloqueios de manutenção) — usado pelo operador da unidade.
|
||||
getSalaReservas: async (salaId: string): Promise<any[]> => {
|
||||
const res = await apiFetch(`${API_URL}/salas/${salaId}/reservas`);
|
||||
return await res.json().catch(() => []);
|
||||
},
|
||||
|
||||
// Bloqueio de sala para manutenção (sem cobrança; tira a sala da agenda no intervalo).
|
||||
criarManutencaoSala: async (salaId: string, body: { inicio: string; fim: string; motivo?: string }) => {
|
||||
const res = await apiFetch(`${API_URL}/salas/${salaId}/manutencao`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data?.error || 'Falha ao bloquear a sala.');
|
||||
return data;
|
||||
},
|
||||
|
||||
saveFinanceiro: async (item: Partial<FinanceiroItem>) => {
|
||||
const clinicaId = HybridBackend.getActiveWorkspace()?.id || '';
|
||||
const data = enforceUppercase(item);
|
||||
@@ -1207,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,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Building2, ArrowRight, Shield, MapPin, BadgeCheck, Palette, Save, Loader2 } from 'lucide-react';
|
||||
import { Building2, ArrowRight, Shield, MapPin, BadgeCheck, Palette, Save, Loader2, Plus, X } from 'lucide-react';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { PageHeader } from '../components/PageHeader.tsx';
|
||||
import { EmptyState } from '../components/EmptyState.tsx';
|
||||
@@ -24,9 +24,43 @@ export const ClinicasView: React.FC = () => {
|
||||
const currentRole = HybridBackend.getCurrentRole();
|
||||
const isAdmin = currentRole === 'admin' || currentRole === 'donoclinica';
|
||||
|
||||
// Papel da CONTA (não da unidade ativa) — é o que o backend usa para decidir
|
||||
// se a nova unidade nasce como 'clinica' ou 'consultorio'.
|
||||
const accountRole = (() => {
|
||||
try { return JSON.parse(localStorage.getItem('SCOREODONTO_USER_DATA') || '{}').role as string | undefined; }
|
||||
catch { return undefined; }
|
||||
})();
|
||||
const isConsultorioAccount = accountRole === 'donoconsultorio';
|
||||
const unitLabel = isConsultorioAccount ? 'CONSULTÓRIO' : 'CLÍNICA';
|
||||
const canCreate = ['donoclinica', 'donoconsultorio', 'admin'].includes(accountRole || '');
|
||||
|
||||
const [isEditingColor, setIsEditingColor] = useState<string | null>(null);
|
||||
const [savingColor, setSavingColor] = useState(false);
|
||||
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [newNome, setNewNome] = useState('');
|
||||
const [newDoc, setNewDoc] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newNome.trim()) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
const result = await HybridBackend.createClinica(newNome.trim(), newDoc.trim() || undefined);
|
||||
if (!result.success) { toast.error(result.message || 'ERRO AO CRIAR UNIDADE.'); return; }
|
||||
// Recarrega a lista COMPLETA do servidor (acrescenta a nova unidade às existentes,
|
||||
// em vez de sobrescrever). A tela relê os workspaces no reload.
|
||||
await HybridBackend.refreshWorkspaces();
|
||||
toast.success(`${unitLabel} CRIADA!`);
|
||||
setTimeout(() => window.location.reload(), 900);
|
||||
} catch {
|
||||
toast.error('ERRO DE CONEXÃO.');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSwitch = (workspaceId: string) => {
|
||||
if (activeWorkspace?.id === workspaceId) {
|
||||
toast.info("VOCÊ JÁ ESTÁ NESTA UNIDADE.");
|
||||
@@ -81,6 +115,17 @@ export const ClinicasView: React.FC = () => {
|
||||
description="Configure a identidade visual e selecione a clínica para gerenciar atendimentos."
|
||||
/>
|
||||
|
||||
{canCreate && (
|
||||
<div className="flex justify-end -mt-4">
|
||||
<button
|
||||
onClick={() => { setNewNome(''); setNewDoc(''); setShowCreate(true); }}
|
||||
className="inline-flex items-center gap-2 px-6 py-3 rounded-2xl bg-teal-600 hover:bg-teal-700 text-white text-xs font-black uppercase tracking-widest shadow-lg transition-colors"
|
||||
>
|
||||
<Plus size={16} /> Nova {unitLabel.toLowerCase()}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{workspaces.length === 0 && (
|
||||
<EmptyState icon={Building2} title="Nenhuma unidade ainda" description="Você ainda não tem clínicas ou consultórios vinculados a esta conta." />
|
||||
)}
|
||||
@@ -208,6 +253,54 @@ export const ClinicasView: React.FC = () => {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/40 backdrop-blur-sm flex items-center justify-center p-4"
|
||||
onClick={() => { if (!creating) setShowCreate(false); }}
|
||||
>
|
||||
<div className="bg-white rounded-3xl w-full max-w-md p-8 shadow-2xl" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h3 className="text-xl font-black text-gray-900 uppercase tracking-tight">Nova {unitLabel.toLowerCase()}</h3>
|
||||
<button
|
||||
onClick={() => { if (!creating) setShowCreate(false); }}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 rounded-xl transition-colors"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleCreate} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-500 mb-1 uppercase">Nome do {unitLabel.toLowerCase()} *</label>
|
||||
<input
|
||||
autoFocus
|
||||
required
|
||||
value={newNome}
|
||||
onChange={e => setNewNome(e.target.value.toUpperCase())}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-teal-500 outline-none text-sm font-medium uppercase"
|
||||
placeholder={isConsultorioAccount ? 'EX: DR. SILVA - CONSULTÓRIO' : 'EX: CLÍNICA SORRISO'}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-500 mb-1 uppercase">CNPJ / CPF (opcional)</label>
|
||||
<input
|
||||
value={newDoc}
|
||||
onChange={e => setNewDoc(e.target.value)}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-teal-500 outline-none text-sm font-medium"
|
||||
placeholder="00.000.000/0000-00"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={creating || !newNome.trim()}
|
||||
className="w-full bg-teal-600 hover:bg-teal-700 text-white py-3 rounded-xl font-black uppercase flex items-center justify-center gap-2 transition-all shadow-lg disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{creating ? <><Loader2 className="animate-spin" size={18} /> Criando...</> : <><Plus size={18} /> Criar {unitLabel.toLowerCase()}</>}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,17 +10,6 @@ function getStoredUser(): { nome?: string; role?: string } {
|
||||
try { return JSON.parse(localStorage.getItem('SCOREODONTO_USER_DATA') || '{}'); } catch { return {}; }
|
||||
}
|
||||
|
||||
function applyWorkspace(result: any, userRole: string) {
|
||||
const ws = {
|
||||
id: result.clinica.id,
|
||||
nome: result.clinica.nome_fantasia,
|
||||
cor: '#2563eb',
|
||||
role: userRole,
|
||||
};
|
||||
localStorage.setItem('SCOREODONTO_WORKSPACES', JSON.stringify([ws]));
|
||||
localStorage.setItem('SCOREODONTO_ACTIVE_WORKSPACE', JSON.stringify(ws));
|
||||
}
|
||||
|
||||
export const CreateClinicView: React.FC<CreateClinicViewProps> = ({ onCreated }) => {
|
||||
const storedUser = getStoredUser();
|
||||
const userRole = storedUser.role || 'donoclinica';
|
||||
@@ -37,7 +26,9 @@ export const CreateClinicView: React.FC<CreateClinicViewProps> = ({ onCreated })
|
||||
const doCreate = async (nomeFinal: string, doc?: string) => {
|
||||
const result = await HybridBackend.createClinica(nomeFinal.trim(), doc?.trim() || undefined);
|
||||
if (!result.success) return result;
|
||||
applyWorkspace(result, userRole);
|
||||
// Recarrega a lista do servidor (acrescenta a nova unidade às existentes em vez de
|
||||
// sobrescrever). No onboarding o usuário tem só esta unidade, então vira a ativa.
|
||||
await HybridBackend.refreshWorkspaces();
|
||||
return result;
|
||||
};
|
||||
|
||||
|
||||
@@ -62,7 +62,9 @@ const PAGINAS_RBAC: Array<{ grupo: string; itens: Array<{ key: string; label: st
|
||||
{ key: 'pacientes', label: 'PACIENTES' },
|
||||
{ key: 'ortodontia', label: 'ORTODONTIA' },
|
||||
{ key: 'tratamentos', label: 'TRATAMENTOS' },
|
||||
{ key: 'proteses', label: 'PRÓTESES' },
|
||||
{ key: 'orcamentos', label: 'ORÇAMENTOS' },
|
||||
{ key: 'contratos', label: 'CONTRATOS' },
|
||||
{ key: 'rx-radiografias', label: 'RX / RADIOGRAFIAS' },
|
||||
],
|
||||
},
|
||||
@@ -94,8 +96,8 @@ const TODAS_PAGINAS = PAGINAS_RBAC.flatMap(g => g.itens.map(i => i.key));
|
||||
|
||||
// Modelos de cargo prontos (acelera a configuração).
|
||||
const TEMPLATES_CARGO: Array<{ nome: string; nivel: number; permissoes: string[] }> = [
|
||||
{ nome: 'RECEPÇÃO', nivel: 10, permissoes: ['dashboard', 'agenda', 'pacientes', 'leads', 'orcamentos'] },
|
||||
{ nome: 'AUXILIAR / ASB', nivel: 20, permissoes: ['dashboard', 'agenda', 'pacientes', 'ortodontia', 'tratamentos'] },
|
||||
{ nome: 'RECEPÇÃO', nivel: 10, permissoes: ['dashboard', 'agenda', 'pacientes', 'leads', 'orcamentos', 'contratos', 'lancar-gto', 'proteses'] },
|
||||
{ nome: 'AUXILIAR / ASB', nivel: 20, permissoes: ['dashboard', 'agenda', 'pacientes', 'ortodontia', 'tratamentos', 'proteses'] },
|
||||
{ nome: 'FINANCEIRO', nivel: 30, permissoes: ['dashboard', 'pacientes', 'financeiro', 'relatorios', 'lancar-gto'] },
|
||||
{ nome: 'GERENTE', nivel: 50, permissoes: [...TODAS_PAGINAS] },
|
||||
];
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { CalendarClock, RefreshCw, Building2, Truck, PackageCheck, CheckCircle2, MapPin, User } from 'lucide-react';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { useToast } from '../contexts/ToastContext.tsx';
|
||||
import { PageHeader } from '../components/PageHeader.tsx';
|
||||
|
||||
const dataHoraBR = (d: string) => { if (!d) return 'Sem data'; const x = new Date(d); return isNaN(x.getTime()) ? d : x.toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }); };
|
||||
const diaBR = (d: string) => { if (!d) return 'Sem data'; const x = new Date(d); return isNaN(x.getTime()) ? d : x.toLocaleDateString('pt-BR', { weekday: 'short', day: '2-digit', month: 'short' }); };
|
||||
|
||||
// Agenda do laboratório: coletas (lab busca) e entregas (lab leva) agendadas.
|
||||
export const LabAgendaView: React.FC = () => {
|
||||
const toast = useToast();
|
||||
const [lista, setLista] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [busy, setBusy] = useState('');
|
||||
|
||||
const carregar = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try { const r = await HybridBackend.getLabAgenda(); setLista(Array.isArray(r) ? r : []); }
|
||||
catch { /* silent */ } finally { setLoading(false); }
|
||||
}, []);
|
||||
useEffect(() => { carregar(); }, [carregar]);
|
||||
|
||||
const acao = async (id: string, status: 'realizada' | 'cancelada') => {
|
||||
setBusy(id);
|
||||
try { await HybridBackend.statusColeta(id, status); toast.success(status === 'realizada' ? 'MARCADA COMO REALIZADA.' : 'CANCELADA.'); carregar(); }
|
||||
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(''); }
|
||||
};
|
||||
|
||||
// agrupa por dia (data_hora)
|
||||
const grupos: Record<string, any[]> = {};
|
||||
for (const c of lista.filter(x => x.status === 'agendada')) {
|
||||
const k = (c.data_hora || '').slice(0, 10) || 'sem-data';
|
||||
(grupos[k] = grupos[k] || []).push(c);
|
||||
}
|
||||
const realizadas = lista.filter(x => x.status === 'realizada').slice(0, 10);
|
||||
|
||||
const Card: React.FC<{ c: any }> = ({ c }) => {
|
||||
const coleta = c.tipo === 'coleta';
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 flex items-start gap-3">
|
||||
<div className={`w-10 h-10 rounded-xl flex items-center justify-center text-white shrink-0 ${coleta ? 'bg-violet-600' : 'bg-blue-600'}`}>{coleta ? <Truck size={18} /> : <PackageCheck size={18} />}</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-[9px] font-black px-2 py-0.5 rounded-full uppercase ${coleta ? 'bg-violet-100 text-violet-700' : 'bg-blue-100 text-blue-700'}`}>{coleta ? 'Coleta' : 'Entrega'}</span>
|
||||
<span className="text-[11px] font-black text-gray-500">{dataHoraBR(c.data_hora)}</span>
|
||||
</div>
|
||||
<p className="font-bold text-gray-800 text-sm truncate mt-1">{c.os_tipo} {c.paciente_nome ? <span className="text-gray-400 font-medium">· {c.paciente_nome}</span> : ''}</p>
|
||||
<p className="text-[11px] text-gray-400 uppercase font-bold flex items-center gap-1"><Building2 size={11} /> {c.clinica_nome || 'Clínica'}</p>
|
||||
{c.endereco && <p className="text-[11px] text-gray-400 flex items-center gap-1 mt-0.5"><MapPin size={11} /> {c.endereco}</p>}
|
||||
{c.responsavel && <p className="text-[11px] text-gray-400 flex items-center gap-1"><User size={11} /> {c.responsavel}</p>}
|
||||
{c.status === 'agendada' && (
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button disabled={busy === c.id} onClick={() => acao(c.id, 'realizada')} className="px-3 py-1.5 rounded-lg bg-[#2d6a4f] text-white text-[10px] font-black uppercase disabled:opacity-50 flex items-center gap-1"><CheckCircle2 size={12} /> Realizada</button>
|
||||
<button disabled={busy === c.id} onClick={() => acao(c.id, 'cancelada')} className="px-3 py-1.5 rounded-lg border border-gray-200 text-gray-500 text-[10px] font-black uppercase disabled:opacity-50">Cancelar</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-10 max-w-2xl">
|
||||
<PageHeader title="AGENDA DE COLETAS">
|
||||
<button onClick={carregar} className="bg-white border border-gray-200 text-gray-600 p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2 text-xs font-bold shadow-sm"><RefreshCw size={16} /></button>
|
||||
</PageHeader>
|
||||
|
||||
{loading ? <div className="flex justify-center py-16"><RefreshCw className="animate-spin text-teal-600" size={28} /></div>
|
||||
: lista.filter(x => x.status === 'agendada').length === 0 && realizadas.length === 0 ? <div className="py-16 text-center text-gray-400 font-bold uppercase text-sm">Nenhuma coleta ou entrega agendada.</div>
|
||||
: (
|
||||
<div className="space-y-5">
|
||||
{Object.keys(grupos).sort().map(dia => (
|
||||
<div key={dia}>
|
||||
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest mb-2 flex items-center gap-1.5"><CalendarClock size={12} /> {dia === 'sem-data' ? 'Sem data' : diaBR(grupos[dia][0].data_hora)}</p>
|
||||
<div className="space-y-2">{grupos[dia].map(c => <Card key={c.id} c={c} />)}</div>
|
||||
</div>
|
||||
))}
|
||||
{realizadas.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[10px] font-black text-gray-300 uppercase tracking-widest mb-2">Realizadas (recentes)</p>
|
||||
<div className="space-y-2 opacity-60">{realizadas.map(c => <Card key={c.id} c={c} />)}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,158 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Building2, Wallet, Clock, RefreshCw, Inbox, CheckCircle2, Phone, CalendarDays, Stethoscope, Briefcase, User } from 'lucide-react';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { PageHeader } from '../components/PageHeader.tsx';
|
||||
|
||||
const fmtBRL = (v: number) => 'R$ ' + Number(v || 0).toLocaleString('pt-BR', { minimumFractionDigits: 2 });
|
||||
const dataBR = (d: string) => (d || '').slice(0, 10).split('-').reverse().join('/');
|
||||
const mesBR = (m: string) => { const [y, mo] = (m || '').split('-'); return mo ? new Date(Number(y), Number(mo) - 1, 1).toLocaleDateString('pt-BR', { month: 'short', year: 'numeric' }) : m; };
|
||||
|
||||
const ORIGEM_META: Record<string, { icon: any; cls: string }> = {
|
||||
'Clínica': { icon: Building2, cls: 'bg-blue-100 text-blue-700' },
|
||||
'Consultório': { icon: Briefcase, cls: 'bg-teal-100 text-teal-700' },
|
||||
'Dentista': { icon: Stethoscope, cls: 'bg-violet-100 text-violet-700' },
|
||||
'Biomédico': { icon: User, cls: 'bg-pink-100 text-pink-700' },
|
||||
'Profissional': { icon: User, cls: 'bg-gray-100 text-gray-600' },
|
||||
};
|
||||
|
||||
// CRM Financeiro do laboratório: Clientes (por origem, com contato/ticket/recebíveis) + Financeiro
|
||||
// (fechamento mensal + recebimentos).
|
||||
export const LabClientesView: React.FC<{ tab?: 'clientes' | 'financeiro' }> = ({ tab = 'clientes' }) => {
|
||||
const [aba, setAba] = useState<'clientes' | 'financeiro'>(tab);
|
||||
const [clientes, setClientes] = useState<any[]>([]);
|
||||
const [pagamentos, setPagamentos] = useState<any[]>([]);
|
||||
const [fechamento, setFechamento] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => { setAba(tab); }, [tab]);
|
||||
const carregar = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [c, p, f] = await Promise.all([HybridBackend.getLabClientes(), HybridBackend.getLabPagamentos(), HybridBackend.getLabFechamento()]);
|
||||
setClientes(Array.isArray(c) ? c : []); setPagamentos(Array.isArray(p) ? p : []); setFechamento(Array.isArray(f) ? f : []);
|
||||
} catch { /* silent */ } finally { setLoading(false); }
|
||||
}, []);
|
||||
useEffect(() => { carregar(); }, [carregar]);
|
||||
|
||||
const totFaturado = clientes.reduce((s, c) => s + Number(c.faturado || 0), 0);
|
||||
const totRecebido = clientes.reduce((s, c) => s + Number(c.recebido || 0), 0);
|
||||
const totReceber = clientes.reduce((s, c) => s + Number(c.a_receber || 0), 0);
|
||||
// resumo por origem
|
||||
const porOrigem: Record<string, number> = {};
|
||||
for (const c of clientes) porOrigem[c.origem] = (porOrigem[c.origem] || 0) + 1;
|
||||
|
||||
const KPI: React.FC<{ titulo: string; valor: string; cor: string; Icon: any }> = ({ titulo, valor, cor, Icon }) => (
|
||||
<div className="bg-white p-5 rounded-2xl border border-gray-100 shadow-sm relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 p-3 opacity-10"><Icon size={56} className={cor} /></div>
|
||||
<h4 className="text-[10px] font-black text-gray-400 uppercase tracking-widest">{titulo}</h4>
|
||||
<div className="mt-2 text-xl font-black text-gray-900">{valor}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-10">
|
||||
<PageHeader title={aba === 'clientes' ? 'CLIENTES DO LABORATÓRIO' : 'FINANCEIRO DO LABORATÓRIO'}>
|
||||
<button onClick={carregar} className="bg-white border border-gray-200 text-gray-600 p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2 text-xs font-bold shadow-sm"><RefreshCw size={16} /></button>
|
||||
</PageHeader>
|
||||
|
||||
<div className="flex border-b border-gray-100">
|
||||
{(['clientes', 'financeiro'] as const).map(t => (
|
||||
<button key={t} onClick={() => setAba(t)} className={`px-4 py-2.5 text-xs font-black uppercase border-b-2 -mb-px transition-colors ${aba === t ? 'border-teal-600 text-teal-700' : 'border-transparent text-gray-400 hover:text-gray-600'}`}>{t === 'clientes' ? 'Clientes (CRM)' : 'Financeiro'}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading ? <div className="flex justify-center py-16"><RefreshCw className="animate-spin text-teal-600" size={28} /></div> : (
|
||||
<>
|
||||
{aba === 'clientes' && (
|
||||
clientes.length === 0 ? <div className="py-16 text-center text-gray-400 font-bold uppercase text-sm">Nenhuma clínica enviou OS ainda.</div> : (
|
||||
<>
|
||||
{/* resumo por origem */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(porOrigem).map(([o, n]) => {
|
||||
const m = ORIGEM_META[o] || ORIGEM_META['Profissional']; const I = m.icon;
|
||||
return <span key={o} className={`text-[10px] font-black px-3 py-1.5 rounded-full uppercase flex items-center gap-1.5 ${m.cls}`}><I size={12} /> {o}: {n}</span>;
|
||||
})}
|
||||
</div>
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-x-auto">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="bg-gray-50/60"><tr>{['Cliente', 'Origem', 'Contato', 'OS', 'Ativas', 'Ticket méd.', 'Últ. pedido', 'A receber'].map(h => <th key={h} className="px-4 py-3 text-[10px] font-black text-gray-400 uppercase tracking-widest whitespace-nowrap">{h}</th>)}</tr></thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{clientes.map(c => {
|
||||
const m = ORIGEM_META[c.origem] || ORIGEM_META['Profissional'];
|
||||
return (
|
||||
<tr key={c.clinica_id} className="hover:bg-gray-50/50">
|
||||
<td className="px-4 py-3 font-black text-gray-700 uppercase">{c.clinica_nome || '—'}</td>
|
||||
<td className="px-4 py-3"><span className={`text-[9px] font-black px-2 py-0.5 rounded-full uppercase ${m.cls}`}>{c.origem}</span></td>
|
||||
<td className="px-4 py-3 text-gray-500 whitespace-nowrap">{c.contato ? <span className="flex items-center gap-1"><Phone size={11} /> {c.contato}</span> : '—'}</td>
|
||||
<td className="px-4 py-3 text-gray-600 font-bold">{c.total}</td>
|
||||
<td className={`px-4 py-3 font-bold ${c.atrasadas > 0 ? 'text-red-500' : 'text-teal-600'}`}>{c.ativas}{c.atrasadas > 0 ? ` (${c.atrasadas}!)` : ''}</td>
|
||||
<td className="px-4 py-3 text-gray-600">{fmtBRL(c.ticket_medio)}</td>
|
||||
<td className="px-4 py-3 text-gray-400 whitespace-nowrap">{c.ultimo_pedido ? dataBR(c.ultimo_pedido) : '—'}</td>
|
||||
<td className={`px-4 py-3 font-black ${c.a_receber > 0 ? 'text-amber-600' : 'text-gray-300'}`}>{fmtBRL(c.a_receber)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
|
||||
{aba === 'financeiro' && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<KPI titulo="Faturado" valor={fmtBRL(totFaturado)} cor="text-gray-700" Icon={CheckCircle2} />
|
||||
<KPI titulo="Recebido" valor={fmtBRL(totRecebido)} cor="text-green-600" Icon={Wallet} />
|
||||
<KPI titulo="A receber" valor={fmtBRL(totReceber)} cor="text-amber-500" Icon={Clock} />
|
||||
</div>
|
||||
|
||||
{/* Fechamento mensal */}
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-100 bg-gray-50/50 flex items-center gap-2"><CalendarDays size={16} className="text-teal-600" /><h3 className="text-sm font-black text-gray-800 uppercase">Fechamento mensal</h3></div>
|
||||
{fechamento.length === 0 ? <div className="py-10 text-center text-gray-400 text-sm font-bold uppercase">Sem movimento ainda.</div> : (
|
||||
<div className="overflow-x-auto"><table className="w-full text-left text-xs">
|
||||
<thead className="bg-gray-50/60"><tr>{['Mês', 'Entregas', 'Faturado', 'Recebido', 'Saldo'].map(h => <th key={h} className="px-4 py-3 text-[10px] font-black text-gray-400 uppercase tracking-widest">{h}</th>)}</tr></thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{fechamento.map(m => (
|
||||
<tr key={m.mes} className="hover:bg-gray-50/50">
|
||||
<td className="px-4 py-3 font-black text-gray-700 uppercase">{mesBR(m.mes)}</td>
|
||||
<td className="px-4 py-3 text-gray-600">{m.entregues}</td>
|
||||
<td className="px-4 py-3 font-bold text-gray-800">{fmtBRL(m.faturado)}</td>
|
||||
<td className="px-4 py-3 font-bold text-green-600">{fmtBRL(m.recebido)}</td>
|
||||
<td className={`px-4 py-3 font-black ${m.faturado - m.recebido > 0 ? 'text-amber-600' : 'text-gray-300'}`}>{fmtBRL(Math.max(0, m.faturado - m.recebido))}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Extrato */}
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-100 bg-gray-50/50 flex items-center gap-2"><Inbox size={16} className="text-teal-600" /><h3 className="text-sm font-black text-gray-800 uppercase">Recebimentos</h3><span className="text-[10px] font-bold text-gray-400 uppercase ml-auto">{pagamentos.length}</span></div>
|
||||
{pagamentos.length === 0 ? <div className="py-12 text-center text-gray-400 text-sm font-bold uppercase">Nenhum recebimento.</div> : (
|
||||
<div className="overflow-x-auto"><table className="w-full text-left text-xs">
|
||||
<thead className="bg-gray-50/60"><tr>{['Cliente', 'Trabalho', 'Forma', 'Data', 'Valor'].map(h => <th key={h} className="px-4 py-3 text-[10px] font-black text-gray-400 uppercase tracking-widest whitespace-nowrap">{h}</th>)}</tr></thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{pagamentos.map(p => (
|
||||
<tr key={p.id} className="hover:bg-gray-50/50">
|
||||
<td className="px-4 py-3 font-bold text-gray-700 uppercase">{p.clinica_nome || '—'}</td>
|
||||
<td className="px-4 py-3 text-gray-500">{p.tipo || '—'}</td>
|
||||
<td className="px-4 py-3 text-gray-500">{p.forma || '—'}</td>
|
||||
<td className="px-4 py-3 text-gray-500 whitespace-nowrap">{dataBR(p.data)}</td>
|
||||
<td className="px-4 py-3 font-black text-green-700">{fmtBRL(p.valor)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table></div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-300 font-bold uppercase">Faturado = custo das OS entregues (≠ garantia). A receber = faturado − recebido.</p>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { FileText, RefreshCw, Building2, CheckCircle2, XCircle, Send, Clock } from 'lucide-react';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { useToast } from '../contexts/ToastContext.tsx';
|
||||
import { PageHeader } from '../components/PageHeader.tsx';
|
||||
|
||||
const dataBR = (d: string) => (d || '').slice(0, 10).split('-').reverse().join('/');
|
||||
const fmtBRL = (v: number) => 'R$ ' + Number(v || 0).toLocaleString('pt-BR', { minimumFractionDigits: 2 });
|
||||
|
||||
// Área do Laboratório (Fatia 3): pedidos de cotação recebidos das clínicas.
|
||||
export const LabCotacoesView: React.FC = () => {
|
||||
const toast = useToast();
|
||||
const [lista, setLista] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [forms, setForms] = useState<Record<string, { valor: string; prazo: string; obs: string }>>({});
|
||||
const [busy, setBusy] = useState('');
|
||||
|
||||
const carregar = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try { const r = await HybridBackend.getRfqRecebidas(); setLista(Array.isArray(r) ? r : []); }
|
||||
catch { /* silent */ } finally { setLoading(false); }
|
||||
}, []);
|
||||
useEffect(() => { carregar(); }, [carregar]);
|
||||
|
||||
const setF = (id: string, k: string, v: string) => setForms(s => ({ ...s, [id]: { ...(s[id] || { valor: '', prazo: '', obs: '' }), [k]: v } }));
|
||||
const responder = async (c: any) => {
|
||||
const f = forms[c.cotacao_id] || { valor: '', prazo: '', obs: '' };
|
||||
const valor = parseFloat(f.valor);
|
||||
if (!(valor > 0)) { toast.error('INFORME O VALOR.'); return; }
|
||||
setBusy(c.cotacao_id);
|
||||
try { await HybridBackend.responderCotacao(c.cotacao_id, { valor, prazo_dias: parseInt(f.prazo, 10) || undefined, observacao: f.obs.trim() || undefined }); toast.success('COTAÇÃO ENVIADA.'); carregar(); }
|
||||
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(''); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-10 max-w-2xl">
|
||||
<PageHeader title="PEDIDOS DE COTAÇÃO">
|
||||
<button onClick={carregar} className="bg-white border border-gray-200 text-gray-600 p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2 text-xs font-bold shadow-sm"><RefreshCw size={16} /></button>
|
||||
</PageHeader>
|
||||
|
||||
{loading ? <div className="flex justify-center py-16"><RefreshCw className="animate-spin text-teal-600" size={28} /></div>
|
||||
: lista.length === 0 ? <div className="py-16 text-center text-gray-400 font-bold uppercase text-sm">Nenhum pedido de cotação.</div>
|
||||
: (
|
||||
<div className="space-y-3">
|
||||
{lista.map(c => {
|
||||
const aberta = c.rfq_status === 'aberta';
|
||||
const ganhou = c.cotacao_status === 'escolhida';
|
||||
const perdeu = c.cotacao_status === 'recusada';
|
||||
const f = forms[c.cotacao_id] || { valor: '', prazo: '', obs: '' };
|
||||
return (
|
||||
<div key={c.cotacao_id} className={`bg-white rounded-2xl border shadow-sm p-4 ${ganhou ? 'border-green-200' : 'border-gray-100'}`}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="font-black text-gray-800 text-sm">{c.descricao}</p>
|
||||
<p className="text-[11px] text-gray-400 uppercase font-bold flex items-center gap-1 mt-0.5"><Building2 size={11} /> {c.clinica_nome || 'Clínica'}{c.dentes ? ` · dentes ${c.dentes}` : ''}</p>
|
||||
</div>
|
||||
{ganhou && <span className="text-[9px] font-black px-2 py-1 rounded-full bg-green-100 text-green-700 uppercase flex items-center gap-1 shrink-0"><CheckCircle2 size={11} /> Você ganhou</span>}
|
||||
{perdeu && <span className="text-[9px] font-black px-2 py-1 rounded-full bg-gray-100 text-gray-500 uppercase flex items-center gap-1 shrink-0"><XCircle size={11} /> Não escolhida</span>}
|
||||
{aberta && c.cotacao_status === 'enviada' && <span className="text-[9px] font-black px-2 py-1 rounded-full bg-amber-100 text-amber-700 uppercase shrink-0">Cotado · {fmtBRL(c.valor)}</span>}
|
||||
</div>
|
||||
{(c.paciente_nome || c.prazo_desejado) && <p className="text-[11px] text-gray-400 mt-2">{c.paciente_nome || ''}{c.prazo_desejado ? ` · prazo desejado ${dataBR(c.prazo_desejado)}` : ''}</p>}
|
||||
|
||||
{aberta && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-100 flex flex-wrap items-end gap-2">
|
||||
<div>
|
||||
<label className="text-[9px] font-black text-gray-400 uppercase block mb-1">Valor (R$)</label>
|
||||
<input type="number" step="0.01" value={f.valor} onChange={e => setF(c.cotacao_id, 'valor', e.target.value)} placeholder={c.valor ? String(c.valor) : '0,00'} className="w-24 p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs font-bold outline-none focus:border-teal-400" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[9px] font-black text-gray-400 uppercase block mb-1">Prazo (dias)</label>
|
||||
<input type="number" value={f.prazo} onChange={e => setF(c.cotacao_id, 'prazo', e.target.value)} className="w-20 p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs font-bold outline-none focus:border-teal-400" />
|
||||
</div>
|
||||
<input value={f.obs} onChange={e => setF(c.cotacao_id, 'obs', e.target.value)} placeholder="Observação" className="flex-1 min-w-[120px] p-2 bg-gray-50 border border-gray-100 rounded-lg text-xs outline-none focus:border-teal-400" />
|
||||
<button disabled={busy === c.cotacao_id} onClick={() => responder(c)} className="px-3 py-2 rounded-lg bg-[#2d6a4f] text-white text-[11px] font-black uppercase disabled:opacity-50 flex items-center gap-1.5"><Send size={13} /> {c.cotacao_status === 'enviada' ? 'Atualizar' : 'Cotar'}</button>
|
||||
</div>
|
||||
)}
|
||||
{!aberta && !ganhou && !perdeu && <p className="text-[10px] text-gray-400 uppercase font-bold mt-2 flex items-center gap-1"><Clock size={11} /> Encerrado</p>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { UsersRound, Building2, RefreshCw, Trash2, Plus, KeyRound, Save } 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';
|
||||
|
||||
// Área do Laboratório (Fase 4): dados PF/PJ (CPF/CNPJ) + equipe de técnicos.
|
||||
export const LabEquipeView: React.FC = () => {
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
const [dados, setDados] = useState<any>(null);
|
||||
const [equipe, setEquipe] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [email, setEmail] = useState('');
|
||||
const [nome, setNome] = useState('');
|
||||
const [cred, setCred] = useState<{ email: string; senha: string } | null>(null);
|
||||
|
||||
const carregar = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [d, e] = await Promise.all([HybridBackend.getLabDados(), HybridBackend.getLabEquipe()]);
|
||||
setDados(d || {});
|
||||
setEquipe(Array.isArray(e) ? e : []);
|
||||
} catch { /* silent */ } finally { setLoading(false); }
|
||||
}, []);
|
||||
useEffect(() => { carregar(); }, [carregar]);
|
||||
|
||||
const salvarDados = async () => {
|
||||
if (!dados?.nome_fantasia?.trim()) { toast.error('INFORME O NOME.'); return; }
|
||||
setBusy(true);
|
||||
try { await HybridBackend.updateLabDados({ nome_fantasia: dados.nome_fantasia, documento: dados.documento || '', pessoa_tipo: dados.pessoa_tipo }); toast.success('DADOS SALVOS.'); carregar(); }
|
||||
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
|
||||
};
|
||||
const addTecnico = async () => {
|
||||
if (!email.trim()) { toast.error('INFORME O E-MAIL.'); return; }
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await HybridBackend.addLabTecnico({ email: email.trim(), nome: nome.trim() || undefined });
|
||||
toast.success('TÉCNICO ADICIONADO.');
|
||||
if (r?.created && r?.tempPassword) setCred({ email: email.trim().toLowerCase(), senha: r.tempPassword });
|
||||
setEmail(''); setNome(''); carregar();
|
||||
} catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
|
||||
};
|
||||
const remover = async (u: any) => {
|
||||
if (!(await confirm(`REMOVER ${u.nome || u.email} DA EQUIPE?`))) return;
|
||||
try { await HybridBackend.removeLabTecnico(u.id); toast.success('REMOVIDO.'); carregar(); }
|
||||
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); }
|
||||
};
|
||||
|
||||
if (loading || !dados) return <div className="flex justify-center py-20"><RefreshCw className="animate-spin text-teal-600" size={28} /></div>;
|
||||
const ehPJ = dados.pessoa_tipo === 'PJ';
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-10 max-w-2xl">
|
||||
<PageHeader title="LABORATÓRIO & EQUIPE">
|
||||
<button onClick={carregar} className="bg-white border border-gray-200 text-gray-600 p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2 text-xs font-bold shadow-sm"><RefreshCw size={16} /></button>
|
||||
</PageHeader>
|
||||
|
||||
{/* Dados do provedor */}
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-5 space-y-4">
|
||||
<h3 className="text-sm font-black text-gray-800 uppercase flex items-center gap-2"><Building2 size={16} className="text-teal-600" /> Dados do provedor</h3>
|
||||
<div className="flex gap-2">
|
||||
{(['PF', 'PJ'] as const).map(t => (
|
||||
<button key={t} onClick={() => setDados((d: any) => ({ ...d, pessoa_tipo: t }))} className={`flex-1 py-2.5 rounded-xl font-black text-xs uppercase border-2 transition-colors ${dados.pessoa_tipo === t ? 'border-teal-500 bg-teal-50 text-teal-700' : 'border-gray-100 text-gray-400'}`}>
|
||||
{t === 'PF' ? 'Protético Individual (PF)' : 'Laboratório (PJ)'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">{ehPJ ? 'Nome Fantasia' : 'Nome'}</label>
|
||||
<input value={dados.nome_fantasia || ''} onChange={e => setDados((d: any) => ({ ...d, nome_fantasia: e.target.value }))} className="w-full p-3 bg-gray-50 border-2 border-gray-100 rounded-xl text-sm font-bold text-gray-700 outline-none focus:border-teal-400 uppercase" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">{ehPJ ? 'CNPJ' : 'CPF'}</label>
|
||||
<input value={dados.documento || ''} onChange={e => setDados((d: any) => ({ ...d, documento: e.target.value }))} placeholder={ehPJ ? '00.000.000/0000-00' : '000.000.000-00'} className="w-full p-3 bg-gray-50 border-2 border-gray-100 rounded-xl text-sm font-bold text-gray-700 outline-none focus:border-teal-400" />
|
||||
</div>
|
||||
<button disabled={busy} onClick={salvarDados} className="bg-teal-600 text-white px-4 py-2.5 rounded-xl font-black text-sm uppercase disabled:opacity-50 flex items-center gap-2"><Save size={15} /> Salvar dados</button>
|
||||
</div>
|
||||
|
||||
{/* Equipe */}
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-5 space-y-4">
|
||||
<h3 className="text-sm font-black text-gray-800 uppercase flex items-center gap-2"><UsersRound size={16} className="text-violet-600" /> Equipe de técnicos</h3>
|
||||
<p className="text-[11px] text-gray-400 font-bold uppercase">Técnicos veem a bancada e operam a produção — sem acesso a clientes, financeiro ou valores do paciente.</p>
|
||||
|
||||
{cred && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-3 text-xs">
|
||||
<p className="font-black text-amber-700 uppercase flex items-center gap-1.5"><KeyRound size={13} /> Credencial criada</p>
|
||||
<p className="text-amber-800 mt-1">{cred.email} · senha temporária: <b>{cred.senha}</b></p>
|
||||
<button onClick={() => setCred(null)} className="text-[10px] font-black text-amber-600 uppercase mt-1">OK, anotei</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
{equipe.map(u => (
|
||||
<div key={u.id} className="flex items-center gap-2 bg-gray-50 rounded-lg px-3 py-2 text-xs">
|
||||
<div className="w-7 h-7 rounded-lg bg-violet-100 flex items-center justify-center text-violet-600 font-black shrink-0">{(u.nome || u.email || '?')[0].toUpperCase()}</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-bold text-gray-700 truncate uppercase">{u.nome || '—'}</p>
|
||||
<p className="text-[10px] text-gray-400 truncate">{u.email}</p>
|
||||
</div>
|
||||
<button onClick={() => remover(u)} className="text-gray-300 hover:text-red-500"><Trash2 size={15} /></button>
|
||||
</div>
|
||||
))}
|
||||
{equipe.length === 0 && <p className="text-[11px] text-gray-300 font-bold uppercase">Nenhum técnico na equipe.</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<input value={nome} onChange={e => setNome(e.target.value)} placeholder="Nome (opcional)" className="w-32 p-2.5 bg-gray-50 border border-gray-100 rounded-lg text-xs outline-none focus:border-teal-400" />
|
||||
<input value={email} onChange={e => setEmail(e.target.value)} placeholder="E-mail do técnico" className="flex-1 p-2.5 bg-gray-50 border border-gray-100 rounded-lg text-xs outline-none focus:border-teal-400" />
|
||||
<button disabled={busy} onClick={addTecnico} className="px-3 rounded-lg bg-violet-600 text-white text-xs font-black uppercase disabled:opacity-50 flex items-center gap-1"><Plus size={14} /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,279 @@
|
||||
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 = [
|
||||
{ v: 'clinica', label: 'Clínica', icon: Building2 },
|
||||
{ v: 'consultorio', label: 'Consultório', icon: Briefcase },
|
||||
{ v: 'dentista', label: 'Dentista', icon: Stethoscope },
|
||||
];
|
||||
|
||||
// Painel do LABORATÓRIO (protético): aparece quando o workspace ativo é tipo='laboratorio'.
|
||||
// Resumo a partir do que já existe — a Bancada (OS) + o financeiro do workspace do lab.
|
||||
export const LabHomeView: React.FC = () => {
|
||||
const ws: any = HybridBackend.getActiveWorkspace();
|
||||
const toast = useToast();
|
||||
const [os, setOs] = useState<any[]>([]);
|
||||
const [fin, setFin] = useState<any[]>([]);
|
||||
const [indicadas, setIndicadas] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showEntrada, setShowEntrada] = useState(false);
|
||||
const [importando, setImportando] = useState<string | null>(null);
|
||||
|
||||
const carregar = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [b, f, ind] = await Promise.all([HybridBackend.getProteseBancada(), HybridBackend.getLabFechamento(), HybridBackend.getLabOsIndicadas()]);
|
||||
setOs(Array.isArray(b) ? b : []);
|
||||
setFin(Array.isArray(f) ? f : []);
|
||||
setIndicadas(Array.isArray(ind) ? ind : []);
|
||||
} catch { /* silent */ } finally { setLoading(false); }
|
||||
}, []);
|
||||
useEffect(() => { carregar(); }, [carregar]);
|
||||
|
||||
const importar = async (osId: string) => {
|
||||
setImportando(osId);
|
||||
try { const r = await HybridBackend.vincularOsLab(osId); if (r?.error) throw new Error(r.error); toast.success('OS IMPORTADA PARA O LABORATÓRIO.'); carregar(); }
|
||||
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);
|
||||
const entreguesMes = os.filter(o => o.status === 'entregue' && (o.updated_at || '').slice(0, 7) === ymMes).length;
|
||||
// Faturamento do mês: do fechamento do próprio lab (custo das OS entregues no mês).
|
||||
const fatMes = Number(fin.find(m => m.mes === ymMes)?.faturado || 0);
|
||||
|
||||
const KPI: React.FC<{ titulo: string; valor: string; cor: string; Icon: any; sub?: string }> = ({ titulo, valor, cor, Icon, sub }) => (
|
||||
<div className="bg-white p-5 rounded-2xl border border-gray-100 shadow-sm relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 p-3 opacity-10"><Icon size={56} className={cor} /></div>
|
||||
<h4 className="text-[10px] font-black text-gray-400 uppercase tracking-widest">{titulo}</h4>
|
||||
<div className="mt-2 text-xl font-black text-gray-900">{valor}</div>
|
||||
{sub && <p className="mt-2 text-[10px] font-bold text-gray-400 uppercase">{sub}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Distribuição por status (fila de produção).
|
||||
const STATUS_ORD = ['solicitado', 'recebido', 'producao', 'prova', 'ajuste', 'finalizado'];
|
||||
const STATUS_LABEL: Record<string, string> = { solicitado: 'Solicitado', recebido: 'Recebido', producao: 'Produção', prova: 'Prova', ajuste: 'Ajuste', finalizado: 'Finalizado' };
|
||||
const porStatus = STATUS_ORD.map(s => ({ s, n: ativas.filter(o => o.status === s).length })).filter(x => x.n > 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-10">
|
||||
<PageHeader title="PAINEL DO LABORATÓRIO">
|
||||
<button onClick={() => setShowEntrada(true)} className="text-white px-3 py-2 rounded-lg flex items-center gap-2 text-xs font-black uppercase shadow-sm" style={{ backgroundColor: '#2d6a4f' }}>
|
||||
<Plus size={16} /> <span className="hidden sm:inline">Dar entrada</span>
|
||||
</button>
|
||||
<button onClick={carregar} className="bg-white border border-gray-200 text-gray-600 p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2 text-xs font-bold transition-all shadow-sm">
|
||||
<RefreshCw size={16} /> <span className="hidden sm:inline">ATUALIZAR</span>
|
||||
</button>
|
||||
</PageHeader>
|
||||
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm px-6 py-4 flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl flex items-center justify-center text-white shrink-0" style={{ backgroundColor: '#2d6a4f' }}><FlaskConical size={20} /></div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-black text-gray-900 uppercase truncate">{ws?.nome || 'Laboratório'}</p>
|
||||
<p className="text-[11px] text-gray-400 font-bold uppercase">Área de gestão do protético</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? <div className="flex justify-center py-16"><RefreshCw className="animate-spin text-teal-600" size={28} /></div> : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<KPI titulo="Na bancada" valor={String(ativas.length)} cor="text-teal-600" Icon={Inbox} sub="OS em produção" />
|
||||
<KPI titulo="Atrasadas" valor={String(atrasadas)} cor="text-red-500" Icon={AlertTriangle} sub="Prazo vencido" />
|
||||
<KPI titulo="Entregues no mês" valor={String(entreguesMes)} cor="text-green-600" Icon={CheckCircle2} sub="Concluídas" />
|
||||
<KPI titulo="Faturamento do mês" valor={fmtBRL(fatMes)} cor="text-violet-600" Icon={Wallet} sub="Receita de prótese" />
|
||||
</div>
|
||||
|
||||
{/* OS indicadas ao laboratório aguardando importação */}
|
||||
{indicadas.length > 0 && (
|
||||
<div className="bg-white rounded-2xl border border-amber-200 shadow-sm overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-amber-100 bg-amber-50/60 flex items-center gap-2">
|
||||
<ArrowDownToLine size={16} className="text-amber-600" />
|
||||
<h3 className="text-sm font-black text-amber-800 uppercase">OS indicadas a você ({indicadas.length})</h3>
|
||||
<span className="text-[10px] font-bold text-amber-500 uppercase ml-auto hidden sm:inline">Importe para entrar na produção e financeiro</span>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-50">
|
||||
{indicadas.map(o => (
|
||||
<div key={o.id} className="px-6 py-3 flex items-center gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-black text-gray-800 uppercase truncate text-sm">{o.tipo}</p>
|
||||
<p className="text-[11px] text-gray-400 font-bold uppercase truncate">{o.cliente_nome || 'Cliente'}{o.prazo_entrega ? ` · prazo ${(o.prazo_entrega).slice(0, 10).split('-').reverse().join('/')}` : ''}</p>
|
||||
</div>
|
||||
<span className="text-sm font-black text-gray-700 hidden sm:block">{fmtBRL(o.custo)}</span>
|
||||
<button disabled={importando === o.id} onClick={() => importar(o.id)} className="px-3 py-2 rounded-lg bg-amber-500 text-white text-[11px] font-black uppercase disabled:opacity-50 flex items-center gap-1.5 shrink-0">
|
||||
<ArrowDownToLine size={13} /> Importar
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fila por status */}
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-100 bg-gray-50/50 flex items-center gap-2">
|
||||
<Clock size={16} className="text-teal-600" />
|
||||
<h3 className="text-sm font-black text-gray-800 uppercase">Fila de produção</h3>
|
||||
</div>
|
||||
{porStatus.length === 0 ? (
|
||||
<div className="py-12 text-center text-gray-400 text-sm font-bold uppercase">Nenhuma OS em produção.</div>
|
||||
) : (
|
||||
<div className="p-4 flex flex-wrap gap-3">
|
||||
{porStatus.map(({ s, n }) => (
|
||||
<div key={s} className="px-4 py-3 rounded-xl bg-gray-50 border border-gray-100 min-w-[120px]">
|
||||
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest">{STATUS_LABEL[s]}</p>
|
||||
<p className="text-2xl font-black text-gray-800">{n}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Próximas entregas */}
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-100 bg-gray-50/50">
|
||||
<h3 className="text-sm font-black text-gray-800 uppercase">Próximas entregas</h3>
|
||||
</div>
|
||||
{ativas.filter(o => o.prazo_entrega).length === 0 ? (
|
||||
<div className="py-10 text-center text-gray-400 text-sm font-bold uppercase">Sem prazos agendados.</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="bg-gray-50/60"><tr>{['Paciente', 'Tipo', 'Prazo', 'Status', 'Valor'].map(h => <th key={h} className="px-4 py-3 text-[10px] font-black text-gray-400 uppercase tracking-widest">{h}</th>)}</tr></thead>
|
||||
<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">{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>
|
||||
<td className="px-4 py-3 font-black text-gray-800">{fmtBRL(o.custo)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{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<{ osAtivas: any[]; onClose: () => void; onSaved: () => void }> = ({ osAtivas, onClose, onSaved }) => {
|
||||
const toast = useToast();
|
||||
const [clientes, setClientes] = useState<any[]>([]);
|
||||
const [produtos, setProdutos] = useState<any[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
// origem do cliente: existente ou avulso
|
||||
const [clienteId, setClienteId] = useState(''); // '' = avulso
|
||||
const [cliNome, setCliNome] = useState('');
|
||||
const [cliTipo, setCliTipo] = useState('clinica');
|
||||
const [produtoId, setProdutoId] = useState(''); // '' = tipo livre
|
||||
const [tipo, setTipo] = useState('');
|
||||
const [custo, setCusto] = useState('');
|
||||
const [prazo, setPrazo] = 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(() => {});
|
||||
}, []);
|
||||
|
||||
const salvar = async () => {
|
||||
if (!clienteId && !cliNome.trim()) { toast.error('INFORME O CLIENTE.'); return; }
|
||||
if (!produtoId && !tipo.trim()) { toast.error('INFORME O PRODUTO OU TIPO.'); return; }
|
||||
setBusy(true);
|
||||
try {
|
||||
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);
|
||||
if (r?.error) throw new Error(r.error);
|
||||
toast.success('ENTRADA REGISTRADA.');
|
||||
onSaved();
|
||||
} catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const lbl = 'text-[10px] font-black text-gray-500 uppercase tracking-wide';
|
||||
const inp = 'w-full mt-1 px-3 py-2 rounded-lg border border-gray-200 text-sm outline-none focus:border-teal-400';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4" onClick={onClose}>
|
||||
<div className="bg-white rounded-2xl w-full max-w-md max-h-[92vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between p-5 border-b border-gray-100 sticky top-0 bg-white z-10">
|
||||
<h3 className="font-black text-gray-800 uppercase text-sm flex items-center gap-2"><Plus size={16} className="text-teal-600" /> Dar entrada em OS</h3>
|
||||
<button onClick={onClose}><X size={20} className="text-gray-400" /></button>
|
||||
</div>
|
||||
<div className="p-5 space-y-4">
|
||||
{/* Cliente */}
|
||||
<div>
|
||||
<label className={lbl}>Cliente</label>
|
||||
<select value={clienteId} onChange={e => setClienteId(e.target.value)} className={inp}>
|
||||
<option value="">➕ Novo cliente avulso…</option>
|
||||
{clientes.map(c => <option key={c.clinica_id} value={c.clinica_id}>{c.clinica_nome} ({c.origem})</option>)}
|
||||
</select>
|
||||
{!clienteId && (
|
||||
<div className="mt-2 space-y-2 bg-gray-50 rounded-xl p-3">
|
||||
<input value={cliNome} onChange={e => setCliNome(e.target.value)} placeholder="Nome do cliente (clínica / dentista)" className={inp.replace('mt-1', '')} />
|
||||
<div className="flex gap-2">
|
||||
{CLI_TIPO.map(t => { const I = t.icon; return (
|
||||
<button key={t.v} onClick={() => setCliTipo(t.v)} className={`flex-1 py-2 rounded-lg text-[10px] font-black uppercase flex items-center justify-center gap-1 border ${cliTipo === t.v ? 'border-teal-500 bg-teal-50 text-teal-700' : 'border-gray-200 text-gray-400'}`}><I size={12} /> {t.label}</button>
|
||||
); })}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Produto */}
|
||||
<div>
|
||||
<label className={lbl}>Produto</label>
|
||||
<select value={produtoId} onChange={e => setProdutoId(e.target.value)} className={inp}>
|
||||
<option value="">✏️ Tipo livre…</option>
|
||||
{produtos.map(p => <option key={p.id} value={p.id}>{p.nome} — {fmtBRL(Number(p.preco || 0))}</option>)}
|
||||
</select>
|
||||
{!produtoId && (
|
||||
<div className="mt-2 flex gap-2">
|
||||
<input value={tipo} onChange={e => setTipo(e.target.value)} placeholder="Ex.: Coroa de Zircônia" className={inp.replace('mt-1', '') + ' flex-1'} />
|
||||
<input value={custo} onChange={e => setCusto(e.target.value)} type="number" step="0.01" placeholder="Custo" className={inp.replace('mt-1', '') + ' w-24'} />
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { BarChart3, RefreshCw, Clock, AlertTriangle, RotateCcw, PackageX, Award, CheckCircle2, BadgeCheck, Star } from 'lucide-react';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { PageHeader } from '../components/PageHeader.tsx';
|
||||
|
||||
// Área do Laboratório (Fase 5): indicadores derivados da captura + Nota ScoreOdonto.
|
||||
export const LabIndicadoresView: React.FC = () => {
|
||||
const [ind, setInd] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const carregar = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try { setInd(await HybridBackend.getLabIndicadores()); } catch { /* silent */ } finally { setLoading(false); }
|
||||
}, []);
|
||||
useEffect(() => { carregar(); }, [carregar]);
|
||||
|
||||
const KPI: React.FC<{ titulo: string; valor: string; cor: string; Icon: any; sub?: string }> = ({ titulo, valor, cor, Icon, sub }) => (
|
||||
<div className="bg-white p-5 rounded-2xl border border-gray-100 shadow-sm relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 p-3 opacity-10"><Icon size={56} className={cor} /></div>
|
||||
<h4 className="text-[10px] font-black text-gray-400 uppercase tracking-widest">{titulo}</h4>
|
||||
<div className="mt-2 text-xl font-black text-gray-900">{valor}</div>
|
||||
{sub && <p className="mt-2 text-[10px] font-bold text-gray-400 uppercase">{sub}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loading || !ind) return <div className="flex justify-center py-20"><RefreshCw className="animate-spin text-teal-600" size={28} /></div>;
|
||||
|
||||
const temNota = ind.nota !== null && ind.nota !== undefined;
|
||||
const notaCor = ind.nota >= 9 ? 'text-green-600' : ind.nota >= 7 ? 'text-teal-600' : ind.nota >= 5 ? 'text-amber-500' : 'text-red-500';
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-10">
|
||||
<PageHeader title="INDICADORES DO LABORATÓRIO">
|
||||
<button onClick={carregar} className="bg-white border border-gray-200 text-gray-600 p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2 text-xs font-bold shadow-sm"><RefreshCw size={16} /></button>
|
||||
</PageHeader>
|
||||
|
||||
{/* Nota ScoreOdonto */}
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6 flex items-center gap-5">
|
||||
<div className={`w-20 h-20 rounded-2xl border-4 flex flex-col items-center justify-center shrink-0 ${temNota ? 'border-current ' + notaCor : 'border-gray-200 text-gray-300'}`}>
|
||||
<Award size={18} />
|
||||
<span className="text-2xl font-black leading-none mt-0.5">{temNota ? ind.nota.toFixed(1) : '—'}</span>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="text-sm font-black text-gray-800 uppercase">Nota ScoreOdonto</h3>
|
||||
{ind.verificado && <span className="text-[9px] font-black px-2 py-0.5 rounded-full bg-teal-100 text-teal-700 uppercase flex items-center gap-0.5"><BadgeCheck size={11} /> Verificado</span>}
|
||||
</div>
|
||||
{temNota ? (
|
||||
<p className="text-[11px] text-gray-400 font-bold uppercase mt-1">Sobre {ind.entregues} entrega(s){ind.avaliacoes ? ` · ${ind.satisfacao}★ de ${ind.avaliacoes} avaliações` : ''}. Combina desempenho (atraso/retrabalho/ocorrências) e satisfação da clínica.</p>
|
||||
) : (
|
||||
<p className="text-[11px] text-amber-500 font-bold uppercase mt-1">Score em formação — mínimo de {ind.min_os} entregas para gerar a nota.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<KPI titulo="Entregues" valor={String(ind.entregues)} cor="text-green-600" Icon={CheckCircle2} sub={`${ind.total} OS no total`} />
|
||||
<KPI titulo="Tempo médio" valor={`${ind.dias_medio} d`} cor="text-blue-600" Icon={Clock} sub="Solicitado → entregue" />
|
||||
<KPI titulo="Atrasos" valor={`${ind.atraso_pct}%`} cor="text-amber-500" Icon={AlertTriangle} sub={`${ind.atrasados} entrega(s)`} />
|
||||
<KPI titulo="Retrabalho" valor={`${ind.retrabalho_pct}%`} cor="text-orange-500" Icon={RotateCcw} sub={`${ind.garantia} garantia(s)`} />
|
||||
<KPI titulo="Ocorrências" valor={`${ind.ocorrencia_pct}%`} cor="text-red-500" Icon={AlertTriangle} sub={`${ind.ocorrencias_resolvidas} resolvida(s)`} />
|
||||
<KPI titulo="Componentes perdidos" valor={String(ind.componentes_ausentes)} cor="text-red-600" Icon={PackageX} sub="Conferidos como ausentes" />
|
||||
<KPI titulo="Satisfação" valor={ind.satisfacao != null ? `${ind.satisfacao}★` : '—'} cor="text-amber-500" Icon={Star} sub={`${ind.avaliacoes || 0} avaliação(ões)`} />
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-5">
|
||||
<h3 className="text-sm font-black text-gray-800 uppercase flex items-center gap-2 mb-2"><BarChart3 size={16} className="text-teal-600" /> Como a nota é calculada</h3>
|
||||
<p className="text-xs text-gray-500">Base 10, descontando proporcionalmente: <b>atraso</b> (peso 3), <b>retrabalho/garantia</b> (peso 4) e <b>ocorrências resolvidas</b> (peso 3). Só ocorrências <b>resolvidas</b> (após contraditório) entram — abertas/contestadas não pesam. A nota aparece para as clínicas no marketplace ao escolherem o laboratório.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+189
-52
@@ -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';
|
||||
@@ -32,6 +34,9 @@ interface GTOItem {
|
||||
valorUnitario: number;
|
||||
valorTotal: number;
|
||||
observacao?: string;
|
||||
// Prótese (Fase 1): item ligado ao catálogo do protético → gera OS ao finalizar.
|
||||
produtoId?: string;
|
||||
proteticoId?: string;
|
||||
}
|
||||
|
||||
interface GTOStore {
|
||||
@@ -119,6 +124,13 @@ export const LancarGTO: React.FC = () => {
|
||||
const [patientSearchOpen, setPatientSearchOpen] = useState(false);
|
||||
const [patientSearchTerm, setPatientSearchTerm] = useState('');
|
||||
|
||||
// --- PRÓTESE (Fase 1): catálogo do protético entra como "procedimentos" do GTO ---
|
||||
const [selectedLab, setSelectedLab] = useState<string>('');
|
||||
const [labProdutos, setLabProdutos] = useState<any[]>([]);
|
||||
const [selectedProdutoId, setSelectedProdutoId] = useState<string>('');
|
||||
const [protValor, setProtValor] = useState<string>('');
|
||||
const [protObs, setProtObs] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
const p = await HybridBackend.getPacientes();
|
||||
@@ -140,14 +152,49 @@ export const LancarGTO: React.FC = () => {
|
||||
return procedimentos.filter(p => p.especialidadeId === selectedEsp);
|
||||
}, [selectedEsp, procedimentos]);
|
||||
|
||||
const filteredPatients = useMemo(() => {
|
||||
if (!patientSearchTerm) return pacientes.slice(0, 30);
|
||||
const term = normalize(patientSearchTerm);
|
||||
return pacientes.filter(p =>
|
||||
normalize(p.nome).includes(term) ||
|
||||
(p.cpf && p.cpf.includes(patientSearchTerm.replace(/\D/g, '')))
|
||||
).slice(0, 30);
|
||||
}, [pacientes, patientSearchTerm]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
// Especialidade de prótese → o "procedimento" vem do catálogo do protético (não da
|
||||
// tabela procedimentos). Detecta pela área da especialidade (re-tagueada no backend).
|
||||
const isProtese = useMemo(
|
||||
() => (especialidades.find(e => e.id === selectedEsp)?.area || '') === 'protese',
|
||||
[especialidades, selectedEsp]
|
||||
);
|
||||
const selectedProduto = useMemo(
|
||||
() => labProdutos.find(p => p.id === selectedProdutoId) || null,
|
||||
[labProdutos, selectedProdutoId]
|
||||
);
|
||||
|
||||
// Catálogo do laboratório selecionado.
|
||||
useEffect(() => {
|
||||
if (!selectedLab) { setLabProdutos([]); return; }
|
||||
let active = true;
|
||||
HybridBackend.getLaboratorioProdutos(selectedLab)
|
||||
.then(p => { if (active) setLabProdutos(Array.isArray(p) ? p : []); })
|
||||
.catch(() => { if (active) setLabProdutos([]); });
|
||||
return () => { active = false; };
|
||||
}, [selectedLab]);
|
||||
|
||||
// Busca de pacientes é SERVER-SIDE: a clínica pode ter milhares e a rota sem termo
|
||||
// devolve só as 50 primeiras. Filtrar essas 50 no cliente fazia a busca "não achar"
|
||||
// quem estava fora da 1ª página. Sem termo, mostra a lista inicial (recentes).
|
||||
const [patientResults, setPatientResults] = useState<Paciente[]>([]);
|
||||
const [searchingPatients, setSearchingPatients] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const term = patientSearchTerm.trim();
|
||||
if (!term) { setPatientResults(pacientes.slice(0, 30)); setSearchingPatients(false); return; }
|
||||
let active = true;
|
||||
setSearchingPatients(true);
|
||||
const t = setTimeout(async () => {
|
||||
try {
|
||||
const r: any = await HybridBackend.getPacientes(term);
|
||||
const list: Paciente[] = Array.isArray(r) ? r : (r?.data ?? []);
|
||||
if (active) setPatientResults(list);
|
||||
} finally {
|
||||
if (active) setSearchingPatients(false);
|
||||
}
|
||||
}, 300);
|
||||
return () => { active = false; clearTimeout(t); };
|
||||
}, [patientSearchTerm, pacientes]);
|
||||
|
||||
useEffect(() => {
|
||||
const { pendingSearch } = store;
|
||||
@@ -250,6 +297,31 @@ export const LancarGTO: React.FC = () => {
|
||||
setArcoObs('');
|
||||
};
|
||||
|
||||
// Adiciona um item de prótese (catálogo do protético) ao rascunho do GTO.
|
||||
const handleAddProtese = () => {
|
||||
if (!selectedLab) { toast.error("SELECIONE O LABORATÓRIO."); return; }
|
||||
if (!selectedProduto) { toast.error("SELECIONE O PRODUTO DE PRÓTESE."); return; }
|
||||
if (store.items.length >= 20) { toast.error("LIMITE DE 20 ITENS ATINGIDO."); return; }
|
||||
const valor = parseFloat(protValor) || Number(selectedProduto.preco) || 0;
|
||||
store.addItem({
|
||||
id: Math.random().toString(36).substring(7),
|
||||
procedimentoId: '',
|
||||
codigoTUSS: '',
|
||||
codigoInterno: '',
|
||||
descricao: selectedProduto.nome,
|
||||
quantidade: 1,
|
||||
valorUnitario: valor,
|
||||
valorTotal: valor,
|
||||
observacao: protObs || undefined,
|
||||
produtoId: selectedProduto.id,
|
||||
proteticoId: selectedLab,
|
||||
});
|
||||
toast.success("PRÓTESE ADICIONADA AO RASCUNHO.");
|
||||
setSelectedProdutoId('');
|
||||
setProtValor('');
|
||||
setProtObs('');
|
||||
};
|
||||
|
||||
const handleFinalize = async () => {
|
||||
if (!store.paciente || !store.dentista || store.items.length === 0) {
|
||||
toast.error("PACIENTE, DENTISTA E PELO MENOS 1 ITEM SÃO OBRIGATÓRIOS.");
|
||||
@@ -276,9 +348,12 @@ export const LancarGTO: React.FC = () => {
|
||||
credenciadaId: store.credenciada,
|
||||
});
|
||||
for (const it of store.items) {
|
||||
const { arco, id: _localId, ...rest } = it as any;
|
||||
const { arco, id: _localId, produtoId, proteticoId, ...rest } = it as any;
|
||||
void arco; void _localId;
|
||||
await HybridBackend.addGtoItem(id, rest);
|
||||
const payload: any = { ...rest };
|
||||
// Prótese: envia produto/protético (snake_case) → o backend abre a OS na finalização.
|
||||
if (produtoId) { payload.produto_id = produtoId; payload.protetico_id = proteticoId; }
|
||||
await HybridBackend.addGtoItem(id, payload);
|
||||
}
|
||||
const fin = await HybridBackend.finalizarGto(id);
|
||||
toast.success(fin?.financeiro_id
|
||||
@@ -383,37 +458,104 @@ 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);
|
||||
setSelectedProdutoId('');
|
||||
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 && (
|
||||
{selectedEsp && isProtese && (
|
||||
<div className="space-y-3">
|
||||
<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">
|
||||
<p className="text-[11px] font-black text-amber-700 uppercase tracking-wide">Este laboratório não tem produtos ativos</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<SearchSelect
|
||||
value={selectedProdutoId}
|
||||
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"
|
||||
/>
|
||||
|
||||
{selectedProduto && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-[10px] font-black text-gray-400 uppercase tracking-widest mb-1.5">Valor (R$)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={protValor}
|
||||
onChange={e => setProtValor(e.target.value)}
|
||||
className="w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl font-black text-gray-800 text-sm focus:border-teal-400 outline-none transition-all"
|
||||
placeholder="0,00"
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
value={protObs}
|
||||
onChange={e => setProtObs(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="OBSERVAÇÃO PARA O PROTÉTICO (cor, material, dentes...)"
|
||||
className="w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl text-[11px] focus:border-amber-400 outline-none transition-all resize-none"
|
||||
/>
|
||||
<div className="bg-teal-50 p-3 rounded-xl border border-teal-100">
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-bold text-teal-800">
|
||||
<ChevronRight size={10} /> AO FINALIZAR A GTO, UMA OS DE PRÓTESE SERÁ ABERTA NA BANCADA DO LABORATÓRIO.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAddProtese}
|
||||
className="w-full bg-[#2d6a4f] text-white py-4 rounded-2xl font-black text-sm uppercase shadow-lg shadow-green-100 hover:bg-[#1b4332] active:scale-95 transition-all flex items-center justify-center gap-2 group"
|
||||
>
|
||||
ADICIONAR PRÓTESE <ChevronRight size={16} className="group-hover:translate-x-1 transition-transform" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedEsp && !isProtese && (
|
||||
<div className="space-y-2">
|
||||
<select
|
||||
value={selectedProc?.id || ''}
|
||||
onChange={(e) => {
|
||||
const pr = procedimentos.find(p => p.id === e.target.value);
|
||||
setSelectedProc(pr || null);
|
||||
setSelectedDentes([]);
|
||||
setSelectedArco(null);
|
||||
setArcoFace('');
|
||||
setArcoObs('');
|
||||
}}
|
||||
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>
|
||||
{filteredProcedimentos.length === 0 ? (
|
||||
<div className="bg-amber-50 border-2 border-amber-100 rounded-2xl p-4 text-center">
|
||||
<p className="text-[11px] font-black text-amber-700 uppercase tracking-wide">Nenhum procedimento cadastrado nesta especialidade</p>
|
||||
<p className="text-[10px] font-bold text-amber-500 uppercase mt-1">Cadastre em Procedimentos para usá-lo aqui</p>
|
||||
</div>
|
||||
) : (
|
||||
<SearchSelect
|
||||
value={selectedProc?.id || ''}
|
||||
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"
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedProc && (
|
||||
<div className="bg-teal-50 p-3 rounded-xl border border-teal-100">
|
||||
@@ -443,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>
|
||||
);
|
||||
@@ -854,12 +991,12 @@ export const LancarGTO: React.FC = () => {
|
||||
className="w-full p-4 bg-gray-50 border-2 border-gray-100 rounded-2xl font-black text-gray-800 text-sm focus:border-teal-500 outline-none transition-all uppercase"
|
||||
/>
|
||||
<div className="max-h-72 overflow-y-auto space-y-1">
|
||||
{filteredPatients.length === 0 ? (
|
||||
{patientResults.length === 0 ? (
|
||||
<div className="py-10 text-center text-gray-300 font-bold uppercase text-xs">
|
||||
{patientSearchTerm ? 'NENHUM PACIENTE ENCONTRADO' : 'CARREGANDO...'}
|
||||
{searchingPatients ? 'BUSCANDO...' : (patientSearchTerm ? 'NENHUM PACIENTE ENCONTRADO' : 'CARREGANDO...')}
|
||||
</div>
|
||||
) : (
|
||||
filteredPatients.map(p => (
|
||||
patientResults.map(p => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Building2, Briefcase, Stethoscope, Sparkles, DoorOpen,
|
||||
Building2, Briefcase, Stethoscope, Sparkles, DoorOpen, FlaskConical,
|
||||
ArrowRight, ArrowLeft, ChevronLeft, Loader2, Send, Check, CheckCircle2, User as UserIcon
|
||||
} from 'lucide-react';
|
||||
|
||||
@@ -17,6 +17,7 @@ const SLIDES: { icon: any; accent: string; title: string; desc: string; cta: str
|
||||
{ icon: Briefcase, accent: '#0d9488', title: 'Consultórios', desc: 'Para quem voa solo: agenda inteligente e cobrança sem complicação.', cta: 'Cadastrar meu consultório', pre: { perfil: 'consultorio' } },
|
||||
{ icon: Stethoscope, accent: '#7c3aed', title: 'Dentistas', desc: 'Sua agenda única — sem overbooking, mesmo atendendo em vários lugares.', cta: 'Sou dentista', pre: { perfil: 'profissional', profissao: 'dentista' } },
|
||||
{ icon: Sparkles, accent: '#db2777', title: 'Biomédicos', desc: 'Harmonização orofacial e estética com prontuário e fotos por procedimento.', cta: 'Sou biomédico(a)', pre: { perfil: 'profissional', profissao: 'biomedico' } },
|
||||
{ icon: FlaskConical, accent: '#2d6a4f', title: 'Protéticos', desc: 'Laboratório completo: bancada, ordens, etapas, componentes, financeiro e score.', cta: 'Sou protético(a)', pre: { perfil: 'profissional', profissao: 'protetico' } },
|
||||
{ icon: DoorOpen, accent: '#0d9488', title: 'Salas', desc: 'Alugue ou anuncie salas por hora, dia ou mês. Valor fixo ou negociado.', cta: 'Tenho salas para alugar', pre: { perfil: 'sala' } },
|
||||
];
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { FlaskConical, Building2, Clock, ShieldCheck, AlertTriangle, CheckCircle2, Circle, Package, History, Camera, MapPin, ChevronRight, Loader2, ImagePlus, Zap, Briefcase, Wallet, ChevronLeft } from 'lucide-react';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { useToast } from '../contexts/ToastContext.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('/');
|
||||
const dataHoraBR = (d: string) => { if (!d) return ''; const x = new Date(d); return isNaN(x.getTime()) ? dataBR(d) : x.toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' }); };
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = { solicitado: 'Solicitado', recebido: 'Recebido', producao: 'Em produção', prova: 'Prova', ajuste: 'Ajuste', finalizado: 'Finalizado', entregue: 'Entregue', cancelado: 'Cancelado' };
|
||||
const STATUS_COR: Record<string, string> = { solicitado: 'bg-gray-100 text-gray-600', recebido: 'bg-blue-100 text-blue-700', producao: 'bg-amber-100 text-amber-700', prova: 'bg-indigo-100 text-indigo-700', ajuste: 'bg-orange-100 text-orange-700', finalizado: 'bg-teal-100 text-teal-700', entregue: 'bg-green-100 text-green-700', cancelado: 'bg-red-100 text-red-600' };
|
||||
const FLUXO = ['solicitado', 'recebido', 'producao', 'prova', 'ajuste', 'finalizado', 'entregue'];
|
||||
const OCOR_CATS: Record<string, string> = { nao_devolvido: 'Componente não devolvido', danificado: 'Componente danificado', incompativel: 'Componente incompatível', parafuso_substituido: 'Parafuso substituído', parafuso_desgastado: 'Parafuso desgastado', peca_incorreta: 'Peça incorreta', ajuste_inadequado: 'Ajuste inadequado', falha_fabricacao: 'Falha de fabricação', retrabalho: 'Retrabalho', outro: 'Outro' };
|
||||
|
||||
const Secao: React.FC<{ titulo: string; Icon: any; children: React.ReactNode }> = ({ titulo, Icon, children }) => (
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4">
|
||||
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest mb-3 flex items-center gap-1.5"><Icon size={12} /> {titulo}</p>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const PROD_FLUXO = ['recebido', 'producao', 'prova', 'ajuste', 'finalizado'];
|
||||
|
||||
const mesBR = (m: string) => { const [y, mo] = (m || '').split('-'); return mo ? new Date(Number(y), Number(mo) - 1, 1).toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' }) : m; };
|
||||
|
||||
// Carteira do protético (via link): trabalhos + mini financeiro mensal (relação com a clínica).
|
||||
const CarteiraView: React.FC<{ carteira: any }> = ({ carteira }) => {
|
||||
if (!carteira) return <div className="flex justify-center py-16"><Loader2 className="animate-spin text-teal-600" size={28} /></div>;
|
||||
const Linha: React.FC<{ t: any; fin?: boolean }> = ({ t, fin }) => (
|
||||
<div className="flex items-center gap-2 bg-white border border-gray-100 rounded-xl px-3 py-2 text-xs">
|
||||
<span className={`text-[8px] font-black px-1.5 py-0.5 rounded uppercase ${STATUS_COR[t.status] || 'bg-gray-100 text-gray-600'}`}>{STATUS_LABEL[t.status] || t.status}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-bold text-gray-700 truncate">{t.tipo}</p>
|
||||
<p className="text-[10px] text-gray-400 truncate">{t.paciente_nome || '—'}{!fin && t.prazo_entrega ? ` · prazo ${dataBR(t.prazo_entrega)}` : ''}</p>
|
||||
</div>
|
||||
{Number(t.custo) > 0 && <span className="font-black text-gray-700 shrink-0">{fmtBRL(t.custo)}</span>}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<main className="max-w-lg mx-auto p-3 space-y-3">
|
||||
{carteira.clinica_nome && <div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-3 text-[11px] font-black text-blue-700 uppercase flex items-center gap-1.5"><Building2 size={12} /> {carteira.clinica_nome}</div>}
|
||||
|
||||
{/* Mini financeiro */}
|
||||
<div className="bg-[#2d6a4f] text-white rounded-2xl p-4">
|
||||
<p className="text-[10px] font-black uppercase tracking-widest opacity-80 flex items-center gap-1.5"><Wallet size={12} /> Recebido desta clínica</p>
|
||||
<p className="text-2xl font-black mt-1">{fmtBRL(carteira.financeiro?.total_pago || 0)}</p>
|
||||
<div className="mt-3 space-y-1">
|
||||
{(carteira.financeiro?.por_mes || []).map((m: any) => (
|
||||
<div key={m.mes} className="flex items-center justify-between text-xs bg-white/10 rounded-lg px-3 py-1.5">
|
||||
<span className="font-bold capitalize">{mesBR(m.mes)}</span>
|
||||
<span className="font-black">{fmtBRL(m.total)}</span>
|
||||
</div>
|
||||
))}
|
||||
{(carteira.financeiro?.por_mes || []).length === 0 && <p className="text-[11px] opacity-70 uppercase font-bold">Nenhum pagamento registrado ainda.</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Secao titulo={`Em andamento (${carteira.andamento?.length || 0})`} Icon={Zap}>
|
||||
<div className="space-y-1.5">
|
||||
{(carteira.andamento || []).map((t: any) => <Linha key={t.id} t={t} />)}
|
||||
{(carteira.andamento || []).length === 0 && <p className="text-[11px] text-gray-300 font-bold uppercase">Nada em produção.</p>}
|
||||
</div>
|
||||
</Secao>
|
||||
<Secao titulo={`Finalizados (${carteira.finalizados?.length || 0})`} Icon={CheckCircle2}>
|
||||
<div className="space-y-1.5">
|
||||
{(carteira.finalizados || []).map((t: any) => <Linha key={t.id} t={t} fin />)}
|
||||
{(carteira.finalizados || []).length === 0 && <p className="text-[11px] text-gray-300 font-bold uppercase">Nenhum entregue ainda.</p>}
|
||||
</div>
|
||||
</Secao>
|
||||
<p className="text-center text-[10px] text-gray-300 uppercase font-bold pb-4">Valores recebidos do laboratório · sem dados do paciente</p>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export const ProtesePublicView: React.FC<{ token: string }> = ({ token }) => {
|
||||
const toast = useToast();
|
||||
const [os, setOs] = useState<any>(null);
|
||||
const [erro, setErro] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const [tela, setTela] = useState<'os' | 'carteira'>('os');
|
||||
const [carteira, setCarteira] = useState<any>(null);
|
||||
|
||||
const recarregar = useCallback(() => HybridBackend.getProtesePublic(token).then(setOs).catch(e => setErro(e.message || 'Link inválido.')), [token]);
|
||||
useEffect(() => { recarregar().finally(() => setLoading(false)); }, [recarregar]);
|
||||
const abrirCarteira = () => { setTela('carteira'); if (!carteira) HybridBackend.getCarteiraPublic(token).then(setCarteira).catch(() => setCarteira({ andamento: [], finalizados: [], financeiro: { por_mes: [], total_pago: 0 } })); };
|
||||
|
||||
const act = async (fn: () => Promise<any>, ok: string) => {
|
||||
setBusy(true);
|
||||
try { await fn(); toast.success(ok); await recarregar(); }
|
||||
catch (e: any) { toast.error((e.message || 'ERRO').toUpperCase()); } finally { setBusy(false); }
|
||||
};
|
||||
const receber = () => act(() => HybridBackend.custodiaPublic(token, 'laboratorio'), 'RECEBIMENTO CONFIRMADO.');
|
||||
const devolver = () => act(() => HybridBackend.custodiaPublic(token, 'clinica'), 'DEVOLUÇÃO REGISTRADA.');
|
||||
const avancar = (s: string) => act(() => HybridBackend.statusPublic(token, s), 'ETAPA ATUALIZADA.');
|
||||
const anexar = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const f = e.target.files?.[0]; if (!f) return;
|
||||
act(() => HybridBackend.uploadFotoPublic(token, f), 'FOTO ANEXADA.'); e.target.value = '';
|
||||
};
|
||||
|
||||
if (loading) return <div className="min-h-screen flex items-center justify-center bg-gray-50"><Loader2 className="animate-spin text-teal-600" size={32} /></div>;
|
||||
if (erro || !os) return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center bg-gray-50 p-6 text-center">
|
||||
<AlertTriangle size={40} className="text-amber-400" />
|
||||
<p className="mt-3 font-black text-gray-700 uppercase text-sm">{erro || 'Link inválido'}</p>
|
||||
<p className="text-xs text-gray-400 mt-1">Peça um novo link à clínica.</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const noLab = (os.local_atual || 'clinica') === 'laboratorio';
|
||||
const idxAtual = FLUXO.indexOf(os.status);
|
||||
const cancelado = os.status === 'cancelado';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Topo / marca */}
|
||||
<header className="bg-[#2d6a4f] text-white px-4 py-3 flex items-center gap-2 sticky top-0 z-10">
|
||||
<FlaskConical size={18} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-black text-sm leading-tight">ScoreOdonto</p>
|
||||
<p className="text-[10px] text-white/70 uppercase tracking-wide leading-tight">Acompanhamento de prótese</p>
|
||||
</div>
|
||||
{tela === 'os'
|
||||
? <button onClick={abrirCarteira} className="text-[9px] font-black px-2 py-1 rounded-full bg-white/15 uppercase flex items-center gap-1"><Briefcase size={11} /> Meus trabalhos</button>
|
||||
: <button onClick={() => setTela('os')} className="text-[9px] font-black px-2 py-1 rounded-full bg-white/15 uppercase flex items-center gap-1"><ChevronLeft size={11} /> Voltar à OS</button>}
|
||||
</header>
|
||||
|
||||
{tela === 'carteira' && <CarteiraView carteira={carteira} />}
|
||||
|
||||
{tela === 'os' && <main className="max-w-lg mx-auto p-3 space-y-3">
|
||||
{/* Cabeçalho da OS */}
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-black text-gray-800 text-base leading-tight">{os.tipo}</h1>
|
||||
<p className="text-xs text-gray-400 mt-0.5">OS {os.id}</p>
|
||||
</div>
|
||||
<span className={`text-[9px] font-black px-2 py-1 rounded-full uppercase shrink-0 ${STATUS_COR[os.status] || 'bg-gray-100 text-gray-600'}`}>{STATUS_LABEL[os.status] || os.status}</span>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
{os.clinica_nome && <span className="text-[10px] font-black text-blue-700 bg-blue-50 px-2 py-1 rounded-lg uppercase flex items-center gap-1"><Building2 size={11} /> {os.clinica_nome}</span>}
|
||||
<span className={`text-[10px] font-black px-2 py-1 rounded-lg uppercase flex items-center gap-1 ${noLab ? 'bg-violet-50 text-violet-700' : 'bg-blue-50 text-blue-700'}`}><MapPin size={11} /> {noLab ? 'Com o laboratório' : 'Na clínica'}</span>
|
||||
{os.tipo_os === 'garantia' && <span className="text-[10px] font-black text-emerald-700 bg-emerald-50 px-2 py-1 rounded-lg uppercase flex items-center gap-1"><ShieldCheck size={11} /> Garantia</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ações do protético (sem conta) */}
|
||||
{!os.finalizada && (
|
||||
<Secao titulo="Atualizar status" Icon={Zap}>
|
||||
<div className="space-y-2">
|
||||
{noLab
|
||||
? <button disabled={busy} onClick={devolver} className="w-full py-2.5 rounded-xl bg-blue-600 text-white font-black text-xs uppercase disabled:opacity-50">Devolvi à clínica</button>
|
||||
: <button disabled={busy} onClick={receber} className="w-full py-2.5 rounded-xl bg-[#2d6a4f] text-white font-black text-xs uppercase disabled:opacity-50">Recebi o trabalho</button>}
|
||||
<div>
|
||||
<p className="text-[9px] font-black text-gray-400 uppercase mb-1">Etapa de produção</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{PROD_FLUXO.map(s => (
|
||||
<button key={s} disabled={busy || os.status === s} onClick={() => avancar(s)}
|
||||
className={`px-2.5 py-1.5 rounded-lg text-[10px] font-black uppercase disabled:opacity-40 ${os.status === s ? 'bg-[#2d6a4f] text-white' : 'border border-gray-200 text-gray-500'}`}>{STATUS_LABEL[s]}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<label className={`w-full flex items-center justify-center gap-2 py-2.5 rounded-xl border border-dashed border-gray-200 text-gray-500 text-xs font-bold uppercase cursor-pointer ${busy ? 'opacity-50 pointer-events-none' : ''}`}>
|
||||
<ImagePlus size={15} /> Anexar foto do trabalho
|
||||
<input type="file" accept="image/*" className="hidden" onChange={anexar} />
|
||||
</label>
|
||||
</div>
|
||||
</Secao>
|
||||
)}
|
||||
|
||||
{/* Etapas */}
|
||||
<Secao titulo="Etapas" Icon={Clock}>
|
||||
{cancelado ? (
|
||||
<div className="bg-red-50 rounded-xl px-3 py-2 text-xs font-black text-red-600 uppercase">OS cancelada</div>
|
||||
) : (
|
||||
<div className="flex items-start overflow-x-auto pb-1 -mx-1 px-1">
|
||||
{FLUXO.map((s, i) => {
|
||||
const done = i < idxAtual, atual = i === idxAtual;
|
||||
const ev = (os.eventos || []).find((e: any) => e.status === s);
|
||||
return (
|
||||
<React.Fragment key={s}>
|
||||
<div className="flex flex-col items-center min-w-[52px]">
|
||||
{done ? <CheckCircle2 size={18} className="text-teal-600" /> : atual ? <div className="w-4 h-4 rounded-full bg-[#2d6a4f] ring-4 ring-green-100" /> : <Circle size={18} className="text-gray-200" />}
|
||||
<span className={`text-[7px] font-black uppercase mt-1 text-center leading-tight ${atual ? 'text-[#2d6a4f]' : done ? 'text-teal-700' : 'text-gray-300'}`}>{STATUS_LABEL[s]}</span>
|
||||
<span className="text-[7px] text-gray-300">{ev ? dataBR(ev.created_at) : ''}</span>
|
||||
</div>
|
||||
{i < FLUXO.length - 1 && <div className={`flex-1 h-0.5 mt-2 min-w-[8px] ${i < idxAtual ? 'bg-teal-400' : 'bg-gray-100'}`} />}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Secao>
|
||||
|
||||
{/* Dados */}
|
||||
<Secao titulo="Dados do trabalho" Icon={FlaskConical}>
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-2 text-xs">
|
||||
{os.paciente_nome && <div className="col-span-2"><span className="text-gray-400 font-bold uppercase text-[9px] block">Paciente</span>{os.paciente_nome}</div>}
|
||||
{os.dentista_nome && <div><span className="text-gray-400 font-bold uppercase text-[9px] block">Dentista</span>{os.dentista_nome}</div>}
|
||||
{os.dentes && <div><span className="text-gray-400 font-bold uppercase text-[9px] block">Dentes</span>{os.dentes}</div>}
|
||||
{os.material && <div><span className="text-gray-400 font-bold uppercase text-[9px] block">Material</span>{os.material}</div>}
|
||||
{os.cor && <div><span className="text-gray-400 font-bold uppercase text-[9px] block">Cor</span>{os.cor}</div>}
|
||||
{os.prazo_entrega && <div><span className="text-gray-400 font-bold uppercase text-[9px] block">Prazo</span>{dataBR(os.prazo_entrega)}</div>}
|
||||
{Number(os.custo) > 0 && <div><span className="text-gray-400 font-bold uppercase text-[9px] block">Valor do lab</span>{fmtBRL(os.custo)}</div>}
|
||||
</div>
|
||||
{os.observacoes && <p className="text-xs text-gray-600 bg-gray-50 rounded-xl p-3 mt-3">{os.observacoes}</p>}
|
||||
{os.garantia_ate && <div className="bg-emerald-50 rounded-xl px-3 py-2 mt-3 flex items-center gap-2 text-xs text-emerald-700 font-bold"><ShieldCheck size={14} /> Garantia até {dataBR(os.garantia_ate)}</div>}
|
||||
</Secao>
|
||||
|
||||
{/* Componentes */}
|
||||
{(os.componentes || []).length > 0 && (
|
||||
<Secao titulo="Componentes" Icon={Package}>
|
||||
<div className="space-y-1.5">
|
||||
{os.componentes.map((c: any) => (
|
||||
<div key={c.id} className="flex items-center gap-2 text-xs bg-gray-50 rounded-lg px-3 py-2">
|
||||
<span className={`text-[8px] font-black px-1.5 py-0.5 rounded uppercase ${c.direcao === 'devolvido' ? 'bg-blue-100 text-blue-700' : 'bg-teal-100 text-teal-700'}`}>{c.direcao === 'devolvido' ? 'Devolvido' : 'Enviado'}</span>
|
||||
<span className="font-black text-gray-700">{c.quantidade}×</span>
|
||||
<span className="font-bold text-gray-700 flex-1 truncate">{c.nome}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Secao>
|
||||
)}
|
||||
|
||||
{/* Fotos */}
|
||||
{(os.fotos || []).length > 0 && (
|
||||
<Secao titulo="Fotos do trabalho" Icon={Camera}>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{os.fotos.map((f: any) => (
|
||||
<a key={f.id} href={f.url} target="_blank" rel="noreferrer" className="block rounded-lg overflow-hidden border border-gray-100">
|
||||
<img src={f.url} alt="" className="w-full h-24 object-cover" />
|
||||
{f.legenda && <p className="text-[10px] font-bold text-gray-600 truncate px-2 py-1">{f.legenda}</p>}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</Secao>
|
||||
)}
|
||||
|
||||
{/* Ocorrências */}
|
||||
{(os.ocorrencias || []).length > 0 && (
|
||||
<Secao titulo="Ocorrências" Icon={AlertTriangle}>
|
||||
<div className="space-y-2">
|
||||
{os.ocorrencias.map((o: any) => (
|
||||
<div key={o.id} className="border border-gray-100 rounded-xl p-2.5 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[8px] font-black px-1.5 py-0.5 rounded uppercase bg-gray-100 text-gray-600">{(o.status || '').replace('_', ' ')}</span>
|
||||
<span className="font-black text-gray-700 flex-1">{OCOR_CATS[o.categoria] || o.categoria}</span>
|
||||
</div>
|
||||
<p className="text-gray-600 mt-1">{o.descricao}</p>
|
||||
{o.resposta && <p className="text-gray-500 mt-1"><b className="uppercase text-[9px]">Resposta:</b> {o.resposta}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Secao>
|
||||
)}
|
||||
|
||||
{/* Histórico */}
|
||||
<Secao titulo="Histórico" Icon={History}>
|
||||
<div className="space-y-2">
|
||||
{(os.eventos || []).map((ev: any, i: number) => (
|
||||
<div key={i} className="flex items-start gap-2 text-xs">
|
||||
<span className={`w-2 h-2 rounded-full mt-1 shrink-0 ${ev.status === 'cancelado' ? 'bg-red-400' : ev.status === 'entregue' ? 'bg-green-500' : 'bg-[#2d6a4f]'}`} />
|
||||
<div className="min-w-0">
|
||||
<span className="font-bold text-gray-700">{ev.observacao || STATUS_LABEL[ev.status] || ev.status}</span>
|
||||
<span className="text-gray-400 block text-[10px]">{dataHoraBR(ev.created_at)}{ev.actor_nome ? ` · ${ev.actor_nome}` : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Secao>
|
||||
|
||||
{/* CTA de conversão */}
|
||||
<a href="/" className="block bg-[#2d6a4f] text-white rounded-2xl p-4 text-center active:scale-[0.99] transition-transform">
|
||||
<p className="font-black text-sm uppercase">Gostou de acompanhar por aqui?</p>
|
||||
<p className="text-[11px] text-white/80 mt-1">Crie sua conta grátis e gerencie seu laboratório: bancada, etapas, financeiro e score.</p>
|
||||
<span className="inline-flex items-center gap-1 mt-2 bg-white text-[#2d6a4f] font-black text-xs uppercase px-4 py-2 rounded-xl">Criar conta grátis <ChevronRight size={14} /></span>
|
||||
</a>
|
||||
<p className="text-center text-[10px] text-gray-300 uppercase font-bold pb-4">ScoreOdonto · Provedor de Prótese</p>
|
||||
</main>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+826
-104
File diff suppressed because it is too large
Load Diff
+102
-14
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { DoorOpen, Wallet, Clock, CalendarClock, CheckCircle, RefreshCw, MapPin, Inbox, BarChart3, Filter, ChevronLeft, ChevronRight, CalendarDays } from 'lucide-react';
|
||||
import { DoorOpen, Wallet, Clock, CalendarClock, CheckCircle, RefreshCw, MapPin, Inbox, BarChart3, Filter, ChevronLeft, ChevronRight, CalendarDays, Wrench, X } from 'lucide-react';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { useToast } from '../contexts/ToastContext.tsx';
|
||||
import { useConfirm } from '../contexts/ConfirmContext.tsx';
|
||||
@@ -14,7 +14,7 @@ const H_INI = 7, H_FIM = 22, ROW_H = 40; // 07h–22h, 40px/hora
|
||||
const segOf = (d: Date) => { const x = new Date(d); const dow = (x.getDay() + 6) % 7; x.setHours(0, 0, 0, 0); x.setDate(x.getDate() - dow); return x; };
|
||||
const sameDay = (a: Date, b: Date) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
|
||||
|
||||
const AgendaSala: React.FC<{ reservas: any[] }> = ({ reservas }) => {
|
||||
const AgendaSala: React.FC<{ reservas: any[]; onBloquear?: () => void }> = ({ reservas, onBloquear }) => {
|
||||
const [weekStart, setWeekStart] = useState(() => segOf(new Date()));
|
||||
const dias = Array.from({ length: 7 }, (_, i) => { const d = new Date(weekStart); d.setDate(d.getDate() + i); return d; });
|
||||
const ativas = reservas.filter(r => r.status === 'pendente' || r.status === 'confirmada');
|
||||
@@ -26,12 +26,17 @@ const AgendaSala: React.FC<{ reservas: any[] }> = ({ reservas }) => {
|
||||
if (!sameDay(ini, dia)) return null;
|
||||
const top = Math.max(0, (ini.getHours() + ini.getMinutes() / 60 - H_INI) * ROW_H);
|
||||
const height = Math.max(16, ((fim.getTime() - ini.getTime()) / 3600000) * ROW_H);
|
||||
const manut = r.tipo === 'manutencao';
|
||||
const conf = r.status === 'confirmada';
|
||||
const cls = manut ? 'bg-gray-200 border-gray-400 text-gray-700'
|
||||
: conf ? 'bg-teal-100 border-teal-300 text-teal-800'
|
||||
: 'bg-amber-100 border-amber-300 text-amber-800';
|
||||
const legenda = manut ? (r.motivo || 'Manutenção') : (r.solicitante_nome || r.clinica_nome || 'Reserva');
|
||||
return (
|
||||
<div key={r.id} className={`absolute left-0.5 right-0.5 rounded-md px-1 py-0.5 overflow-hidden border ${conf ? 'bg-teal-100 border-teal-300 text-teal-800' : 'bg-amber-100 border-amber-300 text-amber-800'}`}
|
||||
style={{ top, height }} title={`${r.solicitante_nome || r.clinica_nome || ''} ${ini.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}–${fim.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}`}>
|
||||
<div key={r.id} className={`absolute left-0.5 right-0.5 rounded-md px-1 py-0.5 overflow-hidden border ${cls}`}
|
||||
style={{ top, height }} title={`${legenda} ${ini.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}–${fim.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}`}>
|
||||
<p className="text-[9px] font-black leading-tight truncate">{ini.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}</p>
|
||||
<p className="text-[9px] font-bold leading-tight truncate uppercase">{r.solicitante_nome || r.clinica_nome || 'Reserva'}</p>
|
||||
<p className="text-[9px] font-bold leading-tight truncate uppercase">{legenda}</p>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -41,6 +46,11 @@ const AgendaSala: React.FC<{ reservas: any[] }> = ({ reservas }) => {
|
||||
<div className="px-6 py-4 border-b border-gray-100 bg-gray-50/50 flex items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-black text-gray-800 uppercase flex items-center gap-2"><CalendarDays size={16} className="text-teal-600" /> Agenda da sala</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{onBloquear && (
|
||||
<button onClick={onBloquear} className="flex items-center gap-1.5 bg-gray-700 text-white px-3 py-1.5 rounded-lg font-black text-[10px] uppercase hover:bg-gray-800 transition-colors">
|
||||
<Wrench size={12} /> Bloquear manutenção
|
||||
</button>
|
||||
)}
|
||||
<span className="text-[11px] font-black text-gray-500 uppercase">{label}</span>
|
||||
<button onClick={() => setWeekStart(d => { const x = new Date(d); x.setDate(x.getDate() - 7); return x; })} className="p-1.5 hover:bg-gray-100 rounded-lg text-gray-500"><ChevronLeft size={16} /></button>
|
||||
<button onClick={() => setWeekStart(segOf(new Date()))} className="text-[10px] font-black uppercase text-teal-600 hover:bg-teal-50 px-2 py-1.5 rounded-lg">Hoje</button>
|
||||
@@ -82,6 +92,63 @@ const AgendaSala: React.FC<{ reservas: any[] }> = ({ reservas }) => {
|
||||
<div className="px-6 py-2 flex gap-4 text-[10px] font-bold text-gray-400 uppercase border-t border-gray-50">
|
||||
<span className="flex items-center gap-1.5"><span className="w-3 h-3 rounded bg-teal-200 border border-teal-300" /> Confirmada</span>
|
||||
<span className="flex items-center gap-1.5"><span className="w-3 h-3 rounded bg-amber-200 border border-amber-300" /> Pendente</span>
|
||||
<span className="flex items-center gap-1.5"><span className="w-3 h-3 rounded bg-gray-200 border border-gray-400" /> Manutenção</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Modal: bloquear a sala para manutenção (data + faixa de horário + motivo).
|
||||
const BloqueioModal: React.FC<{ onClose: () => void; onConfirm: (inicio: string, fim: string, motivo: string) => Promise<void> }> = ({ onClose, onConfirm }) => {
|
||||
const toast = useToast();
|
||||
const hojeYmd = new Date().toISOString().split('T')[0];
|
||||
const [data, setData] = useState(hojeYmd);
|
||||
const [hIni, setHIni] = useState('08:00');
|
||||
const [hFim, setHFim] = useState('09:00');
|
||||
const [motivo, setMotivo] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const salvar = async () => {
|
||||
if (!data || !hIni || !hFim) { toast.error('PREENCHA DATA E HORÁRIOS.'); return; }
|
||||
const inicio = new Date(`${data}T${hIni}`);
|
||||
const fim = new Date(`${data}T${hFim}`);
|
||||
if (fim <= inicio) { toast.error('O FIM DEVE SER DEPOIS DO INÍCIO.'); return; }
|
||||
setSaving(true);
|
||||
try { await onConfirm(inicio.toISOString(), fim.toISOString(), motivo.trim()); }
|
||||
catch (e: any) { toast.error((e?.message || 'ERRO AO BLOQUEAR').toUpperCase()); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-sm">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
|
||||
<h3 className="font-black text-sm uppercase text-gray-800 flex items-center gap-2"><Wrench size={16} className="text-gray-600" /> Bloquear p/ manutenção</h3>
|
||||
<button onClick={onClose} className="p-1.5 hover:bg-gray-100 rounded-lg"><X size={18} /></button>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">Data</label>
|
||||
<input type="date" value={data} onChange={e => setData(e.target.value)} className="w-full bg-gray-50 border-2 border-gray-100 rounded-xl px-3 py-2.5 text-sm font-bold text-gray-700 outline-none focus:border-teal-400" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">Início</label>
|
||||
<input type="time" value={hIni} onChange={e => setHIni(e.target.value)} className="w-full bg-gray-50 border-2 border-gray-100 rounded-xl px-3 py-2.5 text-sm font-bold text-gray-700 outline-none focus:border-teal-400" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">Fim</label>
|
||||
<input type="time" value={hFim} onChange={e => setHFim(e.target.value)} className="w-full bg-gray-50 border-2 border-gray-100 rounded-xl px-3 py-2.5 text-sm font-bold text-gray-700 outline-none focus:border-teal-400" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">Motivo (opcional)</label>
|
||||
<input value={motivo} onChange={e => setMotivo(e.target.value)} placeholder="Ex.: limpeza, conserto do equipo..." className="w-full bg-gray-50 border-2 border-gray-100 rounded-xl px-3 py-2.5 text-sm text-gray-700 outline-none focus:border-teal-400" />
|
||||
</div>
|
||||
<button onClick={salvar} disabled={saving} className="w-full bg-gray-700 text-white py-3 rounded-xl font-black text-sm uppercase hover:bg-gray-800 disabled:opacity-50 flex items-center justify-center gap-2">
|
||||
{saving ? <RefreshCw size={16} className="animate-spin" /> : <Wrench size={16} />} Bloquear sala
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -92,9 +159,12 @@ export const SalaHomeView: React.FC = () => {
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
const ws: any = HybridBackend.getActiveWorkspace();
|
||||
// Operador (ex.: recepção): só vê a AGENDA e bloqueia manutenção — sem locação/financeiro.
|
||||
const isOperador = ws?.papel_sala === 'operador';
|
||||
const [lancamentos, setLancamentos] = useState<any[]>([]);
|
||||
const [reservas, setReservas] = useState<{ feitas: any[]; recebidas: any[] }>({ feitas: [], recebidas: [] });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [bloqueioOpen, setBloqueioOpen] = useState(false);
|
||||
// Relatório por sala (período)
|
||||
const hoje = new Date();
|
||||
const [inicio, setInicio] = useState(new Date(hoje.getFullYear(), hoje.getMonth(), 1).toISOString().split('T')[0]);
|
||||
@@ -105,17 +175,25 @@ export const SalaHomeView: React.FC = () => {
|
||||
const carregar = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [fin, res] = await Promise.all([HybridBackend.getFinanceiro(), HybridBackend.getMinhasReservas()]);
|
||||
setLancamentos(Array.isArray(fin) ? fin : []);
|
||||
setReservas(res && res.recebidas ? res : { feitas: [], recebidas: [] });
|
||||
if (isOperador) {
|
||||
// Operador não tem acesso ao financeiro/recebíveis: carrega só a agenda da sala.
|
||||
const rs = await HybridBackend.getSalaReservas(ws.id);
|
||||
setLancamentos([]);
|
||||
setReservas({ feitas: [], recebidas: (Array.isArray(rs) ? rs : []).map((r: any) => ({ ...r, sala_id: ws.id })) });
|
||||
} else {
|
||||
const [fin, res] = await Promise.all([HybridBackend.getFinanceiro(), HybridBackend.getMinhasReservas()]);
|
||||
setLancamentos(Array.isArray(fin) ? fin : []);
|
||||
setReservas(res && res.recebidas ? res : { feitas: [], recebidas: [] });
|
||||
}
|
||||
} catch { /* silent */ } finally { setLoading(false); }
|
||||
}, []);
|
||||
}, [isOperador, ws?.id]);
|
||||
useEffect(() => { carregar(); }, [carregar]);
|
||||
|
||||
const carregarRel = useCallback(() => {
|
||||
if (isOperador) { setRel(null); return; }
|
||||
setRelLoading(true);
|
||||
HybridBackend.getSalasRelatorio(inicio, fim).then(setRel).catch(() => setRel(null)).finally(() => setRelLoading(false));
|
||||
}, [inicio, fim]);
|
||||
}, [inicio, fim, isOperador]);
|
||||
useEffect(() => { carregarRel(); }, [carregarRel]);
|
||||
|
||||
const recebido = lancamentos.filter(l => l.status === 'Pago').reduce((s, l) => s + Number(l.valor), 0);
|
||||
@@ -124,6 +202,13 @@ export const SalaHomeView: React.FC = () => {
|
||||
const pendentes = reservasDaSala.filter(r => r.status === 'pendente').length;
|
||||
const confirmadas = reservasDaSala.filter(r => r.status === 'confirmada').length;
|
||||
|
||||
const bloquearManutencao = async (inicio: string, fim: string, motivo: string) => {
|
||||
await HybridBackend.criarManutencaoSala(ws.id, { inicio, fim, motivo });
|
||||
toast.success('SALA BLOQUEADA PARA MANUTENÇÃO.');
|
||||
setBloqueioOpen(false);
|
||||
carregar();
|
||||
};
|
||||
|
||||
const darBaixa = async (l: any) => {
|
||||
if (!await confirm(`MARCAR como RECEBIDO ${fmtBRL(l.valor)} de "${l.descricao}"?`)) return;
|
||||
try {
|
||||
@@ -160,15 +245,16 @@ export const SalaHomeView: React.FC = () => {
|
||||
|
||||
{loading ? <div className="flex justify-center py-16"><RefreshCw className="animate-spin text-teal-600" size={28} /></div> : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<KPI titulo="Recebido" valor={fmtBRL(recebido)} cor="text-green-600" Icon={Wallet} sub="Locações pagas" />
|
||||
<KPI titulo="A receber" valor={fmtBRL(aReceber)} cor="text-amber-500" Icon={Clock} sub="Pendentes" />
|
||||
<div className={`grid grid-cols-2 ${isOperador ? 'lg:grid-cols-2' : 'lg:grid-cols-4'} gap-4`}>
|
||||
{!isOperador && <KPI titulo="Recebido" valor={fmtBRL(recebido)} cor="text-green-600" Icon={Wallet} sub="Locações pagas" />}
|
||||
{!isOperador && <KPI titulo="A receber" valor={fmtBRL(aReceber)} cor="text-amber-500" Icon={Clock} sub="Pendentes" />}
|
||||
<KPI titulo="Reservas pendentes" valor={String(pendentes)} cor="text-red-500" Icon={Inbox} sub="Aguardando resposta" />
|
||||
<KPI titulo="Reservas confirmadas" valor={String(confirmadas)} cor="text-teal-600" Icon={CalendarClock} sub="Nesta sala" />
|
||||
</div>
|
||||
|
||||
<AgendaSala reservas={reservasDaSala} />
|
||||
<AgendaSala reservas={reservasDaSala} onBloquear={() => setBloqueioOpen(true)} />
|
||||
|
||||
{!isOperador && (<>
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-100 bg-gray-50/50 flex justify-between items-center">
|
||||
<h3 className="text-sm font-black text-gray-800 uppercase">Recebíveis de locação</h3>
|
||||
@@ -240,8 +326,10 @@ export const SalaHomeView: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>)}
|
||||
</>
|
||||
)}
|
||||
{bloqueioOpen && <BloqueioModal onClose={() => setBloqueioOpen(false)} onConfirm={bloquearManutencao} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user