13af039555
- 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>
558 lines
28 KiB
TypeScript
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>
|
|
);
|
|
};
|