feat: /api/v2/analiza/* endpoints - sport analytics backend

This commit is contained in:
Damir Radulic
2026-05-16 00:28:12 +02:00
parent 7ca5d7d94e
commit aca5051418
1355 changed files with 321891 additions and 4128 deletions
+621 -101
View File
@@ -592,18 +592,31 @@ async function api(path){
return null;
}
}
async function apiPost(path, body){
async function apiPost(path, body, opts){
// apiPost: 30s timeout (added 2026-05-10) — protects against slow enrich endpoints.
// Pass {timeoutMs: N} to override.
const timeoutMs = (opts && opts.timeoutMs) || 30000;
const ctrl = new AbortController();
const tid = setTimeout(() => ctrl.abort(), timeoutMs);
try{
const tok = localStorage.getItem('pgz_access') || sessionStorage.getItem('pgz_access') || localStorage.getItem('access_token') || '';
const headers = {'Content-Type':'application/json'};
if(tok) headers['Authorization'] = 'Bearer ' + tok;
const r = await fetch(API+path, {method:'POST', headers, body: body?JSON.stringify(body):'{}'});
if(!r.ok){
const errText = await r.text().catch(()=>(''));
throw new Error('HTTP '+r.status+(errText? ': '+errText.slice(0,150):''));
const r = await fetch(API+path, {method:'POST', headers, signal: ctrl.signal, body: body?JSON.stringify(body):'{}'});
clearTimeout(tid);
if(!r.ok){
const errText = await r.text().catch(()=>(''));
throw new Error('HTTP '+r.status+(errText? ': '+errText.slice(0,150):''));
}
return await r.json();
}catch(e){
clearTimeout(tid);
if(e.name === 'AbortError'){
const msg = `Timeout (${(timeoutMs/1000)|0}s) — server presporo odgovara`;
console.error('API POST timeout', path);
if(typeof showToast === 'function') showToast(msg, 'err');
return null;
}
console.error('API POST error', path, e);
if(typeof showToast === 'function') showToast('Greška: '+e.message, 'err');
return null;
@@ -1139,6 +1152,21 @@ async function loadDash(){
const total2026 = proracun2026 ? proracun2026.ukupno : d.proracun_aktualni;
root.innerHTML = `
<div class="ai-bar" style="display:flex;flex-direction:column;gap:8px;margin-bottom:12px;padding:12px;border:1px solid var(--bd);border-radius:8px;background:var(--bg2)">
<div style="display:flex;align-items:center;gap:8px">
<span style="font-size:14px">🤖</span>
<span style="font-weight:600;font-size:13px">DABI AI Copilot</span>
<span id="dash-ai-status" style="font-size:11px;color:var(--t2);margin-left:auto"></span>
</div>
<div style="display:flex;gap:6px">
<input id="dash-ai-q" type="text" placeholder="Pitaj DABI… (npr. Koliko klubova ima PGŽ?)"
onkeydown="if(event.key==='Enter'){event.preventDefault();dashAiAsk();}"
style="flex:1;padding:8px 10px;border:1px solid var(--bd);border-radius:6px;background:var(--bg);color:var(--t0);font-size:13px;outline:none">
<button onclick="dashAiAsk()" id="dash-ai-btn"
style="padding:8px 14px;border:0;border-radius:6px;background:var(--accent);color:#fff;font-size:13px;font-weight:600;cursor:pointer">Pitaj</button>
</div>
<div id="dash-ai-out" style="display:none;padding:10px;border-top:1px solid var(--bd);font-size:13px;line-height:1.5;white-space:pre-wrap;color:var(--t0);max-height:400px;overflow-y:auto"></div>
</div>
<div class="kpi-grid">
<div class="kpi"><div class="kpi-l">Saveza</div><div class="kpi-v">${fmtNum(d.aktivnih_saveza)}</div><div class="kpi-s">aktivnih</div></div>
<div class="kpi b"><div class="kpi-l">Klubova</div><div class="kpi-v">${fmtNum(d.aktivnih_klubova)}</div><div class="kpi-s">${d.nositelja_kvalitete||0} nositelja kvalitete</div></div>
@@ -1188,6 +1216,38 @@ async function loadDash(){
refreshDashNositelji();
}
async function dashAiAsk(){
const inp = document.getElementById('dash-ai-q');
const out = document.getElementById('dash-ai-out');
const btn = document.getElementById('dash-ai-btn');
const stat = document.getElementById('dash-ai-status');
if(!inp || !out || !btn) return;
const q = (inp.value||'').trim();
if(!q) return;
const tok = localStorage.getItem('pgz_access') || sessionStorage.getItem('pgz_access') || localStorage.getItem('access_token') || '';
if(!tok){
out.style.display='block';
out.textContent = '⚠ Prijava potrebna. Idi na /login pa se vrati ovamo.';
if(stat) stat.textContent = 'traži prijavu';
return;
}
btn.disabled = true; btn.textContent = '…';
if(stat) stat.textContent = 'razmišljam…';
out.style.display='block';
out.textContent = '⏳ DABI razmišlja…';
try{
const headers = {'Content-Type':'application/json','Authorization':'Bearer '+tok};
const r = await fetch(API+'/v2/ai/ask', {method:'POST', headers, body: JSON.stringify({question:q, query:q, q:q})});
if(r.status===401){ out.textContent = '⚠ Sesija je istekla. Idi na /login.'; if(stat) stat.textContent='401'; return; }
if(!r.ok){ const t = await r.text().catch(()=>''); out.textContent = '❌ Greška: HTTP '+r.status+(t?' — '+t.slice(0,200):''); if(stat) stat.textContent='greška'; return; }
const data = await r.json();
const answer = data.answer || data.response || data.text || JSON.stringify(data, null, 2).slice(0,1200);
out.textContent = answer;
if(stat) stat.textContent = 'odgovor spreman';
}catch(e){ out.textContent = '❌ '+(e.message||String(e)); if(stat) stat.textContent='greška'; }
finally{ btn.disabled = false; btn.textContent = 'Pitaj'; }
}
async function refreshDashNositelji(){
const selG = $('#dash-god');
const selD = $('#dash-davatelj');
@@ -1487,7 +1547,6 @@ function renderSaveziTable(rows){
async function openSavez(id){
openPanel('Savez', '<div class="loading">Učitavanje saveza…</div>');
setTimeout(() => loadSavezKpi(id), 100);
const s = await api('/savezi/'+id);
if(!s || s.detail){
openPanel('Savez', '<div class="empty">Savez nije pronađen</div>');
@@ -1761,6 +1820,64 @@ async function exportKlubovi(format){
} catch(e){ window.toast && window.toast('Export greška: '+(e.message||e), 'error', 4000); }
}
async function openGodisnjak(godina){
// Drill-down panel za jedan godišnjak (PDF + popis spomenutih sportaša).
// Endpoint: /api/v2/godisnjak/{godina}/sportasi (vraća count + sportasi[])
// PDF: /api/v2/dokumenti/godisnjak/{godina}
godina = parseInt(godina, 10);
if(!godina) return;
openPanel('📚 Godišnjak '+godina, '<div class="loading">Učitavanje godišnjaka…</div>');
const d = await api('/v2/godisnjak/'+godina+'/sportasi?limit=500');
if(!d || d.detail){
openPanel('📚 Godišnjak '+godina, '<div class="empty">Godišnjak '+godina+' nije pronađen.</div>');
return;
}
const sportasi = d.sportasi || [];
const cnt = sportasi.length;
const pdfHref = '/sport/api/v2/dokumenti/godisnjak/'+godina;
// distinct sports for filter chips
const sports = Array.from(new Set(sportasi.map(s => s.sport).filter(Boolean))).sort((a,b)=>a.localeCompare(b,'hr'));
const html = `
<div class="card-h" style="border:0;padding:0;margin-bottom:14px">
<div>
<div style="font-size:18px;font-weight:800;color:var(--t0)">Sportski godišnjak ${godina}</div>
<div style="font-size:12px;color:var(--t2);margin-top:4px">${fmtNum(cnt)} sportaša spomenuto</div>
</div>
<a href="${pdfHref}" target="_blank" rel="noopener"
class="btn" style="background:var(--accent);color:#fff;padding:6px 12px;border-radius:6px;font-weight:600;font-size:12px;text-decoration:none">
📄 Otvori PDF ↗
</a>
</div>
${sports.length ? `<div style="margin-bottom:10px;display:flex;flex-wrap:wrap;gap:4px;align-items:center">
<span style="font-size:11px;color:var(--t2)">Sport:</span>
<button class="tag" data-sport="" onclick="window._godSportFilter('${godina}','')" style="cursor:pointer">svi</button>
${sports.map(sp=>`<button class="tag" data-sport="${esc(sp)}" onclick="window._godSportFilter('${godina}',this.dataset.sport)" style="cursor:pointer">${esc(sp)}</button>`).join('')}
</div>` : ''}
<div id="god-${godina}-list" style="overflow-x:auto"><table>
<thead><tr><th>Sportaš</th><th>Sport</th><th>Klub</th><th style="text-align:center">🥇</th><th style="text-align:center">📊</th><th>Ključne riječi</th></tr></thead>
<tbody>${sportasi.map(s => `
<tr onclick="panelDrill(openSportas,${s.clan_id})" style="cursor:pointer" data-sport="${esc(s.sport||'')}">
<td><b>${esc((s.ime||'')+' '+(s.prezime||''))}</b></td>
<td>${esc(s.sport||'—')}</td>
<td>${esc(s.klub||'—')}</td>
<td style="text-align:center">${s.has_medal ? '<span class="tag gd" title="Medalja">🥇</span>' : ''}</td>
<td style="text-align:center">${s.has_kategorija ? '<span class="tag b" title="Kategorija">K</span>' : ''}</td>
<td>${(s.keywords||[]).slice(0,4).map(k=>'<span class="tag" style="font-size:10px">'+esc(k)+'</span>').join(' ')}</td>
</tr>`).join('')}
</tbody>
</table></div>
`;
openPanel('📚 Godišnjak '+godina, html);
}
// Sport filter helper (column index 1 = "Sport")
window._godSportFilter = function(godina, sp){
const tbody = document.querySelector('#god-'+godina+'-list tbody');
if(!tbody) return;
tbody.querySelectorAll('tr').forEach(tr => {
tr.style.display = (!sp || tr.dataset.sport === sp) ? '' : 'none';
});
};
async function openKlub(id){
openPanel('Klub', '<div class="loading">Učitavanje kluba…</div>');
const k = await api('/klubovi/'+id);
@@ -2414,9 +2531,9 @@ async function openSportas(id){
${godisnjaci.length ? `<div class="pp-section-h" style="margin-top:18px">📚 Godišnjaci <span class="cnt">${godisnjaci.length}</span></div>
<div class="kv">
<div class="k">Prvi godišnjak</div><div class="v">${esc(d.godisnjak_prvi||godisnjaci[0])}</div>
<div class="k">Zadnji godišnjak</div><div class="v">${esc(d.godisnjak_zadnji||godisnjaci[godisnjaci.length-1])}</div>
<div class="k">Sve godine</div><div class="v">${godisnjaci.map(g=>'<span class="tag b">'+esc(g)+'</span>').join(' ')}</div>
<div class="k">Prvi godišnjak</div><div class="v">${(()=>{const _g=d.godisnjak_prvi||godisnjaci[0];return _g?'<a class="tag b" href="javascript:void(0)" onclick="openGodisnjak('+_g+');return false;" title="Otvori godišnjak '+esc(_g)+'">'+esc(_g)+'</a>':'—';})()}</div>
<div class="k">Zadnji godišnjak</div><div class="v">${(()=>{const _g=d.godisnjak_zadnji||godisnjaci[godisnjaci.length-1];return _g?'<a class="tag b" href="javascript:void(0)" onclick="openGodisnjak('+_g+');return false;" title="Otvori godišnjak '+esc(_g)+'">'+esc(_g)+'</a>':'—';})()}</div>
<div class="k">Sve godine</div><div class="v">${godisnjaci.map(g=>'<a class="tag b" href="javascript:void(0)" onclick="openGodisnjak('+g+');return false;" title="Otvori godišnjak '+esc(g)+'">'+esc(g)+'</a>').join(' ')}</div>
</div>` : ''}
${nagrade.length ? `<div class="pp-section-h" style="margin-top:18px">🏅 Nagrade <span class="cnt">${nagrade.length}</span></div>
@@ -2470,9 +2587,11 @@ async function loadFinancije(){
<select id="fi-god">
${meta.godine.map(g => `<option value="${g.godina}">${g.godina} (${g.broj}, ${fmtEur(g.suma||0)})</option>`).join('')}
</select>
<select id="fi-davatelj" title="Platitelj">
<option value="all">Svi platitelji</option>
${meta.davatelji.map(d => `<option value="${d}">${d.includes('rijeka') ? '🌆 Grad Rijeka' : '🏛 PGŽ'} (${d})</option>`).join('')}
<select id="fi-davatelj" title="Izvor">
<option value="all">Svi izvori</option>
<option value="pgz">🏛 PGŽ</option>
<option value="rss">🌊 RSS (Riječki sportski savez)</option>
<option value="grad_rijeka">🌆 Grad Rijeka</option>
</select>
<select id="fi-sport" title="Sport">
<option value="">Svi sportovi</option>
@@ -2483,6 +2602,10 @@ async function loadFinancije(){
${meta.vrste.map(v => `<option value="${v}">${v.replace('_',' ')}</option>`).join('')}
</select>
<input type="search" id="fi-q" placeholder="🔍 Pretraži korisnika…">
<label class="tb-s" style="display:flex;align-items:center;gap:6px;cursor:pointer;user-select:none" title="Samo plaćanja koja imaju vezu na realni klub (klub_id IS NOT NULL). Isključuje skrečane PDF section-headere.">
<input type="checkbox" id="fi-samo-klubovi" checked style="cursor:pointer"> samo klubovi
</label>
<button id="fi-manual-btn" class="btn sm" style="background:var(--pgz-gold,#c5a040);color:#000" title="Dodaj plaćanje za poznati klub (pgz_admin / super_admin)"> Ručni unos</button>
<div class="toggle">
<button id="fi-card" class="${_state.viewFinancije==='card'?'active':''}" onclick="setFinancijeView('card')">Kartice</button>
<button id="fi-table-btn" class="${_state.viewFinancije==='table'?'active':''}" onclick="setFinancijeView('table')">Tablica</button>
@@ -2507,6 +2630,8 @@ async function loadFinancije(){
if($('#fi-sport')) $('#fi-sport').addEventListener('change', refreshFinancije);
if($('#fi-vrsta')) $('#fi-vrsta').addEventListener('change', refreshFinancije);
$('#fi-q').addEventListener('input', debounce(refreshFinancije, 200));
if($('#fi-samo-klubovi')) $('#fi-samo-klubovi').addEventListener('change', refreshFinancije);
if($('#fi-manual-btn')) $('#fi-manual-btn').addEventListener('click', openManualFinancijeForm);
refreshFinancije();
}
async function refreshFinancije(){
@@ -2515,23 +2640,53 @@ async function refreshFinancije(){
const sport = $('#fi-sport') ? $('#fi-sport').value : '';
const vrsta = $('#fi-vrsta') ? $('#fi-vrsta').value : '';
const q = ($('#fi-q').value || '').toLowerCase().trim();
// Map davatelj 'rijeka.hr' → 'rijeka', 'www2.pgz.hr' → 'pgz'
let davParam = '';
if(dav.includes('rijeka')) davParam = '&davatelj=rijeka';
else if(dav.includes('pgz')) davParam = '&davatelj=pgz';
const params = `?godina=${god}${davParam}${sport?'&sport='+encodeURIComponent(sport):''}${vrsta?'&vrsta='+encodeURIComponent(vrsta):''}`;
const [analytics, byyear] = await Promise.all([
// PGŽ + Grad Rijeka live in pgz_sport.sufinanciranje_sport (served by /v2/potpore/by-year).
// RSS (Riječki sportski savez) lives separately and is served by /dashboard/top-primatelji.
// Merge both into a unified row shape so the user can filter "Svi izvori" in one table.
let pgzRijekaParam = '';
if (dav === 'grad_rijeka') pgzRijekaParam = '&davatelj=rijeka';
else if (dav === 'pgz') pgzRijekaParam = '&davatelj=pgz';
// For dav='rss' we skip /v2/potpore/by-year entirely; for dav='all' we keep it unfiltered.
const fetchPgzRijeka = (dav !== 'rss');
const fetchRss = (dav === 'all' || dav === 'rss');
const samoKlubovi = $('#fi-samo-klubovi') ? !!$('#fi-samo-klubovi').checked : true;
const params = `?godina=${god}${pgzRijekaParam}${sport?'&sport='+encodeURIComponent(sport):''}${vrsta?'&vrsta='+encodeURIComponent(vrsta):''}&samo_klubovi=${samoKlubovi}`;
const [analytics, byyear, rssJson] = await Promise.all([
api('/v2/analytics/proracun-sport?godina='+god),
api('/v2/potpore/by-year'+params)
fetchPgzRijeka ? api('/v2/potpore/by-year'+params) : Promise.resolve({results:[], total:0}),
fetchRss ? api('/dashboard/top-primatelji?godina='+god+'&limit=500').catch(()=>({rows:[]})) : Promise.resolve({rows:[]}),
]);
const total = (analytics && analytics.total) || (byyear && byyear.total) || 0;
const poSportu = (analytics && analytics.po_sportu) || [];
let rows = (byyear && byyear.results) || [];
// Normalize PGŽ + Grad Rijeka rows: izvor stays 'rijeka.hr' or 'www2.pgz.hr'.
const pgzRijekaRows = (byyear && byyear.results) || [];
// Normalize RSS rows from /dashboard/top-primatelji to the same shape used by the table.
const rssRows = ((rssJson && rssJson.rows) || []).map(r => ({
korisnik: r.naziv_kluba,
sport: r.sport,
vrsta: r.vrsta || 'javne_potrebe',
iznos_eur: r.iznos,
razina: 'rss',
izvor: 'rss.hr',
source_url: r.pdf_url, // /sport/api/v2/dokumenti/godisnjak/<god>
godina: r.godina,
klub_id: r.klub_id,
napomena: r.napomena_short || r.napomena,
_davatelj_label: r.davatelj || 'RSS',
}));
// Apply the in-flight sport / vrsta filter to RSS too (by-year already applies it server-side).
let rssFiltered = rssRows;
if (sport) rssFiltered = rssFiltered.filter(r => (r.sport||'').toLowerCase().includes(sport.toLowerCase()));
if (vrsta) rssFiltered = rssFiltered.filter(r => (r.vrsta||'').toLowerCase().includes(vrsta.toLowerCase()));
let rows = pgzRijekaRows.concat(rssFiltered);
if(q) rows = rows.filter(r => (r.korisnik||'').toLowerCase().includes(q) || (r.sport||'').toLowerCase().includes(q));
const total = rows.reduce((s,r) => s + Number(r.iznos_eur||0), 0);
const poSportu = (analytics && analytics.po_sportu) || [];
$('#fi-kpi').innerHTML = `
<div class="kpi-grid">
<div class="kpi"><div class="kpi-l">Ukupno ${god}</div><div class="kpi-v">${fmtEur(total)}</div></div>
@@ -2575,21 +2730,133 @@ async function refreshFinancije(){
</div>`).join('')+'</div>';
} else {
$('#fi-table').innerHTML = `<div style="overflow-x:auto"><table>
<thead><tr><th>#</th>${sortHeader('financije','korisnik','Korisnik','')}${sortHeader('financije','sport','Sport','')}${sortHeader('financije','vrsta','Vrsta','')}${sortHeader('financije','iznos_eur','Iznos','num')}${sortHeader('financije','izvor','Izvor','')}<th>PDF</th></tr></thead>
<tbody>${sortedRows.map((r,i) => `
<tr onclick='openPrimateljDetail(${JSON.stringify(r).replace(/'/g,"&#39;")})'>
<thead><tr><th></th><th>#</th>${sortHeader('financije','korisnik','Korisnik','')}${sortHeader('financije','sport','Sport','')}${sortHeader('financije','godina','God','num')}${sortHeader('financije','iznos_eur','Iznos','num')}${sortHeader('financije','izvor','Izvor','')}<th>PDF</th></tr></thead>
<tbody>${sortedRows.map((r,i) => {
const rid = 'fi-row-' + i;
const izvLabel = financijeIzvorLabel(r.izvor);
const pdfBtn = r.source_url
? '<a href="'+esc(financijePdfUrl(r))+'" target="_blank" rel="noreferrer" onclick="event.stopPropagation()" class="btn sm" title="Otvori izvorni PDF i skoči na klub">📄 Otvori PDF</a>'
: '<span style="color:var(--t3)">—</span>';
return `
<tr id="${rid}" onclick='toggleFinancijeDrill(${JSON.stringify(rid).replace(/'/g,"&#39;")}, ${JSON.stringify(r.korisnik).replace(/'/g,"&#39;")})' style="cursor:pointer">
<td style="width:24px;text-align:center;color:var(--t3)">▸</td>
<td>${i+1}</td>
<td><b>${esc(r.korisnik)}</b></td>
<td>${txt(r.sport)}</td>
<td>${txt(r.vrsta)}</td>
<td class="num">${esc(r.godina||'')}</td>
<td class="num"><b>${fmtEurFull(r.iznos_eur)}</b></td>
<td>${txt(r.izvor)}</td>
<td>${r.source_url?'<a href="'+esc(r.source_url)+'" target="_blank" onclick="event.stopPropagation()">📄</a>':'—'}</td>
</tr>`).join('')}
<td><span class="badge" style="font-size:10px">${esc(izvLabel)}</span></td>
<td onclick="event.stopPropagation()">${pdfBtn}</td>
</tr>`;
}).join('')}
</tbody>
</table></div>`;
}
}
// ───────────── helpers added 2026-05-09 (Task 03b) ─────────────
// Map raw izvor strings → human label.
function financijeIzvorLabel(izvor){
const v = (izvor||'').toLowerCase();
if (v.includes('rijeka.hr')) return 'Grad Rijeka';
if (v.includes('rss')) return 'RSS';
if (v.includes('pgz')) return 'PGŽ';
return izvor || '—';
}
// Build a PDF link with #search=<korisnik> so most browsers' built-in PDF viewer
// scrolls to the first match. Falls back gracefully when source_url is missing.
function financijePdfUrl(r){
if (!r.source_url) return '#';
const sep = r.source_url.includes('#') ? '&' : '#';
// PDF.js + Chrome built-in viewer respect "#search=…"; for non-PDF docs the
// fragment is harmless.
return r.source_url + sep + 'search=' + encodeURIComponent(r.korisnik || '');
}
// Toggle inline expansion under the clicked row. Loads all-years × all-izvor
// rows for the same korisnik and renders a sub-table grouped by izvor.
async function toggleFinancijeDrill(rowId, korisnik){
const row = document.getElementById(rowId);
if (!row) return;
const next = row.nextElementSibling;
if (next && next.classList && next.classList.contains('fi-drill')) {
next.remove();
row.firstElementChild.textContent = '▸';
return;
}
row.firstElementChild.textContent = '▾';
const drill = document.createElement('tr');
drill.className = 'fi-drill';
const colspan = row.children.length;
drill.innerHTML = `<td colspan="${colspan}" style="background:var(--bg2);padding:10px 16px"><div class="loading">Učitavanje povijesti za <b>${esc(korisnik)}</b>…</div></td>`;
row.parentNode.insertBefore(drill, row.nextSibling);
// Fetch all years from both data sources (by-year accepts q, top-primatelji loops years).
const meta = window._fiMeta || (window._fiMeta = await api('/v2/potpore/meta'));
const years = (meta.godine || []).map(g => g.godina);
// Cap to last ~12 years to avoid blowing the request count.
const yearList = years.slice(0, 12);
const aggregateByYear = await api('/v2/potpore/aggregate?q=' + encodeURIComponent(korisnik) + '&limit=50').catch(() => ({results:[]}));
// For RSS we have to loop years (no per-korisnik filter on top-primatelji).
const rssFetches = await Promise.all(yearList.map(y =>
api('/dashboard/top-primatelji?godina=' + y + '&limit=500').catch(() => ({rows:[]}))
));
const rssAll = [];
rssFetches.forEach(j => (j.rows || []).forEach(r => {
if ((r.naziv_kluba||'').toLowerCase().includes(korisnik.toLowerCase())) {
rssAll.push({
korisnik: r.naziv_kluba, sport: r.sport, godina: r.godina,
iznos_eur: r.iznos, izvor: 'rss.hr', source_url: r.pdf_url,
klub_id: r.klub_id,
});
}
}));
// Pull aggregate hits as flattened rows (one per matched korisnik · tip group).
const aggRows = (aggregateByYear.results || []).filter(a =>
(a.korisnik||'').toLowerCase() === korisnik.toLowerCase()
);
// Build per-izvor summary.
const all = rssAll.slice();
// Add aggregate results as summary rows (they sum iznos across years per tip).
const aggSummary = aggRows.map(a => ({
korisnik: a.korisnik, sport: a.sport, godina: `${a.od_god}${a.do_god}`,
iznos_eur: a.ukupno_eur, izvor: a.izvori || a.tip,
source_url: a.source_url, _is_summary: true, _n: a.n_potpore,
}));
if (all.length === 0 && aggSummary.length === 0) {
drill.firstChild.innerHTML = `<div class="empty" style="padding:8px">Nema dodatnih zapisa za <b>${esc(korisnik)}</b>.</div>`;
return;
}
const groupHtml = (label, rows) => {
if (!rows.length) return '';
const sum = rows.reduce((s,r) => s + Number(r.iznos_eur||0), 0);
return `
<div style="margin-top:8px">
<div style="font-size:11px;color:var(--t2);text-transform:uppercase;letter-spacing:0.6px;margin-bottom:4px">${label} <span style="color:var(--t3)">· ${rows.length} stavki · ${fmtEurFull(sum)}</span></div>
<table style="width:100%;font-size:12px">
<thead><tr><th style="text-align:left">Godina</th><th style="text-align:left">Sport / razina</th><th class="num">Iznos</th><th>Izvor</th><th>PDF</th></tr></thead>
<tbody>${rows.map(r => {
const pdf = r.source_url ? '<a href="'+esc(financijePdfUrl(r))+'" target="_blank" onclick="event.stopPropagation()">📄</a>' : '—';
return `<tr><td>${esc(r.godina)}</td><td>${txt(r.sport)}</td><td class="num"><b>${fmtEurFull(r.iznos_eur)}</b></td><td>${esc(financijeIzvorLabel(r.izvor))}</td><td>${pdf}</td></tr>`;
}).join('')}</tbody>
</table>
</div>`;
};
drill.firstChild.innerHTML = `
<div style="padding:4px 0">
<div style="font-weight:700;font-size:13px;margin-bottom:4px">📜 Povijest financiranja: ${esc(korisnik)}</div>
${groupHtml('🌊 RSS — godišnjaci', rssAll.sort((a,b)=>b.godina-a.godina))}
${groupHtml('🏛 PGŽ + 🌆 Grad Rijeka — agregat', aggSummary)}
</div>`;
}
function setFinancijeView(v){
_state.viewFinancije = v;
const cb = $('#fi-card'), tb = $('#fi-table-btn');
@@ -2695,18 +2962,41 @@ function renderObjektiGrid(rows){
function renderObjektiTable(rows){
if(!rows.length) return '<div class="empty">Nema rezultata</div>';
return `<div class="card" style="padding:0;overflow-x:auto"><table>
<thead><tr>${sortHeader('objekti','naziv','Naziv','')}${sortHeader('objekti','tip','Tip','')}${sortHeader('objekti','grad','Grad','')}${sortHeader('objekti','upravitelj','Upravitelj','')}${sortHeader('objekti','izgradeno','Izgrađeno','num')}${sortHeader('objekti','lat','GPS','')}</tr></thead>
<tbody>${rows.map(o => `
<thead><tr>${sortHeader('objekti','naziv','Naziv','')}${sortHeader('objekti','tip','Tip','')}${sortHeader('objekti','sportovi','Sport','')}${sortHeader('objekti','kapacitet','Kapacitet','num')}<th>Adresa</th>${sortHeader('objekti','upravitelj','Upravitelj / klub','')}<th>Karta</th></tr></thead>
<tbody>${rows.map(o => {
const sportLabel = Array.isArray(o.sportovi) && o.sportovi.length ? o.sportovi.slice(0,2).join(', ') + (o.sportovi.length>2?` +${o.sportovi.length-2}`:'') : '—';
const adresaLabel = [o.adresa, o.grad].filter(Boolean).join(', ') || '—';
return `
<tr onclick="openObjekt(${o.id})">
<td><b>${esc(o.naziv)}</b></td>
<td>${txt(o.tip)}</td>
<td>${txt(o.grad)}</td>
<td>${esc(sportLabel)}</td>
<td class="num">${o.kapacitet ? fmtNum(o.kapacitet) : '—'}</td>
<td>${esc(adresaLabel)}</td>
<td>${txt(o.upravitelj)}</td>
<td class="num">${txt(o.izgradeno)}</td>
<td>${o.lat&&o.lng?'<span class="tag gr">📍</span>':'—'}</td>
</tr>`).join('')}</tbody>
<td onclick="event.stopPropagation()">${objektMapsButton(o)}</td>
</tr>`;
}).join('')}</tbody>
</table></div>`;
}
// Build a Google Maps deep-link button for an objekt row.
// Spec: prefer lat/lng if present, fall back to address search. We
// additionally offer the address-search button alongside the pin so
// users can sanity-check pin placement (some seed coordinates were
// inaccurate — see 22_objekti_maps.md).
function objektMapsButton(o){
const addrParts = [o.naziv, o.adresa, o.grad].filter(Boolean).join(', ') || (o.naziv || '');
const addrUrl = 'https://www.google.com/maps/search/?api=1&query=' + encodeURIComponent(addrParts);
if (o.lat != null && o.lng != null) {
const pinUrl = 'https://www.google.com/maps/search/?api=1&query=' + Number(o.lat) + ',' + Number(o.lng);
return (
'<a class="btn sm" href="' + esc(pinUrl) + '" target="_blank" rel="noreferrer" title="Otvori spremljene koordinate u Google Maps">📍 Pin</a> ' +
'<a class="btn sm" href="' + esc(addrUrl) + '" target="_blank" rel="noreferrer" title="Otvori adresu (sigurnija opcija ako pin izgleda krivo)">🔍 Adresa</a>'
);
}
return '<a class="btn sm" href="' + esc(addrUrl) + '" target="_blank" rel="noreferrer" title="Otvori adresu u Google Maps (nema spremljenih koordinata)">🔍 Adresa</a>';
}
function openObjekt(id){
const o = (_cache.objekti||[]).find(x => x.id===id);
if(!o){ openPanel('Objekt', '<div class="empty">Objekt nije pronađen</div>'); return; }
@@ -2744,9 +3034,10 @@ function openObjekt(id){
//=========== MANIFESTACIJE ===========
// View mode persisted in localStorage as `_manifViewMode` ('card'|'table')
const _manifFilter = {mjesto:'', razina:'', organizator:'', q:''};
const _manifFilter = {mjesto:'', razina:'', organizator:'', q:'', godina:'', sport:'', savez_id:''};
let _manifMeta = null;
let _manifLoadSeq = 0;
const _manifExpanded = new Set();
async function loadManifestacije(){
const root = $('#pg-manifestacije');
@@ -2755,7 +3046,7 @@ async function loadManifestacije(){
if(saved==='card' || saved==='table') _state.viewManif = saved;
if(!_manifMeta){
root.innerHTML = '<div class="loading">Učitavanje manifestacija…</div>';
_manifMeta = await api('/v2/manifestacije/meta') || {mjesta:[], razine:[], organizatori:[]};
_manifMeta = await api('/v2/manifestacije/meta') || {mjesta:[], razine:[], organizatori:[], godine:[], sportovi:[], savezi:[]};
}
renderManifShell();
await reloadManifestacije();
@@ -2771,6 +3062,9 @@ async function reloadManifestacije(){
if(_manifFilter.razina) params.set('razina', _manifFilter.razina);
if(_manifFilter.organizator) params.set('organizator', _manifFilter.organizator);
if(_manifFilter.q) params.set('q', _manifFilter.q);
if(_manifFilter.godina) params.set('godina', _manifFilter.godina);
if(_manifFilter.sport) params.set('sport', _manifFilter.sport);
if(_manifFilter.savez_id) params.set('savez_id', _manifFilter.savez_id);
params.set('limit', '500');
const qs = params.toString();
const d = await api('/v2/manifestacije'+(qs?'?'+qs:''));
@@ -2780,15 +3074,20 @@ async function reloadManifestacije(){
return;
}
_cache.manifestacije = d.rows || [];
_manifExpanded.clear();
renderManifBody();
}
function renderManifShell(){
const root = $('#pg-manifestacije');
const meta = _manifMeta || {mjesta:[], razine:[], organizatori:[]};
const meta = _manifMeta || {mjesta:[], razine:[], organizatori:[], godine:[], sportovi:[], savezi:[]};
const optList = (arr) => (arr||[]).filter(x=>x!==null && x!==undefined && x!=='').map(v=>'<option value="'+esc(v)+'">'+esc(v)+'</option>').join('');
const optSavezi = (meta.savezi||[]).map(s=>'<option value="'+esc(s.id)+'">'+esc(s.naziv)+'</option>').join('');
root.innerHTML = `
<div class="toolbar">
<input type="search" id="mn-q" placeholder="🔍 Pretraži manifestaciju…" value="${esc(_manifFilter.q)}">
<select id="mn-godina" title="Godina"><option value="">Sve godine</option>${optList(meta.godine)}</select>
<select id="mn-sport" title="Sport"><option value="">Svi sportovi</option>${optList(meta.sportovi)}</select>
<select id="mn-savez" title="Savez"><option value="">Svi savezi</option>${optSavezi}</select>
<select id="mn-mjesto" title="Mjesto"><option value="">Sva mjesta</option>${optList(meta.mjesta)}</select>
<select id="mn-raz" title="Razina"><option value="">Sve razine</option>${optList(meta.razine)}</select>
<select id="mn-org" title="Organizator"><option value="">Svi organizatori</option>${optList(meta.organizatori)}</select>
@@ -2805,13 +3104,21 @@ function renderManifShell(){
if($('#mn-mjesto')) $('#mn-mjesto').value = _manifFilter.mjesto;
if($('#mn-raz')) $('#mn-raz').value = _manifFilter.razina;
if($('#mn-org')) $('#mn-org').value = _manifFilter.organizator;
if($('#mn-godina')) $('#mn-godina').value = _manifFilter.godina;
if($('#mn-sport')) $('#mn-sport').value = _manifFilter.sport;
if($('#mn-savez')) $('#mn-savez').value = _manifFilter.savez_id;
$('#mn-q').addEventListener('input', debounce(()=>{ _manifFilter.q = $('#mn-q').value.trim(); reloadManifestacije(); }, 250));
$('#mn-mjesto').addEventListener('change', ()=>{ _manifFilter.mjesto = $('#mn-mjesto').value; reloadManifestacije(); });
$('#mn-raz').addEventListener('change', ()=>{ _manifFilter.razina = $('#mn-raz').value; reloadManifestacije(); });
$('#mn-org').addEventListener('change', ()=>{ _manifFilter.organizator = $('#mn-org').value; reloadManifestacije(); });
$('#mn-godina').addEventListener('change', ()=>{ _manifFilter.godina = $('#mn-godina').value; reloadManifestacije(); });
$('#mn-sport').addEventListener('change', ()=>{ _manifFilter.sport = $('#mn-sport').value; reloadManifestacije(); });
$('#mn-savez').addEventListener('change', ()=>{ _manifFilter.savez_id = $('#mn-savez').value; reloadManifestacije(); });
$('#mn-reset').addEventListener('click', ()=>{
_manifFilter.mjesto=''; _manifFilter.razina=''; _manifFilter.organizator=''; _manifFilter.q='';
_manifFilter.godina=''; _manifFilter.sport=''; _manifFilter.savez_id='';
$('#mn-q').value=''; $('#mn-mjesto').value=''; $('#mn-raz').value=''; $('#mn-org').value='';
$('#mn-godina').value=''; $('#mn-sport').value=''; $('#mn-savez').value='';
reloadManifestacije();
});
}
@@ -2830,89 +3137,117 @@ function renderManifBody(){
}
// Backwards-compat: existing handlers (e.g. sortHeader) call applyManifFilter()
function applyManifFilter(){ renderManifBody(); }
function manifLinkFor(m){
if(m && m.source_url) return m.source_url;
const gq = encodeURIComponent(((m&&m.naziv)||'')+' '+((m&&m.mjesto)||'')+' sport');
return 'https://www.google.com/search?q='+gq;
function manifDetailHTML(m){
const gq = encodeURIComponent((m.naziv||'')+' '+(m.mjesto||'')+' sport');
const googleUrl = 'https://www.google.com/search?q='+gq;
const created = m.created_at ? new Date(m.created_at).toLocaleDateString('hr') : '—';
const scraped = m.last_scraped_at ? new Date(m.last_scraped_at).toLocaleDateString('hr') : '—';
const savezLink = m.savez_id ? '<a href="#" onclick="event.preventDefault();event.stopPropagation();openSavez('+m.savez_id+')">'+esc(m.savez_naziv||('Savez '+m.savez_id))+'</a>' : '—';
return `
<div class="kv">
<div class="k">Sport</div><div class="v">${m.sport?'<span class="tag b">'+esc(m.sport)+'</span>':'—'}</div>
<div class="k">Savez</div><div class="v">${savezLink}</div>
<div class="k">Razina</div><div class="v">${m.razina?'<span class="tag b">'+esc(m.razina)+'</span>':'—'}</div>
<div class="k">Mjesto</div><div class="v">${txt(m.mjesto)}</div>
<div class="k">Organizator (klub)</div><div class="v">${txt(m.organizator)}</div>
<div class="k">Godina od</div><div class="v">${txt(m.godina_od)}</div>
<div class="k">Sudionici</div><div class="v">${txt(m.broj_ucesnika)}</div>
<div class="k">Spol/kategorija</div><div class="v">${txt(m.spol_kategorija)}</div>
<div class="k">Izvor</div><div class="v">${txt(m.source)}</div>
<div class="k">Web savez</div><div class="v">${m.savez_web?'<a href="'+esc(m.savez_web)+'" target="_blank" rel="noopener" onclick="event.stopPropagation()">'+esc(m.savez_web)+' ↗</a>':'—'}</div>
<div class="k">Unos</div><div class="v">${esc(created)}</div>
<div class="k">Zadnji scrape</div><div class="v">${esc(scraped)}</div>
</div>
${m.napomena ? '<div style="margin-top:10px;font-size:12px;line-height:1.5;color:var(--t1);padding:10px;background:var(--bg3);border-radius:5px">'+esc(m.napomena)+'</div>' : ''}
<div style="margin-top:10px;text-align:right">
<a href="${googleUrl}" target="_blank" class="btn" rel="noopener" onclick="event.stopPropagation()" style="text-decoration:none">🔍 Google</a>
</div>
`;
}
function manifOtvoriBtn(m){
if(m.source_url){
return '<a class="btn primary" href="'+esc(m.source_url)+'" target="_blank" rel="noopener" onclick="event.stopPropagation()" style="text-decoration:none">Otvori ↗</a>';
}
const open = _manifExpanded.has(m.id);
return '<button class="btn" type="button" onclick="event.stopPropagation();toggleManif('+m.id+')">'+(open?'▴ Sakrij':'▾ Detalji')+'</button>';
}
function renderManifGrid(rows){
if(!rows.length) return '<div class="empty">Nema manifestacija za zadane filtere</div>';
return '<div class="grid">'+rows.map(m => {
const url = manifLinkFor(m);
const linkIcon = '<a class="et-link" href="'+esc(url)+'" target="_blank" rel="noopener" onclick="event.stopPropagation()" title="'+(m.source_url?'Otvori izvor':'Pretraži online')+'">🔗</a>';
const open = _manifExpanded.has(m.id);
const sportTag = m.sport ? '<span class="tag b">'+esc(m.sport)+'</span>' : '';
return `
<div class="entity" onclick="openManif(${m.id})">
${m.razina?'<div class="et-tag">'+esc(m.razina)+'</div>':''}
<div class="et">${esc(m.naziv)} ${linkIcon}</div>
<div class="es">${txt(m.mjesto,'—')}${m.spol_kategorija?' · '+esc(m.spol_kategorija):''}${m.godina_od?' · od '+esc(m.godina_od):''}</div>
<div class="et">${esc(m.naziv)}</div>
<div class="es">${sportTag}${m.godina_od?' <span class="tag">'+esc(m.godina_od)+'</span>':''} ${txt(m.mjesto,'—')}</div>
<div class="em">
${m.organizator?'<span>'+esc((m.organizator||'').slice(0,50))+'</span>':''}
${m.broj_ucesnika?'<span><b>'+esc(m.broj_ucesnika)+'</b> sudionika</span>':''}
${m.organizator?'<span>'+esc((m.organizator||'').slice(0,40))+'</span>':''}
</div>
<div style="margin-top:8px;text-align:right">${manifOtvoriBtn(m)}</div>
${open?'<div class="card" style="margin-top:10px;padding:10px" onclick="event.stopPropagation()">'+manifDetailHTML(m)+'</div>':''}
</div>`;
}).join('')+'</div>';
}
function renderManifTable(rows){
if(!rows.length) return '<div class="empty">Nema manifestacija za zadane filtere</div>';
return `<div class="card" style="padding:0;overflow-x:auto"><table>
<thead><tr>${sortHeader('manifestacije','naziv','Naziv','')}${sortHeader('manifestacije','mjesto','Mjesto','')}${sortHeader('manifestacije','razina','Razina','')}${sortHeader('manifestacije','organizator','Organizator','')}${sortHeader('manifestacije','broj_ucesnika','Sudionici','')}<th>Link</th></tr></thead>
<thead><tr>${sortHeader('manifestacije','naziv','Naziv','')}${sortHeader('manifestacije','sport','Sport','')}${sortHeader('manifestacije','godina_od','Datum','')}${sortHeader('manifestacije','mjesto','Mjesto','')}${sortHeader('manifestacije','organizator','Klub/Organizator','')}<th></th></tr></thead>
<tbody>${rows.map(m => {
const url = manifLinkFor(m);
const open = _manifExpanded.has(m.id);
return `
<tr onclick="openManif(${m.id})">
<td><b>${esc(m.naziv)}</b></td>
<tr onclick="openManif(${m.id})" style="cursor:pointer">
<td><b>${esc(m.naziv)}</b>${m.razina?' <span class="tag b">'+esc(m.razina)+'</span>':''}</td>
<td>${m.sport?'<span class="tag b">'+esc(m.sport)+'</span>':'—'}</td>
<td>${txt(m.godina_od)}</td>
<td>${txt(m.mjesto)}</td>
<td>${m.razina?'<span class="tag b">'+esc(m.razina)+'</span>':'—'}</td>
<td>${txt(m.organizator)}</td>
<td>${txt(m.broj_ucesnika)}</td>
<td><a href="${esc(url)}" target="_blank" rel="noopener" onclick="event.stopPropagation()" title="${m.source_url?'Otvori izvor':'Pretraži online'}">🔗</a></td>
</tr>`;
<td style="text-align:right">${manifOtvoriBtn(m)}</td>
</tr>
${open?'<tr onclick="event.stopPropagation()"><td colspan="6" style="background:var(--bg3);padding:14px">'+manifDetailHTML(m)+'</td></tr>':''}`;
}).join('')}</tbody>
</table></div>`;
}
function toggleManif(id){
if(_manifExpanded.has(id)) _manifExpanded.delete(id);
else _manifExpanded.add(id);
renderManifBody();
}
function openManif(id){
const m = (_cache.manifestacije||[]).find(x => x.id===id);
if(!m){ openPanel('Manifestacija', '<div class="empty">Nije pronađeno</div>'); return; }
// If we have a source_url, open it directly in a new tab
if(!m) return;
if(m.source_url){
window.open(m.source_url, '_blank', 'noopener');
return;
}
// Otherwise show details + Google search fallback
const gq = encodeURIComponent((m.naziv||'')+' '+(m.mjesto||'')+' sport');
const googleUrl = 'https://www.google.com/search?q='+gq;
const html = `
<div class="card-h" style="border:0;padding:0;margin-bottom:14px">
<div>
<div style="font-size:18px;font-weight:800;color:var(--t0)">${esc(m.naziv)}</div>
<div style="font-size:12px;color:var(--t2);margin-top:4px">${txt(m.mjesto,'—')} · ${txt(m.razina,'')}</div>
</div>
</div>
<div class="card">
<div class="card-h"><div class="card-t">📋 Detalji</div></div>
<div class="kv">
<div class="k">Organizator</div><div class="v">${txt(m.organizator)}</div>
<div class="k">Razina</div><div class="v">${m.razina?'<span class="tag b">'+esc(m.razina)+'</span>':'—'}</div>
<div class="k">Sudionici</div><div class="v">${txt(m.broj_ucesnika)}</div>
<div class="k">Spol/kategorija</div><div class="v">${txt(m.spol_kategorija)}</div>
<div class="k">Godina od</div><div class="v">${txt(m.godina_od)}</div>
<div class="k">Mjesto</div><div class="v">${txt(m.mjesto)}</div>
</div>
${m.napomena ? '<div style="margin-top:14px;font-size:12px;line-height:1.5;color:var(--t1);padding:10px;background:var(--bg3);border-radius:5px">'+esc(m.napomena)+'</div>' : ''}
</div>
<div class="card">
<div class="card-h"><div class="card-t">🌐 Online izvori</div></div>
<div class="empty" style="padding:14px">Nema poznatog izvornog URL-a. Pokušaj pronaći više informacija online:</div>
<div style="text-align:center;margin-top:10px">
<a href="${googleUrl}" target="_blank" class="btn primary" style="display:inline-block;text-decoration:none">🔍 Pretraži na Googleu</a>
</div>
</div>
`;
openPanel('Manifestacija · '+m.naziv, html);
toggleManif(id);
}
//=========== MREŽA (Network Graph) ===========
const _mreza = {data:null, sim:null, allNodes:null, allEdges:null, filter:{osoba:'', klub:'', tvrtka:'', tip:''}};
const _mreza = {data:null, sim:null, allNodes:null, allEdges:null, filter:{osoba:'', klub:'', tvrtka:'', tip:''}, toggle:{osoba:true, entitet:true, klub_savez:true}};
// Heuristic to decide whether an entity is actually a sports klub/savez.
// Matches: NK/HNK/MOK/RK/KK/VK/HK/TK/JK/ŠK token, or the words "klub"/"savez" anywhere.
function _mrezaIsKlubSavez(label){
if(!label) return false;
const s = String(label);
if(/\b(NK|HNK|MOK|RK|KK|VK|HK|TK|JK|ŠK|FK|BK|GK)\b/.test(s)) return true;
if(/\b(klub|savez)\b/i.test(s)) return true;
return false;
}
// Map a node to one of three logical filter groups; returns null for nodes that
// shouldn't participate in toggling (e.g. user-injected suggestions).
function _mrezaCategory(n){
if(!n) return null;
if(n.type === 'person') return 'osoba';
if(n.type === 'pgz_savez') return 'klub_savez';
if(n.type === 'entity' || n.type === 'supplier'){
return _mrezaIsKlubSavez(n.label) ? 'klub_savez' : 'entitet';
}
return null; // injected / unknown — never hidden by toggles
}
async function loadMreza(){
const root = $('#pg-mreza');
@@ -2944,6 +3279,17 @@ async function loadMreza(){
edges.push({source:anchorId, target:t.id, color:'#F4C43055', size:0.6});
}
}
// Tag each node with a logical filter category (osoba / entitet / klub_savez).
// Recolor sport klubovi/savezi so they visually align with the green Klub/Savez toggle,
// and bump procurement suppliers (which were also green) to amber to avoid the colour clash.
for(const n of nodes){
n.category = _mrezaCategory(n);
if(n.category === 'klub_savez' && n.type !== 'pgz_savez'){
n.color = '#00e68a';
} else if(n.type === 'supplier'){
n.color = '#ffaa00';
}
}
_mreza.data = {nodes, edges};
_mreza.allNodes = nodes;
_mreza.allEdges = edges;
@@ -2982,8 +3328,8 @@ function renderMrezaShell(){
<div class="kpi-grid" style="grid-template-columns:repeat(4,1fr);margin-bottom:14px">
<div class="kpi"><div class="kpi-l">Čvorova</div><div class="kpi-v">${totalN}</div></div>
<div class="kpi b"><div class="kpi-l">Veza</div><div class="kpi-v">${totalE}</div></div>
<div class="kpi g"><div class="kpi-l">Osoba</div><div class="kpi-v">${(_mreza.allNodes||[]).filter(n=>n.type==='person').length}</div></div>
<div class="kpi r"><div class="kpi-l">Tvrtki / entiteta</div><div class="kpi-v">${(_mreza.allNodes||[]).filter(n=>n.type==='entity'||n.type==='supplier').length}</div></div>
<div class="kpi g"><div class="kpi-l">Osoba</div><div class="kpi-v">${(_mreza.allNodes||[]).filter(n=>n.category==='osoba').length}</div></div>
<div class="kpi r"><div class="kpi-l">Klubova / saveza</div><div class="kpi-v">${(_mreza.allNodes||[]).filter(n=>n.category==='klub_savez').length}</div></div>
</div>
<div class="toolbar" style="margin-bottom:10px;align-items:flex-start">
@@ -3007,12 +3353,21 @@ function renderMrezaShell(){
</div>
<div class="card" style="margin-top:10px">
<div class="card-h"><div class="card-t">🎨 Legenda</div></div>
<div style="display:flex;gap:14px;flex-wrap:wrap;font-size:12px">
<div><span style="display:inline-block;width:12px;height:12px;border-radius:50%;background:#8b5cf6;vertical-align:middle;margin-right:5px"></span>Osoba</div>
<div><span style="display:inline-block;width:12px;height:12px;border-radius:50%;background:#ff4466;vertical-align:middle;margin-right:5px"></span>Entitet (high risk)</div>
<div><span style="display:inline-block;width:12px;height:12px;border-radius:50%;background:#00e68a;vertical-align:middle;margin-right:5px"></span>Dobavljač</div>
<div style="color:var(--t2)">Veličina = risk / promet · Klikni čvor za detalje · 3D force graph (drag rotate, scroll zoom)</div>
<div class="card-h"><div class="card-t">🎨 Legenda &amp; filteri tipova</div></div>
<div id="mr-toggles" style="display:flex;gap:8px;flex-wrap:wrap;font-size:12px;align-items:center">
<button type="button" class="mr-tg" data-cat="osoba" aria-pressed="true"
style="display:inline-flex;align-items:center;gap:6px;padding:7px 12px;min-height:36px;border-radius:18px;border:1px solid #283560;background:rgba(139,92,246,0.12);color:var(--t1);font-size:12px;cursor:pointer;line-height:1">
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#8b5cf6"></span>Osoba <span class="mr-tg-n" style="opacity:.7">${(_mreza.allNodes||[]).filter(n=>n.category==='osoba').length}</span>
</button>
<button type="button" class="mr-tg" data-cat="entitet" aria-pressed="true"
style="display:inline-flex;align-items:center;gap:6px;padding:7px 12px;min-height:36px;border-radius:18px;border:1px solid #283560;background:rgba(255,68,102,0.12);color:var(--t1);font-size:12px;cursor:pointer;line-height:1">
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#ff4466"></span>Entitet <span class="mr-tg-n" style="opacity:.7">${(_mreza.allNodes||[]).filter(n=>n.category==='entitet').length}</span>
</button>
<button type="button" class="mr-tg" data-cat="klub_savez" aria-pressed="true"
style="display:inline-flex;align-items:center;gap:6px;padding:7px 12px;min-height:36px;border-radius:18px;border:1px solid #283560;background:rgba(0,230,138,0.14);color:var(--t1);font-size:12px;cursor:pointer;line-height:1">
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#00e68a"></span>Klub/Savez <span class="mr-tg-n" style="opacity:.7">${(_mreza.allNodes||[]).filter(n=>n.category==='klub_savez').length}</span>
</button>
<div style="color:var(--t2);margin-left:6px">Klikni za uključi/isključi · Veličina = risk / promet · 3D force graph (drag rotate, scroll zoom)</div>
</div>
</div>
`;
@@ -3028,6 +3383,25 @@ function renderMrezaShell(){
el.addEventListener('blur', () => setTimeout(() => closeSuggest(el), 200));
});
$('#mr-tip').addEventListener('change', applyMrezaFilter);
// Wire 3 category toggle pills (Osoba / Entitet / Klub-Savez)
document.querySelectorAll('#mr-toggles .mr-tg').forEach(btn => {
// Sync visual state with current _mreza.toggle (preserve across re-render)
const cat = btn.dataset.cat;
if(_mreza.toggle && _mreza.toggle[cat] === false){
btn.setAttribute('aria-pressed','false');
btn.style.opacity = '0.4';
btn.style.textDecoration = 'line-through';
}
btn.addEventListener('click', () => {
const c = btn.dataset.cat;
_mreza.toggle[c] = !_mreza.toggle[c];
const on = _mreza.toggle[c];
btn.setAttribute('aria-pressed', on ? 'true' : 'false');
btn.style.opacity = on ? '1' : '0.4';
btn.style.textDecoration = on ? 'none' : 'line-through';
applyMrezaFilter();
});
});
}
async function fetchSuggest(inputEl){
@@ -3111,6 +3485,13 @@ function applyMrezaFilter(){
const tip = $('#mr-tip').value;
let nodes = (_mreza.allNodes||[]).slice();
// Category toggles: hide nodes whose category is switched off (null category = always shown).
const tg = _mreza.toggle || {osoba:true, entitet:true, klub_savez:true};
nodes = nodes.filter(n => {
const c = n.category;
if(!c) return true;
return tg[c] !== false;
});
if(osoba) nodes = nodes.filter(n => n.type==='person' && (n.label||'').toLowerCase().includes(osoba) || n.type!=='person');
if(klub){
// filter entity/supplier by label match (savezi/klubovi appear as entities)
@@ -3897,7 +4278,8 @@ window._wrapOpener = function(name){
};
// Wrap all known root openers (idempotent)
['openSavez','openKlub','openSportas','openObjekt','openManif',
// openManif intentionally excluded: uses inline expand, not the side panel
['openSavez','openKlub','openSportas','openObjekt',
'openPrimateljDetail','openProracunDrill','openSavezByName',
'openMrezaNode','openForensicDetail','openOIB']
.forEach(function(n){ try { window._wrapOpener(n); } catch(e) {} });
@@ -3913,7 +4295,145 @@ window.closePanel = function(){
const ov = document.getElementById('panel-overlay');
if(ov){ ov.classList.remove('open'); ov.style.removeProperty('display'); }
};
// ═══════════════════════════════════════════════════════
// SPORT-S4: Ručni unos financije (admin only)
// ═══════════════════════════════════════════════════════
window._klubsCache = null;
async function loadKlubsForPicker(){
if(window._klubsCache) return window._klubsCache;
try {
const r = await api('/v2/klubovi?limit=2000');
const list = r && (r.results || r.rows || []) || [];
window._klubsCache = list.map(k => ({id:k.id, naziv:k.naziv, sport:k.sport||''}))
.sort((a,b)=>(a.naziv||'').localeCompare(b.naziv||''));
} catch(e){ console.warn('klubovi load fail', e); window._klubsCache = []; }
return window._klubsCache;
}
async function openManualFinancijeForm(){
const klubs = await loadKlubsForPicker();
if(!klubs.length){ alert('Nije moguće učitati popis klubova.'); return; }
const yearNow = new Date().getFullYear();
const yearsOpts = [];
for(let y=yearNow+1; y>=yearNow-10; y--) yearsOpts.push(y);
// Modal overlay
const old = document.getElementById('fi-manual-modal');
if(old) old.remove();
const m = document.createElement('div');
m.id = 'fi-manual-modal';
m.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:9999;display:flex;align-items:center;justify-content:center;padding:14px;font-family:inherit';
m.innerHTML = `
<div style="background:var(--bg,#101018);border:1px solid var(--border,#333);border-radius:10px;padding:22px;max-width:520px;width:100%;max-height:90vh;overflow:auto;color:var(--text,#e8e8f0)">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px">
<h3 style="margin:0;font-size:17px"> Ručni unos financija (klubu)</h3>
<button onclick="document.getElementById('fi-manual-modal').remove()" style="background:none;border:none;color:#aaa;font-size:24px;cursor:pointer;line-height:1">&times;</button>
</div>
<div style="display:flex;flex-direction:column;gap:10px;font-size:13px">
<label>Godina
<select id="fm-god" style="width:100%;padding:7px;background:var(--surface,#1a1a25);border:1px solid var(--border,#333);color:inherit;border-radius:5px">
${yearsOpts.map(y => '<option value="'+y+'"'+(y===yearNow?' selected':'')+'>'+y+'</option>').join('')}
</select>
</label>
<label>Klub (${klubs.length} u registru)
<input id="fm-klub-q" type="text" placeholder="Pretraži klub po nazivu…" autocomplete="off" style="width:100%;padding:7px;background:var(--surface,#1a1a25);border:1px solid var(--border,#333);color:inherit;border-radius:5px">
<select id="fm-klub" size="6" style="width:100%;margin-top:4px;padding:4px;background:var(--surface,#1a1a25);border:1px solid var(--border,#333);color:inherit;border-radius:5px;font-size:12px">
${klubs.slice(0,200).map(k => '<option value="'+k.id+'">'+esc(k.naziv)+(k.sport?' · '+esc(k.sport):'')+'</option>').join('')}
</select>
</label>
<label>Iznos (€)
<input id="fm-iznos" type="number" step="0.01" min="0.01" placeholder="npr. 12500.00" style="width:100%;padding:7px;background:var(--surface,#1a1a25);border:1px solid var(--border,#333);color:inherit;border-radius:5px">
</label>
<label>Razina
<select id="fm-razina" style="width:100%;padding:7px;background:var(--surface,#1a1a25);border:1px solid var(--border,#333);color:inherit;border-radius:5px">
<option value="ručni_unos" selected>ručni_unos</option>
<option value="županija">županija (PGŽ)</option>
<option value="grad_rijeka">grad_rijeka</option>
<option value="opcina">opcina</option>
<option value="ministarstvo">ministarstvo</option>
</select>
</label>
<label>Vrsta (kratki tag)
<input id="fm-vrsta" type="text" value="ručni_unos" style="width:100%;padding:7px;background:var(--surface,#1a1a25);border:1px solid var(--border,#333);color:inherit;border-radius:5px">
</label>
<label>Opis / Napomena
<textarea id="fm-opis" rows="3" placeholder="npr. ugovor 2025/RSS-117 — Treninzi i natjecanja" style="width:100%;padding:7px;background:var(--surface,#1a1a25);border:1px solid var(--border,#333);color:inherit;border-radius:5px;font-family:inherit"></textarea>
</label>
<label>Source URL (PDF link, opcionalno)
<input id="fm-url" type="url" placeholder="https://…" style="width:100%;padding:7px;background:var(--surface,#1a1a25);border:1px solid var(--border,#333);color:inherit;border-radius:5px">
</label>
<div id="fm-msg" style="font-size:12px;margin-top:4px;min-height:18px"></div>
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:6px">
<button onclick="document.getElementById('fi-manual-modal').remove()" style="padding:8px 14px;background:none;border:1px solid var(--border,#333);color:inherit;border-radius:5px;cursor:pointer">Odustani</button>
<button id="fm-save-btn" onclick="saveManualFinancije()" style="padding:8px 14px;background:var(--pgz-gold,#c5a040);border:none;color:#000;border-radius:5px;cursor:pointer;font-weight:600">💾 Spremi</button>
</div>
</div>
</div>`;
document.body.appendChild(m);
// Live klub filter
const qIn = document.getElementById('fm-klub-q');
const sel = document.getElementById('fm-klub');
qIn.addEventListener('input', () => {
const q = qIn.value.toLowerCase().trim();
const filtered = q
? klubs.filter(k => (k.naziv||'').toLowerCase().includes(q) || (k.sport||'').toLowerCase().includes(q))
: klubs;
sel.innerHTML = filtered.slice(0, 200).map(k =>
'<option value="'+k.id+'">'+esc(k.naziv)+(k.sport?' · '+esc(k.sport):'')+'</option>'
).join('');
if(filtered.length) sel.value = filtered[0].id;
});
}
async function saveManualFinancije(){
const msg = document.getElementById('fm-msg');
const btn = document.getElementById('fm-save-btn');
const payload = {
godina: parseInt(document.getElementById('fm-god').value),
klub_id: parseInt(document.getElementById('fm-klub').value || 0),
iznos_eur: parseFloat(document.getElementById('fm-iznos').value || 0),
razina: document.getElementById('fm-razina').value,
vrsta: (document.getElementById('fm-vrsta').value || 'ručni_unos').trim() || 'ručni_unos',
opis: (document.getElementById('fm-opis').value || '').trim(),
napomena: (document.getElementById('fm-opis').value || '').trim(),
source_url:(document.getElementById('fm-url').value || '').trim() || null,
};
if(!payload.klub_id){ msg.textContent = '❌ Odaberi klub.'; msg.style.color = '#ef4444'; return; }
if(!payload.iznos_eur || payload.iznos_eur <= 0){ msg.textContent = '❌ Iznos mora biti > 0.'; msg.style.color = '#ef4444'; return; }
btn.disabled = true; btn.textContent = 'Spremam…';
msg.style.color = ''; msg.textContent = 'Spremam…';
try {
const r = await fetch('/sport/api/v2/financije/manual-entry', {
method:'POST',
headers:{
'Content-Type':'application/json',
...(window._AUTH_TOKEN ? {'Authorization':'Bearer '+window._AUTH_TOKEN} : {}),
},
body: JSON.stringify(payload),
});
if(!r.ok){
const t = await r.text();
throw new Error('HTTP '+r.status+': '+t.slice(0,200));
}
const d = await r.json();
msg.style.color = '#22c55e';
msg.textContent = '✅ Spremljeno (id='+d.id+'). Tablica se osvježava…';
setTimeout(() => {
const modal = document.getElementById('fi-manual-modal');
if(modal) modal.remove();
if(typeof refreshFinancije === 'function') refreshFinancije();
}, 900);
} catch(e){
msg.style.color = '#ef4444';
msg.textContent = '❌ '+(e.message||e);
btn.disabled = false; btn.textContent = '💾 Spremi';
}
}
</script>
<script src="/static/js/export_dropdown.js"></script>
<script src="/static/_ai_widget.js" defer></script>
</body>
</html>