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:
@@ -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