From 2d0396fcb10e6c3e358fabd4946ac928810a66b2 Mon Sep 17 00:00:00 2001 From: VPS 4 Builder Date: Wed, 8 Jul 2026 02:27:34 +0200 Subject: [PATCH] =?UTF-8?q?feat(timezone):=20configura=C3=A7=C3=A3o=20de?= =?UTF-8?q?=20fuso=20hor=C3=A1rio=20por=20workspace=20(Fase=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/server.js | 30 ++++++++++++++++++++++++++++ frontend/views/ConfiguracoesView.tsx | 19 ++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/backend/server.js b/backend/server.js index f4505ea..7c2e1ae 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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 diff --git a/frontend/views/ConfiguracoesView.tsx b/frontend/views/ConfiguracoesView.tsx index c48fef9..107553e 100644 --- a/frontend/views/ConfiguracoesView.tsx +++ b/frontend/views/ConfiguracoesView.tsx @@ -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 = { pessoal: 'Pessoal', consultorio: 'Consultório', clinica: 'Clínica' }; +const TZ_LABEL: Record = { + '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({}); const [allAreas, setAllAreas] = useState<{ slug: string; nome: string; cor: string }[]>([]); const [clinAreas, setClinAreas] = useState([]); + const [tzOpts, setTzOpts] = useState([]); 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 }> =
set('cidade', e.target.value.toUpperCase())} />
set('estado', e.target.value.toUpperCase())} />
set('cor', e.target.value)} />
+
+ + +

A Secretária e a agenda usam este fuso para o horário local desta unidade.

+