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
+50
View File
@@ -5708,3 +5708,53 @@ def v2_sportasi_by_kategorija(
out = sorted(groups.values(), key=lambda x: x["kategorija"])
return {"ok": True, "groups": out, "total_kategorija": len(out)}
# ──────────────────────────────────────────────────────────────────
# PGŽ-financed (priority) thin wrappers for savezi & clanovi (SUB6)
# ──────────────────────────────────────────────────────────────────
@router.get("/savezi/priority-sort")
def v2_savezi_priority_sort(only: bool = False, limit: int = 500):
"""Savezi sa pgz_relevant=true prvi (ili samo oni ako only=true)."""
where = "WHERE COALESCE(s.aktivan,true)"
if only:
where += " AND COALESCE(s.pgz_relevant,false) = TRUE"
rows = db_query(f"""
SELECT s.*,
COALESCE(s.pgz_relevant,false) AS priority,
(SELECT COUNT(*) FROM pgz_sport.klubovi WHERE savez_id=s.id) AS broj_klubova
FROM pgz_sport.savezi s
{where}
ORDER BY COALESCE(s.pgz_relevant,false) DESC, s.naziv COLLATE "hr-HR-x-icu"
LIMIT %s
""", (limit,))
return {"count": len(rows), "rows": rows}
@router.get("/clanovi/priority-sort")
def v2_clanovi_priority_sort(only: bool = False, limit: int = 500):
"""Sportaši čiji klub je PGŽ-financiran ili u godišnjaku — prioritetni prvi."""
priority_expr = ("(COALESCE(k.pgz_sufinanciran,false) "
"OR (k.godisnjak_godine IS NOT NULL "
"AND array_length(k.godisnjak_godine,1) > 0))")
where = "WHERE c.aktivan = TRUE"
if only:
where += f" AND {priority_expr}"
rows = db_query(f"""
SELECT c.id, c.ime, c.prezime, c.oib, c.datum_rodenja, c.spol, c.sport,
c.pozicija, c.reprezentativac, c.kategoriziran, c.stipendiran,
c.kategorija, c.kategorije, c.kategorija_hoo, c.hoo_kategorija,
c.aktivan, c.klub_id, c.klub_naziv_godisnjak, c.slika_url,
c.broj_dresa, c.uloga,
k.naziv AS klub_naziv,
COALESCE(k.pgz_sufinanciran,false) AS klub_financiran,
(k.godisnjak_godine IS NOT NULL
AND array_length(k.godisnjak_godine,1) > 0) AS klub_godisnjak,
{priority_expr} AS priority
FROM pgz_sport.clanovi c
LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id
{where}
ORDER BY {priority_expr} DESC, c.prezime, c.ime
LIMIT %s
""", (limit,))
return {"count": len(rows), "rows": rows}
+130 -2
View File
@@ -1134,14 +1134,142 @@ def proracun_list():
# ═══════════════════════════════════════════════════════════════════
# 11) HEALTH/DEBUG
# 11) INVOICE UPLOADS (PDF/scan attachments to ulazni računi)
# ═══════════════════════════════════════════════════════════════════
@router.get("/invoice-uploads")
def invoice_uploads_list(klub_id: Optional[int] = None,
ocr_status: Optional[str] = None,
q: Optional[str] = None,
limit: int = 200):
where = ["1=1"]
params: list = []
if klub_id:
where.append("klub_id=%s"); params.append(klub_id)
if ocr_status:
where.append("ocr_status=%s"); params.append(ocr_status)
if q:
where.append("(file_name ILIKE %s OR ai_vendor_name ILIKE %s OR ai_invoice_no ILIKE %s)")
params.extend([f"%{q}%", f"%{q}%", f"%{q}%"])
params.append(limit)
rows = db_query(
"SELECT id, klub_id, file_name, file_path, file_size, mime, ocr_status, "
"ocr_confidence, ai_invoice_no, ai_invoice_date, ai_vendor_name, ai_vendor_oib, "
"ai_amount_gross, ai_currency, invoice_id, uploaded_by, "
"uploaded_at FROM pgz_sport.invoice_uploads "
f"WHERE {' AND '.join(where)} ORDER BY id DESC LIMIT %s",
tuple(params))
return {"count": len(rows), "rows": rows}
@router.get("/racuni/ulazni/{rid}/uploads")
def racuni_ulazni_uploads(rid: int):
"""Uploads (file attachments) linked to an ulazni racun via invoice_id."""
rows = db_query(
"SELECT id, file_name, file_path, file_size, mime, ocr_status, "
"ai_invoice_no, ai_vendor_name, ai_amount_gross, uploaded_at "
"FROM pgz_sport.invoice_uploads WHERE invoice_id=%s ORDER BY id DESC",
(rid,))
return {"count": len(rows), "rows": rows}
# ═══════════════════════════════════════════════════════════════════
# 12) PUTNI NALOZI / EXPENSE REPORTS
# ═══════════════════════════════════════════════════════════════════
@router.get("/expense-reports")
def expense_reports_list(klub_id: Optional[int] = None,
status: Optional[str] = None,
report_type: Optional[str] = None,
godina: Optional[int] = None,
limit: int = 200):
where = ["1=1"]
params: list = []
if klub_id:
where.append("er.klub_id=%s"); params.append(klub_id)
if status:
where.append("er.status=%s"); params.append(status)
if report_type:
where.append("er.report_type=%s"); params.append(report_type)
if godina:
where.append("EXTRACT(YEAR FROM er.date_from)=%s"); params.append(godina)
params.append(limit)
rows = db_query(
"SELECT er.id, er.klub_id, k.naziv AS klub_naziv, er.report_type, er.report_no, "
"er.destination, er.purpose, er.date_from, er.date_to, er.km_driven, "
"er.cost_total, er.dnevnice_count, er.dnevnice_amount, er.status, "
"er.approved_at, er.paid_at, er.created_at "
"FROM pgz_sport.expense_reports er "
"LEFT JOIN pgz_sport.klubovi k ON k.id=er.klub_id "
f"WHERE {' AND '.join(where)} ORDER BY er.id DESC LIMIT %s",
tuple(params))
return {"count": len(rows), "rows": rows}
@router.get("/putni-nalog-racuni")
def putni_nalog_racuni_list(putni_nalog_id: Optional[int] = None,
invoice_id: Optional[int] = None,
limit: int = 200):
where = ["1=1"]
params: list = []
if putni_nalog_id:
where.append("pnr.putni_nalog_id=%s"); params.append(putni_nalog_id)
if invoice_id:
where.append("pnr.invoice_id=%s"); params.append(invoice_id)
params.append(limit)
rows = db_query(
"SELECT pnr.id, pnr.putni_nalog_id, pnr.invoice_id, pnr.kategorija, "
"pnr.napomena, pnr.attached_at, "
"er.report_no, er.destination, er.purpose, "
"i.invoice_no, i.vendor_name, i.amount_gross, i.currency "
"FROM pgz_sport.putni_nalog_racuni pnr "
"LEFT JOIN pgz_sport.expense_reports er ON er.id=pnr.putni_nalog_id "
"LEFT JOIN pgz_sport.invoices i ON i.id=pnr.invoice_id "
f"WHERE {' AND '.join(where)} ORDER BY pnr.id DESC LIMIT %s",
tuple(params))
return {"count": len(rows), "rows": rows}
# ═══════════════════════════════════════════════════════════════════
# 13) PAYMENTS (uplate/isplate, bank reconciliation)
# ═══════════════════════════════════════════════════════════════════
@router.get("/payments")
def payments_list(klub_id: Optional[int] = None,
matched_status: Optional[str] = None,
payment_method: Optional[str] = None,
godina: Optional[int] = None,
limit: int = 200):
where = ["1=1"]
params: list = []
if klub_id:
where.append("p.klub_id=%s"); params.append(klub_id)
if matched_status:
where.append("p.matched_status=%s"); params.append(matched_status)
if payment_method:
where.append("p.payment_method=%s"); params.append(payment_method)
if godina:
where.append("EXTRACT(YEAR FROM p.payment_date)=%s"); params.append(godina)
params.append(limit)
rows = db_query(
"SELECT p.id, p.klub_id, k.naziv AS klub_naziv, p.invoice_id, "
"p.expense_report_id, p.clanarina_id, p.payment_date, p.amount, p.currency, "
"p.payment_method, p.iban_from, p.iban_to, p.reference, p.description, "
"p.bank_statement_no, p.bank_transaction_id, p.matched_status, p.created_at "
"FROM pgz_sport.payments p "
"LEFT JOIN pgz_sport.klubovi k ON k.id=p.klub_id "
f"WHERE {' AND '.join(where)} ORDER BY p.payment_date DESC, p.id DESC LIMIT %s",
tuple(params))
return {"count": len(rows), "rows": rows}
# ═══════════════════════════════════════════════════════════════════
# 14) HEALTH/DEBUG
# ═══════════════════════════════════════════════════════════════════
@router.get("/health")
def erp_health():
counts = {}
for t in ["kontni_plan", "partneri", "dnevnik_zapisa", "knjizenja",
"racuni_ulazni", "racuni_izlazni", "racun_stavke",
"zaposlenici", "place_obracun"]:
"zaposlenici", "place_obracun", "proracun",
"invoice_uploads", "expense_reports", "putni_nalog_racuni", "payments"]:
try:
r = db_one(f"SELECT COUNT(*) AS c FROM pgz_sport.{t}")
counts[t] = r["c"] if r else 0
+94 -15
View File
@@ -1230,47 +1230,62 @@ SECTIONS['pgz:korisnici'] = () => {
};
SECTIONS['pgz:savezi'] = async () => {
const d = await api('/savezi') || {rows:[]};
const onPGZ = !!window._pgz_filter_priority;
const url = onPGZ ? '/v2/savezi/priority-sort?only=true&limit=300' : '/savezi';
const d = await api(url) || {rows:[]};
const top = (d.rows||[]).slice(0,30);
const bp = window.pgzBadgePrefix || (()=> '');
const rows = top.map(s => `
<tr style="cursor:pointer" onclick="showDetail('savez',${s.id},${JSON.stringify(s.naziv)})">
<td><b>${esc(s.naziv)}</b></td>
<td><b>${bp(s)}${esc(s.naziv)}</b></td>
<td class="num">${fmt(s.broj_klubova||'—')}</td>
<td class="num">${fmt(s.broj_sportasa||'—')}</td>
<td>${esc(s.predsjednik||'—')}</td>
<td><button class="btn sm" onclick="event.stopPropagation();showDetail('savez',${s.id},${JSON.stringify(s.naziv)})">Detalji</button></td>
</tr>`).join('');
return `<div class="card"><div class="card-h"><div class="card-t">🏅 Savezi PGŽ — top 30 (od ${d.count||246})</div></div>
const tb = window.renderPGZToggleBtn ? window.renderPGZToggleBtn() : '';
return `<div class="card"><div class="card-h"><div class="card-t">🏅 Savezi PGŽ — top 30 (od ${d.count||246})${onPGZ?' · ⭐ samo PGŽ-relevantni':''}</div></div>
${tb}
<table><thead><tr><th>Naziv</th><th class="num">Klubovi</th><th class="num">Sportaši</th><th>Predsjednik</th><th></th></tr></thead><tbody>${rows||'<tr><td colspan=5 class="empty">Učitavam...</td></tr>'}</tbody></table>
</div>`;
};
SECTIONS['pgz:klubovi'] = async () => {
const d = await api('/klubovi?limit=40') || {rows:[]};
const rows = (d.rows||[]).slice(0,40).map(k => `
<tr style="cursor:pointer" onclick="showDetail('klub',${k.id},${JSON.stringify(k.naziv)})">
<td><b>${esc(k.naziv)}</b></td>
const onPGZ = !!window._pgz_filter_priority;
const url = onPGZ ? '/klubovi?kategorija=priority&limit=80' : '/klubovi?limit=40';
const d = await api(url) || {rows:[]};
const bp = window.pgzBadgePrefix || (()=> '');
const rows = (d.rows||[]).slice(0,80).map(k => `
<tr style="cursor:pointer" onclick="showDetail('klub',${k.id},${JSON.stringify(k.naziv||k.klub)})">
<td><b>${bp(k)}${esc(k.naziv||k.klub||'—')}</b></td>
<td>${esc(k.savez||'—')}</td>
<td>${esc(k.grad||'—')}</td>
<td class="num">${fmt(k.broj_clanova||'—')}</td>
<td>${esc(k.predsjednik||'—')}</td>
</tr>`).join('');
return `<div class="card"><div class="card-h"><div class="card-t">⬢ Klubovi (${d.count||0})</div></div>
const tb = window.renderPGZToggleBtn ? window.renderPGZToggleBtn() : '';
return `<div class="card"><div class="card-h"><div class="card-t">⬢ Klubovi (${d.count||0})${onPGZ?' · ⭐ samo PGŽ-prioritet':''}</div></div>
${tb}
<table><thead><tr><th>Naziv</th><th>Savez</th><th>Grad</th><th class="num">Članova</th><th>Predsjednik</th></tr></thead><tbody>${rows||'<tr><td colspan=5 class="empty">—</td></tr>'}</tbody></table>
</div>`;
};
SECTIONS['pgz:sportasi'] = async () => {
const d = await api('/clanovi?limit=40') || {rows:[]};
const rows = (d.rows||[]).slice(0,40).map(c => `
const onPGZ = !!window._pgz_filter_priority;
const url = onPGZ ? '/v2/clanovi/priority-sort?only=true&limit=400' : '/clanovi?limit=40';
const d = await api(url) || {rows:[]};
const bp = window.pgzBadgePrefix || (()=> '');
const rows = (d.rows||[]).slice(0,80).map(c => `
<tr>
<td><b>${esc(c.ime+' '+(c.prezime||''))}</b></td>
<td>${esc(c.klub||'—')}</td>
<td><b>${bp(c)}${esc((c.ime||'')+' '+(c.prezime||''))}</b></td>
<td>${esc(c.klub||c.klub_naziv||c.klub_naziv_godisnjak||'—')}</td>
<td>${esc(c.kategorija||'—')}</td>
<td>${esc(c.spol||'—')}</td>
<td>${esc(c.datum_rodjenja||'—')}</td>
<td>${esc(c.datum_rodjenja||c.datum_rodenja||'—')}</td>
</tr>`).join('');
return `<div class="card"><div class="card-h"><div class="card-t">👤 Sportaši (${d.count||0})</div></div>
const tb = window.renderPGZToggleBtn ? window.renderPGZToggleBtn() : '';
return `<div class="card"><div class="card-h"><div class="card-t">👤 Sportaši (${d.count||0})${onPGZ?' · ⭐ samo PGŽ-prioritet':''}</div></div>
${tb}
<table><thead><tr><th>Ime i prezime</th><th>Klub</th><th>Kategorija</th><th>Spol</th><th>Rođen</th></tr></thead><tbody>${rows||'<tr><td colspan=5 class="empty">—</td></tr>'}</tbody></table>
</div>`;
};
@@ -1480,6 +1495,51 @@ SECTIONS['pgz:forenzika'] = () => `
</div>`).join('')}
</div>`;
// ─── pgz:dokumenti — knjižnica godišnjaka (in-page) ────────────────────
// Prikazuje grid kartica za 18+ godišnjaka iz pgz_sport.dokumenti.
// Klik na karticu → otvara PDF u novom tabu preko /api/v2/dokumenti/godisnjak/{godina}.
SECTIONS['pgz:dokumenti'] = async () => {
// Cache godisnjaci na _state da ne dohvaćamo svaki put
if(!_state._godisnjaci){
try {
const r = await fetch('/sport/api/v2/dokumenti/godisnjaci/list');
const j = await r.json();
_state._godisnjaci = (j && j.godisnjaci) || [];
} catch(e) { _state._godisnjaci = []; }
}
const docs = _state._godisnjaci.slice().sort((a,b)=> (b.godina||9999) - (a.godina||9999));
const fmtMB = b => b ? (b/1024/1024).toFixed(1)+' MB' : '—';
const cards = docs.map(d => {
const yr = d.godina || (d.izdano_datum ? String(d.izdano_datum).slice(0,4) : '—');
const url = d.godina ? `/sport/api/v2/dokumenti/godisnjak/${d.godina}` : `/sport/api/v2/dokumenti/${d.id}/pdf`;
return `
<div class="card" style="cursor:pointer;transition:transform 0.15s, border-color 0.15s"
onmouseover="this.style.borderColor='var(--gold,#F4C430)';this.style.transform='translateY(-2px)'"
onmouseout="this.style.borderColor='';this.style.transform=''"
onclick="window.open('${url}','_blank','noopener')">
<div style="font-size:1.9rem;font-weight:700;color:var(--gold,#F4C430);line-height:1;margin-bottom:6px;letter-spacing:-1px">${esc(yr)}</div>
<div style="font-weight:600;font-size:0.92rem;margin-bottom:6px">${esc(d.title || '(bez naslova)')}</div>
<div style="color:var(--t2);font-size:11px;margin-bottom:4px">🏛️ ${esc(d.organizacija || '—')}</div>
<div style="color:var(--t4);font-size:11px">📄 ${fmtMB(d.sadrzaj_size)}</div>
</div>`;
}).join('');
return `
<div class="card">
<div class="card-h">
<div class="card-t">📚 Dokumenti — Godišnjaci ZSP PGŽ</div>
<div class="card-actions">
<a href="/sport/dokumenti" class="btn primary" style="text-decoration:none">📖 Otvori knjižnicu</a>
</div>
</div>
<div style="color:var(--t2);font-size:12px;margin-bottom:14px">
${docs.length} godišnjaka u bazi · klik na karticu otvara PDF u novom tabu
</div>
${docs.length === 0
? '<div class="empty">Nema godišnjaka u bazi.</div>'
: `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px">${cards}</div>`}
</div>`;
};
// =======================================================================
// SAVEZ ADMIN — Dashboard + sub-pages
// =======================================================================
@@ -2095,12 +2155,31 @@ function navItemClick(item){
if(item && item.id) navTo(item.id);
}
// PGŽ priority filter helpers (CRISIS V4)
// PGŽ priority filter helpers (CRISIS V4 / SUB6)
window._pgz_filter_priority = window._pgz_filter_priority || false;
window.togglePGZFilter = function(){
window._pgz_filter_priority = !window._pgz_filter_priority;
if(typeof loadSection === 'function') loadSection();
};
window.pgzBadgePrefix = function(it){
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(){
const on = !!window._pgz_filter_priority;
return '<button class="btn '+(on?'primary':'')+'" '
+ 'style="margin:6px 8px 10px 0" '
+ 'title="Prikaži samo PGŽ-financirane / u godišnjaku" '
+ 'onclick="togglePGZFilter()">'
+ (on ? '⭐ PGŽ filter ON' : '☆ PGŽ filter OFF') + '</button>';
};
</script>
</body>
</html>
+222 -6
View File
@@ -117,6 +117,9 @@ table tbody tr:hover{background:var(--bg3)}
<button class="tab" data-panel="glavna">📊 Glavna knjiga</button>
<button class="tab" data-panel="partneri">🤝 Partneri</button>
<button class="tab" data-panel="racuni">🧾 Računi</button>
<button class="tab" data-panel="uploads">📎 Uploads (OCR)</button>
<button class="tab" data-panel="putni">✈ Putni nalozi</button>
<button class="tab" data-panel="payments">💰 Plaćanja</button>
<button class="tab" data-panel="pdv">% PDV</button>
<button class="tab" data-panel="place">💼 Plaće</button>
<button class="tab" data-panel="proracun">€ Proračun</button>
@@ -198,7 +201,76 @@ table tbody tr:hover{background:var(--bg3)}
<button class="btn" onclick="loadRacuni()">Osvježi</button>
</div>
<div class="tbl-wrap">
<table id="rac-tbl"><thead><tr><th>#</th><th>Broj</th><th>Datum</th><th>Partner</th><th>OIB</th><th class="num">Neto</th><th class="num">PDV</th><th class="num">Brutto</th><th>Status</th><th>Akcije</th></tr></thead><tbody></tbody></table>
<table id="rac-tbl"><thead><tr><th>#</th><th>Broj</th><th>Datum</th><th>Partner</th><th>OIB</th><th class="num">Neto</th><th class="num">PDV</th><th class="num">Brutto</th><th>Status</th><th>Akcije</th></tr></thead><tbody><tr><td colspan="10" style="color:var(--t2);text-align:center;padding:18px">Klikni "Osvježi" za učitavanje…</td></tr></tbody></table>
</div>
<div id="rac-detail" style="display:none;margin-top:14px;border-top:1px solid var(--bd);padding-top:12px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<h4 id="rac-detail-title" style="font-size:12px;color:var(--t2);margin:0">Stavke</h4>
<button class="btn sec" onclick="document.getElementById('rac-detail').style.display='none'">× Zatvori</button>
</div>
<div class="tbl-wrap">
<table id="rac-stavke-tbl"><thead><tr><th>#</th><th>Naziv</th><th class="num">Količina</th><th>JM</th><th class="num">Cijena</th><th class="num">Popust %</th><th class="num">PDV %</th><th class="num">Neto</th><th class="num">Brutto</th></tr></thead><tbody></tbody></table>
</div>
<h4 id="rac-uploads-title" style="font-size:12px;color:var(--t2);margin:12px 0 6px;display:none">Privitci (uploads)</h4>
<div id="rac-uploads-wrap" style="display:none" class="tbl-wrap">
<table id="rac-uploads-tbl"><thead><tr><th>#</th><th>Datoteka</th><th class="num">Veličina</th><th>Mime</th><th>OCR</th><th>Datum</th></tr></thead><tbody></tbody></table>
</div>
</div>
</div>
</section>
<!-- ============ INVOICE UPLOADS (OCR) ============ -->
<section class="panel" id="panel-uploads">
<div class="card">
<div class="card-h"><div class="card-t">Invoice Uploads (OCR/AI extraction)</div></div>
<div class="toolbar">
<input id="up-q" placeholder="Datoteka / vendor / broj…">
<label>Status <select id="up-status"><option value="">— svi —</option><option value="pending">pending</option><option value="ocr_done">ocr_done</option><option value="approved">approved</option><option value="rejected">rejected</option></select></label>
<button class="btn" onclick="loadUploads()">Osvježi</button>
</div>
<div class="tbl-wrap">
<table id="up-tbl"><thead><tr><th>#</th><th>Datoteka</th><th class="num">Veličina</th><th>Vendor</th><th>OIB</th><th>Br. računa</th><th>Datum</th><th class="num">Brutto</th><th>OCR status</th><th class="num">Conf</th><th>Račun</th></tr></thead><tbody><tr><td colspan="11" style="color:var(--t2);text-align:center;padding:18px">Klikni "Osvježi"…</td></tr></tbody></table>
</div>
</div>
</section>
<!-- ============ PUTNI NALOZI / EXPENSE REPORTS ============ -->
<section class="panel" id="panel-putni">
<div class="card">
<div class="card-h"><div class="card-t">Putni nalozi i ostali troškovi (expense_reports)</div></div>
<div class="toolbar">
<label>Tip <select id="pn-type"><option value="">— svi —</option><option value="putni_nalog">Putni nalog</option><option value="expense">Trošak</option></select></label>
<label>Status <select id="pn-status"><option value="">— svi —</option><option value="draft">draft</option><option value="podnesen">podnesen</option><option value="odobren">odobren</option><option value="isplacen">isplacen</option><option value="rejected">rejected</option></select></label>
<label>Godina <input type="number" id="pn-godina" placeholder="2026" style="width:90px"></label>
<button class="btn" onclick="loadExpenseReports()">Osvježi</button>
</div>
<div class="tbl-wrap">
<table id="pn-tbl"><thead><tr><th>#</th><th>Tip</th><th>Klub</th><th>Odredište</th><th>Svrha</th><th>Od</th><th>Do</th><th class="num">Km</th><th class="num">Trošak</th><th class="num">Dnevnice</th><th>Status</th></tr></thead><tbody><tr><td colspan="11" style="color:var(--t2);text-align:center;padding:18px">Klikni "Osvježi"…</td></tr></tbody></table>
</div>
<div id="pn-detail" style="display:none;margin-top:14px;border-top:1px solid var(--bd);padding-top:12px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<h4 id="pn-detail-title" style="font-size:12px;color:var(--t2);margin:0">Vezani računi</h4>
<button class="btn sec" onclick="document.getElementById('pn-detail').style.display='none'">× Zatvori</button>
</div>
<div class="tbl-wrap">
<table id="pn-rac-tbl"><thead><tr><th>#</th><th>Broj računa</th><th>Vendor</th><th class="num">Brutto</th><th>Valuta</th><th>Kategorija</th><th>Datum</th></tr></thead><tbody></tbody></table>
</div>
</div>
</div>
</section>
<!-- ============ PAYMENTS ============ -->
<section class="panel" id="panel-payments">
<div class="card">
<div class="card-h"><div class="card-t">Plaćanja / Bank Reconciliation (payments)</div></div>
<div class="toolbar">
<label>Status <select id="py-status"><option value="">— svi —</option><option value="unmatched">unmatched</option><option value="matched">matched</option><option value="manual">manual</option></select></label>
<label>Način <select id="py-method"><option value="">— svi —</option><option value="transfer">transfer</option><option value="cash">cash</option><option value="card">card</option></select></label>
<label>Godina <input type="number" id="py-godina" placeholder="2026" style="width:90px"></label>
<button class="btn" onclick="loadPayments()">Osvježi</button>
</div>
<div class="tbl-wrap">
<table id="py-tbl"><thead><tr><th>#</th><th>Datum</th><th>Klub</th><th class="num">Iznos</th><th>Valuta</th><th>Način</th><th>IBAN OD</th><th>IBAN ZA</th><th>Referenca</th><th>Račun</th><th>Putni nalog</th><th>Match</th></tr></thead><tbody><tr><td colspan="12" style="color:var(--t2);text-align:center;padding:18px">Klikni "Osvježi"…</td></tr></tbody></table>
</div>
</div>
</section>
@@ -689,10 +761,33 @@ async function knjizi(tip, id){
catch(e){ alert(e.message); }
}
async function racDetail(tip, id){
try {
const d = await api('/racuni/'+tip+'/'+id);
let html = `Račun ${d.head.broj||''} · ${d.head.partner_naziv}\nNeto: ${fmt(d.head.iznos_neto)} · PDV: ${fmt(d.head.iznos_pdv)} · Brutto: ${fmt(d.head.iznos_brutto)}\n\nSTAVKE:\n`;
d.stavke.forEach(s => html += `${s.naziv} ${s.kolicina}×${fmt(s.cijena_jed)} = ${fmt(s.iznos_brutto)}\n`);
alert(html);
document.getElementById('rac-detail').style.display = 'block';
document.getElementById('rac-detail-title').textContent =
`Stavke računa ${tip} · #${id} · ${d.head.broj||'(bez broja)'} · ${d.head.partner_naziv||''} · Neto ${fmt(d.head.iznos_neto)} · Brutto ${fmt(d.head.iznos_brutto)}`;
const sb = document.querySelector('#rac-stavke-tbl tbody');
sb.innerHTML = (d.stavke||[]).length
? d.stavke.map((s,ix)=>`<tr><td>${ix+1}</td><td>${s.naziv||''}</td><td class="num">${fmt(s.kolicina)}</td><td>${s.jed_mjera||''}</td><td class="num">${fmt(s.cijena_jed)}</td><td class="num">${fmt(s.popust_pct)}</td><td class="num">${fmt(s.pdv_pct)}</td><td class="num">${fmt(s.iznos_neto)}</td><td class="num"><b>${fmt(s.iznos_brutto)}</b></td></tr>`).join('')
: `<tr><td colspan="9" style="color:var(--t2);text-align:center;padding:14px">Nema stavki.</td></tr>`;
// For ulazni: show linked invoice_uploads (file_name)
const upT = document.getElementById('rac-uploads-title');
const upW = document.getElementById('rac-uploads-wrap');
if (tip === 'ulazni') {
upT.style.display='block'; upW.style.display='block';
try {
const u = await api('/racuni/ulazni/'+id+'/uploads');
const ub = document.querySelector('#rac-uploads-tbl tbody');
ub.innerHTML = (u.rows||[]).length
? u.rows.map(r=>`<tr><td>${r.id}</td><td><a href="/uploads/${r.file_path||''}" target="_blank">${r.file_name||''}</a></td><td class="num">${fmt((r.file_size||0)/1024)} KB</td><td>${r.mime||''}</td><td>${r.ocr_status||''}</td><td>${(r.uploaded_at||'').slice(0,10)}</td></tr>`).join('')
: `<tr><td colspan="6" style="color:var(--t2);text-align:center;padding:10px">Nema privitaka.</td></tr>`;
} catch(e){
document.querySelector('#rac-uploads-tbl tbody').innerHTML = `<tr><td colspan="6" style="color:var(--red)">Greška: ${e.message}</td></tr>`;
}
} else {
upT.style.display='none'; upW.style.display='none';
}
} catch(e) { alert('Greška: '+e.message); }
}
async function openRacunModal(tip){
await loadPartnerCache();
@@ -858,13 +953,131 @@ async function savePlaca(){
// ===== PRORAČUN =====
async function loadProracun(){
const tbody = document.querySelector('#pr-tbl tbody');
tbody.innerHTML = `<tr><td colspan="8" style="color:var(--t2);text-align:center;padding:14px">Učitavam…</td></tr>`;
try {
const d = await api('/proracun');
document.querySelector('#pr-tbl tbody').innerHTML = d.rows.map(r=>`<tr>
tbody.innerHTML = (d.rows||[]).length
? d.rows.map(r=>`<tr>
<td><b>${r.godina}</b></td><td class="num">${fmt(r.proracun_pgz)}</td>
<td class="num">${fmt(r.rebalans1)}</td><td class="num">${fmt(r.rebalans2)}</td>
<td class="num"><b>${fmt(r.ukupno_pgz)}</b></td><td class="num">${fmt(r.ministarstvo)}</td>
<td class="num"><b>${fmt(r.ukupno)}</b></td><td>${r.napomena||''}</td>
</tr>`).join('');
</tr>`).join('')
: `<tr><td colspan="8" style="color:var(--t2);text-align:center;padding:14px">Nema podataka.</td></tr>`;
} catch(e) {
tbody.innerHTML = `<tr><td colspan="8" style="color:var(--red)">Greška: ${e.message}</td></tr>`;
}
}
// ===== INVOICE UPLOADS =====
async function loadUploads(){
const tbody = document.querySelector('#up-tbl tbody');
tbody.innerHTML = `<tr><td colspan="11" style="color:var(--t2);text-align:center;padding:14px">Učitavam…</td></tr>`;
try {
const q = (document.getElementById('up-q').value||'').trim();
const st = document.getElementById('up-status').value;
const p = new URLSearchParams();
if(q) p.set('q', q);
if(st) p.set('ocr_status', st);
const d = await api('/invoice-uploads?'+p.toString());
tbody.innerHTML = (d.rows||[]).length
? d.rows.map(r=>`<tr>
<td>${r.id}</td>
<td><a href="/uploads/${r.file_path||''}" target="_blank">${r.file_name||''}</a></td>
<td class="num">${fmt((r.file_size||0)/1024)} KB</td>
<td>${r.ai_vendor_name||''}</td>
<td>${r.ai_vendor_oib||''}</td>
<td>${r.ai_invoice_no||''}</td>
<td>${r.ai_invoice_date||''}</td>
<td class="num">${fmt(r.ai_amount_gross)}</td>
<td><span class="badge ${r.ocr_status||''}">${r.ocr_status||'—'}</span></td>
<td class="num">${r.ocr_confidence!=null?fmt(r.ocr_confidence)+' %':''}</td>
<td>${r.invoice_id?('#'+r.invoice_id):'—'}</td>
</tr>`).join('')
: `<tr><td colspan="11" style="color:var(--t2);text-align:center;padding:14px">Nema uploadova.</td></tr>`;
} catch(e) {
tbody.innerHTML = `<tr><td colspan="11" style="color:var(--red)">Greška: ${e.message}</td></tr>`;
}
}
// ===== PUTNI NALOZI / EXPENSE REPORTS =====
async function loadExpenseReports(){
const tbody = document.querySelector('#pn-tbl tbody');
tbody.innerHTML = `<tr><td colspan="11" style="color:var(--t2);text-align:center;padding:14px">Učitavam…</td></tr>`;
try {
const t = document.getElementById('pn-type').value;
const s = document.getElementById('pn-status').value;
const g = document.getElementById('pn-godina').value;
const p = new URLSearchParams();
if(t) p.set('report_type', t);
if(s) p.set('status', s);
if(g) p.set('godina', g);
const d = await api('/expense-reports?'+p.toString());
tbody.innerHTML = (d.rows||[]).length
? d.rows.map(r=>`<tr onclick="expenseDetail(${r.id})" style="cursor:pointer">
<td>${r.id}</td>
<td>${r.report_type||''}</td>
<td>${r.klub_naziv||r.klub_id||''}</td>
<td>${r.destination||''}</td>
<td>${r.purpose||''}</td>
<td>${r.date_from||''}</td>
<td>${r.date_to||''}</td>
<td class="num">${fmt(r.km_driven)}</td>
<td class="num">${fmt(r.cost_total)}</td>
<td class="num">${fmt(r.dnevnice_amount)}</td>
<td><span class="badge ${r.status||''}">${r.status||''}</span></td>
</tr>`).join('')
: `<tr><td colspan="11" style="color:var(--t2);text-align:center;padding:14px">Nema putnih naloga.</td></tr>`;
} catch(e) {
tbody.innerHTML = `<tr><td colspan="11" style="color:var(--red)">Greška: ${e.message}</td></tr>`;
}
}
async function expenseDetail(id){
try {
document.getElementById('pn-detail').style.display='block';
document.getElementById('pn-detail-title').textContent = `Vezani računi za putni nalog #${id}`;
const d = await api('/putni-nalog-racuni?putni_nalog_id='+id);
const tb = document.querySelector('#pn-rac-tbl tbody');
tb.innerHTML = (d.rows||[]).length
? d.rows.map(r=>`<tr><td>${r.id}</td><td>${r.invoice_no||('#'+r.invoice_id)}</td><td>${r.vendor_name||''}</td><td class="num">${fmt(r.amount_gross)}</td><td>${r.currency||''}</td><td>${r.kategorija||''}</td><td>${(r.attached_at||'').slice(0,10)}</td></tr>`).join('')
: `<tr><td colspan="7" style="color:var(--t2);text-align:center;padding:10px">Nema vezanih računa.</td></tr>`;
} catch(e) { alert('Greška: '+e.message); }
}
// ===== PAYMENTS =====
async function loadPayments(){
const tbody = document.querySelector('#py-tbl tbody');
tbody.innerHTML = `<tr><td colspan="12" style="color:var(--t2);text-align:center;padding:14px">Učitavam…</td></tr>`;
try {
const s = document.getElementById('py-status').value;
const m = document.getElementById('py-method').value;
const g = document.getElementById('py-godina').value;
const p = new URLSearchParams();
if(s) p.set('matched_status', s);
if(m) p.set('payment_method', m);
if(g) p.set('godina', g);
const d = await api('/payments?'+p.toString());
tbody.innerHTML = (d.rows||[]).length
? d.rows.map(r=>`<tr>
<td>${r.id}</td>
<td>${r.payment_date||''}</td>
<td>${r.klub_naziv||r.klub_id||''}</td>
<td class="num"><b>${fmt(r.amount)}</b></td>
<td>${r.currency||''}</td>
<td>${r.payment_method||''}</td>
<td>${r.iban_from||''}</td>
<td>${r.iban_to||''}</td>
<td>${r.reference||''}</td>
<td>${r.invoice_id?('#'+r.invoice_id):'—'}</td>
<td>${r.expense_report_id?('#'+r.expense_report_id):'—'}</td>
<td><span class="badge ${r.matched_status||''}">${r.matched_status||''}</span></td>
</tr>`).join('')
: `<tr><td colspan="12" style="color:var(--t2);text-align:center;padding:14px">Nema plaćanja.</td></tr>`;
} catch(e) {
tbody.innerHTML = `<tr><td colspan="12" style="color:var(--red)">Greška: ${e.message}</td></tr>`;
}
}
// ===== IZVJEŠTAJI =====
@@ -925,6 +1138,9 @@ const loaders = {
glavna: loadGlavnaKnjiga,
partneri: loadPartneri,
racuni: loadRacuni,
uploads: loadUploads,
putni: loadExpenseReports,
payments: loadPayments,
pdv: loadPdv,
place: () => { loadZap(); loadPlace(); },
proracun: loadProracun,
+8 -6
View File
@@ -35,12 +35,14 @@
{id:'notif', ic:'\u{1F514}', label:'Notifikacije', href:'/app#notif'}
]},
{title:'CRM', items: [
{id:'crm_v2', ic:'\u{1F3AF}', label:'CRM (Salesforce-Lite)', href:'/sport/crm_v2'},
{id:'clanarine', ic:'\u{1F4B3}', label:'Članarine', href:'/crm#clanarine'},
{id:'lijecnicki',ic:'⚕', label:'Liječnički', href:'/crm#lijecnicki'},
{id:'obrasci', ic:'\u{1F4CB}', label:'Obrasci', href:'/crm#obrasci'},
{id:'dokumenti', ic:'\u{1F4C4}', label:'Dokumenti', href:'/crm#dokumenti'}
]},
{title:'ERP', items: [
{id:'erpfull', ic:'\u{1F4D2}', label:'ERP Full (SAP-Lite)', href:'/erp/full'},
{id:'racuni', ic:'\u{1F9FE}', label:'Računi (OCR)', href:'/erp#racuni'},
{id:'putni', ic:'✈', label:'Putni nalozi', href:'/erp#putni'},
{id:'placanja', ic:'\u{1F4B0}', label:'Plaćanja', href:'/erp#placanja'},
@@ -54,12 +56,12 @@
{id:'audit', ic:'\u{1F512}', label:'Audit log', href:'/audit'}
]},
{title:'ADMIN', requireRole:['pgz_admin','super_admin'], items: [
{id:'users', ic:'\u{1F465}', label:'Korisnici', href:'/admin/users#users'},
{id:'tenants', ic:'\u{1F3E2}', label:'Tenanti', href:'/admin/users#tenants'},
{id:'security', ic:'\u{1F6E1}', label:'Sigurnost', href:'/admin/users#security'},
{id:'rbac', ic:'\u{1F511}', label:'RBAC matrica', href:'/admin/users#rbac'},
{id:'audit', ic:'\u{1F512}', label:'Audit log', href:'/admin/users#audit'},
{id:'gdpr', ic:'\u{1F512}', label:'GDPR', href:'/admin/users#gdpr'}
{id:'users', ic:'\u{1F465}', label:'Korisnici', href:'/sport/admin/users#users'},
{id:'tenants', ic:'\u{1F3E2}', label:'Tenanti', href:'/sport/admin/users#tenants'},
{id:'security', ic:'\u{1F6E1}', label:'Sigurnost', href:'/sport/admin/users#security'},
{id:'rbac', ic:'\u{1F511}', label:'RBAC matrica', href:'/sport/admin/users#rbac'},
{id:'auditlog', ic:'\u{1F512}', label:'Audit log', href:'/sport/admin/users#audit'},
{id:'gdpr', ic:'\u{1F510}', label:'GDPR', href:'/sport/admin/users#gdpr'}
]}
];
+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 => `