feat(timezone): configuração de fuso horário por workspace (Fase 1)
Fundação do multi-fuso: cada clínica define seu timezone (Configurações → Perfil da Unidade → Fuso horário). Migration clinicas.timezone (default America/Sao_Paulo); GET/PUT /api/clinicas/:id/timezone (whitelist de fusos do Brasil; PUT só dono/admin). Base para a Fase 2 (usar o fuso no cálculo de slots, no "agora" da Secretária e na exibição da agenda) — que corrige o -1h observado (IA confirmou 09:30, agenda mostrou 08:30) causado por satélite=UTC-3, motor=UTC e navegador=UTC-4 com horário naive. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1016,6 +1016,33 @@ app.put('/api/clinicas/:id/color', authGuard, async (req, res) => {
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// Fusos horários válidos do Brasil (whitelist — evita injeção de TZ inválido).
|
||||
const TIMEZONES_BR = [
|
||||
'America/Sao_Paulo', 'America/Campo_Grande', 'America/Cuiaba', 'America/Manaus',
|
||||
'America/Rio_Branco', 'America/Belem', 'America/Fortaleza', 'America/Recife',
|
||||
'America/Bahia', 'America/Boa_Vista', 'America/Porto_Velho', 'America/Maceio', 'America/Noronha',
|
||||
];
|
||||
app.get('/api/clinicas/:id/timezone', authGuard, async (req, res) => {
|
||||
try {
|
||||
const { rows } = await pool.query('SELECT timezone FROM clinicas WHERE id = $1', [req.params.id]);
|
||||
res.json({ timezone: rows[0]?.timezone || 'America/Sao_Paulo', opcoes: TIMEZONES_BR });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
app.put('/api/clinicas/:id/timezone', authGuard, async (req, res) => {
|
||||
const tz = String(req.body?.timezone || '');
|
||||
if (!TIMEZONES_BR.includes(tz)) return res.status(400).json({ error: 'Fuso horário inválido.' });
|
||||
// Só dono/admin da unidade altera o fuso.
|
||||
const { rows: _own } = await pool.query(
|
||||
`SELECT 1 FROM clinicas WHERE id=$1 AND owner_id=$2
|
||||
UNION SELECT 1 FROM vinculos WHERE clinica_id=$1 AND usuario_id=$2 AND role = ANY(ARRAY['donoclinica','donoconsultorio','admin']) LIMIT 1`,
|
||||
[req.params.id, req.authUser?.userId]);
|
||||
if (!_own.length) return res.status(403).json({ error: 'Apenas o dono/admin pode alterar o fuso horário.' });
|
||||
try {
|
||||
await pool.query('UPDATE clinicas SET timezone = $1 WHERE id = $2', [tz, req.params.id]);
|
||||
res.json({ success: true, timezone: tz });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// Renomear a unidade (clínica/consultório/laboratório). Só o dono (ou vínculo dono/admin).
|
||||
app.put('/api/clinicas/:id/nome', authGuard, async (req, res) => {
|
||||
const nome = (req.body?.nome_fantasia || '').trim();
|
||||
@@ -6175,6 +6202,9 @@ app.post('/api/pedidos-exame', authGuard, async (req, res) => {
|
||||
|
||||
async function runMigrations() {
|
||||
const migrations = [
|
||||
// Fuso horário por workspace (clínica) — usado p/ a Secretária e a agenda operarem
|
||||
// no horário local da unidade. Default = Brasília (comportamento atual).
|
||||
`ALTER TABLE clinicas ADD COLUMN IF NOT EXISTS timezone TEXT DEFAULT 'America/Sao_Paulo'`,
|
||||
// Comunicação interna: perfil de contato de cada NÃO-paciente (dentista/funcionário).
|
||||
// Números de WhatsApp múltiplos (cada um com descrição, situações e janelas de horário
|
||||
// permitido p/ a Secretária enviar msg — evita mensagem fora do expediente), endereço e
|
||||
|
||||
@@ -11,6 +11,15 @@ import { buscarCep } from '../services/viacep.ts';
|
||||
const FIELD_CLS = 'w-full border border-gray-200 rounded-xl px-3 py-2.5 text-sm font-medium text-gray-800 outline-none focus:border-teal-500 focus:ring-2 focus:ring-teal-100 transition-all';
|
||||
const LABEL_CLS = 'text-[10px] font-black text-gray-400 uppercase tracking-wider block mb-1';
|
||||
const TIPO_LABEL: Record<string, string> = { pessoal: 'Pessoal', consultorio: 'Consultório', clinica: 'Clínica' };
|
||||
const TZ_LABEL: Record<string, string> = {
|
||||
'America/Sao_Paulo': 'Brasília (UTC-3) — SP/RJ/MG/Sul/NE', 'America/Campo_Grande': 'Campo Grande/MS (UTC-4)',
|
||||
'America/Cuiaba': 'Cuiabá/MT (UTC-4)', 'America/Manaus': 'Manaus/AM (UTC-4)', 'America/Rio_Branco': 'Rio Branco/AC (UTC-5)',
|
||||
'America/Belem': 'Belém/PA (UTC-3)', 'America/Fortaleza': 'Fortaleza/CE (UTC-3)', 'America/Recife': 'Recife/PE (UTC-3)',
|
||||
'America/Bahia': 'Salvador/BA (UTC-3)', 'America/Boa_Vista': 'Boa Vista/RR (UTC-4)', 'America/Porto_Velho': 'Porto Velho/RO (UTC-4)',
|
||||
'America/Maceio': 'Maceió/AL (UTC-3)', 'America/Noronha': 'F. de Noronha (UTC-2)',
|
||||
};
|
||||
const tzApi = (path: string, opts: RequestInit = {}) =>
|
||||
fetch(`${(window as any).__API_URL__ || ''}${path}`, { ...opts, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('SCOREODONTO_AUTH_TOKEN')}` } });
|
||||
|
||||
// --- Card: Meu Perfil (Sistema) ---
|
||||
const MeuPerfilCard: React.FC<{ clinColor: string; role: string }> = ({ clinColor, role }) => {
|
||||
@@ -100,6 +109,7 @@ const UnidadePerfilCard: React.FC<{ workspaceId?: string; clinColor: string }> =
|
||||
const [f, setF] = useState<any>({});
|
||||
const [allAreas, setAllAreas] = useState<{ slug: string; nome: string; cor: string }[]>([]);
|
||||
const [clinAreas, setClinAreas] = useState<string[]>([]);
|
||||
const [tzOpts, setTzOpts] = useState<string[]>([]);
|
||||
useEffect(() => { HybridBackend.getAreas().then(setAllAreas); }, []);
|
||||
|
||||
const load = async () => {
|
||||
@@ -108,6 +118,7 @@ const UnidadePerfilCard: React.FC<{ workspaceId?: string; clinColor: string }> =
|
||||
try {
|
||||
const r = await HybridBackend.getClinicaProfile(workspaceId); if (r.success) setF(r.clinica || {});
|
||||
setClinAreas(await HybridBackend.getClinicaAreas(workspaceId));
|
||||
try { const tr = await tzApi(`/api/clinicas/${workspaceId}/timezone`); if (tr.ok) { const td = await tr.json(); setTzOpts(td.opcoes || []); setF((p: any) => ({ ...p, timezone: td.timezone })); } } catch { /* ignore */ }
|
||||
}
|
||||
catch { /* ignore */ } finally { setLoading(false); }
|
||||
};
|
||||
@@ -136,6 +147,7 @@ const UnidadePerfilCard: React.FC<{ workspaceId?: string; clinColor: string }> =
|
||||
});
|
||||
if (!r.success) { toast.error(r.message || 'ERRO AO SALVAR.'); return; }
|
||||
await HybridBackend.setClinicaAreas(workspaceId, clinAreas);
|
||||
if (f.timezone) { try { await tzApi(`/api/clinicas/${workspaceId}/timezone`, { method: 'PUT', body: JSON.stringify({ timezone: f.timezone }) }); } catch { /* ignore */ } }
|
||||
toast.success('DADOS DA UNIDADE ATUALIZADOS!'); setEditing(false);
|
||||
} catch { toast.error('ERRO AO SALVAR.'); } finally { setSaving(false); }
|
||||
};
|
||||
@@ -181,6 +193,13 @@ const UnidadePerfilCard: React.FC<{ workspaceId?: string; clinColor: string }> =
|
||||
<div><label className={LABEL_CLS}>Cidade</label><input className={`${FIELD_CLS} uppercase`} value={f.cidade || ''} onChange={e => set('cidade', e.target.value.toUpperCase())} /></div>
|
||||
<div><label className={LABEL_CLS}>Estado</label><input maxLength={2} className={`${FIELD_CLS} uppercase`} value={f.estado || ''} onChange={e => set('estado', e.target.value.toUpperCase())} /></div>
|
||||
<div><label className={LABEL_CLS}>Cor</label><input type="color" className="w-full h-10 border border-gray-200 rounded-xl px-1" value={f.cor || clinColor} onChange={e => set('cor', e.target.value)} /></div>
|
||||
<div className="md:col-span-2">
|
||||
<label className={LABEL_CLS}>Fuso horário da unidade</label>
|
||||
<select className={FIELD_CLS} value={f.timezone || 'America/Sao_Paulo'} onChange={e => set('timezone', e.target.value)}>
|
||||
{(tzOpts.length ? tzOpts : ['America/Sao_Paulo']).map(tz => <option key={tz} value={tz}>{TZ_LABEL[tz] || tz}</option>)}
|
||||
</select>
|
||||
<p className="text-[10px] text-gray-400 mt-1">A Secretária e a agenda usam este fuso para o horário local desta unidade.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL_CLS}>Áreas de atuação da unidade</label>
|
||||
|
||||
Reference in New Issue
Block a user