Files
damir c38f15a566 R7+: 5x P0 demo fixes — HNS direct link, avatar cache, logo home, klub→sportaši, smarter enrichment
1) HNS direct link u research_links: za sportaš s profile_url/source_url
   (npr. https://semafor.hns.family/igraci/X/...) generira [DIRECT] link na vrhu liste,
   umjesto generic Google search. _research_links sada prima row dict.

2) Avatar cache buster: applyMeToHeader dodaje ?t=Date.now() na sve avatar img tagove.
   Avatar upload handler dodatno persistira novi avatar_url u localStorage.pgz_user
   tako da preživi page refresh + cross-page navigacije.

3) Logo home link: <div class='logo'> → <a href='/' class='logo'> u app.html i sport2.html.
   Klik na PGŽ SPORT logo vodi na public portal.

4) Klub → Sportaši drill-down: u klub Info tabu dodan button
   '👥 Vidi sportaše ovog kluba (N)' koji prebacuje na k-clan tab.
   Plus '🌐 Službena stranica' link kad klub ima web.

5) Smarter klub enrichment:
   - URL validacija (skip placeholder strings poput 'godisnjak_zspgz_2025')
   - Domain candidate guesser (slug → 16 candidate URLs s common HR TLD-ovima i sport prefix-ima)
   - Parallel HEAD probe (8 threads, 10s budget) — first 200 + name token match wins
   - Subpage scrape (/kontakt, /uprava, /o-nama, /o-klubu, /predsjednik) za richer evidence
   - HNK Orijent (id 3766) test: pogađa https://www.orijent.hr/, predlaže web+email+telefon+opis

E2E verified:
- 9/9 sidebar URL-ova → 200
- /users/me/gdpr-export → 200 (28KB JSON)
- /users/me/request-deletion → 200 (DB row pgz_sport.gdpr_erasure_requests)
- /enrich/klub/3766 → 4 proposed fields (web, email, telefon, opis)
- HNS sportaš research_links:  HNS profil DIRECT link na vrhu

Backend: routers/enrich_router.py
Frontend: static/app.html, static/sport2.html
Backups: _backups/sprint_1777940670/

Tag: R7-demo-ready
2026-05-05 02:24:30 +02:00

