Files
scoreodonto.com/frontend/views/PluginsView.tsx
T
VPS 4 Builder 13af039555 feat(newwhats): pareamento por TOKEN — cole 1 token e auto-configura
- Backend POST /api/nw/config/token (superadmin): decodifica nwpair_<base64url({u,k,s})>,
  VALIDA no motor (/api/ext/v1/sessions) e salva URL+chave+webhook. Token inválido/
  sem conexão é rejeitado antes de salvar.
- Frontend: campo 'Token de pareamento' como caminho principal (textarea + Aplicar);
  estado da conexão; clínica no dropdown (local); campos manuais em 'configuração
  manual' (avançado).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 19:05:13 +02:00

558 lines
28 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 { HybridBackend } from '../services/backend.ts';
import { getActivePlugins, togglePlugin, getPluginConfig, savePluginConfig, ALWAYS_ON_PLUGINS } 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: 'newwhats',
name: 'NewWhats — WhatsApp & Secretária IA',
tagline: 'Inbox WhatsApp + Atendente virtual',
description: 'Integração com o motor NewWhats: Inbox de WhatsApp em tempo real e Secretária IA. Inbox e Secretária ficam disponíveis para todos os usuários; esta configuração é exclusiva do super-admin.',
version: '1.0.0',
author: 'ScoreOdonto',
category: 'COMUNICAÇÃO',
color: '#16a34a',
sidebarItems: [
{ id: 'wa-inbox', label: 'WHATSAPP' },
{ id: 'wa-secretaria', label: 'SECRETÁRIA IA' },
],
features: [
'Inbox WhatsApp (conversas, mensagens, envio)',
'Secretária IA (atendente virtual do motor)',
'Base de conhecimento da clínica sincronizada com o motor',
],
configFields: [
{ key: 'NEWWHATS_URL', label: 'URL DO MOTOR', type: 'text', placeholder: 'https://newwhats.clube67.com', defaultValue: '' },
{ key: 'NEWWHATS_INTEGRATION_KEY', label: 'CHAVE DE INTEGRAÇÃO', type: 'password', placeholder: 'nw_...', defaultValue: '' },
{ key: 'NEWWHATS_WEBHOOK_SECRET', label: 'SEGREDO DO WEBHOOK', type: 'password', placeholder: '(HMAC)', defaultValue: '' },
{ key: 'NEWWHATS_CLINICA_ID', label: 'CLÍNICA (ID)', type: 'text', placeholder: 'c_...', defaultValue: '' },
],
},
{
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',
},
],
},
{
id: 'locacao-salas',
name: 'Locação de Salas',
tagline: 'Marketplace de salas',
description: 'Disponibilize salas clínicas, de consultório ou independentes para locação por hora, turno, diária, semana ou mês — e reserve salas equipadas de outros profissionais com anti-conflito de agenda.',
version: '1.0',
author: 'ScoreOdonto',
category: 'MARKETPLACE',
color: '#0d9488',
sidebarItems: [{ id: 'salas', label: 'LOCAÇÃO DE SALAS' }],
features: [
'Cadastrar salas com equipamentos, endereço e valores',
'Modelos de locação: hora, turno, diária, semanal e mensal',
'Marketplace com busca por tipo, cidade e estado',
'Reservas com confirmação do locador',
'Anti-conflito: bloqueia choque com a agenda global do profissional',
],
configFields: [],
},
{
id: 'profissionais-marketplace',
name: 'Profissionais',
tagline: 'Marketplace de profissionais',
description: 'Encontre e contrate dentistas, biomédicos(as) e protéticos por especialidade, cidade, CEP e raio de atuação. Profissionais divulgam o perfil e recebem propostas das clínicas e consultórios.',
version: '1.0',
author: 'ScoreOdonto',
category: 'MARKETPLACE',
color: '#0d9488',
sidebarItems: [{ id: 'marketplace-profissionais', label: 'PROFISSIONAIS' }],
features: [
'Busca por especialidade, cidade, estado e região (CEP)',
'Perfil profissional com raio de atuação e apresentação',
'Inclui dentistas, biomédicos(as) e protéticos',
'Propostas de contratação com aceite/recusa',
'Notificações automáticas para as duas partes',
],
configFields: [],
},
];
// ─── 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 ──────────────────────────────────────────────────────────────
// ─── Config dedicada do NewWhats (dropdown de clínicas + backend + testar) ──────
const NW_API = (import.meta as any).env?.VITE_API_URL || '/api';
const nwAuthHeaders = () => {
const t = localStorage.getItem('SCOREODONTO_AUTH_TOKEN');
return { 'Content-Type': 'application/json', ...(t ? { Authorization: `Bearer ${t}` } : {}) };
};
const NewwhatsConfigModal: React.FC<{ plugin: PluginDef; onClose: () => void }> = ({ plugin, onClose }) => {
const toast = useToast();
const [motorUrl, setMotorUrl] = useState('');
const [clinicaId, setClinicaId] = useState('');
const [integrationKey, setIntegrationKey] = useState('');
const [webhookSecret, setWebhookSecret] = useState('');
const [hasKey, setHasKey] = useState(false);
const [hasSecret, setHasSecret] = useState(false);
const [clinics, setClinics] = useState<Array<{ id: string; nome_fantasia: string }>>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [test, setTest] = useState<{ ok: boolean; msg: string } | null>(null);
const [token, setToken] = useState('');
const [applying, setApplying] = useState(false);
const [advanced, setAdvanced] = useState(false);
const loadCfg = async () => {
try {
const cfgR = await fetch(`${NW_API}/nw/config`, { headers: nwAuthHeaders() });
if (cfgR.ok) {
const c = await cfgR.json();
setMotorUrl(c.motorUrl || ''); setClinicaId(c.clinicaId || '');
setHasKey(!!c.hasIntegrationKey); setHasSecret(!!c.hasWebhookSecret);
}
} catch { /* noop */ }
};
useEffect(() => {
(async () => {
try {
const clR = await fetch(`${NW_API}/nw/clinicas`, { headers: nwAuthHeaders() });
if (clR.ok) setClinics(await clR.json());
await loadCfg();
} catch { /* noop */ }
finally { setLoading(false); }
})();
}, []);
const applyToken = async () => {
if (!token.trim()) return;
setApplying(true); setTest(null);
try {
const r = await fetch(`${NW_API}/nw/config/token`, { method: 'POST', headers: nwAuthHeaders(), body: JSON.stringify({ token: token.trim() }) });
const j = await r.json();
if (!r.ok) throw new Error(j.error || 'Falha ao aplicar token');
await loadCfg();
setToken('');
setTest({ ok: true, msg: 'Token aplicado — ' + (j.detail || 'configurado') });
toast.success('TOKEN APLICADO! Conexão configurada.');
} catch (e: any) { setTest({ ok: false, msg: String(e.message).slice(0, 120) }); }
finally { setApplying(false); }
};
const handleSave = async () => {
setSaving(true);
try {
const body: any = { motorUrl, clinicaId };
if (integrationKey) body.integrationKey = integrationKey;
if (webhookSecret) body.webhookSecret = webhookSecret;
const r = await fetch(`${NW_API}/nw/config`, { method: 'PUT', headers: nwAuthHeaders(), body: JSON.stringify(body) });
if (!r.ok) throw new Error(await r.text());
toast.success('CONFIGURAÇÕES SALVAS!');
onClose();
} catch (e: any) { toast.error('FALHA AO SALVAR: ' + String(e.message).slice(0, 80)); }
finally { setSaving(false); }
};
const handleTest = async () => {
setTest(null);
try {
const r = await fetch(`${NW_API}/nw/status`, { headers: nwAuthHeaders() });
const s = await r.json();
setTest({ ok: !!s.motorOk, msg: s.detail || (s.motorOk ? 'OK' : 'Falha') });
} catch (e: any) { setTest({ ok: false, msg: String(e.message).slice(0, 80) }); }
};
const inputCls = "w-full border border-gray-200 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:border-green-400 focus:ring-2 focus:ring-green-100 bg-white";
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 }}>CONEXÃO COM O MOTOR</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">
{loading ? <div className="text-sm text-gray-500">Carregando</div> : <>
{/* Caminho principal: colar o token de pareamento → auto-configura */}
<div>
<label className="text-[9px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">TOKEN DE PAREAMENTO</label>
<textarea className={inputCls + ' font-mono text-xs h-20 resize-none'} value={token} onChange={e => setToken(e.target.value)} placeholder="Cole aqui o token do motor (nwpair_…). URL, chave e webhook são configurados automaticamente." />
<button onClick={applyToken} disabled={applying || !token.trim()} className="mt-2 w-full py-2.5 text-white rounded-xl text-xs font-black uppercase flex items-center justify-center gap-2 disabled:opacity-50" style={{ backgroundColor: plugin.color }}>
{applying ? 'APLICANDO…' : 'APLICAR TOKEN'}
</button>
</div>
{/* Estado atual da conexão */}
<div className="text-[11px] text-gray-500 bg-gray-50 rounded-xl px-3 py-2">
Motor: <b>{motorUrl || '—'}</b> · Chave: <b className={hasKey ? 'text-green-600' : ''}>{hasKey ? 'definida' : '—'}</b> · Webhook: <b className={hasSecret ? 'text-green-600' : ''}>{hasSecret ? 'definido' : '—'}</b>
</div>
{test && <div className={`text-xs font-bold ${test.ok ? 'text-green-600' : 'text-red-600'}`}>{test.ok ? '✓ ' : '✗ '}{test.msg}</div>}
{/* Clínica — local (o motor não conhece os IDs daqui) */}
<div>
<label className="text-[9px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">CLÍNICA</label>
<select className={inputCls} value={clinicaId} onChange={e => setClinicaId(e.target.value)}>
<option value=""> selecione a clínica </option>
{clinics.map(c => <option key={c.id} value={c.id}>{c.nome_fantasia || c.id}</option>)}
</select>
</div>
{/* Configuração manual (avançado) */}
<button onClick={() => setAdvanced(a => !a)} className="text-[10px] font-black uppercase tracking-widest text-gray-400 hover:text-gray-600">
{advanced ? '▾ ocultar configuração manual' : '▸ configuração manual'}
</button>
{advanced && <div className="space-y-4 border-t border-gray-100 pt-4">
<div>
<label className="text-[9px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">URL DO MOTOR</label>
<input className={inputCls} value={motorUrl} onChange={e => setMotorUrl(e.target.value)} placeholder="https://newwhats.clube67.com" />
</div>
<div>
<label className="text-[9px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">CHAVE DE INTEGRAÇÃO {hasKey && <span className="text-green-600">(definida)</span>}</label>
<input className={inputCls} type="password" value={integrationKey} onChange={e => setIntegrationKey(e.target.value)} placeholder={hasKey ? '•••• (deixe em branco p/ manter)' : 'nw_...'} />
</div>
<div>
<label className="text-[9px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">SEGREDO DO WEBHOOK {hasSecret && <span className="text-green-600">(definido)</span>}</label>
<input className={inputCls} type="password" value={webhookSecret} onChange={e => setWebhookSecret(e.target.value)} placeholder={hasSecret ? '•••• (deixe em branco p/ manter)' : '(HMAC)'} />
</div>
<button onClick={handleTest} className="text-xs font-black uppercase text-gray-600 border border-gray-200 rounded-xl px-4 py-2 hover:bg-gray-50">Testar conexão</button>
</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} disabled={saving} className="flex-1 py-3 text-white rounded-xl text-xs font-black uppercase transition-colors flex items-center justify-center gap-2 disabled:opacity-60" style={{ backgroundColor: plugin.color }}>
<Save size={14} /> {saving ? 'SALVANDO…' : 'SALVAR'}
</button>
</div>
</div>
</div>
);
};
const PluginCard: React.FC<{ plugin: PluginDef; isActive: boolean; alwaysOn?: boolean; price?: number; onToggle: () => void; onConfigure: () => void }> = ({ plugin, isActive, alwaysOn, price, 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>
{/* Preço do add-on vem da Tabela de Preços (superadmin/financeiro) */}
{price !== undefined && (
<span className={`text-[8px] font-black px-2 py-0.5 rounded-full uppercase ${price > 0 ? 'bg-emerald-50 text-emerald-600' : 'bg-gray-100 text-gray-500'}`}>
{price > 0 ? `R$ ${price.toFixed(2).replace('.', ',')}/MÊS` : 'INCLUÍDO NO PLANO'}
</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">
{alwaysOn ? (
<div className="flex-1 py-3 rounded-2xl text-xs font-black uppercase tracking-wider flex items-center justify-center gap-2 bg-gray-50 text-gray-400 border border-gray-100 cursor-default" title="Capability de plataforma — sempre incluída">
<CheckCircle2 size={15} /> SEMPRE INCLUÍDO
</div>
) : (
<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>>({});
const [pluginPrices, setPluginPrices] = useState<Record<string, number>>({});
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(() => {});
// Preços dos add-ons (Tabela de Preços do superadmin → chaves `plugin:<id>`).
// Falha silenciosa p/ papéis sem acesso ao pricing: card fica sem o chip de preço.
fetch('/api/superadmin/pricing', { headers: { Authorization: `Bearer ${localStorage.getItem('SCOREODONTO_AUTH_TOKEN')}` } })
.then(r => r.ok ? r.json() : null)
.then(d => {
if (!d) return;
const prices: Record<string, number> = {};
for (const [k, v] of Object.entries(d)) if (k.startsWith('plugin:')) prices[k.slice(7)] = Number(v) || 0;
setPluginPrices(prices);
})
.catch(() => {});
}, []);
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 {
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) || ALWAYS_ON_PLUGINS.includes(plugin.id)}
alwaysOn={ALWAYS_ON_PLUGINS.includes(plugin.id)}
price={pluginPrices[plugin.id]}
onToggle={() => handleToggle(plugin)}
onConfigure={() => setConfiguringPlugin(plugin)}
/>
))}
</div>
{/* Config modal */}
{configuringPlugin && configuringPlugin.id === 'newwhats' && (
<NewwhatsConfigModal plugin={configuringPlugin} onClose={() => setConfiguringPlugin(null)} />
)}
{configuringPlugin && configuringPlugin.id !== 'newwhats' && (
<ConfigModal plugin={configuringPlugin} onClose={() => setConfiguringPlugin(null)} />
)}
</div>
);
};