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
+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>