Files
scoreodonto.com/frontend/components/HorariosConfig.tsx
T
VPS 4 Builder 773e8d4bf2 fix(agenda): fuso automático (America/Sao_Paulo) + dentista configura seus horários
- 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 <noreply@anthropic.com>
2026-07-06 04:44:38 +02:00

270 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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; 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<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 [folgas, setFolgas] = useState<any[]>([]);
const [novaFolga, setNovaFolga] = useState({ data_inicio: '', data_fim: '', motivo: '' });
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]);
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([]); 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));
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); }
};
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">
{(soDentista ? (['dentistas'] as const) : (['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 segsex 08h18h. 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>
{dentSel && (
<section className="pt-4 border-t border-gray-100">
<h3 className="text-sm font-black text-gray-800 uppercase tracking-tight flex items-center gap-2 mb-3"><CalendarOff size={16} className="text-teal-600" /> Folgas e férias (não atende nestas datas)</h3>
<div className="flex flex-wrap items-end gap-2 mb-3 bg-gray-50 p-4 rounded-2xl">
<div><label className="text-[9px] font-black text-gray-400 uppercase block mb-1">De</label>
<input type="date" value={novaFolga.data_inicio} onChange={(e) => 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" /></div>
<div><label className="text-[9px] font-black text-gray-400 uppercase block mb-1">Até (opcional)</label>
<input type="date" value={novaFolga.data_fim} onChange={(e) => 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" /></div>
<div className="flex-1 min-w-[140px]"><label className="text-[9px] font-black text-gray-400 uppercase block mb-1">Motivo</label>
<input type="text" placeholder="Ex: Férias, congresso" value={novaFolga.motivo} onChange={(e) => 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" /></div>
<button onClick={addFolga} 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>
{folgas.length > 0 ? (
<div className="space-y-1">
{folgas.map((f) => (
<div key={f.id} className="flex items-center justify-between bg-amber-50 rounded-xl px-3 py-2">
<span className="text-xs font-bold text-amber-700">
{f.data_inicio.split('-').reverse().join('/')}{f.data_fim !== f.data_inicio ? ` até ${f.data_fim.split('-').reverse().join('/')}` : ''}{f.motivo ? ` · ${f.motivo}` : ''}
</span>
<button onClick={() => delFolga(f.id)} className="text-amber-300 hover:text-amber-600"><Trash2 size={15} /></button>
</div>
))}
</div>
) : <p className="text-[11px] text-gray-400 italic">Nenhuma folga futura cadastrada.</p>}
</section>
)}
</div>
)}
</div>
</div>
</div>
);
};