From 03711e62800c17598ff894cef6ffd587fb0f2139 Mon Sep 17 00:00:00 2001 From: VPS 4 Builder Date: Sun, 5 Jul 2026 19:48:29 +0200 Subject: [PATCH 1/9] =?UTF-8?q?feat(secretaria):=20agente/c=C3=A9rebro=20p?= =?UTF-8?q?or=20n=C3=BAmero=20na=20aba=20N=C3=BAmeros?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adiciona o seletor "Agente / Cérebro deste número" no NumbersPanel: cada número de WhatsApp passa a apontar para um agente (sec_numbers.agent_id), tornando a Secretária, os nós do cérebro e a agenda separados POR SESSÃO. Mostra o cérebro vinculado em cada card; tipos SecNumber.agent_id e CalendarSlot.instance_id + param instance_id no calendar. Co-Authored-By: Claude Opus 4.8 --- frontend/views/newwhats/SecretariaView.tsx | 36 ++++++++++++++++--- .../views/newwhats/services/secretariaApi.ts | 5 ++- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/frontend/views/newwhats/SecretariaView.tsx b/frontend/views/newwhats/SecretariaView.tsx index da4d248..c18f2a8 100644 --- a/frontend/views/newwhats/SecretariaView.tsx +++ b/frontend/views/newwhats/SecretariaView.tsx @@ -722,9 +722,10 @@ const ROLE_OPTIONS: { value: NumberRole; label: string }[] = [ interface BaileysInstance { id: string; name: string; status: string } function NumbersPanel({ - numbers, loading, onCreate, onUpdate, onDelete, + numbers, agents, loading, onCreate, onUpdate, onDelete, }: { numbers: SecNumber[] + agents: SecAgent[] loading: boolean onCreate: (d: Partial) => void onUpdate: (id: string, d: Partial) => void @@ -734,7 +735,8 @@ function NumbersPanel({ const [loadingInst, setLoadingInst] = useState(false) const [rolePickerFor, setRolePickerFor] = useState(null) // instance_id const [editingId, setEditingId] = useState(null) // sec_number.id - const [editForm, setEditForm] = useState<{ area: string; priority: number; notes: string }>({ area: '', priority: 10, notes: '' }) + const [editForm, setEditForm] = useState<{ area: string; priority: number; notes: string; agent_id: string | null }>({ area: '', priority: 10, notes: '', agent_id: null }) + const agentNameOf = (id: string | null | undefined) => agents.find((a) => a.id === id)?.name ?? null useEffect(() => { setLoadingInst(true) @@ -825,6 +827,14 @@ function NumbersPanel({ )} {num?.area && · {num.area}} + {num && ( +
+ + + {agentNameOf(num.agent_id) ?? 'Cérebro padrão (1º agente ativo)'} + +
+ )} {/* Actions */} @@ -843,6 +853,8 @@ function NumbersPanel({ {/* Role picker dropdown */} {rolePickerFor === inst.id && ( + <> +
setRolePickerFor(null)} /> + )} @@ -891,7 +904,7 @@ function NumbersPanel({ {/* Editar detalhes (área, prioridade) */} {num && !isEditing && ( -
- {/* Inline edit: área + prioridade */} + {/* Inline edit: agente/cérebro + área + prioridade */} {isEditing && (
+
+

Agente / Cérebro deste número

+ +

Cada número responde com o cérebro (persona, regras, conhecimento, agenda) do agente escolhido aqui.

+

Área / Especialidade

void } = {activeTab === 'numbers' && ( store.createNumber(d)} onUpdate={(id, d) => store.updateNumber(id, d)} diff --git a/frontend/views/newwhats/services/secretariaApi.ts b/frontend/views/newwhats/services/secretariaApi.ts index e537e3d..5a9cb2c 100644 --- a/frontend/views/newwhats/services/secretariaApi.ts +++ b/frontend/views/newwhats/services/secretariaApi.ts @@ -75,6 +75,7 @@ export interface CalendarSlot { date: string time_start: string time_end: string + instance_id?: string | null // número/sessão dona do slot (null = global) attendee_name: string | null attendee_phone: string | null status: 'available' | 'booked' | 'cancelled' @@ -132,6 +133,8 @@ export type NumberRole = export interface SecNumber { id: string instance_id: string + clinica_id?: string | null + agent_id: string | null // agente/cérebro deste número (separação por sessão) label: string role: NumberRole area: string | null @@ -153,7 +156,7 @@ export const secNumberApi = { // ─── Calendar ───────────────────────────────────────────────────────────────── export const secCalApi = { - list: (params?: { from?: string; to?: string; status?: string }) => + list: (params?: { from?: string; to?: string; status?: string; instance_id?: string }) => api.get(`${BASE}/calendar`, { params }).then((r) => r.data), create: (d: Partial) => api.post(`${BASE}/calendar`, d).then((r) => r.data), update: (id: string, d: Partial) => api.put(`${BASE}/calendar/${id}`, d).then((r) => r.data), -- 2.53.0 From 77dec56dd024ffc5f869b1557e965cc7fe109344 Mon Sep 17 00:00:00 2001 From: VPS 4 Builder Date: Mon, 6 Jul 2026 01:23:27 +0200 Subject: [PATCH 2/9] =?UTF-8?q?feat(secretaria):=20hor=C3=A1rios=20de=20fu?= =?UTF-8?q?ncionamento=20+=20feriados=20na=20agenda/Secret=C3=A1ria?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Secretária passa a saber quando a clínica está aberta e a NÃO oferecer horário/agendar em dia fechado (domingo, fora do horário, feriado). - feriados.js: feriados nacionais BR (fixos + móveis via Páscoa) + resolver com exceções da clínica. - agenda-schema.js: tabelas clinicas_horarios (horário do dono) e feriados (exceções/fechamentos), idempotentes. - agenda-config.js: CRUD com ownership (horário/feriado da clínica = dono; horário do dentista = ele mesmo ou o dono), em /api/nw/agenda-config. - agenda-bridge.js: GET /status (aberto hoje?, motivo, próximo aberto, grade, feriados) + /slots agora = clínica ∩ dentista − feriado. - HorariosConfig.tsx + botão no AgendaSettingsModal: telas de configuração (abas Clínica e Dentistas). Testado em dev: a Secretária ofereceu "amanhã" ao pedirem "hoje" num domingo, com os horários configurados da clínica. Obs.: index.js também traz trabalho acumulado do satélite (proxy de mídia via Wasabi/storage) que estava no working tree. Co-Authored-By: Claude Opus 4.8 --- backend/newwhats/agenda-bridge.js | 241 +++++++++++++++++++- backend/newwhats/agenda-config.js | 180 +++++++++++++++ backend/newwhats/agenda-schema.js | 40 ++++ backend/newwhats/feriados.js | 92 ++++++++ backend/newwhats/index.js | 36 ++- frontend/components/AgendaSettingsModal.tsx | 14 ++ frontend/components/HorariosConfig.tsx | 224 ++++++++++++++++++ 7 files changed, 810 insertions(+), 17 deletions(-) create mode 100644 backend/newwhats/agenda-config.js create mode 100644 backend/newwhats/agenda-schema.js create mode 100644 backend/newwhats/feriados.js create mode 100644 frontend/components/HorariosConfig.tsx diff --git a/backend/newwhats/agenda-bridge.js b/backend/newwhats/agenda-bridge.js index 75662c9..91472c5 100644 --- a/backend/newwhats/agenda-bridge.js +++ b/backend/newwhats/agenda-bridge.js @@ -10,18 +10,71 @@ // POST /book — cria agendamento real (anti-overbooking + vínculo de paciente) import express from 'express'; import { getConfig } from './config.js'; +import { resolverFeriado, feriadoNacionalNome } from './feriados.js'; const SLOT_MIN = 30; // duração padrão do slot (min) const HORIZON_DAYS = 7; // janela padrão de busca const MAX_SLOTS = 20; -// A agenda do scoreodonto é livre (quase ninguém preenche dentistas_horarios). -// Sem jornada configurada, oferecemos horário comercial padrão (seg–sex 08–18). +// 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, 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 }; +} + +// 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 — @@ -79,13 +132,16 @@ export function createAgendaBridge(pool) { const { rows: dentistas } = await pool.query(`SELECT id, nome FROM dentistas WHERE ${dWhere}`, dParams); if (!dentistas.length) return res.json({ slots: [], motivo: 'Nenhum dentista cadastrado na clínica.' }); - // Jornadas configuradas (opcional). + // Jornada dos DENTISTAS (dentistas_horarios) — opcional, por dentista. const { rows: horarios } = await pool.query( `SELECT dentista_id, dia_semana, hora_inicio, hora_fim FROM dentistas_horarios WHERE clinica_id = $1 AND ativo = 1`, [clinicaId]); const hoursBy = new Map(); for (const h of horarios) { const a = hoursBy.get(h.dentista_id) || []; a.push(h); hoursBy.set(h.dentista_id, a); } + // Horário de FUNCIONAMENTO da clínica (config do dono) — a "casca" externa. + const clinicHours = await carregarHorariosClinica(pool, clinicaId); + 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( @@ -101,13 +157,18 @@ export function createAgendaBridge(pool) { for (let i = 0; i < days && slots.length < MAX_SLOTS; i++) { const day = new Date(start0); day.setDate(day.getDate() + i); const dow = day.getDay(); + // Janelas da CLÍNICA no dia (já considera feriado). Fechado → pula o dia. + const { janelas: clinicWins, fechado } = await janelasClinicaNoDia(pool, clinicaId, ymd(day), dow, clinicHours); + if (fechado || !clinicWins.length) continue; for (const dent of dentistas) { if (slots.length >= MAX_SLOTS) break; const cfg = hoursBy.get(dent.id); - // Janelas do dia: jornada configurada OU padrão comercial (seg–sex). - const windows = (cfg && cfg.length) - ? cfg.filter((h) => h.dia_semana === dow).map((h) => [h.hora_inicio, h.hora_fim]) - : ((dow >= 1 && dow <= 5) ? [[DEFAULT_INI, DEFAULT_FIM]] : []); + // Dentista: se configurou jornada, usa a do dia; senão, atende sempre + // que a clínica está aberta. Efetivo = CLÍNICA ∩ DENTISTA. + const dentWins = (cfg && cfg.length) + ? cfg.filter((h) => h.dia_semana === dow).map((h) => [hm(h.hora_inicio), hm(h.hora_fim)]) + : clinicWins; + const windows = interseccao(clinicWins, dentWins); const bs = busyBy.get(dent.id) || []; for (const [ini, fim] of windows) { const [hi, mi] = String(ini).split(':').map(Number); @@ -132,6 +193,66 @@ export function createAgendaBridge(pool) { } }); + // ── 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 /book ──────────────────────────────────────────────────────────────── // Body: { clinica_id, dentista_id, start, end?, paciente_nome, paciente_celular, procedimento? } router.post('/book', async (req, res) => { @@ -190,5 +311,111 @@ export function createAgendaBridge(pool) { } }); + // ════════ 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; } diff --git a/backend/newwhats/agenda-config.js b/backend/newwhats/agenda-config.js new file mode 100644 index 0000000..2f81217 --- /dev/null +++ b/backend/newwhats/agenda-config.js @@ -0,0 +1,180 @@ +// 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'; + +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, 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; + await client.query( + `INSERT INTO dentistas_horarios (id, dentista_id, clinica_id, dia_semana, hora_inicio, hora_fim, ativo) + VALUES ($1,$2,$3,$4,$5,$6,1)`, [uid('dh'), dentistaId, dent.clinica_id, 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(); } + }); + + // ── 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 }); } + }); + + // 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; +} diff --git a/backend/newwhats/agenda-schema.js b/backend/newwhats/agenda-schema.js new file mode 100644 index 0000000..785f000 --- /dev/null +++ b/backend/newwhats/agenda-schema.js @@ -0,0 +1,40 @@ +// 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)`); + 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; + } +} diff --git a/backend/newwhats/feriados.js b/backend/newwhats/feriados.js new file mode 100644 index 0000000..3f7082b --- /dev/null +++ b/backend/newwhats/feriados.js @@ -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; +} diff --git a/backend/newwhats/index.js b/backend/newwhats/index.js index 9eb197c..e2b4fec 100644 --- a/backend/newwhats/index.js +++ b/backend/newwhats/index.js @@ -6,7 +6,7 @@ // registerNewwhats(app, pool, { superadminGuard }) import { createRoutes } from './routes.js' import { createWebhookReceiver } from './webhook-receiver.js' -import { createRestProxy, provisionNewwhatsAccount } from './proxy.js' +import { createRestProxy, createAccountsRoute, provisionNewwhatsAccount } from './proxy.js' import { getConfig, isConfigured, loadConfigFromDb, saveConfigToDb } from './config.js' import { Readable } from 'node:stream' @@ -24,6 +24,8 @@ export async function provisionNewwhatsAccountEager(pool, email) { } catch { return false } } import { createAgendaBridge } from './agenda-bridge.js' +import { createAgendaConfig } from './agenda-config.js' +import { ensureAgendaSchema } from './agenda-schema.js' // Valida uma chave contra o motor. Envia um email-sonda (a chave mestra exige // x-nw-email). 200 = ok com dados; 404 = chave válida mas email-sonda inexistente @@ -48,26 +50,39 @@ export function registerNewwhats(app, pool, opts = {}) { // Auth própria por segredo compartilhado (x-nw-agenda-secret). Server-to-server. app.use('/api/nw/agenda', createAgendaBridge(pool)); - // ── Proxy de MÍDIA: /api/nw/media/* → motor /media/* ─────────────────────── - // A mídia do WhatsApp vive no motor (servida do Wasabi/local, pública). O - // satélite a repassa mantendo same-origin no /
@@ -275,5 +287,7 @@ export const AgendaSettingsModal: React.FC = ({ isOpen } `}
+ setShowHorarios(false)} clinicaId={clinicaId} /> + ); }; diff --git a/frontend/components/HorariosConfig.tsx b/frontend/components/HorariosConfig.tsx new file mode 100644 index 0000000..df34bb2 --- /dev/null +++ b/frontend/components/HorariosConfig.tsx @@ -0,0 +1,224 @@ +// 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 } 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 } +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 }> = ({ faixas, onChange, disabled }) => { + 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]); + return ( +
+ {DIAS.map((nome, d) => { + const fs = porDia(d); + return ( +
+
{nome}
+
+ {fs.length === 0 && Fechado} + {fs.map((f, i) => ( +
+ { const n = [...fs]; n[i] = { ...f, hora_inicio: e.target.value }; setDia(d, n); }} + 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" /> + até + { const n = [...fs]; n[i] = { ...f, hora_fim: e.target.value }; setDia(d, n); }} + 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" /> + {!disabled && } +
+ ))} + {!disabled && ( + + )} +
+
+ ); + })} +
+ ); +}; + +export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; clinicaId?: string }> = ({ isOpen, onClose, clinicaId }) => { + const [aba, setAba] = useState<'clinica' | 'dentistas'>('clinica'); + const [erro, setErro] = useState(null); + const [ok, setOk] = useState(null); + const [saving, setSaving] = useState(false); + + // Clínica + const [clinFaixas, setClinFaixas] = useState([]); + const [feriados, setFeriados] = useState<{ excecoes: any[]; nacionais: any[] }>({ excecoes: [], nacionais: [] }); + const [novo, setNovo] = useState({ data: '', nome: '', fecha: true }); + + // Dentistas + const [dentistas, setDentistas] = useState([]); + const [dentSel, setDentSel] = useState(''); + const [dentFaixas, setDentFaixas] = useState([]); + + 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`); + setDentistas(d.dentistas || []); + const first = (d.dentistas || []).find((x: Dentista) => x.eh_voce) || (d.dentistas || [])[0]; + if (first) setDentSel(first.id); + } catch (e: any) { flash(setErro, e.message); } + }, [clinicaId]); + + useEffect(() => { if (isOpen) { carregarClinica(); carregarDentistas(); } }, [isOpen, carregarClinica, carregarDentistas]); + useEffect(() => { + if (!dentSel) { setDentFaixas([]); 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 }))) + ).catch((e) => flash(setErro, e.message)); + }, [dentSel]); + + 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); } + }; + + if (!isOpen) return null; + + return ( +
+
+ {/* Header */} +
+
+
+
+

Horários de Funcionamento

+

A Secretária IA usa isto para atender

+
+
+ +
+ + {/* Tabs */} +
+ {(['clinica', 'dentistas'] as const).map((t) => ( + + ))} +
+ + {(erro || ok) && ( +
{erro || ok}
+ )} + +
+ {aba === 'clinica' ? ( +
+
+
+

Dias e horários que a clínica funciona

+ +
+ +

Sem horário configurado, assume-se seg–sex 08h–18h. Só o dono edita.

+
+ +
+

Feriados e fechamentos

+
+
+ 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" />
+
+ 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" />
+ +
+ {feriados.excecoes.length > 0 && ( +
+ {feriados.excecoes.map((f) => ( +
+ {f.data.split('-').reverse().join('/')} · {f.nome} + +
+ ))} +
+ )} +
+ Feriados nacionais (automáticos) +
+ {feriados.nacionais.map((f) =>
{f.data.split('-').reverse().join('/')} · {f.nome}
)} +
+
+
+
+ ) : ( +
+
+
+ + +
+ +
+ {dentSel ? :

Nenhum dentista.

} +

Sem horário próprio, o dentista atende sempre que a clínica está aberta. Cada dentista (ou o dono) edita os seus.

+
+ )} +
+
+
+ ); +}; -- 2.53.0 From 0a324bf8c8083421976d9495a6444ffc10bcf3df Mon Sep 17 00:00:00 2001 From: VPS 4 Builder Date: Mon, 6 Jul 2026 02:12:50 +0200 Subject: [PATCH 3/9] feat(secretaria): folga do dentista por data (A) + salas alugadas na agenda (B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A — Folga/férias do dentista por DATA: tabela dentistas_folgas; o dentista (ou o dono) marca dias em que não atende; o /slots pula o dentista nessas datas. Endpoints /dentista/:id/folgas (GET/POST/DELETE) + UI na aba Dentistas. B — Salas alugadas: o dentista com sala_reserva aprovada passa a ter horários oferecidos pela Secretária no quarto (disponibilidade = janela da reserva, casada pelo usuario_id). /slots ganha passada de salas (slot com sala_id + local); /book grava agendamentos.sala_id (coluna aditiva). Geração passa a usar CANDIDATE_CAP e ordena por horário (clínica e salas competem). Testado em dev: folga pula os dias do dentista; reserva de sala gera slots com o nome da sala. Co-Authored-By: Claude Opus 4.8 --- backend/newwhats/agenda-bridge.js | 71 +++++++++++++++++++++++--- backend/newwhats/agenda-config.js | 45 ++++++++++++++++ backend/newwhats/agenda-schema.js | 18 +++++++ frontend/components/HorariosConfig.tsx | 46 ++++++++++++++++- 4 files changed, 171 insertions(+), 9 deletions(-) diff --git a/backend/newwhats/agenda-bridge.js b/backend/newwhats/agenda-bridge.js index 91472c5..67cfdc1 100644 --- a/backend/newwhats/agenda-bridge.js +++ b/backend/newwhats/agenda-bridge.js @@ -14,7 +14,8 @@ import { resolverFeriado, feriadoNacionalNome } from './feriados.js'; const SLOT_MIN = 30; // duração padrão do slot (min) const HORIZON_DAYS = 7; // janela padrão de busca -const MAX_SLOTS = 20; +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'; @@ -56,6 +57,24 @@ async function carregarHorariosClinica(pool, clinicaId) { return { byDow, hasConfig }; } +// Carrega as folgas (por data) dos dentistas da clínica → Map. +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) { @@ -141,6 +160,8 @@ export function createAgendaBridge(pool) { // 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); @@ -154,14 +175,15 @@ export function createAgendaBridge(pool) { const now = new Date(); const slots = []; - for (let i = 0; i < days && slots.length < MAX_SLOTS; i++) { + for (let i = 0; i < days && slots.length < CANDIDATE_CAP; i++) { const day = new Date(start0); day.setDate(day.getDate() + i); const dow = day.getDay(); // Janelas da CLÍNICA no dia (já considera feriado). Fechado → pula o dia. const { janelas: clinicWins, fechado } = await janelasClinicaNoDia(pool, clinicaId, ymd(day), dow, clinicHours); if (fechado || !clinicWins.length) continue; for (const dent of dentistas) { - if (slots.length >= MAX_SLOTS) break; + if (slots.length >= CANDIDATE_CAP) break; + if (emFolga(folgasBy.get(dent.id), ymd(day))) continue; // dentista de folga nesse dia const cfg = hoursBy.get(dent.id); // Dentista: se configurou jornada, usa a do dia; senão, atende sempre // que a clínica está aberta. Efetivo = CLÍNICA ∩ DENTISTA. @@ -175,7 +197,7 @@ export function createAgendaBridge(pool) { const [hf, mf] = String(fim).split(':').map(Number); let t = new Date(day); t.setHours(hi, mi || 0, 0, 0); const limit = new Date(day); limit.setHours(hf, mf || 0, 0, 0); - while (t.getTime() + duration * 60000 <= limit.getTime() && slots.length < MAX_SLOTS) { + while (t.getTime() + duration * 60000 <= limit.getTime() && slots.length < CANDIDATE_CAP) { const s = new Date(t); const e = new Date(t.getTime() + duration * 60000); t = e; @@ -186,6 +208,41 @@ export function createAgendaBridge(pool) { } } } + + // ── 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 */ } + } + slots.sort((a, b) => a.start.localeCompare(b.start)); res.json({ slots: slots.slice(0, MAX_SLOTS) }); } catch (e) { @@ -296,11 +353,11 @@ export function createAgendaBridge(pool) { const { rows: ins } = await client.query( `INSERT INTO agendamentos (id, clinica_id, pacientenome, pacientecelular, pacienteid, dentistaid, procedimento, - start_time, end_time, status, setor_id, created_by_nome, created_at, updated_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,'agendado',$10,'Secretária IA',NOW(),NOW()) + start_time, end_time, status, setor_id, created_by_nome, created_at, updated_at, sala_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,'agendado',$10,'Secretária IA',NOW(),NOW(),$11) RETURNING id, start_time, end_time, dentistaid`, [id, b.clinica_id, b.paciente_nome || 'Cliente WhatsApp', b.paciente_celular || null, - pacienteId, b.dentista_id, b.procedimento || 'Consulta', b.start, end, setorId]); + pacienteId, b.dentista_id, b.procedimento || 'Consulta', b.start, end, setorId, b.sala_id || null]); await client.query('COMMIT'); res.json({ ok: true, agendamento: ins[0], paciente_vinculado: !!pacienteId, paciente_criado: pacienteCriado }); } catch (e) { diff --git a/backend/newwhats/agenda-config.js b/backend/newwhats/agenda-config.js index 2f81217..8fc0e21 100644 --- a/backend/newwhats/agenda-config.js +++ b/backend/newwhats/agenda-config.js @@ -119,6 +119,51 @@ export function createAgendaConfig(pool) { 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) => { diff --git a/backend/newwhats/agenda-schema.js b/backend/newwhats/agenda-schema.js index 785f000..061b92e 100644 --- a/backend/newwhats/agenda-schema.js +++ b/backend/newwhats/agenda-schema.js @@ -31,6 +31,24 @@ export async function ensureAgendaSchema(pool) { 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`); return true; } catch (e) { // Não derruba o boot do satélite se o banco estiver indisponível no momento. diff --git a/frontend/components/HorariosConfig.tsx b/frontend/components/HorariosConfig.tsx index df34bb2..ec59eca 100644 --- a/frontend/components/HorariosConfig.tsx +++ b/frontend/components/HorariosConfig.tsx @@ -77,6 +77,8 @@ export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; cl const [dentistas, setDentistas] = useState([]); const [dentSel, setDentSel] = useState(''); const [dentFaixas, setDentFaixas] = useState([]); + const [folgas, setFolgas] = useState([]); + const [novaFolga, setNovaFolga] = useState({ data_inicio: '', data_fim: '', motivo: '' }); const flash = (setter: (v: string | null) => void, msg: string) => { setter(msg); setTimeout(() => setter(null), 3000); }; @@ -101,12 +103,25 @@ export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; cl }, [clinicaId]); useEffect(() => { if (isOpen) { carregarClinica(); carregarDentistas(); } }, [isOpen, carregarClinica, carregarDentistas]); + const carregarFolgas = useCallback((id: string) => { + req(`/dentista/${id}/folgas`).then((f) => setFolgas(f.folgas || [])).catch(() => setFolgas([])); + }, []); useEffect(() => { - if (!dentSel) { setDentFaixas([]); return; } + 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 }))) ).catch((e) => flash(setErro, e.message)); - }, [dentSel]); + 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); @@ -215,6 +230,33 @@ export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; cl {dentSel ? :

Nenhum dentista.

}

