// Configuração de horários de funcionamento (clínica) + feriados/fechamentos e // horários dos dentistas. Consome /api/nw/agenda-config (o backend garante o // ownership: horário/feriado da clínica = dono; horário do dentista = ele ou dono). import React, { useState, useEffect, useCallback } from 'react'; import { X, Clock, CalendarOff, Plus, Trash2, Save, Loader2, User, Copy, AlertTriangle } from 'lucide-react'; const API = (import.meta as any).env?.VITE_API_URL || '/api'; const BASE = `${API}/nw/agenda-config`; const DIAS = ['Domingo', 'Segunda', 'Terça', 'Quarta', 'Quinta', 'Sexta', 'Sábado']; async function req(path: string, opts: RequestInit = {}) { const token = localStorage.getItem('SCOREODONTO_AUTH_TOKEN'); const res = await fetch(`${BASE}${path}`, { ...opts, headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(opts.headers || {}) }, }); const txt = await res.text(); const data = txt ? JSON.parse(txt) : {}; if (!res.ok) throw new Error(data.error || `Erro ${res.status}`); return data; } interface Faixa { dia_semana: number; hora_inicio: string; hora_fim: string; hora_inicio_flex?: string | null; hora_fim_flex?: string | null } interface Dentista { id: string; nome: string; usuario_id: string | null; eh_voce: boolean } // Editor de grade semanal (7 dias, cada um com 0..n faixas). Reusado p/ clínica e dentista. const GradeSemanal: React.FC<{ faixas: Faixa[]; onChange: (f: Faixa[]) => void; disabled?: boolean; withFlex?: boolean }> = ({ faixas, onChange, disabled, withFlex }) => { const porDia = (d: number) => faixas.filter((f) => f.dia_semana === d); const setDia = (d: number, novas: Faixa[]) => onChange([...faixas.filter((f) => f.dia_semana !== d), ...novas]); // Copia as faixas do dia `d` para TODOS os outros dias. const duplicarParaTodos = (d: number) => { const base = porDia(d).map((f) => ({ hora_inicio: f.hora_inicio, hora_fim: f.hora_fim })); onChange(DIAS.flatMap((_, x) => base.map((f) => ({ dia_semana: x, ...f })))); }; return (
{DIAS.map((nome, d) => { const fs = porDia(d); return (
{nome}
{fs.length === 0 && Fechado} {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 && ( )}
); })}
); }; export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; clinicaId?: string; initialTab?: 'clinica' | 'dentistas' | 'situacoes'; soDentista?: boolean }> = ({ isOpen, onClose, clinicaId, initialTab, soDentista }) => { const [aba, setAba] = useState<'clinica' | 'dentistas' | 'situacoes'>(initialTab || 'clinica'); useEffect(() => { if (isOpen && initialTab) setAba(initialTab); }, [isOpen, initialTab]); const [erro, setErro] = useState(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 [folgas, setFolgas] = useState([]); const [novaFolga, setNovaFolga] = useState({ data_inicio: '', data_fim: '', motivo: '' }); // Situações (regras que a Secretária IA segue) const [situacoes, setSituacoes] = 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`); let list: Dentista[] = d.dentistas || []; if (soDentista) { const meus = list.filter((x) => x.eh_voce); if (meus.length) list = meus; } // dentista só vê o próprio setDentistas(list); const first = list.find((x: Dentista) => x.eh_voce) || list[0]; if (first) setDentSel(first.id); } catch (e: any) { flash(setErro, e.message); } }, [clinicaId, soDentista]); const carregarSituacoes = useCallback(async () => { if (!clinicaId) return; try { const s = await req(`/clinica/${clinicaId}/situacoes`); setSituacoes((s.situacoes || []).map((r: any) => r.texto)); } catch (e: any) { flash(setErro, e.message); } }, [clinicaId]); useEffect(() => { if (isOpen) { carregarClinica(); carregarDentistas(); if (!soDentista) carregarSituacoes(); } }, [isOpen, carregarClinica, carregarDentistas, carregarSituacoes, soDentista]); const carregarFolgas = useCallback((id: string) => { req(`/dentista/${id}/folgas`).then((f) => setFolgas(f.folgas || [])).catch(() => setFolgas([])); }, []); useEffect(() => { if (!dentSel) { setDentFaixas([]); setFolgas([]); return; } req(`/dentista/${dentSel}/horarios`).then((h) => setDentFaixas((h.horarios || []).map((r: any) => ({ dia_semana: r.dia_semana, hora_inicio: r.hora_inicio, hora_fim: r.hora_fim, hora_inicio_flex: r.hora_inicio_flex, hora_fim_flex: r.hora_fim_flex }))) ).catch((e) => flash(setErro, e.message)); carregarFolgas(dentSel); }, [dentSel, carregarFolgas]); const addFolga = async () => { if (!novaFolga.data_inicio) { flash(setErro, 'Informe a data.'); return; } try { await req(`/dentista/${dentSel}/folgas`, { method: 'POST', body: JSON.stringify(novaFolga) }); setNovaFolga({ data_inicio: '', data_fim: '', motivo: '' }); carregarFolgas(dentSel); flash(setOk, 'Folga adicionada!'); } catch (e: any) { flash(setErro, e.message); } }; const delFolga = async (id: string) => { try { await req(`/dentista/${dentSel}/folgas/${id}`, { method: 'DELETE' }); carregarFolgas(dentSel); } catch (e: any) { flash(setErro, e.message); } }; const salvarClinica = async () => { setSaving(true); setErro(null); try { await req(`/clinica/${clinicaId}/horarios`, { method: 'PUT', body: JSON.stringify({ horarios: clinFaixas }) }); flash(setOk, 'Horário da clínica salvo!'); } catch (e: any) { flash(setErro, e.message); } finally { setSaving(false); } }; const salvarDentista = async () => { setSaving(true); setErro(null); try { await req(`/dentista/${dentSel}/horarios`, { method: 'PUT', body: JSON.stringify({ horarios: dentFaixas }) }); flash(setOk, 'Horário do dentista salvo!'); } catch (e: any) { flash(setErro, e.message); } finally { setSaving(false); } }; const addFeriado = async () => { if (!novo.data || !novo.nome) { flash(setErro, 'Informe data e nome.'); return; } try { await req(`/clinica/${clinicaId}/feriados`, { method: 'POST', body: JSON.stringify(novo) }); setNovo({ data: '', nome: '', fecha: true }); carregarClinica(); flash(setOk, 'Fechamento adicionado!'); } catch (e: any) { flash(setErro, e.message); } }; const delFeriado = async (id: string) => { try { await req(`/clinica/${clinicaId}/feriados/${id}`, { method: 'DELETE' }); carregarClinica(); } catch (e: any) { flash(setErro, e.message); } }; const salvarSituacoes = async () => { setSaving(true); setErro(null); try { await req(`/clinica/${clinicaId}/situacoes`, { method: 'PUT', body: JSON.stringify({ situacoes: situacoes.map((s) => s.trim()).filter(Boolean) }) }); flash(setOk, 'Situações salvas!'); } catch (e: any) { flash(setErro, e.message); } finally { setSaving(false); } }; if (!isOpen) return null; return (
{/* Header */}

Horários de Funcionamento

A Secretária IA usa isto para atender

{/* Tabs */}
{(soDentista ? (['dentistas'] as const) : (['clinica', 'dentistas', 'situacoes'] 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}
)}
) : aba === 'dentistas' ? (
{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 && (

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.

}
)}
) : (

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) => (