PGŽ Sport Platform — Round 1+2 baseline (sport2.html + API)

This commit is contained in:
Damir Radulić
2026-05-04 23:39:08 +02:00
commit a7ec0a86be
1820 changed files with 694455 additions and 0 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 385 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 367 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 KiB

+495
View File
@@ -0,0 +1,495 @@
<!DOCTYPE html>
<html lang="hr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>PGŽ Sport · Admin Dashboard</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='6' fill='%2306080d'/><text x='16' y='23' text-anchor='middle' font-size='18' font-family='monospace' fill='%2300f0ff'>A</text></svg>">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--bg: #06080d;
--bg-2: #0d1117;
--bg-3: #161b22;
--border: #1f2937;
--text: #e6edf3;
--text-2: #8b949e;
--text-3: #6e7681;
--accent: #00f0ff;
--accent-2: #00b8d4;
--green: #56d364;
--yellow: #d29922;
--red: #f85149;
--purple: #bc8cff;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Inter', system-ui, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
font-size: 14px;
line-height: 1.5;
}
.app { display: grid; grid-template-columns: 240px 1fr; min-height: 100vh; }
.sidebar {
background: var(--bg-2);
border-right: 1px solid var(--border);
padding: 20px 0;
display: flex; flex-direction: column;
}
.brand {
padding: 0 20px 20px;
border-bottom: 1px solid var(--border);
margin-bottom: 12px;
}
.brand h1 {
font-size: 16px; font-weight: 700; color: var(--accent);
font-family: 'JetBrains Mono', monospace;
}
.brand .sub { font-size: 11px; color: var(--text-3); margin-top: 2px; }
.nav-item {
display: flex; align-items: center; gap: 10px;
padding: 10px 20px; cursor: pointer;
color: var(--text-2); font-size: 13px;
border-left: 3px solid transparent;
transition: all 0.15s;
}
.nav-item:hover { background: var(--bg-3); color: var(--text); }
.nav-item.active {
color: var(--accent);
background: rgba(0,240,255,0.05);
border-left-color: var(--accent);
}
.nav-item .icon { font-size: 16px; width: 18px; }
.tenant-switch {
margin: auto 12px 12px;
padding: 12px;
background: var(--bg-3);
border-radius: 6px;
border: 1px solid var(--border);
}
.tenant-switch label { font-size: 11px; color: var(--text-3); display: block; margin-bottom: 4px; }
.tenant-switch select {
width: 100%;
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
padding: 6px 8px;
border-radius: 4px;
font-family: inherit;
font-size: 13px;
}
.main { padding: 20px 28px; overflow-y: auto; }
.header {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 20px; padding-bottom: 16px;
border-bottom: 1px solid var(--border);
}
.header h2 { font-size: 22px; font-weight: 700; }
.header .meta { color: var(--text-3); font-size: 12px; font-family: 'JetBrains Mono', monospace; }
.kpi-grid {
display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px; margin-bottom: 24px;
}
.kpi-card {
background: var(--bg-2); border: 1px solid var(--border);
border-radius: 8px; padding: 16px;
position: relative; overflow: hidden;
}
.kpi-card::before {
content: ''; position: absolute; top: 0; left: 0;
width: 3px; height: 100%; background: var(--accent);
}
.kpi-card.green::before { background: var(--green); }
.kpi-card.yellow::before { background: var(--yellow); }
.kpi-card.purple::before { background: var(--purple); }
.kpi-label { font-size: 11px; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.5px; }
.kpi-value { font-size: 28px; font-weight: 700; color: var(--text); margin-top: 6px; font-family: 'JetBrains Mono', monospace; }
.kpi-sub { font-size: 11px; color: var(--text-2); margin-top: 4px; }
.section {
background: var(--bg-2); border: 1px solid var(--border);
border-radius: 8px; padding: 18px; margin-bottom: 18px;
}
.section h3 { font-size: 14px; font-weight: 600; margin-bottom: 12px; color: var(--accent); }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th { text-align: left; padding: 8px 10px; color: var(--text-3); font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 1px solid var(--border); }
td { padding: 10px; border-bottom: 1px solid var(--border); color: var(--text); }
tr:hover { background: var(--bg-3); }
td.num { font-family: 'JetBrains Mono', monospace; text-align: right; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; }
.badge.green { background: rgba(86,211,100,0.15); color: var(--green); }
.badge.yellow { background: rgba(210,153,34,0.15); color: var(--yellow); }
.badge.red { background: rgba(248,81,73,0.15); color: var(--red); }
.badge.gray { background: rgba(110,118,129,0.15); color: var(--text-3); }
.search {
width: 100%; max-width: 320px;
background: var(--bg); border: 1px solid var(--border);
padding: 8px 12px; border-radius: 6px;
color: var(--text); font-family: inherit; font-size: 13px;
}
.search:focus { outline: none; border-color: var(--accent); }
.tab-content { display: none; }
.tab-content.active { display: block; }
.iframe-wrap {
background: var(--bg-2); border: 1px solid var(--border);
border-radius: 8px; overflow: hidden; height: 600px;
}
.iframe-wrap iframe { width: 100%; height: 100%; border: 0; }
.spinner {
display: inline-block; width: 14px; height: 14px;
border: 2px solid var(--border); border-top-color: var(--accent);
border-radius: 50%; animation: spin 1s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.tenants-grid {
display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 14px;
}
.tenant-card {
background: var(--bg-2); border: 1px solid var(--border);
border-radius: 8px; padding: 18px;
}
.tenant-card .name { font-size: 16px; font-weight: 600; margin-bottom: 4px; }
.tenant-card .slug { font-size: 11px; color: var(--text-3); font-family: 'JetBrains Mono', monospace; }
.tenant-card .stats { margin-top: 12px; display: flex; gap: 16px; }
.tenant-card .stats .stat { font-size: 12px; color: var(--text-2); }
.tenant-card .stats .stat strong { color: var(--accent); display: block; font-size: 16px; font-family: 'JetBrains Mono', monospace; }
@media (max-width: 768px) {
.app { grid-template-columns: 1fr; }
.sidebar { display: none; }
}
</style>
</head>
<body>
<div class="app">
<aside class="sidebar">
<div class="brand">
<h1>PGŽ SPORT</h1>
<div class="sub">Admin Dashboard v1.1</div>
</div>
<div class="nav-item active" data-tab="dashboard">
<span class="icon"></span>
<span>Dashboard</span>
</div>
<div class="nav-item" data-tab="erp">
<span class="icon"></span>
<span>ERP — Financije</span>
</div>
<div class="nav-item" data-tab="crm">
<span class="icon"></span>
<span>CRM — Klubovi</span>
</div>
<div class="nav-item" data-tab="osobe">
<span class="icon"></span>
<span>Kontakti</span>
</div>
<div class="nav-item" data-tab="graph3d">
<span class="icon"></span>
<span>3D Graf</span>
</div>
<div class="nav-item" data-tab="tenants">
<span class="icon"></span>
<span>Tenants</span>
</div>
<div class="nav-item" data-tab="reports">
<span class="icon"></span>
<span>Reports</span>
</div>
<div class="tenant-switch">
<label>Aktivan tenant</label>
<select id="tenantSel"></select>
</div>
</aside>
<main class="main">
<div class="header">
<h2 id="pageTitle">Dashboard</h2>
<span class="meta" id="metaInfo">učitavam…</span>
</div>
<!-- DASHBOARD -->
<div class="tab-content active" id="tab-dashboard">
<div class="kpi-grid" id="kpiGrid"></div>
<div class="section">
<h3>Top Klubovi (po aktivnosti)</h3>
<table id="topKlubovi"><thead><tr><th>Naziv</th><th>Sport</th><th>Grad</th><th class="num">Članovi</th><th class="num">Računi</th></tr></thead><tbody></tbody></table>
</div>
</div>
<!-- ERP -->
<div class="tab-content" id="tab-erp">
<div class="kpi-grid" id="erpKpi"></div>
<div class="section">
<h3>Računi</h3>
<table id="invTable"><thead><tr><th>Broj</th><th>Dobavljač</th><th>Klub</th><th class="num">Iznos</th><th>Status</th><th>Datum</th></tr></thead><tbody></tbody></table>
</div>
<div class="section">
<h3>Putni nalozi / izdaci</h3>
<table id="expTable"><thead><tr><th>Broj</th><th>Klub</th><th>Destinacija</th><th class="num">Iznos</th><th>Status</th><th>Datum</th></tr></thead><tbody></tbody></table>
</div>
</div>
<!-- CRM klubovi -->
<div class="tab-content" id="tab-crm">
<input type="text" class="search" id="klubSearch" placeholder="Traži klub po imenu, OIB-u, gradu, sportu...">
<div class="section" style="margin-top: 14px;">
<h3>Klubovi</h3>
<table id="klubTable"><thead><tr><th>Naziv</th><th>OIB</th><th>Sport</th><th>Grad</th><th>Email</th><th class="num">Članovi</th><th class="num">Računi</th></tr></thead><tbody></tbody></table>
</div>
</div>
<!-- Osobe -->
<div class="tab-content" id="tab-osobe">
<input type="text" class="search" id="osobaSearch" placeholder="Traži po imenu, prezimenu, OIB-u...">
<div class="section" style="margin-top: 14px;">
<h3>Kontakti / Članovi</h3>
<table id="osobeTable"><thead><tr><th>Ime</th><th>Prezime</th><th>OIB</th><th>Klub</th><th>Pozicija</th><th>Email</th><th>Status</th></tr></thead><tbody></tbody></table>
</div>
</div>
<!-- 3D Graph -->
<div class="tab-content" id="tab-graph3d">
<div class="section">
<h3>3D Sport Graph</h3>
<p style="color: var(--text-3); margin-bottom: 12px;">Interaktivni 3D prikaz svih klubova, saveza i osoba s drill-down na detalje.</p>
<div class="iframe-wrap">
<iframe id="graph3dIframe" loading="lazy"></iframe>
</div>
</div>
</div>
<!-- Tenants -->
<div class="tab-content" id="tab-tenants">
<div class="section">
<h3>Multi-tenant Management</h3>
<p style="color: var(--text-3); margin-bottom: 16px;">Tenants u sustavu. Svaki tenant ima vlastiti scope klubova, financija i konfiguracije.</p>
<div class="tenants-grid" id="tenantsGrid"></div>
</div>
</div>
<!-- Reports -->
<div class="tab-content" id="tab-reports">
<div class="section">
<h3>Top 10 Klubova (po dokumentima i računima)</h3>
<table id="repTable"><thead><tr><th>Naziv</th><th>Sport</th><th>Grad</th><th class="num">Računi</th><th class="num">Članovi</th></tr></thead><tbody></tbody></table>
</div>
</div>
</main>
</div>
<script>
const API = '/admin/api';
let currentTenant = 1;
let dashboardData = null;
let tenantsList = [];
const $ = sel => document.querySelector(sel);
const $$ = sel => document.querySelectorAll(sel);
async function fetchJSON(url) {
try {
const r = await fetch(url);
if (!r.ok) throw new Error(r.status);
return await r.json();
} catch (e) { console.error('Fetch fail:', url, e); return null; }
}
function fmt(n) {
if (n == null) return '—';
if (typeof n !== 'number') return n;
return new Intl.NumberFormat('hr-HR').format(n);
}
function fmtEur(n) { return n != null ? '€' + fmt(Math.round(n)) : '—'; }
function fmtDate(d) { return d ? d.substring(0, 10) : '—'; }
function badge(text, color) { return '<span class="badge ' + color + '">' + (text || '—') + '</span>'; }
function statusBadge(s) {
if (!s) return badge('—', 'gray');
const s2 = s.toLowerCase();
if (['paid', 'approved', 'active', 'completed'].includes(s2)) return badge(s, 'green');
if (['pending', 'submitted', 'draft', 'open'].includes(s2)) return badge(s, 'yellow');
if (['overdue', 'rejected', 'cancelled', 'failed'].includes(s2)) return badge(s, 'red');
return badge(s, 'gray');
}
async function loadDashboard() {
const d = await fetchJSON(`${API}/dashboard?tenant_id=${currentTenant}`);
if (!d) return;
dashboardData = d;
const k = d.kpi;
$('#kpiGrid').innerHTML = `
<div class="kpi-card"><div class="kpi-label">Klubovi</div><div class="kpi-value">${fmt(k.klubovi_total)}</div><div class="kpi-sub">${fmt(k.klubovi_aktivni_90d)} aktivnih /90d</div></div>
<div class="kpi-card green"><div class="kpi-label">Osobe</div><div class="kpi-value">${fmt(k.osobe)}</div><div class="kpi-sub">članovi i kontakti</div></div>
<div class="kpi-card yellow"><div class="kpi-label">Računi</div><div class="kpi-value">${fmt(k.invoices)}</div><div class="kpi-sub">${fmtEur(k.invoices_total_eur)}</div></div>
<div class="kpi-card purple"><div class="kpi-label">Putni nalozi</div><div class="kpi-value">${fmt(k.expenses)}</div><div class="kpi-sub">${fmtEur(k.expenses_total_eur)}</div></div>
<div class="kpi-card"><div class="kpi-label">Aktivnost</div><div class="kpi-value">${fmt(k.activity_30d)}</div><div class="kpi-sub">audit eventova /30d</div></div>
<div class="kpi-card green"><div class="kpi-label">Dokumenti</div><div class="kpi-value">${fmt(k.dokumenti_7d)}</div><div class="kpi-sub">novih /7d</div></div>
`;
// Top klubovi
const top = await fetchJSON(`${API}/reports/top_klubovi?tenant_id=${currentTenant}&limit=8`);
if (top && top.top_klubovi) {
$('#topKlubovi tbody').innerHTML = top.top_klubovi.map(k => `
<tr><td>${k.naziv}</td><td>${k.sport || '—'}</td><td>${k.grad || '—'}</td>
<td class="num">${fmt(k.clanovi)}</td><td class="num">${fmt(k.invoices)}</td></tr>
`).join('');
}
$('#metaInfo').textContent = `Tenant: ${d.tenant.display_name} · OIB: ${d.tenant.oib || '—'} · ${new Date().toLocaleString('hr-HR')}`;
}
async function loadERP() {
const s = await fetchJSON(`${API}/erp/summary?tenant_id=${currentTenant}`);
if (s) {
$('#erpKpi').innerHTML = `
<div class="kpi-card"><div class="kpi-label">Računi total</div><div class="kpi-value">${fmt(s.invoices.total)}</div><div class="kpi-sub">${fmtEur(s.invoices.sum_total)}</div></div>
<div class="kpi-card green"><div class="kpi-label">Plaćeno</div><div class="kpi-value">${fmt(s.invoices.paid)}</div><div class="kpi-sub">${fmtEur(s.invoices.sum_paid)}</div></div>
<div class="kpi-card yellow"><div class="kpi-label">Neplaćeno</div><div class="kpi-value">${fmt(s.invoices.pending + s.invoices.overdue + (s.invoices.other||0))}</div><div class="kpi-sub">${fmtEur(s.invoices.sum_unpaid)}</div></div>
<div class="kpi-card purple"><div class="kpi-label">Putni nalozi</div><div class="kpi-value">${fmt(s.expenses.total)}</div><div class="kpi-sub">${fmtEur(s.expenses.sum_total)}</div></div>
<div class="kpi-card"><div class="kpi-label">Plaćanja /90d</div><div class="kpi-value">${fmt(s.payments_90d.total)}</div><div class="kpi-sub">${fmtEur(s.payments_90d.sum_total)}</div></div>
<div class="kpi-card green"><div class="kpi-label">Proračun</div><div class="kpi-value">${fmtEur(s.proracun.sum_planirano)}</div><div class="kpi-sub">${s.proracun.n} godina · izvršeno: ${fmtEur(s.proracun.sum_izvrseno)}</div></div>
`;
}
const inv = await fetchJSON(`${API}/erp/invoices?tenant_id=${currentTenant}&limit=20`);
if (inv && inv.invoices) {
$('#invTable tbody').innerHTML = inv.invoices.length ? inv.invoices.map(i => `
<tr><td>${i.invoice_no || '—'}</td><td>${i.vendor_name || '—'}</td>
<td>${i.klub_naziv || '—'}</td><td class="num">${fmtEur(i.amount_gross)}</td>
<td>${statusBadge(i.payment_status)}</td><td>${fmtDate(i.invoice_date)}</td></tr>
`).join('') : '<tr><td colspan="6" style="color:var(--text-3);text-align:center;padding:20px">Nema podataka</td></tr>';
}
const exp = await fetchJSON(`${API}/erp/expenses?tenant_id=${currentTenant}&limit=20`);
if (exp && exp.expenses) {
$('#expTable tbody').innerHTML = exp.expenses.length ? exp.expenses.map(e => `
<tr><td>${e.report_no || '—'}</td><td>${e.klub_naziv || '—'}</td>
<td>${e.destination || '—'}</td><td class="num">${fmtEur(e.cost_total)}</td>
<td>${statusBadge(e.status)}</td><td>${fmtDate(e.created_at)}</td></tr>
`).join('') : '<tr><td colspan="6" style="color:var(--text-3);text-align:center;padding:20px">Nema podataka</td></tr>';
}
}
async function loadCRM(q='') {
const url = `${API}/crm/klubovi?tenant_id=${currentTenant}&limit=50${q ? '&q=' + encodeURIComponent(q) : ''}`;
const d = await fetchJSON(url);
if (d && d.klubovi) {
$('#klubTable tbody').innerHTML = d.klubovi.map(k => `
<tr><td><strong>${k.naziv}</strong></td><td>${k.oib || '—'}</td>
<td>${k.sport || '—'}</td><td>${k.grad || '—'}</td>
<td>${k.email || '—'}</td><td class="num">${fmt(k.clanovi)}</td>
<td class="num">${fmt(k.invoices_count)}</td></tr>
`).join('');
}
}
async function loadOsobe(q='') {
const url = `${API}/crm/osobe?limit=50${q ? '&q=' + encodeURIComponent(q) : ''}`;
const d = await fetchJSON(url);
if (d && d.osobe) {
$('#osobeTable tbody').innerHTML = d.osobe.map(o => `
<tr><td>${o.ime}</td><td><strong>${o.prezime}</strong></td>
<td>${o.oib || '—'}</td><td>${o.klub_naziv || '—'}</td>
<td>${o.pozicija || '—'}</td><td>${o.email || '—'}</td>
<td>${o.aktivan ? badge('Aktivan', 'green') : badge('Neaktivan', 'gray')}</td></tr>
`).join('');
}
}
async function loadTenants() {
const d = await fetchJSON(`${API}/tenants`);
if (d && d.tenants) {
$('#tenantsGrid').innerHTML = d.tenants.map(t => `
<div class="tenant-card">
<div class="name">${t.display_name}</div>
<div class="slug">@${t.slug} · ${t.type} · ${t.oib || 'no OIB'}</div>
<div class="stats">
<div class="stat"><strong>${fmt(t.klubovi_count || 0)}</strong>klubovi</div>
<div class="stat"><strong>${statusBadge(t.status).match(/>([^<]+)</)[1]}</strong>status</div>
</div>
</div>
`).join('');
}
}
async function loadReports() {
const d = await fetchJSON(`${API}/reports/top_klubovi?tenant_id=${currentTenant}&limit=20`);
if (d && d.top_klubovi) {
$('#repTable tbody').innerHTML = d.top_klubovi.map(k => `
<tr><td>${k.naziv}</td><td>${k.sport || '—'}</td><td>${k.grad || '—'}</td>
<td class="num">${fmt(k.invoices)}</td><td class="num">${fmt(k.clanovi)}</td></tr>
`).join('');
}
}
function load3D() {
const f = $('#graph3dIframe');
if (!f.src) f.src = '/3d';
}
async function loadTenantSelector() {
const d = await fetchJSON(`${API}/tenants`);
if (d && d.tenants) {
tenantsList = d.tenants;
$('#tenantSel').innerHTML = d.tenants.map(t =>
`<option value="${t.id}" ${t.id === currentTenant ? 'selected' : ''}>${t.display_name}</option>`
).join('');
}
}
function activateTab(name) {
$$('.nav-item').forEach(n => n.classList.toggle('active', n.dataset.tab === name));
$$('.tab-content').forEach(c => c.classList.toggle('active', c.id === 'tab-' + name));
const titles = {
dashboard: 'Dashboard',
erp: 'ERP — Financije',
crm: 'CRM — Klubovi',
osobe: 'Kontakti',
graph3d: '3D Graf',
tenants: 'Multi-tenant',
reports: 'Reports'
};
$('#pageTitle').textContent = titles[name] || name;
if (name === 'dashboard') loadDashboard();
if (name === 'erp') loadERP();
if (name === 'crm') loadCRM();
if (name === 'osobe') loadOsobe();
if (name === 'graph3d') load3D();
if (name === 'tenants') loadTenants();
if (name === 'reports') loadReports();
}
// Init
$$('.nav-item').forEach(n => n.addEventListener('click', () => activateTab(n.dataset.tab)));
let searchTimeout;
$('#klubSearch').addEventListener('input', e => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => loadCRM(e.target.value), 300);
});
$('#osobaSearch').addEventListener('input', e => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => loadOsobe(e.target.value), 300);
});
$('#tenantSel').addEventListener('change', e => {
currentTenant = parseInt(e.target.value);
activateTab($('.nav-item.active').dataset.tab);
});
(async () => {
await loadTenantSelector();
await loadDashboard();
})();
</script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