Sem horário próprio, o dentista atende sempre que a clínica está aberta. Cada dentista (ou o dono) edita os seus.

+ + {dentSel && ( +
+

Folgas e férias (não atende nestas datas)

+
+
+ 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" />
+
+ 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" />
+
+ 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" />
+ +
+ {folgas.length > 0 ? ( +
+ {folgas.map((f) => ( +
+ + {f.data_inicio.split('-').reverse().join('/')}{f.data_fim !== f.data_inicio ? ` até ${f.data_fim.split('-').reverse().join('/')}` : ''}{f.motivo ? ` · ${f.motivo}` : ''} + + +
+ ))} +
+ ) :

Nenhuma folga futura cadastrada.

} +
+ )} )} -- 2.53.0 From 773e8d4bf2685cb16fc82702d64b99699831cdb2 Mon Sep 17 00:00:00 2001 From: VPS 4 Builder Date: Mon, 6 Jul 2026 04:44:38 +0200 Subject: [PATCH 4/9] =?UTF-8?q?fix(agenda):=20fuso=20autom=C3=A1tico=20(Am?= =?UTF-8?q?erica/Sao=5FPaulo)=20+=20dentista=20configura=20seus=20hor?= =?UTF-8?q?=C3=A1rios?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TZ=America/Sao_Paulo no backend (compose): a agenda deixa de rodar em UTC — horários/slots passam a ser Brasília automaticamente (corrige deslocamento de 3h). - Acesso do DENTISTA: novo botão "Meus Horários" na agenda do dentista, abrindo a config direto nos horários/folgas DELE (HorariosConfig com initialTab/soDentista, restrito ao próprio registro). Antes só admin/donoclinica alcançavam a config. - Config da clínica: gate passa a incluir donoconsultorio (dono de consultório). Obs.: AgendaView.tsx também traz trabalho acumulado da agenda que estava no working tree. Co-Authored-By: Claude Opus 4.8 --- docker-compose.yml | 1 + frontend/components/HorariosConfig.tsx | 15 ++-- frontend/views/AgendaView.tsx | 95 +++++++++++++++++--------- 3 files changed, 71 insertions(+), 40 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index ed83493..969aa52 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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). diff --git a/frontend/components/HorariosConfig.tsx b/frontend/components/HorariosConfig.tsx index ec59eca..198b2cc 100644 --- a/frontend/components/HorariosConfig.tsx +++ b/frontend/components/HorariosConfig.tsx @@ -62,8 +62,9 @@ const GradeSemanal: React.FC<{ faixas: Faixa[]; onChange: (f: Faixa[]) => void; ); }; -export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; clinicaId?: string }> = ({ isOpen, onClose, clinicaId }) => { - const [aba, setAba] = useState<'clinica' | 'dentistas'>('clinica'); +export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; clinicaId?: string; initialTab?: 'clinica' | 'dentistas'; soDentista?: boolean }> = ({ isOpen, onClose, clinicaId, initialTab, soDentista }) => { + const [aba, setAba] = useState<'clinica' | 'dentistas'>(initialTab || 'clinica'); + useEffect(() => { if (isOpen && initialTab) setAba(initialTab); }, [isOpen, initialTab]); const [erro, setErro] = useState(null); const [ok, setOk] = useState(null); const [saving, setSaving] = useState(false); @@ -96,11 +97,13 @@ export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; cl if (!clinicaId) return; try { const d = await req(`/clinica/${clinicaId}/dentistas`); - setDentistas(d.dentistas || []); - const first = (d.dentistas || []).find((x: Dentista) => x.eh_voce) || (d.dentistas || [])[0]; + 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]); + }, [clinicaId, soDentista]); useEffect(() => { if (isOpen) { carregarClinica(); carregarDentistas(); } }, [isOpen, carregarClinica, carregarDentistas]); const carregarFolgas = useCallback((id: string) => { @@ -162,7 +165,7 @@ export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; cl {/* Tabs */}
- {(['clinica', 'dentistas'] as const).map((t) => ( + {(soDentista ? (['dentistas'] as const) : (['clinica', 'dentistas'] as const)).map((t) => ( + {/* Título + recolher menu (desktop, ao lado). No mobile o AGENDAR e as + ações ficam empilhados abaixo (dentro da gaveta). */} +
{onToggleSidebar && ( )}

AGENDA CLÍNICA

- {(currentRole !== 'paciente' && !souDentista) && ( - - )}
- {/* AGENDAR (desktop, full-width no topo da coluna) */} + {/* AGENDAR (full-width no topo da coluna/gaveta — mobile e desktop) */} {(currentRole !== 'paciente' && !souDentista) && ( - )} - {/* ícones de ação — mesmo tamanho, distribuídos horizontalmente */} -
+ {/* Ações — mobile: empilhadas, ícone + nome, largura cheia; desktop: fileira de ícones */} +
{(currentRole !== 'paciente') && ( - )} {(currentRole === 'admin' || currentRole === 'donoclinica' || currentRole === 'donoconsultorio') && ( )} - {(currentRole === 'admin' || currentRole === 'donoclinica') && ( - )} {souDentista && ( - + )} + {souDentista && ( + )} {(currentRole !== 'paciente' && !souDentista) && ( - )} @@ -747,7 +770,10 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebar onEditar={() => { setShowDetails(false); setIsModalOpen(true); }} onAtender={handleAtender} onVerWhats={(info) => setWhatsTarget(info)} - podeAtender={!!onNavigate && currentRole !== 'paciente'} + // "Atender" (abre Lançar GTO) é ação CLÍNICA — atende quem trata: dentista, + // biomédico, dono da clínica/consultório, dentista locatário, admin. A recepção + // (funcionario) NÃO atende; ela só remarca/desmarca/agenda. + podeAtender={!!onNavigate && currentRole !== 'paciente' && currentRole !== 'funcionario'} dentistaRestrito={souDentista} /> @@ -861,6 +887,7 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebar initialPatient={reagendarPatient} /> setIsSettingsOpen(false)} dentists={dentists} /> + setShowMeusHorarios(false)} clinicaId={(HybridBackend.getActiveWorkspace() as any)?.id} initialTab="dentistas" soDentista /> {showInteresses && (
setShowInteresses(false)}> -- 2.53.0 From ca2cfa1f359bb3eeda7779fca6d8bce2cab7c8df Mon Sep 17 00:00:00 2001 From: VPS 4 Builder Date: Mon, 6 Jul 2026 05:29:25 +0200 Subject: [PATCH 5/9] =?UTF-8?q?feat(agenda):=20janela=20IA-livre=20vs=20es?= =?UTF-8?q?tendida=20(zona=20cinza)=20+=20fila=20da=20secret=C3=A1ria=20hu?= =?UTF-8?q?mana?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Duplicar horário para todos os dias (botão na grade). - Janela do dentista com DOIS limites: "IA agenda livre" [inicio,fim] e "aceito confirmar também" (mais cedo/mais tarde) — colunas hora_inicio_flex/hora_fim_flex. - Zona cinza: quando o cliente pede um horário fora da janela livre, a Secretária IA NÃO agenda — registra um pedido (POST /pedido) e diz "vou confirmar e retorno". - Fila da secretária humana: tabela agenda_pedidos + endpoints (listar/confirmar/ recusar); tela PedidosPendentes (confirmar → vira agendamento real, com escolha do dentista); botão com BADGE de contagem na Agenda (poll a cada 30 min) para staff. Testado em dev: dentista configura flex; IA registra pedido de 8h30 e responde "vou verificar com a secretária"; humana confirma → agendamento criado. Co-Authored-By: Claude Opus 4.8 --- backend/newwhats/agenda-bridge.js | 30 ++++++ backend/newwhats/agenda-config.js | 78 ++++++++++++++- backend/newwhats/agenda-schema.js | 30 ++++++ frontend/components/HorariosConfig.tsx | 60 ++++++++---- frontend/components/PedidosPendentes.tsx | 115 +++++++++++++++++++++++ frontend/views/AgendaView.tsx | 30 ++++++ 6 files changed, 323 insertions(+), 20 deletions(-) create mode 100644 frontend/components/PedidosPendentes.tsx diff --git a/backend/newwhats/agenda-bridge.js b/backend/newwhats/agenda-bridge.js index 67cfdc1..cd45678 100644 --- a/backend/newwhats/agenda-bridge.js +++ b/backend/newwhats/agenda-bridge.js @@ -310,6 +310,36 @@ export function createAgendaBridge(pool) { } }); + // ── 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 /book ──────────────────────────────────────────────────────────────── // Body: { clinica_id, dentista_id, start, end?, paciente_nome, paciente_celular, procedimento? } router.post('/book', async (req, res) => { diff --git a/backend/newwhats/agenda-config.js b/backend/newwhats/agenda-config.js index 8fc0e21..b5ce0cd 100644 --- a/backend/newwhats/agenda-config.js +++ b/backend/newwhats/agenda-config.js @@ -84,7 +84,9 @@ export function createAgendaConfig(pool) { 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 + 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 }); @@ -109,9 +111,13 @@ export function createAgendaConfig(pool) { 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, ativo) - VALUES ($1,$2,$3,$4,$5,$6,1)`, [uid('dh'), dentistaId, dent.clinica_id, dow, ini, fim]); + `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 }); @@ -210,6 +216,72 @@ export function createAgendaConfig(pool) { } 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, 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(); } + }); + + 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) => { diff --git a/backend/newwhats/agenda-schema.js b/backend/newwhats/agenda-schema.js index 061b92e..9ebbc7f 100644 --- a/backend/newwhats/agenda-schema.js +++ b/backend/newwhats/agenda-schema.js @@ -49,6 +49,36 @@ export async function ensureAgendaSchema(pool) { // ── 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)`); return true; } catch (e) { // Não derruba o boot do satélite se o banco estiver indisponível no momento. diff --git a/frontend/components/HorariosConfig.tsx b/frontend/components/HorariosConfig.tsx index 198b2cc..f02a3e8 100644 --- a/frontend/components/HorariosConfig.tsx +++ b/frontend/components/HorariosConfig.tsx @@ -2,7 +2,7 @@ // 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 } from 'lucide-react'; +import { X, Clock, CalendarOff, Plus, Trash2, Save, Loader2, User, Copy } from 'lucide-react'; const API = (import.meta as any).env?.VITE_API_URL || '/api'; const BASE = `${API}/nw/agenda-config`; @@ -20,13 +20,18 @@ async function req(path: string, opts: RequestInit = {}) { return data; } -interface Faixa { dia_semana: number; hora_inicio: string; hora_fim: string } +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 }> = ({ faixas, onChange, disabled }) => { +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 (
{DIAS.map((nome, d) => { @@ -36,24 +41,45 @@ const GradeSemanal: React.FC<{ faixas: Faixa[]; onChange: (f: Faixa[]) => void;
{nome}
{fs.length === 0 && Fechado} - {fs.map((f, i) => ( -
- { const n = [...fs]; n[i] = { ...f, hora_inicio: e.target.value }; setDia(d, n); }} - 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" /> - até - { const n = [...fs]; n[i] = { ...f, hora_fim: e.target.value }; setDia(d, n); }} - 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" /> - {!disabled && } + {fs.map((f, i) => { + const upd = (patch: Partial) => { const n = [...fs]; n[i] = { ...f, ...patch }; setDia(d, n); }; + return ( +
+
+ 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" /> + até + 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 && IA agenda livre} + {!disabled && } +
+ {withFlex && ( +
+ Aceito confirmar também: + antes, a partir de + 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" /> + e depois, até + 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" /> +
+ )}
- ))} + ); + })} {!disabled && ( )} + {!disabled && fs.length > 0 && ( + + )}
); @@ -112,7 +138,7 @@ export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; cl 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 }))) + 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]); @@ -231,8 +257,8 @@ export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; cl {saving ? : } Salvar
- {dentSel ? :

Nenhum dentista.

} -

Sem horário próprio, o dentista atende sempre que a clínica está aberta. Cada dentista (ou o dono) edita os seus.

+ {dentSel ? :

Nenhum dentista.

} +

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.

{dentSel && (
diff --git a/frontend/components/PedidosPendentes.tsx b/frontend/components/PedidosPendentes.tsx new file mode 100644 index 0000000..1a564a9 --- /dev/null +++ b/frontend/components/PedidosPendentes.tsx @@ -0,0 +1,115 @@ +// 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 } 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([]); + const [dentistas, setDentistas] = useState([]); + const [sel, setSel] = useState>({}); // pedido_id → dentista_id escolhido + const [busy, setBusy] = useState(null); + const [erro, setErro] = useState(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); } + }; + + if (!isOpen) return null; + return ( +
+
+
+
+
+
+

Pedidos aguardando confirmação

+

Horários fora do padrão que a Secretária IA encaminhou

+
+
+ +
+ + {erro &&
{erro}
} + +
+ {loading ? ( +
+ ) : pedidos.length === 0 ? ( +

Nenhum pedido pendente. 🎉

+ ) : pedidos.map((p) => ( +
+
+
+
+ {dm(p.data)} às {p.hora_inicio}{p.hora_fim ? `–${p.hora_fim}` : ''} +
+
{p.paciente_nome || 'Cliente'} + {p.paciente_telefone && {p.paciente_telefone}}
+ {p.procedimento &&
{p.procedimento}
} +
+
+ {p.dentista_id ? ( + {p.dentista_nome} + ) : ( + + )} +
+ + +
+
+
+
+ ))} +
+
+
+ ); +}; diff --git a/frontend/views/AgendaView.tsx b/frontend/views/AgendaView.tsx index 2e855a5..3467ed9 100644 --- a/frontend/views/AgendaView.tsx +++ b/frontend/views/AgendaView.tsx @@ -6,6 +6,7 @@ import interactionPlugin from '@fullcalendar/interaction'; import { Calendar as CalendarIcon, Plus, X, Loader2, GripVertical, Settings as GearIcon, Edit, Trash2, CalendarRange, Clock, User, Stethoscope, AlertCircle, CheckCircle2, Bell, CalendarClock, History, Download, PanelLeftClose, PanelLeftOpen } from 'lucide-react'; import { AgendaSettingsModal } from '../components/AgendaSettingsModal.tsx'; import { HorariosConfig } from '../components/HorariosConfig.tsx'; +import { PedidosPendentes } from '../components/PedidosPendentes.tsx'; import { AgendaDetailModal, ScoreBadge, FamiliaChips } from '../components/AgendaDetailModal.tsx'; import { WhatsChatDrawer } from './newwhats/WhatsChatDrawer.tsx'; import { AgendaPresence } from '../components/AgendaPresence.tsx'; @@ -352,6 +353,25 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebar 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(null); const [selectedAppointment, setSelectedAppointment] = useState(null); const [showDetails, setShowDetails] = useState(false); @@ -653,6 +673,14 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebar Importar do Google )} + {['admin', 'donoclinica', 'donoconsultorio', 'funcionario'].includes(currentRole) && ( + + )} {(currentRole === 'admin' || currentRole === 'donoclinica' || currentRole === 'donoconsultorio') && ( ))}
@@ -244,7 +258,7 @@ export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; cl
- ) : ( + ) : aba === 'dentistas' ? (
@@ -287,6 +301,30 @@ export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; cl )}
+ ) : ( +
+
+

Situações e regras da Secretária IA

+ +
+

+ Escreva regras que a Secretária IA deve seguir ao atender — uma por caixa. Ex.: "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." Quando uma situação exigir decisão humana, a IA encaminha o caso e pede para o paciente aguardar (aparece em Pedidos pendentes). +

+
+ {situacoes.map((s, i) => ( +
+