rooting-future / templates /admin_panel.html
mtornani's picture
Fix admin panel: deleteUser onclick, renderPlans null crash, credits PATCH route
dbfe927
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Panel – Rooting Future</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet">
<style>body { font-family: 'Inter', sans-serif; background: #f3f4f6; }</style>
</head>
<body class="bg-gray-100 min-h-screen p-4">
<div class="admin-dashboard-container" style="max-width: 1600px; margin: 0 auto; padding: 20px; font-family: 'Inter', sans-serif;">
<!-- HEADER PROFESSIONALE -->
<div class="flex flex-col gap-4 mb-8 sm:flex-row sm:justify-between sm:items-end">
<div>
<h1 class="text-2xl sm:text-3xl font-extrabold text-gray-900 tracking-tight flex items-center gap-3">
<span class="bg-indigo-600 text-white p-2 rounded-lg shadow-lg">🏛️</span>
Control Room <span class="text-indigo-600">Super Admin</span>
</h1>
<p class="text-gray-500 mt-2 font-medium text-sm">Monitoraggio globale, integrità scientifica e gestione flussi di lavoro.</p>
</div>
<div class="flex flex-wrap gap-2">
<button onclick="loadStats()" class="bg-white border border-gray-200 text-gray-600 px-3 py-2 rounded-lg hover:bg-gray-50 transition-all flex items-center gap-1 shadow-sm text-sm">
<span>🔄</span> <span class="hidden sm:inline">Aggiorna</span>
</button>
<button onclick="clearAICache()" class="bg-orange-500 hover:bg-orange-600 text-white px-3 py-2 rounded-lg shadow-lg flex items-center gap-1 font-bold transition-all text-sm">
<span>🗑️</span> <span class="hidden sm:inline">Cache AI</span>
</button>
<button onclick="openNewUserModal()" class="bg-indigo-600 hover:bg-indigo-700 text-white px-3 py-2 rounded-lg shadow-lg flex items-center gap-1 font-bold transition-all text-sm">
<span>+</span> Manager
</button>
<button onclick="generateManagerInvite()" class="bg-violet-600 hover:bg-violet-700 text-white px-3 py-2 rounded-lg shadow-lg flex items-center gap-1 font-bold transition-all text-sm">
<span>👤</span> Invita
</button>
<button onclick="generateTeamInvite()" class="bg-emerald-600 hover:bg-emerald-700 text-white px-3 py-2 rounded-lg shadow-lg flex items-center gap-1 font-bold transition-all text-sm">
<span>🔗</span> <span class="hidden sm:inline">Collaboratori</span>
</button>
<button onclick="generateRFInvite()" class="bg-teal-600 hover:bg-teal-700 text-white px-3 py-2 rounded-lg shadow-lg flex items-center gap-1 font-bold transition-all text-sm" title="Link unico multi-uso per soci RF">
<span>🌱</span> <span class="hidden sm:inline">Soci RF</span>
</button>
<a href="/admin/guest-log" target="_blank" class="bg-slate-700 hover:bg-slate-600 text-white px-3 py-2 rounded-lg shadow-lg flex items-center gap-1 font-bold transition-all text-sm">
<span>👁</span> <span class="hidden sm:inline">Log</span>
</a>
</div>
</div>
<!-- KPI GLOBALI -->
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-4 mb-8">
<div class="bg-white p-5 rounded-2xl shadow-sm border border-gray-100 flex items-center gap-4">
<div class="p-3 bg-blue-50 text-blue-600 rounded-xl text-xl">📦</div>
<div>
<div class="text-gray-400 text-[10px] font-bold uppercase tracking-wider">Piani Totali</div>
<div class="text-2xl font-black text-gray-800" id="stat-total-plans">-</div>
</div>
</div>
<div class="bg-white p-5 rounded-2xl shadow-sm border border-gray-100 flex items-center gap-4">
<div class="p-3 bg-indigo-50 text-indigo-600 rounded-xl text-xl">👥</div>
<div>
<div class="text-gray-400 text-[10px] font-bold uppercase tracking-wider">Manager</div>
<div class="text-2xl font-black text-gray-800" id="stat-total-users">-</div>
</div>
</div>
<div class="bg-white p-5 rounded-2xl shadow-sm border border-gray-100 flex items-center gap-4">
<div class="p-3 bg-purple-50 text-purple-600 rounded-xl text-xl">🎯</div>
<div>
<div class="text-gray-400 text-[10px] font-bold uppercase tracking-wider">Avg Quality</div>
<div class="text-2xl font-black text-gray-800" id="stat-avg-credibility">-</div>
</div>
</div>
<div class="bg-white p-5 rounded-2xl shadow-sm border border-gray-100 flex items-center gap-4">
<div class="p-3 bg-amber-50 text-amber-600 rounded-xl text-xl">💰</div>
<div>
<div class="text-gray-400 text-[10px] font-bold uppercase tracking-wider">Crediti Totali</div>
<div class="text-2xl font-black text-gray-800" id="stat-total-credits">-</div>
</div>
</div>
<div class="bg-white p-5 rounded-2xl shadow-sm border border-gray-100 flex items-center gap-4">
<div class="p-3 bg-red-50 text-red-600 rounded-xl text-xl">🛡️</div>
<div>
<div class="text-gray-400 text-[10px] font-bold uppercase tracking-wider">Security</div>
<div class="flex items-center gap-2">
<span id="status-text" class="text-sm font-black text-green-600">ENCRYPTED</span>
</div>
</div>
</div>
</div>
<div class="flex flex-col lg:flex-row gap-6">
<!-- CONTENUTO PRINCIPALE -->
<div class="flex-1">
<!-- TABS NAVIGATION -->
<div class="flex gap-4 sm:gap-8 border-b border-gray-200 mb-6 overflow-x-auto">
<button onclick="switchTab('plans')" id="tab-btn-plans" class="pb-4 px-2 font-bold text-sm sm:text-lg border-b-4 border-indigo-600 text-indigo-600 transition-all whitespace-nowrap">
📦 Piani <span id="badge-plans-count" class="ml-2 bg-indigo-100 text-indigo-600 text-xs py-0.5 px-2 rounded-full">0</span>
</button>
<button onclick="switchTab('users')" id="tab-btn-users" class="pb-4 px-2 font-bold text-sm sm:text-lg border-b-4 border-transparent text-gray-400 hover:text-gray-600 transition-all whitespace-nowrap">
👥 Team
</button>
<button onclick="switchTab('licenses')" id="tab-btn-licenses" class="pb-4 px-2 font-bold text-sm sm:text-lg border-b-4 border-transparent text-gray-400 hover:text-gray-600 transition-all whitespace-nowrap">
🔑 Licenze <span id="badge-licenses-count" class="ml-2 bg-gray-100 text-gray-600 text-xs py-0.5 px-2 rounded-full">0</span>
</button>
</div>
<!-- TAB: MASTER DATABASE -->
<div id="tab-content-plans" class="tab-content">
<div class="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden">
<div class="p-4 border-b border-gray-100 bg-gray-50/30 flex flex-wrap gap-3 justify-between items-center">
<div class="flex gap-2 flex-1 min-w-[300px]">
<div class="relative flex-1">
<input type="text" id="plan-search" onkeyup="filterPlans()" placeholder="Cerca club o manager..." class="w-full pl-9 pr-4 py-1.5 rounded-lg border border-gray-300 focus:ring-2 focus:ring-indigo-500 text-sm transition-all">
<span class="absolute left-3 top-2 text-gray-400 text-xs">🔍</span>
</div>
<select id="filter-status" onchange="filterPlans()" class="px-3 py-1.5 rounded-lg border border-gray-300 text-xs font-bold">
<option value="">TUTTI GLI STATI</option>
<option value="draft">BOZZA</option>
<option value="review">IN REVISIONE</option>
<option value="exported">ESPORTATO</option>
</select>
</div>
</div>
<div class="overflow-x-auto">
<table class="w-full text-left border-collapse table-auto">
<thead>
<tr class="bg-gray-50 text-gray-400 text-[9px] uppercase tracking-widest font-black border-b border-gray-100">
<th class="px-4 py-3">Club & ID</th>
<th class="px-4 py-3">Categoria</th>
<th class="px-4 py-3">Copertura STW</th>
<th class="px-4 py-3">Owner</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3 text-right">Master Actions</th>
</tr>
</thead>
<tbody id="plans-table-body" class="divide-y divide-gray-50">
<!-- Injection via JS -->
</tbody>
</table>
</div>
</div>
</div>
<!-- TAB: TEAM MANAGEMENT -->
<div id="tab-content-users" class="tab-content hidden">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4" id="users-grid">
<!-- Cards injection -->
</div>
</div>
<!-- TAB: LICENSES -->
<div id="tab-content-licenses" class="tab-content hidden">
<!-- License Stats -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div class="bg-white p-4 rounded-xl border border-gray-100 shadow-sm">
<div class="text-gray-400 text-[10px] font-bold uppercase">Totali</div>
<div class="text-2xl font-black text-gray-800" id="lic-stat-total">0</div>
</div>
<div class="bg-white p-4 rounded-xl border border-gray-100 shadow-sm">
<div class="text-gray-400 text-[10px] font-bold uppercase">Attive</div>
<div class="text-2xl font-black text-green-600" id="lic-stat-active">0</div>
</div>
<div class="bg-white p-4 rounded-xl border border-gray-100 shadow-sm">
<div class="text-gray-400 text-[10px] font-bold uppercase">Revocate</div>
<div class="text-2xl font-black text-red-600" id="lic-stat-revoked">0</div>
</div>
<div class="bg-white p-4 rounded-xl border border-gray-100 shadow-sm">
<div class="text-gray-400 text-[10px] font-bold uppercase">In Scadenza (30gg)</div>
<div class="text-2xl font-black text-amber-600" id="lic-stat-expiring">0</div>
</div>
</div>
<!-- License Table -->
<div class="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden">
<div class="p-4 border-b border-gray-100 bg-gray-50/30 flex flex-wrap gap-3 justify-between items-center">
<div class="flex gap-2">
<select id="filter-lic-status" onchange="loadLicenses()" class="px-3 py-1.5 rounded-lg border border-gray-300 text-xs font-bold">
<option value="">TUTTE</option>
<option value="active">ATTIVE</option>
<option value="revoked">REVOCATE</option>
</select>
</div>
<button onclick="loadLicenses()" class="text-xs text-indigo-600 font-bold hover:underline">🔄 Aggiorna</button>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm" id="licenses-table">
<thead class="bg-gray-50 text-[11px] uppercase text-gray-500 tracking-wider">
<tr>
<th class="px-4 py-3 text-left font-bold">Email</th>
<th class="px-4 py-3 text-left font-bold">HWID</th>
<th class="px-4 py-3 text-left font-bold">Chiave</th>
<th class="px-4 py-3 text-left font-bold">Creata</th>
<th class="px-4 py-3 text-left font-bold">Scadenza</th>
<th class="px-4 py-3 text-left font-bold">Stato</th>
<th class="px-4 py-3 text-center font-bold">Azioni</th>
</tr>
</thead>
<tbody id="licenses-body" class="divide-y divide-gray-100">
<tr><td colspan="7" class="px-4 py-8 text-center text-gray-400">Caricamento...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- SIDEBAR: AUDIT & LOGS (UX Friendly per non impazzire) -->
<div class="w-full lg:w-80 space-y-6">
<div class="bg-gray-900 rounded-2xl p-5 text-white shadow-xl border border-gray-800">
<h3 class="text-xs font-black uppercase tracking-[2px] text-indigo-400 mb-4 flex items-center justify-between">
Live Audit Log
<span class="flex h-2 w-2 rounded-full bg-green-500 animate-pulse"></span>
</h3>
<div class="space-y-4 max-h-[600px] overflow-y-auto pr-2 custom-scrollbar" id="audit-log-container">
<div class="text-center py-10 text-gray-500 italic text-xs">Caricamento attività...</div>
</div>
</div>
<div class="bg-indigo-600 rounded-2xl p-5 text-white shadow-xl relative overflow-hidden group">
<div class="relative z-10">
<h3 class="text-xs font-black uppercase tracking-[2px] mb-2">Generatore Licenze</h3>
<p class="text-[10px] text-indigo-100 leading-relaxed mb-4">Crea chiavi di sblocco per i terminali dei clienti.</p>
<div class="space-y-2">
<input type="text" id="gen-hwid" placeholder="Codice Macchina (XXXX-...)" class="w-full bg-white/10 border border-white/20 rounded-lg p-2 text-xs placeholder:text-white/40 focus:bg-white/20 outline-none">
<input type="email" id="gen-email" placeholder="Email Cliente" class="w-full bg-white/10 border border-white/20 rounded-lg p-2 text-xs placeholder:text-white/40 focus:bg-white/20 outline-none">
<button onclick="generateLicenseKey()" class="w-full py-2 bg-white text-indigo-600 rounded-lg text-xs font-black hover:bg-indigo-50 transition-all">Genera Chiave Rooting Future</button>
</div>
<div id="license-result" class="mt-3 p-2 bg-black/20 rounded-lg text-[10px] font-mono break-all hidden select-all"></div>
</div>
<div class="absolute -right-4 -bottom-4 text-6xl opacity-10 transform group-hover:scale-110 transition-transform">🔑</div>
</div>
</div>
</div>
</div>
</div>
<!-- MODALE ASSEGNAZIONE PIANO -->
<div id="assign-modal" class="fixed inset-0 bg-gray-900/60 backdrop-blur-md z-[100] hidden flex items-center justify-center p-4">
<div class="bg-white rounded-3xl shadow-2xl w-full max-w-lg overflow-hidden transform transition-all">
<div class="bg-indigo-600 p-6 text-white">
<h3 class="text-2xl font-bold">Assegna Piano Strategico</h3>
<p class="text-indigo-100 text-sm mt-1" id="assign-club-name">Club: ...</p>
</div>
<div class="p-8">
<input type="hidden" id="assign-plan-id">
<label class="block text-xs font-black text-gray-400 uppercase tracking-widest mb-3">Seleziona Temporary Manager</label>
<div class="space-y-3 max-h-60 overflow-y-auto pr-2" id="assign-user-list">
<!-- User list injection -->
</div>
<div class="mt-8 flex gap-3">
<button onclick="closeAssignModal()" class="flex-1 py-3 text-gray-500 font-bold hover:bg-gray-100 rounded-xl transition-all">Annulla</button>
<button onclick="confirmAssignment()" class="flex-1 py-3 bg-indigo-600 text-white font-bold rounded-xl shadow-lg hover:bg-indigo-700 transition-all">Conferma Assegnazione</button>
</div>
</div>
</div>
</div>
<!-- MODALE NUOVO UTENTE (Minimalist) -->
<div id="new-user-modal" class="fixed inset-0 bg-gray-900/60 backdrop-blur-md z-[100] hidden flex items-center justify-center p-4">
<div class="bg-white rounded-3xl shadow-2xl w-full max-w-md p-8">
<h3 class="text-2xl font-bold text-gray-900 mb-6">Nuovo Account Manager</h3>
<form id="new-user-form" onsubmit="createUser(event)" class="space-y-4">
<div>
<label class="block text-xs font-bold text-gray-400 uppercase mb-2">Nome Completo</label>
<input type="text" name="full_name" required class="w-full px-4 py-3 bg-gray-50 border-none rounded-xl focus:ring-2 focus:ring-indigo-500">
</div>
<div>
<label class="block text-xs font-bold text-gray-400 uppercase mb-2">Email</label>
<input type="email" name="email" required class="w-full px-4 py-3 bg-gray-50 border-none rounded-xl focus:ring-2 focus:ring-indigo-500">
</div>
<button type="submit" class="w-full py-4 bg-indigo-600 text-white font-bold rounded-xl shadow-xl hover:bg-indigo-700 transition-all mt-4">Crea Account</button>
<button type="button" onclick="closeNewUserModal()" class="w-full py-2 text-gray-400 font-medium hover:text-gray-600">Chiudi</button>
</form>
</div>
</div>
<script>
let allPlans = [];
let allUsers = [];
let selectedUserIdForAssign = null;
document.addEventListener('DOMContentLoaded', () => {
loadData();
});
async function loadData() {
await Promise.all([loadPlans(), loadUsers(), loadLicenses()]);
updateGlobalStats();
}
function loadStats() {
loadData();
}
function openNewUserModal() {
document.getElementById('new-user-modal').classList.remove('hidden');
document.getElementById('new-user-form').reset();
}
function closeNewUserModal() {
document.getElementById('new-user-modal').classList.add('hidden');
}
async function createUser(event) {
event.preventDefault();
const form = document.getElementById('new-user-form');
const fd = new FormData(form);
const body = {
full_name: fd.get('full_name'),
email: fd.get('email'),
role: 'manager'
};
const resp = await fetch('/api/admin/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const data = await resp.json();
if (data.success) {
alert(`✅ Account creato!\n\nEmail: ${body.email}\nPassword temporanea: ${data.temp_password}\n\n⚠️ Comunicala all'utente — non verrà mostrata di nuovo.`);
closeNewUserModal();
loadUsers();
} else {
alert('❌ Errore: ' + (data.error || 'sconosciuto'));
}
}
async function generateManagerInvite() {
const label = prompt('Nome del manager (es. "Mario Rossi – Riccione"):');
if (!label) return;
const clubSlug = prompt('Slug club assegnato (es. "riccione-calcio-1926") — lascia vuoto per nessuno:') || '';
const resp = await fetch('/api/admin/manager-invite', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ label, club_slug: clubSlug, expires_days: 7 })
});
const data = await resp.json();
if (data.link) {
const copied = await navigator.clipboard.writeText(data.link).then(() => true).catch(() => false);
alert(`✅ Link registrazione per "${label}":\n\n${data.link}\n\n${copied ? '📋 Copiato!' : 'Copia il link.'}\n\nScade tra 7 giorni. Uso singolo.`);
} else {
alert('❌ Errore: ' + (data.error || 'sconosciuto'));
}
}
async function generateTeamInvite() {
const label = prompt('Nome/etichetta per questo link (es. "Staff Tecnico"):') || 'Team Rooting Future';
if (label === null) return; // user cancelled
const resp = await fetch('/api/admin/team-invite', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ label, expires_days: 90 })
});
const data = await resp.json();
if (data.link) {
const copied = await navigator.clipboard.writeText(data.link).then(() => true).catch(() => false);
alert(`✅ Link generato per "${label}":\n\n${data.link}\n\n${copied ? '📋 Copiato negli appunti!' : 'Copia il link qui sopra.'}\n\nScade: ${data.expires_at ? new Date(data.expires_at).toLocaleDateString('it-IT') : '∞'}`);
} else {
alert('❌ Errore: ' + (data.error || 'sconosciuto'));
}
}
async function generateRFInvite() {
const label = prompt('Etichetta per il link soci (es. "Soci 2025"):') || 'Soci Rooting Future';
if (label === null) return;
const resp = await fetch('/api/admin/rf-invite', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ label })
});
const data = await resp.json();
if (data.link) {
const copied = await navigator.clipboard.writeText(data.link).then(() => true).catch(() => false);
alert(
`🌱 Link soci RF generato per "${label}":\n\n${data.link}\n\n` +
`${copied ? '📋 Copiato negli appunti!' : 'Copia il link sopra.'}\n\n` +
`♾️ Nessuna scadenza — usa più volte.\n` +
`Ruolo assegnato: manager (5 crediti al primo accesso)`
);
} else {
alert('❌ Errore: ' + (data.error || 'sconosciuto'));
}
}
async function clearAICache() {
if (!confirm('Svuotare tutta la cache AI? Il prossimo piano userà chiamate reali a Gemini/NIM.')) return;
const resp = await fetch('/api/admin/clear-cache', { method: 'POST' });
const data = await resp.json();
if (data.success) {
alert(`✅ Cache svuotata: ${data.deleted} file eliminati.`);
} else {
alert('❌ Errore: ' + (data.error || 'sconosciuto'));
}
}
async function loadPlans() {
try {
const resp = await fetch('/api/admin/plans?limit=500');
const data = await resp.json();
if (data.success) {
allPlans = data.plans;
renderPlans(allPlans);
document.getElementById('badge-plans-count').innerText = allPlans.length;
}
} catch (e) { console.error(e); }
}
async function loadUsers() {
try {
const resp = await fetch('/api/admin/users');
const data = await resp.json();
if (data.success) {
allUsers = data.users;
renderUsersTab(allUsers);
}
} catch (e) { console.error(e); }
}
function renderPlans(plans) {
const tbody = document.getElementById('plans-table-body');
const emptyState = document.getElementById('plans-empty-state');
if (plans.length === 0) {
tbody.innerHTML = '';
if (emptyState) emptyState.classList.remove('hidden');
return;
}
if (emptyState) emptyState.classList.add('hidden');
tbody.innerHTML = '';
plans.forEach(p => {
const tr = document.createElement('tr');
tr.className = "hover:bg-indigo-50/20 transition-all group border-b border-gray-50";
const score = p.credibility_score || 0;
// Progress Bar STW (Fallback logic)
const stwProgress = stw_progress_fallback(p);
// Manager assegnato
const manager = allUsers.find(u => u.id == p.owner_id);
const managerHtml = manager
? `<div class="flex items-center gap-2"><div class="w-5 h-5 rounded-full bg-indigo-100 flex items-center justify-center text-[8px] font-black text-indigo-600">${manager.full_name[0]}</div> <span class="text-gray-600 font-semibold text-xs">${manager.full_name.split(' ')[0]}</span></div>`
: `<span class="text-gray-300 italic text-[10px]">Non assegnato</span>`;
tr.innerHTML = `
<td class="px-4 py-3">
<div class="font-black text-gray-800 text-sm">${p.club_name}</div>
<div class="text-[9px] text-gray-400 font-mono">${p.id.substring(0,12)}...</div>
</td>
<td class="px-4 py-3">
<span class="text-[10px] font-black text-gray-500 bg-gray-100 px-2 py-0.5 rounded uppercase">${p.category}</span>
</td>
<td class="px-4 py-3 min-w-[150px]">
<div class="flex items-center gap-3">
<div class="flex-1 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div class="h-full bg-indigo-500 rounded-full" style="width: ${stwProgress}%"></div>
</div>
<span class="text-[10px] font-black text-gray-400">${stwProgress}%</span>
</div>
</td>
<td class="px-4 py-3">
${managerHtml}
</td>
<td class="px-4 py-3">
<span class="status-pill status-${p.status || 'draft'}">${p.status || 'draft'}</span>
</td>
<td class="px-4 py-3 text-right">
<div class="flex justify-end gap-1 opacity-40 group-hover:opacity-100 transition-opacity">
<button onclick="openAssignModal('${p.id}', '${p.club_name}')" class="p-1.5 bg-white border border-gray-200 rounded hover:bg-indigo-600 hover:text-white transition-all shadow-sm" title="Riassegna">🤝</button>
<button onclick="quickExport('${p.id}')" class="p-1.5 bg-white border border-gray-200 rounded hover:bg-indigo-600 hover:text-white transition-all shadow-sm" title="Esporta ZIP">📦</button>
<a href="/plan/${p.id}" class="p-1.5 bg-white border border-gray-200 rounded hover:bg-indigo-600 hover:text-white transition-all shadow-sm" title="Apri Editor">⚡</a>
</div>
</td>
`;
tbody.appendChild(tr);
});
document.getElementById('current-visible-plans').innerText = plans.length;
document.getElementById('total-plans-count').innerText = allPlans.length;
updateAuditLog();
}
function stw_progress_fallback(p) {
if(p.status === 'exported') return 100;
if(p.status === 'approved') return 95;
return Math.round(p.credibility_score) || 45;
}
function renderUsersTab(users) {
const grid = document.getElementById('users-grid');
grid.innerHTML = '';
users.forEach(u => {
const card = document.createElement('div');
card.className = "bg-white p-6 rounded-3xl shadow-sm border border-gray-100 hover:shadow-xl transition-all";
card.innerHTML = `
<div class="flex items-center gap-4 mb-4">
<div class="w-12 h-12 rounded-2xl bg-indigo-600 text-white flex items-center justify-center text-xl font-black shadow-lg">
${u.full_name[0]}
</div>
<div>
<div class="font-bold text-gray-900">${u.full_name}</div>
<div class="text-xs text-gray-400">${u.email}</div>
</div>
</div>
<div class="grid grid-cols-2 gap-3 mb-4">
<div class="bg-gray-50 p-3 rounded-2xl text-center">
<div class="text-[10px] text-gray-400 uppercase font-bold">Crediti</div>
<div class="text-lg font-black text-indigo-600">${u.credits}</div>
</div>
<div class="bg-gray-50 p-3 rounded-2xl text-center">
<div class="text-[10px] text-gray-400 uppercase font-bold">Piani</div>
<div class="text-lg font-black text-gray-800">${u.assigned_plans_count || 0}</div>
</div>
</div>
<div class="flex gap-2">
<button onclick="adjustUserCredits('${u.id}', 5)" class="flex-1 py-2 text-xs font-bold bg-indigo-50 text-indigo-600 rounded-xl hover:bg-indigo-100">+5 Crediti</button>
<button onclick="deleteUser('${u.id}', '${u.full_name}')" class="flex-1 py-2 text-xs font-bold text-gray-400 hover:text-red-500">Disabilita</button>
</div>
`;
grid.appendChild(card);
});
}
async function adjustUserCredits(userId, amount) {
const resp = await fetch(`/api/admin/users/${userId}/credits`, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({amount})
});
const data = await resp.json();
if (data.success) { loadUsers(); }
else { alert('Errore: ' + (data.error || 'Sconosciuto')); }
}
async function deleteUser(userId, userName) {
if (!confirm(`Eliminare ${userName}? L'operazione è irreversibile.`)) return;
const resp = await fetch(`/api/admin/users/${userId}`, { method: 'DELETE' });
const data = await resp.json();
if (data.success) { loadUsers(); }
else { alert('Errore: ' + (data.error || 'Sconosciuto')); }
}
function filterPlans() {
const q = document.getElementById('plan-search').value.toLowerCase();
const cat = document.getElementById('filter-category').value;
const filtered = allPlans.filter(p => {
const matchQ = p.club_name.toLowerCase().includes(q) || p.id.toLowerCase().includes(q);
const matchCat = cat === "" || p.category === cat;
return matchQ && matchCat;
});
renderPlans(filtered);
}
// GESTIONE ASSEGNAZIONE
function openAssignModal(planId, clubName) {
document.getElementById('assign-plan-id').value = planId;
document.getElementById('assign-club-name').innerText = `Club: ${clubName}`;
const list = document.getElementById('assign-user-list');
list.innerHTML = '';
allUsers.filter(u => u.role !== 'super_admin').forEach(u => {
const div = document.createElement('div');
div.className = "flex items-center justify-between p-3 rounded-xl border border-gray-100 hover:bg-indigo-50 cursor-pointer transition-all assign-user-row";
div.onclick = () => selectUserForAssign(u.id, div);
div.innerHTML = `
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full bg-gray-200 flex items-center justify-center text-xs font-bold">${u.full_name[0]}</div>
<div class="font-bold text-sm text-gray-700">${u.full_name}</div>
</div>
<div class="text-xs text-gray-400">${u.assigned_plans_count} piani attivi</div>
`;
list.appendChild(div);
});
document.getElementById('assign-modal').classList.remove('hidden');
}
function selectUserForAssign(userId, element) {
selectedUserIdForAssign = userId;
document.querySelectorAll('.assign-user-row').forEach(el => el.classList.remove('ring-2', 'ring-indigo-600', 'bg-indigo-50'));
element.classList.add('ring-2', 'ring-indigo-600', 'bg-indigo-50');
}
async function confirmAssignment() {
const planId = document.getElementById('assign-plan-id').value;
if (!selectedUserIdForAssign) return alert("Seleziona un manager!");
try {
const resp = await fetch('/api/admin/assign_plan', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ plan_id: planId, user_id: selectedUserIdForAssign })
});
const data = await resp.json();
if (data.success) {
alert("Piano assegnato con successo!");
closeAssignModal();
loadData();
}
} catch(e) { console.error(e); }
}
function closeAssignModal() {
document.getElementById('assign-modal').classList.add('hidden');
selectedUserIdForAssign = null;
}
// TABS SWITCHER
function switchTab(tab) {
document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden'));
document.getElementById(`tab-content-${tab}`).classList.remove('hidden');
document.querySelectorAll('[id^="tab-btn-"]').forEach(el => {
el.classList.remove('border-indigo-600', 'text-indigo-600');
el.classList.add('border-transparent', 'text-gray-400');
});
document.getElementById(`tab-btn-${tab}`).classList.add('border-indigo-600', 'text-indigo-600');
}
function updateGlobalStats() {
document.getElementById('stat-total-plans').innerText = allPlans.length;
document.getElementById('stat-total-users').innerText = allUsers.length;
const avg = allPlans.reduce((acc, p) => acc + (p.credibility_score || 0), 0) / (allPlans.length || 1);
document.getElementById('stat-avg-credibility').innerText = `${Math.round(avg)}%`;
const totalCredits = allUsers.reduce((acc, u) => acc + (u.credits || 0), 0);
if(document.getElementById('stat-total-credits')) {
document.getElementById('stat-total-credits').innerText = totalCredits;
}
}
function updateAuditLog() {
const container = document.getElementById('audit-log-container');
if(!container) return;
const activities = [
{ user: 'Mirko T.', action: 'Generato piano', club: 'Rimini FC', time: '2m fa', icon: '🚀' },
{ user: 'Sist. AI', action: 'Audit completato', club: 'AC Riccione', time: '15m fa', icon: '🛡️' },
{ user: 'Manager Rossi', action: 'Modifica Area Sportiva', club: 'SPAL', time: '1h fa', icon: '📝' },
{ user: 'Sist. AI', action: 'Rilevata anomalia budget', club: 'Cesena', time: '3h fa', icon: '⚠️' }
];
container.innerHTML = activities.map(a => `
<div class="bg-gray-800/50 p-3 rounded-xl border border-gray-700/50 hover:bg-gray-800 transition-all cursor-default mb-3">
<div class="flex justify-between items-start mb-1">
<span class="text-[10px] font-black text-indigo-400 uppercase">${a.user}</span>
<span class="text-[9px] text-gray-500 uppercase">${a.time}</span>
</div>
<div class="text-[11px] text-gray-200">${a.icon} ${a.action}: <strong class="text-white">${a.club}</strong></div>
</div>
`).join('');
}
async function quickExport(planId) {
if(typeof broadcast_log === 'function') {
broadcast_log("INFO", `Avvio esportazione rapida pacchetto per ${planId}...`);
}
try {
const resp = await fetch(`/api/export/${planId}/package`, { method: 'POST' });
if (resp.ok) {
const blob = await resp.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `StrategyPack_${planId}.zip`;
document.body.appendChild(a);
a.click();
a.remove();
} else {
alert("Errore durante l'esportazione. Verifica i log.");
}
} catch (e) {
console.error(e);
alert("Errore di connessione durante l'export.");
}
}
async function generateLicenseKey() {
const hwid = document.getElementById('gen-hwid').value.trim();
const email = document.getElementById('gen-email').value.trim();
const resultDiv = document.getElementById('license-result');
if (!hwid || !email) {
alert("Inserire sia HWID che Email per generare la chiave.");
return;
}
// Algoritmo di generazione (deve coincidere con quello in license_manager.py)
// Usiamo una chiamata API al server per non esporre il SALT nel client
try {
const resp = await fetch('/api/admin/generate-license', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ hwid: hwid, email: email })
});
const data = await resp.json();
if (data.success) {
resultDiv.innerText = data.key;
resultDiv.classList.remove('hidden');
broadcast_log("INFO", `Generata nuova licenza per ${email}`);
// Ricarica la lista licenze se visibile
loadLicenses();
} else {
alert("Errore generazione: " + data.error);
}
} catch (e) { alert("Errore di rete."); }
}
// =========================================================================
// LICENSE MANAGEMENT
// =========================================================================
async function loadLicenses() {
const status = document.getElementById('filter-lic-status')?.value || '';
const tbody = document.getElementById('licenses-body');
try {
const url = status ? `/api/admin/licenses?status=${status}` : '/api/admin/licenses';
const resp = await fetch(url);
const data = await resp.json();
if (data.success) {
// Update stats
if (data.stats) {
document.getElementById('lic-stat-total').innerText = data.stats.total || 0;
document.getElementById('lic-stat-active').innerText = data.stats.active || 0;
document.getElementById('lic-stat-revoked').innerText = data.stats.revoked || 0;
document.getElementById('lic-stat-expiring').innerText = data.stats.expiring_soon || 0;
document.getElementById('badge-licenses-count').innerText = data.stats.total || 0;
}
// Render table
if (!data.licenses || data.licenses.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="px-4 py-8 text-center text-gray-400">Nessuna licenza trovata</td></tr>';
return;
}
tbody.innerHTML = data.licenses.map(lic => {
const createdDate = lic.created_at ? new Date(lic.created_at).toLocaleDateString('it-IT') : '-';
const expiresDate = lic.expires_at ? new Date(lic.expires_at).toLocaleDateString('it-IT') : 'Perpetua';
const statusBadge = lic.status === 'active'
? '<span class="px-2 py-1 bg-green-100 text-green-700 rounded-full text-[10px] font-bold">ATTIVA</span>'
: '<span class="px-2 py-1 bg-red-100 text-red-700 rounded-full text-[10px] font-bold">REVOCATA</span>';
const shortHwid = lic.hwid ? lic.hwid.substring(0, 8) + '...' : '-';
const shortKey = lic.license_key ? lic.license_key.substring(0, 12) + '...' : '-';
return `
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 font-medium text-gray-900">${lic.email || '-'}</td>
<td class="px-4 py-3 font-mono text-xs text-gray-500">${shortHwid}</td>
<td class="px-4 py-3 font-mono text-xs text-gray-500">${shortKey}</td>
<td class="px-4 py-3 text-gray-500">${createdDate}</td>
<td class="px-4 py-3 text-gray-500">${expiresDate}</td>
<td class="px-4 py-3">${statusBadge}</td>
<td class="px-4 py-3 text-center">
${lic.status === 'active' ?
`<button onclick="revokeLicense(${lic.id}, '${lic.email}')" class="text-red-600 hover:text-red-800 text-xs font-bold">Revoca</button>`
: '<span class="text-gray-400 text-xs">-</span>'}
</td>
</tr>
`;
}).join('');
}
} catch (e) {
tbody.innerHTML = '<tr><td colspan="7" class="px-4 py-8 text-center text-red-500">Errore caricamento licenze</td></tr>';
}
}
async function revokeLicense(licenseId, email) {
if (!confirm(`Confermi la revoca della licenza per ${email}? Questa azione è irreversibile.`)) {
return;
}
try {
const resp = await fetch(`/api/admin/licenses/${licenseId}/revoke`, {
method: 'POST',
headers: {'Content-Type': 'application/json'}
});
const data = await resp.json();
if (data.success) {
broadcast_log("WARN", `Licenza revocata per ${email}`);
loadLicenses();
} else {
alert("Errore: " + data.error);
}
} catch (e) {
alert("Errore di connessione");
}
}
async function toggleKillswitch() {
const key = prompt("INSERIRE MASTER KEY DI SICUREZZA:");
if (!key) return;
const currentState = document.getElementById('status-text').innerText;
const newState = currentState === 'LIVE' ? 'lock' : 'live';
try {
// URL Offuscato: /api/v1/internal/stat/sync-provider/
const resp = await fetch(`/api/v1/internal/stat/sync-provider/${newState}?token=${encodeURIComponent(key)}`);
if (resp.status === 404) {
alert("ERRORE: Comando non trovato o Master Key errata.");
return;
}
const data = await resp.json();
if (data.status === 'executed') {
location.reload();
}
} catch (e) {
alert("Errore critico di comunicazione.");
}
}
</script>
<style>
.status-pill {
font-size: 10px;
font-weight: 800;
text-transform: uppercase;
padding: 4px 10px;
border-radius: 20px;
letter-spacing: 0.5px;
}
.status-draft { background: #fef3c7; color: #92400e; }
.status-approved { background: #dcfce7; color: #166534; }
.status-review { background: #dbeafe; color: #1e40af; }
</style>
</body>
</html>