feat(newwhats): config DB-backed + UI dedicada (dropdown de clínicas, testar conexão)

Backend:
- config.js: config lida da tabela settings (id=newwhats_config) com fallback ENV;
  loadConfigFromDb/saveConfigToDb + cache.
- index.js: endpoints superadmin GET/PUT /api/nw/config (secrets nunca em claro;
  branco mantém valor), GET /api/nw/clinicas (dropdown), GET /api/nw/status (testar
  conexão). server.js passa { superadminGuard }.
Frontend:
- PluginsView: NewwhatsConfigModal dedicado (carrega/salva no backend, dropdown de
  clínicas em vez de ID cru, botão Testar conexão). Substitui o form genérico p/ newwhats.

Resolve UX ruim: nada de digitar ID de clínica; config passa a funcionar de fato.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-06-28 18:55:54 +02:00
parent 3751d84da8
commit abbd9374b4
4 changed files with 236 additions and 27 deletions
+115 -1
View File
@@ -196,6 +196,117 @@ const ConfigModal: React.FC<{ plugin: PluginDef; onClose: () => void }> = ({ plu
};
// ─── 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);
useEffect(() => {
(async () => {
try {
const [cfgR, clR] = await Promise.all([
fetch(`${NW_API}/nw/config`, { headers: nwAuthHeaders() }),
fetch(`${NW_API}/nw/clinicas`, { headers: nwAuthHeaders() }),
]);
if (cfgR.ok) {
const c = await cfgR.json();
setMotorUrl(c.motorUrl || ''); setClinicaId(c.clinicaId || '');
setHasKey(!!c.hasIntegrationKey); setHasSecret(!!c.hasWebhookSecret);
}
if (clR.ok) setClinics(await clR.json());
} catch { /* noop */ }
finally { setLoading(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> : <>
<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">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>
<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>
{test && <div className={`text-xs font-bold ${test.ok ? 'text-green-600' : 'text-red-600'}`}>{test.ok ? '✓ ' : '✗ '}{test.msg}</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);
@@ -391,7 +502,10 @@ export const PluginsView: React.FC = () => {
</div>
{/* Config modal */}
{configuringPlugin && (
{configuringPlugin && configuringPlugin.id === 'newwhats' && (
<NewwhatsConfigModal plugin={configuringPlugin} onClose={() => setConfiguringPlugin(null)} />
)}
{configuringPlugin && configuringPlugin.id !== 'newwhats' && (
<ConfigModal plugin={configuringPlugin} onClose={() => setConfiguringPlugin(null)} />
)}
</div>