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_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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }) => (
|
||||||
|
|||||||
@@ -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');
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user