+1146
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+99
View File
@@ -0,0 +1,99 @@
<!DOCTYPE html>
<html lang="hr">
<head>
<meta charset="UTF-8">
<title>RINET KPI Dashboard</title>
<style>
body { font-family: -apple-system, sans-serif; background: #0a0e1a; color: #d0d8e8; margin: 0; padding: 20px; }
h1 { color: #4af; margin: 0 0 20px; font-size: 24px; }
h2 { color: #6cf; margin: 20px 0 8px; font-size: 16px; border-bottom: 1px solid #2a3a4a; padding-bottom: 4px; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 12px; margin-bottom: 16px; }
.card { background: #14192a; padding: 12px 16px; border-radius: 6px; border-left: 3px solid #4af; }
.card .label { color: #88a; font-size: 11px; text-transform: uppercase; }
.card .value { color: #fff; font-size: 22px; font-weight: bold; margin: 4px 0; }
.card .sub { color: #aab; font-size: 12px; }
.card.good { border-left-color: #4f4; }
.card.warn { border-left-color: #fa4; }
.card.bad { border-left-color: #f44; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 6px 12px; border-bottom: 1px solid #2a3a4a; font-size: 12px; }
th { color: #6cf; font-weight: normal; text-transform: uppercase; font-size: 10px; }
tr:hover { background: #1a2030; }
.updated { color: #678; font-size: 11px; }
.refresh { background: #4af; color: #fff; border: none; padding: 4px 12px; border-radius: 4px; cursor: pointer; }
</style>
</head>
<body>
<h1>RINET KPI Dashboard <span class="updated" id="updated"></span> <button class="refresh" onclick="load()"></button></h1>
<div id="root">Loading...</div>
<script>
async function load() {
document.getElementById('updated').textContent = '...';
try {
const r = await fetch('/admin/api/kpi');
const d = await r.json();
if (d.error) {
document.getElementById('root').innerHTML = '<div class="card bad">Error: ' + d.error + '</div>';
return;
}
const haluClass = d.queries.halu_pct > 5 ? 'bad' : d.queries.halu_pct > 1 ? 'warn' : 'good';
const clusterTotal = Object.values(d.cluster).reduce((a,b)=>a+b, 0);
const clusterUnhealthy = Object.entries(d.cluster).filter(([s,n]) => !['healthy','skipped'].includes(s)).reduce((a,[s,n])=>a+n, 0);
const clusterClass = clusterUnhealthy > 0 ? 'bad' : 'good';
const incClass = d.open_incidents > 0 ? 'warn' : 'good';
const embClass = d.knowledge.embed_pct >= 99 ? 'good' : d.knowledge.embed_pct >= 95 ? 'warn' : 'bad';
let html = `
<h2>Queries (Production)</h2>
<div class="grid">
<div class="card good"><div class="label">Last 1h</div><div class="value">${d.queries.h1}</div></div>
<div class="card good"><div class="label">Last 24h</div><div class="value">${d.queries.h24}</div></div>
<div class="card ${haluClass}"><div class="label">Halucinacije 24h</div><div class="value">${d.queries.halucinacije_h24}</div><div class="sub">${d.queries.halu_pct}%</div></div>
<div class="card good"><div class="label">Avg latency</div><div class="value">${d.queries.avg_latency_sec}s</div></div>
<div class="card good"><div class="label">Avg confidence</div><div class="value">${d.queries.avg_confidence}</div></div>
</div>
<h2>Knowledge Base</h2>
<div class="grid">
<div class="card good"><div class="label">Total facts</div><div class="value">${d.knowledge.total.toLocaleString()}</div></div>
<div class="card good"><div class="label">Added 1h / 24h</div><div class="value">+${d.knowledge.added_h1} / +${d.knowledge.added_h24}</div></div>
<div class="card ${embClass}"><div class="label">Embed coverage</div><div class="value">${d.knowledge.embed_pct}%</div><div class="sub">${d.knowledge.embed_pending} pending</div></div>
<div class="card good"><div class="label">Training Q&A</div><div class="value">${d.training.total.toLocaleString()}</div><div class="sub">+${d.training.added_h24} / 24h, ${d.training.from_capture} from capture</div></div>
</div>
<h2>Cluster Health</h2>
<div class="grid">
<div class="card ${clusterClass}"><div class="label">Healthy</div><div class="value">${d.cluster.healthy || 0} / ${clusterTotal}</div></div>
<div class="card ${incClass}"><div class="label">Open incidents</div><div class="value">${d.open_incidents}</div></div>
<div class="card good"><div class="label">Skipped</div><div class="value">${d.cluster.skipped || 0}</div><div class="sub">PG/Redis/cold by design</div></div>
<div class="card ${clusterUnhealthy>0?'bad':'good'}"><div class="label">Unhealthy</div><div class="value">${clusterUnhealthy}</div></div>
</div>
<h2>Top Sources (24h scrape)</h2>
<table>
<tr><th>Source</th><th>Count</th></tr>
${d.top_sources_h24.map(s => `<tr><td>${s.source}</td><td>${s.count.toLocaleString()}</td></tr>`).join('')}
</table>
<h2>Top Models (24h)</h2>
<table>
<tr><th>Model</th><th>Calls</th><th>Avg latency</th></tr>
${d.top_models_h24.map(m => `<tr><td>${m.model || '-'}</td><td>${m.count}</td><td>${m.avg_latency}s</td></tr>`).join('')}
</table>
`;
document.getElementById('root').innerHTML = html;
document.getElementById('updated').textContent = new Date().toLocaleTimeString();
} catch (e) {
document.getElementById('root').innerHTML = '<div class="card bad">Network error: ' + e.message + '</div>';
}
}
load();
setInterval(load, 30000); // 30s refresh
</script>
</body>
</html>
+1283
View File
File diff suppressed because it is too large Load Diff
+1867
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+533
View File
@@ -0,0 +1,533 @@
<!DOCTYPE html>
<html lang="hr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>3D Mreža · PGŽ Sport</title>
<style>
:root {
--bg: #06080d;
--bg-2: #0d1117;
--bg-3: #161b22;
--border: #1f2937;
--text: #e6edf3;
--text-2: #8b949e;
--text-3: #6e7681;
--accent: #00f0ff;
--gold: #FFD700;
--green: #22c55e;
--cyan: #06b6d4;
--red: #ef4444;
--purple: #a78bfa;
--orange: #f59e0b;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Inter', -apple-system, system-ui, sans-serif;
background: var(--bg);
color: var(--text);
height: 100vh;
overflow: hidden;
font-size: 13px;
}
.header {
position: fixed;
top: 0; left: 0; right: 0;
background: rgba(13, 17, 23, 0.95);
backdrop-filter: blur(8px);
border-bottom: 1px solid var(--border);
padding: 10px 16px;
z-index: 10;
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
}
.header h1 { font-size: 16px; font-weight: 700; color: var(--accent); margin-right: auto; }
.header h1 .meta { font-size: 11px; color: var(--text-3); margin-left: 8px; font-weight: 400; }
.ctrl-group { display: flex; gap: 6px; align-items: center; }
.ctrl-group label { font-size: 11px; color: var(--text-3); }
.btn, select, input {
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
padding: 5px 9px;
border-radius: 5px;
font-size: 11px;
font-family: inherit;
}
.btn { cursor: pointer; transition: all 0.15s; }
.btn:hover { border-color: var(--accent); color: var(--accent); }
.btn.active { background: var(--accent); color: #000; border-color: var(--accent); font-weight: 600; }
input { width: 160px; }
input:focus, select:focus { outline: none; border-color: var(--accent); }
#graph {
position: fixed;
inset: 56px 0 0 0;
width: 100%;
height: calc(100vh - 56px);
background: #050608;
}
.hud {
position: fixed;
top: 70px; left: 12px;
background: rgba(13, 17, 23, 0.92);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px 16px;
font-size: 11px;
z-index: 5;
min-width: 200px;
line-height: 1.7;
}
.hud .row { display: flex; justify-content: space-between; gap: 14px; }
.hud .row b { color: var(--accent); font-family: 'JetBrains Mono', monospace; }
.hud .row.gold b { color: var(--gold); }
.legend {
position: fixed;
bottom: 12px; left: 12px;
background: rgba(13, 17, 23, 0.92);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 14px;
font-size: 11px;
display: flex;
gap: 14px;
z-index: 5;
}
.dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; margin-right: 5px; vertical-align: middle; }
.tooltip {
position: fixed;
background: rgba(10, 13, 20, 0.97);
border: 1px solid var(--gold);
border-radius: 6px;
padding: 10px 14px;
font-size: 12px;
pointer-events: none;
display: none;
z-index: 100;
max-width: 280px;
line-height: 1.6;
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
}
.tooltip b { color: var(--gold); }
.tooltip .typ { color: var(--text-3); font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; }
#detail-panel {
position: fixed;
top: 70px; right: 12px;
width: 360px;
max-height: calc(100vh - 92px);
overflow-y: auto;
background: rgba(13, 17, 23, 0.96);
border: 1px solid var(--gold);
border-radius: 8px;
padding: 16px;
font-size: 12px;
z-index: 7;
display: none;
line-height: 1.6;
}
#detail-panel h3 { color: var(--gold); font-size: 14px; margin-bottom: 8px; }
#detail-panel .field { display: flex; justify-content: space-between; padding: 4px 0; border-bottom: 1px solid var(--border); }
#detail-panel .field b { color: var(--text); }
#detail-panel .close-btn { float: right; cursor: pointer; color: var(--text-3); font-size: 16px; line-height: 1; }
#detail-panel .close-btn:hover { color: var(--accent); }
#detail-panel .section { margin-top: 12px; }
#detail-panel .section h4 { font-size: 11px; color: var(--accent); text-transform: uppercase; margin-bottom: 6px; letter-spacing: 0.5px; }
#detail-panel a { color: var(--cyan); text-decoration: none; }
#detail-panel a:hover { color: var(--accent); }
.spinner {
position: fixed;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
width: 40px; height: 40px;
border: 3px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 1s linear infinite;
z-index: 20;
display: none;
}
@keyframes spin { to { transform: translate(-50%, -50%) rotate(360deg); } }
.show { display: block !important; }
</style>
</head>
<body>
<div class="header">
<h1>⚡ 3D Mreža PGŽ Sport <span class="meta">klubovi · savezi · osobe · drill-down</span></h1>
<div class="ctrl-group">
<label>Mode:</label>
<select id="mode">
<option value="multichair">Multi-chair osobe</option>
<option value="all">Sve veze</option>
<option value="savezi">Po savezima</option>
</select>
</div>
<div class="ctrl-group">
<label>Sport:</label>
<select id="sport"><option value="">Svi sportovi</option></select>
</div>
<div class="ctrl-group">
<label>Min org:</label>
<input type="number" id="minOrgs" value="2" min="1" max="10" style="width: 60px;">
</div>
<div class="ctrl-group">
<label>Limit:</label>
<button class="btn" data-limit="100">100</button>
<button class="btn active" data-limit="200">200</button>
<button class="btn" data-limit="500">500</button>
</div>
<div class="ctrl-group">
<input type="text" id="search" placeholder="🔍 Highlight (ime osobe ili klub)...">
</div>
<button class="btn" onclick="loadGraph()" style="background: var(--accent); color: #000; font-weight: 600;">↻ Osvježi</button>
<button class="btn" onclick="resetCamera()">⊕ Reset kamera</button>
</div>
<div id="graph"></div>
<div class="spinner" id="spinner"></div>
<div class="hud" id="hud">
<div class="row"><span>Čvorovi:</span><b id="s-nodes"></b></div>
<div class="row"><span>Veze:</span><b id="s-links"></b></div>
<div class="row"><span>Osobe:</span><b id="s-persons"></b></div>
<div class="row gold"><span>Multi-chair:</span><b id="s-mc"></b></div>
<div class="row"><span>Klubovi:</span><b id="s-klubovi"></b></div>
<div class="row"><span>Savezi:</span><b id="s-savezi"></b></div>
</div>
<div class="legend">
<span><span class="dot" style="background: #FFD700"></span>Multi-chair</span>
<span><span class="dot" style="background: #22c55e"></span>Osoba</span>
<span><span class="dot" style="background: #06b6d4"></span>Savez</span>
<span><span class="dot" style="background: #a78bfa"></span>Klub</span>
<span style="color: var(--text-3); margin-left: 12px;">Klik = drill-down · Mouse = rotacija · Scroll = zoom</span>
</div>
<div class="tooltip" id="tooltip"></div>
<div id="detail-panel">
<span class="close-btn" onclick="closeDetail()">×</span>
<h3 id="dp-title"></h3>
<div id="dp-content"></div>
</div>
<script src="https://unpkg.com/three@0.160.0/build/three.min.js"></script>
<script src="https://unpkg.com/3d-force-graph@1.73.4/dist/3d-force-graph.min.js"></script>
<script>
const COLOR = {
multichair: '#FFD700',
person: '#22c55e',
savez: '#06b6d4',
klub: '#a78bfa',
highlight: '#FFD700'
};
let Graph;
let currentLimit = 200;
let lastData = null;
const $ = s => document.querySelector(s);
function showSpinner(b) { $('#spinner').classList.toggle('show', b); }
async function loadSports() {
try {
const r = await fetch('/sport/api/v2/dashboard/sport-stats');
const d = await r.json();
const sel = $('#sport');
const sports = ((d.klub_breakdown || []).map(k => k.sport).filter(Boolean));
[...new Set(sports)].sort().forEach(s => {
const o = document.createElement('option'); o.value = s; o.textContent = s;
sel.appendChild(o);
});
} catch(e) { console.warn('sports load fail', e); }
}
async function loadGraph() {
showSpinner(true);
const minOrgs = $('#minOrgs').value || 2;
const sport = $('#sport').value || '';
const mode = $('#mode').value;
let url;
if (mode === 'multichair') {
url = `/sport/api/v2/graph/3d-network?min_orgs=${minOrgs}&top_n=${currentLimit}&sport=${encodeURIComponent(sport)}`;
} else if (mode === 'all') {
url = `/sport/api/v2/graph/3d-network?min_orgs=1&top_n=${currentLimit}&sport=${encodeURIComponent(sport)}`;
} else {
url = `/sport/api/v2/graph/3d-network?min_orgs=${minOrgs}&top_n=${currentLimit}&sport=${encodeURIComponent(sport)}`;
}
try {
const r = await fetch(url);
const d = await r.json();
lastData = d;
// Update HUD
const stats = d.stats || {};
$('#s-nodes').textContent = (d.nodes || []).length;
$('#s-links').textContent = (d.links || []).length;
$('#s-persons').textContent = stats.persons || 0;
$('#s-mc').textContent = stats.multichair || 0;
$('#s-klubovi').textContent = (d.nodes || []).filter(n => n.type === 'klub').length;
$('#s-savezi').textContent = (d.nodes || []).filter(n => n.type === 'savez').length;
renderGraph(d);
} catch(e) {
console.error('graph load fail', e);
alert('Greška pri učitavanju grafa: ' + e.message);
}
showSpinner(false);
}
function renderGraph(data) {
const highlightQ = $('#search').value.toLowerCase();
if (!Graph) {
Graph = ForceGraph3D()(document.getElementById('graph'))
.backgroundColor('#050608')
.nodeColor(getNodeColor)
.nodeVal(getNodeVal)
.nodeOpacity(0.95)
.linkColor(() => 'rgba(148,163,184,0.18)')
.linkWidth(0.6)
.linkOpacity(0.4)
.nodeLabel(() => '') // koristimo custom tooltip
.onNodeHover(handleNodeHover)
.onNodeClick(handleNodeClick)
.graphData(data);
} else {
Graph.graphData(data);
Graph.nodeColor(getNodeColor);
Graph.nodeVal(getNodeVal);
}
}
function getNodeColor(n) {
const q = $('#search').value.toLowerCase();
if (q && n.name && n.name.toLowerCase().includes(q)) return COLOR.highlight;
return COLOR[n.type] || '#94a3b8';
}
function getNodeVal(n) {
const q = $('#search').value.toLowerCase();
const base = n.val || 5;
if (q && n.name && n.name.toLowerCase().includes(q)) return base * 2.5;
return base;
}
function handleNodeHover(n) {
const tt = $('#tooltip');
if (n) {
tt.style.display = 'block';
let html = `<b>${n.name || '—'}</b><div class="typ">${n.type}</div>`;
if (n.n_orgs) html += `<br>📊 ${n.n_orgs} organizacija`;
if (n.sport) html += `<br>⚡ Sport: ${n.sport}`;
if (n.id && n.id.startsWith('klub:')) html += `<br>🆔 Klub ID: ${n.id.split(':')[1]}`;
if (n.id && n.id.startsWith('savez:')) html += `<br>🆔 Savez ID: ${n.id.split(':')[1]}`;
html += `<br><span style="color:#888;font-size:10px;margin-top:4px;display:inline-block">▸ Klik za detalje</span>`;
tt.innerHTML = html;
} else {
tt.style.display = 'none';
}
}
document.addEventListener('mousemove', e => {
const tt = $('#tooltip');
tt.style.left = (e.clientX + 14) + 'px';
tt.style.top = (e.clientY + 14) + 'px';
});
async function handleNodeClick(n) {
// Camera focus
const dist = 80;
const distRatio = 1 + dist / Math.hypot(n.x, n.y, n.z);
Graph.cameraPosition({
x: n.x * distRatio,
y: n.y * distRatio,
z: n.z * distRatio
}, n, 1500);
// Open detail panel based on node type
const panel = $('#detail-panel');
panel.classList.add('show');
$('#dp-title').textContent = n.name || '—';
$('#dp-content').innerHTML = '<div style="color:var(--text-3)">Učitavam detalje...</div>';
try {
if (n.id && n.id.startsWith('klub:')) {
const klubId = n.id.split(':')[1];
const r = await fetch(`/admin/api/crm/klub/${klubId}`);
const d = await r.json();
renderKlubDetail(d);
} else if (n.type === 'multichair' || n.type === 'person') {
renderPersonDetail(n);
} else if (n.id && n.id.startsWith('savez:')) {
const savezId = n.id.split(':')[1];
renderSavezDetail(n, savezId);
} else {
$('#dp-content').innerHTML = `<div class="field"><span>Tip:</span><b>${n.type}</b></div>
<div class="field"><span>ID:</span><b>${n.id}</b></div>`;
}
} catch(e) {
$('#dp-content').innerHTML = `<div style="color:var(--red)">Greška: ${e.message}</div>`;
}
}
function renderKlubDetail(d) {
const k = d.klub || {};
let html = `
<div class="field"><span>OIB</span><b>${k.oib || '—'}</b></div>
<div class="field"><span>Sport</span><b>${k.sport || '—'}</b></div>
<div class="field"><span>Grad</span><b>${k.grad || '—'}</b></div>
<div class="field"><span>Adresa</span><b>${k.adresa || '—'}</b></div>
<div class="field"><span>Email</span><b>${k.email || '—'}</b></div>
<div class="field"><span>Telefon</span><b>${k.telefon || '—'}</b></div>
<div class="field"><span>Predsjednik</span><b>${k.predsjednik || '—'}</b></div>
<div class="field"><span>Tajnik</span><b>${k.tajnik || '—'}</b></div>
<div class="field"><span>Trener</span><b>${k.trener_glavni || '—'}</b></div>
<div class="field"><span>Br. članova</span><b>${k.broj_clanova || '—'}</b></div>
<div class="field"><span>Aktivnih sportaša</span><b>${k.broj_aktivnih_sportasa || '—'}</b></div>
<div class="field"><span>Aktivan</span><b>${k.aktivan ? '✅ Da' : '❌ Ne'}</b></div>
`;
if (k.web) html += `<div class="field"><span>Web</span><a href="${k.web}" target="_blank">${k.web}</a></div>`;
if (d.dokumenti && d.dokumenti.length) {
html += `<div class="section"><h4>Dokumenti (${d.dokumenti.length})</h4>`;
d.dokumenti.slice(0, 8).forEach(doc => {
html += `<div class="field"><span>${doc.naziv || doc.title || '—'}</span><b style="font-size:10px">${doc.vrsta || '—'}</b></div>`;
});
html += `</div>`;
}
if (d.invoices && d.invoices.length) {
html += `<div class="section"><h4>Računi (${d.invoices.length})</h4>`;
d.invoices.slice(0, 5).forEach(inv => {
html += `<div class="field"><span>${inv.invoice_no || '—'}</span><b>€${(inv.amount_gross || 0).toLocaleString('hr-HR')}</b></div>`;
});
html += `</div>`;
}
html += `<div class="section"><h4>Akcije</h4>
<a href="/sport/klubovi/${d.klub.id}" target="_blank">▸ Otvori detalj klub</a>
</div>`;
$('#dp-content').innerHTML = html;
}
function renderPersonDetail(n) {
let html = `
<div class="field"><span>Tip</span><b>${n.type === 'multichair' ? '⭐ Multi-chair osoba' : 'Osoba'}</b></div>
<div class="field"><span>Org. broj</span><b>${n.n_orgs || '?'}</b></div>
`;
// Pronađi sve veze za ovu osobu
if (lastData && lastData.links) {
const myLinks = lastData.links.filter(l =>
(typeof l.source === 'object' ? l.source.id : l.source) === n.id
);
if (myLinks.length) {
html += `<div class="section"><h4>Pozicije (${myLinks.length})</h4>`;
myLinks.forEach(l => {
const targetNode = typeof l.target === 'object' ? l.target :
(lastData.nodes.find(x => x.id === l.target));
if (targetNode) {
const orgName = targetNode.name || targetNode.id;
const role = l.role || '—';
html += `<div class="field"><span>${orgName}</span><b>${role}</b></div>`;
}
});
html += `</div>`;
}
}
if (n.n_orgs >= 3) {
html += `<div class="section" style="border:1px solid var(--orange);background:rgba(245,158,11,0.1);padding:10px;border-radius:6px;margin-top:12px">
<div style="color:var(--orange);font-weight:600">⚠️ Forenzički flag</div>
<div style="font-size:11px;color:var(--text-2);margin-top:4px">
Ova osoba sjedi na ${n.n_orgs} stolica. Sukob interesa moguć — provjeri financijske transfere između organizacija.
</div>
</div>`;
}
$('#dp-content').innerHTML = html;
}
function renderSavezDetail(n, id) {
let html = `<div class="field"><span>Tip</span><b>Savez</b></div>
<div class="field"><span>ID</span><b>${id}</b></div>`;
if (lastData && lastData.links) {
const klubovi = lastData.links.filter(l =>
(typeof l.target === 'object' ? l.target.id : l.target) === n.id
);
if (klubovi.length) {
html += `<div class="section"><h4>Funkcioneri (${klubovi.length})</h4>`;
klubovi.forEach(l => {
const src = typeof l.source === 'object' ? l.source : null;
if (src) {
html += `<div class="field"><span>${src.name || '?'}</span><b>${l.role || '—'}</b></div>`;
}
});
html += `</div>`;
}
}
$('#dp-content').innerHTML = html;
}
function closeDetail() {
$('#detail-panel').classList.remove('show');
}
function resetCamera() {
if (Graph) Graph.cameraPosition({x:0,y:0,z:300}, {x:0,y:0,z:0}, 1500);
}
// Limit buttons
document.querySelectorAll('.btn[data-limit]').forEach(b => {
b.addEventListener('click', () => {
document.querySelectorAll('.btn[data-limit]').forEach(x => x.classList.remove('active'));
b.classList.add('active');
currentLimit = parseInt(b.dataset.limit);
loadGraph();
});
});
// Search highlight (real-time)
let searchTimeout;
$('#search').addEventListener('input', () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
if (Graph && lastData) renderGraph(lastData);
}, 200);
});
// Mode change auto-reload
$('#mode').addEventListener('change', loadGraph);
$('#sport').addEventListener('change', loadGraph);
$('#minOrgs').addEventListener('change', loadGraph);
// Init
loadSports().then(loadGraph);
</script>
</body>
</html>
+372
View File
@@ -0,0 +1,372 @@
<!DOCTYPE html>
<html lang="hr"><head><meta charset="utf-8"><title>3D Sport Network · PGŽ</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{background:#06080d;color:#e8eaf0;font-family:system-ui,sans-serif;overflow:hidden}
#g{width:100vw;height:100vh;position:fixed;inset:0}
.panel{position:fixed;background:rgba(10,14,24,.92);border:1px solid #1e293b;border-radius:8px;
backdrop-filter:blur(8px);z-index:100}
#hud{top:12px;left:12px;padding:12px 16px;font-size:11px;line-height:1.7;min-width:240px}
#hud .h{font-size:13px;font-weight:600;color:#00f0ff;margin-bottom:8px;display:flex;justify-content:space-between;align-items:center}
#hud .row{display:flex;justify-content:space-between;gap:16px}
#hud b{color:#fbbf24}
#ctrl{top:12px;right:12px;padding:10px 12px;width:240px}
#ctrl .h{font-size:11px;color:#94a3b8;margin-bottom:6px;text-transform:uppercase;letter-spacing:.5px}
input,select{width:100%;background:#0d1117;color:#e8eaf0;border:1px solid #1e293b;
padding:6px 8px;border-radius:4px;font-size:11px;margin:3px 0;font-family:inherit}
input:focus,select:focus{outline:none;border-color:#00f0ff}
button{cursor:pointer;font-weight:600}
.btn-pri{background:#00f0ff;color:#000;border:none;padding:6px 12px;border-radius:4px;font-size:11px;width:100%;margin-top:6px}
.btn-pri:hover{background:#22d3ee}
.btn-lim{background:#0d1117;color:#94a3b8;border:1px solid #1e293b;padding:4px 8px;border-radius:4px;font-size:10px;margin:0 2px}
.btn-lim.act{background:#00f0ff;color:#000;border-color:#00f0ff}
#leg{bottom:12px;left:12px;padding:8px 12px;font-size:10px;display:flex;gap:14px;align-items:center}
.dot{width:9px;height:9px;border-radius:50%;display:inline-block;margin-right:4px;vertical-align:middle}
#tt{position:fixed;display:none;z-index:200;background:rgba(10,14,24,.96);border:1px solid #fbbf24;
border-radius:8px;padding:10px 14px;font-size:11px;max-width:300px;pointer-events:none;line-height:1.6}
#tt b{color:#00f0ff}
#tt .meta{color:#94a3b8;font-size:10px;margin-top:4px}
#detail{display:none;position:fixed;right:12px;top:12px;bottom:12px;width:420px;
background:rgba(10,14,24,.97);border:1px solid #00f0ff;border-radius:10px;
padding:18px;font-size:12px;overflow-y:auto;z-index:300;backdrop-filter:blur(10px)}
#detail.open{display:block}
#detail .x{position:absolute;top:10px;right:14px;cursor:pointer;font-size:18px;color:#94a3b8;background:none;border:none;width:auto;padding:4px}
#detail h2{color:#00f0ff;font-size:16px;margin-bottom:8px;padding-right:30px}
#detail h3{color:#fbbf24;font-size:12px;text-transform:uppercase;letter-spacing:.5px;margin:14px 0 6px}
#detail .kv{display:flex;justify-content:space-between;padding:4px 0;border-bottom:1px solid #1e293b;font-size:11px}
#detail .kv b{color:#e8eaf0}
#detail ul{list-style:none;padding-left:0}
#detail li{padding:6px 0;border-bottom:1px dotted #1e293b;font-size:11px;cursor:pointer}
#detail li:hover{color:#00f0ff}
#yearSel{margin:8px 0;padding:6px 8px}
#load{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);color:#00f0ff;font-size:14px;z-index:50}
.loader{display:inline-block;width:14px;height:14px;border:2px solid #00f0ff;border-top-color:transparent;border-radius:50%;animation:sp 0.8s linear infinite;vertical-align:middle;margin-right:6px}
@keyframes sp{to{transform:rotate(360deg)}}
</style>
</head><body>
<div id="g"></div>
<div id="load"><span class="loader"></span> Učitavam mrežu...</div>
<div id="hud" class="panel">
<div class="h"><span>⚡ 3D SPORT NETWORK</span><span style="font-size:10px;color:#475569">v2.0</span></div>
<div class="row"><span>Osobe:</span><b id="s-p"></b></div>
<div class="row"><span>Multi-chair:</span><b id="s-mc" style="color:#fbbf24"></b></div>
<div class="row"><span>Savezi:</span><b id="s-s"></b></div>
<div class="row"><span>Klubovi:</span><b id="s-k"></b></div>
<div class="row"><span>Veze:</span><b id="s-l"></b></div>
<div style="font-size:10px;color:#475569;margin-top:8px;border-top:1px solid #1e293b;padding-top:6px">Klik = detalj · Hover = info</div>
</div>
<div id="ctrl" class="panel">
<div class="h">FILTRI</div>
<input type="text" id="f-search" placeholder="🔍 Označi po imenu (highlight zlato)..." oninput="applyHighlight()"/>
<select id="f-sport"><option value="">Svi sportovi</option></select>
<input type="number" id="f-min" value="2" min="1" max="10" placeholder="Min organizacija"/>
<select id="f-year"><option value="">Sve sezone</option></select>
<div style="display:flex;gap:4px;margin:6px 0;justify-content:space-between">
<span style="font-size:10px;color:#94a3b8;align-self:center">Limit:</span>
<button class="btn-lim" data-l="50" onclick="setLimit(50)">50</button>
<button class="btn-lim act" data-l="100" onclick="setLimit(100)">100</button>
<button class="btn-lim" data-l="200" onclick="setLimit(200)">200</button>
<button class="btn-lim" data-l="500" onclick="setLimit(500)">500</button>
</div>
<button class="btn-pri" onclick="loadGraph()">↻ Učitaj</button>
</div>
<div id="leg" class="panel">
<span><span class="dot" style="background:#fbbf24"></span>Multi-chair</span>
<span><span class="dot" style="background:#22c55e"></span>Osoba</span>
<span><span class="dot" style="background:#06b6d4"></span>Savez</span>
<span><span class="dot" style="background:#64748b"></span>Klub</span>
<span style="color:#475569;font-size:9px">drag/scroll/click</span>
</div>
<div id="tt"></div>
<div id="detail">
<button class="x" onclick="closeDetail()"></button>
<div id="detail-content">Loading...</div>
</div>
<script src="https://unpkg.com/three@0.160.0/build/three.min.js"></script>
<script src="https://unpkg.com/3d-force-graph@1.73.4/dist/3d-force-graph.min.js"></script>
<script>
const COLOR = {person:'#22c55e', multichair:'#fbbf24', savez:'#06b6d4', klub:'#64748b', highlight:'#FFD700'};
let Graph = null, currentLimit = 100, currentData = null, highlightQuery = '';
async function loadSports() {
try {
const r = await fetch('/sport/api/v2/dashboard/sport-stats');
const d = await r.json();
const sel = document.getElementById('f-sport');
const sports = (d.klub_breakdown || []).map(k => k.sport).filter(Boolean);
[...new Set(sports)].sort().forEach(s => {
const o = document.createElement('option'); o.value=s; o.textContent=s; sel.appendChild(o);
});
} catch(e){}
// Years
const yearSel = document.getElementById('f-year');
for (let y = 2026; y >= 2015; y--) {
const o = document.createElement('option'); o.value=y; o.textContent=y; yearSel.appendChild(o);
}
}
async function loadGraph() {
document.getElementById('load').style.display = 'block';
const min = document.getElementById('f-min').value || 2;
const sport = document.getElementById('f-sport').value || '';
const year = document.getElementById('f-year').value || '';
const url = `/sport/api/v2/graph/3d-network?min_orgs=${min}&top_n=${currentLimit}&sport=${encodeURIComponent(sport)}` +
(year ? `&year=${year}` : '');
try {
const r = await fetch(url);
const data = await r.json();
currentData = data;
// Update HUD
const stats = data.stats || {};
document.getElementById('s-p').textContent = (stats.persons || 0) - (stats.multichair || 0);
document.getElementById('s-mc').textContent = stats.multichair || 0;
document.getElementById('s-l').textContent = stats.links || 0;
// Count savezi vs klubovi
let nSav = 0, nKl = 0;
(data.nodes || []).forEach(n => {
if (n.type === 'savez') nSav++;
else if (n.type === 'klub') nKl++;
});
document.getElementById('s-s').textContent = nSav;
document.getElementById('s-k').textContent = nKl;
if (!Graph) initGraph(data);
else Graph.graphData(data);
} catch(e) {
console.error('Load fail', e);
}
document.getElementById('load').style.display = 'none';
}
function initGraph(data) {
const el = document.getElementById('g');
Graph = ForceGraph3D()(el)
.backgroundColor('#06080d')
.nodeColor(getNodeColor)
.nodeVal(getNodeVal)
.nodeOpacity(0.92)
.linkColor(() => 'rgba(148,163,184,0.18)')
.linkWidth(0.6)
.linkDirectionalParticles(0)
.nodeLabel(n => '') // we use custom tooltip
.onNodeHover(n => {
const tt = document.getElementById('tt');
if (n) {
let html = `<b>${escape(n.name)}</b>`;
if (n.type === 'multichair' || n.type === 'person') {
html += `<div class="meta">${n.n_orgs || 1} organizacija${(n.n_orgs||1)>=2?' · MULTI-CHAIR':''}</div>`;
} else if (n.type === 'savez') {
html += `<div class="meta">Savez</div>`;
} else if (n.type === 'klub') {
html += `<div class="meta">Klub${n.sport?' · '+n.sport:''}</div>`;
}
html += `<div class="meta">Klik = detalj</div>`;
tt.innerHTML = html;
tt.style.display = 'block';
} else tt.style.display = 'none';
})
.onNodeClick(n => {
openDetail(n);
})
.graphData(data);
}
function getNodeColor(n) {
if (highlightQuery && n.name && n.name.toLowerCase().includes(highlightQuery.toLowerCase())) {
return COLOR.highlight;
}
return COLOR[n.type] || '#94a3b8';
}
function getNodeVal(n) {
let base = n.val || 5;
if (highlightQuery && n.name && n.name.toLowerCase().includes(highlightQuery.toLowerCase())) {
return base * 2.5;
}
return base;
}
function applyHighlight() {
highlightQuery = document.getElementById('f-search').value.trim();
if (Graph) Graph.refresh();
}
function setLimit(l) {
currentLimit = l;
document.querySelectorAll('.btn-lim').forEach(b => b.classList.toggle('act', +b.dataset.l === l));
loadGraph();
}
async function openDetail(node) {
const d = document.getElementById('detail');
const c = document.getElementById('detail-content');
d.classList.add('open');
c.innerHTML = '<div style="text-align:center;padding:30px"><span class="loader"></span> Učitavam detalje...</div>';
try {
if (node.type === 'klub') {
const klubId = node.id.replace('klub:', '');
const r = await fetch(`/sport/api/klubovi/${klubId}`);
const k = await r.json();
let html = `<h2>🏆 ${escape(k.naziv || node.name)}</h2>`;
html += `<div class="kv"><span>Sport</span><b>${escape(k.sport || '—')}</b></div>`;
html += `<div class="kv"><span>Grad</span><b>${escape(k.grad || '—')}</b></div>`;
html += `<div class="kv"><span>OIB</span><b>${escape(k.oib || '—')}</b></div>`;
html += `<div class="kv"><span>Predsjednik</span><b>${escape(k.predsjednik || '—')}</b></div>`;
html += `<div class="kv"><span>Tajnik</span><b>${escape(k.tajnik || '—')}</b></div>`;
html += `<div class="kv"><span>Email</span><b>${escape(k.email || '—')}</b></div>`;
html += `<div class="kv"><span>Web</span><b>${k.web_stranica?'<a href="'+escape(k.web_stranica)+'" target="_blank" style="color:#00f0ff">otvori</a>':'—'}</b></div>`;
// RGFI year selector
html += `<h3>FINANCIJE PO GODINI</h3>`;
html += `<select id="yearSel" onchange="loadKlubFinance('${klubId}')">`;
html += `<option value="">Odaberi godinu...</option>`;
for (let y = 2024; y >= 2015; y--) html += `<option value="${y}">${y}</option>`;
html += `</select>`;
html += `<div id="finance-data"></div>`;
// Linked savezi & osobe
if (currentData && currentData.links) {
const linkedNodes = currentData.links
.filter(l => l.source.id === node.id || l.target.id === node.id)
.map(l => l.source.id === node.id ? l.target : l.source);
if (linkedNodes.length) {
html += `<h3>POVEZANE OSOBE/SAVEZI (${linkedNodes.length})</h3><ul>`;
linkedNodes.slice(0, 30).forEach(n => {
html += `<li onclick="focusNode('${n.id}')">${getEmoji(n.type)} ${escape(n.name)}</li>`;
});
html += `</ul>`;
}
}
c.innerHTML = html;
} else if (node.type === 'savez') {
const savezId = node.id.replace('savez:', '');
const r = await fetch(`/sport/api/savezi/${savezId}`);
const s = await r.json();
let html = `<h2>🏛 ${escape(s.naziv || node.name)}</h2>`;
html += `<div class="kv"><span>Sport</span><b>${escape(s.sport || '—')}</b></div>`;
html += `<div class="kv"><span>OIB</span><b>${escape(s.oib || '—')}</b></div>`;
html += `<div class="kv"><span>Predsjednik</span><b>${escape(s.predsjednik || '—')}</b></div>`;
html += `<div class="kv"><span>Tajnik</span><b>${escape(s.tajnik || '—')}</b></div>`;
html += `<div class="kv"><span>Godina osnutka</span><b>${escape(s.godina_osnutka || '—')}</b></div>`;
if (currentData && currentData.links) {
const linkedKlubovi = currentData.links
.filter(l => l.source.id === node.id || l.target.id === node.id)
.map(l => l.source.id === node.id ? l.target : l.source)
.filter(n => n.type === 'klub');
if (linkedKlubovi.length) {
html += `<h3>KLUBOVI U SAVEZU (${linkedKlubovi.length})</h3><ul>`;
linkedKlubovi.forEach(n => {
html += `<li onclick="focusNode('${n.id}')">🏆 ${escape(n.name)}</li>`;
});
html += `</ul>`;
}
}
c.innerHTML = html;
} else {
// Person / multi-chair
let html = `<h2>${node.type==='multichair'?'⚠️':'👤'} ${escape(node.name)}</h2>`;
html += `<div class="kv"><span>Tip</span><b>${node.type === 'multichair' ? 'MULTI-CHAIR' : 'Osoba'}</b></div>`;
html += `<div class="kv"><span>Broj organizacija</span><b>${node.n_orgs || 1}</b></div>`;
if (currentData && currentData.links) {
const orgs = currentData.links
.filter(l => l.source.id === node.id || l.target.id === node.id)
.map(l => ({
node: l.source.id === node.id ? l.target : l.source,
role: l.role
}));
if (orgs.length) {
html += `<h3>FUNKCIJE U ORGANIZACIJAMA</h3><ul>`;
orgs.forEach(o => {
html += `<li onclick="focusNode('${o.node.id}')">${getEmoji(o.node.type)} <b>${escape(o.node.name)}</b><div style="color:#94a3b8;font-size:10px;margin-top:2px">${escape(o.role || '')}</div></li>`;
});
html += `</ul>`;
}
}
c.innerHTML = html;
}
} catch(e) {
c.innerHTML = '<div style="color:#f87171">Greška učitavanja: '+escape(e.message)+'</div>';
}
}
async function loadKlubFinance(klubId) {
const yr = document.getElementById('yearSel').value;
const fd = document.getElementById('finance-data');
if (!yr) { fd.innerHTML = ''; return; }
fd.innerHTML = '<div style="padding:10px;color:#94a3b8"><span class="loader"></span> Tražim RGFI '+yr+'...</div>';
try {
// Try multiple endpoints
let data = null;
for (const ep of [`/sport/api/klubovi/${klubId}/finance?year=${yr}`, `/sport/api/v2/klub/${klubId}/rgfi?year=${yr}`]) {
try { const r = await fetch(ep); if (r.ok) { data = await r.json(); break; } } catch{}
}
if (!data || (Array.isArray(data) && !data.length) || data.error) {
fd.innerHTML = '<div style="padding:10px;color:#475569;font-size:10px">⚠ Nema RGFI podataka za '+yr+'. (Klubovi su udruge, ne d.o.o. — RGFI nije obavezan.)</div>';
return;
}
// Render financial data with clickable accounts
let html = '<div style="padding:6px;background:#0d1117;border:1px solid #1e293b;border-radius:6px;margin-top:8px">';
html += '<div style="font-size:10px;color:#fbbf24;margin-bottom:6px">RGFI '+yr+'</div>';
Object.entries(data).forEach(([k,v]) => {
if (typeof v === 'number' && v) {
html += '<div class="kv" onclick="alert(\'Konto: '+k+' = '+v.toLocaleString('hr-HR')+' EUR\')" style="cursor:pointer"><span>'+k+'</span><b>'+(v||0).toLocaleString('hr-HR')+' €</b></div>';
}
});
html += '</div>';
fd.innerHTML = html;
} catch(e) {
fd.innerHTML = '<div style="color:#f87171;padding:10px;font-size:10px">Greška: '+e.message+'</div>';
}
}
function focusNode(nodeId) {
if (!currentData || !Graph) return;
const node = currentData.nodes.find(n => n.id === nodeId);
if (!node) return;
const dist = 80;
const distRatio = 1 + dist/Math.hypot(node.x||1, node.y||1, node.z||1);
Graph.cameraPosition({x:(node.x||0)*distRatio, y:(node.y||0)*distRatio, z:(node.z||0)*distRatio}, node, 1500);
setTimeout(() => openDetail(node), 800);
}
function getEmoji(type) {
return {multichair:'⚠️', person:'👤', savez:'🏛', klub:'🏆'}[type] || '•';
}
function escape(s) {
return String(s||'').replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));
}
function closeDetail() {
document.getElementById('detail').classList.remove('open');
}
document.addEventListener('mousemove', e => {
const tt = document.getElementById('tt');
tt.style.left = (e.clientX+14)+'px';
tt.style.top = (e.clientY+14)+'px';
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape') closeDetail();
});
// Init
loadSports().then(loadGraph);
</script>
</body></html>