feat(secretaria): horários de funcionamento + feriados na agenda/Secretária
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<dow,[[ini,fim]]>, hasConfig }.
|
||||
async function carregarHorariosClinica(pool, clinicaId) {
|
||||
const byDow = new Map();
|
||||
let hasConfig = false;
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT dia_semana, hora_inicio, hora_fim FROM clinicas_horarios WHERE clinica_id = $1 AND ativo = 1`,
|
||||
[clinicaId]);
|
||||
hasConfig = rows.length > 0;
|
||||
for (const r of rows) {
|
||||
const arr = byDow.get(r.dia_semana) || [];
|
||||
arr.push([hm(r.hora_inicio), hm(r.hora_fim)]);
|
||||
byDow.set(r.dia_semana, arr);
|
||||
}
|
||||
} catch { /* tabela pode não existir ainda → cai no fallback */ }
|
||||
return { byDow, hasConfig };
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+26
-10
@@ -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 <img>/<video> (sem exigir header
|
||||
// de auth no browser), com suporte a Range para áudio/vídeo.
|
||||
// ── Schema de horários/feriados (idempotente) + config (dono/dentista) ─────
|
||||
// clinicas_horarios + feriados; usados pela agenda-bridge (Secretária) e pelas
|
||||
// telas de configuração do scoreodonto.
|
||||
ensureAgendaSchema(pool).catch((e) => console.error('[newwhats] schema agenda:', e?.message));
|
||||
app.use('/api/nw/agenda-config', createAgendaConfig(pool));
|
||||
|
||||
// ── Proxy de MÍDIA: /api/nw/media/* → motor ────────────────────────────────
|
||||
// A mídia/avatar vivem no motor (Wasabi ou local). O Wasabi é servido por
|
||||
// /api/storage/view/* (plugin UnifiedStorageProvider, que cobre Wasabi + fallback
|
||||
// local); a mídia local ANTIGA fica no /media estático. Tentamos o storage do
|
||||
// plugin primeiro e caímos no /media se der 404. Same-origin no <img>/<video>.
|
||||
app.use('/api/nw/media', async (req, res) => {
|
||||
if (req.method !== 'GET') { res.status(405).end(); return; }
|
||||
const cfg = getConfig();
|
||||
if (!cfg.motorUrl) { res.status(503).end(); return; }
|
||||
const rest = req.path.replace(/^\//, '');
|
||||
const url = `${cfg.motorUrl.replace(/\/$/, '')}/media/${rest}`;
|
||||
const base = cfg.motorUrl.replace(/\/$/, '');
|
||||
const headers = {};
|
||||
if (req.headers.range) headers.range = req.headers.range;
|
||||
const candidates = [`${base}/api/storage/view/${rest}`, `${base}/media/${rest}`];
|
||||
try {
|
||||
const headers = {};
|
||||
if (req.headers.range) headers.range = req.headers.range;
|
||||
const r = await fetch(url, { headers });
|
||||
let r = null;
|
||||
for (const url of candidates) {
|
||||
r = await fetch(url, { headers });
|
||||
if (r.status !== 404) break; // achou (ou erro que não é 404) → usa esse
|
||||
}
|
||||
res.status(r.status);
|
||||
for (const h of ['content-type', 'content-length', 'content-range', 'accept-ranges', 'cache-control', 'content-disposition']) {
|
||||
const v = r.headers.get(h);
|
||||
if (v) res.setHeader(h, v);
|
||||
}
|
||||
if (!res.getHeader('cache-control')) res.setHeader('cache-control', 'private, max-age=3600');
|
||||
// Mídia é imutável (path único por arquivo) → cache longo no browser.
|
||||
if (!res.getHeader('cache-control')) res.setHeader('cache-control', 'public, max-age=604800, immutable');
|
||||
if (r.body) Readable.fromWeb(r.body).pipe(res);
|
||||
else res.end();
|
||||
} catch (e) {
|
||||
@@ -160,6 +175,7 @@ export function registerNewwhats(app, pool, opts = {}) {
|
||||
// ── Operação ────────────────────────────────────────────────────────────────
|
||||
app.use('/nw', createRoutes(pool)); // Secretária do motor consulta a clínica
|
||||
app.use(createWebhookReceiver(pool)); // webhook motor → satélite
|
||||
app.get('/api/nw/accounts', createAccountsRoute(pool)); // seletor multi-conta da inbox (próprias + liberadas) — ANTES do catch-all
|
||||
app.use('/api/nw/v1', createRestProxy(pool)); // proxy do inbox → motor (express 5: use, não wildcard) + enforcement de ownership
|
||||
|
||||
console.log('[newwhats] integração registrada (/nw, /api/webhook/newwhats, /api/nw/v1/*, /api/nw/config)');
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { HorariosConfig } from './HorariosConfig.tsx';
|
||||
import { X, ChevronLeft, ChevronRight, Save, Mail, Calendar, Settings as GearIcon, CheckCircle2 } from 'lucide-react';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { Settings, Dentista } from '../types.ts';
|
||||
@@ -22,6 +23,7 @@ export const AgendaSettingsModal: React.FC<AgendaSettingsModalProps> = ({ isOpen
|
||||
});
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [connectedAccounts, setConnectedAccounts] = useState<any[]>([]);
|
||||
const [showHorarios, setShowHorarios] = useState(false);
|
||||
const toast = useToast();
|
||||
// Escopo: tudo (status Google, convites, settings) é da clínica/workspace ativa.
|
||||
const clinicaId = HybridBackend.getActiveWorkspace()?.id;
|
||||
@@ -84,6 +86,7 @@ export const AgendaSettingsModal: React.FC<AgendaSettingsModalProps> = ({ isOpen
|
||||
const adminAccount = connectedAccounts.find(a => a.owner_id && a.owner_id.includes('@'));
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 bg-black/60 z-[60] flex items-center justify-center backdrop-blur-md p-0">
|
||||
<div className="bg-white shadow-2xl w-full h-dvh md:w-[96vw] md:h-[98vh] rounded-none md:rounded-[2.5rem] flex flex-col overflow-hidden border border-gray-100 animate-in fade-in zoom-in-95 duration-300">
|
||||
|
||||
@@ -135,6 +138,15 @@ export const AgendaSettingsModal: React.FC<AgendaSettingsModalProps> = ({ isOpen
|
||||
/>
|
||||
<p className="text-[10px] text-gray-400 italic ml-1">* Este e-mail será usado para alertas de sistema e envio de comprovantes.</p>
|
||||
</div>
|
||||
|
||||
<button onClick={() => setShowHorarios(true)}
|
||||
className="w-full flex items-center justify-between bg-teal-50 hover:bg-teal-100 border-2 border-teal-100 rounded-2xl p-4 transition-colors group">
|
||||
<div className="text-left">
|
||||
<p className="text-sm font-black text-teal-800 uppercase tracking-tight">Horários de Funcionamento</p>
|
||||
<p className="text-[10px] font-bold text-teal-500 uppercase tracking-widest mt-0.5">Dias/horas da clínica · feriados · horários dos dentistas</p>
|
||||
</div>
|
||||
<span className="text-teal-600 font-black text-lg group-hover:translate-x-1 transition-transform">→</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -275,5 +287,7 @@ export const AgendaSettingsModal: React.FC<AgendaSettingsModalProps> = ({ isOpen
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
<HorariosConfig isOpen={showHorarios} onClose={() => setShowHorarios(false)} clinicaId={clinicaId} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,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 (
|
||||
<div className="space-y-2">
|
||||
{DIAS.map((nome, d) => {
|
||||
const fs = porDia(d);
|
||||
return (
|
||||
<div key={d} className="flex items-start gap-3 py-2 border-b border-gray-50">
|
||||
<div className="w-24 shrink-0 pt-2 text-xs font-black text-gray-700 uppercase tracking-tight">{nome}</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
{fs.length === 0 && <span className="text-[11px] text-gray-400 italic">Fechado</span>}
|
||||
{fs.map((f, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<input type="time" disabled={disabled} value={f.hora_inicio}
|
||||
onChange={(e) => { 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" />
|
||||
<span className="text-gray-400 text-xs font-bold">até</span>
|
||||
<input type="time" disabled={disabled} value={f.hora_fim}
|
||||
onChange={(e) => { 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 && <button onClick={() => setDia(d, fs.filter((_, j) => j !== i))} className="text-gray-300 hover:text-red-500 p-1"><Trash2 size={15} /></button>}
|
||||
</div>
|
||||
))}
|
||||
{!disabled && (
|
||||
<button onClick={() => setDia(d, [...fs, { dia_semana: d, hora_inicio: '08:00', hora_fim: '18:00' }])}
|
||||
className="text-[11px] font-black text-teal-600 hover:text-teal-800 uppercase tracking-wide flex items-center gap-1">
|
||||
<Plus size={13} /> {fs.length ? 'Outra faixa' : 'Abrir neste dia'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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<string | null>(null);
|
||||
const [ok, setOk] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Clínica
|
||||
const [clinFaixas, setClinFaixas] = useState<Faixa[]>([]);
|
||||
const [feriados, setFeriados] = useState<{ excecoes: any[]; nacionais: any[] }>({ excecoes: [], nacionais: [] });
|
||||
const [novo, setNovo] = useState({ data: '', nome: '', fecha: true });
|
||||
|
||||
// Dentistas
|
||||
const [dentistas, setDentistas] = useState<Dentista[]>([]);
|
||||
const [dentSel, setDentSel] = useState<string>('');
|
||||
const [dentFaixas, setDentFaixas] = useState<Faixa[]>([]);
|
||||
|
||||
const 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 (
|
||||
<div className="fixed inset-0 bg-black/60 z-[70] flex items-center justify-center backdrop-blur-md p-0">
|
||||
<div className="bg-white shadow-2xl w-full h-dvh md:w-[92vw] md:max-w-4xl md:h-[94vh] rounded-none md:rounded-[2rem] flex flex-col overflow-hidden border border-gray-100">
|
||||
{/* Header */}
|
||||
<div className="px-8 py-6 border-b border-gray-50 flex justify-between items-center bg-gradient-to-br from-gray-50 to-white">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 bg-teal-600 rounded-2xl shadow-lg shadow-teal-200"><Clock size={22} className="text-white" /></div>
|
||||
<div>
|
||||
<h2 className="text-lg font-black text-gray-900 uppercase tracking-tighter">Horários de Funcionamento</h2>
|
||||
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mt-0.5">A Secretária IA usa isto para atender</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-xl text-gray-400"><X size={22} /></button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 px-8 pt-4 border-b border-gray-50">
|
||||
{(['clinica', 'dentistas'] as const).map((t) => (
|
||||
<button key={t} onClick={() => setAba(t)}
|
||||
className={`px-4 py-2.5 text-xs font-black uppercase tracking-wide rounded-t-xl transition-colors ${aba === t ? 'bg-teal-600 text-white' : 'text-gray-400 hover:text-gray-600'}`}>
|
||||
{t === 'clinica' ? 'Clínica' : 'Dentistas'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{(erro || ok) && (
|
||||
<div className={`px-8 py-2 text-xs font-bold ${erro ? 'bg-red-50 text-red-600' : 'bg-green-50 text-green-600'}`}>{erro || ok}</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-8">
|
||||
{aba === 'clinica' ? (
|
||||
<div className="space-y-10">
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-black text-gray-800 uppercase tracking-tight flex items-center gap-2"><Clock size={16} className="text-teal-600" /> Dias e horários que a clínica funciona</h3>
|
||||
<button onClick={salvarClinica} disabled={saving} className="bg-teal-600 hover:bg-teal-700 text-white text-xs font-black uppercase tracking-wide px-4 py-2 rounded-xl flex items-center gap-2 disabled:opacity-50">
|
||||
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />} Salvar
|
||||
</button>
|
||||
</div>
|
||||
<GradeSemanal faixas={clinFaixas} onChange={setClinFaixas} />
|
||||
<p className="text-[11px] text-gray-400 italic mt-3">Sem horário configurado, assume-se seg–sex 08h–18h. Só o dono edita.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="text-sm font-black text-gray-800 uppercase tracking-tight flex items-center gap-2 mb-4"><CalendarOff size={16} className="text-teal-600" /> Feriados e fechamentos</h3>
|
||||
<div className="flex flex-wrap items-end gap-2 mb-4 bg-gray-50 p-4 rounded-2xl">
|
||||
<div><label className="text-[9px] font-black text-gray-400 uppercase block mb-1">Data</label>
|
||||
<input type="date" value={novo.data} onChange={(e) => setNovo({ ...novo, data: e.target.value })} className="bg-white border-2 border-transparent focus:border-teal-600 rounded-xl px-3 py-2 text-sm font-bold outline-none" /></div>
|
||||
<div className="flex-1 min-w-[160px]"><label className="text-[9px] font-black text-gray-400 uppercase block mb-1">Motivo</label>
|
||||
<input type="text" placeholder="Ex: Recesso, ponte" value={novo.nome} onChange={(e) => setNovo({ ...novo, nome: e.target.value })} className="w-full bg-white border-2 border-transparent focus:border-teal-600 rounded-xl px-3 py-2 text-sm font-bold outline-none" /></div>
|
||||
<button onClick={addFeriado} className="bg-gray-900 hover:bg-black text-white text-xs font-black uppercase px-4 py-2.5 rounded-xl flex items-center gap-1"><Plus size={14} /> Adicionar</button>
|
||||
</div>
|
||||
{feriados.excecoes.length > 0 && (
|
||||
<div className="space-y-1 mb-4">
|
||||
{feriados.excecoes.map((f) => (
|
||||
<div key={f.id} className="flex items-center justify-between bg-red-50 rounded-xl px-3 py-2">
|
||||
<span className="text-xs font-bold text-red-700">{f.data.split('-').reverse().join('/')} · {f.nome}</span>
|
||||
<button onClick={() => delFeriado(f.id)} className="text-red-300 hover:text-red-600"><Trash2 size={15} /></button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<details className="text-xs">
|
||||
<summary className="cursor-pointer font-black text-gray-400 uppercase tracking-wide">Feriados nacionais (automáticos)</summary>
|
||||
<div className="mt-2 grid grid-cols-2 gap-1">
|
||||
{feriados.nacionais.map((f) => <div key={f.data} className="text-[11px] text-gray-500">{f.data.split('-').reverse().join('/')} · {f.nome}</div>)}
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<User size={16} className="text-teal-600" />
|
||||
<select value={dentSel} onChange={(e) => setDentSel(e.target.value)} className="bg-gray-50 border-2 border-transparent focus:border-teal-600 rounded-xl px-3 py-2 text-sm font-black text-gray-800 outline-none">
|
||||
{dentistas.map((d) => <option key={d.id} value={d.id}>{d.nome}{d.eh_voce ? ' (você)' : ''}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<button onClick={salvarDentista} disabled={saving || !dentSel} className="bg-teal-600 hover:bg-teal-700 text-white text-xs font-black uppercase tracking-wide px-4 py-2 rounded-xl flex items-center gap-2 disabled:opacity-50">
|
||||
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />} Salvar
|
||||
</button>
|
||||
</div>
|
||||
{dentSel ? <GradeSemanal faixas={dentFaixas} onChange={setDentFaixas} /> : <p className="text-xs text-gray-400">Nenhum dentista.</p>}
|
||||
<p className="text-[11px] text-gray-400 italic">Sem horário próprio, o dentista atende sempre que a clínica está aberta. Cada dentista (ou o dono) edita os seus.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user