feat: /api/v2/analiza/* endpoints - sport analytics backend
This commit is contained in:
+621
-101
@@ -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,"'")})'>
|
||||
<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,"'")}, ${JSON.stringify(r.korisnik).replace(/'/g,"'")})' 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 & 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">×</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>
|
||||
|
||||
Reference in New Issue
Block a user