feat(plugins): Salas como add-on por conta (pessoa-cêntrico) + Visualizar como biomédico

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 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-06-13 15:04:25 +02:00
parent 98728b4e57
commit ca7eba4531
5 changed files with 108 additions and 1 deletions
+35
View File
@@ -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_usuario_id TEXT`,
`ALTER TABLE sala_reservas ADD COLUMN IF NOT EXISTS profissional_nome 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)`, `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 ───────────────────────────── // ── PLUGIN: Marketplace de Profissionais ─────────────────────────────
`ALTER TABLE usuarios ADD COLUMN IF NOT EXISTS cep TEXT`, `ALTER TABLE usuarios ADD COLUMN IF NOT EXISTS cep TEXT`,
`ALTER TABLE usuarios ADD COLUMN IF NOT EXISTS raio_atuacao_km INTEGER`, `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); } } 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) // Minhas salas (como locador)
app.get('/api/salas/minhas', authGuard, async (req, res) => { app.get('/api/salas/minhas', authGuard, async (req, res) => {
try { try {
+14
View File
@@ -251,6 +251,11 @@ const App: React.FC = () => {
if (role === 'superadmin') return true; if (role === 'superadmin') return true;
// Sync GLOBAL removido do app (substituído pelo backup por clínica em Configurações). // Sync GLOBAL removido do app (substituído pelo backup por clínica em Configurações).
if (view === 'sync') return false; 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. // 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'); 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 // 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 = () => { const renderView = () => {
switch (currentView) { switch (currentView) {
case 'landing': return <LandingPage case 'landing': return <LandingPage
+6 -1
View File
@@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { import {
Users, Calendar as CalendarIcon, LayoutDashboard, Users, Calendar as CalendarIcon, LayoutDashboard,
DollarSign, RefreshCw, Activity, BookOpen, Award, Megaphone, LogOut, ClipboardList, Bell, Building2, User, BarChart3, Settings, FileText, Puzzle, ScanLine, Monitor, UsersRound, GraduationCap, UserSearch, Briefcase, TrendingUp, Eye, FlaskConical, DoorOpen DollarSign, RefreshCw, Activity, BookOpen, Award, Megaphone, LogOut, ClipboardList, Bell, Building2, User, BarChart3, Settings, FileText, Puzzle, ScanLine, Monitor, UsersRound, GraduationCap, UserSearch, Briefcase, TrendingUp, Eye, FlaskConical, DoorOpen, Sparkles
} from 'lucide-react'; } from 'lucide-react';
import { ToothIcon } from './ToothIcon.tsx'; import { ToothIcon } from './ToothIcon.tsx';
import { HybridBackend } from '../services/backend.ts'; import { HybridBackend } from '../services/backend.ts';
@@ -43,6 +43,10 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
if (activePluginIds.includes('profissionais-marketplace') && currentRole !== 'superadmin' && currentRole !== 'paciente') { if (activePluginIds.includes('profissionais-marketplace') && currentRole !== 'superadmin' && currentRole !== 'paciente') {
pluginMenuItems.push({ id: 'marketplace-profissionais', label: 'PROFISSIONAIS', icon: UserSearch, color: '#ea580c' }); 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 = [ const menuItems = [
{ id: 'superadmin-overview', label: 'VISÃO GERAL', icon: TrendingUp }, { id: 'superadmin-overview', label: 'VISÃO GERAL', icon: TrendingUp },
@@ -163,6 +167,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
{[ {[
{ role: 'donoclinica', label: 'DONO DE CLÍNICA', icon: Building2 }, { role: 'donoclinica', label: 'DONO DE CLÍNICA', icon: Building2 },
{ role: 'dentista', label: 'DENTISTA', icon: ToothIcon }, { role: 'dentista', label: 'DENTISTA', icon: ToothIcon },
{ role: 'biomedico', label: 'BIOMÉDICO(A)', icon: Sparkles },
{ role: 'protetico', label: 'PROTÉTICO', icon: FlaskConical }, { role: 'protetico', label: 'PROTÉTICO', icon: FlaskConical },
{ role: 'paciente', label: 'PACIENTE', icon: User }, { role: 'paciente', label: 'PACIENTE', icon: User },
].map(({ role, label, icon: Icon }) => ( ].map(({ role, label, icon: Icon }) => (
+50
View File
@@ -75,6 +75,9 @@ export const HybridBackend = {
localStorage.setItem('SCOREODONTO_ACTIVE_WORKSPACE', JSON.stringify(data.workspaces[0])); 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) { if (data.noWorkspace) {
return { ok: true, noWorkspace: true, userRole: data.userRole }; return { ok: true, noWorkspace: true, userRole: data.userRole };
} }
@@ -256,6 +259,12 @@ export const HybridBackend = {
getPreviewRole: (): string | null => localStorage.getItem('SCOREODONTO_PREVIEW_ROLE'), getPreviewRole: (): string | null => localStorage.getItem('SCOREODONTO_PREVIEW_ROLE'),
enterPreview: (role: string) => { 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 = { const synthetic = {
id: '00000000-0000-0000-0000-000000000000', // UUID inexistente → consultas retornam vazio id: '00000000-0000-0000-0000-000000000000', // UUID inexistente → consultas retornam vazio
nome: `VISUALIZAÇÃO: ${role.toUpperCase()}`, nome: `VISUALIZAÇÃO: ${role.toUpperCase()}`,
@@ -266,6 +275,8 @@ export const HybridBackend = {
localStorage.setItem('SCOREODONTO_PREVIEW_ROLE', role); localStorage.setItem('SCOREODONTO_PREVIEW_ROLE', role);
localStorage.setItem('SCOREODONTO_ACTIVE_WORKSPACE', JSON.stringify(synthetic)); localStorage.setItem('SCOREODONTO_ACTIVE_WORKSPACE', JSON.stringify(synthetic));
localStorage.setItem('SCOREODONTO_WORKSPACES', 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'; const path = role === 'paciente' ? '/tratamentos' : '/dashboard';
window.location.assign(path); window.location.assign(path);
}, },
@@ -274,9 +285,48 @@ export const HybridBackend = {
localStorage.removeItem('SCOREODONTO_PREVIEW_ROLE'); localStorage.removeItem('SCOREODONTO_PREVIEW_ROLE');
localStorage.removeItem('SCOREODONTO_ACTIVE_WORKSPACE'); localStorage.removeItem('SCOREODONTO_ACTIVE_WORKSPACE');
localStorage.removeItem('SCOREODONTO_WORKSPACES'); 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'); window.location.assign('/superadmin/overview');
}, },
// --- PLUGINS POR USUÁRIO (add-on por conta; fonte da verdade no servidor) ---
getMyPlugins: async (): Promise<string[]> => {
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<boolean> => {
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<void> => {
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 => { isAuthenticated: (): boolean => {
return !!localStorage.getItem('SCOREODONTO_AUTH_TOKEN'); return !!localStorage.getItem('SCOREODONTO_AUTH_TOKEN');
}, },
+3
View File
@@ -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 { Puzzle, Radio, CheckCircle2, XCircle, Settings2, Zap, ChevronDown, ChevronUp, Eye, EyeOff, Save, X } from 'lucide-react';
import { PageHeader } from '../components/PageHeader.tsx'; import { PageHeader } from '../components/PageHeader.tsx';
import { useToast } from '../contexts/ToastContext.tsx'; import { useToast } from '../contexts/ToastContext.tsx';
import { HybridBackend } from '../services/backend.ts';
import { getActivePlugins, togglePlugin, getPluginConfig, savePluginConfig } from './plugins/pluginRegistry.ts'; import { getActivePlugins, togglePlugin, getPluginConfig, savePluginConfig } from './plugins/pluginRegistry.ts';
// ─── Plugin definitions ─────────────────────────────────────────────────────── // ─── Plugin definitions ───────────────────────────────────────────────────────
@@ -303,6 +304,8 @@ export const PluginsView: React.FC = () => {
const handleToggle = (plugin: PluginDef) => { const handleToggle = (plugin: PluginDef) => {
const nowActive = togglePlugin(plugin.id); const nowActive = togglePlugin(plugin.id);
setActivePlugins(getActivePlugins()); 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) { if (nowActive) {
toast.success(`${plugin.name.toUpperCase()} ATIVADO! RECARREGANDO...`); toast.success(`${plugin.name.toUpperCase()} ATIVADO! RECARREGANDO...`);
} else { } else {