From ca7eba45315bea137018bcfc5d6554cb49bd8866 Mon Sep 17 00:00:00 2001 From: VPS 4 Builder Date: Sat, 13 Jun 2026 15:04:25 +0200 Subject: [PATCH] =?UTF-8?q?feat(plugins):=20Salas=20como=20add-on=20por=20?= =?UTF-8?q?conta=20(pessoa-c=C3=AAntrico)=20+=20Visualizar=20como=20biom?= =?UTF-8?q?=C3=A9dico?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Superadmin / Visualizar como: - adiciona BIOMÉDICO(A) à lista de preview do superadmin - enterPreview ativa os plugins (RX/Salas/Profissionais) p/ teste; exitPreview restaura Locação de Salas — ativação POR USUÁRIO (independe de clínica): - backend: tabela usuario_plugins + GET/POST /api/me/plugins (authGuard, sem tenant) - sala continua dona=owner_usuario_id (pessoa) + salaID, clinica_id opcional - backend.ts: getMyPlugins/setMyPlugin/hydrateMyPlugins; login + App montam o hydrate - isViewAllowed: 'salas' liberado se plugin ativo na conta; catálogo 'plugins' p/ não-paciente - Sidebar: entrada ADD-ONS/PLUGINS p/ papéis operacionais ativarem - PluginsView: toggle de locacao-salas persiste no servidor - escopo: só locacao-salas é server-backed (RX/Profissionais seguem localStorage) Co-Authored-By: Claude Opus 4.8 --- backend/server.js | 35 +++++++++++++++++++++++ frontend/App.tsx | 14 +++++++++ frontend/components/Sidebar.tsx | 7 ++++- frontend/services/backend.ts | 50 +++++++++++++++++++++++++++++++++ frontend/views/PluginsView.tsx | 3 ++ 5 files changed, 108 insertions(+), 1 deletion(-) diff --git a/backend/server.js b/backend/server.js index a28f130..5cc3bdd 100644 --- a/backend/server.js +++ b/backend/server.js @@ -3988,6 +3988,15 @@ async function runMigrations() { `ALTER TABLE sala_reservas ADD COLUMN IF NOT EXISTS profissional_usuario_id TEXT`, `ALTER TABLE sala_reservas ADD COLUMN IF NOT EXISTS profissional_nome TEXT`, `CREATE INDEX IF NOT EXISTS idx_sala_reservas_clinica ON sala_reservas (clinica_id, inicio)`, + // Ativação de plugin POR USUÁRIO (add-on por conta) — independe de clínica. + // É a fonte da verdade do que cada pessoa habilitou (ex.: locacao-salas). + `CREATE TABLE IF NOT EXISTS usuario_plugins ( + usuario_id TEXT NOT NULL, + plugin_id TEXT NOT NULL, + ativo BOOLEAN DEFAULT true, + ativado_em TIMESTAMPTZ DEFAULT NOW(), + PRIMARY KEY (usuario_id, plugin_id) + )`, // ── PLUGIN: Marketplace de Profissionais ───────────────────────────── `ALTER TABLE usuarios ADD COLUMN IF NOT EXISTS cep TEXT`, `ALTER TABLE usuarios ADD COLUMN IF NOT EXISTS raio_atuacao_km INTEGER`, @@ -4117,6 +4126,32 @@ async function notificarUsuario(usuarioId, titulo, mensagem, tipo = 'info') { } catch (e) { console.error('[notificarUsuario]', e.message); } } +// ── Plugins POR USUÁRIO (add-on por conta) ────────────────────────────────── +// Fonte da verdade da ativação de plugins do usuário logado (ex.: locacao-salas). +// Sem tenantGuard de propósito: independe de o usuário ter clínica. +app.get('/api/me/plugins', authGuard, async (req, res) => { + try { + const { rows } = await pool.query( + 'SELECT plugin_id FROM usuario_plugins WHERE usuario_id = $1 AND ativo = true', + [req.authUser.userId]); + res.json({ plugins: rows.map(r => r.plugin_id) }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +app.post('/api/me/plugins', authGuard, async (req, res) => { + try { + const pluginId = String(req.body?.pluginId || '').trim(); + const ativo = req.body?.ativo !== false; + if (!pluginId) return res.status(400).json({ error: 'pluginId é obrigatório.' }); + await pool.query( + `INSERT INTO usuario_plugins (usuario_id, plugin_id, ativo, ativado_em) + VALUES ($1, $2, $3, NOW()) + ON CONFLICT (usuario_id, plugin_id) DO UPDATE SET ativo = EXCLUDED.ativo, ativado_em = NOW()`, + [req.authUser.userId, pluginId, ativo]); + res.json({ success: true, pluginId, ativo }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + // Minhas salas (como locador) app.get('/api/salas/minhas', authGuard, async (req, res) => { try { diff --git a/frontend/App.tsx b/frontend/App.tsx index 4724885..17ea641 100644 --- a/frontend/App.tsx +++ b/frontend/App.tsx @@ -251,6 +251,11 @@ const App: React.FC = () => { if (role === 'superadmin') return true; // Sync GLOBAL removido do app (substituído pelo backup por clínica em Configurações). if (view === 'sync') return false; + // Locação de Salas: capability PESSOAL (add-on por conta), liberada a qualquer + // usuário com o plugin ativo na conta — independe de ter clínica/cargo. + if (view === 'salas') return isPluginActive('locacao-salas'); + // Catálogo de add-ons: qualquer usuário (menos paciente) pode abrir p/ ativar. + if (view === 'plugins') return role !== 'paciente'; // Admin/donos têm acesso amplo, MAS as telas de superadmin são exclusivas do superadmin. if (role === 'admin' || role === 'donoclinica' || role === 'donoconsultorio') return !view.startsWith('superadmin'); @@ -366,6 +371,15 @@ const App: React.FC = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // Sincroniza os plugins ativados na conta (servidor → cache local) ao montar, + // para o menu de Salas refletir a ativação mesmo em outro dispositivo. + useEffect(() => { + if (HybridBackend.isAuthenticated() && !HybridBackend.isPreviewMode()) { + HybridBackend.hydrateMyPlugins().then(() => forcePwRerender(t => t + 1)); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const renderView = () => { switch (currentView) { case 'landing': return = ({ activeTab, setActiveTab }) => if (activePluginIds.includes('profissionais-marketplace') && currentRole !== 'superadmin' && currentRole !== 'paciente') { pluginMenuItems.push({ id: 'marketplace-profissionais', label: 'PROFISSIONAIS', icon: UserSearch, color: '#ea580c' }); } + // Catálogo de add-ons (por conta): qualquer usuário operacional pode abrir p/ ativar (ex.: Salas). + if (currentRole !== 'superadmin' && currentRole !== 'paciente') { + pluginMenuItems.push({ id: 'plugins', label: 'ADD-ONS / PLUGINS', icon: Puzzle, color: '#64748b' }); + } const menuItems = [ { id: 'superadmin-overview', label: 'VISÃO GERAL', icon: TrendingUp }, @@ -163,6 +167,7 @@ export const Sidebar: React.FC = ({ activeTab, setActiveTab }) => {[ { role: 'donoclinica', label: 'DONO DE CLÍNICA', icon: Building2 }, { role: 'dentista', label: 'DENTISTA', icon: ToothIcon }, + { role: 'biomedico', label: 'BIOMÉDICO(A)', icon: Sparkles }, { role: 'protetico', label: 'PROTÉTICO', icon: FlaskConical }, { role: 'paciente', label: 'PACIENTE', icon: User }, ].map(({ role, label, icon: Icon }) => ( diff --git a/frontend/services/backend.ts b/frontend/services/backend.ts index e279350..9b7d690 100644 --- a/frontend/services/backend.ts +++ b/frontend/services/backend.ts @@ -75,6 +75,9 @@ export const HybridBackend = { localStorage.setItem('SCOREODONTO_ACTIVE_WORKSPACE', JSON.stringify(data.workspaces[0])); } + // Hidrata os plugins ativados na conta do usuário (fonte da verdade no servidor). + await HybridBackend.hydrateMyPlugins(); + if (data.noWorkspace) { return { ok: true, noWorkspace: true, userRole: data.userRole }; } @@ -256,6 +259,12 @@ export const HybridBackend = { getPreviewRole: (): string | null => localStorage.getItem('SCOREODONTO_PREVIEW_ROLE'), enterPreview: (role: string) => { + // Na primeira entrada do preview, guarda os plugins reais do superadmin p/ restaurar no exit. + // (não sobrescreve o backup se já estiver previewando e só trocando de papel) + if (!localStorage.getItem('SCOREODONTO_PREVIEW_ROLE')) { + const prevPlugins = localStorage.getItem('SCOREODONTO_ACTIVE_PLUGINS'); + localStorage.setItem('SCOREODONTO_PREVIEW_PREV_PLUGINS', prevPlugins === null ? '__none__' : prevPlugins); + } const synthetic = { id: '00000000-0000-0000-0000-000000000000', // UUID inexistente → consultas retornam vazio nome: `VISUALIZAÇÃO: ${role.toUpperCase()}`, @@ -266,6 +275,8 @@ export const HybridBackend = { localStorage.setItem('SCOREODONTO_PREVIEW_ROLE', role); localStorage.setItem('SCOREODONTO_ACTIVE_WORKSPACE', JSON.stringify(synthetic)); localStorage.setItem('SCOREODONTO_WORKSPACES', JSON.stringify([synthetic])); + // Preview ativa os plugins para o superadmin ver/testar as telas (RX, Salas, Profissionais). + localStorage.setItem('SCOREODONTO_ACTIVE_PLUGINS', JSON.stringify(['rx-scoreodonto', 'locacao-salas', 'profissionais-marketplace'])); const path = role === 'paciente' ? '/tratamentos' : '/dashboard'; window.location.assign(path); }, @@ -274,9 +285,48 @@ export const HybridBackend = { localStorage.removeItem('SCOREODONTO_PREVIEW_ROLE'); localStorage.removeItem('SCOREODONTO_ACTIVE_WORKSPACE'); localStorage.removeItem('SCOREODONTO_WORKSPACES'); + // Restaura os plugins que o superadmin tinha antes do preview. + const prev = localStorage.getItem('SCOREODONTO_PREVIEW_PREV_PLUGINS'); + if (prev === null || prev === '__none__') localStorage.removeItem('SCOREODONTO_ACTIVE_PLUGINS'); + else localStorage.setItem('SCOREODONTO_ACTIVE_PLUGINS', prev); + localStorage.removeItem('SCOREODONTO_PREVIEW_PREV_PLUGINS'); window.location.assign('/superadmin/overview'); }, + // --- PLUGINS POR USUÁRIO (add-on por conta; fonte da verdade no servidor) --- + getMyPlugins: async (): Promise => { + try { + const res = await apiFetch(`${API_URL}/me/plugins`); + if (!res.ok) return []; + const d = await res.json(); + return Array.isArray(d?.plugins) ? d.plugins : []; + } catch { return []; } + }, + + setMyPlugin: async (pluginId: string, ativo: boolean): Promise => { + try { + const res = await apiFetch(`${API_URL}/me/plugins`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ pluginId, ativo }), + }); + return res.ok; + } catch { return false; } + }, + + // Reconcilia o cache local (SCOREODONTO_ACTIVE_PLUGINS) com o servidor. + // Escopo atual: só 'locacao-salas' é server-backed; demais plugins seguem locais. + // Não mexe durante o modo visualização (preview sobrescreve os plugins temporariamente). + hydrateMyPlugins: async (): Promise => { + if (localStorage.getItem('SCOREODONTO_PREVIEW_ROLE')) return; + const server = await HybridBackend.getMyPlugins(); + let local: string[] = []; + try { local = JSON.parse(localStorage.getItem('SCOREODONTO_ACTIVE_PLUGINS') || '[]'); } catch { local = []; } + const next = local.filter(p => p !== 'locacao-salas'); + if (server.includes('locacao-salas')) next.push('locacao-salas'); + localStorage.setItem('SCOREODONTO_ACTIVE_PLUGINS', JSON.stringify(next)); + }, + isAuthenticated: (): boolean => { return !!localStorage.getItem('SCOREODONTO_AUTH_TOKEN'); }, diff --git a/frontend/views/PluginsView.tsx b/frontend/views/PluginsView.tsx index 9801a3f..f8016be 100644 --- a/frontend/views/PluginsView.tsx +++ b/frontend/views/PluginsView.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react'; import { Puzzle, Radio, CheckCircle2, XCircle, Settings2, Zap, ChevronDown, ChevronUp, Eye, EyeOff, Save, X } from 'lucide-react'; import { PageHeader } from '../components/PageHeader.tsx'; import { useToast } from '../contexts/ToastContext.tsx'; +import { HybridBackend } from '../services/backend.ts'; import { getActivePlugins, togglePlugin, getPluginConfig, savePluginConfig } from './plugins/pluginRegistry.ts'; // ─── Plugin definitions ─────────────────────────────────────────────────────── @@ -303,6 +304,8 @@ export const PluginsView: React.FC = () => { const handleToggle = (plugin: PluginDef) => { const nowActive = togglePlugin(plugin.id); setActivePlugins(getActivePlugins()); + // 'locacao-salas' é ativado POR CONTA no servidor (fonte da verdade); persiste o estado. + if (plugin.id === 'locacao-salas') HybridBackend.setMyPlugin('locacao-salas', nowActive); if (nowActive) { toast.success(`${plugin.name.toUpperCase()} ATIVADO! RECARREGANDO...`); } else {