feat(secretaria): folga do dentista por data (A) + salas alugadas na agenda (B)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<dentista_id,[{ini,fim}]>.
|
||||
async function carregarFolgas(pool, clinicaId) {
|
||||
const byDent = new Map();
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT f.dentista_id, to_char(f.data_inicio,'YYYY-MM-DD') AS ini, to_char(f.data_fim,'YYYY-MM-DD') AS fim
|
||||
FROM dentistas_folgas f JOIN dentistas d ON d.id = f.dentista_id
|
||||
WHERE d.clinica_id = $1`, [clinicaId]);
|
||||
for (const r of rows) {
|
||||
const arr = byDent.get(r.dentista_id) || [];
|
||||
arr.push([r.ini, r.fim]);
|
||||
byDent.set(r.dentista_id, arr);
|
||||
}
|
||||
} catch { /* tabela pode não existir ainda */ }
|
||||
return byDent;
|
||||
}
|
||||
const emFolga = (folgas, dateStr) => (folgas || []).some(([i, f]) => dateStr >= i && dateStr <= f);
|
||||
|
||||
// Janelas de funcionamento da CLÍNICA num dia específico, já considerando feriado.
|
||||
// Retorna { janelas: [[ini,fim]], fechado: bool, motivo: string|null }.
|
||||
async function janelasClinicaNoDia(pool, clinicaId, dateStr, dow, clinicHours) {
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user