Crisis V7 MEGA: sufinanciranje_sport + panel + CRM auth

DB:
- pgz_sport.sufinanciranje_sport.je_klub flag (RSS programi/totals false)
- pgz_sport.sufinanciranje_sport.klub_id matched

Endpoints:
- /v2/potpore/by-year: samo_klubovi=True default + davatelj filter

Frontend:
- sport2.html PANEL FORCE HIDE CSS (right:-100vw default)
- crm_v2.html: redirect to /login only on actual 401, not on page load
This commit is contained in:
2026-05-05 15:02:47 +02:00
parent 007825acee
commit f07fdad919
18 changed files with 1235 additions and 65 deletions
+10
View File
@@ -674,10 +674,18 @@ def get_savez(savez_id: int, authorization: Optional[str] = Header(None)):
return {**savez, "klubovi": klubovi, "statistika": statistika, "manifestacije": manifestacije}
# ==================== KLUBOVI ====================
# ─────────────────────────────────────────────────────────────────────
# Endpoint: GET /api/klubovi
# Author: Damir Radulić (dradulic@outlook.com / damir@rinet.one)
# Date: 2026-05-05 (BUG-E filter sprint)
# Note: `samo_hns_roster` added — keeps priority-sort behaviour but
# lets UI filter to klubs that have at least 1 HNS roster row.
# ─────────────────────────────────────────────────────────────────────
@app.get("/api/klubovi")
def list_klubovi(authorization: Optional[str] = Header(None), q: Optional[str] = None, savez_id: Optional[int] = None,
nositelj: Optional[bool] = None, region: Optional[str] = None, sport: Optional[str] = None, grad: Optional[str] = None,
kategorija: Optional[str] = None, godisnjak: Optional[bool] = None, financiran: Optional[bool] = None,
samo_hns_roster: Optional[bool] = None,
sort: str = "naziv", order: str = "asc"):
where = ["v.aktivan"]
params = []
@@ -703,6 +711,8 @@ def list_klubovi(authorization: Optional[str] = Header(None), q: Optional[str] =
where.append("(k.godisnjak_godine IS NULL OR array_length(k.godisnjak_godine,1) IS NULL)")
if kategorija and kategorija.strip().lower() == "priority":
where.append("(COALESCE(k.pgz_sufinanciran,false) OR (k.godisnjak_godine IS NOT NULL AND array_length(k.godisnjak_godine,1) > 0))")
if samo_hns_roster:
where.append("EXISTS (SELECT 1 FROM pgz_sport.hns_klub_roster r WHERE r.klub_id = k.id)")
sort_col = {"naziv": "v.klub", "savez": "v.savez", "broj_clanova": "v.broj_clanova",
"razina": "v.razina", "region": "v.region", "grad": "v.grad", "sport": "v.sport"}.get(sort, "v.klub")
order_sql = "DESC" if order.lower() == "desc" else "ASC"
+19 -7
View File
@@ -4960,19 +4960,31 @@ def proracun_sport(godina: int = None):
# POTPORE — by year filter
# ═══════════════════════════════════════════════════════
@router.get("/potpore/by-year")
def potpore_by_year(godina: int = None, q: str = ""):
"""Sufinanciranje za specifičnu godinu."""
def potpore_by_year(godina: int = None, q: str = "", samo_klubovi: bool = True, davatelj: str = None):
"""Sufinanciranje za specifičnu godinu — samo_klubovi=True izbacuje programe/totals/services."""
import datetime
yr = godina or datetime.date.today().year
like = f"%{q}%" if q else "%"
rows = db_query("""
SELECT korisnik, sport, iznos_eur, vrsta, napomena, izvor, source_url, godina,
(SELECT k.id FROM pgz_sport.klubovi k WHERE LOWER(k.naziv) LIKE LOWER('%%'||LEFT(korisnik,20)||'%%') AND k.aktivan=true LIMIT 1) as klub_id
where = ["godina = %s", "LOWER(COALESCE(korisnik,'')) LIKE LOWER(%s)"]
params = [yr, like]
if samo_klubovi:
where.append("(je_klub IS NULL OR je_klub = true)")
if davatelj == 'rijeka':
where.append("izvor ILIKE '%%rijeka.hr%%'")
elif davatelj == 'pgz':
where.append("izvor ILIKE '%%sport-pgz%%'")
sql = f"""
SELECT id, korisnik, sport, iznos_eur, vrsta, napomena, izvor, source_url, godina, klub_id, je_klub
FROM pgz_sport.sufinanciranje_sport
WHERE godina = %s AND LOWER(COALESCE(korisnik,'')) LIKE LOWER(%s)
WHERE {' AND '.join(where)}
ORDER BY iznos_eur DESC NULLS LAST
LIMIT 500
""", (yr, like))
"""
rows = db_query(sql, params)
total = sum(float(r.get('iznos_eur') or 0) for r in rows)
return {"godina": yr, "count": len(rows), "total": total, "results": rows}
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env python3
"""data.gov.hr — Open Data PGZ."""
import sys, json, time
sys.path.insert(0, "/opt/pgz-sport/scrapers/harvesters")
from _common import upsert_facts, DSN, UA
import urllib.request
import psycopg2
API = "https://data.gov.hr/api/3/action"
def search(query, rows=50):
url = f"{API}/package_search?q={urllib.parse.quote(query)}&rows={rows}"
try:
req = urllib.request.Request(url, headers={"User-Agent": UA})
with urllib.request.urlopen(req, timeout=30) as r:
d = json.loads(r.read())
return d.get("result", {}).get("results", [])
except Exception as e:
print(f"search err: {e}")
return []
import urllib.parse
def main():
conn = psycopg2.connect(DSN); conn.autocommit = True
queries = [
"Primorsko-goranska", "Rijeka", "Opatija", "Crikvenica", "Krk",
"Cres", "Lošinj", "Rab", "Delnice", "Bakar", "Kvarner",
]
total_inserted = 0
seen = set()
for q in queries:
results = search(q, rows=50)
ff = []
for pkg in results:
pkg_id = pkg.get("id", "")
if pkg_id in seen: continue
seen.add(pkg_id)
title = pkg.get("title", "")
notes = pkg.get("notes", "")[:600]
org = pkg.get("organization", {}).get("title", "")
tags = ", ".join([t.get("name", "") for t in pkg.get("tags", [])])
fact = f"[OpenData] {title} | Org: {org} | {notes} | Tags: {tags}"[:1200]
if len(fact) > 50:
ff.append({
"fact": fact,
"url": f"https://data.gov.hr/dataset/{pkg.get('name', '')}",
"title": title,
})
n = upsert_facts(conn, ff, source_name="data_gov_hr_pgz",
category="opendata_pgz", confidence=0.85)
total_inserted += n
print(f" query='{q}' -> {len(results)} results, {n} new facts")
time.sleep(1)
conn.close()
print(f"=== TOTAL: {total_inserted} ===")
print(json.dumps({"queries": len(queries), "total_facts": total_inserted}))
if __name__ == "__main__":
main()
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env python3
"""Public events i festivali PGZ."""
import sys, json, time
sys.path.insert(0, "/opt/pgz-sport/scrapers/harvesters")
from _common import (fetch, extract_text, extract_title, chunk_text,
upsert_facts, find_internal_links, DSN)
from urllib.parse import urlparse
import psycopg2
EVENTS = {
"rijeka_karneval": ["https://www.rijecki-karneval.hr/", "https://rijekakarneval.hr/"],
"ljeto_kvarner": ["https://www.kvarner-ljeto.hr/"],
"rijeka_film_fest": ["https://www.riff.hr/"],
"vinski_festival_op": ["https://opatijawine.hr/"],
"festival_culture": ["https://www.festival-of-cultures.hr/"],
"muzicki_kvarner": ["https://www.kvarnermusic.hr/"],
"porto_etno": ["https://www.portoetno.eu/"],
"ri_rock": ["https://rockkonferencija.com/"],
"fjeshta_lovran": ["https://www.fjeshta.hr/"],
"njanje_zvoncari": ["https://halubajskizvoncari.hr/"],
"skoljka_festival": ["https://www.kostrena.hr/"],
"sumski_film": ["https://www.lifftrijeka.hr/"],
"zicfest": ["https://www.zicfest.hr/"],
}
def crawl(name, urls, max_pages=12):
conn = psycopg2.connect(DSN); conn.autocommit = True
visited = set(); queue = list(urls); facts = 0
while queue and len(visited) < max_pages:
url = queue.pop(0)
if url in visited: continue
visited.add(url)
html, status = fetch(url, timeout=15)
if not html or status != 200: continue
title = extract_title(html); text = extract_text(html)
if not text or len(text) < 200: continue
ff = []
if title and len(title) > 8:
ff.append({"fact": f"{name} - {title}", "url": url, "title": title})
for c in chunk_text(text, 800):
if len(c) > 100:
ff.append({"fact": c, "url": url, "title": title})
facts += upsert_facts(conn, ff, source_name=name,
category="events_pgz", confidence=0.85)
base = urlparse(url).hostname
for link in find_internal_links(html, url):
if link not in visited and (urlparse(link).hostname or "") == base and len(queue) < 30:
queue.append(link)
time.sleep(0.5)
conn.close()
return {"name": name, "visited": len(visited), "facts": facts}
def main():
results = []
for name, urls in EVENTS.items():
try:
r = crawl(name, urls, max_pages=10)
print(f" {name:25} {r['visited']:>3}p {r['facts']:>5}f")
results.append(r)
except Exception as e:
print(f" {name:25} FAIL: {str(e)[:60]}")
total = sum(r.get("facts", 0) for r in results)
print(f"=== TOTAL: {total} ===")
print(json.dumps({"events_count": len(results), "total_facts": total}))
if __name__ == "__main__":
main()
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""Gorski kotar deep — Risnjak, Delnice, Cabar, Lokve."""
import sys, json, time
sys.path.insert(0, "/opt/pgz-sport/scrapers/harvesters")
from _common import (fetch, extract_text, extract_title, chunk_text,
upsert_facts, find_internal_links, DSN)
from urllib.parse import urlparse
import psycopg2
GORSKI = {
"tz_gorski_kotar": ["https://www.gorskikotar.hr/", "https://www.tz-gorskikotar.hr/"],
"np_risnjak": ["https://www.np-risnjak.hr/"],
"delnice_info": ["https://delnice.com/"],
"cabar_info": ["https://www.cabar.hr/"],
"lokvarsko_jezero": ["https://www.lokve.hr/"],
"fuzine_info": ["https://www.fuzine.hr/"],
"ravnagora_blog": ["https://www.ravnagora.hr/"],
"skrad_info": ["https://www.skrad.hr/"],
"vrbovsko_info": ["https://www.vrbovsko.hr/"],
"spilja_lokvarka": ["https://www.spilja-lokvarka.com/"],
"skijanje_platak": ["https://www.platak.hr/"],
"snowboard_klub": ["https://www.snowboardklub-rijeka.hr/"],
}
def crawl(name, urls, max_pages=15):
conn = psycopg2.connect(DSN); conn.autocommit = True
visited = set(); queue = list(urls); facts = 0
while queue and len(visited) < max_pages:
url = queue.pop(0)
if url in visited: continue
visited.add(url)
html, status = fetch(url, timeout=15)
if not html or status != 200: continue
title = extract_title(html); text = extract_text(html)
if not text or len(text) < 200: continue
ff = []
if title and len(title) > 8:
ff.append({"fact": f"{name} - {title}", "url": url, "title": title})
for c in chunk_text(text, 800):
if len(c) > 100:
ff.append({"fact": c, "url": url, "title": title})
facts += upsert_facts(conn, ff, source_name=name,
category="gorski_kotar_pgz", confidence=0.85)
base = urlparse(url).hostname
for link in find_internal_links(html, url):
if link not in visited and (urlparse(link).hostname or "") == base and len(queue) < 40:
queue.append(link)
time.sleep(0.5)
conn.close()
return {"name": name, "visited": len(visited), "facts": facts}
def main():
results = []
for name, urls in GORSKI.items():
try:
r = crawl(name, urls, max_pages=12)
print(f" {name:25} {r['visited']:>3}p {r['facts']:>5}f")
results.append(r)
except Exception as e:
print(f" {name:25} FAIL: {str(e)[:60]}")
total = sum(r.get("facts", 0) for r in results)
print(f"=== TOTAL: {total} ===")
print(json.dumps({"gorski_count": len(results), "total_facts": total}))
if __name__ == "__main__":
main()
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""Media deep crawl — full pages of local portals."""
import sys, json, time
sys.path.insert(0, "/opt/pgz-sport/scrapers/harvesters")
from _common import (fetch, extract_text, extract_title, chunk_text,
upsert_facts, find_internal_links, DSN)
from urllib.parse import urlparse
import psycopg2
MEDIA = {
"novilist_rijeka": ["https://www.novilist.hr/rijeka/", "https://www.novilist.hr/regija/"],
"rijekadanas_full": ["https://rijekadanas.com/category/rijeka/", "https://rijekadanas.com/category/pgz/"],
"rijekain_full": ["https://rijekain.hr/category/rijeka/"],
"primorske_full": ["https://primorskenovice.hr/"],
"rkc_blog": ["https://www.rkcrijeka.hr/blog/"],
"rijeka2020_arhiva": ["https://rijeka2020.eu/category/news/"],
"kulturpunkt_ri": ["https://www.kulturpunkt.hr/tag/rijeka"],
"5portala_hr_pgz": ["https://www.5portala.hr/regije/primorsko-goranska/"],
}
def crawl(name, urls, max_pages=20):
conn = psycopg2.connect(DSN); conn.autocommit = True
visited = set(); queue = list(urls); facts = 0
while queue and len(visited) < max_pages:
url = queue.pop(0)
if url in visited: continue
visited.add(url)
html, status = fetch(url, timeout=15)
if not html or status != 200: continue
title = extract_title(html); text = extract_text(html)
if not text or len(text) < 300: continue
ff = []
if title and len(title) > 12:
ff.append({"fact": f"[{name}] {title}", "url": url, "title": title})
for c in chunk_text(text, 800):
if len(c) > 120:
ff.append({"fact": c, "url": url, "title": title})
facts += upsert_facts(conn, ff, source_name=f"media_{name}",
category="media_pgz_deep", confidence=0.82)
base = urlparse(url).hostname
for link in find_internal_links(html, url):
if link not in visited and (urlparse(link).hostname or "") == base and len(queue) < 60:
queue.append(link)
time.sleep(0.6)
conn.close()
return {"name": name, "visited": len(visited), "facts": facts}
def main():
results = []
for name, urls in MEDIA.items():
try:
r = crawl(name, urls, max_pages=18)
print(f" {name:25} {r['visited']:>3}p {r['facts']:>5}f")
results.append(r)
except Exception as e:
print(f" {name:25} FAIL: {str(e)[:60]}")
total = sum(r.get("facts", 0) for r in results)
print(f"=== TOTAL: {total} ===")
print(json.dumps({"media_count": len(results), "total_facts": total}))
if __name__ == "__main__":
main()
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""Naselja PGZ — sela, zaseoci, otocna mjesta."""
import sys, json, time
sys.path.insert(0, "/opt/pgz-sport/scrapers/harvesters")
from _common import chunk_text, upsert_facts, DSN, UA
from urllib.parse import urlencode, quote
import urllib.request
import psycopg2
API_HR = "https://hr.wikipedia.org/w/api.php"
def wiki_cat_members(cat, limit=200):
"""Get pages in a Wikipedia category."""
params = {"action":"query","list":"categorymembers","cmtitle":cat,
"cmlimit":str(limit),"format":"json"}
url = API_HR + "?" + urlencode(params)
try:
req = urllib.request.Request(url, headers={"User-Agent": UA})
with urllib.request.urlopen(req, timeout=20) as r:
d = json.loads(r.read())
return [m["title"] for m in d.get("query", {}).get("categorymembers", [])]
except Exception:
return []
def wiki_extract(title, timeout=15):
params = {"action":"query","prop":"extracts","explaintext":"1",
"redirects":"1","format":"json","titles":title}
url = API_HR + "?" + urlencode(params)
try:
req = urllib.request.Request(url, headers={"User-Agent": UA})
with urllib.request.urlopen(req, timeout=timeout) as r:
d = json.loads(r.read())
for pid, p in d.get("query", {}).get("pages", {}).items():
if pid == "-1": return None
return p.get("extract", "")
except Exception:
return None
CATEGORIES = [
"Kategorija:Naselja_u_Primorsko-goranskoj_županiji",
"Kategorija:Naselja_u_Hrvatskoj_(otok_Krk)",
"Kategorija:Naselja_u_Hrvatskoj_(otok_Cres)",
"Kategorija:Naselja_u_Hrvatskoj_(otok_Lošinj)",
"Kategorija:Naselja_u_Hrvatskoj_(otok_Rab)",
"Kategorija:Gorski_kotar",
]
def main():
conn = psycopg2.connect(DSN); conn.autocommit = True
total = 0; pages = 0
seen = set()
for cat in CATEGORIES:
members = wiki_cat_members(cat, limit=200)
print(f" {cat[:50]:50} {len(members):>3} members")
for title in members:
if title in seen: continue
seen.add(title)
text = wiki_extract(title)
if not text or len(text) < 200: continue
pages += 1
facts = [{"fact": c, "url": f"https://hr.wikipedia.org/wiki/{quote(title)}",
"title": title}
for c in chunk_text(text, 600) if len(c) > 100]
n = upsert_facts(conn, facts, source_name="wikipedia_pgz_naselja",
category="naselja_pgz", confidence=0.86)
total += n
time.sleep(0.3)
conn.close()
print(f"=== TOTAL pages={pages} facts={total} ===")
print(json.dumps({"pages": pages, "total_facts": total}))
if __name__ == "__main__":
main()
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env python3
"""Real estate + housing PGZ."""
import sys, json, time
sys.path.insert(0, "/opt/pgz-sport/scrapers/harvesters")
from _common import (fetch, extract_text, extract_title, chunk_text,
upsert_facts, find_internal_links, DSN)
from urllib.parse import urlparse
import psycopg2
REAL = {
"rijeka_najam": ["https://www.rijeka.hr/javnu-najam/"],
"stanovanje_pgz": ["https://www.dom-rijeka.hr/"],
"katastar_pgz": ["https://geoportal.dgu.hr/"],
"uprava_imovine": ["https://www.rijeka.hr/imovinsko-pravna/"],
"rijeka_arhitekt": ["https://www.rijeka.hr/arhitektonska/"],
"drzavna_imovina": ["https://www.drzavnaimovina.hr/"],
}
def crawl(name, urls, max_pages=8):
conn = psycopg2.connect(DSN); conn.autocommit = True
visited = set(); queue = list(urls); facts = 0
while queue and len(visited) < max_pages:
url = queue.pop(0)
if url in visited: continue
visited.add(url)
html, status = fetch(url, timeout=15)
if not html or status != 200: continue
title = extract_title(html); text = extract_text(html)
if not text or len(text) < 200: continue
ff = []
if title and len(title) > 8:
ff.append({"fact": f"{name} - {title}", "url": url, "title": title})
for c in chunk_text(text, 800):
if len(c) > 100:
ff.append({"fact": c, "url": url, "title": title})
facts += upsert_facts(conn, ff, source_name=name,
category="nekretnine_pgz", confidence=0.83)
base = urlparse(url).hostname
for link in find_internal_links(html, url):
if link not in visited and (urlparse(link).hostname or "") == base and len(queue) < 25:
queue.append(link)
time.sleep(0.5)
conn.close()
return {"name": name, "visited": len(visited), "facts": facts}
def main():
results = []
for name, urls in REAL.items():
try:
r = crawl(name, urls, max_pages=8)
print(f" {name:25} {r['visited']:>3}p {r['facts']:>5}f")
results.append(r)
except Exception as e:
print(f" {name:25} FAIL: {str(e)[:60]}")
total = sum(r.get("facts", 0) for r in results)
print(f"=== TOTAL: {total} ===")
print(json.dumps({"real_count": len(results), "total_facts": total}))
if __name__ == "__main__":
main()
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""Otoci PGZ deep — Krk, Cres, Losinj, Rab portali."""
import sys, json, time
sys.path.insert(0, "/opt/pgz-sport/scrapers/harvesters")
from _common import (fetch, extract_text, extract_title, chunk_text,
upsert_facts, find_internal_links, DSN)
from urllib.parse import urlparse
import psycopg2
OTOCI = {
"krk_info": ["https://www.krk.com/"],
"krkonline": ["https://www.krkonline.com/"],
"krkinfo_news": ["https://www.krk-info.com/"],
"cres_info": ["https://www.cres.info/"],
"cres_lapis": ["https://lapis.cres.hr/"],
"losinj_info": ["https://www.losinj.info/"],
"losinj_centar": ["https://www.muzejmalilosinj.hr/"],
"rab_info": ["https://www.rab.com/"],
"rab_news": ["https://rabnews.com/"],
"susak_info": ["https://www.susakisland.com/"],
"ilovik_info": ["https://www.ilovik.eu/"],
}
def crawl(name, urls, max_pages=12):
conn = psycopg2.connect(DSN); conn.autocommit = True
visited = set(); queue = list(urls); facts = 0
while queue and len(visited) < max_pages:
url = queue.pop(0)
if url in visited: continue
visited.add(url)
html, status = fetch(url, timeout=15)
if not html or status != 200: continue
title = extract_title(html); text = extract_text(html)
if not text or len(text) < 200: continue
ff = []
if title and len(title) > 8:
ff.append({"fact": f"{name} - {title}", "url": url, "title": title})
for c in chunk_text(text, 800):
if len(c) > 100:
ff.append({"fact": c, "url": url, "title": title})
facts += upsert_facts(conn, ff, source_name=name,
category="otoci_pgz", confidence=0.85)
base = urlparse(url).hostname
for link in find_internal_links(html, url):
if link not in visited and (urlparse(link).hostname or "") == base and len(queue) < 30:
queue.append(link)
time.sleep(0.5)
conn.close()
return {"name": name, "visited": len(visited), "facts": facts}
def main():
results = []
for name, urls in OTOCI.items():
try:
r = crawl(name, urls, max_pages=12)
print(f" {name:25} {r['visited']:>3}p {r['facts']:>5}f")
results.append(r)
except Exception as e:
print(f" {name:25} FAIL: {str(e)[:60]}")
total = sum(r.get("facts", 0) for r in results)
print(f"=== TOTAL: {total} ===")
print(json.dumps({"otoci_count": len(results), "total_facts": total}))
if __name__ == "__main__":
main()
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""Povijesni izvori PGZ — Liburnija, arhivi."""
import sys, json, time
sys.path.insert(0, "/opt/pgz-sport/scrapers/harvesters")
from _common import (fetch, extract_text, extract_title, chunk_text,
upsert_facts, find_internal_links, DSN)
from urllib.parse import urlparse
import psycopg2
HISTORY = {
"drzavni_arhiv_ri": ["https://www.dari.hr/"],
"arhiv_pazin": ["https://www.dapa.hr/"],
"muzej_glagoljice": ["https://glagoljica.hr/"],
"glagoljaska_alea": ["https://www.aleja-glagoljasa.hr/"],
"frankopani": ["https://www.frankopani.eu/"],
"trsatske_legende": ["https://www.trsat-svetiste.com/povijest/"],
"rijeka_povijest": ["https://rijeka-history.eu/"],
"stare_rijeke": ["https://www.stararijeka.com/"],
"kvarner_arhiv": ["https://www.kvarnerheritage.eu/"],
"muzeji_pgz_arhiv": ["https://www.muzeji-pgz.hr/"],
"razno_pomorski": ["https://www.kpu.hr/"],
}
def crawl(name, urls, max_pages=12):
conn = psycopg2.connect(DSN); conn.autocommit = True
visited = set(); queue = list(urls); facts = 0
while queue and len(visited) < max_pages:
url = queue.pop(0)
if url in visited: continue
visited.add(url)
html, status = fetch(url, timeout=15)
if not html or status != 200: continue
title = extract_title(html); text = extract_text(html)
if not text or len(text) < 200: continue
ff = []
if title and len(title) > 8:
ff.append({"fact": f"{name} - {title}", "url": url, "title": title})
for c in chunk_text(text, 800):
if len(c) > 100:
ff.append({"fact": c, "url": url, "title": title})
facts += upsert_facts(conn, ff, source_name=name,
category="povijest_pgz", confidence=0.86)
base = urlparse(url).hostname
for link in find_internal_links(html, url):
if link not in visited and (urlparse(link).hostname or "") == base and len(queue) < 30:
queue.append(link)
time.sleep(0.5)
conn.close()
return {"name": name, "visited": len(visited), "facts": facts}
def main():
results = []
for name, urls in HISTORY.items():
try:
r = crawl(name, urls, max_pages=10)
print(f" {name:25} {r['visited']:>3}p {r['facts']:>5}f")
results.append(r)
except Exception as e:
print(f" {name:25} FAIL: {str(e)[:60]}")
total = sum(r.get("facts", 0) for r in results)
print(f"=== TOTAL: {total} ===")
print(json.dumps({"hist_count": len(results), "total_facts": total}))
if __name__ == "__main__":
main()
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""Lokalni propisi PGZ — sluzbene novine, statuti."""
import sys, json, time
sys.path.insert(0, "/opt/pgz-sport/scrapers/harvesters")
from _common import (fetch, extract_text, extract_title, chunk_text,
upsert_facts, find_internal_links, DSN)
from urllib.parse import urlparse
import psycopg2
PROPISI = {
"sluzbene_novine_pgz": ["https://www.sn.pgz.hr/"],
"sluzbene_glasnik_rijeka": ["https://www.sluzbene-novine.com/"],
"sn_opatija": ["https://www.opatija.hr/sluzbene-novine"],
"pgz_dokumenti": ["https://www.pgz.hr/dokumenti"],
"pgz_skupstina": ["https://www.pgz.hr/skupstina"],
"rijeka_grad_v_savjet":["https://www.rijeka.hr/gradska-uprava/"],
"pgz_javnatime": ["https://www.pgz.hr/javna-nabava/"],
"rijeka_javna_nabava": ["https://www.rijeka.hr/javna-nabava/"],
"narodne_novine_pgz": ["https://narodne-novine.nn.hr/"],
}
def crawl(name, urls, max_pages=15):
conn = psycopg2.connect(DSN); conn.autocommit = True
visited = set(); queue = list(urls); facts = 0
while queue and len(visited) < max_pages:
url = queue.pop(0)
if url in visited: continue
visited.add(url)
html, status = fetch(url, timeout=15)
if not html or status != 200: continue
title = extract_title(html); text = extract_text(html)
if not text or len(text) < 200: continue
ff = []
if title and len(title) > 8:
ff.append({"fact": f"{name} - {title}", "url": url, "title": title})
for c in chunk_text(text, 800):
if len(c) > 100:
ff.append({"fact": c, "url": url, "title": title})
facts += upsert_facts(conn, ff, source_name=name,
category="propisi_pgz", confidence=0.88)
base = urlparse(url).hostname
for link in find_internal_links(html, url):
if link not in visited and (urlparse(link).hostname or "") == base and len(queue) < 60:
queue.append(link)
time.sleep(0.5)
conn.close()
return {"name": name, "visited": len(visited), "facts": facts}
def main():
results = []
for name, urls in PROPISI.items():
try:
r = crawl(name, urls, max_pages=15)
print(f" {name:25} {r['visited']:>3}p {r['facts']:>5}f")
results.append(r)
except Exception as e:
print(f" {name:25} FAIL: {str(e)[:60]}")
total = sum(r.get("facts", 0) for r in results)
print(f"=== TOTAL: {total} ===")
print(json.dumps({"propisi_count": len(results), "total_facts": total}))
if __name__ == "__main__":
main()
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""Sport infrastruktura PGZ — dvorane, baze, skole."""
import sys, json, time
sys.path.insert(0, "/opt/pgz-sport/scrapers/harvesters")
from _common import (fetch, extract_text, extract_title, chunk_text,
upsert_facts, find_internal_links, DSN)
from urllib.parse import urlparse
import psycopg2
INFRA = {
"dvorana_zamet": ["https://www.dvoranazamet.hr/"],
"stadion_kantrida": ["https://www.kantrida.hr/"],
"stadion_rujevica": ["https://www.nk-rijeka.hr/stadion-rujevica/"],
"ck_kantrida_pliv": ["https://kantridapool.hr/"],
"ri_sport_centar": ["https://www.ri-sport.hr/"],
"delta_jumbo": ["https://www.deltajumbo.hr/"],
"skolski_sport": ["https://www.hsss.hr/"],
"platak_skijanje": ["https://www.platak.hr/"],
"rec_velebit": ["https://www.velebit.hr/"],
"platak_ski_klub": ["https://www.skiclub-platak.hr/"],
"rijeka_marina": ["https://www.aci-marinas.com/"],
}
def crawl(name, urls, max_pages=15):
conn = psycopg2.connect(DSN); conn.autocommit = True
visited = set(); queue = list(urls); facts = 0
while queue and len(visited) < max_pages:
url = queue.pop(0)
if url in visited: continue
visited.add(url)
html, status = fetch(url, timeout=15)
if not html or status != 200: continue
title = extract_title(html); text = extract_text(html)
if not text or len(text) < 200: continue
ff = []
if title and len(title) > 8:
ff.append({"fact": f"{name} - {title}", "url": url, "title": title})
for c in chunk_text(text, 800):
if len(c) > 100:
ff.append({"fact": c, "url": url, "title": title})
facts += upsert_facts(conn, ff, source_name=name,
category="sport_infra_pgz", confidence=0.85)
base = urlparse(url).hostname
for link in find_internal_links(html, url):
if link not in visited and (urlparse(link).hostname or "") == base and len(queue) < 40:
queue.append(link)
time.sleep(0.5)
conn.close()
return {"name": name, "visited": len(visited), "facts": facts}
def main():
results = []
for name, urls in INFRA.items():
try:
r = crawl(name, urls, max_pages=12)
print(f" {name:25} {r['visited']:>3}p {r['facts']:>5}f")
results.append(r)
except Exception as e:
print(f" {name:25} FAIL: {str(e)[:60]}")
total = sum(r.get("facts", 0) for r in results)
print(f"=== TOTAL: {total} ===")
print(json.dumps({"infra_count": len(results), "total_facts": total}))
if __name__ == "__main__":
main()
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""Sport klubovi PGZ — direktno s web stranica."""
import sys, json, time
sys.path.insert(0, "/opt/pgz-sport/scrapers/harvesters")
from _common import (fetch, extract_text, extract_title, chunk_text,
upsert_facts, find_internal_links, DSN)
from urllib.parse import urlparse
import psycopg2
KLUBOVI = {
"hnk_rijeka": ["https://www.nk-rijeka.hr/"],
"kk_kvarner": ["https://www.kk-kvarner.hr/"],
"rk_zamet": ["https://www.rk-zamet.hr/"],
"vk_primorje": ["https://www.vkprimorje.hr/"],
"ok_rijeka": ["https://www.ok-rijeka.hr/"],
"haok_mladost": ["https://www.haok-mladost.hr/"],
"abc_rijeka": ["https://www.abc-rijeka.hr/"],
"rugby_rijeka": ["https://www.rugbyrijeka.hr/"],
"pliva_klub_primorje":["https://www.primorje-aquarius.hr/"],
"judo_kvarner": ["https://www.judokvarner.hr/"],
"kuglacki_savez_pgz": ["https://www.kuglacki-savez-pgz.hr/"],
"tenis_kvarner": ["https://www.tk-kvarner.hr/"],
"atletika_rijeka": ["https://www.akrijeka.hr/"],
"biciklisticki": ["https://www.bk-rijeka.hr/"],
"stoljecesporta": ["https://stoljecesporta.com/"],
}
def crawl(name, urls, max_pages=12):
conn = psycopg2.connect(DSN); conn.autocommit = True
visited = set(); queue = list(urls); facts = 0
while queue and len(visited) < max_pages:
url = queue.pop(0)
if url in visited: continue
visited.add(url)
html, status = fetch(url, timeout=15)
if not html or status != 200: continue
title = extract_title(html); text = extract_text(html)
if not text or len(text) < 200: continue
ff = []
if title and len(title) > 8:
ff.append({"fact": f"{name} - {title}", "url": url, "title": title})
for c in chunk_text(text, 800):
if len(c) > 100:
ff.append({"fact": c, "url": url, "title": title})
facts += upsert_facts(conn, ff, source_name=name,
category="sport_klub_pgz", confidence=0.88)
base = urlparse(url).hostname
for link in find_internal_links(html, url):
if link not in visited and (urlparse(link).hostname or "") == base and len(queue) < 30:
queue.append(link)
time.sleep(0.5)
conn.close()
return {"name": name, "visited": len(visited), "facts": facts}
def main():
results = []
for name, urls in KLUBOVI.items():
try:
r = crawl(name, urls, max_pages=10)
print(f" {name:25} {r['visited']:>3}p {r['facts']:>5}f")
results.append(r)
except Exception as e:
print(f" {name:25} FAIL: {str(e)[:60]}")
total = sum(r.get("facts", 0) for r in results)
print(f"=== TOTAL: {total} ===")
print(json.dumps({"klub_count": len(results), "total_facts": total}))
if __name__ == "__main__":
main()
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env python3
"""Crkve i vjerske institucije PGZ."""
import sys, json, time
sys.path.insert(0, "/opt/pgz-sport/scrapers/harvesters")
from _common import (fetch, extract_text, extract_title, chunk_text,
upsert_facts, find_internal_links, DSN)
from urllib.parse import urlparse
import psycopg2
CRKVA = {
"rijecka_nadbiskupija":["https://www.ri-nadbiskupija.hr/"],
"krcka_biskupija": ["https://www.biskupija-krk.hr/"],
"isusovci_rijeka": ["https://isusovci.hr/"],
"trsat_svetiste": ["https://trsat-svetiste.com/"],
"katedrala_rijeka": ["https://katedrala-rijeka.hr/"],
"samostan_kosljun": ["https://www.kosljun.hr/"],
"samostan_glavotok": ["https://www.glavotok.hr/"],
"katedrala_krk": ["https://www.biskupija-krk.hr/katedrala/"],
"crkva_opatija": ["https://www.zupa-opatija.hr/"],
"rijecka_eparhija": ["https://www.eparhija-zagrebackoljubljanska.com/"],
}
def crawl(name, urls, max_pages=10):
conn = psycopg2.connect(DSN); conn.autocommit = True
visited = set(); queue = list(urls); facts = 0
while queue and len(visited) < max_pages:
url = queue.pop(0)
if url in visited: continue
visited.add(url)
html, status = fetch(url, timeout=15)
if not html or status != 200: continue
title = extract_title(html); text = extract_text(html)
if not text or len(text) < 200: continue
ff = []
if title and len(title) > 8:
ff.append({"fact": f"{name} - {title}", "url": url, "title": title})
for c in chunk_text(text, 800):
if len(c) > 100:
ff.append({"fact": c, "url": url, "title": title})
facts += upsert_facts(conn, ff, source_name=name,
category="vjera_pgz", confidence=0.84)
base = urlparse(url).hostname
for link in find_internal_links(html, url):
if link not in visited and (urlparse(link).hostname or "") == base and len(queue) < 25:
queue.append(link)
time.sleep(0.5)
conn.close()
return {"name": name, "visited": len(visited), "facts": facts}
def main():
results = []
for name, urls in CRKVA.items():
try:
r = crawl(name, urls, max_pages=10)
print(f" {name:25} {r['visited']:>3}p {r['facts']:>5}f")
results.append(r)
except Exception as e:
print(f" {name:25} FAIL: {str(e)[:60]}")
total = sum(r.get("facts", 0) for r in results)
print(f"=== TOTAL: {total} ===")
print(json.dumps({"crkva_count": len(results), "total_facts": total}))
if __name__ == "__main__":
main()
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""Zdravstvo + udruge PGZ."""
import sys, json, time
sys.path.insert(0, "/opt/pgz-sport/scrapers/harvesters")
from _common import (fetch, extract_text, extract_title, chunk_text,
upsert_facts, find_internal_links, DSN)
from urllib.parse import urlparse
import psycopg2
ZDRAVSTVO = {
"kbc_rijeka": ["https://www.kbc-rijeka.hr/"],
"thalassotherapia": ["https://thalassotherapia-opatija.hr/"],
"klinika_lovran": ["https://www.tnz-lovran.hr/"],
"klinika_crikvenica": ["https://www.thalassotherapia-crikvenica.hr/"],
"dom_zdravlja_pgz": ["https://www.dom-zdravlja-pgz.hr/"],
"zavod_javno_zdravlje":["https://www.zzjzpgz.hr/"],
"crveni_kriz_pgz": ["https://www.crveni-kriz-rijeka.hr/"],
"hzzo_rijeka": ["https://hzzo.hr/"],
"savjetovaliste_ri": ["https://www.zzjzpgz.hr/savjetovaliste/"],
"deinstitucionalizacija":["https://www.cczg-rijeka.hr/"],
"centar_socijalne": ["https://www.czss.rijeka.hr/"],
}
def crawl(name, urls, max_pages=12):
conn = psycopg2.connect(DSN); conn.autocommit = True
visited = set(); queue = list(urls); facts = 0
while queue and len(visited) < max_pages:
url = queue.pop(0)
if url in visited: continue
visited.add(url)
html, status = fetch(url, timeout=15)
if not html or status != 200: continue
title = extract_title(html); text = extract_text(html)
if not text or len(text) < 200: continue
ff = []
if title and len(title) > 8:
ff.append({"fact": f"{name} - {title}", "url": url, "title": title})
for c in chunk_text(text, 800):
if len(c) > 100:
ff.append({"fact": c, "url": url, "title": title})
facts += upsert_facts(conn, ff, source_name=name,
category="zdravstvo_pgz", confidence=0.86)
base = urlparse(url).hostname
for link in find_internal_links(html, url):
if link not in visited and (urlparse(link).hostname or "") == base and len(queue) < 40:
queue.append(link)
time.sleep(0.5)
conn.close()
return {"name": name, "visited": len(visited), "facts": facts}
def main():
results = []
for name, urls in ZDRAVSTVO.items():
try:
r = crawl(name, urls, max_pages=10)
print(f" {name:25} {r['visited']:>3}p {r['facts']:>5}f")
results.append(r)
except Exception as e:
print(f" {name:25} FAIL: {str(e)[:60]}")
total = sum(r.get("facts", 0) for r in results)
print(f"=== TOTAL: {total} ===")
print(json.dumps({"zdr_count": len(results), "total_facts": total}))
if __name__ == "__main__":
main()
+42 -7
View File
@@ -487,7 +487,18 @@ footer { height:36px; background:var(--bg2); border-top:1px solid var(--rim);
<div id="toast"></div>
<script>
const TOKEN = localStorage.getItem('token') || localStorage.getItem('access_token') || '';
// ━━━ AUTH: model after app.html (pgz_access primary, fallbacks for legacy keys) ━━━
function getToken(){
try {
return localStorage.getItem('pgz_access')
|| sessionStorage.getItem('pgz_access')
|| localStorage.getItem('jwt')
|| localStorage.getItem('access_token')
|| localStorage.getItem('token')
|| '';
} catch(e){ return ''; }
}
let TOKEN = getToken();
const API = '/sport/api/v2/crm';
const STAGE_LABEL = {
prospecting:'Prospecting', qualification:'Qualification', proposal:'Proposal',
@@ -495,14 +506,34 @@ const STAGE_LABEL = {
};
const STAGES = ['prospecting','qualification','proposal','negotiation','closed_won','closed_lost'];
// JWT expiry pre-check + redirect only when truly missing/expired
(function checkAuth(){
if(!TOKEN){
location.href = '/sport/login?next=' + encodeURIComponent(location.pathname);
if(!window.__pgz_redirecting && window.__pgz_made_api_call){ window.__pgz_redirecting = true; location.href = '/login?next=' + encodeURIComponent(location.pathname); } else { console.warn('[CRM] no token — login optional'); }
return;
}
try {
const payload = JSON.parse(atob(TOKEN.split('.')[1]));
if(payload.exp && payload.exp * 1000 < Date.now()){
['pgz_access','pgz_refresh','pgz_user','jwt','access_token','token'].forEach(k => {
try{localStorage.removeItem(k); sessionStorage.removeItem(k);}catch(e){}
});
if(!window.__pgz_redirecting){ window.__pgz_redirecting = true; location.href = '/login?reason=expired'; }
}
} catch(e){ /* not parseable, let server respond */ }
})();
async function api(path, opts={}) {
TOKEN = getToken(); // refresh in case of token rotation
const headers = {'Authorization':'Bearer '+TOKEN, 'Content-Type':'application/json', ...(opts.headers||{})};
const res = await fetch(API+path, {...opts, headers});
if (res.status === 401) { location.href='/sport/login'; throw new Error('401'); }
if (res.status === 401) {
['pgz_access','pgz_refresh','pgz_user','jwt','access_token','token'].forEach(k => {
try{localStorage.removeItem(k); sessionStorage.removeItem(k);}catch(e){}
});
if(!window.__pgz_redirecting){ window.__pgz_redirecting = true; location.href='/login?reason=unauthorized'; }
throw new Error('401');
}
const txt = await res.text();
let data; try { data = JSON.parse(txt); } catch { data = txt; }
if (!res.ok) {
@@ -548,16 +579,20 @@ function switchTab(name) {
// ────── /me ──────
async function loadMe() {
try {
const me = await fetch('/sport/api/v2/me', {headers:{'Authorization':'Bearer '+TOKEN}}).then(r=>r.json());
const tok = getToken();
const me = await fetch('/sport/api/v2/auth/me', {headers:{'Authorization':'Bearer '+tok}}).then(r=>r.json());
document.getElementById('me').textContent = (me.email || me.full_name || 'user');
} catch { document.getElementById('me').textContent='?'; }
}
document.getElementById('logout').addEventListener('click', async (e) => {
e.preventDefault();
try { await fetch('/sport/api/v2/auth/logout', {method:'POST', headers:{'Authorization':'Bearer '+TOKEN}}); } catch {}
localStorage.removeItem('token'); localStorage.removeItem('access_token');
location.href='/sport/login';
const tok = getToken();
try { await fetch('/sport/api/v2/auth/logout', {method:'POST', headers:{'Authorization':'Bearer '+tok}}); } catch {}
['pgz_access','pgz_refresh','pgz_user','app-role','jwt','access_token','refresh_token','pgz_session_id','token'].forEach(k => {
try{localStorage.removeItem(k); sessionStorage.removeItem(k);}catch(e){}
});
location.href='/login';
});
// ────── Pipeline & Dashboard ──────
+1 -1
View File
@@ -160,7 +160,7 @@
<div class="pgz-sb-h">
<div class="pgz-mark">PGŽ</div>
<div class="pgz-htxt">
<div class="pgz-logo">PGŽ <span class="g">SPORT</span></div>
<a href="/" class="pgz-logo" style="text-decoration:none;color:inherit;cursor:pointer" title="Početna">PGŽ <span class="g">SPORT</span></a>
<div class="pgz-sub">Odjel za sport</div>
</div>
<div class="pgz-sb-toggle" onclick="PGZSidebar.toggle()" title="Skupi/raširi">←</div>
+264 -48
View File
@@ -134,6 +134,17 @@ table tbody tr.no-click:hover{background:transparent}
.btn.primary{background:linear-gradient(135deg,var(--pgz-blue),var(--pgz-blue2));border-color:transparent;color:#fff}
.btn.primary:hover{filter:brightness(1.1)}
/* BUG-E (2026-05-05) — filter-bar above section toolbar */
.bugE-bar{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin:0 0 10px 0;padding:10px 12px;background:linear-gradient(180deg,rgba(20,30,48,.6),rgba(15,22,36,.5));border:1px solid var(--rim);border-left:3px solid var(--pgz-gold);border-radius:6px}
.bugE-bar .bugE-lbl{font-size:10px;color:var(--pgz-gold);font-weight:800;letter-spacing:1.4px}
.bugE-bar label{font-size:11px;color:var(--t1);display:flex;align-items:center;gap:6px;cursor:pointer;user-select:none}
.bugE-bar label small{color:var(--t2);font-weight:400}
.bugE-bar input[type=checkbox]{accent-color:var(--pgz-gold)}
.bugE-bar input[type=number]{background:var(--bg2);border:1px solid var(--rim);border-radius:4px;padding:5px 8px;color:var(--t1);font-size:12px}
.bugE-bar .btn{padding:6px 12px;font-size:11px}
.bugE-bar .bugE-cnt{margin-left:auto;font-size:11px;color:var(--t2);font-weight:600;letter-spacing:.5px}
.bugE-bar .bugE-cnt strong{color:var(--pgz-gold)}
.toggle{display:inline-flex;background:var(--bg2);border:1px solid var(--rim);border-radius:5px;overflow:hidden}
.toggle button{background:transparent;border:0;padding:6px 12px;color:var(--t2);font-size:11px;font-weight:600;cursor:pointer}
.toggle button.active{background:var(--pgz-blue);color:#fff}
@@ -161,8 +172,8 @@ a.tag:hover,.tag[onclick]:hover{transform:translateY(-1px);filter:brightness(1.1
.tag.rd{background:var(--red);color:#fff}
.tag.am{background:var(--amber);color:var(--bg0)}
#panel{position:fixed;top:0;right:-620px;width:600px;max-width:96vw;height:100vh;background:var(--bg1);border-left:1px solid var(--rim);z-index:200;transition:right .25s ease;display:flex;flex-direction:column;box-shadow:-8px 0 30px rgba(0,0,0,.5)}
#panel.open{right:0}
#panel{position:fixed;top:0;right:0;width:600px;max-width:96vw;height:100vh;background:var(--bg1);border-left:1px solid var(--rim);z-index:200;transform:translateX(100%);visibility:hidden;transition:transform .25s ease,visibility 0s linear .25s;display:flex;flex-direction:column;box-shadow:-8px 0 30px rgba(0,0,0,.5)}
#panel.open{transform:translateX(0);visibility:visible;transition:transform .25s ease,visibility 0s linear 0s}
#panel-hdr{padding:14px 16px;border-bottom:1px solid var(--rim);display:flex;align-items:center;justify-content:space-between;flex-shrink:0;background:var(--bg2)}
#panel-hdr-t{font-size:14px;font-weight:700;color:var(--t0)}
#panel-x{cursor:pointer;font-size:22px;color:var(--t4);width:30px;height:30px;display:flex;align-items:center;justify-content:center;border-radius:5px;transition:all .15s}
@@ -284,7 +295,7 @@ a.tag:hover,.tag[onclick]:hover{transform:translateY(-1px);filter:brightness(1.1
.table-container, .card { overflow-x: auto; }
/* Drill-down panel full-width */
#panel { width: 100vw !important; max-width: 100vw !important; right: -100vw !important; }
#panel { width: 100vw !important; max-width: 100vw !important; right: 0 !important; }
#panel.open { right: 0 !important; }
/* Buttons */
@@ -314,6 +325,12 @@ a.tag:hover,.tag[onclick]:hover{transform:translateY(-1px);filter:brightness(1.1
/* HNS karijera tabela full-width */
#panel table, #dpanel table { width: 100%; font-size: 12px; }
#panel .hns-stats td { padding: 4px 6px; }
/* PANEL FORCE HIDE (CRISIS V7) — uvijek skriven dok nije .open */
#panel:not(.open) { right: -100vw !important; transform: translateX(0) !important; }
#panel.open { right: 0 !important; }
#panel-overlay:not(.open) { display: none !important; }
#panel-overlay.open { display: block !important; }
</style>
<link rel="stylesheet" href="/static/shared/sidebar.css">
<script src="/static/shared/sidebar.js" defer data-active="dashboard"></script>
@@ -406,6 +423,83 @@ 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;
// ════════════════════════════════════════════════════════════════════
// BUG-E (2026-05-05) — explicit filter-bar state per section
// Author: Damir Radulić (dradulic@outlook.com / damir@rinet.one)
// Defaults match constitution: financirani=true + u-godišnjaku=true.
// User can uncheck either checkbox to broaden the result set.
// ════════════════════════════════════════════════════════════════════
const _filters = {
klubovi: { financirani: true, godisnjak: true, hns_roster: false, total: 0 },
sportasi: { priority: true, hns_profil: false, godina_od: null, godina_do: null, total: 0 },
savezi: { financirani: true, total: 0 }
};
function _filtersDefaults(sec){
if(sec==='klubovi') return { financirani:true, godisnjak:true, hns_roster:false };
if(sec==='sportasi') return { priority:true, hns_profil:false, godina_od:null, godina_do:null };
if(sec==='savezi') return { financirani:true };
return {};
}
function _filtersReset(sec){
Object.assign(_filters[sec], _filtersDefaults(sec));
_filtersApply(sec);
}
function _filtersApply(sec){
if(sec==='klubovi') { _cache.klubovi = null; loadKlubovi(); }
if(sec==='sportasi') { _cache.clanovi = null; loadSportasi(); }
if(sec==='savezi') { _cache.savezi = null; loadSavezi(); }
}
function _filtersBar(sec){
// Returns HTML for the BUG-E filter-bar above the existing toolbar.
const f = _filters[sec] || {};
const cnt = '<span class="bugE-cnt" id="bugE-cnt-'+sec+'">Prikazano: '
+ (f.shown||0) + ' od ' + (f.total||0) + '</span>';
if(sec==='klubovi'){
return `
<div class="bugE-bar">
<span class="bugE-lbl">FILTER:</span>
<label><input type="checkbox" ${f.financirani?'checked':''} onchange="_filters.klubovi.financirani=this.checked"> Samo financirani <small>(PGŽ + RSS + Grad Rijeka)</small></label>
<label><input type="checkbox" ${f.godisnjak?'checked':''} onchange="_filters.klubovi.godisnjak=this.checked"> U godišnjaku</label>
<label><input type="checkbox" ${f.hns_roster?'checked':''} onchange="_filters.klubovi.hns_roster=this.checked"> Ima HNS roster</label>
<button class="btn primary" onclick="_filtersApply('klubovi')">Primijeni</button>
<button class="btn" onclick="_filtersReset('klubovi')">Reset</button>
${cnt}
</div>`;
}
if(sec==='sportasi'){
return `
<div class="bugE-bar">
<span class="bugE-lbl">FILTER:</span>
<label><input type="checkbox" ${f.priority?'checked':''} onchange="_filters.sportasi.priority=this.checked"> Samo iz priority kluba</label>
<label><input type="checkbox" ${f.hns_profil?'checked':''} onchange="_filters.sportasi.hns_profil=this.checked"> Ima HNS profil</label>
<label class="bugE-range">Godina rođ. od: <input type="number" min="1900" max="2030" value="${f.godina_od||''}" placeholder="—" onchange="_filters.sportasi.godina_od=this.value?parseInt(this.value,10):null" style="width:90px"></label>
<label class="bugE-range">do: <input type="number" min="1900" max="2030" value="${f.godina_do||''}" placeholder="—" onchange="_filters.sportasi.godina_do=this.value?parseInt(this.value,10):null" style="width:90px"></label>
<button class="btn primary" onclick="_filtersApply('sportasi')">Primijeni</button>
<button class="btn" onclick="_filtersReset('sportasi')">Reset</button>
${cnt}
</div>`;
}
if(sec==='savezi'){
return `
<div class="bugE-bar">
<span class="bugE-lbl">FILTER:</span>
<label><input type="checkbox" ${f.financirani?'checked':''} onchange="_filters.savezi.financirani=this.checked"> Samo financirani</label>
<button class="btn primary" onclick="_filtersApply('savezi')">Primijeni</button>
<button class="btn" onclick="_filtersReset('savezi')">Reset</button>
${cnt}
</div>`;
}
return '';
}
function _filtersUpdateCount(sec, shown){
_filters[sec].shown = shown;
const el = document.getElementById('bugE-cnt-'+sec);
if(el) el.textContent = 'Prikazano: '+shown+' od '+(_filters[sec].total||shown);
}
window._filters = _filters;
window._filtersReset = _filtersReset;
window._filtersApply = _filtersApply;
// === 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){
@@ -1240,13 +1334,16 @@ async function loadSavezi(){
const root = $('#pg-savezi');
if(!_cache.savezi){
root.innerHTML = '<div class="loading">Učitavanje saveza…</div>';
// PGŽ filter: switch to v2 priority-sort endpoint (only=true returns just PGŽ-relevant savezi)
const url = window._pgz_filter_priority
// BUG-E (2026-05-05): explicit filter — when financirani=true → priority-sort?only=true
const f = _filters.savezi;
const useOnly = f.financirani || window._pgz_filter_priority;
const url = useOnly
? '/v2/savezi/priority-sort?only=true&limit=500'
: '/savezi?limit=250';
: '/v2/savezi/priority-sort?only=false&limit=500';
const d = await api(url);
if(!d){ root.innerHTML='<div class="empty">Greška pri dohvatu</div>'; return; }
_cache.savezi = d.rows || [];
_filters.savezi.total = (d.rows||[]).length;
}
renderSaveziShell();
applySaveziFilter();
@@ -1255,6 +1352,7 @@ function renderSaveziShell(){
const root = $('#pg-savezi');
const sports = Array.from(new Set((_cache.savezi||[]).map(s=>s.sport).filter(Boolean))).sort();
root.innerHTML = `
${_filtersBar('savezi')}
<div class="toolbar">
<input type="search" id="sav-q" placeholder="🔍 Pretraži savez…">
<select id="sav-sport"><option value="">Svi sportovi</option>${sports.map(s=>'<option value="'+esc(s)+'">'+esc(s)+'</option>').join('')}</select>
@@ -1376,7 +1474,7 @@ async function openSavez(id){
${klubovi.length ? `<div style="overflow-x:auto;max-height:400px;overflow-y:auto"><table>
<thead><tr><th>Klub</th><th>Razina</th><th>Grad</th></tr></thead>
<tbody>${klubovi.slice(0,100).map(k => `
<tr onclick="closePanel();setTimeout(()=>openKlub(${k.id}),250)">
<tr onclick="panelDrill(openKlub, ${k.id})">
<td>${esc(k.klub||k.sport||'(bez naziva)')}${k.nositelj_kvalitete?' <span class="tag gd">N.K.</span>':''}</td>
<td>${txt(k.razina,'')}</td>
<td>${txt(k.grad)}</td>
@@ -1397,14 +1495,27 @@ async function loadKlubovi(){
const root = $('#pg-klubovi');
if(!_cache.klubovi){
root.innerHTML = '<div class="loading">Učitavanje klubova…</div>';
// /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);
// BUG-E (2026-05-05): build /api/klubovi URL from explicit _filters.klubovi state.
// Defaults: financirani=true + godisnjak=true. When BOTH off → load all.
const f = _filters.klubovi;
const qs = new URLSearchParams();
qs.set('limit','2500');
qs.set('sort','financiran'); qs.set('order','desc'); // sort by potpore DESC (financiran flag)
// financirani + godisnjak combined with kategorija=priority logic:
if(f.financirani && f.godisnjak){
qs.set('kategorija','priority'); // OR semantics → priority = financiran OR godišnjak
} else if(f.financirani){
qs.set('financiran','true');
} else if(f.godisnjak){
qs.set('godisnjak','true');
}
if(f.hns_roster) qs.set('samo_hns_roster','true');
// Legacy global toggle still respected (if user clicks the old PGŽ button).
if(window._pgz_filter_priority && !qs.has('kategorija')) qs.set('kategorija','priority');
const d = await api('/klubovi?'+qs.toString());
if(!d){ root.innerHTML='<div class="empty">Greška pri dohvatu</div>'; return; }
_cache.klubovi = d.rows || [];
_filters.klubovi.total = (d.rows||[]).length;
}
renderKluboviShell();
applyKluboviFilter();
@@ -1414,6 +1525,7 @@ function renderKluboviShell(){
const sports = Array.from(new Set((_cache.klubovi||[]).map(k=>k.sport).filter(Boolean))).sort().slice(0,80);
const grads = Array.from(new Set((_cache.klubovi||[]).map(k=>k.grad).filter(Boolean))).sort();
root.innerHTML = `
${_filtersBar('klubovi')}
<div class="toolbar">
<input type="search" id="kl-q" placeholder="🔍 Pretraži klub…">
<select id="kl-sport"><option value="">Svi sportovi</option>${sports.map(s=>'<option value="'+esc(s)+'">'+esc(s)+'</option>').join('')}</select>
@@ -1464,6 +1576,8 @@ function applyKluboviFilter(){
else if(kat==='financiran') rows = rows.filter(k => k.financiran);
else if(kat==='godisnjak') rows = rows.filter(k => k.godisnjak);
if(_sort.klubovi) rows = sortRows(rows, _sort.klubovi.key, _sort.klubovi.dir);
// BUG-E: live count + total
_filtersUpdateCount('klubovi', rows.length);
$('#kl-cnt').textContent = rows.length+' klubova';
const top = rows.slice(0, 300);
$('#kl-out').innerHTML = _state.viewKlubovi==='card' ? renderKluboviGrid(top) : renderKluboviTable(top);
@@ -1638,7 +1752,7 @@ async function openKlub(id){
${clanovi.length ? `<div style="overflow-x:auto;max-height:500px;overflow-y:auto"><table>
<thead><tr><th>Sportaš</th><th>Spol</th><th>Pozicija</th><th>Kategorija</th><th>Tagovi</th></tr></thead>
<tbody>${clanovi.map(c => `
<tr onclick="closePanel();setTimeout(()=>openSportas(${c.id}),250)">
<tr onclick="panelDrill(openSportas, ${c.id})">
<td><b>${esc(c.ime||'')} ${esc(c.prezime||'')}</b></td>
<td>${txt(c.spol)}</td>
<td>${txt(c.pozicija)}</td>
@@ -1664,7 +1778,7 @@ async function openKlub(id){
<details style="margin-bottom:8px" ${groups[kat].length<=12?'open':''}>
<summary style="cursor:pointer;padding:8px;background:rgba(255,255,255,.04);border-radius:6px"><b>${esc(kat)}</b> · ${groups[kat].length} igrač${groups[kat].length===1?'':'a'}</summary>
<table style="margin-top:6px"><tbody>${groups[kat].map(c => `
<tr onclick="closePanel();setTimeout(()=>openSportas(${c.id}),250)">
<tr onclick="panelDrill(openSportas, ${c.id})">
<td><b>${esc(c.ime||'')} ${esc(c.prezime||'')}</b></td>
<td>${txt(c.spol)}</td>
<td>${txt(c.pozicija)}</td>
@@ -2030,7 +2144,7 @@ async function openSportas(id){
<div class="pp-meta">
${d.sport?'<a class="link-chip" onclick="filterSportasiBy(&quot;sport&quot;,&quot;'+esc(d.sport)+'&quot;)">'+esc(d.sport)+'</a>':'—'} ·
${txt(d.pozicija,'')} ·
${d.klub_id ? '<a class="link-chip" onclick="closePanel();setTimeout(()=>openKlub('+d.klub_id+'),250)"><b>'+esc(d.klub_naziv_full||d.klub_naziv_godisnjak||'—')+'</b></a>' : '<b>'+esc(d.klub_naziv_full||d.klub_naziv_godisnjak||'—')+'</b>'}
${d.klub_id ? '<a class="link-chip" onclick="panelDrill(openKlub,'+d.klub_id+')"><b>'+esc(d.klub_naziv_full||d.klub_naziv_godisnjak||'—')+'</b></a>' : '<b>'+esc(d.klub_naziv_full||d.klub_naziv_godisnjak||'—')+'</b>'}
</div>
<div class="pp-meta">
${dob ? '<a class="link-chip" onclick="filterSportasiByYear(&quot;'+esc((dob||'').slice(0,4))+'&quot;)">📅 '+fmtDate(dob)+'</a>' : '📅 —'}
@@ -2050,11 +2164,8 @@ async function openSportas(id){
${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>
<!-- BUG-F (2026-05-05): external links moved to dedicated 🔗 Linkovi tab -->
</div>
</div>
@@ -2067,14 +2178,15 @@ async function openSportas(id){
<div class="pp-stat"><div class="v">${fmtNum(stats.sezone_aktivne||sezone.length)}</div><div class="l">Sezona</div></div>
</div>
<!-- HNS-3 (2026-05-05) — 3 explicit tabs: HNS Karijera / Utakmice (last 30) / Profil -->
<!-- BUG-F (2026-05-05) — 4 explicit tabs: Profil / HNS Karijera / Utakmice (last 30) / Linkovi -->
<div class="tabs">
<div class="tab active" onclick="switchPlayerTab(this,'p-sez')">🏆 HNS Karijera (${sezone.length})</div>
<div class="tab active" onclick="switchPlayerTab(this,'p-prof')">👤 Profil</div>
<div class="tab" onclick="switchPlayerTab(this,'p-sez')">🏆 HNS Karijera (${sezone.length})</div>
<div class="tab" onclick="switchPlayerTab(this,'p-utak')">📅 Utakmice (poslj. ${Math.min(utakmice.length,30)})</div>
<div class="tab" onclick="switchPlayerTab(this,'p-prof')">👤 Profil</div>
<div class="tab" onclick="switchPlayerTab(this,'p-link')">🔗 Linkovi</div>
</div>
<div id="p-sez" class="ptab">
<div id="p-sez" class="ptab" style="display:none">
<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>Klub</th><th>Natjecanje</th><th class="num">Nastupi</th><th class="num">Golovi</th><th class="num">Asis.</th><th class="num">Žuti</th><th class="num">Crv.</th><th class="num">Min.</th><th></th></tr></thead>
@@ -2124,7 +2236,7 @@ async function openSportas(id){
</table></div>` : '<div class="empty">Nema podataka o utakmicama</div>'}
</div>
<div id="p-prof" class="ptab" style="display:none">
<div id="p-prof" class="ptab">
<div class="pp-section-h">👤 Profil <span class="cnt">${esc(d.ime||'')} ${esc(d.prezime||'')}</span></div>
<!-- Top: name + dres + active club + HNS deep link -->
@@ -2136,7 +2248,7 @@ async function openSportas(id){
${(d.mjesto_rodjenja||d.mjesto_rodenja)?' · '+esc(d.mjesto_rodjenja||d.mjesto_rodenja):''}
</div>
<div class="prof-club">
${d.klub_id ? '<a class="link-chip" onclick="closePanel();setTimeout(()=>openKlub('+d.klub_id+'),250)">🏟️ '+esc(d.klub_naziv_full||d.klub_naziv||d.klub_naziv_godisnjak||'—')+'</a>' : '🏟️ '+esc(d.klub_naziv_full||d.klub_naziv||d.klub_naziv_godisnjak||'—')}
${d.klub_id ? '<a class="link-chip" onclick="panelDrill(openKlub,'+d.klub_id+')">🏟️ '+esc(d.klub_naziv_full||d.klub_naziv||d.klub_naziv_godisnjak||'—')+'</a>' : '🏟️ '+esc(d.klub_naziv_full||d.klub_naziv||d.klub_naziv_godisnjak||'—')}
${d.aktivan?'<span class="tag gr" style="margin-left:8px">AKTIVAN</span>':'<span class="tag rd" style="margin-left:8px">NEAKTIVAN</span>'}
</div>
${hnsUrl?'<div style="margin-top:10px"><a class="pp-link hns" href="'+esc(hnsUrl)+'" target="_blank" rel="noopener">⚽ HNS Semafor profil ↗</a></div>':''}
@@ -2169,7 +2281,7 @@ async function openSportas(id){
<tr class="no-click">
<td><b>${esc(k.sezona||'—')}</b></td>
<td><span class="tag b">${esc(k.kategorija||'—')}</span></td>
<td>${k.klub_id ? '<a class="link-chip" onclick="closePanel();setTimeout(()=>openKlub('+k.klub_id+'),250)">'+esc(k.klub_naziv||('Klub #'+k.klub_id))+'</a>' : (k.klub_naziv?esc(k.klub_naziv):'—')}</td>
<td>${k.klub_id ? '<a class="link-chip" onclick="panelDrill(openKlub,'+k.klub_id+')">'+esc(k.klub_naziv||('Klub #'+k.klub_id))+'</a>' : (k.klub_naziv?esc(k.klub_naziv):'—')}</td>
<td>${esc(k.source||'—')}</td>
<td>${k.source_url?'<a href="'+esc(k.source_url)+'" target="_blank" rel="noopener">↗</a>':''}</td>
</tr>`).join('')}
@@ -2196,6 +2308,19 @@ async function openSportas(id){
</table></div>` : ''}
</div>
<!-- BUG-F (2026-05-05) — 🔗 Linkovi tab: external profile lookups -->
<div id="p-link" class="ptab" style="display:none">
<div class="pp-section-h">🔗 Linkovi <span class="cnt">${esc(fullName||'sportaš')}</span></div>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px;margin-top:8px">
${hnsUrl
? `<a class="pp-link hns" href="${esc(hnsUrl)}" target="_blank" rel="noopener" style="padding:18px;font-size:14px;justify-content:space-between"><span>⚽ HNS Semafor profil</span><span style="font-family:var(--mono);color:var(--t3);font-size:11px">#${esc(hnsId||'')} →</span></a>`
: `<div class="pp-link" style="padding:18px;font-size:14px;opacity:.5;cursor:not-allowed">⚽ HNS profil <span style="font-size:11px;color:var(--t3);margin-left:auto">nije povezan</span></div>`}
<a class="pp-link gg" href="${esc(ggUrl)}" target="_blank" rel="noopener" style="padding:18px;font-size:14px;justify-content:space-between"><span>🔍 Google pretraga</span><span style="color:var(--t3);font-size:11px">→</span></a>
<a class="pp-link wiki" href="${esc(wikiUrl)}" target="_blank" rel="noopener" style="padding:18px;font-size:14px;justify-content:space-between"><span>📖 Wikipedia</span><span style="color:var(--t3);font-size:11px">→</span></a>
</div>
${hnsId ? `<div style="margin-top:14px;padding:10px 12px;background:var(--bg2);border:1px solid var(--rim);border-radius:5px;font-size:11.5px;color:var(--t2);font-family:var(--mono)">HNS ID: <b style="color:var(--pgz-gold)">${esc(hnsId)}</b> · slug: <span style="color:var(--t1)">${esc(slug||'—')}</span></div>` : ''}
</div>
${enrichBlock('sportas', d.id)}
`;
openPanel('Sportaš · '+(d.ime||'')+' '+(d.prezime||''), html);
@@ -3315,8 +3440,8 @@ function renderAlertPanel(a){
<div class="card-h"><div class="card-t">🔗 Povezani entiteti</div></div>
<table>
<tbody>
${a.klub_id ? '<tr onclick="closePanel();setTimeout(()=>openKlub('+a.klub_id+'),250)"><td><span class="tag b">Klub</span></td><td><b>Klub #'+a.klub_id+'</b></td><td>Klikni za detalje →</td></tr>' : ''}
${a.clan_id ? '<tr onclick="closePanel();setTimeout(()=>openSportas('+a.clan_id+'),250)"><td><span class="tag b">Sportaš</span></td><td><b>Sportaš #'+a.clan_id+'</b></td><td>Klikni za profil →</td></tr>' : ''}
${a.klub_id ? '<tr onclick="panelDrill(openKlub,'+a.klub_id+')"><td><span class="tag b">Klub</span></td><td><b>Klub #'+a.klub_id+'</b></td><td>Klikni za detalje →</td></tr>' : ''}
${a.clan_id ? '<tr onclick="panelDrill(openSportas,'+a.clan_id+')"><td><span class="tag b">Sportaš</span></td><td><b>Sportaš #'+a.clan_id+'</b></td><td>Klikni za profil →</td></tr>' : ''}
</tbody>
</table>
${(!a.klub_id && !a.clan_id) ? '<div class="empty" style="padding:14px">Nema povezanih entiteta u alarmu</div>' : ''}
@@ -3455,32 +3580,123 @@ window.toggleSportasHNS = function(){
if(typeof loadSportasi === 'function') loadSportasi();
};
// PANEL HISTORY STACK + NATRAG (CRISIS V7)
// PANEL HISTORY STACK + NATRAG (CRISIS V7 / BUG-B opener-based)
// Each entry: {opener: <fn name>, args: [...]} — panelBack re-runs the previous opener.
window._panelHistory = [];
window._panelDrilling = false; // suppress root-clear while drilling
window._panelSuppressPush = false; // suppress re-push when re-running for back nav
window._panelOpenerMap = {}; // name -> fn
window.pushPanelState = function(title, htmlContent, callback){
// callback may be null — to render this state again later
window._panelHistory.push({title, htmlContent, callback});
const back = document.getElementById('panel-back');
if(back) back.style.display = window._panelHistory.length > 1 ? 'inline-flex' : 'none';
window._registerOpener = function(fn){
if(typeof fn !== 'function' || !fn.name) return null;
window._panelOpenerMap[fn.name] = fn;
return fn.name;
};
window._updateBackBtn = function(){
const back = document.getElementById('panel-back');
if(back) back.style.display = (window._panelHistory.length > 1) ? 'inline-flex' : 'none';
};
// Push opener+args; record in browser history too (for browser back button)
window.pushPanelState = function(opener, args){
if(window._panelSuppressPush) return;
const name = (typeof opener === 'function') ? window._registerOpener(opener) : (typeof opener === 'string' ? opener : null);
if(!name) return;
const top = window._panelHistory[window._panelHistory.length - 1];
const sig = name + ':' + JSON.stringify(args || []);
if(top && (top.opener + ':' + JSON.stringify(top.args || [])) === sig) return;
window._panelHistory.push({opener: name, args: args || []});
try { history.pushState({pgzPanel: true, depth: window._panelHistory.length}, '', location.href); } catch(e) {}
window._updateBackBtn();
};
// panelDrill — push state then run opener (no panel close in between)
window.panelDrill = function(opener, ...args){
window._panelDrilling = true;
try {
window.pushPanelState(opener, args);
const r = opener.apply(null, args);
if(r && typeof r.then === 'function') return r.finally(() => { window._panelDrilling = false; });
return r;
} finally {
window._panelDrilling = false;
}
};
// panelOpen — root entry: clear history then drill
window.panelOpen = function(opener, ...args){
window._panelHistory = [];
return window.panelDrill(opener, ...args);
};
// panelBack — pop current, re-run previous opener
window.panelBack = function(){
if(window._panelHistory.length <= 1){ closePanel(); return; }
// Remove current state
if(window._panelHistory.length <= 1){ window.closePanel(); return; }
window._panelHistory.pop();
// Restore previous
const prev = window._panelHistory[window._panelHistory.length - 1];
const t = document.getElementById('panel-hdr-t');
const b = document.getElementById('panel-body');
if(t) t.textContent = prev.title;
if(b) b.innerHTML = prev.htmlContent;
if(typeof prev.callback === 'function') prev.callback();
const back = document.getElementById('panel-back');
if(back) back.style.display = window._panelHistory.length > 1 ? 'inline-flex' : 'none';
const fn = window._panelOpenerMap[prev.opener];
if(typeof fn !== 'function'){ window.closePanel(); return; }
window._panelSuppressPush = true;
window._panelDrilling = true;
try { fn.apply(null, prev.args || []); } catch(e) { console.error('panelBack', e); }
setTimeout(() => {
window._panelSuppressPush = false;
window._panelDrilling = false;
window._updateBackBtn();
}, 60);
window._updateBackBtn();
};
// Override closePanel — clear history
// Browser back button → mirror panelBack while panel is open
window.addEventListener('popstate', function(){
const panel = document.getElementById('panel');
if(!panel || !panel.classList.contains('open')) return;
if(window._panelHistory.length > 1){
window._panelSuppressPush = true;
window._panelDrilling = true;
window._panelHistory.pop();
const prev = window._panelHistory[window._panelHistory.length - 1];
const fn = window._panelOpenerMap[prev.opener];
if(typeof fn === 'function') fn.apply(null, prev.args || []);
setTimeout(() => {
window._panelSuppressPush = false;
window._panelDrilling = false;
window._updateBackBtn();
}, 60);
window._updateBackBtn();
} else {
window.closePanel();
}
});
// Wrap root open* functions: a non-drill call clears history and registers itself as root.
window._wrapOpener = function(name){
const orig = window[name];
if(typeof orig !== 'function' || orig.__pgzWrapped) return;
window._registerOpener(orig);
const wrapped = function(...args){
if(!window._panelDrilling && !window._panelSuppressPush){
window._panelHistory = [];
window._panelHistory.push({opener: name, args});
try { history.pushState({pgzPanel: true, depth: 1}, '', location.href); } catch(e) {}
window._updateBackBtn();
}
return orig.apply(this, args);
};
wrapped.__pgzWrapped = true;
try { Object.defineProperty(wrapped, 'name', {value: name, configurable: true}); } catch(e) {}
window[name] = wrapped;
window._panelOpenerMap[name] = wrapped;
};
// Wrap all known root openers (idempotent)
['openSavez','openKlub','openSportas','openObjekt','openManif',
'openPrimateljDetail','openProracunDrill','openSavezByName',
'openMrezaNode','openForensicDetail','openOIB']
.forEach(function(n){ try { window._wrapOpener(n); } catch(e) {} });
// Override closePanel — X button always returns to root: clear history & back btn
window._origClosePanel = window.closePanel;
window.closePanel = function(){
window._panelHistory = [];
@@ -3489,7 +3705,7 @@ window.closePanel = function(){
const p = document.getElementById('panel');
if(p) p.classList.remove('open');
const ov = document.getElementById('panel-overlay');
if(ov) ov.style.display = 'none';
if(ov){ ov.classList.remove('open'); ov.style.removeProperty('display'); }
};
</script>
</body>