6-sub sprint: Dokumenti+HNS profil+Admin+ERP+CRM+PGŽ filter

SUB1 Dokumenti: pgz:dokumenti SECTIONS handler u app.html (klikabilan grid 19 godišnjaka, PDF stream)
SUB2 HNS profil: sport2.html drill-down — bio-chips (visina/težina/noga/poz/dres) + HNS deep + Google + Wiki + 🏆 Karijera/📅 Utakmice tabovi (Josip Zec id=449: 257 nast/182 gol/15 sez)
SUB3 Admin Users: sidebar.js href fix /admin/users → /sport/admin/users + razriješen audit ID konflikt
SUB4 ERP Full: 5 novih endpointa (invoice-uploads, racuni/ulazni/{rid}/uploads, expense-reports, putni-nalog-racuni, payments) + 3 nova taba (📎 Uploads/OCR, ✈ Putni, 💰 Plaćanja) + inline stavke drill-down + sidebar entry
SUB5 CRM Salesforce-Lite: dodan crm_v2 sidebar entry (router 956 linija već mounted)
SUB6 PGŽ filter: 2 nova endpointa /api/v2/savezi/priority-sort + /api/v2/clanovi/priority-sort; togglePGZFilter wired u Klubovi/Savezi/Sportaši (sport2.html + app.html); 💰📖 badge prefix; klubovi 1536/1641, savezi 35/246, sportaši 4979/5499

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Damir Radulić
2026-05-05 13:17:56 +02:00
parent 1d02c0897d
commit 16b980e842
6 changed files with 625 additions and 77 deletions
+115 -42
View File
@@ -183,6 +183,21 @@ a.tag:hover,.tag[onclick]:hover{transform:translateY(-1px);filter:brightness(1.1
.pp-stat{text-align:center}
.pp-stat .v{font-size:20px;font-weight:800;color:var(--pgz-gold);font-family:var(--mono)}
.pp-stat .l{font-size:9px;color:var(--t4);text-transform:uppercase;letter-spacing:.5px;margin-top:2px;font-weight:600}
.pp-bio-row{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px;font-size:11px;color:var(--t2)}
.pp-bio-chip{padding:3px 8px;background:var(--bg3);border:1px solid var(--rim);border-radius:4px;color:var(--t1);font-weight:500}
.pp-bio-chip b{color:var(--pgz-gold);font-family:var(--mono);font-weight:700;margin-right:4px}
.pp-links{display:flex;flex-wrap:wrap;gap:6px;margin-top:10px}
.pp-link{display:inline-flex;align-items:center;gap:6px;padding:6px 11px;background:var(--bg3);border:1px solid var(--rim2);border-radius:5px;color:var(--t1);font-size:11.5px;font-weight:600;text-decoration:none;cursor:pointer;transition:all .15s}
.pp-link:hover{background:var(--bg4);border-color:var(--pgz-gold);color:var(--pgz-gold);transform:translateY(-1px)}
.pp-link.hns{border-color:#0066cc;color:#3aa8ff}
.pp-link.hns:hover{background:rgba(0,102,204,.15);color:#5cc8ff;border-color:#3aa8ff}
.pp-link.gg{border-color:#9b8aff;color:#b9aeff}
.pp-link.gg:hover{background:rgba(155,138,255,.15);color:#cfc6ff}
.pp-link.wiki{border-color:#aaa;color:#ddd}
.pp-link.wiki:hover{background:rgba(255,255,255,.06);color:#fff}
.pp-section{margin-top:18px}
.pp-section-h{display:flex;align-items:center;gap:8px;margin-bottom:8px;padding-bottom:6px;border-bottom:1px solid var(--rim);font-size:13px;font-weight:700;color:var(--t0);letter-spacing:.3px}
.pp-section-h .cnt{font-size:10.5px;color:var(--t4);font-weight:600;font-family:var(--mono)}
.kv{display:grid;grid-template-columns:140px 1fr;gap:6px 12px;font-size:12px}
.kv .k{color:var(--t2);font-weight:600}
@@ -343,6 +358,42 @@ const _state = {section:'dashboard', viewSavezi:'card', viewKlubovi:'card', view
const _sort = {savezi:null, klubovi:null, sportasi:null, objekti:null, manifestacije:null, financije:null};
let _proracunChart=null, _financijeChart=null;
// === PGŽ priority filter (SUB6) — global helper, works across Klubovi/Savezi/Sportaši ===
window._pgz_filter_priority = window._pgz_filter_priority || false;
window.togglePGZFilter = function(section){
window._pgz_filter_priority = !window._pgz_filter_priority;
// Drop caches so the next load fetches priority-only or full set.
if(!section || section==='klubovi') _cache.klubovi = null;
if(!section || section==='savezi') _cache.savezi = null;
if(!section || section==='sportasi') _cache.clanovi = null;
// Reload whichever section is active.
const cur = (section || _state.section);
if(cur==='klubovi') loadKlubovi();
else if(cur==='savezi') loadSavezi();
else if(cur==='sportasi') loadSportasi();
else loadSection(_state.section);
};
window.pgzBadgePrefix = function(it, kind){
// returns ⭐💰📖 prefix tailored to which markers apply
const fin = !!(it && (it.financiran || it.klub_financiran || it.pgz_sufinanciran));
const god = !!(it && (it.godisnjak || it.klub_godisnjak || (it.godisnjak_godine && (it.godisnjak_godine.length||0)>0)));
const pgzs = !!(it && it.pgz_relevant);
const pri = !!(it && it.priority) || fin || god || pgzs;
if(!pri) return '';
let s = '⭐';
if(fin || pgzs) s += '💰';
if(god) s += '📖';
return s + ' ';
};
window.renderPGZToggleBtn = function(section){
const on = !!window._pgz_filter_priority;
return '<button class="btn '+(on?'primary':'')+'" '
+ 'title="Prikaži samo PGŽ-financirane / u godišnjaku" '
+ 'onclick="togglePGZFilter(\''+section+'\')">'
+ (on ? '⭐ PGŽ filter ON' : '☆ PGŽ filter OFF') + '</button>';
};
//=========== UTIL ===========
const $ = s => document.querySelector(s);
const $$ = s => Array.from(document.querySelectorAll(s));
@@ -1129,7 +1180,11 @@ async function loadSavezi(){
const root = $('#pg-savezi');
if(!_cache.savezi){
root.innerHTML = '<div class="loading">Učitavanje saveza…</div>';
const d = await api('/savezi?limit=250');
// PGŽ filter: switch to v2 priority-sort endpoint (only=true returns just PGŽ-relevant savezi)
const url = window._pgz_filter_priority
? '/v2/savezi/priority-sort?only=true&limit=500'
: '/savezi?limit=250';
const d = await api(url);
if(!d){ root.innerHTML='<div class="empty">Greška pri dohvatu</div>'; return; }
_cache.savezi = d.rows || [];
}
@@ -1156,6 +1211,7 @@ function renderSaveziShell(){
<button id="sav-card" class="${_state.viewSavezi==='card'?'active':''}" onclick="setSaveziView('card')">Kartice</button>
<button id="sav-table" class="${_state.viewSavezi==='table'?'active':''}" onclick="setSaveziView('table')">Tablica</button>
</div>
${window.renderPGZToggleBtn ? window.renderPGZToggleBtn('savezi') : ''}
<span class="tb-s" id="sav-cnt"></span>
</div>
<div id="sav-out"></div>
@@ -1191,7 +1247,7 @@ function renderSaveziGrid(rows){
return '<div class="grid">'+rows.map(s => `
<div class="entity" onclick="openSavez(${s.id})">
${s.pgz_relevant?'<div class="et-tag">PGŽ</div>':''}
<div class="et">${esc(s.naziv)}</div>
<div class="et">${(window.pgzBadgePrefix?window.pgzBadgePrefix(s,'savez'):'')}${esc(s.naziv)}</div>
<div class="es">${txt(s.sport,'—')} · ${txt(s.predsjednik,'bez predsjednika')}</div>
<div class="em">
<span><b>${fmtNum(s.broj_klubova)}</b> klubova</span>
@@ -1206,7 +1262,7 @@ function renderSaveziTable(rows){
<thead><tr>${sortHeader('savezi','naziv','Naziv','')}${sortHeader('savezi','sport','Sport','')}${sortHeader('savezi','predsjednik','Predsjednik','')}${sortHeader('savezi','email','Email','')}${sortHeader('savezi','broj_klubova','Klubova','num')}${sortHeader('savezi','reg_2024','Reg.','num')}${sortHeader('savezi','pgz_relevant','PGŽ','')}</tr></thead>
<tbody>${rows.map(s => `
<tr onclick="openSavez(${s.id})">
<td><b>${esc(s.naziv)}</b></td>
<td><b>${(window.pgzBadgePrefix?window.pgzBadgePrefix(s,'savez'):'')}${esc(s.naziv)}</b></td>
<td>${txt(s.sport)}</td>
<td>${txt(s.predsjednik)}</td>
<td>${s.email?'<span class="tag b">'+esc(s.email)+'</span>':'—'}</td>
@@ -1275,40 +1331,18 @@ async function openSavez(id){
}
//=========== KLUBOVI ===========
async
// === PGŽ FINANCIRANI FILTER (CRISIS V4) ===
window._klubFilters = window._klubFilters || {financiran: false, godisnjak: false};
// (legacy _klubFilters helpers replaced by global togglePGZFilter — SUB6)
window.toggleKlubFilter = function(name){
window._klubFilters[name] = !window._klubFilters[name];
loadKlubovi();
};
window.renderKlubFilters = function(targetEl){
if(!targetEl) return;
const f = window._klubFilters;
targetEl.innerHTML = `
<label style="margin-right:12px;cursor:pointer;color:var(--t1)">
<input type="checkbox" ${f.financiran?'checked':''} onchange="toggleKlubFilter('financiran')" data-filter="financiran">
💰 Samo financirani od PGŽ
</label>
<label style="margin-right:12px;cursor:pointer;color:var(--t1)">
<input type="checkbox" ${f.godisnjak?'checked':''} onchange="toggleKlubFilter('godisnjak')" data-filter="godisnjak">
📖 U godišnjaku
</label>
<label style="cursor:pointer;color:var(--t1)">
<input type="checkbox" ${f.priority_only?'checked':''} onchange="toggleKlubFilter('priority_only')" data-filter="priority_only">
⭐ Samo prioritet (financiran ili godišnjak)
</label>
`;
};
function loadKlubovi(){
async function loadKlubovi(){
const root = $('#pg-klubovi');
if(!_cache.klubovi){
root.innerHTML = '<div class="loading">Učitavanje klubova…</div>';
// request all clubs sorted by priority (financed-or-godišnjak first) from backend
const d = await api('/klubovi?limit=2500');
// /api/klubovi already returns priority/financiran/godisnjak flags.
// When PGŽ filter is on, ask backend to only return priority klubs.
const url = window._pgz_filter_priority
? '/klubovi?kategorija=priority&limit=2500'
: '/klubovi?limit=2500';
const d = await api(url);
if(!d){ root.innerHTML='<div class="empty">Greška pri dohvatu</div>'; return; }
_cache.klubovi = d.rows || [];
}
@@ -1338,6 +1372,7 @@ function renderKluboviShell(){
<button class="btn" onclick="exportKlubovi('xlsx')">⬇ XLSX</button>
<button class="btn" onclick="exportKlubovi('csv')">⬇ CSV</button>
<button class="btn" onclick="enrichBulk('klub', 50, 70)">✨ Obogati (50)</button>
${window.renderPGZToggleBtn ? window.renderPGZToggleBtn('klubovi') : ''}
<span class="tb-s" id="kl-cnt"></span>
</div>
<div id="kl-out"></div>
@@ -1388,7 +1423,7 @@ function renderKluboviGrid(rows){
return '<div class="grid-club">'+rows.map(k => `
<div class="entity" onclick="openKlub(${k.id})">
${k.priority?'<div class="et-tag" style="background:#ffd700;color:#1a1a1a">★ PRIO</div>':(k.nositelj_kvalitete?'<div class="et-tag">N.K.</div>':'')}
<div class="et">${esc(k.klub||k.sport||'(bez naziva)')}</div>
<div class="et">${(window.pgzBadgePrefix?window.pgzBadgePrefix(k,'klub'):'')}${esc(k.klub||k.sport||'(bez naziva)')}</div>
<div class="es">${txt(k.razina,'')} · ${txt(k.grad,'—')}</div>
<div class="em">
${k.financiran?'<span class="tag gd" title="PGŽ sufinanciran">€</span>':''}
@@ -1407,7 +1442,7 @@ function renderKluboviTable(rows){
<tr>
<td onclick="event.stopPropagation()"><input type="checkbox" class="kl-pick" data-id="${k.id}"></td>
<td onclick="openKlub(${k.id})">${k.priority?'<span class="tag gd" title="financiran ili u godišnjaku">★</span>':''}</td>
<td onclick="openKlub(${k.id})"><b>${esc(k.klub||k.sport||'(bez naziva)')}</b></td>
<td onclick="openKlub(${k.id})"><b>${(window.pgzBadgePrefix?window.pgzBadgePrefix(k,'klub'):'')}${esc(k.klub||k.sport||'(bez naziva)')}</b></td>
<td onclick="openKlub(${k.id})">${txt(k.sport)}</td>
<td onclick="openKlub(${k.id})">${txt(k.razina)}</td>
<td onclick="openKlub(${k.id})">${txt(k.grad)}</td>
@@ -1678,7 +1713,11 @@ async function loadSportasi(){
const root = $('#pg-sportasi');
if(!_cache.clanovi){
root.innerHTML = '<div class="loading">Učitavanje sportaša…</div>';
const d = await api('/clanovi-full?limit=500');
// PGŽ filter: switch to v2 priority-sort (joins club, marks priority)
const url = window._pgz_filter_priority
? '/v2/clanovi/priority-sort?only=true&limit=2000'
: '/clanovi-full?limit=500';
const d = await api(url);
if(!d){ root.innerHTML='<div class="empty">Greška pri dohvatu</div>'; return; }
_cache.clanovi = d.rows || [];
}
@@ -1718,6 +1757,7 @@ function renderSportasiShell(){
<button id="sp-card" class="${_state.viewSportasi==='card'?'active':''}" onclick="setSportasiView('card')">Kartice</button>
<button id="sp-table" class="${_state.viewSportasi==='table'?'active':''}" onclick="setSportasiView('table')">Tablica</button>
</div>
${window.renderPGZToggleBtn ? window.renderPGZToggleBtn('sportasi') : ''}
<span class="tb-s" id="sp-cnt"></span>
<button class="btn" onclick="enrichBulk('sportas', 50, 70)"> Obogati sve (50)</button>
<button class="btn primary" onclick="openSportas(449)"> Test: Josip Zec</button>
@@ -1855,7 +1895,7 @@ function buildPlayerCard(c){
<div class="player-card" onclick="openSportas(${c.id})">
<div class="ph">${photo}</div>
<div class="pb">
<div class="pn">${esc(c.ime||'')} ${esc(c.prezime||'')}</div>
<div class="pn">${(window.pgzBadgePrefix?window.pgzBadgePrefix(c,'sportas'):'')}${esc(c.ime||'')} ${esc(c.prezime||'')}</div>
<div class="pp">${txt(c.sport,'—')} · ${txt(c.pozicija,'')}</div>
<div class="pk">${txt(c.klub_naziv_godisnjak,'')}</div>
<div class="badges">
@@ -1873,7 +1913,7 @@ function renderSportasiTable(rows){
<thead><tr>${sortHeader('sportasi','prezime','Prezime','')}${sortHeader('sportasi','ime','Ime','')}${sortHeader('sportasi','sport','Sport','')}${sortHeader('sportasi','pozicija','Pozicija','')}${sortHeader('sportasi','hoo_kategorija','HOO','')}${sortHeader('sportasi','reprezentativac','Repr.','')}${sortHeader('sportasi','slika_url','Foto','')}</tr></thead>
<tbody>${rows.map(c => `
<tr onclick="openSportas(${c.id})">
<td><b>${esc(c.prezime||'')}</b></td>
<td><b>${(window.pgzBadgePrefix?window.pgzBadgePrefix(c,'sportas'):'')}${esc(c.prezime||'')}</b></td>
<td>${esc(c.ime||'')}</td>
<td>${txt(c.sport)}</td>
<td>${txt(c.pozicija)}</td>
@@ -1886,11 +1926,23 @@ function renderSportasiTable(rows){
async function openSportas(id){
openPanel('Sportaš', '<div class="loading">Učitavanje profila…</div>');
const d = await api('/sportas/'+id+'/profil');
if(!d || d.detail){
// Pull primary profile + (optionally) richer v2 dossier in parallel.
// /sportas/{id}/profil = sezone+utakmice (primary). /v2/clan/{id}/full = canonical multi-sport fields.
const [dRaw, dV2] = await Promise.all([
api('/sportas/'+id+'/profil'),
api('/v2/clan/'+id+'/full').catch(()=>null)
]);
if(!dRaw || dRaw.detail){
openPanel('Sportaš', '<div class="empty">Sportaš nije pronađen</div>');
return;
}
// Merge: primary wins, but pull missing height/weight/foot/biografija from v2 dossier
const d = Object.assign({}, dRaw);
if(dV2 && dV2.profile){
const p = dV2.profile;
['visina_cm','tezina_kg','dominantna_noga','broj_dresa','biografija','mjesto_rodenja','mjesto_rodjenja','datum_rodenja','datum_rodjenja','spol','sport','aktivan']
.forEach(k => { if((d[k]===null||d[k]===undefined||d[k]==='') && p[k]!==null && p[k]!==undefined && p[k]!=='') d[k]=p[k]; });
}
const stats = d.stats || {};
const sezone = d.clan_sezona || [];
const utakmice = d.utakmice || [];
@@ -1900,6 +1952,13 @@ async function openSportas(id){
const photo = d.slika_url ? '<img src="'+esc(d.slika_url)+'" alt="" onerror="this.style.display=\'none\';if(this.parentElement)this.parentElement.innerHTML=\'<div class=\\\'no\\\'>'+initials+'</div>\'">' : '<div class="no">'+initials+'</div>';
const dob = d.datum_rodjenja || d.datum_rodenja;
const hooCat = d.hoo_kategorija || d.kategorija_hoo;
// Build HNS deep link: prefer profile_url; otherwise compose /igraci/{hns_id}/{slug}/ from semafor when we have hns_igrac_id.
const slug = d.slug || ((d.ime||'')+'-'+(d.prezime||'')).toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g,'').replace(/[^a-z0-9]+/g,'-').replace(/^-+|-+$/g,'');
const hnsId = d.hns_igrac_id || d.source_id || (d.vanjski_id && d.vanjski_id.hns_semafor) || null;
const hnsUrl = d.profile_url || (hnsId ? ('https://semafor.hns.family/igraci/'+encodeURIComponent(hnsId)+'/'+encodeURIComponent(slug)+'/') : null);
const fullName = ((d.ime||'')+' '+(d.prezime||'')).trim();
const ggUrl = 'https://www.google.com/search?q='+encodeURIComponent(fullName+' nogometaš HR');
const wikiUrl = 'https://hr.wikipedia.org/w/index.php?search='+encodeURIComponent(fullName);
const html = `
<div class="pp-hdr">
@@ -1922,6 +1981,18 @@ async function openSportas(id){
${d.broj_dresa?'<span class="tag">#'+esc(d.broj_dresa)+'</span>':''}
${d.stipendiran?'<a class="tag am" onclick="filterSportasiBy(&quot;stipendiran&quot;,true)">STIP</a>':''}
</div>
<div class="pp-bio-row">
${d.visina_cm?'<span class="pp-bio-chip"><b>'+esc(d.visina_cm)+'</b>cm visina</span>':''}
${d.tezina_kg?'<span class="pp-bio-chip"><b>'+esc(d.tezina_kg)+'</b>kg težina</span>':''}
${d.dominantna_noga?'<span class="pp-bio-chip"><b>'+esc(d.dominantna_noga)+'</b>noga</span>':''}
${d.pozicija?'<span class="pp-bio-chip"><b>'+esc(d.pozicija)+'</b></span>':''}
${d.broj_dresa?'<span class="pp-bio-chip">#<b>'+esc(d.broj_dresa)+'</b></span>':''}
</div>
<div class="pp-links">
${hnsUrl?'<a class="pp-link hns" href="'+esc(hnsUrl)+'" target="_blank" rel="noopener">⚽ HNS profil ↗</a>':''}
<a class="pp-link gg" href="${esc(ggUrl)}" target="_blank" rel="noopener">🔍 Google</a>
<a class="pp-link wiki" href="${esc(wikiUrl)}" target="_blank" rel="noopener">📖 Wikipedia</a>
</div>
</div>
</div>
@@ -1935,14 +2006,15 @@ async function openSportas(id){
</div>
<div class="tabs">
<div class="tab active" onclick="switchPlayerTab(this,'p-sez')">Sezone (${sezone.length})</div>
<div class="tab" onclick="switchPlayerTab(this,'p-utak')">Utakmice (${utakmice.length})</div>
<div class="tab active" onclick="switchPlayerTab(this,'p-sez')">🏆 HNS Karijera (${sezone.length})</div>
<div class="tab" onclick="switchPlayerTab(this,'p-utak')">📅 Posljednje utakmice (${utakmice.length})</div>
<div class="tab" onclick="switchPlayerTab(this,'p-bio')">Bio</div>
<div class="tab" onclick="switchPlayerTab(this,'p-god')">Godišnjaci (${godisnjaci.length})</div>
<div class="tab" onclick="switchPlayerTab(this,'p-nag')">Nagrade (${nagrade.length})</div>
</div>
<div id="p-sez" class="ptab">
<div class="pp-section-h">🏆 HNS Karijera <span class="cnt">${sezone.length} sezon${sezone.length===1?'a':(sezone.length<5&&sezone.length>1?'e':'a')}</span></div>
${sezone.length ? `<div style="overflow-x:auto"><table>
<thead><tr><th>Sezona</th><th>Natjecanje</th><th>Klub</th><th class="num">Nas.</th><th class="num">Gol.</th><th class="num">Asis.</th><th class="num">Žuti</th><th class="num">Crv.</th><th></th></tr></thead>
<tbody>${sezone.map(s => `
@@ -1962,6 +2034,7 @@ async function openSportas(id){
</div>
<div id="p-utak" class="ptab" style="display:none">
<div class="pp-section-h">📅 Posljednje utakmice <span class="cnt">${utakmice.length} zabilježeno</span></div>
${utakmice.length ? `<div style="overflow-x:auto;max-height:500px;overflow-y:auto"><table>
<thead><tr><th>Datum</th><th>Natjecanje</th><th colspan="3">Susret</th><th class="num">Gol.</th><th class="num">Min.</th><th></th></tr></thead>
<tbody>${utakmice.map(u => `