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.

} +
+ )} )}