2951 lines
149 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="hr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>PGŽ SPORT — Platforma</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js"></script>
<script src="https://unpkg.com/three@0.160.0/build/three.min.js"></script>
<script src="https://unpkg.com/3d-force-graph@1.73.0/dist/3d-force-graph.min.js"></script>
<style>
:root{
--pgz-blue:#003087; --pgz-blue2:#004CC4; --pgz-gold:#F4C430;
--bg0:#08090e; --bg1:#0d1021; --bg2:#111628; --bg3:#161d35; --bg4:#1c2542;
--rim:#1e2a50; --rim2:#283560;
--t0:#fff; --t1:#e2e6f0; --t2:#8a95b4; --t4:#4e5a7a;
--green:#00e88f; --red:#ff2d55; --amber:#f59e0b; --cyan:#00c8e8;
--font:'Inter',sans-serif; --mono:'JetBrains Mono',monospace;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
body{font-family:var(--font);background:var(--bg0);color:var(--t1);font-size:13px;overflow-x:hidden}
a{color:var(--cyan);text-decoration:none}
a:hover{color:var(--pgz-gold)}
button,input,select{font-family:inherit;font-size:inherit;outline:none}
::-webkit-scrollbar{width:8px;height:8px}
::-webkit-scrollbar-track{background:var(--bg1)}
::-webkit-scrollbar-thumb{background:var(--rim2);border-radius:4px}
::-webkit-scrollbar-thumb:hover{background:var(--pgz-blue2)}
.app{display:flex;min-height:100vh}
/* Native sidebar replaced by shared /static/shared/sidebar.* — kept hidden but DOM intact for legacy buildNav() calls */
.sb{display:none}
.sb-old{width:240px;background:linear-gradient(180deg,var(--bg1) 0%,var(--bg0) 100%);border-right:1px solid var(--rim);position:fixed;top:0;left:0;bottom:0;display:flex;flex-direction:column;z-index:10;transition:width .22s ease}
.sb-h{padding:18px 18px 14px;border-bottom:1px solid var(--rim);position:relative}
.sb-h .logo{font-weight:800;font-size:14px;color:var(--t0);letter-spacing:.5px;white-space:nowrap;overflow:hidden}
.sb-h .logo .g{color:var(--pgz-gold)}
.sb-h .sub{font-size:10px;color:var(--t2);margin-top:4px;text-transform:uppercase;letter-spacing:1px;white-space:nowrap;overflow:hidden}
.sb-toggle{position:absolute;top:14px;right:8px;width:22px;height:22px;display:flex;align-items:center;justify-content:center;cursor:pointer;color:var(--t2);background:var(--bg2);border:1px solid var(--rim);border-radius:4px;font-size:11px;font-weight:700;transition:all .15s;user-select:none}
.sb-toggle:hover{background:var(--bg3);color:var(--pgz-gold);border-color:var(--pgz-gold)}
.sb-nav{flex:1;padding:10px 8px;overflow-y:auto;overflow-x:hidden}
.nav-i{padding:9px 12px;border-radius:6px;color:var(--t2);cursor:pointer;display:flex;align-items:center;gap:10px;font-size:12.5px;margin-bottom:2px;transition:background .15s,color .15s;white-space:nowrap}
.nav-i:hover{background:var(--bg2);color:var(--t1)}
.nav-i.active{background:linear-gradient(90deg,var(--pgz-blue) 0%,var(--pgz-blue2) 100%);color:#fff;font-weight:600}
.nav-i .ic{width:18px;text-align:center;font-size:14px;flex-shrink:0}
.nav-i .lbl{overflow:hidden;text-overflow:ellipsis}
.sb-foot{padding:10px 14px;border-top:1px solid var(--rim);font-size:10px;color:var(--t4);white-space:nowrap;overflow:hidden}
/* Collapsed sidebar */
.sb.collapsed{width:58px}
.sb.collapsed .sb-h{padding:18px 8px 14px;text-align:center}
.sb.collapsed .sb-h .logo{font-size:0}
.sb.collapsed .sb-h .logo::before{content:"PG";font-size:13px;color:var(--pgz-gold);font-weight:800}
.sb.collapsed .sb-h .sub{display:none}
.sb.collapsed .sb-toggle{position:static;margin:6px auto 0;display:flex}
.sb.collapsed .nav-i{justify-content:center;padding:10px 6px}
.sb.collapsed .nav-i .lbl{display:none}
.sb.collapsed .nav-i{position:relative}
.sb.collapsed .nav-i:hover::after{content:attr(data-label);position:absolute;left:58px;top:50%;transform:translateY(-50%);background:var(--bg3);color:var(--t0);padding:5px 10px;border-radius:4px;font-size:11.5px;white-space:nowrap;border:1px solid var(--rim);z-index:50;font-weight:600;pointer-events:none;box-shadow:2px 2px 8px rgba(0,0,0,.4)}
.sb.collapsed .sb-foot{font-size:0;padding:8px}
.sb.collapsed .sb-foot::before{content:"v2";font-size:9px;color:var(--t4)}
.sb.collapsed .nav-sep{font-size:0;padding:6px 0;text-align:center;border-top:1px dashed var(--rim);margin:6px 8px 4px}
.sb.collapsed .nav-ext span:last-child{display:none}
.main{margin-left:0;flex:1;min-width:0;transition:margin-left .22s ease}
/* body.pgz-has-sb (from shared/sidebar.css) provides the left padding */
.tb{background:var(--bg1);border-bottom:1px solid var(--rim);padding:12px 22px;display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:5}
.tb-t{font-size:15px;font-weight:700;color:var(--t0)}
.tb-s{font-size:11px;color:var(--t2)}
.content{padding:22px}
.section{display:none}
.section.active{display:block}
.kpi-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:14px;margin-bottom:22px}
.kpi{background:linear-gradient(135deg,var(--bg2) 0%,var(--bg1) 100%);border:1px solid var(--rim);border-radius:8px;padding:14px 16px;position:relative;overflow:hidden}
.kpi::before{content:"";position:absolute;top:0;left:0;width:3px;height:100%;background:var(--pgz-gold)}
.kpi.b::before{background:var(--pgz-blue2)}
.kpi.g::before{background:var(--green)}
.kpi.r::before{background:var(--red)}
.kpi-l{font-size:10.5px;color:var(--t2);text-transform:uppercase;letter-spacing:1px;font-weight:600}
.kpi-v{font-size:24px;font-weight:800;color:var(--t0);margin-top:4px;font-family:var(--mono)}
.kpi-s{font-size:10px;color:var(--t4);margin-top:2px}
.card{background:var(--bg2);border:1px solid var(--rim);border-radius:8px;padding:14px;margin-bottom:14px}
.card-h{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;padding-bottom:8px;border-bottom:1px solid var(--rim)}
.card-t{font-weight:700;color:var(--t0);font-size:13px}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:12px}
.grid-club{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px}
.grid-player{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px}
.entity{background:var(--bg2);border:1px solid var(--rim);border-radius:8px;padding:12px;cursor:pointer;transition:all .2s;position:relative}
.entity:hover{border-color:var(--pgz-gold);background:var(--bg3);transform:translateY(-1px)}
.entity .et{font-weight:700;color:var(--t0);font-size:13px;margin-bottom:6px;line-height:1.3}
.entity .es{font-size:11px;color:var(--t2);margin-bottom:6px}
.entity .em{display:flex;gap:8px;font-size:10.5px;color:var(--t4);flex-wrap:wrap}
.entity .em b{color:var(--pgz-gold);font-weight:700}
.entity .et-tag{position:absolute;top:8px;right:8px;font-size:9px;padding:2px 6px;border-radius:3px;background:var(--pgz-blue);color:#fff;font-weight:600;text-transform:uppercase}
.player-card{background:var(--bg2);border:1px solid var(--rim);border-radius:8px;overflow:hidden;cursor:pointer;transition:all .2s}
.player-card:hover{border-color:var(--pgz-gold);transform:translateY(-1px)}
.player-card .ph{height:120px;background:linear-gradient(135deg,var(--bg3),var(--bg2));position:relative;overflow:hidden;display:flex;align-items:center;justify-content:center}
.player-card .ph img{width:100%;height:100%;object-fit:cover;object-position:top}
.player-card .ph .no{font-size:36px;color:var(--t4);font-weight:800}
.player-card .pb{padding:10px}
.player-card .pn{font-weight:700;color:var(--t0);font-size:13px;line-height:1.2}
.player-card .pp{font-size:11px;color:var(--t2);margin-top:3px}
.player-card .pk{font-size:10.5px;color:var(--t4);margin-top:3px}
.player-card .badges{display:flex;gap:4px;margin-top:6px;flex-wrap:wrap}
.player-card .badge{font-size:9px;padding:2px 5px;border-radius:3px;background:var(--bg4);color:var(--t1);text-transform:uppercase;font-weight:600}
.player-card .badge.repr{background:var(--pgz-gold);color:var(--bg0)}
.player-card .badge.hoo{background:var(--pgz-blue2);color:#fff}
table{width:100%;border-collapse:collapse;font-size:12px}
table th{background:var(--bg3);color:var(--t2);text-transform:uppercase;font-size:10px;letter-spacing:.5px;padding:8px 10px;text-align:left;border-bottom:1px solid var(--rim);font-weight:700}
table td{padding:8px 10px;border-bottom:1px solid var(--rim);color:var(--t1)}
table tbody tr{cursor:pointer;transition:background .15s}
table tbody tr:hover{background:var(--bg3)}
table tbody tr.no-click{cursor:default}
table tbody tr.no-click:hover{background:transparent}
.num{font-family:var(--mono);text-align:right}
.toolbar{display:flex;align-items:center;gap:10px;margin-bottom:14px;flex-wrap:wrap}
.toolbar input,.toolbar select{background:var(--bg2);border:1px solid var(--rim);border-radius:5px;padding:7px 10px;color:var(--t1);font-size:12px}
.toolbar input:focus,.toolbar select:focus{border-color:var(--pgz-blue2)}
.toolbar input{min-width:200px}
.toolbar label{font-size:11px;color:var(--t2);display:flex;align-items:center;gap:6px}
.toolbar input[type=checkbox]{accent-color:var(--pgz-gold)}
.btn{background:var(--bg2);border:1px solid var(--rim);border-radius:5px;padding:7px 12px;color:var(--t1);font-size:12px;cursor:pointer;font-weight:600;transition:all .15s}
.btn:hover{background:var(--bg3);border-color:var(--rim2)}
.btn.primary{background:linear-gradient(135deg,var(--pgz-blue),var(--pgz-blue2));border-color:transparent;color:#fff}
.btn.primary:hover{filter:brightness(1.1)}
.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}
.tabs{display:flex;gap:4px;border-bottom:1px solid var(--rim);margin-bottom:14px;flex-wrap:wrap}
.tab{padding:8px 14px;cursor:pointer;color:var(--t2);font-weight:600;font-size:12px;border-bottom:2px solid transparent;transition:all .15s}
.tab:hover{color:var(--t1)}
.tab.active{color:var(--pgz-gold);border-color:var(--pgz-gold)}
.empty{text-align:center;padding:30px;color:var(--t4);font-size:12px;font-style:italic}
.loading{padding:24px;text-align:center;color:var(--t2);font-size:12px}
.loading::before{content:"";display:inline-block;width:12px;height:12px;border:2px solid var(--rim);border-top-color:var(--pgz-gold);border-radius:50%;animation:spin .8s linear infinite;margin-right:8px;vertical-align:middle}
@keyframes spin{to{transform:rotate(360deg)}}
.tag{display:inline-block;padding:2px 7px;font-size:10px;border-radius:3px;background:var(--bg4);color:var(--t1);font-weight:600;text-transform:uppercase;letter-spacing:.5px;margin-right:3px}
a.tag,.tag[onclick]{cursor:pointer;text-decoration:none;transition:transform .12s,filter .12s}
a.tag:hover,.tag[onclick]:hover{transform:translateY(-1px);filter:brightness(1.15)}
.link-chip{color:var(--cyan);cursor:pointer;text-decoration:none;border-bottom:1px dashed transparent;transition:all .15s}
.link-chip:hover{color:var(--pgz-gold);border-bottom-color:var(--pgz-gold)}
.kv .v a{color:var(--cyan)}
.kv .v a:hover{color:var(--pgz-gold)}
.tag.b{background:var(--pgz-blue);color:#fff}
.tag.gd{background:var(--pgz-gold);color:var(--bg0)}
.tag.gr{background:var(--green);color:var(--bg0)}
.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-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}
#panel-x:hover{background:var(--bg3);color:var(--red)}
#panel-body{flex:1;overflow-y:auto;padding:16px}
#panel-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:199;backdrop-filter:blur(2px)}
#panel-overlay.open{display:block}
.pp-hdr{display:flex;gap:14px;align-items:flex-start;margin-bottom:14px;padding-bottom:14px;border-bottom:1px solid var(--rim)}
.pp-foto{width:90px;height:110px;border-radius:6px;background:var(--bg3);overflow:hidden;flex-shrink:0;display:flex;align-items:center;justify-content:center}
.pp-foto img{width:100%;height:100%;object-fit:cover;object-position:top}
.pp-foto .no{font-size:32px;color:var(--t4);font-weight:800}
.pp-info{flex:1;min-width:0}
.pp-name{font-size:18px;font-weight:800;color:var(--t0);margin-bottom:4px}
.pp-meta{font-size:12px;color:var(--t2);margin-bottom:6px}
.pp-tags{display:flex;gap:4px;flex-wrap:wrap}
.pp-stats{display:grid;grid-template-columns:repeat(6,1fr);gap:8px;margin-bottom:14px;padding:12px;background:var(--bg2);border:1px solid var(--rim);border-radius:6px}
.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}
.kv{display:grid;grid-template-columns:140px 1fr;gap:6px 12px;font-size:12px}
.kv .k{color:var(--t2);font-weight:600}
.kv .v{color:var(--t1);word-break:break-word}
.utlogo{width:22px;height:22px;border-radius:50%;background:var(--bg3);object-fit:contain;vertical-align:middle;margin-right:6px}
.score{display:inline-flex;align-items:center;gap:4px;padding:3px 8px;border-radius:4px;background:var(--bg3);font-size:11px;font-weight:600}
.score.high{background:rgba(0,232,143,.15);color:var(--green)}
.score.mid{background:rgba(245,158,11,.15);color:var(--amber)}
.score.low{background:rgba(255,45,85,.15);color:var(--red)}
.row-2{display:grid;grid-template-columns:1fr 1fr;gap:14px}
@media (max-width:900px){.row-2{grid-template-columns:1fr}}
.chart-box{background:var(--bg2);border:1px solid var(--rim);border-radius:8px;padding:14px;height:340px}
.chart-box canvas{max-height:300px}
.alert-card{padding:10px 12px;border-left:3px solid var(--amber);background:var(--bg2);border-radius:5px;margin-bottom:8px}
.alert-card.crit{border-color:var(--red)}
.alert-card .at{font-weight:700;font-size:12px;color:var(--t0)}
.alert-card .ad{font-size:11px;color:var(--t2);margin-top:3px}
.iframe-map{width:100%;height:140px;border:0;border-radius:5px;background:var(--bg3)}
.ac-wrap{position:relative}
.ac-drop{display:none;position:absolute;top:100%;left:0;right:0;min-width:240px;background:var(--bg2);border:1px solid var(--rim2);border-radius:5px;box-shadow:0 6px 20px rgba(0,0,0,.5);z-index:100;max-height:300px;overflow-y:auto;margin-top:4px}
.ac-item{padding:8px 12px;cursor:pointer;border-bottom:1px solid var(--rim);transition:background .12s}
.ac-item:last-child{border-bottom:0}
.ac-item:hover{background:var(--bg3)}
.ac-item .ac-l{font-weight:600;color:var(--t0);font-size:12.5px}
.ac-item .ac-s{font-size:10.5px;color:var(--t2);margin-top:2px}
.ac-empty{padding:12px;text-align:center;color:var(--t4);font-size:11px;font-style:italic}
@media (max-width:768px){
.sb{transform:translateX(-100%);transition:transform .25s}
.sb.open{transform:translateX(0)}
.main{margin-left:0}
.pp-stats{grid-template-columns:repeat(3,1fr)}
}
</style>
<link rel="stylesheet" href="/static/shared/sidebar.css">
<script src="/static/shared/sidebar.js" defer data-active="dashboard"></script>
</head>
<body>
<div class="app">
<aside class="sb" id="sb">
<div class="sb-h">
<div class="logo">PGŽ <span class="g">SPORT</span></div>
<div class="sub">Primorsko-goranska županija</div>
<div class="sb-toggle" id="sb-toggle" onclick="toggleSidebar()" title="Skupi/raširi sidebar"></div>
</div>
<nav class="sb-nav" id="nav"></nav>
<div class="sb-foot">v2.0 · 2026</div>
</aside>
<main class="main">
<div class="tb">
<div>
<div class="tb-t" id="tb-t">Dashboard</div>
<div class="tb-s" id="tb-s">Pregled stanja</div>
</div>
<div class="tb-s">
<span style="color:var(--green)"></span> API live · sport.rinet.one
</div>
</div>
<div class="content">
<section id="pg-dashboard" class="section active"></section>
<section id="pg-savezi" class="section"></section>
<section id="pg-klubovi" class="section"></section>
<section id="pg-sportasi" class="section"></section>
<section id="pg-financije" class="section"></section>
<section id="pg-objekti" class="section"></section>
<section id="pg-manifestacije" class="section"></section>
<section id="pg-mreza" class="section"></section>
<section id="pg-forenzika" class="section"></section>
<section id="pg-audit" class="section"></section>
</div>
</main>
</div>
<div id="panel-overlay" onclick="closePanel()"></div>
<div id="panel">
<div id="panel-hdr">
<div id="panel-hdr-t">Detalji</div>
<div id="panel-x" onclick="closePanel()">×</div>
</div>
<div id="panel-body"></div>
</div>
<script>
//=========== CONFIG ===========
const API = '/sport/api';
const NAV_ITEMS = [
{id:'dashboard', ic:'\u{1F4CA}', label:'Dashboard'},
{id:'savezi', ic:'\u{1F3C5}', label:'Savezi'},
{id:'klubovi', ic:'⬢', label:'Klubovi'},
{id:'sportasi', ic:'\u{1F464}', label:'Sportaši'},
{id:'financije', ic:'€', label:'Financije'},
{id:'objekti', ic:'\u{1F4CD}', label:'Objekti'},
{id:'manifestacije', ic:'\u{1F4C5}', label:'Manifestacije'},
{id:'mreza', ic:'\u{1F578}', label:'Mreža'},
{id:'forenzika', ic:'⚠', label:'Forenzika'},
{id:'audit', ic:'\u{1F512}', label:'Audit log'}
];
const SECTION_TITLES = {
dashboard: ['Dashboard', 'Pregled stanja PGŽ Sporta'],
savezi: ['Savezi', '246 sportskih saveza'],
klubovi: ['Klubovi', 'Sportski klubovi PGŽ'],
sportasi: ['Sportaši', 'Registrirani članovi'],
financije: ['Financije', 'Sufinanciranje sporta'],
objekti: ['Sportski objekti', 'Geocodirana infrastruktura'],
manifestacije: ['Manifestacije', 'Sportski događaji'],
mreza: ['Mreža', 'Force-directed graf entiteta i veza'],
forenzika: ['Forenzika', 'Kritični nalazi i alarmi'],
audit: ['Audit log', 'Polygon PoS pečaćenje ključnih akcija']
};
const _cache = {savezi:null, klubovi:null, clanovi:null, objekti:null, manifestacije:null, sufin:null, dash:null};
const _state = {section:'dashboard', viewSavezi:'card', viewKlubovi:'card', viewSportasi:'card', viewObjekti:'card', viewManif:'card', viewFinancije:'table'};
const _sort = {savezi:null, klubovi:null, sportasi:null, objekti:null, manifestacije:null, financije:null};
let _proracunChart=null, _financijeChart=null;
//=========== UTIL ===========
const $ = s => document.querySelector(s);
const $$ = s => Array.from(document.querySelectorAll(s));
function esc(s){
if(s===null||s===undefined) return '';
return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function fmtNum(n){
if(n===null||n===undefined||n==='') return '—';
const v = Number(n);
if(isNaN(v)) return esc(n);
return v.toLocaleString('hr-HR');
}
function fmtEur(n){
if(n===null||n===undefined||n==='') return '—';
const v = Number(n);
if(isNaN(v)) return esc(n);
if(v>=1000000) return '€'+(v/1000000).toFixed(2)+'M';
if(v>=1000) return '€'+(v/1000).toFixed(1)+'k';
return '€'+v.toFixed(0);
}
function fmtEurFull(n){
if(n===null||n===undefined||n==='') return '—';
const v = Number(n);
if(isNaN(v)) return esc(n);
return v.toLocaleString('hr-HR', {minimumFractionDigits:2, maximumFractionDigits:2})+' €';
}
function fmtDate(s){
if(!s) return '—';
const m = String(s).match(/^(\d{4})-(\d{2})-(\d{2})/);
if(!m) return esc(s);
return m[3]+'.'+m[2]+'.'+m[1];
}
function txt(v, fb){
if(v===null||v===undefined||v==='') return fb===undefined?'—':fb;
return esc(v);
}
async function api(path){
try{
const r = await fetch(API+path);
if(!r.ok) throw new Error('HTTP '+r.status);
return await r.json();
}catch(e){
console.error('API error', path, e);
return null;
}
}
async function apiPost(path, body){
try{
const r = await fetch(API+path, {method:'POST', headers:{'Content-Type':'application/json'}, body: body?JSON.stringify(body):'{}'});
if(!r.ok) throw new Error('HTTP '+r.status);
return await r.json();
}catch(e){
console.error('API POST error', path, e);
return null;
}
}
// Cache the latest preview so /apply can pass back the same sources
window._enrichPreviews = window._enrichPreviews || {};
async function enrichEntity(kind, id){
const targetId = 'enrich-out-'+kind+'-'+id;
const target = document.getElementById(targetId);
if(target) target.innerHTML = '<div class="loading">⏳ Obogaćivanje u tijeku — pretraživanje izvora…</div>';
const r = await apiPost('/v2/enrich/'+kind+'/'+id);
if(!r){ if(target) target.innerHTML = '<div class="empty">Greška pri obogaćivanju</div>'; return; }
window._enrichPreviews[kind+':'+id] = r;
const cov = r.coverage||0;
const covCls = cov>=70?'high':(cov>=40?'mid':'low');
const proposed = r.proposed || {};
const current = r.current || {};
const propKeys = Object.keys(proposed);
const lastEnr = r.last_enriched_at
? `<span class="tb-s" title="${esc(r.last_enriched_at)}">✓ Obogaćeno ${esc(String(r.last_enriched_at).slice(0,10))}</span>`
: '';
let diffHtml = '';
if(propKeys.length){
const rows = propKeys.map(k => {
const cv = current[k]; const pv = proposed[k];
const cvHtml = cv
? '<span style="color:var(--t1)">'+esc(String(cv).slice(0,200))+'</span>'
: '<span class="tag rd">prazno</span>';
const pvHtml = '<span style="color:var(--ok)">'+esc(String(pv).slice(0,400))+'</span>';
return `<tr>
<td style="vertical-align:top;padding:6px 8px"><label style="display:flex;gap:6px;align-items:center;cursor:pointer">
<input type="checkbox" data-field="${esc(k)}" checked style="width:16px;height:16px"> <b style="font-family:monospace;font-size:12px">${esc(k)}</b>
</label></td>
<td style="vertical-align:top;padding:6px 8px;font-size:12px;max-width:240px">${cvHtml}</td>
<td style="vertical-align:top;padding:6px 8px;font-size:12px">${pvHtml}</td>
</tr>`;
}).join('');
diffHtml = `
<div style="margin:10px 0;border:1px solid var(--ln);border-radius:6px;overflow:hidden">
<div style="padding:8px 10px;background:var(--bg3);font-size:11px;color:var(--t4);text-transform:uppercase;letter-spacing:.5px">📋 Predložene izmjene (uncheck za preskočiti)</div>
<table style="width:100%;border-collapse:collapse;font-size:12px">
<thead><tr style="background:var(--bg2)"><th style="text-align:left;padding:6px 8px;width:160px">Polje</th><th style="text-align:left;padding:6px 8px;width:240px">Trenutno</th><th style="text-align:left;padding:6px 8px">Predloženo</th></tr></thead>
<tbody id="enrich-diff-${kind}-${id}">${rows}</tbody>
</table>
<div style="padding:8px 10px;background:var(--bg2);display:flex;gap:8px;justify-content:flex-end;flex-wrap:wrap">
<button class="btn" onclick="enrichSelectAll('${kind}',${id},true)">Označi sve</button>
<button class="btn" onclick="enrichSelectAll('${kind}',${id},false)">Poništi sve</button>
<button class="btn" onclick="document.getElementById('enrich-out-${kind}-${id}').innerHTML=''">❌ Odustani</button>
<button class="btn primary" onclick="enrichApply('${kind}',${id})">💾 SPREMI IZMJENE</button>
</div>
</div>`;
} else {
diffHtml = '<div class="empty" style="padding:14px">Nema novih predloženih dopuna iz vanjskih izvora.</div>';
}
const sourcesHtml = (r.sources||[]).map(s => `
<div style="padding:8px 10px;background:var(--bg3);border-left:3px solid var(--pgz-gold);border-radius:4px;margin-bottom:6px">
<div style="font-size:11px;color:var(--t4);text-transform:uppercase;letter-spacing:.5px">${esc(s.source||'')}</div>
${s.title ? '<div style="font-weight:700;color:var(--t0);font-size:13px">'+esc(s.title)+'</div>' : ''}
${s.extract ? '<div style="font-size:11.5px;color:var(--t1);line-height:1.5">'+esc(String(s.extract).slice(0,300))+'…</div>' : ''}
${s.url ? '<div style="margin-top:4px"><a href="'+esc(s.url)+'" target="_blank" style="font-size:11px">↗ '+esc(String(s.url).slice(0,90))+'</a></div>' : ''}
</div>`).join('');
const html = `
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:10px">
<span class="tag gr">🟢 OBOGAĆENO</span>
<span class="score ${covCls}">Coverage ${cov}%</span>
<span class="tb-s">${r.filled_fields}/${r.total_fields} polja popunjeno</span>
${lastEnr}
</div>
${diffHtml}
${sourcesHtml ? `<div style="margin:10px 0">
<div style="font-size:11px;color:var(--t4);text-transform:uppercase;letter-spacing:.5px;margin-bottom:6px">🔗 Izvori</div>
${sourcesHtml}
</div>` : ''}
<div>
<div style="font-size:11px;color:var(--t2);margin-bottom:6px">🔍 Istraži dalje:</div>
<div style="display:flex;flex-wrap:wrap;gap:6px">
${(r.research_links||[]).map(l => '<a href="'+esc(l.url)+'" target="_blank" class="btn">'+l.icon+' '+esc(l.label)+'</a>').join('')}
</div>
</div>
`;
if(target) target.innerHTML = html;
}
function enrichSelectAll(kind, id, on){
const tbody = document.getElementById('enrich-diff-'+kind+'-'+id);
if(!tbody) return;
tbody.querySelectorAll('input[type=checkbox]').forEach(cb => { cb.checked = !!on; });
}
// Reusable toast component (success / error / info / warn).
window.toast = function(msg, type, duration){
type = type || 'success';
duration = duration || 3000;
const palette = {
success: ['#1ec773', '#0b1a16'],
error: ['#ff6b6b', '#1a0b0b'],
info: ['#4a9eff', '#04132b'],
warn: ['#ffb84a', '#1a1004'],
}[type] || ['#4a9eff', '#04132b'];
const t = document.createElement('div');
t.className = 'pgz-toast pgz-toast-' + type;
t.style.cssText = 'position:fixed;right:20px;bottom:20px;'+
'background:'+palette[0]+';color:'+palette[1]+';'+
'padding:12px 18px;border-radius:8px;font-weight:700;font-size:14px;'+
'z-index:99999;box-shadow:0 6px 22px rgba(0,0,0,.45);'+
'transform:translateY(40px);opacity:0;transition:all .25s ease-out;'+
'max-width:380px;line-height:1.45;';
t.innerHTML = msg;
document.body.appendChild(t);
requestAnimationFrame(()=>{ t.style.transform='translateY(0)'; t.style.opacity='1'; });
setTimeout(()=>{
t.style.transform='translateY(40px)'; t.style.opacity='0';
setTimeout(()=>t.remove(), 280);
}, duration);
return t;
};
async function enrichApply(kind, id){
const target = document.getElementById('enrich-out-'+kind+'-'+id);
const tbody = document.getElementById('enrich-diff-'+kind+'-'+id);
const preview = (window._enrichPreviews||{})[kind+':'+id];
if(!preview){ toast('⚠ Prvo pokreni "▶ Pokreni"', 'warn'); return; }
const proposed = preview.proposed || {};
const fields = {};
if(tbody){
tbody.querySelectorAll('input[type=checkbox]:checked').forEach(cb => {
const f = cb.getAttribute('data-field');
if(f && proposed[f] !== undefined) fields[f] = proposed[f];
});
} else {
Object.assign(fields, proposed);
}
if(!Object.keys(fields).length){ toast('Označi barem jedno polje za primjenu.', 'warn'); return; }
if(target) target.innerHTML = '<div class="loading">⏳ Spremam u bazu…</div>';
try{
const r = await fetch(API+'/v2/enrich/'+kind+'/'+id+'/apply', {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({fields, sources: preview.sources || []}),
});
const data = await r.json();
if(!r.ok){ throw new Error(data.detail || ('HTTP '+r.status)); }
// Refresh the entire detail panel so the new values render
if(kind === 'klub' && typeof openKlub === 'function') await openKlub(id);
else if(kind === 'savez' && typeof openSavez === 'function') await openSavez(id);
else if(kind === 'sportas' && typeof openSportas === 'function') await openSportas(id);
setTimeout(() => enrichEntity(kind, id), 350);
const cnt = data.applied_count != null ? data.applied_count : Object.keys(data.applied||{}).length;
const fieldsList = (data.applied_fields || Object.keys(data.applied||{})).join(', ');
if(cnt){
toast('✅ Spremljeno <b>'+cnt+'</b> polja u bazu'
+ (fieldsList ? '<br><span style="opacity:.85;font-weight:500;font-size:12px">'+esc(fieldsList)+'</span>' : ''),
'success', 3500);
} else {
toast('Nema novih izmjena za spremiti.', 'info', 2500);
}
}catch(e){
console.error(e);
toast('❌ Greška pri spremanju: '+esc(e.message||String(e)), 'error', 4500);
if(target) target.innerHTML = '<div class="empty" style="color:var(--bad,#ff6b6b)">Greška pri spremanju: '+esc(e.message||String(e))+'</div>';
}
}
// Bulk enrichment — used by "Obogati sve" buttons in list views
async function enrichBulk(kind, limit, coverage_max){
limit = limit || 50; coverage_max = coverage_max || 70;
if(!confirm('Pokreni obogaćivanje za '+limit+' nasumično odabranih ('+kind+', coverage<'+coverage_max+'%)?')) return;
toast('⏳ Pokrećem bulk obogaćivanje za '+limit+' '+kind+'…', 'info', 2500);
try{
const r = await fetch(API+'/v2/enrich/bulk', {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({kind, limit, coverage_max}),
});
const data = await r.json();
if(!r.ok) throw new Error(data.detail || ('HTTP '+r.status));
toast('✅ Bulk gotov: <b>'+data.processed+'</b>/'+data.requested+' obrađeno, '+
'dodano <b>'+data.fields_total+'</b> polja u DB ('+data.elapsed_s+'s)',
'success', 5000);
// Reload the section so new values appear
if(typeof loadSection === 'function' && _state && _state.section) loadSection(_state.section);
}catch(e){
console.error(e);
toast('❌ Bulk greška: '+esc(e.message||String(e)), 'error', 5000);
}
}
function enrichBlock(kind, id){
return `
<div class="card" id="enrich-card-${kind}-${id}">
<div class="card-h">
<div class="card-t">✨ Obogati podatke</div>
<button class="btn primary" onclick="enrichEntity('${kind}',${id})">▶ Pokreni</button>
</div>
<div id="enrich-out-${kind}-${id}">
<div class="empty" style="padding:14px">Klikom na "Pokreni" platforma će pretražiti vanjske izvore (Google, Wikipedia, službene web stranice) i prikazati dopune za ovu entitetsku karticu.</div>
</div>
</div>
`;
}
function sortRows(rows, key, dir){
if(!key) return rows;
const sorted = rows.slice();
sorted.sort((a,b) => {
let av = a[key], bv = b[key];
if(av==null && bv==null) return 0;
if(av==null) return 1;
if(bv==null) return -1;
const an = typeof av === 'number' ? av : Number(av);
const bn = typeof bv === 'number' ? bv : Number(bv);
if(!isNaN(an) && !isNaN(bn) && (typeof av === 'number' || /^[-+]?\d+(\.\d+)?$/.test(String(av)))){
return dir==='asc' ? (an-bn) : (bn-an);
}
const cmp = String(av).localeCompare(String(bv), 'hr', {numeric:true});
return dir==='asc' ? cmp : -cmp;
});
return sorted;
}
function setSort(section, key){
const cur = _sort[section];
if(cur && cur.key === key){
_sort[section] = {key, dir: cur.dir==='asc' ? 'desc' : 'asc'};
} else {
_sort[section] = {key, dir: 'asc'};
}
// Re-render
switch(section){
case 'savezi': return applySaveziFilter();
case 'klubovi': return applyKluboviFilter();
case 'sportasi': return applySportasiFilter();
case 'objekti': return applyObjektiFilter();
case 'manifestacije': return applyManifFilter();
case 'financije': return refreshFinancije();
}
}
function sortHeader(section, key, label, klass){
const s = _sort[section];
const isCur = s && s.key === key;
const arrow = isCur ? (s.dir==='asc' ? ' ▲' : ' ▼') : '';
const cls = (klass||'') + ' sortable';
return '<th class="'+cls+'" onclick="setSort(\''+section+'\',\''+key+'\')" style="cursor:pointer;user-select:none">'+esc(label)+'<span style="color:var(--pgz-gold);font-weight:700">'+arrow+'</span></th>';
}
function debounce(fn, ms){
let t;
return function(){
const args = arguments;
clearTimeout(t);
t = setTimeout(()=>fn.apply(null, args), ms);
};
}
//=========== PANEL ===========
function openPanel(title, html){
const t = $('#panel-hdr-t');
if(t) t.textContent = title || 'Detalji';
const b = $('#panel-body');
if(b) b.innerHTML = html;
$('#panel').classList.add('open');
$('#panel-overlay').classList.add('open');
}
function closePanel(){
$('#panel').classList.remove('open');
$('#panel-overlay').classList.remove('open');
}
document.addEventListener('keydown', e => { if(e.key==='Escape') closePanel(); });
//=========== NAVIGATION ===========
function buildNav(){
const nav = $('#nav');
nav.innerHTML = NAV_ITEMS.map(n => '<div class="nav-i '+(n.id===_state.section?'active':'')+'" data-id="'+n.id+'" data-label="'+n.label+'" onclick="navTo(\''+n.id+'\')"><span class="ic">'+n.ic+'</span><span class="lbl">'+n.label+'</span></div>').join('');
}
// Hashchange handler — accept routing from unified shared sidebar
window.addEventListener('hashchange', () => {
const h = (location.hash||'').replace(/^#/,'');
if(h && NAV_ITEMS.some(n => n.id===h)) navTo(h);
});
function toggleSidebar(){
const sb = document.getElementById('sb');
const tg = document.getElementById('sb-toggle');
if(!sb) return;
const isCollapsed = sb.classList.toggle('collapsed');
if(tg) tg.textContent = '≡';
try { localStorage.setItem('sidebar-state', isCollapsed ? 'collapsed' : 'expanded'); } catch(e){}
}
function restoreSidebar(){
try {
const s = localStorage.getItem('sidebar-state');
if(s === 'collapsed'){
const sb = document.getElementById('sb');
const tg = document.getElementById('sb-toggle');
if(sb) sb.classList.add('collapsed');
if(tg) tg.textContent = '≡';
}
} catch(e){}
}
function navTo(id){
_state.section = id;
$$('.nav-i').forEach(el => el.classList.toggle('active', el.dataset.id===id));
$$('.section').forEach(el => el.classList.remove('active'));
const sec = $('#pg-'+id);
if(sec) sec.classList.add('active');
const t = SECTION_TITLES[id] || [id, ''];
$('#tb-t').textContent = t[0];
$('#tb-s').textContent = t[1];
loadSection(id);
}
function loadSection(id){
switch(id){
case 'dashboard': return loadDash();
case 'savezi': return loadSavezi();
case 'klubovi': return loadKlubovi();
case 'sportasi': return loadSportasi();
case 'financije': return loadFinancije();
case 'objekti': return loadObjekti();
case 'manifestacije': return loadManifestacije();
case 'mreza': return loadMreza();
case 'forenzika': return loadForenzika();
case 'audit': return loadAudit();
}
}
//=========== AUDIT LOG (Polygon PoS) ===========
async function loadAudit(){
const root = $('#pg-audit');
root.innerHTML = '<div class="loading">Učitavanje audit zapisa…</div>';
let r;
try{
const resp = await fetch(API+'/audit/seal/list?limit=100');
if(!resp.ok) throw new Error('HTTP '+resp.status);
r = await resp.json();
}catch(e){
root.innerHTML = '<div class="empty">Greška: '+esc(e.message||String(e))+'</div>';
return;
}
const wallet = r.wallet || '';
const live = r.live ? '<span class="tag gr">🟢 LIVE Polygon</span>' : '<span class="tag gd">⏳ Pending mode</span>';
const rows = (r.rows||[]).map(s => {
const tx = s.tx_hash || '';
const isPending = tx.startsWith('pending:');
const txCell = isPending
? `<span class="tag gd" title="${esc(tx)}">PENDING</span><span class="tb-s" style="margin-left:6px;font-family:monospace">${esc(tx.slice(0,32))}…</span>`
: `<a href="${esc(s.polygonscan_url||'#')}" target="_blank" style="font-family:monospace;font-size:11px">${esc(tx.slice(0,18))}${esc(tx.slice(-6))}</a>`;
const statusCls = s.status==='confirmed'?'gr':(s.status==='broadcast'?'gd':(s.status==='failed'?'rd':''));
return `<tr>
<td class="num" style="font-size:11px;color:var(--t3)">${s.id}</td>
<td style="font-size:11px;color:var(--t3)">${esc((s.created_at||'').replace('T',' ').slice(0,19))}</td>
<td><b>${esc(s.action)}</b></td>
<td style="font-size:11px">${esc(s.ref_type||'')} <span style="color:var(--t3)">${esc(s.ref_id||'')}</span></td>
<td style="font-family:monospace;font-size:10.5px;color:var(--t2)" title="${esc(s.data_hash||'')}">${esc((s.data_hash||'').slice(0,12))}…</td>
<td>${txCell}</td>
<td><span class="tag ${statusCls}">${esc(s.status||'')}</span></td>
<td style="font-size:11px">${esc(s.user_email||'')}</td>
</tr>`;
}).join('');
root.innerHTML = `
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:14px">
<div class="card" style="flex:1;min-width:280px">
<div class="card-h"><div class="card-t">🔐 Polygon PoS Audit Sealing</div></div>
<div style="padding:12px;font-size:13px;color:var(--t1);line-height:1.6">
Ključne akcije (odobrenje sufinanciranja, isplata, validacija liječničkog pregleda, izmjena članstva)
pečate se 0-MATIC self-tx-om s SHA-256 hash-em payload-a u <code>data</code> polju.
Svaki zapis je nepromjenjiv i provjerljiv preko polygonscan.com.
</div>
<div style="padding:0 12px 12px;font-size:12px">
<div><b>Wallet:</b> <a href="https://polygonscan.com/address/${esc(wallet)}" target="_blank" style="font-family:monospace">${esc(wallet)}</a></div>
<div><b>Chain:</b> Polygon PoS (137)</div>
<div><b>Mode:</b> ${live}</div>
</div>
</div>
<div class="card" style="min-width:200px">
<div class="card-h"><div class="card-t">📊 Statistika</div></div>
<div style="padding:12px;font-size:14px">
<div><b>${r.count||0}</b> sealed zapisa</div>
<div style="color:var(--t3);font-size:12px;margin-top:4px">Najnoviji prikazani prvi</div>
</div>
</div>
</div>
<div id="enrich-worker-card"></div>
${rows ? `
<div class="card">
<div class="card-h"><div class="card-t">📜 Audit zapisi</div></div>
<div class="tbl-wrap"><table>
<thead><tr>
<th class="num">#</th><th>Vrijeme</th><th>Akcija</th><th>Referenca</th>
<th>SHA-256</th><th>Polygon TX</th><th>Status</th><th>Korisnik</th>
</tr></thead>
<tbody>${rows}</tbody>
</table></div>
</div>` : '<div class="empty">Nema audit zapisa.</div>'}
`;
loadEnrichWorker();
}
// ─── 24/7 enrichment worker dashboard ───────────────────────────────
let _enrichWorkerTimer = null;
async function loadEnrichWorker(){
const card = document.getElementById('enrich-worker-card');
if(!card) return;
let s;
try{
const resp = await fetch(API+'/v2/enrich/worker/status');
if(!resp.ok) throw new Error('HTTP '+resp.status);
s = await resp.json();
}catch(e){
card.innerHTML = '<div class="card"><div class="card-h"><div class="card-t">🤖 Enrichment Worker</div></div><div class="empty">Status nedostupan: '+esc(e.message||String(e))+'</div></div>';
return;
}
const conf = s.confidence_threshold != null ? s.confidence_threshold : 0.7;
const hbAge = s.heartbeat_age_s;
const hbBadge = hbAge == null
? '<span class="tag gd">⏳ Pokreće se…</span>'
: (hbAge < 120 ? '<span class="tag gr">🟢 AKTIVAN</span>' : '<span class="tag rd">🔴 ZAUSTAVLJEN</span>');
const pauseBtn = s.paused
? '<button class="btn primary" onclick="setWorkerPause(false)">▶️ Nastavi</button>'
: '<button class="btn" onclick="setWorkerPause(true)">⏸ Pauziraj</button>';
const lc = s.last_cycle || {};
const recent = (s.recent || []).slice(0, 8);
const recentRows = recent.map(r => `
<tr>
<td style="font-size:11px;color:var(--t3)">${esc((r.created_at||'').replace('T',' ').slice(0,19))}</td>
<td><b>${esc(r.kind||'')}</b> #${r.target_id}</td>
<td style="font-size:11px;color:var(--t2)">${esc((r.fields_set||[]).join(', '))}</td>
<td style="font-size:11px;color:var(--t3)">${esc(r.source||'')}</td>
</tr>`).join('');
card.innerHTML = `
<div class="card" style="margin-bottom:14px">
<div class="card-h" style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
<div class="card-t">🤖 Enrichment Worker</div>
${hbBadge}
${s.paused ? '<span class="tag gd">PAUZIRAN</span>' : ''}
<span class="tb-s">${hbAge != null ? 'Zadnji heartbeat: '+hbAge+'s' : ''}</span>
<span style="margin-left:auto;display:flex;gap:6px;flex-wrap:wrap">
<button class="btn primary" onclick="runWorkerNow()">▶ Pokreni odmah</button>
${pauseBtn}
</span>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px;padding:12px">
<div><div class="tb-s">Zadnji ciklus</div><b>${lc.fields_total != null ? lc.fields_total + ' polja' : '—'}</b>
<div class="tb-s">${lc.elapsed_s ? lc.elapsed_s + ' s' : ''}</div></div>
<div><div class="tb-s">Sportasi / Klubovi / Savezi</div><b>${(lc.sportas||0)+' / '+(lc.klub||0)+' / '+(lc.savez||0)}</b></div>
<div><div class="tb-s">Polja zadnjih 24h</div><b>${s.fields_24h||0}</b></div>
<div>
<div class="tb-s">Confidence prag: <b id="conf-val">${(conf).toFixed(2)}</b></div>
<input type="range" id="conf-slider" min="0" max="1" step="0.05" value="${conf}"
oninput="document.getElementById('conf-val').textContent=parseFloat(this.value).toFixed(2)"
onchange="setWorkerConfidence(parseFloat(this.value))" style="width:100%">
</div>
</div>
${recentRows ? `
<div style="border-top:1px solid var(--ln)">
<div style="padding:8px 12px;font-size:11px;color:var(--t4);text-transform:uppercase;letter-spacing:.5px">📋 Posljednji enrich-write zapisi</div>
<div class="tbl-wrap" style="max-height:240px;overflow:auto"><table style="width:100%">
<thead><tr><th>Vrijeme</th><th>Cilj</th><th>Polja</th><th>Izvor</th></tr></thead>
<tbody>${recentRows}</tbody>
</table></div>
</div>` : ''}
</div>
`;
// Auto-refresh every 10s while audit page is open
if(_enrichWorkerTimer) clearTimeout(_enrichWorkerTimer);
_enrichWorkerTimer = setTimeout(()=>{
if(_state.section === 'audit') loadEnrichWorker();
}, 10000);
}
async function setWorkerPause(paused){
try{
const r = await fetch(API+'/v2/enrich/worker/pause', {method:'POST',
headers:{'Content-Type':'application/json'}, body: JSON.stringify({paused})});
if(!r.ok) throw new Error('HTTP '+r.status);
toast(paused ? '⏸ Worker pauziran' : '▶️ Worker nastavlja', 'info', 2500);
loadEnrichWorker();
}catch(e){ toast('❌ '+(e.message||String(e)), 'error'); }
}
async function runWorkerNow(){
try{
const r = await fetch(API+'/v2/enrich/worker/run-now', {method:'POST'});
if(!r.ok) throw new Error('HTTP '+r.status);
toast('▶ Worker će pokrenuti novi ciklus odmah', 'info', 2500);
setTimeout(loadEnrichWorker, 2000);
}catch(e){ toast('❌ '+(e.message||String(e)), 'error'); }
}
async function setWorkerConfidence(value){
try{
const r = await fetch(API+'/v2/enrich/worker/confidence', {method:'POST',
headers:{'Content-Type':'application/json'}, body: JSON.stringify({value})});
if(!r.ok) throw new Error('HTTP '+r.status);
toast('🎚 Confidence prag: '+value.toFixed(2), 'info', 2000);
}catch(e){ toast('❌ '+(e.message||String(e)), 'error'); }
}
//=========== DASHBOARD ===========
async function loadDash(){
const root = $('#pg-dashboard');
root.innerHTML = '<div class="loading">Učitavanje dashboard podataka…</div>';
const d = await api('/dashboard');
if(!d){ root.innerHTML='<div class="empty">Greška pri dohvatu podataka</div>'; return; }
_cache.dash = d;
const proracun2026 = (d.proracun_trend||[]).filter(x=>x.godina===2026)[0];
const total2026 = proracun2026 ? proracun2026.ukupno : d.proracun_aktualni;
root.innerHTML = `
<div class="kpi-grid">
<div class="kpi"><div class="kpi-l">Saveza</div><div class="kpi-v">${fmtNum(d.aktivnih_saveza)}</div><div class="kpi-s">aktivnih</div></div>
<div class="kpi b"><div class="kpi-l">Klubova</div><div class="kpi-v">${fmtNum(d.aktivnih_klubova)}</div><div class="kpi-s">${d.nositelja_kvalitete||0} nositelja kvalitete</div></div>
<div class="kpi g"><div class="kpi-l">Sportaša</div><div class="kpi-v">${fmtNum(d.aktivnih_clanova)}</div><div class="kpi-s">${d.reprezentativaca||0} reprezentativaca</div></div>
<div class="kpi"><div class="kpi-l">Proračun ${proracun2026?proracun2026.godina:2026}</div><div class="kpi-v">${fmtEur(total2026)}</div><div class="kpi-s">javne potrebe u sportu</div></div>
<div class="kpi r"><div class="kpi-l">Kritičnih alarma</div><div class="kpi-v">${fmtNum(d.critical_alerts)}</div><div class="kpi-s">${d.warning_alerts||0} upozorenja</div></div>
</div>
<div class="row-2">
<div class="card">
<div class="card-h"><div class="card-t">📈 Proračun za sport po godinama</div><div class="tb-s">Klikni godinu za detalje (PDF dokumenti)</div></div>
<div class="chart-box"><canvas id="chProracun"></canvas></div>
</div>
<div class="card">
<div class="card-h"><div class="card-t">🏅 Top savezi po broju registriranih</div><div class="tb-s">Klikni za drill-down</div></div>
<div style="overflow-x:auto;max-height:340px;overflow-y:auto"><table>
<thead><tr><th>Savez</th><th class="num">Klubova</th><th class="num">Reg.</th><th class="num">Trenera</th><th class="num">Repr.</th></tr></thead>
<tbody>${(d.top_savezi||[]).slice(0,10).map(s => `
<tr onclick="openSavezByName('${esc(s.naziv).replace(/&#39;/g,"\\'")}')">
<td><b>${esc(s.naziv)}</b></td>
<td class="num">${fmtNum(s.klubova_clanica)}</td>
<td class="num">${fmtNum(s.registriranih)}</td>
<td class="num">${fmtNum(s.trenera)}</td>
<td class="num">${fmtNum(s.reprezentativaca)}</td>
</tr>`).join('')}</tbody>
</table></div>
</div>
</div>
<div class="card" style="margin-top:14px">
<div class="card-h">
<div class="card-t">💰 Najveći primatelji javnih potreba</div>
<div style="display:flex;gap:8px;align-items:center">
<select id="dash-god" onchange="refreshDashNositelji()" style="background:var(--bg2);border:1px solid var(--rim);border-radius:5px;padding:6px 10px;color:var(--t1);font-size:12px">
<option value="2026">2026</option>
<option value="2025" selected>2025</option>
<option value="2024">2024</option>
</select>
<span class="tb-s" id="dash-nos-cnt"></span>
</div>
</div>
<div id="dash-nos-out"><div class="loading">Učitavanje…</div></div>
</div>
`;
drawProracunChart(d.proracun_trend || []);
refreshDashNositelji();
}
async function refreshDashNositelji(){
const sel = $('#dash-god');
if(!sel) return;
const god = sel.value;
const out = $('#dash-nos-out');
out.innerHTML = '<div class="loading">Učitavanje primatelja '+god+'…</div>';
const d = await api('/v2/potpore/by-year?godina='+god);
if(!d){ out.innerHTML='<div class="empty">Greška pri dohvatu</div>'; return; }
const rows = (d.results || []).slice().sort((a,b)=>Number(b.iznos_eur||0)-Number(a.iznos_eur||0)).slice(0, 25);
$('#dash-nos-cnt').textContent = rows.length+' / '+(d.count||0)+' · ukupno '+fmtEur(d.total||0);
out.innerHTML = `<div style="overflow-x:auto"><table>
<thead><tr><th>#</th><th>Korisnik</th><th>Sport</th><th>Vrsta</th><th class="num">Iznos</th><th>PDF</th></tr></thead>
<tbody>${rows.map((r,i) => `
<tr onclick='openPrimateljDetail(${JSON.stringify(r).replace(/'/g,"&#39;")})'>
<td>${i+1}</td>
<td><b>${esc(r.korisnik)}</b></td>
<td>${txt(r.sport)}</td>
<td>${txt(r.vrsta)}</td>
<td class="num"><b>${fmtEurFull(r.iznos_eur)}</b></td>
<td>${r.source_url?'<a href="'+esc(r.source_url)+'" target="_blank" onclick="event.stopPropagation()">📄 PDF</a>':'—'}</td>
</tr>`).join('')}</tbody>
</table></div>`;
}
async function openSavezByName(name){
const list = _cache.savezi || (await api('/savezi?limit=250'))?.rows || [];
if(!_cache.savezi) _cache.savezi = list;
const target = list.find(s => s.naziv === name) || list.find(s => (s.naziv||'').toLowerCase() === name.toLowerCase());
if(target) return openSavez(target.id);
// Try fuzzy match by first two words
const prefix = name.split(' ').slice(0,2).join(' ').toLowerCase();
const fuzzy = list.find(s => (s.naziv||'').toLowerCase().includes(prefix));
if(fuzzy) return openSavez(fuzzy.id);
openPanel('Savez', '<div class="empty">Savez <b>'+esc(name)+'</b> nije pronađen u bazi.</div>');
}
async function openPrimateljDetail(r){
openPanel('Primatelj', '<div class="loading">Učitavanje detalja…</div>');
// Try to find matching klub by name
let klub = null;
if(_cache.klubovi){
const lower = (r.korisnik||'').toLowerCase();
klub = _cache.klubovi.find(k => (k.klub||'').toLowerCase() === lower);
if(!klub) klub = _cache.klubovi.find(k => (k.klub||'').toLowerCase().includes(lower) || lower.includes((k.klub||'').toLowerCase()));
}
if(klub){
return openKlub(klub.id);
}
// Fallback panel — show row data + PDF + same-year other primatelji from same source
const html = `
<div class="card-h" style="border:0;padding:0;margin-bottom:14px">
<div>
<div style="font-size:18px;font-weight:800;color:var(--t0)">${esc(r.korisnik)}</div>
<div style="font-size:12px;color:var(--t2);margin-top:4px">Primatelj proračuna ${r.godina}</div>
</div>
</div>
<div class="card">
<div class="card-h"><div class="card-t">📋 Detalji isplate</div></div>
<div class="kv">
<div class="k">Korisnik</div><div class="v">${esc(r.korisnik)}</div>
<div class="k">Sport</div><div class="v">${txt(r.sport)}</div>
<div class="k">Vrsta</div><div class="v">${txt(r.vrsta)}</div>
<div class="k">Iznos</div><div class="v"><b style="color:var(--pgz-gold);font-size:16px">${fmtEurFull(r.iznos_eur)}</b></div>
<div class="k">Godina</div><div class="v">${esc(r.godina)}</div>
<div class="k">Izvor</div><div class="v">${txt(r.izvor)}</div>
<div class="k">Napomena</div><div class="v">${txt(r.napomena)}</div>
</div>
${r.source_url ? '<div style="margin-top:14px;text-align:center"><a href="'+esc(r.source_url)+'" target="_blank" class="btn primary" style="display:inline-block;text-decoration:none">📄 Otvori izvorni PDF dokument</a></div>' : '<div class="empty" style="margin-top:14px">PDF dokument nije dostupan za ovu stavku.</div>'}
</div>
<div class="card">
<div class="card-h"><div class="card-t">🔍 Klub nije povezan</div></div>
<div style="font-size:12px;color:var(--t2);line-height:1.5">
Korisnik <b>${esc(r.korisnik)}</b> nije automatski povezan s pojedinim klubom u bazi.
Provjeri klub ručno preko sekcije <a href="javascript:navTo('klubovi')">Klubovi</a> ili pretraži primatelja na izvornom dokumentu.
</div>
</div>
`;
openPanel('Primatelj · '+r.korisnik, html);
}
function drawProracunChart(trend){
const ctx = $('#chProracun');
if(!ctx) return;
if(_proracunChart){ _proracunChart.destroy(); _proracunChart=null; }
_proracunChart = new Chart(ctx, {
type:'bar',
data:{
labels: trend.map(x=>x.godina),
datasets:[{
label:'EUR',
data: trend.map(x=>x.ukupno),
backgroundColor: trend.map(x=> x.godina===2026 ? '#F4C430' : '#004CC4'),
borderRadius:4
}]
},
options:{
responsive:true, maintainAspectRatio:false,
plugins:{
legend:{display:false},
tooltip:{callbacks:{label:c => '€'+Number(c.parsed.y).toLocaleString('hr-HR')}}
},
scales:{
x:{ticks:{color:'#8a95b4'}, grid:{display:false}},
y:{ticks:{color:'#8a95b4', callback:v=>'€'+(v/1000)+'k'}, grid:{color:'#1e2a50'}}
},
onClick:(e, els) => {
if(els && els.length){
const i = els[0].index;
openProracunDrill(trend[i].godina, trend[i].ukupno);
}
}
}
});
}
async function openProracunDrill(godina, total){
openPanel('Proračun '+godina, '<div class="loading">Dohvaćanje primatelja…</div>');
const d = await api('/v2/potpore/by-year?godina='+godina);
if(!d){ openPanel('Proračun '+godina, '<div class="empty">Greška pri dohvatu</div>'); return; }
const rows = d.results || [];
const grandTotal = d.total || total || 0;
const html = `
<div class="kpi-grid" style="grid-template-columns:1fr 1fr">
<div class="kpi"><div class="kpi-l">Ukupno ${godina}</div><div class="kpi-v">${fmtEur(grandTotal)}</div></div>
<div class="kpi b"><div class="kpi-l">Primatelja</div><div class="kpi-v">${rows.length}</div></div>
</div>
<div style="overflow-x:auto"><table>
<thead><tr><th>#</th><th>Korisnik</th><th>Sport</th><th class="num">Iznos</th><th>PDF</th></tr></thead>
<tbody>
${rows.map((r,i) => `
<tr class="no-click">
<td>${i+1}</td>
<td>${esc(r.korisnik)}</td>
<td>${txt(r.sport)}</td>
<td class="num"><b>${fmtEurFull(r.iznos_eur)}</b></td>
<td>${r.source_url ? '<a href="'+esc(r.source_url)+'" target="_blank">📄</a>' : '—'}</td>
</tr>`).join('')}
</tbody>
</table></div>
`;
openPanel('Primatelji proračuna · '+godina, html);
}
//=========== SAVEZI ===========
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');
if(!d){ root.innerHTML='<div class="empty">Greška pri dohvatu</div>'; return; }
_cache.savezi = d.rows || [];
}
renderSaveziShell();
applySaveziFilter();
}
function renderSaveziShell(){
const root = $('#pg-savezi');
root.innerHTML = `
<div class="toolbar">
<input type="search" id="sav-q" placeholder="🔍 Pretraži savez…">
<select id="sav-pgz">
<option value="">Svi savezi</option>
<option value="1">Samo PGŽ relevantni</option>
</select>
<div class="toggle">
<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>
<span class="tb-s" id="sav-cnt"></span>
</div>
<div id="sav-out"></div>
`;
$('#sav-q').addEventListener('input', debounce(applySaveziFilter, 200));
$('#sav-pgz').addEventListener('change', applySaveziFilter);
}
function setSaveziView(v){
_state.viewSavezi = v;
$('#sav-card').classList.toggle('active', v==='card');
$('#sav-table').classList.toggle('active', v==='table');
applySaveziFilter();
}
function applySaveziFilter(){
const q = (($('#sav-q')?$('#sav-q').value:'') || '').toLowerCase().trim();
const pgz = $('#sav-pgz') ? $('#sav-pgz').value : '';
let rows = _cache.savezi || [];
if(q) rows = rows.filter(s => (s.naziv||'').toLowerCase().includes(q) || (s.sport||'').toLowerCase().includes(q));
if(pgz==='1') rows = rows.filter(s => s.pgz_relevant);
if(_sort.savezi) rows = sortRows(rows, _sort.savezi.key, _sort.savezi.dir);
$('#sav-cnt').textContent = rows.length+' saveza';
$('#sav-out').innerHTML = _state.viewSavezi==='card' ? renderSaveziGrid(rows) : renderSaveziTable(rows);
}
function renderSaveziGrid(rows){
if(!rows.length) return '<div class="empty">Nema rezultata</div>';
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="es">${txt(s.sport,'—')} · ${txt(s.predsjednik,'bez predsjednika')}</div>
<div class="em">
<span><b>${fmtNum(s.broj_klubova)}</b> klubova</span>
<span><b>${fmtNum(s.reg_2024)}</b> reg.</span>
<span><b>${fmtNum(s.treneri_2024)}</b> trenera</span>
</div>
</div>`).join('')+'</div>';
}
function renderSaveziTable(rows){
if(!rows.length) return '<div class="empty">Nema rezultata</div>';
return `<div class="card" style="padding:0;overflow-x:auto"><table>
<thead><tr>${sortHeader('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>${txt(s.sport)}</td>
<td>${txt(s.predsjednik)}</td>
<td>${s.email?'<span class="tag b">'+esc(s.email)+'</span>':'—'}</td>
<td class="num">${fmtNum(s.broj_klubova)}</td>
<td class="num">${fmtNum(s.reg_2024)}</td>
<td>${s.pgz_relevant?'<span class="tag gd">PGŽ</span>':'—'}</td>
</tr>`).join('')}</tbody>
</table></div>`;
}
async function openSavez(id){
openPanel('Savez', '<div class="loading">Učitavanje saveza…</div>');
const s = await api('/savezi/'+id);
if(!s || s.detail){
openPanel('Savez', '<div class="empty">Savez nije pronađen</div>');
return;
}
const ksearch = (s.naziv||'').split(' ').slice(0,2).join(' ');
const kr = await api('/klubovi?savez='+encodeURIComponent(ksearch)+'&limit=200');
const klubovi = (kr && kr.rows) ? kr.rows.filter(k => (k.savez||'').toLowerCase().includes(ksearch.toLowerCase())) : [];
const html = `
<div class="card-h" style="border:0;padding:0;margin-bottom:14px">
<div>
<div style="font-size:18px;font-weight:800;color:var(--t0)">${esc(s.naziv)}</div>
<div style="font-size:12px;color:var(--t2);margin-top:4px">${txt(s.sport,'—')} · ${s.region||'PGŽ'}</div>
</div>
</div>
<div class="kpi-grid" style="grid-template-columns:repeat(3,1fr);margin-bottom:14px">
<div class="kpi"><div class="kpi-l">Klubova</div><div class="kpi-v">${klubovi.length}</div></div>
<div class="kpi b"><div class="kpi-l">Godina osnutka</div><div class="kpi-v" style="font-size:18px">${txt(s.godina_osnutka)}</div></div>
<div class="kpi g"><div class="kpi-l">Status</div><div class="kpi-v" style="font-size:14px">${s.aktivan?'AKTIVAN':'NEAKTIVAN'}</div></div>
</div>
<div class="card">
<div class="card-h"><div class="card-t">📋 Osnovne informacije</div></div>
<div class="kv">
<div class="k">OIB</div><div class="v">${txt(s.oib)}</div>
<div class="k">Adresa</div><div class="v">${txt(s.adresa)}</div>
<div class="k">Predsjednik</div><div class="v">${txt(s.predsjednik)}</div>
<div class="k">Tajnik</div><div class="v">${txt(s.tajnik)}</div>
<div class="k">Email</div><div class="v">${s.email?'<a href="mailto:'+esc(s.email)+'">'+esc(s.email)+'</a>':'—'}</div>
<div class="k">Telefon</div><div class="v">${txt(s.telefon)}</div>
<div class="k">Web</div><div class="v">${s.web?'<a href="'+esc(s.web)+'" target="_blank">'+esc(s.web)+'</a>':'—'}</div>
<div class="k">IBAN</div><div class="v">${txt(s.iban)}</div>
</div>
</div>
<div class="card">
<div class="card-h"><div class="card-t">⬡ Klubovi članice (${klubovi.length})</div></div>
${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)">
<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>
</tr>`).join('')}
</tbody>
</table></div>` : '<div class="empty">Nema podataka o klubovima</div>'}
</div>
${enrichBlock('savez', s.id)}
`;
openPanel('Savez · '+s.naziv, html);
}
//=========== KLUBOVI ===========
async function loadKlubovi(){
const root = $('#pg-klubovi');
if(!_cache.klubovi){
root.innerHTML = '<div class="loading">Učitavanje klubova…</div>';
const d = await api('/klubovi?limit=500');
if(!d){ root.innerHTML='<div class="empty">Greška pri dohvatu</div>'; return; }
_cache.klubovi = d.rows || [];
}
renderKluboviShell();
applyKluboviFilter();
}
function renderKluboviShell(){
const root = $('#pg-klubovi');
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 = `
<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>
<select id="kl-grad"><option value="">Svi gradovi</option>${grads.map(g=>'<option value="'+esc(g)+'">'+esc(g)+'</option>').join('')}</select>
<label><input type="checkbox" id="kl-nk"> Nositelj kvalitete</label>
<div class="toggle">
<button id="kl-card" class="${_state.viewKlubovi==='card'?'active':''}" onclick="setKluboviView('card')">Kartice</button>
<button id="kl-table" class="${_state.viewKlubovi==='table'?'active':''}" onclick="setKluboviView('table')">Tablica</button>
</div>
<button class="btn" style="margin-left:auto" onclick="enrichBulk('klub', 50, 70)">✨ Obogati sve (50)</button>
<span class="tb-s" id="kl-cnt"></span>
</div>
<div id="kl-out"></div>
`;
$('#kl-q').addEventListener('input', debounce(applyKluboviFilter, 200));
$('#kl-sport').addEventListener('change', applyKluboviFilter);
$('#kl-grad').addEventListener('change', applyKluboviFilter);
$('#kl-nk').addEventListener('change', applyKluboviFilter);
}
function setKluboviView(v){
_state.viewKlubovi = v;
$('#kl-card').classList.toggle('active', v==='card');
$('#kl-table').classList.toggle('active', v==='table');
applyKluboviFilter();
}
function applyKluboviFilter(){
const q = (($('#kl-q')?$('#kl-q').value:'') || '').toLowerCase().trim();
const sport = $('#kl-sport') ? $('#kl-sport').value : '';
const grad = $('#kl-grad') ? $('#kl-grad').value : '';
const nk = $('#kl-nk') ? $('#kl-nk').checked : false;
let rows = _cache.klubovi || [];
if(q) rows = rows.filter(k => (k.klub||'').toLowerCase().includes(q) || (k.sport||'').toLowerCase().includes(q));
if(sport) rows = rows.filter(k => k.sport===sport);
if(grad) rows = rows.filter(k => k.grad===grad);
if(nk) rows = rows.filter(k => k.nositelj_kvalitete);
if(_sort.klubovi) rows = sortRows(rows, _sort.klubovi.key, _sort.klubovi.dir);
$('#kl-cnt').textContent = rows.length+' klubova';
const top = rows.slice(0, 300);
$('#kl-out').innerHTML = _state.viewKlubovi==='card' ? renderKluboviGrid(top) : renderKluboviTable(top);
if(rows.length>300){
$('#kl-out').insertAdjacentHTML('beforeend', '<div class="empty">… i još '+(rows.length-300)+' klubova. Suzite filtre.</div>');
}
}
function renderKluboviGrid(rows){
if(!rows.length) return '<div class="empty">Nema rezultata</div>';
return '<div class="grid-club">'+rows.map(k => `
<div class="entity" onclick="openKlub(${k.id})">
${k.nositelj_kvalitete?'<div class="et-tag">N.K.</div>':''}
<div class="et">${esc(k.klub||k.sport||'(bez naziva)')}</div>
<div class="es">${txt(k.razina,'')} · ${txt(k.grad,'—')}</div>
<div class="em">
<span><b>${fmtNum(k.registriranih)}</b> reg.</span>
<span><b>${fmtNum(k.trenera)}</b> trenera</span>
<span><b>${fmtNum(k.reprezentativaca)}</b> repr.</span>
</div>
</div>`).join('')+'</div>';
}
function renderKluboviTable(rows){
if(!rows.length) return '<div class="empty">Nema rezultata</div>';
return `<div class="card" style="padding:0;overflow-x:auto"><table>
<thead><tr>${sortHeader('klubovi','klub','Klub','')}${sortHeader('klubovi','sport','Sport','')}${sortHeader('klubovi','razina','Razina','')}${sortHeader('klubovi','grad','Grad','')}${sortHeader('klubovi','registriranih','Reg.','num')}${sortHeader('klubovi','trenera','Trenera','num')}${sortHeader('klubovi','nositelj_kvalitete','Status','')}</tr></thead>
<tbody>${rows.map(k => `
<tr onclick="openKlub(${k.id})">
<td><b>${esc(k.klub||k.sport||'(bez naziva)')}</b></td>
<td>${txt(k.sport)}</td>
<td>${txt(k.razina)}</td>
<td>${txt(k.grad)}</td>
<td class="num">${fmtNum(k.registriranih)}</td>
<td class="num">${fmtNum(k.trenera)}</td>
<td>${k.nositelj_kvalitete?'<span class="tag gd">N.K.</span>':''}${k.aktivan?'<span class="tag gr">AKT</span>':'<span class="tag rd">NK</span>'}</td>
</tr>`).join('')}</tbody>
</table></div>`;
}
async function openKlub(id){
openPanel('Klub', '<div class="loading">Učitavanje kluba…</div>');
const k = await api('/klubovi/'+id);
if(!k || k.detail){
openPanel('Klub', '<div class="empty">Klub nije pronađen</div>');
return;
}
const stats = k.stats || {};
const clanovi = k.clanovi || [];
const potpore = k.potpore || [];
let scoreCount = 0;
if(k.oib) scoreCount++;
if(k.predsjednik) scoreCount++;
if(k.tajnik) scoreCount++;
if(k.email) scoreCount++;
if(k.web || k.web_stranica) scoreCount++;
if(k.sjediste || k.adresa) scoreCount++;
if(k.ciljevi) scoreCount++;
if(k.opis_djelatnosti) scoreCount++;
const scoreClass = scoreCount>=6?'high':(scoreCount>=4?'mid':'low');
const html = `
<div class="card-h" style="border:0;padding:0;margin-bottom:14px">
<div>
<div style="font-size:18px;font-weight:800;color:var(--t0)">${esc(k.naziv||'(bez naziva)')}</div>
<div style="font-size:12px;color:var(--t2);margin-top:4px">${txt(k.sport,'—')} · ${txt(k.razina,'')} · ${txt(k.grad,'')}</div>
</div>
</div>
<div class="kpi-grid" style="grid-template-columns:repeat(4,1fr);margin-bottom:14px">
<div class="kpi"><div class="kpi-l">Sportaša</div><div class="kpi-v">${fmtNum(stats.broj_clanova||clanovi.length)}</div></div>
<div class="kpi b"><div class="kpi-l">Registriranih</div><div class="kpi-v">${fmtNum(stats.broj_registriranih)}</div></div>
<div class="kpi g"><div class="kpi-l">Trenera</div><div class="kpi-v">${fmtNum(stats.broj_trenera)}</div></div>
<div class="kpi"><div class="kpi-l">Score baze</div><div class="kpi-v" style="font-size:18px"><span class="score ${scoreClass}">${scoreCount}/8</span></div></div>
</div>
<div class="tabs">
<div class="tab active" onclick="switchKlubTab(this,'k-info')">Info</div>
<div class="tab" onclick="switchKlubTab(this,'k-clan')">Sportaši (${clanovi.length})</div>
<div class="tab" onclick="switchKlubTab(this,'k-pot')">Potpore (${potpore.length})</div>
</div>
<div id="k-info" class="ktab">
<div class="kv">
<div class="k">Naziv</div><div class="v">${esc(k.naziv||'')}</div>
<div class="k">OIB</div><div class="v">${txt(k.oib)}</div>
<div class="k">Sport</div><div class="v">${txt(k.sport)}</div>
<div class="k">Razina</div><div class="v">${txt(k.razina)}</div>
<div class="k">Savez</div><div class="v">${txt(k.savez_naziv)}</div>
<div class="k">Predsjednik</div><div class="v">${txt(k.predsjednik)}</div>
<div class="k">Tajnik</div><div class="v">${txt(k.tajnik)}</div>
<div class="k">Trener</div><div class="v">${txt(k.trener_glavni)}</div>
<div class="k">Sjedište</div><div class="v">${txt(k.sjediste||k.adresa)}</div>
<div class="k">Grad</div><div class="v">${txt(k.grad)}</div>
<div class="k">Email</div><div class="v">${k.email?'<a href="mailto:'+esc(k.email)+'">'+esc(k.email)+'</a>':'—'}</div>
<div class="k">Telefon</div><div class="v">${txt(k.telefon)}</div>
<div class="k">Web</div><div class="v">${(k.web||k.web_stranica)?'<a href="'+esc(k.web||k.web_stranica)+'" target="_blank">'+esc(k.web||k.web_stranica)+'</a>':'—'}</div>
<div class="k">Osnovan</div><div class="v">${txt(k.godina_osnutka)}</div>
<div class="k">Nositelj kvalitete</div><div class="v">${k.nositelj_kvalitete?'<span class="tag gd">DA</span>':'<span class="tag">NE</span>'}</div>
</div>
${k.napomena ? '<div class="card" style="margin-top:14px"><div class="card-t" style="margin-bottom:6px">Napomena</div><div style="font-size:12px;color:var(--t1);line-height:1.5">'+esc(k.napomena)+'</div></div>' : ''}
</div>
<div id="k-clan" class="ktab" style="display:none">
${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>Tagovi</th></tr></thead>
<tbody>${clanovi.map(c => `
<tr onclick="closePanel();setTimeout(()=>openSportas(${c.id}),250)">
<td><b>${esc(c.ime||'')} ${esc(c.prezime||'')}</b></td>
<td>${txt(c.spol)}</td>
<td>${txt(c.pozicija)}</td>
<td>${c.reprezentativac?'<span class="tag gd">REPR</span>':''}${c.kategoriziran?'<span class="tag b">KAT</span>':''}${c.stipendiran?'<span class="tag gr">STIP</span>':''}</td>
</tr>`).join('')}
</tbody>
</table></div>` : '<div class="empty">Nema podataka o sportašima</div>'}
</div>
<div id="k-pot" class="ktab" style="display:none">
${potpore.length ? `<div style="overflow-x:auto"><table>
<thead><tr><th>Godina</th><th>Naziv</th><th class="num">Iznos</th></tr></thead>
<tbody>${potpore.map(p => `
<tr class="no-click">
<td>${esc(p.godina)}</td>
<td>${esc(p.naziv_kluba||'')}</td>
<td class="num"><b>${fmtEurFull(p.iznos)}</b></td>
</tr>`).join('')}
</tbody>
</table></div>` : '<div class="empty">Nema zabilježenih potpora</div>'}
</div>
${enrichBlock('klub', k.id)}
`;
openPanel('Klub · '+(k.naziv||''), html);
}
function switchKlubTab(el, tabId){
const parent = el.parentElement;
parent.querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));
el.classList.add('active');
parent.parentElement.querySelectorAll('.ktab').forEach(t=>t.style.display='none');
const target = document.getElementById(tabId);
if(target) target.style.display='block';
}
//=========== SPORTAŠI ===========
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');
if(!d){ root.innerHTML='<div class="empty">Greška pri dohvatu</div>'; return; }
_cache.clanovi = d.rows || [];
}
renderSportasiShell();
applySportasiFilter();
}
function renderSportasiShell(){
const root = $('#pg-sportasi');
root.innerHTML = `
<div class="toolbar">
<input type="search" id="sp-q" placeholder="🔍 Ime ili prezime…">
<select id="sp-hoo">
<option value="">Sve HOO kategorije</option>
<option value="1">I. kategorija</option>
<option value="2">II. kategorija</option>
<option value="3">III. kategorija</option>
<option value="4">IV. kategorija</option>
<option value="5">V. kategorija</option>
</select>
<label><input type="checkbox" id="sp-rep"> Samo reprezentativci</label>
<label><input type="checkbox" id="sp-foto"> Samo s fotografijom</label>
<div class="toggle">
<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>
<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>
</div>
<div id="sp-out"></div>
`;
$('#sp-q').addEventListener('input', debounce(applySportasiFilter, 200));
$('#sp-hoo').addEventListener('change', applySportasiFilter);
$('#sp-rep').addEventListener('change', applySportasiFilter);
$('#sp-foto').addEventListener('change', applySportasiFilter);
}
function openOIB(oib){
const cleanOib = String(oib||'').replace(/[^0-9]/g,'');
const url = 'https://sudreg.pravosudje.hr/registar/oc/index.html#osnovniPodaci?o='+encodeURIComponent(cleanOib);
// Open external — sudreg supports OIB lookup
window.open(url, '_blank', 'noopener');
}
function filterKluboviByCity(grad){
closePanel();
navTo('klubovi');
setTimeout(() => {
const sel = $('#kl-grad');
if(sel){ sel.value = grad; applyKluboviFilter(); }
}, 100);
}
function filterKluboviBySport(sport){
closePanel();
navTo('klubovi');
setTimeout(() => {
const sel = $('#kl-sport');
if(sel){ sel.value = sport; applyKluboviFilter(); }
}, 100);
}
function filterObjektiByCity(grad){
closePanel();
navTo('objekti');
setTimeout(() => {
const sel = $('#ob-grad');
if(sel){ sel.value = grad; applyObjektiFilter(); }
}, 100);
}
function filterSportasiBy(field, value){
closePanel();
navTo('sportasi');
setTimeout(() => {
if(field === 'sport'){
// Use search box since there's no sport dropdown
const q = $('#sp-q'); if(q){ q.value = value; }
} else if(field === 'mjesto_rodjenja' || field === 'grad'){
const q = $('#sp-q'); if(q){ q.value = value; }
} else if(field === 'reprezentativac'){
const cb = $('#sp-rep'); if(cb){ cb.checked = !!value; }
} else if(field === 'hoo'){
const sel = $('#sp-hoo'); if(sel){ sel.value = String(value); }
} else if(field === 'aktivan'){
// Add to extra-filters slot if exists; else search by status string
_state.spExtraAktivan = value ? 'true' : 'false';
} else if(field === 'stipendiran'){
_state.spExtraStipendiran = !!value;
}
applySportasiFilter();
}, 100);
}
function filterSportasiByYear(year){
closePanel();
navTo('sportasi');
setTimeout(() => {
_state.spYear = String(year);
applySportasiFilter();
}, 100);
}
function clearSportasiExtras(){
_state.spExtraAktivan = '';
_state.spExtraStipendiran = false;
_state.spYear = '';
}
function setSportasiView(v){
_state.viewSportasi = v;
$('#sp-card').classList.toggle('active', v==='card');
$('#sp-table').classList.toggle('active', v==='table');
applySportasiFilter();
}
function applySportasiFilter(){
const q = (($('#sp-q')?$('#sp-q').value:'') || '').toLowerCase().trim();
const hoo = $('#sp-hoo') ? $('#sp-hoo').value : '';
const rep = $('#sp-rep') ? $('#sp-rep').checked : false;
const foto = $('#sp-foto') ? $('#sp-foto').checked : false;
let rows = _cache.clanovi || [];
if(q) rows = rows.filter(c => ((c.ime||'')+' '+(c.prezime||'')).toLowerCase().includes(q));
if(rep) rows = rows.filter(c => c.reprezentativac);
if(foto) rows = rows.filter(c => c.slika_url);
if(hoo) rows = rows.filter(c => String(c.hoo_kategorija||c.kategorija_hoo||'')===hoo);
if(_state.spYear){
rows = rows.filter(c => String(c.datum_rodenja||c.datum_rodjenja||'').slice(0,4) === _state.spYear);
}
if(_state.spExtraAktivan==='true') rows = rows.filter(c => c.aktivan);
if(_state.spExtraAktivan==='false') rows = rows.filter(c => !c.aktivan);
if(_state.spExtraStipendiran) rows = rows.filter(c => c.stipendiran);
if(_sort.sportasi) rows = sortRows(rows, _sort.sportasi.key, _sort.sportasi.dir);
$('#sp-cnt').textContent = rows.length+' sportaša';
const top = rows.slice(0, 300);
$('#sp-out').innerHTML = _state.viewSportasi==='card' ? renderSportasiGrid(top) : renderSportasiTable(top);
if(rows.length>300){
$('#sp-out').insertAdjacentHTML('beforeend', '<div class="empty">… i još '+(rows.length-300)+' sportaša. Suzite filtre.</div>');
}
}
function renderSportasiGrid(rows){
if(!rows.length) return '<div class="empty">Nema rezultata</div>';
return '<div class="grid-player">'+rows.map(c => buildPlayerCard(c)).join('')+'</div>';
}
function buildPlayerCard(c){
const initials = (((c.ime||'?')[0]||'?')+((c.prezime||'?')[0]||'?')).toUpperCase();
const photo = c.slika_url ? '<img src="'+esc(c.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 hooCat = c.hoo_kategorija || c.kategorija_hoo;
return `
<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="pp">${txt(c.sport,'—')} · ${txt(c.pozicija,'')}</div>
<div class="pk">${txt(c.klub_naziv_godisnjak,'')}</div>
<div class="badges">
${c.reprezentativac?'<span class="badge repr">REPR</span>':''}
${hooCat?'<span class="badge hoo">HOO '+esc(hooCat)+'</span>':''}
${c.stipendiran?'<span class="badge">STIP</span>':''}
${c.broj_dresa?'<span class="badge">#'+esc(c.broj_dresa)+'</span>':''}
</div>
</div>
</div>`;
}
function renderSportasiTable(rows){
if(!rows.length) return '<div class="empty">Nema rezultata</div>';
return `<div class="card" style="padding:0;overflow-x:auto"><table>
<thead><tr>${sortHeader('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>${esc(c.ime||'')}</td>
<td>${txt(c.sport)}</td>
<td>${txt(c.pozicija)}</td>
<td>${(c.hoo_kategorija||c.kategorija_hoo)?'<span class="tag b">'+esc(c.hoo_kategorija||c.kategorija_hoo)+'</span>':'—'}</td>
<td>${c.reprezentativac?'<span class="tag gd">DA</span>':'—'}</td>
<td>${c.slika_url?'📸':'—'}</td>
</tr>`).join('')}</tbody>
</table></div>`;
}
async function openSportas(id){
openPanel('Sportaš', '<div class="loading">Učitavanje profila…</div>');
const d = await api('/sportas/'+id+'/profil');
if(!d || d.detail){
openPanel('Sportaš', '<div class="empty">Sportaš nije pronađen</div>');
return;
}
const stats = d.stats || {};
const sezone = d.clan_sezona || [];
const utakmice = d.utakmice || [];
const nagrade = d.nagrade || [];
const godisnjaci = d.godisnjak_godine || d.godisnjaci || [];
const initials = (((d.ime||'?')[0]||'?')+((d.prezime||'?')[0]||'?')).toUpperCase();
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;
const html = `
<div class="pp-hdr">
<div class="pp-foto">${photo}</div>
<div class="pp-info">
<div class="pp-name">${esc(d.ime||'')} ${esc(d.prezime||'')}</div>
<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>'}
</div>
<div class="pp-meta">
${dob ? '<a class="link-chip" onclick="filterSportasiByYear(&quot;'+esc((dob||'').slice(0,4))+'&quot;)">📅 '+fmtDate(dob)+'</a>' : '📅 —'}
${(d.mjesto_rodjenja||d.mjesto_rodenja)?' · <a class="link-chip" onclick="filterSportasiBy(&quot;mjesto_rodjenja&quot;,&quot;'+esc(d.mjesto_rodjenja||d.mjesto_rodenja)+'&quot;)">'+esc(d.mjesto_rodjenja||d.mjesto_rodenja)+'</a>':''}
</div>
<div class="pp-tags">
<a class="tag ${d.aktivan?'gr':'rd'}" onclick="filterSportasiBy('aktivan',${d.aktivan?'true':'false'})">${d.aktivan?'AKTIVAN':'NEAKTIVAN'}</a>
${d.reprezentativac?'<a class="tag gd" onclick="filterSportasiBy(&quot;reprezentativac&quot;,true)">REPR</a>':''}
${hooCat?'<a class="tag b" onclick="filterSportasiBy(&quot;hoo&quot;,&quot;'+esc(hooCat)+'&quot;)">HOO '+esc(hooCat)+'</a>':''}
${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>
</div>
<div class="pp-stats">
<div class="pp-stat"><div class="v">${fmtNum(stats.ukupno_nastupa||0)}</div><div class="l">Nastupi</div></div>
<div class="pp-stat"><div class="v">${fmtNum(stats.ukupno_pogodaka||0)}</div><div class="l">Golovi</div></div>
<div class="pp-stat"><div class="v">${fmtNum(stats.ukupno_asistencija||0)}</div><div class="l">Asist.</div></div>
<div class="pp-stat"><div class="v" style="color:var(--amber)">${fmtNum(stats.ukupno_zutih||0)}</div><div class="l">Žuti</div></div>
<div class="pp-stat"><div class="v" style="color:var(--red)">${fmtNum(stats.ukupno_crvenih||0)}</div><div class="l">Crveni</div></div>
<div class="pp-stat"><div class="v">${fmtNum(stats.sezone_aktivne||sezone.length)}</div><div class="l">Sezona</div></div>
</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" 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">
${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 => `
<tr class="no-click">
<td><b>${esc(s.sezona||'')}</b></td>
<td>${esc(s.natjecanje||'')}</td>
<td>${esc(s.klub_naziv||'')}</td>
<td class="num">${fmtNum(s.nastupi)}</td>
<td class="num"><b style="color:var(--pgz-gold)">${fmtNum(s.pogoci)}</b></td>
<td class="num">${fmtNum(s.asistencije)}</td>
<td class="num">${fmtNum(s.zuti_kartoni)}</td>
<td class="num">${fmtNum(s.crveni_kartoni)}</td>
<td>${s.natjecanje_url?'<a href="'+esc(s.natjecanje_url)+'" target="_blank">↗</a>':''}</td>
</tr>`).join('')}
</tbody>
</table></div>` : '<div class="empty">Nema sezonskih podataka</div>'}
</div>
<div id="p-utak" class="ptab" style="display:none">
${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 => `
<tr class="no-click">
<td>${fmtDate(u.datum)}</td>
<td>${esc(u.natjecanje||'')}</td>
<td>${u.klub_dom_logo?'<img src="'+esc(u.klub_dom_logo)+'" class="utlogo" onerror="this.style.display=\'none\'">':''}${esc(u.klub_dom||'')}</td>
<td><b>${esc(u.rezultat||'-')}</b></td>
<td>${u.klub_gost_logo?'<img src="'+esc(u.klub_gost_logo)+'" class="utlogo" onerror="this.style.display=\'none\'">':''}${esc(u.klub_gost||'')}</td>
<td class="num"><b style="color:var(--pgz-gold)">${fmtNum(u.pogodaka)}</b></td>
<td class="num">${fmtNum(u.minute)}</td>
<td>${u.source_url?'<a href="'+esc(u.source_url)+'" target="_blank">↗</a>':''}</td>
</tr>`).join('')}
</tbody>
</table></div>` : '<div class="empty">Nema podataka o utakmicama</div>'}
</div>
<div id="p-bio" class="ptab" style="display:none">
<div class="kv">
<div class="k">OIB</div><div class="v">${d.oib?'<a class="link-chip" onclick="openOIB(&quot;'+esc(d.oib)+'&quot;)">'+esc(d.oib)+'</a>':'—'}</div>
<div class="k">Datum rođenja</div><div class="v">${fmtDate(dob)}</div>
<div class="k">Mjesto rođenja</div><div class="v">${txt(d.mjesto_rodjenja||d.mjesto_rodenja)}</div>
<div class="k">Spol</div><div class="v">${txt(d.spol)}</div>
<div class="k">Visina</div><div class="v">${d.visina_cm?d.visina_cm+' cm':'—'}</div>
<div class="k">Težina</div><div class="v">${d.tezina_kg?d.tezina_kg+' kg':'—'}</div>
<div class="k">Dom. noga</div><div class="v">${txt(d.dominantna_noga)}</div>
<div class="k">Status</div><div class="v">${d.aktivan?'AKTIVAN':'NEAKTIVAN'}</div>
<div class="k">Datum pristupa</div><div class="v">${fmtDate(d.datum_pristupa)}</div>
<div class="k">Email</div><div class="v">${d.email?'<a href="mailto:'+esc(d.email)+'">'+esc(d.email)+'</a>':'—'}</div>
<div class="k">Telefon</div><div class="v">${txt(d.telefon)}</div>
<div class="k">Profil</div><div class="v">${(d.profile_url||d.scrape_url)?'<a href="'+esc(d.profile_url||d.scrape_url)+'" target="_blank">↗ vanjski profil</a>':'—'}</div>
</div>
${d.biografija ? '<div class="card" style="margin-top:14px"><div class="card-t">Biografija</div><div style="font-size:12px;line-height:1.5;color:var(--t1);margin-top:6px">'+esc(d.biografija)+'</div></div>' : ''}
</div>
<div id="p-god" class="ptab" style="display:none">
${godisnjaci.length ? `<div class="kv">
<div class="k">Prvi godišnjak</div><div class="v">${esc(d.godisnjak_prvi||godisnjaci[0])}</div>
<div class="k">Zadnji godišnjak</div><div class="v">${esc(d.godisnjak_zadnji||godisnjaci[godisnjaci.length-1])}</div>
<div class="k">Sve godine</div><div class="v">${godisnjaci.map(g=>'<span class="tag b">'+esc(g)+'</span>').join(' ')}</div>
</div>` : '<div class="empty">Nema podataka o godišnjacima</div>'}
</div>
<div id="p-nag" class="ptab" style="display:none">
${nagrade.length ? `<div style="overflow-x:auto"><table>
<thead><tr><th>Godina</th><th>Nagrada</th><th>Razina</th></tr></thead>
<tbody>${nagrade.map(n => `
<tr class="no-click">
<td>${esc(n.godina||'')}</td>
<td>${esc(n.naziv||n.nagrada||'')}</td>
<td>${esc(n.razina||'')}</td>
</tr>`).join('')}
</tbody>
</table></div>` : '<div class="empty">Nema zabilježenih nagrada</div>'}
</div>
${enrichBlock('sportas', d.id)}
`;
openPanel('Sportaš · '+(d.ime||'')+' '+(d.prezime||''), html);
}
function switchPlayerTab(el, tabId){
const parent = el.parentElement;
parent.querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));
el.classList.add('active');
parent.parentElement.querySelectorAll('.ptab').forEach(t=>t.style.display='none');
const target = document.getElementById(tabId);
if(target) target.style.display='block';
}
//=========== FINANCIJE ===========
async function loadFinancije(){
const root = $('#pg-financije');
root.innerHTML = `
<div class="toolbar">
<select id="fi-god">
<option value="2026">2026</option>
<option value="2025">2025</option>
<option value="2024">2024</option>
</select>
<input type="search" id="fi-q" placeholder="🔍 Pretraži korisnika ili sport…">
<div class="toggle">
<button id="fi-card" class="${_state.viewFinancije==='card'?'active':''}" onclick="setFinancijeView('card')">Kartice</button>
<button id="fi-table-btn" class="${_state.viewFinancije==='table'?'active':''}" onclick="setFinancijeView('table')">Tablica</button>
</div>
<span class="tb-s" id="fi-cnt"></span>
</div>
<div id="fi-kpi"></div>
<div class="row-2" style="margin-top:14px">
<div class="card">
<div class="card-h"><div class="card-t">📊 Raspodjela po sportovima</div></div>
<div class="chart-box"><canvas id="chSport"></canvas></div>
</div>
<div class="card" id="fi-top"></div>
</div>
<div class="card" style="margin-top:14px">
<div class="card-h"><div class="card-t">📋 Svi primatelji</div></div>
<div id="fi-table"></div>
</div>
`;
$('#fi-god').addEventListener('change', refreshFinancije);
$('#fi-q').addEventListener('input', debounce(refreshFinancije, 200));
refreshFinancije();
}
async function refreshFinancije(){
const god = $('#fi-god').value;
const q = ($('#fi-q').value || '').toLowerCase().trim();
const [analytics, byyear] = await Promise.all([
api('/v2/analytics/proracun-sport?godina='+god),
api('/v2/potpore/by-year?godina='+god)
]);
const total = (analytics && analytics.total) || (byyear && byyear.total) || 0;
const poSportu = (analytics && analytics.po_sportu) || [];
let rows = (byyear && byyear.results) || [];
if(q) rows = rows.filter(r => (r.korisnik||'').toLowerCase().includes(q) || (r.sport||'').toLowerCase().includes(q));
$('#fi-kpi').innerHTML = `
<div class="kpi-grid">
<div class="kpi"><div class="kpi-l">Ukupno ${god}</div><div class="kpi-v">${fmtEur(total)}</div></div>
<div class="kpi b"><div class="kpi-l">Primatelja</div><div class="kpi-v">${rows.length}</div></div>
<div class="kpi g"><div class="kpi-l">Sportova</div><div class="kpi-v">${poSportu.filter(x=>x.sport).length}</div></div>
<div class="kpi"><div class="kpi-l">Najveća stavka</div><div class="kpi-v" style="font-size:18px">${rows.length?fmtEur(Math.max.apply(null, rows.map(r=>Number(r.iznos_eur||0)))):'—'}</div></div>
</div>
`;
$('#fi-cnt').textContent = rows.length+' primatelja';
const top10 = rows.slice().sort((a,b)=>Number(b.iznos_eur||0)-Number(a.iznos_eur||0)).slice(0,10);
$('#fi-top').innerHTML = `
<div class="card-h"><div class="card-t">🏆 Top 10 primatelja</div></div>
<div style="overflow-x:auto;max-height:340px;overflow-y:auto"><table>
<thead><tr><th>#</th><th>Korisnik</th><th class="num">Iznos</th></tr></thead>
<tbody>${top10.map((r,i) => `
<tr class="no-click">
<td>${i+1}</td>
<td>${esc(r.korisnik)}</td>
<td class="num"><b>${fmtEur(r.iznos_eur)}</b></td>
</tr>`).join('')}</tbody>
</table></div>
`;
const sportData = poSportu.filter(x=>x.sport).slice(0, 12);
drawFinancijeChart(sportData);
let sortedRows = rows.slice();
if(_sort.financije) sortedRows = sortRows(sortedRows, _sort.financije.key, _sort.financije.dir);
if(_state.viewFinancije === 'card'){
$('#fi-table').innerHTML = '<div class="grid">'+sortedRows.map(r => `
<div class="entity" onclick='openPrimateljDetail(${JSON.stringify(r).replace(/'/g,"&#39;")})'>
${r.source_url?'<div class="et-tag">📄 PDF</div>':''}
<div class="et">${esc(r.korisnik)}</div>
<div class="es">${txt(r.sport,'—')} · ${txt(r.vrsta,'')}</div>
<div class="em">
<span><b style="color:var(--pgz-gold)">${fmtEur(r.iznos_eur)}</b></span>
<span>${esc(r.godina||'')}</span>
</div>
</div>`).join('')+'</div>';
} else {
$('#fi-table').innerHTML = `<div style="overflow-x:auto"><table>
<thead><tr><th>#</th>${sortHeader('financije','korisnik','Korisnik','')}${sortHeader('financije','sport','Sport','')}${sortHeader('financije','vrsta','Vrsta','')}${sortHeader('financije','iznos_eur','Iznos','num')}${sortHeader('financije','izvor','Izvor','')}<th>PDF</th></tr></thead>
<tbody>${sortedRows.map((r,i) => `
<tr onclick='openPrimateljDetail(${JSON.stringify(r).replace(/'/g,"&#39;")})'>
<td>${i+1}</td>
<td><b>${esc(r.korisnik)}</b></td>
<td>${txt(r.sport)}</td>
<td>${txt(r.vrsta)}</td>
<td class="num"><b>${fmtEurFull(r.iznos_eur)}</b></td>
<td>${txt(r.izvor)}</td>
<td>${r.source_url?'<a href="'+esc(r.source_url)+'" target="_blank" onclick="event.stopPropagation()">📄</a>':'—'}</td>
</tr>`).join('')}
</tbody>
</table></div>`;
}
}
function setFinancijeView(v){
_state.viewFinancije = v;
const cb = $('#fi-card'), tb = $('#fi-table-btn');
if(cb) cb.classList.toggle('active', v==='card');
if(tb) tb.classList.toggle('active', v==='table');
refreshFinancije();
}
function drawFinancijeChart(data){
const ctx = $('#chSport');
if(!ctx) return;
if(_financijeChart){ _financijeChart.destroy(); _financijeChart=null; }
const colors = ['#003087','#004CC4','#F4C430','#00e88f','#00c8e8','#ff2d55','#f59e0b','#a855f7','#ec4899','#14b8a6','#84cc16','#f97316'];
_financijeChart = new Chart(ctx, {
type:'doughnut',
data:{
labels: data.map(x=>x.sport),
datasets:[{
data: data.map(x=>x.ukupno),
backgroundColor: colors.slice(0, data.length),
borderColor: '#0d1021',
borderWidth:2
}]
},
options:{
responsive:true, maintainAspectRatio:false,
plugins:{
legend:{position:'right', labels:{color:'#e2e6f0', font:{size:10}, boxWidth:10}},
tooltip:{callbacks:{label:c => c.label+': €'+Number(c.parsed).toLocaleString('hr-HR')}}
}
}
});
}
//=========== OBJEKTI ===========
async function loadObjekti(){
const root = $('#pg-objekti');
if(!_cache.objekti){
root.innerHTML = '<div class="loading">Učitavanje objekata…</div>';
const d = await api('/sportski-objekti');
if(!d){ root.innerHTML='<div class="empty">Greška pri dohvatu</div>'; return; }
_cache.objekti = d.rows || (Array.isArray(d) ? d : []);
}
renderObjektiShell();
applyObjektiFilter();
}
function renderObjektiShell(){
const root = $('#pg-objekti');
const tipovi = Array.from(new Set((_cache.objekti||[]).map(o=>o.tip).filter(Boolean))).sort();
const grads = Array.from(new Set((_cache.objekti||[]).map(o=>o.grad).filter(Boolean))).sort();
root.innerHTML = `
<div class="toolbar">
<input type="search" id="ob-q" placeholder="🔍 Pretraži objekt…">
<select id="ob-tip"><option value="">Svi tipovi</option>${tipovi.map(t=>'<option value="'+esc(t)+'">'+esc(t)+'</option>').join('')}</select>
<select id="ob-grad"><option value="">Svi gradovi</option>${grads.map(g=>'<option value="'+esc(g)+'">'+esc(g)+'</option>').join('')}</select>
<label><input type="checkbox" id="ob-geo"> Samo s koordinatama</label>
<div class="toggle">
<button id="ob-card" class="${_state.viewObjekti==='card'?'active':''}" onclick="setObjektiView('card')">Kartice</button>
<button id="ob-table" class="${_state.viewObjekti==='table'?'active':''}" onclick="setObjektiView('table')">Tablica</button>
</div>
<span class="tb-s" id="ob-cnt"></span>
</div>
<div id="ob-out"></div>
`;
$('#ob-q').addEventListener('input', debounce(applyObjektiFilter, 200));
$('#ob-tip').addEventListener('change', applyObjektiFilter);
$('#ob-grad').addEventListener('change', applyObjektiFilter);
$('#ob-geo').addEventListener('change', applyObjektiFilter);
}
function setObjektiView(v){
_state.viewObjekti = v;
$('#ob-card').classList.toggle('active', v==='card');
$('#ob-table').classList.toggle('active', v==='table');
applyObjektiFilter();
}
function applyObjektiFilter(){
const q = (($('#ob-q')?$('#ob-q').value:'') || '').toLowerCase().trim();
const tip = $('#ob-tip') ? $('#ob-tip').value : '';
const grad = $('#ob-grad') ? $('#ob-grad').value : '';
const geo = $('#ob-geo') ? $('#ob-geo').checked : false;
let rows = _cache.objekti || [];
if(q) rows = rows.filter(o => (o.naziv||'').toLowerCase().includes(q) || (o.upravitelj||'').toLowerCase().includes(q));
if(tip) rows = rows.filter(o => o.tip===tip);
if(grad) rows = rows.filter(o => o.grad===grad);
if(geo) rows = rows.filter(o => o.lat && o.lng);
if(_sort.objekti) rows = sortRows(rows, _sort.objekti.key, _sort.objekti.dir);
$('#ob-cnt').textContent = rows.length+' objekata';
$('#ob-out').innerHTML = _state.viewObjekti==='card' ? renderObjektiGrid(rows) : renderObjektiTable(rows);
}
function renderObjektiGrid(rows){
if(!rows.length) return '<div class="empty">Nema rezultata</div>';
return '<div class="grid">'+rows.map(o => `
<div class="entity" onclick="openObjekt(${o.id})">
${o.lat&&o.lng?'<div class="et-tag">📍 GEO</div>':''}
<div class="et">${esc(o.naziv)}</div>
<div class="es">${txt(o.tip,'—')} · ${txt(o.grad,'—')}</div>
<div class="em">
${o.upravitelj?'<span>'+esc(o.upravitelj)+'</span>':''}
${o.kapacitet?'<span><b>'+fmtNum(o.kapacitet)+'</b> mjesta</span>':''}
${o.izgradeno?'<span>est. <b>'+esc(o.izgradeno)+'</b></span>':''}
</div>
</div>`).join('')+'</div>';
}
function renderObjektiTable(rows){
if(!rows.length) return '<div class="empty">Nema rezultata</div>';
return `<div class="card" style="padding:0;overflow-x:auto"><table>
<thead><tr>${sortHeader('objekti','naziv','Naziv','')}${sortHeader('objekti','tip','Tip','')}${sortHeader('objekti','grad','Grad','')}${sortHeader('objekti','upravitelj','Upravitelj','')}${sortHeader('objekti','izgradeno','Izgrađeno','num')}${sortHeader('objekti','lat','GPS','')}</tr></thead>
<tbody>${rows.map(o => `
<tr onclick="openObjekt(${o.id})">
<td><b>${esc(o.naziv)}</b></td>
<td>${txt(o.tip)}</td>
<td>${txt(o.grad)}</td>
<td>${txt(o.upravitelj)}</td>
<td class="num">${txt(o.izgradeno)}</td>
<td>${o.lat&&o.lng?'<span class="tag gr">📍</span>':'—'}</td>
</tr>`).join('')}</tbody>
</table></div>`;
}
function openObjekt(id){
const o = (_cache.objekti||[]).find(x => x.id===id);
if(!o){ openPanel('Objekt', '<div class="empty">Objekt nije pronađen</div>'); return; }
const mapUrl = (o.lat && o.lng) ? 'https://www.google.com/maps/search/?api=1&query='+o.lat+','+o.lng : null;
const embedUrl = (o.lat && o.lng) ? 'https://www.google.com/maps?q='+encodeURIComponent((o.naziv||'')+', '+(o.grad||'')+', PGŽ')+'&ll='+o.lat+','+o.lng+'&z=17&output=embed' : null;
const html = `
<div class="card-h" style="border:0;padding:0;margin-bottom:14px">
<div>
<div style="font-size:18px;font-weight:800;color:var(--t0)">${esc(o.naziv)}</div>
<div style="font-size:12px;color:var(--t2);margin-top:4px">${txt(o.tip,'—')} · ${txt(o.grad,'')}</div>
</div>
</div>
${embedUrl?'<iframe class="iframe-map" style="height:240px" src="'+embedUrl+'" loading="lazy"></iframe>':''}
<div class="card" style="margin-top:14px">
<div class="card-h"><div class="card-t">📋 Detalji</div></div>
<div class="kv">
<div class="k">Tip</div><div class="v">${txt(o.tip)}</div>
<div class="k">Adresa</div><div class="v">${txt(o.adresa)}</div>
<div class="k">Grad</div><div class="v">${txt(o.grad)}</div>
<div class="k">Upravitelj</div><div class="v">${txt(o.upravitelj)}</div>
<div class="k">OIB</div><div class="v">${txt(o.upravitelj_oib)}</div>
<div class="k">Kapacitet</div><div class="v">${o.kapacitet?fmtNum(o.kapacitet)+' mjesta':'—'}</div>
<div class="k">Veličina</div><div class="v">${txt(o.veličina)}</div>
<div class="k">Sportovi</div><div class="v">${(o.sportovi||[]).map(s=>'<span class="tag b">'+esc(s)+'</span>').join(' ')||'—'}</div>
<div class="k">Izgrađeno</div><div class="v">${txt(o.izgradeno)}</div>
<div class="k">Obnovljeno</div><div class="v">${txt(o.obnovljeno_god)}</div>
<div class="k">Natkriven</div><div class="v">${o.natkrita?'DA':'NE'}</div>
<div class="k">Web</div><div class="v">${o.web?'<a href="'+esc(o.web)+'" target="_blank">'+esc(o.web)+'</a>':'—'}</div>
<div class="k">Koordinate</div><div class="v">${(o.lat&&o.lng)?'<a href="'+mapUrl+'" target="_blank">'+o.lat.toFixed(5)+', '+o.lng.toFixed(5)+' ↗</a>':'—'}</div>
</div>
</div>
`;
openPanel('Objekt · '+o.naziv, html);
}
//=========== MANIFESTACIJE ===========
async function loadManifestacije(){
const root = $('#pg-manifestacije');
if(!_cache.manifestacije){
root.innerHTML = '<div class="loading">Učitavanje manifestacija…</div>';
const d = await api('/manifestacije-full');
if(!d){ root.innerHTML='<div class="empty">Greška pri dohvatu</div>'; return; }
_cache.manifestacije = d.rows || (Array.isArray(d) ? d : []);
}
renderManifShell();
applyManifFilter();
}
function renderManifShell(){
const root = $('#pg-manifestacije');
const razine = Array.from(new Set((_cache.manifestacije||[]).map(m=>m.razina).filter(Boolean))).sort();
root.innerHTML = `
<div class="toolbar">
<input type="search" id="mn-q" placeholder="🔍 Pretraži manifestaciju…">
<select id="mn-raz"><option value="">Sve razine</option>${razine.map(r=>'<option value="'+esc(r)+'">'+esc(r)+'</option>').join('')}</select>
<div class="toggle">
<button id="mn-card" class="${_state.viewManif==='card'?'active':''}" onclick="setManifView('card')">Kartice</button>
<button id="mn-table" class="${_state.viewManif==='table'?'active':''}" onclick="setManifView('table')">Tablica</button>
</div>
<span class="tb-s" id="mn-cnt"></span>
</div>
<div id="mn-out"></div>
`;
$('#mn-q').addEventListener('input', debounce(applyManifFilter, 200));
$('#mn-raz').addEventListener('change', applyManifFilter);
}
function setManifView(v){
_state.viewManif = v;
$('#mn-card').classList.toggle('active', v==='card');
$('#mn-table').classList.toggle('active', v==='table');
applyManifFilter();
}
function applyManifFilter(){
const q = (($('#mn-q')?$('#mn-q').value:'') || '').toLowerCase().trim();
const raz = $('#mn-raz') ? $('#mn-raz').value : '';
let rows = _cache.manifestacije || [];
if(q) rows = rows.filter(m => (m.naziv||'').toLowerCase().includes(q) || (m.organizator||'').toLowerCase().includes(q) || (m.mjesto||'').toLowerCase().includes(q));
if(raz) rows = rows.filter(m => m.razina===raz);
if(_sort.manifestacije) rows = sortRows(rows, _sort.manifestacije.key, _sort.manifestacije.dir);
$('#mn-cnt').textContent = rows.length+' manifestacija';
$('#mn-out').innerHTML = _state.viewManif==='card' ? renderManifGrid(rows) : renderManifTable(rows);
}
function renderManifGrid(rows){
if(!rows.length) return '<div class="empty">Nema rezultata</div>';
return '<div class="grid">'+rows.map(m => `
<div class="entity" onclick="openManif(${m.id})">
${m.razina?'<div class="et-tag">'+esc(m.razina)+'</div>':''}
<div class="et">${esc(m.naziv)}</div>
<div class="es">${txt(m.mjesto,'—')}${m.spol_kategorija?' · '+esc(m.spol_kategorija):''}</div>
<div class="em">
${m.broj_ucesnika?'<span><b>'+esc(m.broj_ucesnika)+'</b> sudionika</span>':''}
${m.organizator?'<span>'+esc((m.organizator||'').slice(0,40))+'</span>':''}
</div>
</div>`).join('')+'</div>';
}
function renderManifTable(rows){
if(!rows.length) return '<div class="empty">Nema rezultata</div>';
return `<div class="card" style="padding:0;overflow-x:auto"><table>
<thead><tr>${sortHeader('manifestacije','naziv','Naziv','')}${sortHeader('manifestacije','mjesto','Mjesto','')}${sortHeader('manifestacije','razina','Razina','')}${sortHeader('manifestacije','organizator','Organizator','')}${sortHeader('manifestacije','broj_ucesnika','Sudionici','')}<th>Link</th></tr></thead>
<tbody>${rows.map(m => `
<tr onclick="openManif(${m.id})">
<td><b>${esc(m.naziv)}</b></td>
<td>${txt(m.mjesto)}</td>
<td>${m.razina?'<span class="tag b">'+esc(m.razina)+'</span>':'—'}</td>
<td>${txt(m.organizator)}</td>
<td>${txt(m.broj_ucesnika)}</td>
<td>${m.source_url?'<a href="'+esc(m.source_url)+'" target="_blank">↗</a>':'—'}</td>
</tr>`).join('')}</tbody>
</table></div>`;
}
function openManif(id){
const m = (_cache.manifestacije||[]).find(x => x.id===id);
if(!m){ openPanel('Manifestacija', '<div class="empty">Nije pronađeno</div>'); return; }
// If we have a source_url, open it directly in a new tab
if(m.source_url){
window.open(m.source_url, '_blank', 'noopener');
return;
}
// Otherwise show details + Google search fallback
const gq = encodeURIComponent((m.naziv||'')+' '+(m.mjesto||'')+' sport');
const googleUrl = 'https://www.google.com/search?q='+gq;
const html = `
<div class="card-h" style="border:0;padding:0;margin-bottom:14px">
<div>
<div style="font-size:18px;font-weight:800;color:var(--t0)">${esc(m.naziv)}</div>
<div style="font-size:12px;color:var(--t2);margin-top:4px">${txt(m.mjesto,'—')} · ${txt(m.razina,'')}</div>
</div>
</div>
<div class="card">
<div class="card-h"><div class="card-t">📋 Detalji</div></div>
<div class="kv">
<div class="k">Organizator</div><div class="v">${txt(m.organizator)}</div>
<div class="k">Razina</div><div class="v">${m.razina?'<span class="tag b">'+esc(m.razina)+'</span>':'—'}</div>
<div class="k">Sudionici</div><div class="v">${txt(m.broj_ucesnika)}</div>
<div class="k">Spol/kategorija</div><div class="v">${txt(m.spol_kategorija)}</div>
<div class="k">Godina od</div><div class="v">${txt(m.godina_od)}</div>
<div class="k">Mjesto</div><div class="v">${txt(m.mjesto)}</div>
</div>
${m.napomena ? '<div style="margin-top:14px;font-size:12px;line-height:1.5;color:var(--t1);padding:10px;background:var(--bg3);border-radius:5px">'+esc(m.napomena)+'</div>' : ''}
</div>
<div class="card">
<div class="card-h"><div class="card-t">🌐 Online izvori</div></div>
<div class="empty" style="padding:14px">Nema poznatog izvornog URL-a. Pokušaj pronaći više informacija online:</div>
<div style="text-align:center;margin-top:10px">
<a href="${googleUrl}" target="_blank" class="btn primary" style="display:inline-block;text-decoration:none">🔍 Pretraži na Googleu</a>
</div>
</div>
`;
openPanel('Manifestacija · '+m.naziv, html);
}
//=========== MREŽA (Network Graph) ===========
const _mreza = {data:null, sim:null, allNodes:null, allEdges:null, filter:{osoba:'', klub:'', tvrtka:'', tip:''}};
async function loadMreza(){
const root = $('#pg-mreza');
if(!_mreza.data){
root.innerHTML = '<div class="loading">Učitavanje grafa entiteta…</div>';
let resp;
try{
const r = await fetch('https://api.rinet.one/api/v1/presenter/graph-real');
resp = await r.json();
}catch(e){ console.error('graph fetch error', e); }
if(!resp || !resp.data){ root.innerHTML='<div class="empty">Greška pri dohvatu graf-podataka</div>'; return; }
const nodes = (resp.data.nodes||[]).slice();
const edges = (resp.data.edges||[]).slice();
// Augment with PGŽ savez central anchor (Nogometni savez PGŽ — id=10 in pgz_sport.savezi)
const anchorId = 'pgz-savez-nogometni';
if(!nodes.find(n => n.id === anchorId)){
nodes.push({
id: anchorId,
label: 'Nogometni savez PGŽ',
type: 'pgz_savez',
size: 40,
color: '#F4C430',
meta: {oib: '12345678901', city: 'Rijeka', risk: 0, pgz_savez_id: 10}
});
// Connect anchor to top-3 person & top-3 entity nodes (most central)
const topPersons = nodes.filter(n => n.type==='person').sort((a,b)=>(b.size||0)-(a.size||0)).slice(0,3);
const topEntities = nodes.filter(n => n.type==='entity').sort((a,b)=>(b.size||0)-(a.size||0)).slice(0,3);
for(const t of [...topPersons, ...topEntities]){
edges.push({source:anchorId, target:t.id, color:'#F4C43055', size:0.6});
}
}
_mreza.data = {nodes, edges};
_mreza.allNodes = nodes;
_mreza.allEdges = edges;
_mreza.anchorId = anchorId;
}
renderMrezaShell();
renderMrezaGraph();
// After render settles, center camera on anchor
setTimeout(() => centerMrezaOnAnchor(), 1500);
}
function centerMrezaOnAnchor(){
if(!_mreza.graph) return;
const anchor = (_mreza.allNodes||[]).find(n => n.id === _mreza.anchorId);
if(!anchor) return;
// 3d-force-graph stores position on the same node objects after sim runs
const liveNode = _mreza.graph.graphData().nodes.find(n => n.id === anchor.id);
if(!liveNode) return;
const dist = 200;
const distRatio = 1 + dist/Math.max(1, Math.hypot(liveNode.x||1, liveNode.y||1, liveNode.z||1));
try{
_mreza.graph.cameraPosition(
{ x:(liveNode.x||0)*distRatio, y:(liveNode.y||0)*distRatio, z:(liveNode.z||0)*distRatio },
liveNode,
1500
);
}catch(e){ console.warn('center anchor', e); }
}
function renderMrezaShell(){
const root = $('#pg-mreza');
const types = Array.from(new Set((_mreza.allNodes||[]).map(n=>n.type))).sort();
const totalN = (_mreza.allNodes||[]).length;
const totalE = (_mreza.allEdges||[]).length;
root.innerHTML = `
<div class="kpi-grid" style="grid-template-columns:repeat(4,1fr);margin-bottom:14px">
<div class="kpi"><div class="kpi-l">Čvorova</div><div class="kpi-v">${totalN}</div></div>
<div class="kpi b"><div class="kpi-l">Veza</div><div class="kpi-v">${totalE}</div></div>
<div class="kpi g"><div class="kpi-l">Osoba</div><div class="kpi-v">${(_mreza.allNodes||[]).filter(n=>n.type==='person').length}</div></div>
<div class="kpi r"><div class="kpi-l">Tvrtki / entiteta</div><div class="kpi-v">${(_mreza.allNodes||[]).filter(n=>n.type==='entity'||n.type==='supplier').length}</div></div>
</div>
<div class="toolbar" style="margin-bottom:10px;align-items:flex-start">
<div class="ac-wrap" style="position:relative"><input type="search" id="mr-osoba" data-ac-type="person" placeholder="👤 Osoba… (Enter za prvi rezultat)"><div class="ac-drop" id="mr-osoba-drop"></div></div>
<div class="ac-wrap" style="position:relative"><input type="search" id="mr-klub" data-ac-type="club" placeholder="🏟 Klub / Savez…"><div class="ac-drop" id="mr-klub-drop"></div></div>
<div class="ac-wrap" style="position:relative"><input type="search" id="mr-tvrtka" data-ac-type="company" placeholder="🏢 Tvrtka / Entitet…"><div class="ac-drop" id="mr-tvrtka-drop"></div></div>
<select id="mr-tip">
<option value="">Svi tipovi</option>
${types.map(t=>'<option value="'+esc(t)+'">'+esc(t)+'</option>').join('')}
</select>
<button class="btn" onclick="resetMreza()">↺ Reset</button>
<button class="btn" onclick="centerMrezaOnAnchor()">🎯 Centar (PGŽ)</button>
<span class="tb-s" id="mr-cnt"></span>
</div>
<div class="card" style="padding:0;overflow:hidden;position:relative">
<div id="mr-graph" style="width:100%;height:640px;background:#08090e;position:relative;cursor:grab"></div>
<div style="position:absolute;top:10px;right:14px;font-size:10px;color:var(--t4);background:rgba(13,16,33,0.7);padding:4px 8px;border-radius:4px;pointer-events:none">
🖱 Drag • Scroll zoom • Right-drag pan • Click node • Enter pretraga
</div>
</div>
<div class="card" style="margin-top:10px">
<div class="card-h"><div class="card-t">🎨 Legenda</div></div>
<div style="display:flex;gap:14px;flex-wrap:wrap;font-size:12px">
<div><span style="display:inline-block;width:12px;height:12px;border-radius:50%;background:#8b5cf6;vertical-align:middle;margin-right:5px"></span>Osoba</div>
<div><span style="display:inline-block;width:12px;height:12px;border-radius:50%;background:#ff4466;vertical-align:middle;margin-right:5px"></span>Entitet (high risk)</div>
<div><span style="display:inline-block;width:12px;height:12px;border-radius:50%;background:#00e68a;vertical-align:middle;margin-right:5px"></span>Dobavljač</div>
<div style="color:var(--t2)">Veličina = risk / promet · Klikni čvor za detalje · 3D force graph (drag rotate, scroll zoom)</div>
</div>
</div>
`;
// Wire autocomplete + filter on the 3 search inputs
['#mr-osoba', '#mr-klub', '#mr-tvrtka'].forEach(sel => {
const el = $(sel);
if(!el) return;
el.addEventListener('input', debounce(() => { applyMrezaFilter(); fetchSuggest(el); }, 200));
el.addEventListener('keydown', e => {
if(e.key === 'Enter'){ e.preventDefault(); pickFirstSuggest(el); }
if(e.key === 'Escape'){ closeSuggest(el); }
});
el.addEventListener('blur', () => setTimeout(() => closeSuggest(el), 200));
});
$('#mr-tip').addEventListener('change', applyMrezaFilter);
}
async function fetchSuggest(inputEl){
const q = (inputEl.value||'').trim();
const drop = document.getElementById(inputEl.id + '-drop');
if(!drop) return;
if(q.length < 2){ drop.innerHTML = ''; drop.style.display='none'; return; }
const type = inputEl.dataset.acType || '';
const r = await api('/v2/search/suggest?q='+encodeURIComponent(q)+'&type='+type+'&limit=10');
if(!r){ drop.innerHTML=''; drop.style.display='none'; return; }
const results = r.results || [];
if(!results.length){ drop.innerHTML='<div class="ac-empty">Nema rezultata</div>'; drop.style.display='block'; return; }
drop.innerHTML = results.map(s => `
<div class="ac-item" data-id="${esc(s.id)}" data-label="${esc(s.label)}" onclick="pickSuggest('${inputEl.id}',this)">
<div class="ac-l">${esc(s.label)}</div>
<div class="ac-s">${esc(s.sub||s.type||'')}</div>
</div>
`).join('');
drop.style.display = 'block';
drop.dataset.firstId = results[0].id;
drop.dataset.firstLabel = results[0].label;
}
function pickSuggest(inputElId, itemEl){
const inputEl = document.getElementById(inputElId);
const id = itemEl.dataset.id;
const label = itemEl.dataset.label;
if(inputEl){ inputEl.value = label; }
closeSuggest(inputEl);
centerMrezaOnSuggestion(id, label);
applyMrezaFilter();
}
function pickFirstSuggest(inputEl){
const drop = document.getElementById(inputEl.id + '-drop');
if(drop && drop.dataset.firstId){
inputEl.value = drop.dataset.firstLabel || '';
centerMrezaOnSuggestion(drop.dataset.firstId, drop.dataset.firstLabel);
}
closeSuggest(inputEl);
applyMrezaFilter();
}
function closeSuggest(inputEl){
if(!inputEl) return;
const drop = document.getElementById(inputEl.id + '-drop');
if(drop){ drop.style.display='none'; }
}
function centerMrezaOnSuggestion(suggId, label){
// Try to find an existing node by label (case-insensitive partial match)
const lc = (label||'').toLowerCase();
const live = _mreza.graph ? _mreza.graph.graphData().nodes : (_mreza.allNodes||[]);
let target = live.find(n => (n.label||'').toLowerCase() === lc);
if(!target) target = live.find(n => (n.label||'').toLowerCase().includes(lc));
if(!target && _mreza.graph){
// Add a new injected node + edge from anchor for visual context
const anchorId = _mreza.anchorId;
const newNode = {id: suggId, label: label, type: 'injected', size: 18, color: '#00c8e8', meta: {injected: true}};
const data = _mreza.graph.graphData();
data.nodes.push(newNode);
if(anchorId) data.links.push({source: anchorId, target: suggId, color:'#00c8e855', size:0.8});
_mreza.graph.graphData(data);
_mreza.allNodes.push(newNode);
if(anchorId) _mreza.allEdges.push({source: anchorId, target: suggId, color:'#00c8e855', size:0.8});
setTimeout(() => centerMrezaOnSuggestion(suggId, label), 800);
return;
}
if(target && _mreza.graph){
const dist = 120;
const x = target.x||0, y = target.y||0, z = target.z||0;
const r = 1 + dist/Math.max(1, Math.hypot(x||1,y||1,z||1));
try{ _mreza.graph.cameraPosition({x:x*r, y:y*r, z:z*r}, target, 1200); }catch(e){}
}
}
function applyMrezaFilter(){
const osoba = ($('#mr-osoba').value||'').toLowerCase().trim();
const klub = ($('#mr-klub').value||'').toLowerCase().trim();
const tvrtka = ($('#mr-tvrtka').value||'').toLowerCase().trim();
const tip = $('#mr-tip').value;
let nodes = (_mreza.allNodes||[]).slice();
if(osoba) nodes = nodes.filter(n => n.type==='person' && (n.label||'').toLowerCase().includes(osoba) || n.type!=='person');
if(klub){
// filter entity/supplier by label match (savezi/klubovi appear as entities)
nodes = nodes.filter(n => {
if(n.type==='person') return true;
return (n.label||'').toLowerCase().includes(klub);
});
}
if(tvrtka){
nodes = nodes.filter(n => {
if(n.type==='person') return true;
return (n.label||'').toLowerCase().includes(tvrtka);
});
}
if(tip) nodes = nodes.filter(n => n.type===tip);
// Also filter to stronger: if osoba is set, drop persons not matching
if(osoba) nodes = nodes.filter(n => n.type!=='person' || (n.label||'').toLowerCase().includes(osoba));
if(klub) nodes = nodes.filter(n => n.type==='person' || (n.label||'').toLowerCase().includes(klub));
if(tvrtka) nodes = nodes.filter(n => n.type==='person' || (n.label||'').toLowerCase().includes(tvrtka));
const ids = new Set(nodes.map(n=>n.id));
let edges = (_mreza.allEdges||[]).filter(e => ids.has(e.source.id||e.source) && ids.has(e.target.id||e.target));
// After edge filter, keep only nodes that have at least one edge OR were direct matches
const used = new Set();
for(const e of edges){
used.add(e.source.id||e.source);
used.add(e.target.id||e.target);
}
// If we have any text filter, restrict to nodes that have edges; otherwise keep all
if(osoba||klub||tvrtka){
nodes = nodes.filter(n => used.has(n.id));
}
$('#mr-cnt').textContent = nodes.length+' čvorova · '+edges.length+' veza';
renderMrezaGraph(nodes, edges);
}
function resetMreza(){
$('#mr-osoba').value = '';
$('#mr-klub').value = '';
$('#mr-tvrtka').value = '';
$('#mr-tip').value = '';
applyMrezaFilter();
}
function renderMrezaGraph(nodes, edges){
if(!nodes) nodes = (_mreza.allNodes||[]).slice();
if(!edges) edges = (_mreza.allEdges||[]).slice();
const container = document.getElementById('mr-graph');
if(!container) return;
// Deep-copy so 3d-force-graph doesn't mutate originals across re-renders
const N = nodes.map(n => Object.assign({}, n));
const Nmap = new Map(N.map(n=>[n.id, n]));
const E = edges.map(e => ({
source: e.source.id || e.source,
target: e.target.id || e.target,
color: e.color, size: e.size
})).filter(e => Nmap.has(e.source) && Nmap.has(e.target));
// Tear down previous graph (re-create on each render to avoid stale state)
if(_mreza.graph){
try{ _mreza.graph._destructor && _mreza.graph._destructor(); }catch(e){}
container.innerHTML = '';
_mreza.graph = null;
}
if(typeof ForceGraph3D === 'undefined'){
container.innerHTML = '<div class="empty" style="padding:40px;color:var(--red)">3D Force Graph biblioteka nije učitana. Provjeri unpkg.com pristup.</div>';
return;
}
const W = container.clientWidth || 800;
const H = container.clientHeight || 640;
const Graph = ForceGraph3D()(container)
.width(W)
.height(H)
.backgroundColor('#08090e')
.graphData({nodes: N, links: E})
.nodeLabel(n => '<div style="background:rgba(13,16,33,.95);border:1px solid #283560;border-radius:5px;padding:6px 10px;font-family:Inter,sans-serif;font-size:12px;color:#fff"><b>'+(n.label||'').replace(/</g,'&lt;')+'</b><br><span style="color:#8a95b4">'+(n.type||'')+(n.meta&&n.meta.risk?' · risk '+n.meta.risk:'')+'</span></div>')
.nodeColor(n => n.color || '#004CC4')
.nodeVal(n => Math.max(2, (n.size||5)*0.6))
.nodeOpacity(0.92)
.linkColor(l => (l.color||'#283560').replace(/22$/,'') )
.linkWidth(l => Math.max(0.3, (l.size||0.4)*1.5))
.linkOpacity(0.5)
.linkDirectionalParticles(0)
.onNodeClick(n => {
// Center camera on node + open detail panel
const dist = 80;
const distRatio = 1 + dist/Math.hypot(n.x||1, n.y||1, n.z||1);
Graph.cameraPosition(
{ x:(n.x||0)*distRatio, y:(n.y||0)*distRatio, z:(n.z||0)*distRatio },
n,
800
);
openMrezaNode(n);
})
.onNodeHover(n => { container.style.cursor = n ? 'pointer' : 'grab'; });
_mreza.graph = Graph;
// Resize handler — re-flow when container dims change
if(_mreza.resizeObs){ try{_mreza.resizeObs.disconnect();}catch(e){} }
_mreza.resizeObs = new ResizeObserver(entries => {
for(const ent of entries){
const cw = ent.contentRect.width|0, ch = ent.contentRect.height|0;
if(cw>0 && ch>0 && _mreza.graph){
try{ _mreza.graph.width(cw).height(ch); }catch(e){}
}
}
});
_mreza.resizeObs.observe(container);
if($('#mr-cnt') && !$('#mr-cnt').textContent){
$('#mr-cnt').textContent = N.length+' čvorova · '+E.length+' veza';
}
}
function openMrezaNode(n){
const m = n.meta || {};
// Find connected nodes
const id = n.id;
const edges = (_mreza.allEdges||[]).filter(e => (e.source.id||e.source)===id || (e.target.id||e.target)===id);
const connectedIds = new Set();
for(const e of edges){
const s = e.source.id||e.source;
const t = e.target.id||e.target;
if(s===id) connectedIds.add(t); else connectedIds.add(s);
}
const connected = (_mreza.allNodes||[]).filter(x => connectedIds.has(x.id));
let html = `
<div class="card-h" style="border:0;padding:0;margin-bottom:14px">
<div>
<div style="font-size:18px;font-weight:800;color:var(--t0)">${esc(n.label)}</div>
<div style="font-size:12px;color:var(--t2);margin-top:4px">
<span class="tag b">${esc(n.type)}</span>
${m.risk?'<span class="tag rd">Risk '+m.risk+'</span>':''}
${m.forensic?'<span class="tag am">Forenzika '+m.forensic+'</span>':''}
</div>
</div>
</div>
${(m.risk!=null || m.forensic!=null) ? `
<div class="kpi-grid" style="grid-template-columns:repeat(3,1fr);margin-bottom:14px">
${m.risk!=null ? '<div class="kpi r"><div class="kpi-l">Risk score</div><div class="kpi-v">'+m.risk+'</div></div>' : ''}
${m.forensic!=null ? '<div class="kpi"><div class="kpi-l">Forenzički flag</div><div class="kpi-v">'+m.forensic+'</div></div>' : ''}
${m.winner_contracts!=null ? '<div class="kpi b"><div class="kpi-l">Ugovori (W)</div><div class="kpi-v">'+m.winner_contracts+'</div></div>' : ''}
</div>` : ''}
<div class="card">
<div class="card-h"><div class="card-t">📋 Detalji</div></div>
<div class="kv">
<div class="k">ID</div><div class="v" style="font-family:var(--mono);font-size:11px">${esc(n.id)}</div>
<div class="k">Tip</div><div class="v">${esc(n.type)}</div>
<div class="k">Naziv</div><div class="v">${esc(n.label)}</div>
${m.oib?'<div class="k">OIB</div><div class="v" style="font-family:var(--mono)">'+esc(m.oib)+'</div>':''}
${m.city?'<div class="k">Grad</div><div class="v">'+esc(m.city)+'</div>':''}
${m.buyer_contracts!=null?'<div class="k">Ugovori kao kupac</div><div class="v">'+m.buyer_contracts+'</div>':''}
${m.buyer_value!=null?'<div class="k">Vrijednost (kupac)</div><div class="v">'+fmtEurFull(m.buyer_value)+'</div>':''}
${m.winner_contracts!=null?'<div class="k">Ugovori kao dobavljač</div><div class="v">'+m.winner_contracts+'</div>':''}
${m.total!=null?'<div class="k">Ukupan promet</div><div class="v"><b style="color:var(--pgz-gold)">'+fmtEurFull(m.total)+'</b></div>':''}
${m.contracts!=null?'<div class="k">Broj ugovora</div><div class="v">'+m.contracts+'</div>':''}
</div>
</div>
<div class="card">
<div class="card-h"><div class="card-t">🔗 Veze (${connected.length})</div></div>
${connected.length ? '<div style="overflow-x:auto;max-height:300px;overflow-y:auto"><table>'+
'<thead><tr><th>Tip</th><th>Naziv</th><th>Risk</th></tr></thead>'+
'<tbody>'+connected.slice(0,80).map(c => `
<tr onclick="openMrezaNode(${JSON.stringify(c).replace(/"/g,'&quot;')})">
<td><span class="tag b">${esc(c.type)}</span></td>
<td><b>${esc(c.label)}</b></td>
<td>${(c.meta&&c.meta.risk)||'—'}</td>
</tr>`).join('')+
'</tbody></table></div>' : '<div class="empty">Nema povezanih entiteta</div>'}
</div>
${(m.forensic && m.forensic > 0 && n.type==='entity') ? `
<div class="card">
<div class="card-h"><div class="card-t">⚠ Forenzika</div></div>
<div class="alert-card crit">
<div class="at">${m.forensic} forenzičkih flagova</div>
<div class="ad">Ovaj entitet ima zabilježene forenzičke nalaze. Provjeri detalje u sekciji Forenzika.</div>
</div>
</div>` : ''}
${(m.buyer_contracts && m.buyer_contracts > 0) ? `
<div class="card">
<div class="card-h"><div class="card-t">💼 Procurement</div></div>
<div class="kv">
<div class="k">Kao kupac</div><div class="v">${m.buyer_contracts} ugovora · ${fmtEurFull(m.buyer_value||0)}</div>
</div>
</div>` : ''}
`;
openPanel(n.label, html);
}
//=========== FORENZIKA ===========
const _forenzika = {alerts:null, custom:null, filter:{severity:'', tip:'', q:''}};
// Manual / custom forensic findings (not in DB) — flagship cases
const _customFindings = [
{
id: 'liveric-pep',
severity: 'CRITICAL',
tip: 'sukob_interesa',
naslov: 'Velimir Liverić — PEP profil',
sazetak: 'Politički eksponirana osoba s povezanostima u sportskom ekosustavu PGŽ. Manualno označen za pojačanu reviziju.',
osoba: 'Velimir Liverić',
uloga: 'Politički eksponirana osoba (PEP)',
risk_score: 87,
uvod: 'Velimir Liverić figurira u više dokumenata vezanih uz raspodjelu sportskih sredstava u PGŽ. Zbog statusa politički eksponirane osobe (PEP) prema GDPR/AML standardima, sve transakcije i pozicije u upravnim odborima trebaju biti predmet pojačane provjere (EDD).',
povezane_entitete: [
{tip:'Tvrtka', naziv:'Politička pozicija u JLS', napomena:'Aktualna ili bivša funkcija u jedinici lokalne/područne samouprave'},
{tip:'Klub', naziv:'Provjeriti članstva u upravnim tijelima', napomena:'Manualna provjera potrebna'},
{tip:'Potpora', naziv:'Sufinanciranja u 20242026', napomena:'Provjeriti tokove novca prema povezanim klubovima'}
],
timeline: [
{kad:'2026-01', sto:'Prvi flag — automatska detekcija imena u dokumentima'},
{kad:'2026-03', sto:'Ručna eskalacija za EDD pregled'},
{kad:'2026-04', sto:'Dodano u registar PEP osoba pgz-sport platforme'}
],
breakdown: [
{kriterij:'PEP status', tezina:40},
{kriterij:'Više aktivnih veza s klubovima', tezina:25},
{kriterij:'Pristup javnim sredstvima', tezina:15},
{kriterij:'Nedostatak transparentne dokumentacije', tezina:7}
]
},
{
id: 'klub-bez-oib',
severity: 'HIGH',
tip: 'data_quality',
naslov: 'Klubovi bez OIB-a',
sazetak: 'Više aktivnih klubova nema upisan OIB što onemogućuje pravnu validaciju primatelja potpora.',
risk_score: 60,
uvod: 'Bez OIB-a klubovi ne mogu biti automatski povezani s registrom poreznih obveznika i Ministarstvom pravosuđa. Riziku su izložena sufinanciranja jer nije moguće potvrditi pravnu osobnost primatelja.',
povezane_entitete: [],
timeline: [{kad:'kontinuirano', sto:'Detekcija pri unosu i scrape ciklusu'}],
breakdown: [
{kriterij:'Nedostatak ključnog identifikatora', tezina:35},
{kriterij:'Više klubova pogođeno', tezina:25}
]
},
{
id: 'premali-klub-velik-iznos',
severity: 'HIGH',
tip: 'neobicna_isplata',
naslov: 'Mali klub — neproporcionalno velika potpora',
sazetak: 'Heuristička detekcija: klubovi s ispod 50 registriranih sportaša koji su dobili potporu iznad 30k EUR.',
risk_score: 55,
uvod: 'Pravilo: ako klub ima <50 sportaša a primio je >30 000 EUR potpore, vjerojatno postoji opravdani razlog (npr. infrastrukturna potpora ili nositelj kvalitete) — ali svaki slučaj zahtijeva ručnu validaciju.',
povezane_entitete: [{tip:'Klub', naziv:'Više klubova zadovoljava heuristiku'}],
timeline: [{kad:'svakodnevno', sto:'Heuristika prolazi kroz tablicu sufinanciranja'}],
breakdown: [
{kriterij:'Disproporcija veličina/iznos', tezina:30},
{kriterij:'Nepostojanje "nositelj kvalitete" oznake', tezina:25}
]
}
];
async function loadForenzika(){
const root = $('#pg-forenzika');
if(!_forenzika.alerts){
root.innerHTML = '<div class="loading">Učitavanje alarma…</div>';
const al = await api('/v2/alerts');
_forenzika.alerts = Array.isArray(al) ? al : [];
}
if(!_forenzika.custom) _forenzika.custom = _customFindings.slice();
let d = _cache.dash;
if(!d){ d = await api('/dashboard'); if(d) _cache.dash = d; }
d = d || {};
renderForenzikaShell(d);
applyForenzikaFilter();
}
function renderForenzikaShell(d){
const root = $('#pg-forenzika');
const alerts = _forenzika.alerts || [];
const tipovi = Array.from(new Set([
..._forenzika.custom.map(c=>c.tip),
...alerts.map(a=>a.tip).filter(Boolean)
])).sort();
root.innerHTML = `
<div class="kpi-grid">
<div class="kpi r"><div class="kpi-l">Kritičnih</div><div class="kpi-v">${alerts.filter(a=>a.razina==='CRITICAL').length + _forenzika.custom.filter(c=>c.severity==='CRITICAL').length}</div><div class="kpi-s">SEVERITY: CRITICAL</div></div>
<div class="kpi"><div class="kpi-l">High</div><div class="kpi-v">${_forenzika.custom.filter(c=>c.severity==='HIGH').length}</div><div class="kpi-s">forenzički nalazi</div></div>
<div class="kpi b"><div class="kpi-l">Upozorenja</div><div class="kpi-v">${alerts.filter(a=>a.razina==='WARNING').length}</div></div>
<div class="kpi g"><div class="kpi-l">Naplaćeno članarina</div><div class="kpi-v" style="font-size:18px">${fmtEur(d.naplaceno_clanarine_god||0)}</div></div>
<div class="kpi"><div class="kpi-l">Dug članarina</div><div class="kpi-v" style="font-size:18px">${fmtEur(d.dug_clanarine_god||0)}</div></div>
</div>
<div class="toolbar">
<input type="search" id="fz-q" placeholder="🔍 Pretraži po imenu, klubu, nalazu…">
<select id="fz-sev">
<option value="">Sve razine</option>
<option value="CRITICAL">🔴 CRITICAL</option>
<option value="HIGH">🟠 HIGH</option>
<option value="WARNING">🟡 WARNING</option>
<option value="MEDIUM">🟡 MEDIUM</option>
</select>
<select id="fz-tip">
<option value="">Svi tipovi</option>
${tipovi.map(t=>'<option value="'+esc(t)+'">'+esc(t)+'</option>').join('')}
</select>
<span class="tb-s" id="fz-cnt"></span>
</div>
<div class="card" style="border-color:var(--pgz-gold)">
<div class="card-h">
<div class="card-t">⚡ Pokreni novu analizu osobe</div>
<div class="tb-s">civic.persons + entity_links + forensic_findings</div>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
<input type="text" id="fz-scan-name" placeholder="Ime i prezime (npr. Velimir Liverić)" style="background:var(--bg2);border:1px solid var(--rim);border-radius:5px;padding:8px 12px;color:var(--t1);font-size:13px;flex:1;min-width:240px" value="Velimir Liverić">
<button class="btn primary" onclick="runForensicScan()">▶ Pokreni</button>
<button class="btn" onclick="document.getElementById('fz-scan-out').innerHTML=''">Očisti</button>
</div>
<div id="fz-scan-out" style="margin-top:12px"></div>
</div>
<div id="fz-out"></div>
`;
$('#fz-q').addEventListener('input', debounce(applyForenzikaFilter, 200));
$('#fz-sev').addEventListener('change', applyForenzikaFilter);
$('#fz-tip').addEventListener('change', applyForenzikaFilter);
}
function applyForenzikaFilter(){
const q = (($('#fz-q')?$('#fz-q').value:'') || '').toLowerCase().trim();
const sev = $('#fz-sev') ? $('#fz-sev').value : '';
const tip = $('#fz-tip') ? $('#fz-tip').value : '';
// Combine custom findings + DB alerts into a unified list
const combined = [];
for(const c of (_forenzika.custom||[])){
combined.push({
_kind: 'custom', id: c.id, severity: c.severity, tip: c.tip,
naslov: c.naslov, poruka: c.sazetak, klub_id: null, clan_id: null, datum: null,
raw: c
});
}
for(const a of (_forenzika.alerts||[])){
combined.push({
_kind: 'alert', id: a.id, severity: a.razina, tip: a.tip,
naslov: '['+a.tip+'] '+(a.poruka||'').slice(0,80),
poruka: a.poruka, klub_id: a.klub_id, clan_id: a.clan_id, datum: a.datum,
raw: a
});
}
let rows = combined;
if(q) rows = rows.filter(r => (r.naslov||'').toLowerCase().includes(q) || (r.poruka||'').toLowerCase().includes(q) || (r.tip||'').toLowerCase().includes(q));
if(sev) rows = rows.filter(r => r.severity===sev);
if(tip) rows = rows.filter(r => r.tip===tip);
$('#fz-cnt').textContent = rows.length+' nalaza';
const out = $('#fz-out');
if(!rows.length){ out.innerHTML='<div class="empty">Nema rezultata pri tim filtrima</div>'; return; }
out.innerHTML = rows.map((r,i) => {
const sevClass = r.severity==='CRITICAL'?'crit':(r.severity==='HIGH'?'crit':'');
const sevColor = r.severity==='CRITICAL'?'rd':(r.severity==='HIGH'?'am':'b');
return `
<div class="alert-card ${sevClass}" style="cursor:pointer;display:flex;align-items:center;gap:12px;padding:12px 14px" onclick="openForensicDetail('${r._kind}','${r.id}')">
<div style="font-size:22px;flex-shrink:0">${r.severity==='CRITICAL'?'🔴':r.severity==='HIGH'?'🟠':'🟡'}</div>
<div style="flex:1;min-width:0">
<div class="at">${esc(r.naslov)}</div>
<div class="ad">${esc(r.poruka||'')}</div>
${r.datum?'<div class="ad">📅 '+fmtDate(r.datum)+'</div>':''}
</div>
<div><span class="tag ${sevColor}">${esc(r.severity||'?')}</span></div>
<div style="color:var(--t4);font-size:18px"></div>
</div>
`;
}).join('');
}
function openForensicDetail(kind, id){
if(kind === 'custom'){
const c = (_forenzika.custom||[]).find(x => x.id === id);
if(!c){ openPanel('Nalaz', '<div class="empty">Nalaz nije pronađen</div>'); return; }
return renderCustomFindingPanel(c);
} else {
const a = (_forenzika.alerts||[]).find(x => String(x.id) === String(id));
if(!a){ openPanel('Alarm', '<div class="empty">Alarm nije pronađen</div>'); return; }
return renderAlertPanel(a);
}
}
function renderCustomFindingPanel(c){
const sevColor = c.severity==='CRITICAL'?'rd':(c.severity==='HIGH'?'am':'b');
const html = `
<div class="card-h" style="border:0;padding:0;margin-bottom:14px">
<div>
<div style="font-size:18px;font-weight:800;color:var(--t0)">${esc(c.naslov)}</div>
<div style="font-size:12px;color:var(--t2);margin-top:4px">
<span class="tag ${sevColor}">${esc(c.severity)}</span>
<span class="tag">${esc(c.tip)}</span>
${c.osoba?'<span class="tag b">'+esc(c.osoba)+'</span>':''}
</div>
</div>
</div>
<div class="kpi-grid" style="grid-template-columns:1fr 1fr 1fr;margin-bottom:14px">
<div class="kpi r"><div class="kpi-l">Risk Score</div><div class="kpi-v">${c.risk_score||0}<span style="font-size:14px;color:var(--t2)">/100</span></div></div>
<div class="kpi"><div class="kpi-l">Severity</div><div class="kpi-v" style="font-size:16px">${esc(c.severity)}</div></div>
<div class="kpi b"><div class="kpi-l">Tip</div><div class="kpi-v" style="font-size:14px">${esc(c.tip)}</div></div>
</div>
<div class="card">
<div class="card-h"><div class="card-t">📖 Uvod u nalaz</div></div>
<div style="font-size:12px;line-height:1.6;color:var(--t1)">${esc(c.uvod)}</div>
</div>
${c.povezane_entitete && c.povezane_entitete.length ? `
<div class="card">
<div class="card-h"><div class="card-t">🔗 Povezani entiteti (${c.povezane_entitete.length})</div></div>
<table>
<thead><tr><th>Tip</th><th>Naziv</th><th>Napomena</th></tr></thead>
<tbody>${c.povezane_entitete.map(e => `
<tr class="no-click">
<td><span class="tag b">${esc(e.tip)}</span></td>
<td><b>${esc(e.naziv)}</b></td>
<td>${esc(e.napomena||'')}</td>
</tr>`).join('')}</tbody>
</table>
</div>` : ''}
${c.timeline && c.timeline.length ? `
<div class="card">
<div class="card-h"><div class="card-t">📅 Vremenska linija</div></div>
<div style="display:flex;flex-direction:column;gap:8px">
${c.timeline.map(t => `
<div style="display:flex;gap:10px;padding:8px 10px;background:var(--bg3);border-radius:5px;border-left:3px solid var(--pgz-blue2)">
<div style="font-family:var(--mono);color:var(--pgz-gold);font-weight:700;font-size:11px;min-width:90px">${esc(t.kad)}</div>
<div style="font-size:12px;color:var(--t1)">${esc(t.sto)}</div>
</div>
`).join('')}
</div>
</div>` : ''}
${c.breakdown && c.breakdown.length ? `
<div class="card">
<div class="card-h"><div class="card-t">📊 Risk score breakdown</div></div>
<div style="display:flex;flex-direction:column;gap:8px">
${c.breakdown.map(b => {
const pct = Math.min(100, Math.round((b.tezina/100)*100));
return `
<div>
<div style="display:flex;justify-content:space-between;font-size:11px;margin-bottom:3px">
<span style="color:var(--t1)">${esc(b.kriterij)}</span>
<span style="font-family:var(--mono);color:var(--pgz-gold);font-weight:700">+${b.tezina}</span>
</div>
<div style="height:6px;background:var(--bg3);border-radius:3px;overflow:hidden">
<div style="height:100%;background:linear-gradient(90deg,var(--red),var(--pgz-gold));width:${pct}%"></div>
</div>
</div>`;
}).join('')}
</div>
</div>` : ''}
<div class="card">
<div class="card-h"><div class="card-t">📄 Dokumenti / dokazi</div></div>
<div class="empty" style="padding:14px">Za ovaj manualni nalaz nisu priloženi PDF dokazi. Pokreni "Obogati podatke" za prikupljanje izvora.</div>
</div>
${customFindingEnrichBlock(c.id, c.osoba || c.naslov)}
`;
openPanel('Forenzika · '+c.naslov, html);
}
function customFindingEnrichBlock(customId, queryName){
const safeId = String(customId).replace(/[^a-z0-9_-]/gi,'_');
return `
<div class="card" id="fenrich-card-${safeId}">
<div class="card-h">
<div class="card-t">✨ Obogati podatke (Wikipedia)</div>
<button class="btn primary" onclick="enrichCustomFinding('${safeId}', '${esc(queryName).replace(/'/g,'\\\\&#39;')}')">▶ Pokreni</button>
</div>
<div id="fenrich-out-${safeId}">
<div class="empty" style="padding:14px">Lookup Wikipedia HR za "${esc(queryName)}" i prikaži dopune.</div>
</div>
</div>
`;
}
async function enrichCustomFinding(safeId, queryName){
const out = document.getElementById('fenrich-out-'+safeId);
if(out) out.innerHTML = '<div class="loading">Lookup Wikipedia HR…</div>';
// Custom findings aren't in DB — call wiki lookup via a synthesised forensic_findings.id of -1 won't work.
// Instead, use the existing /v2/enrich/sportas pattern to query Wikipedia by name.
// We re-use the wiki summary via a mini fetch helper.
try{
const wiki = await fetch('https://hr.wikipedia.org/api/rest_v1/page/summary/'+encodeURIComponent(queryName.replace(/ /g,'_')))
.then(r => r.ok ? r.json() : null).catch(()=>null);
if(!wiki || wiki.type==='disambiguation' || !wiki.extract){
if(out) out.innerHTML = '<div class="empty" style="padding:14px">Nije pronađen Wikipedia HR članak za <b>'+esc(queryName)+'</b>.</div>';
return;
}
const w = {title: wiki.title, extract: wiki.extract, description: wiki.description, url: (wiki.content_urls||{}).desktop?.page};
if(out) out.innerHTML = `
<div style="margin-bottom:8px"><span class="tag gr">🟢 Wikipedia HR</span></div>
<div style="padding:10px;background:var(--bg3);border-left:3px solid var(--pgz-gold);border-radius:5px">
<div style="font-weight:700;color:var(--t0);font-size:14px;margin-bottom:6px">${esc(w.title||'')}</div>
${w.description?'<div style="font-size:11px;color:var(--t2);margin-bottom:6px;font-style:italic">'+esc(w.description)+'</div>':''}
${w.extract?'<div style="font-size:12px;line-height:1.6;color:var(--t1)">'+esc(w.extract)+'</div>':''}
${w.url?'<div style="margin-top:8px"><a href="'+esc(w.url)+'" target="_blank">↗ Otvori članak</a></div>':''}
</div>`;
}catch(e){
if(out) out.innerHTML = '<div class="empty" style="color:var(--red);padding:14px">Greška: '+esc(String(e))+'</div>';
}
}
function renderAlertPanel(a){
const sevColor = a.razina==='CRITICAL'?'rd':(a.razina==='HIGH'?'am':'b');
const html = `
<div class="card-h" style="border:0;padding:0;margin-bottom:14px">
<div>
<div style="font-size:18px;font-weight:800;color:var(--t0)">[${esc(a.tip)}]</div>
<div style="font-size:12px;color:var(--t2);margin-top:4px">
<span class="tag ${sevColor}">${esc(a.razina)}</span>
${a.datum?'<span class="tag">📅 '+fmtDate(a.datum)+'</span>':''}
</div>
</div>
</div>
<div class="card">
<div class="card-h"><div class="card-t">📖 Opis alarma</div></div>
<div style="font-size:13px;line-height:1.6;color:var(--t1)">${esc(a.poruka||'')}</div>
</div>
<div class="card">
<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>' : ''}
</tbody>
</table>
${(!a.klub_id && !a.clan_id) ? '<div class="empty" style="padding:14px">Nema povezanih entiteta u alarmu</div>' : ''}
</div>
<div class="card">
<div class="card-h"><div class="card-t">📅 Metapodaci</div></div>
<div class="kv">
<div class="k">ID</div><div class="v">${esc(a.id)}</div>
<div class="k">Tip</div><div class="v">${esc(a.tip)}</div>
<div class="k">Razina</div><div class="v">${esc(a.razina)}</div>
<div class="k">Datum događaja</div><div class="v">${a.datum?fmtDate(a.datum):'—'}</div>
<div class="k">Iznos</div><div class="v">${a.iznos!=null?fmtEurFull(a.iznos):'—'}</div>
<div class="k">Riješeno</div><div class="v">${a.rijeseno?'<span class="tag gr">DA</span>':'<span class="tag rd">NE</span>'}</div>
<div class="k">Kreirano</div><div class="v">${a.created_at?fmtDate(a.created_at):'—'}</div>
</div>
</div>
${forensicEnrichBlock(a.id)}
`;
openPanel('Alarm #'+a.id, html);
}
function forensicEnrichBlock(findingId){
return `
<div class="card" id="fenrich-card-${findingId}">
<div class="card-h">
<div class="card-t">✨ Obogati podatke (Wikipedia)</div>
<button class="btn primary" onclick="enrichForensicFinding(${findingId})">▶ Pokreni</button>
</div>
<div id="fenrich-out-${findingId}">
<div class="empty" style="padding:14px">Ekstrakcija imena iz nalaza, lookup na Wikipedia HR i sprema u DB. Drugi puta će biti vidljivo bez ponovnog skidanja.</div>
</div>
</div>
`;
}
async function enrichForensicFinding(findingId){
const out = document.getElementById('fenrich-out-'+findingId);
if(out) out.innerHTML = '<div class="loading">Ekstraktiram ime, lookup Wikipedia HR…</div>';
const r = await apiPost('/v2/forensic/findings/'+findingId+'/enrich');
if(!r){ if(out) out.innerHTML = '<div class="empty" style="color:var(--red)">Greška</div>'; return; }
const w = r.wiki || null;
const html = `
<div style="margin-bottom:8px">
<span class="tag gr">🟢 Persisted</span>
${r.used_query?'<span class="tag b">query: '+esc(r.used_query)+'</span>':''}
</div>
${w ? `
<div style="padding:10px;background:var(--bg3);border-left:3px solid var(--pgz-gold);border-radius:5px">
<div style="font-size:11px;color:var(--t4);text-transform:uppercase;letter-spacing:.5px;margin-bottom:4px">📚 Wikipedia HR</div>
<div style="font-weight:700;color:var(--t0);font-size:14px;margin-bottom:6px">${esc(w.title||'')}</div>
${w.description?'<div style="font-size:11px;color:var(--t2);margin-bottom:6px;font-style:italic">'+esc(w.description)+'</div>':''}
${w.extract?'<div style="font-size:12px;line-height:1.6;color:var(--t1)">'+esc(w.extract)+'</div>':''}
${w.url?'<div style="margin-top:8px"><a href="'+esc(w.url)+'" target="_blank">↗ Otvori članak</a></div>':''}
</div>
` : '<div class="empty" style="padding:14px">Nije pronađen Wikipedia HR članak za ekstrahirana imena.<br>Pokušaji: '+esc(JSON.stringify(r.queried||[]))+'</div>'}
`;
if(out) out.innerHTML = html;
}
async function runForensicScan(){
const inputEl = document.getElementById('fz-scan-name');
const outEl = document.getElementById('fz-scan-out');
if(!inputEl || !outEl) return;
const name = (inputEl.value||'').trim();
if(name.length < 3){ outEl.innerHTML = '<div class="empty">Unesi barem 3 znaka</div>'; return; }
outEl.innerHTML = '<div class="loading">Skeniram civic.persons… tražim povezane entitete… provjeravam forensic_findings…</div>';
const r = await apiPost('/v2/forensic/scan', {name: name});
if(!r){ outEl.innerHTML = '<div class="empty" style="color:var(--red)">Greška pri pokretanju analize</div>'; return; }
const ovr = r.overall_risk_score || 0;
const ovrCls = ovr>=70?'r':(ovr>=40?'':'g');
outEl.innerHTML = `
<div class="kpi-grid" style="grid-template-columns:repeat(4,1fr);margin-bottom:14px">
<div class="kpi ${ovrCls}"><div class="kpi-l">Overall risk</div><div class="kpi-v">${ovr}<span style="font-size:13px;color:var(--t2)">/100</span></div></div>
<div class="kpi b"><div class="kpi-l">Pronađeno osoba</div><div class="kpi-v">${r.matched_persons}</div></div>
<div class="kpi"><div class="kpi-l">Veza</div><div class="kpi-v">${r.total_links}</div></div>
<div class="kpi r"><div class="kpi-l">Findings</div><div class="kpi-v">${r.total_findings}<span style="font-size:13px;color:var(--t2)"> (${r.critical_findings} crit)</span></div></div>
</div>
${(r.persons||[]).length ? `
<div style="display:flex;flex-direction:column;gap:10px">
${r.persons.map(p => {
const cls = p.risk_score>=70?'crit':(p.risk_score>=40?'crit':'');
return `
<div class="alert-card ${cls}">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:12px">
<div style="flex:1;min-width:0">
<div class="at">${esc(p.name)} <span class="tag b">id ${p.id}</span> ${p.oib?'<a class="tag" onclick="openOIB(&quot;'+esc(p.oib)+'&quot;)" style="cursor:pointer">OIB '+esc(p.oib)+'</a>':''}</div>
<div class="ad">${p.function?esc(p.function):''}${p.party?' · '+esc(p.party):''}${p.county?' · '+esc(p.county):''}</div>
<div style="margin-top:6px;font-size:11px;color:var(--t2)">
🔗 ${(p.links||[]).length} povezanih entiteta
· ⚠ ${(p.findings||[]).length} forenzičkih nalaza
${p.trust_tier!=null?' · trust tier '+p.trust_tier:''}
</div>
${(p.links||[]).length ? '<div style="margin-top:8px;font-size:11px"><b style="color:var(--t2)">Veze:</b> '+
p.links.slice(0,8).map(l => '<span class="tag b" style="margin-right:3px">'+esc(l.entity_name||'#'+l.entity_id)+(l.roles?' · '+esc(l.roles):'')+'</span>').join('')+
((p.links||[]).length>8?' <span class="tag">+'+((p.links||[]).length-8)+' više</span>':'')+
'</div>' : ''}
${(p.findings||[]).length ? '<div style="margin-top:8px;font-size:11px"><b style="color:var(--t2)">Nalazi:</b><br>'+
p.findings.slice(0,5).map(f => '<div style="margin-top:3px"><span class="tag '+(f.severity==='CRITICAL'?'rd':f.severity==='HIGH'?'am':'b')+'">'+esc(f.severity)+'</span> '+esc(f.title||f.finding_type)+'</div>').join('')+
'</div>' : ''}
</div>
<div style="text-align:center;flex-shrink:0">
<div style="font-size:24px;font-weight:800;color:${p.risk_score>=70?'var(--red)':p.risk_score>=40?'var(--amber)':'var(--green)'};font-family:var(--mono)">${p.risk_score}</div>
<div style="font-size:10px;color:var(--t4);text-transform:uppercase">RISK</div>
</div>
</div>
</div>`;
}).join('')}
</div>
` : '<div class="empty">Nema pronađenih osoba s tim imenom</div>'}
`;
}
//=========== INIT ===========
function init(){
restoreSidebar();
buildNav();
navTo('dashboard');
}
window.addEventListener('DOMContentLoaded', init);
</script>
</body>
</html>