Files
scoreodonto.com/frontend/views/PluginsView.tsx
T
VPS 4 Builder f084656712 feat(agenda): auditoria/autoria, faltas, a-reagendar, presença + RBAC por cargo
Agenda (multi-secretária):
- audit_log genérico + autoria (created/updated/canceled_by) + soft-delete + version (lock otimista)
- status novo vocabulário + índice único PARCIAL (libera slot ao cancelar/faltar)
- endpoints: falta, remarcar, restaurar, historico, pendencias (a reagendar), faltas, familia
- presença: heartbeat (DB última visita + Dragonfly ao vivo, janela 45s)
- frontend: StackModal, AgendaDetailModal, AgendaPresence; lista a-reagendar; pacienteId no fluxo de criação; chips de família + badge de faltas no modal

RBAC por cargo: funcoes.permissoes/nivel; gate de menu por cargo (array vazio herda role); hierarquia só-gerencia-abaixo no backend

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 16:10:58 +02:00

308 lines
14 KiB
TypeScript

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 { getActivePlugins, togglePlugin, getPluginConfig, savePluginConfig } from './plugins/pluginRegistry.ts';
// ─── Plugin definitions ───────────────────────────────────────────────────────
interface ConfigField {
key: string;
label: string;
type: 'text' | 'password';
placeholder: string;
defaultValue: string;
}
interface PluginDef {
id: string;
name: string;
tagline: string;
description: string;
version: string;
author: string;
category: string;
color: string;
sidebarItems: Array<{ id: string; label: string }>;
features: string[];
configFields: ConfigField[];
}
const PLUGINS: PluginDef[] = [
{
id: 'rx-scoreodonto',
name: 'RX ScoreOdonto',
tagline: 'Radiografias & GTOs',
description: 'Integração completa com o sistema de radiografias. Gerencie imagens, GTOs e sincronize com o client Windows instalado nas clínicas.',
version: '',
author: 'ScoreOdonto',
category: 'DIAGNÓSTICO',
color: '#7c3aed',
sidebarItems: [{ id: 'rx-radiografias', label: 'RX / RADIOGRAFIAS' }],
features: [
'Visualizar radiografias por paciente',
'Upload de imagens (arquivo ou Base64)',
'Gerenciar GTOs e marcar como enviadas',
'Sincronização com client Windows',
'Criar fichas de pacientes remotamente',
],
configFields: [
{
key: 'apiToken',
label: 'TOKEN DE API',
type: 'password',
placeholder: 'api_...',
defaultValue: 'api_d5c8b4f4297ca579c7beceac6b4b1a274eb0df599331d06b',
},
{
key: 'apiUrl',
label: 'URL DO SERVIDOR RX',
type: 'text',
placeholder: 'https://dev.rx.scoreodonto.com',
defaultValue: 'https://dev.rx.scoreodonto.com',
},
],
},
];
// ─── Config Modal ─────────────────────────────────────────────────────────────
const ConfigModal: React.FC<{ plugin: PluginDef; onClose: () => void }> = ({ plugin, onClose }) => {
const toast = useToast();
const savedConfig = getPluginConfig(plugin.id);
const [values, setValues] = useState<Record<string, string>>(
Object.fromEntries(plugin.configFields.map(f => [f.key, savedConfig[f.key] ?? f.defaultValue]))
);
const [showPassword, setShowPassword] = useState<Record<string, boolean>>({});
const handleSave = () => {
savePluginConfig(plugin.id, values);
toast.success('CONFIGURAÇÕES SALVAS!');
onClose();
};
const inputCls = "w-full border border-gray-200 rounded-xl px-4 py-2.5 text-sm font-mono focus:outline-none focus:border-purple-400 focus:ring-2 focus:ring-purple-100 bg-white pr-10";
return (
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center backdrop-blur-sm p-4">
<div className="bg-white rounded-2xl w-full max-w-md shadow-2xl overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between" style={{ background: `linear-gradient(to right, ${plugin.color}10, transparent)` }}>
<div>
<h3 className="text-sm font-black text-gray-900 uppercase">CONFIGURAR {plugin.name}</h3>
<p className="text-[10px] font-bold uppercase tracking-widest" style={{ color: plugin.color }}>PARÂMETROS DO PLUGIN</p>
</div>
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-xl transition-colors"><X size={18} /></button>
</div>
<div className="p-6 space-y-4">
{plugin.configFields.map(field => (
<div key={field.key}>
<label className="text-[9px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">{field.label}</label>
<div className="relative">
<input
type={field.type === 'password' && !showPassword[field.key] ? 'password' : 'text'}
value={values[field.key] ?? ''}
onChange={e => setValues(v => ({ ...v, [field.key]: e.target.value }))}
className={inputCls}
placeholder={field.placeholder}
/>
{field.type === 'password' && (
<button
type="button"
onClick={() => setShowPassword(s => ({ ...s, [field.key]: !s[field.key] }))}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 transition-colors"
>
{showPassword[field.key] ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
)}
</div>
</div>
))}
</div>
<div className="px-6 pb-6 flex gap-3">
<button onClick={onClose} className="flex-1 py-3 border border-gray-200 rounded-xl text-xs font-black uppercase text-gray-500 hover:bg-gray-50 transition-colors">
CANCELAR
</button>
<button onClick={handleSave} className="flex-1 py-3 text-white rounded-xl text-xs font-black uppercase transition-colors flex items-center justify-center gap-2" style={{ backgroundColor: plugin.color }}>
<Save size={14} /> SALVAR
</button>
</div>
</div>
</div>
);
};
// ─── Plugin Card ──────────────────────────────────────────────────────────────
const PluginCard: React.FC<{ plugin: PluginDef; isActive: boolean; onToggle: () => void; onConfigure: () => void }> = ({ plugin, isActive, onToggle, onConfigure }) => {
const [expanded, setExpanded] = useState(false);
return (
<div className={`bg-white rounded-[2rem] border-2 transition-all duration-300 overflow-hidden shadow-sm ${isActive ? 'shadow-md' : ''}`}
style={{ borderColor: isActive ? plugin.color : 'transparent', borderWidth: isActive ? 2 : 1, borderStyle: 'solid' }}>
{/* Header strip */}
<div className="h-1.5 w-full" style={{ backgroundColor: plugin.color, opacity: isActive ? 1 : 0.2 }} />
<div className="p-7">
<div className="flex items-start justify-between gap-4 mb-5">
<div className="flex items-center gap-4">
<div className="w-14 h-14 rounded-2xl flex items-center justify-center shadow-lg flex-shrink-0" style={{ backgroundColor: plugin.color, color: '#fff', boxShadow: `0 10px 20px -8px ${plugin.color}66` }}>
<Radio size={26} />
</div>
<div>
<div className="flex items-center gap-2 flex-wrap">
<h3 className="text-lg font-black text-gray-900 uppercase tracking-tight">{plugin.name}</h3>
<span className="text-[8px] font-black px-2 py-0.5 rounded-full uppercase" style={{ backgroundColor: `${plugin.color}15`, color: plugin.color }}>
{plugin.category}
</span>
</div>
<p className="text-[10px] text-gray-400 font-bold uppercase tracking-wider mt-0.5">
{plugin.version ? `v${plugin.version} · ` : ''}por {plugin.author}
</p>
</div>
</div>
{/* Active badge */}
{isActive && (
<span className="text-[9px] font-black px-3 py-1.5 rounded-full uppercase tracking-widest flex items-center gap-1.5 flex-shrink-0" style={{ backgroundColor: `${plugin.color}15`, color: plugin.color }}>
<CheckCircle2 size={11} /> ATIVO
</span>
)}
</div>
<p className="text-sm text-gray-500 font-medium leading-relaxed mb-5">{plugin.description}</p>
{/* Features (collapsible) */}
<button
onClick={() => setExpanded(e => !e)}
className="flex items-center gap-2 text-[10px] font-black text-gray-400 uppercase tracking-widest hover:text-gray-600 transition-colors mb-3"
>
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
FUNCIONALIDADES ({plugin.features.length})
</button>
{expanded && (
<ul className="space-y-2 mb-5 pl-1">
{plugin.features.map((f, i) => (
<li key={i} className="flex items-start gap-2 text-xs text-gray-500 font-medium">
<span className="w-4 h-4 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5" style={{ backgroundColor: `${plugin.color}15`, color: plugin.color }}>
<CheckCircle2 size={10} />
</span>
{f}
</li>
))}
</ul>
)}
{/* Sidebar items info */}
{isActive && plugin.sidebarItems.length > 0 && (
<div className="mb-5 p-3 rounded-xl" style={{ backgroundColor: `${plugin.color}08` }}>
<p className="text-[9px] font-black uppercase tracking-widest mb-2" style={{ color: plugin.color }}>ADICIONADO AO MENU</p>
{plugin.sidebarItems.map(item => (
<div key={item.id} className="flex items-center gap-2 text-xs font-bold text-gray-600">
<Zap size={10} style={{ color: plugin.color }} />{item.label}
</div>
))}
</div>
)}
{/* Actions */}
<div className="flex gap-3 pt-2 border-t border-gray-50">
<button
onClick={onToggle}
className={`flex-1 py-3 rounded-2xl text-xs font-black uppercase tracking-wider transition-all flex items-center justify-center gap-2 ${isActive ? 'border-2 border-red-200 text-red-500 hover:bg-red-50' : 'text-white shadow-md hover:opacity-90'}`}
style={!isActive ? { backgroundColor: plugin.color, boxShadow: `0 8px 20px -8px ${plugin.color}88` } : {}}
>
{isActive ? <><XCircle size={15} /> DESATIVAR PLUGIN</> : <><CheckCircle2 size={15} /> ATIVAR PLUGIN</>}
</button>
{plugin.configFields.length > 0 && (
<button
onClick={onConfigure}
className="px-4 py-3 rounded-2xl border border-gray-200 text-gray-500 hover:bg-gray-50 hover:text-gray-700 transition-colors"
title="Configurar plugin"
>
<Settings2 size={16} />
</button>
)}
</div>
</div>
</div>
);
};
// ─── Main View ────────────────────────────────────────────────────────────────
export const PluginsView: React.FC = () => {
const toast = useToast();
const [activePlugins, setActivePlugins] = useState<string[]>(getActivePlugins);
const [configuringPlugin, setConfiguringPlugin] = useState<PluginDef | null>(null);
const [pluginVersions, setPluginVersions] = useState<Record<string, string>>({});
useEffect(() => {
// Busca versão real do servidor RX
const rxUrl = getPluginConfig('rx-scoreodonto').apiUrl || 'https://rx.scoreodonto.com';
fetch(`${rxUrl.replace(/\/$/, '')}/api/version`)
.then(r => r.ok ? r.json() : null)
.then(d => { if (d?.client) setPluginVersions(v => ({ ...v, 'rx-scoreodonto': d.client })); })
.catch(() => {});
}, []);
const handleToggle = (plugin: PluginDef) => {
const nowActive = togglePlugin(plugin.id);
setActivePlugins(getActivePlugins());
if (nowActive) {
toast.success(`${plugin.name.toUpperCase()} ATIVADO! RECARREGANDO...`);
} else {
toast.info(`${plugin.name.toUpperCase()} DESATIVADO. RECARREGANDO...`);
}
setTimeout(() => window.location.reload(), 1200);
};
return (
<div className="space-y-10 animate-in fade-in duration-500">
<PageHeader
title="ÁREA DE PLUGINS"
description="Estenda as funcionalidades do ScoreOdonto com integrações oficiais."
/>
{/* Summary bar */}
<div className="bg-white rounded-[2rem] p-6 border border-gray-100 flex items-center justify-between gap-4 shadow-sm">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-gray-50 rounded-2xl flex items-center justify-center">
<Puzzle size={22} className="text-gray-400" />
</div>
<div>
<p className="text-xs font-black text-gray-900 uppercase">{PLUGINS.length} PLUGIN{PLUGINS.length !== 1 ? 'S' : ''} DISPONÍVEL{PLUGINS.length !== 1 ? 'S' : ''}</p>
<p className="text-[10px] text-gray-400 font-medium">{activePlugins.length} ativo{activePlugins.length !== 1 ? 's' : ''}</p>
</div>
</div>
<div className="flex gap-2">
{PLUGINS.map(p => (
<div
key={p.id}
className="w-3 h-3 rounded-full transition-all"
style={{ backgroundColor: activePlugins.includes(p.id) ? p.color : '#e5e7eb' }}
title={p.name}
/>
))}
</div>
</div>
{/* Plugin grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{PLUGINS.map(plugin => (
<PluginCard
key={plugin.id}
plugin={{ ...plugin, version: pluginVersions[plugin.id] || plugin.version }}
isActive={activePlugins.includes(plugin.id)}
onToggle={() => handleToggle(plugin)}
onConfigure={() => setConfiguringPlugin(plugin)}
/>
))}
</div>
{/* Config modal */}
{configuringPlugin && (
<ConfigModal plugin={configuringPlugin} onClose={() => setConfiguringPlugin(null)} />
)}
</div>
);
};