f084656712
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>
308 lines
14 KiB
TypeScript
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>
|
|
);
|
|
};
|