diff --git a/backend/server.js b/backend/server.js index e284501..3d52d82 100644 --- a/backend/server.js +++ b/backend/server.js @@ -198,15 +198,6 @@ function authGuard(req, res, next) { } } -// Biomédicos não gerenciam procedimentos/especialidades (são conceitos odontológicos). -// Camada de backend além do menu/permissão do front. Fail-open só em erro de DB. -async function blockBiomedico(req, res, next) { - try { - const { rows } = await pool.query('SELECT role FROM usuarios WHERE id = $1', [req.authUser?.userId]); - if (rows[0]?.role === 'biomedico') return res.status(403).json({ error: 'Biomédicos não podem gerenciar procedimentos e especialidades.' }); - } catch (e) { console.error('[blockBiomedico]', e.message); } - next(); -} // ============================================================================= // AUDITORIA GENÉRICA + PENDÊNCIAS DE AGENDA (Fase 1) @@ -358,6 +349,45 @@ async function pushGoogleEvent(action, ag) { } catch (e) { console.error('[google-push]', e.message); } } +// Catálogo padrão de Biomedicina Estética (especialidades + procedimentos com valor sugerido). +const BIOMED_ESPECIALIDADES = [ + 'BIOMEDICINA ESTÉTICA', 'HARMONIZAÇÃO OROFACIAL', 'ESTÉTICA FACIAL', 'ESTÉTICA CORPORAL', 'PROCEDIMENTOS INJETÁVEIS', +]; +const BIOMED_PROCEDIMENTOS = [ + { nome: 'TOXINA BOTULÍNICA (BOTOX)', esp: 'PROCEDIMENTOS INJETÁVEIS', valor: 800 }, + { nome: 'PREENCHIMENTO COM ÁCIDO HIALURÔNICO', esp: 'PROCEDIMENTOS INJETÁVEIS', valor: 1200 }, + { nome: 'BIOESTIMULADOR DE COLÁGENO', esp: 'ESTÉTICA FACIAL', valor: 1500 }, + { nome: 'SKINBOOSTER', esp: 'ESTÉTICA FACIAL', valor: 900 }, + { nome: 'PEELING QUÍMICO', esp: 'ESTÉTICA FACIAL', valor: 300 }, + { nome: 'MICROAGULHAMENTO', esp: 'ESTÉTICA FACIAL', valor: 400 }, + { nome: 'LIMPEZA DE PELE', esp: 'ESTÉTICA FACIAL', valor: 150 }, + { nome: 'FIOS DE SUSTENTAÇÃO (PDO)', esp: 'HARMONIZAÇÃO OROFACIAL', valor: 2000 }, + { nome: 'HARMONIZAÇÃO FACIAL', esp: 'HARMONIZAÇÃO OROFACIAL', valor: 2500 }, + { nome: 'ESCLEROTERAPIA (VASINHOS)', esp: 'ESTÉTICA CORPORAL', valor: 350 }, + { nome: 'INTRADERMOTERAPIA / MESOTERAPIA', esp: 'ESTÉTICA CORPORAL', valor: 400 }, + { nome: 'LIPÓLISE ENZIMÁTICA', esp: 'ESTÉTICA CORPORAL', valor: 500 }, +]; +// Popula o catálogo de biomedicina de uma clínica/consultório (idempotente: só se vazio). +async function seedBiomedicina(clinicaId) { + if (!clinicaId) return false; + const { rows } = await pool.query('SELECT 1 FROM especialidades WHERE clinica_id = $1 LIMIT 1', [clinicaId]); + if (rows.length) return false; + const espId = {}; + for (let i = 0; i < BIOMED_ESPECIALIDADES.length; i++) { + const id = `esp_${Date.now()}_${i}_${Math.random().toString(36).slice(2, 6)}`; + await pool.query('INSERT INTO especialidades (id, nome, clinica_id, ordem, ativo) VALUES ($1,$2,$3,$4,true)', [id, BIOMED_ESPECIALIDADES[i], clinicaId, i]); + espId[BIOMED_ESPECIALIDADES[i]] = id; + } + for (let i = 0; i < BIOMED_PROCEDIMENTOS.length; i++) { + const pr = BIOMED_PROCEDIMENTOS[i]; + const id = `proc_${Date.now()}_${i}_${Math.random().toString(36).slice(2, 6)}`; + await pool.query( + 'INSERT INTO procedimentos (id, nome, especialidadeid, especialidadenome, valorparticular, clinica_id, ordem, ativo) VALUES ($1,$2,$3,$4,$5,$6,$7,true)', + [id, pr.nome, espId[pr.esp] || null, pr.esp, pr.valor, clinicaId, i]); + } + return true; +} + // --- REGISTRO DE USUÁRIOS --- app.post('/api/register', async (req, res) => { const { nome, email, senha, role, workspaceNome, especialidade } = req.body; @@ -396,6 +426,8 @@ app.post('/api/register', async (req, res) => { [`v_${userId}_${wsId}`, userId, wsId, userRole] ); workspaces = [{ id: wsId, nome: wsNome, cor: '#2563eb', role: userRole }]; + // Biomédico já nasce com o catálogo de biomedicina estética no consultório dele. + if (userRole === 'biomedico') { try { await seedBiomedicina(wsId); } catch (e) { console.error('[seedBiomedicina]', e.message); } } } const token = jwt.sign({ userId, email: email.toLowerCase().trim() }, JWT_SECRET, { expiresIn: '12h' }); @@ -2608,7 +2640,7 @@ app.get('/api/especialidades', authGuard, async (req, res) => { } catch (err) { res.status(500).json({ error: err.message }); } }); -app.post('/api/especialidades', authGuard, blockBiomedico, async (req, res) => { +app.post('/api/especialidades', authGuard, async (req, res) => { try { const { rows: userRows } = await pool.query('SELECT role FROM usuarios WHERE id = $1', [req.authUser.userId]); const isSuperAdmin = userRows[0]?.role === 'superadmin'; @@ -2635,7 +2667,7 @@ app.put('/api/especialidades/reorder', async (req, res) => { } }); -app.put('/api/especialidades/:id', authGuard, blockBiomedico, async (req, res) => { +app.put('/api/especialidades/:id', async (req, res) => { try { const upd = buildUpdate('especialidades', req.body, 'id', req.params.id); await pool.query(upd.text, upd.values); @@ -2643,7 +2675,7 @@ app.put('/api/especialidades/:id', authGuard, blockBiomedico, async (req, res) = } catch (err) { res.status(500).json({ error: err.message }); } }); -app.delete('/api/especialidades/:id', tenantGuard, blockBiomedico, async (req, res) => { +app.delete('/api/especialidades/:id', tenantGuard, async (req, res) => { try { const clinicaId = req.clinicaId; if (!clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); @@ -2724,7 +2756,7 @@ app.get('/api/procedimentos', authGuard, async (req, res) => { } catch (err) { res.status(500).json({ error: err.message }); } }); -app.post('/api/procedimentos', authGuard, blockBiomedico, async (req, res) => { +app.post('/api/procedimentos', authGuard, async (req, res) => { try { const { rows: userRows } = await pool.query('SELECT role FROM usuarios WHERE id = $1', [req.authUser.userId]); const isSuperAdmin = userRows[0]?.role === 'superadmin'; @@ -2782,7 +2814,7 @@ app.put('/api/procedimentos/reorder', async (req, res) => { } }); -app.put('/api/procedimentos/:id', authGuard, blockBiomedico, async (req, res) => { +app.put('/api/procedimentos/:id', async (req, res) => { try { const { valoresPlanos, ...proc } = req.body; const updateData = { ...proc, exige_face: proc.exige_face ? 1 : 0 }; @@ -2804,7 +2836,7 @@ app.put('/api/procedimentos/:id', authGuard, blockBiomedico, async (req, res) => } }); -app.delete('/api/procedimentos/:id', tenantGuard, blockBiomedico, async (req, res) => { +app.delete('/api/procedimentos/:id', tenantGuard, async (req, res) => { try { const clinicaId = req.clinicaId; if (!clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); @@ -4023,6 +4055,8 @@ async function runMigrations() { `ALTER TABLE usuarios ADD COLUMN IF NOT EXISTS geo geography(Point,4326)`, `ALTER TABLE usuarios ADD COLUMN IF NOT EXISTS logradouro TEXT`, `ALTER TABLE usuarios ADD COLUMN IF NOT EXISTS numero TEXT`, + `ALTER TABLE especialidades ADD COLUMN IF NOT EXISTS ativo BOOLEAN DEFAULT true`, + `ALTER TABLE procedimentos ADD COLUMN IF NOT EXISTS ativo BOOLEAN DEFAULT true`, `ALTER TABLE clinicas ADD COLUMN IF NOT EXISTS geo geography(Point,4326)`, `CREATE INDEX IF NOT EXISTS idx_salas_geo ON salas USING GIST (geo)`, `CREATE INDEX IF NOT EXISTS idx_usuarios_geo ON usuarios USING GIST (geo)`, @@ -4182,6 +4216,26 @@ app.post('/api/me/plugins', authGuard, async (req, res) => { } catch (err) { res.status(500).json({ error: err.message }); } }); +// Semeia o catálogo de biomedicina estética na clínica/consultório do usuário (idempotente). +// Usado no 1º acesso do biomédico ao catálogo (cobre contas criadas antes do auto-seed). +app.post('/api/me/seed-biomedicina', authGuard, async (req, res) => { + try { + const clinicaId = String(req.body?.clinicaId || req.query.clinicaId || '').trim(); + if (!clinicaId) return res.status(400).json({ error: 'clinicaId é obrigatório.' }); + // só BIOMÉDICO semeia (evita poluir clínica odonto vazia com catálogo de biomedicina) + const { rows: u } = await pool.query('SELECT role FROM usuarios WHERE id = $1', [req.authUser.userId]); + if (u[0]?.role !== 'biomedico') return res.status(403).json({ error: 'Apenas biomédicos têm o catálogo de biomedicina.' }); + // e precisa ser dono/vinculado da unidade + const { rows } = 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 LIMIT 1`, + [clinicaId, req.authUser.userId]); + if (!rows.length) return res.status(403).json({ error: 'Sem acesso a esta unidade.' }); + const seeded = await seedBiomedicina(clinicaId); + res.json({ success: true, seeded }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + // ── Geocoding de CEP: cache + rotação de provedores grátis ────────────────── // Economia: cada CEP é geocodado UMA vez (cache cep_geo). Rotação robusta p/ o // egress deste servidor: AwesomeAPI → Nominatim(CEP) → ViaCEP+Nominatim(endereço). diff --git a/frontend/App.tsx b/frontend/App.tsx index 48cb5a0..e932db3 100644 --- a/frontend/App.tsx +++ b/frontend/App.tsx @@ -272,7 +272,7 @@ const App: React.FC = () => { const permissions: Record = { paciente: ['tratamentos', 'notificacoes', 'configuracoes'], dentista: ['dashboard', 'agenda', 'ortodontia', 'tratamentos', 'lancar-gto', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'rx-radiografias', 'salas', 'marketplace-profissionais', 'candidatura-tutor', 'tutoria-painel', 'diretorio-profissionais'], - biomedico: ['dashboard', 'agenda', 'tratamentos', 'lancar-gto', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'salas', 'marketplace-profissionais', 'diretorio-profissionais'], + biomedico: ['dashboard', 'agenda', 'tratamentos', 'lancar-gto', 'especialidades', 'procedimentos', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'salas', 'marketplace-profissionais', 'diretorio-profissionais'], protetico: ['dashboard', 'agenda', 'tratamentos', 'lancar-gto', 'notificacoes', 'clinicas', 'configuracoes', 'plugins', 'salas', 'marketplace-profissionais', 'diretorio-profissionais'], funcionario: ['dashboard', 'leads', 'pacientes', 'agenda', 'financeiro', 'tratamentos', 'lancar-gto', 'relatorios', 'notificacoes', 'clinicas', 'configuracoes', 'contratos', 'plugins', 'rx-radiografias', 'salas', 'marketplace-profissionais', 'candidatura-tutor', 'diretorio-profissionais'], }; diff --git a/frontend/components/Sidebar.tsx b/frontend/components/Sidebar.tsx index 958c2b5..9524c01 100644 --- a/frontend/components/Sidebar.tsx +++ b/frontend/components/Sidebar.tsx @@ -107,8 +107,9 @@ export const Sidebar: React.FC = ({ activeTab, setActiveTab }) => if (currentRole === 'paciente') return ['tratamentos', 'notificacoes', 'configuracoes'].includes(item.id); // Dentista: clínico odonto completo (ortodontia, tutoria orto). if (currentRole === 'dentista') return ['dashboard', 'tratamentos', 'agenda', 'ortodontia', 'notificacoes', 'clinicas', 'configuracoes', 'candidatura-tutor', 'diretorio-profissionais'].includes(item.id); - // Biomédico(a): SEM itens odontológicos (ortodontia, RX, tutoria orto). - if (currentRole === 'biomedico') return ['dashboard', 'agenda', 'tratamentos', 'notificacoes', 'clinicas', 'configuracoes', 'diretorio-profissionais'].includes(item.id); + // Biomédico(a): seu próprio catálogo (especialidades/procedimentos de biomedicina); + // SEM itens odontológicos (ortodontia, RX, tutoria orto). + if (currentRole === 'biomedico') return ['dashboard', 'agenda', 'tratamentos', 'especialidades', 'procedimentos', 'notificacoes', 'clinicas', 'configuracoes', 'diretorio-profissionais'].includes(item.id); // Protético: laboratório — agenda própria + GTO; sem ortodontia/RX/pacientes clínicos. if (currentRole === 'protetico') return ['dashboard', 'agenda', 'tratamentos', 'lancar-gto', 'notificacoes', 'clinicas', 'configuracoes', 'diretorio-profissionais'].includes(item.id); if (currentRole === 'funcionario') return ['dashboard', 'leads', 'pacientes', 'tratamentos', 'lancar-gto', 'agenda', 'financeiro', 'relatorios', 'notificacoes', 'clinicas', 'configuracoes', 'contratos', 'diretorio-profissionais'].includes(item.id); diff --git a/frontend/services/backend.ts b/frontend/services/backend.ts index 0c0f0ed..b2cf929 100644 --- a/frontend/services/backend.ts +++ b/frontend/services/backend.ts @@ -725,6 +725,20 @@ export const HybridBackend = { return await res.json(); }, + // Semeia o catálogo de biomedicina estética no workspace ativo (idempotente). + seedBiomedicina: async (): Promise => { + const clinicaId = HybridBackend.getActiveWorkspace()?.id || ''; + if (!clinicaId) return false; + try { + const res = await apiFetch(`${API_URL}/me/seed-biomedicina`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ clinicaId }), + }); + const d = await res.json(); + return !!d?.seeded; + } catch { return false; } + }, + getProcedimentos: async (): Promise => { const clinicaId = HybridBackend.getActiveWorkspace()?.id || ''; const res = await apiFetch(`${API_URL}/procedimentos${clinicaId ? `?clinicaId=${clinicaId}` : ''}`); diff --git a/frontend/types.ts b/frontend/types.ts index 98debbf..3062418 100644 --- a/frontend/types.ts +++ b/frontend/types.ts @@ -226,6 +226,7 @@ export interface Especialidade { nome: string; descricao: string; ordem: number; + ativo?: boolean; } export interface ProcedimentoValorPlano { @@ -252,6 +253,7 @@ export interface Procedimento { tipo_regiao: 'DENTE' | 'ARCO' | 'GERAL'; exige_face: boolean; valoresPlanos?: ProcedimentoValorPlano[]; + ativo?: boolean; } export interface Lead { diff --git a/frontend/views/AdminViews.tsx b/frontend/views/AdminViews.tsx index 503e27e..1e1fe09 100644 --- a/frontend/views/AdminViews.tsx +++ b/frontend/views/AdminViews.tsx @@ -1,5 +1,5 @@ -import React, { useState, useEffect } from 'react'; -import { Plus, Edit, Trash2, X, FileText, Stethoscope, Award, BookOpen, RefreshCw, Database, Loader2, Mail, Phone, ClipboardList, GripVertical, Share2, Check, Clock, ArrowRight, Activity, AlertTriangle, Users, Calendar, Banknote, UserPlus, Unlink, Copy, KeyRound } from 'lucide-react'; +import React, { useState, useEffect, useRef } from 'react'; +import { Plus, Edit, Trash2, X, FileText, Stethoscope, Award, BookOpen, RefreshCw, Database, Loader2, Mail, Phone, ClipboardList, GripVertical, Share2, Check, Clock, ArrowRight, Activity, AlertTriangle, Users, Calendar, Banknote, UserPlus, Unlink, Copy, KeyRound, ToggleLeft, ToggleRight } from 'lucide-react'; import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd'; import { HybridBackend } from '../services/backend.ts'; import { Dentista, Especialidade, Plano, Procedimento, ProcedimentoValorPlano, DentistaHorario } from '../types.ts'; @@ -338,6 +338,16 @@ export const EspecialidadesView: React.FC = () => { const [isModalOpen, setIsModalOpen] = useState(false); const [editingEsp, setEditingEsp] = useState | null>(null); const toast = useToast(); + const seedTried = useRef(false); + + // Biomédico com catálogo vazio: semeia biomedicina estética no 1º acesso (idempotente). + useEffect(() => { + if (seedTried.current || isLoading || !especialidades) return; + if (HybridBackend.getCurrentRole() === 'biomedico' && especialidades.length === 0) { + seedTried.current = true; + HybridBackend.seedBiomedicina().then(seeded => { if (seeded) refresh(); }); + } + }, [isLoading, especialidades, refresh]); useEffect(() => { if (!isModalOpen) return; @@ -378,6 +388,13 @@ export const EspecialidadesView: React.FC = () => { } } + const handleToggleAtivo = async (esp: Especialidade) => { + const novo = esp.ativo === false; + await HybridBackend.updateEspecialidade({ ...esp, ativo: novo }); + toast.success(novo ? "Especialidade ativada." : "Especialidade desativada."); + refresh(); + } + const onDragEnd = async (result: any) => { if (!result.destination || !especialidades) return; @@ -426,15 +443,16 @@ export const EspecialidadesView: React.FC = () => { - {esp.nome} + {esp.nome}{esp.ativo === false && Inativa} {esp.descricao}
+
@@ -563,6 +581,13 @@ export const ProcedimentosView: React.FC = () => { } }; + const handleToggleAtivo = async (proc: Procedimento) => { + const novo = proc.ativo === false; + await HybridBackend.updateProcedimento({ ...proc, ativo: novo }); + toast.success(novo ? "Procedimento ativado." : "Procedimento desativado."); + refresh(); + }; + const handleAddPlanValue = () => setPlanValues(prev => [...prev, { planoId: '', valor: 0, codigoPlano: '' }]); const handleRemovePlanValue = (index: number) => setPlanValues(prev => prev.filter((_, i) => i !== index)); const handlePlanValueChange = (index: number, field: 'planoId' | 'valor' | 'codigoPlano', value: string) => { @@ -628,12 +653,12 @@ export const ProcedimentosView: React.FC = () => { - {proc.nome} + {proc.nome}{proc.ativo === false && Inativo}
TUSS: {proc.codigo || '---'} @@ -645,6 +670,7 @@ export const ProcedimentosView: React.FC = () => { R$ {Number(proc.valorParticular).toFixed(2)}
+