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:
VPS 4 Builder
2026-07-08 02:27:34 +02:00
parent c8443a146a
commit 2d0396fcb1
2 changed files with 49 additions and 0 deletions
+30
View File
@@ -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
+19
View File
@@ -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>