PGŽ Sport Platform — Round 1+2 baseline (sport2.html + API)
|
After Width: | Height: | Size: 268 KiB |
|
After Width: | Height: | Size: 173 KiB |
|
After Width: | Height: | Size: 268 KiB |
|
After Width: | Height: | Size: 378 KiB |
|
After Width: | Height: | Size: 175 KiB |
|
After Width: | Height: | Size: 385 KiB |
|
After Width: | Height: | Size: 384 KiB |
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 295 KiB |
|
After Width: | Height: | Size: 308 KiB |
|
After Width: | Height: | Size: 367 KiB |
|
After Width: | Height: | Size: 351 KiB |
@@ -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>
|
||||
|
After Width: | Height: | Size: 8.9 KiB |
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>
|
||||