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:
@@ -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 {
|
||||
|
||||
@@ -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 <LandingPage
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
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';
|
||||
import { ToothIcon } from './ToothIcon.tsx';
|
||||
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') {
|
||||
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<SidebarProps> = ({ 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 }) => (
|
||||
|
||||
@@ -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<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 => {
|
||||
return !!localStorage.getItem('SCOREODONTO_AUTH_TOKEN');
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user