CC1 R2 — full Round 2 done (8/8 stavki)
- geocode_objekti_v2.py + DB updates (Kastav, Rujevica, Platak, Petehovac, Crikvenica, Krk hand-curated)
- Maps URL → /maps/search/?api=1 format for proper pin
- Dashboard: year selector for nositelji, click → klub/PDF panel; top savezi clickable
- Universal sort (asc/desc) on Savezi/Klubovi/Sportaši/Objekti/Manifestacije/Financije
- Card↔Table toggle on Financije
- Manifestacije: source_url direct open, Google fallback
- Forenzika: severity/tip filter, search, run-scan, Liverić PEP custom findings + DB alerts
- Enrich endpoint /api/v2/enrich/{kind}/{id} + button on savez/klub/sportaš panels
- New 'Mreža' section: D3 force graph from /api/v1/presenter/graph-real
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1376,6 +1376,15 @@ if HAS_S3_ROUTERS:
|
||||
app.include_router(img_proxy_router, prefix='/api/v2')
|
||||
app.include_router(audit_coverage_router, prefix='/api/v2')
|
||||
|
||||
# Round-2 enrichment endpoint
|
||||
try:
|
||||
from enrich_router import router as enrich_router
|
||||
app.include_router(enrich_router, prefix='/api/v2')
|
||||
print('[ENRICH] router loaded')
|
||||
except Exception as e:
|
||||
print(f'[ENRICH] router fail: {e}')
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"""
|
||||
enrich_router.py — Round-2 enrichment endpoint
|
||||
Author: dradulic@outlook.com Date: 2026-05-04
|
||||
|
||||
Surfaces "Obogati podatke" buttons for klubovi, savezi, sportasi.
|
||||
|
||||
Strategy:
|
||||
1) Read what's already in DB and surface fields the frontend may not have shown.
|
||||
2) Build curated research URLs (Google, Wikipedia HR, Sportilus, sport-pgz.hr,
|
||||
HNS Semafor) so the operator can verify or expand by hand.
|
||||
3) If the entity has a `web` URL set, quickly fetch the page and extract
|
||||
<title> + <meta description> to return as a "live snippet". 5s timeout, fail-soft.
|
||||
"""
|
||||
import os, re, json, time, urllib.parse, urllib.request, html
|
||||
import psycopg2, psycopg2.extras
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
DB = dict(host=os.environ.get('PG_HOST','10.10.0.2'),
|
||||
port=int(os.environ.get('PG_PORT','6432')),
|
||||
dbname=os.environ.get('PG_DB','rinet_v3'),
|
||||
user=os.environ.get('PG_USER','rinet'),
|
||||
password=os.environ.get('PG_PASS',''))
|
||||
|
||||
UA = 'pgz-sport-enrich/2.0'
|
||||
|
||||
def _db():
|
||||
c = psycopg2.connect(**DB); c.autocommit = True; return c
|
||||
|
||||
def _fetch_one(sql, p):
|
||||
with _db() as c, c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||
cur.execute(sql, p)
|
||||
r = cur.fetchone()
|
||||
return dict(r) if r else None
|
||||
|
||||
def _fetch_title(url, timeout=5):
|
||||
if not url: return None
|
||||
try:
|
||||
if not url.startswith('http'):
|
||||
return None
|
||||
req = urllib.request.Request(url, headers={'User-Agent': UA})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
data = r.read(40000).decode('utf-8','ignore')
|
||||
title_m = re.search(r'<title[^>]*>([^<]+)</title>', data, re.I)
|
||||
desc_m = re.search(r'<meta\s+name=["\']description["\']\s+content=["\']([^"\']+)["\']', data, re.I)
|
||||
og_desc_m = re.search(r'<meta\s+property=["\']og:description["\']\s+content=["\']([^"\']+)["\']', data, re.I)
|
||||
return {
|
||||
'url': url,
|
||||
'title': html.unescape(title_m.group(1).strip())[:300] if title_m else None,
|
||||
'description': html.unescape((desc_m or og_desc_m).group(1).strip())[:500] if (desc_m or og_desc_m) else None,
|
||||
'fetched_at': int(time.time()),
|
||||
}
|
||||
except Exception as e:
|
||||
return {'url': url, 'error': str(e)[:120]}
|
||||
|
||||
def _research_links(naziv, kind, grad=None):
|
||||
base_q = (naziv or '').strip()
|
||||
if grad: q = base_q + ' ' + grad
|
||||
else: q = base_q
|
||||
qenc = urllib.parse.quote(q)
|
||||
out = [
|
||||
{'label':'Google', 'icon':'🔍', 'url':'https://www.google.com/search?q='+qenc},
|
||||
{'label':'Wikipedia HR', 'icon':'📚', 'url':'https://hr.wikipedia.org/w/index.php?search='+qenc},
|
||||
{'label':'sport-pgz.hr', 'icon':'🏅', 'url':'https://sport-pgz.hr/?s='+qenc},
|
||||
]
|
||||
if kind == 'klub':
|
||||
out.append({'label':'Sportilus', 'icon':'⬡', 'url':'https://www.sportilus.com/?s='+qenc})
|
||||
out.append({'label':'Sudski registar', 'icon':'⚖', 'url':'https://sudreg.pravosudje.hr/registar/oc/index.html'})
|
||||
if kind == 'sportas':
|
||||
out.append({'label':'HNS Semafor', 'icon':'⚽', 'url':'https://semafor.hns.family/?s='+qenc})
|
||||
out.append({'label':'transfermarkt', 'icon':'⚽', 'url':'https://www.transfermarkt.com/schnellsuche/ergebnis/schnellsuche?query='+qenc})
|
||||
if kind == 'savez':
|
||||
out.append({'label':'sport-pgz.hr savezi', 'icon':'🏅', 'url':'https://sport-pgz.hr/savezi'})
|
||||
return out
|
||||
|
||||
@router.post("/enrich/{kind}/{eid}")
|
||||
def enrich(kind: str, eid: int):
|
||||
if kind not in ('klub','savez','sportas'):
|
||||
raise HTTPException(400, "kind must be klub|savez|sportas")
|
||||
|
||||
if kind == 'klub':
|
||||
row = _fetch_one("""SELECT id, naziv, oib, sport, grad, predsjednik, tajnik,
|
||||
web, web_stranica, email, telefon, ciljevi, opis_djelatnosti,
|
||||
sjediste, godina_osnutka, savez_id, scrape_url, source_url
|
||||
FROM pgz_sport.klubovi WHERE id=%s""", (eid,))
|
||||
elif kind == 'savez':
|
||||
row = _fetch_one("""SELECT id, naziv, oib, sport, predsjednik, tajnik, email, telefon, web,
|
||||
adresa, godina_osnutka, source_url
|
||||
FROM pgz_sport.savezi WHERE id=%s""", (eid,))
|
||||
else: # sportas
|
||||
row = _fetch_one("""SELECT id, ime, prezime, sport, klub_id, profile_url, scrape_url,
|
||||
slika_url, source_url, hns_igrac_id, biografija
|
||||
FROM pgz_sport.clanovi WHERE id=%s""", (eid,))
|
||||
if not row:
|
||||
raise HTTPException(404, kind+" not found")
|
||||
|
||||
# Build display name
|
||||
if kind == 'sportas':
|
||||
naziv = (row.get('ime','') + ' ' + row.get('prezime','')).strip()
|
||||
grad = None
|
||||
else:
|
||||
naziv = row.get('naziv','')
|
||||
grad = row.get('grad') if kind=='klub' else None
|
||||
|
||||
# Live web snippet from primary URL
|
||||
primary = row.get('web') or row.get('web_stranica') or row.get('source_url') or row.get('scrape_url') or row.get('profile_url')
|
||||
snippet = _fetch_title(primary) if primary else None
|
||||
|
||||
# Coverage score: how many key fields are filled?
|
||||
if kind == 'klub':
|
||||
keys = ['oib','sport','grad','predsjednik','tajnik','web','email','telefon','sjediste','godina_osnutka','ciljevi']
|
||||
elif kind == 'savez':
|
||||
keys = ['oib','sport','predsjednik','tajnik','email','telefon','web','adresa','godina_osnutka']
|
||||
else:
|
||||
keys = ['sport','profile_url','slika_url','hns_igrac_id','biografija']
|
||||
filled = sum(1 for k in keys if row.get(k))
|
||||
coverage = round(filled/len(keys)*100)
|
||||
|
||||
# Suggested missing fields
|
||||
missing = [k for k in keys if not row.get(k)]
|
||||
|
||||
return {
|
||||
'kind': kind,
|
||||
'id': eid,
|
||||
'naziv': naziv,
|
||||
'coverage': coverage,
|
||||
'filled_fields': filled,
|
||||
'total_fields': len(keys),
|
||||
'missing_fields': missing,
|
||||
'live_snippet': snippet,
|
||||
'research_links': _research_links(naziv, kind, grad),
|
||||
'enriched_at': int(time.time()),
|
||||
}
|
||||
+401
-11
@@ -30,19 +30,37 @@ button,input,select{font-family:inherit;font-size:inherit;outline:none}
|
||||
::-webkit-scrollbar-thumb:hover{background:var(--pgz-blue2)}
|
||||
|
||||
.app{display:flex;min-height:100vh}
|
||||
.sb{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}
|
||||
.sb-h{padding:18px 18px 14px;border-bottom:1px solid var(--rim)}
|
||||
.sb-h .logo{font-weight:800;font-size:14px;color:var(--t0);letter-spacing:.5px}
|
||||
.sb{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}
|
||||
.sb-nav{flex:1;padding:10px 8px;overflow-y:auto}
|
||||
.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:all .15s}
|
||||
.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}
|
||||
.sb-foot{padding:10px 14px;border-top:1px solid var(--rim);font-size:10px;color:var(--t4)}
|
||||
.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}
|
||||
|
||||
.main{margin-left:240px;flex:1;min-width:0}
|
||||
/* 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)}
|
||||
|
||||
.main{margin-left:240px;flex:1;min-width:0;transition:margin-left .22s ease}
|
||||
.sb.collapsed ~ .main{margin-left:58px}
|
||||
.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)}
|
||||
@@ -189,10 +207,11 @@ table tbody tr.no-click:hover{background:transparent}
|
||||
<body>
|
||||
|
||||
<div class="app">
|
||||
<aside class="sb">
|
||||
<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">⮜</div>
|
||||
</div>
|
||||
<nav class="sb-nav" id="nav"></nav>
|
||||
<div class="sb-foot">v2.0 · 2026</div>
|
||||
@@ -217,6 +236,7 @@ table tbody tr.no-click:hover{background:transparent}
|
||||
<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>
|
||||
</div>
|
||||
</main>
|
||||
@@ -242,6 +262,7 @@ const NAV_ITEMS = [
|
||||
{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'}
|
||||
];
|
||||
const SECTION_TITLES = {
|
||||
@@ -252,6 +273,7 @@ const SECTION_TITLES = {
|
||||
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']
|
||||
};
|
||||
|
||||
@@ -307,6 +329,68 @@ async function api(path){
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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; }
|
||||
const cov = r.coverage||0;
|
||||
const covCls = cov>=70?'high':(cov>=40?'mid':'low');
|
||||
const html = `
|
||||
<div style="display:flex;gap:8px;align-items:center;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>
|
||||
</div>
|
||||
${r.live_snippet && r.live_snippet.title ? `
|
||||
<div style="padding:10px;background:var(--bg3);border-left:3px solid var(--pgz-gold);border-radius:5px;margin-bottom:10px">
|
||||
<div style="font-size:11px;color:var(--t4);text-transform:uppercase;letter-spacing:.5px;margin-bottom:4px">📡 Live snippet</div>
|
||||
<div style="font-weight:700;color:var(--t0);font-size:13px;margin-bottom:4px">${esc(r.live_snippet.title)}</div>
|
||||
${r.live_snippet.description ? '<div style="font-size:11.5px;color:var(--t1);line-height:1.5">'+esc(r.live_snippet.description)+'</div>' : ''}
|
||||
<div style="margin-top:6px"><a href="${esc(r.live_snippet.url)}" target="_blank">↗ ${esc(r.live_snippet.url.slice(0,80))}</a></div>
|
||||
</div>
|
||||
` : ''}
|
||||
${r.missing_fields && r.missing_fields.length ? `
|
||||
<div style="margin-bottom:10px">
|
||||
<div style="font-size:11px;color:var(--t2);margin-bottom:4px">Nedostaje:</div>
|
||||
<div>${r.missing_fields.map(f=>'<span class="tag rd">'+esc(f)+'</span>').join('')}</div>
|
||||
</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 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();
|
||||
@@ -376,7 +460,26 @@ 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+'" onclick="navTo(\''+n.id+'\')"><span class="ic">'+n.ic+'</span><span>'+n.label+'</span></div>').join('');
|
||||
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('');
|
||||
}
|
||||
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 = isCollapsed ? '⮞' : '⮜';
|
||||
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;
|
||||
@@ -398,6 +501,7 @@ function loadSection(id){
|
||||
case 'financije': return loadFinancije();
|
||||
case 'objekti': return loadObjekti();
|
||||
case 'manifestacije': return loadManifestacije();
|
||||
case 'mreza': return loadMreza();
|
||||
case 'forenzika': return loadForenzika();
|
||||
}
|
||||
}
|
||||
@@ -734,6 +838,8 @@ async function openSavez(id){
|
||||
</tbody>
|
||||
</table></div>` : '<div class="empty">Nema podataka o klubovima</div>'}
|
||||
</div>
|
||||
|
||||
${enrichBlock('savez', s.id)}
|
||||
`;
|
||||
openPanel('Savez · '+s.naziv, html);
|
||||
}
|
||||
@@ -917,6 +1023,8 @@ async function openKlub(id){
|
||||
</tbody>
|
||||
</table></div>` : '<div class="empty">Nema zabilježenih potpora</div>'}
|
||||
</div>
|
||||
|
||||
${enrichBlock('klub', k.id)}
|
||||
`;
|
||||
openPanel('Klub · '+(k.naziv||''), html);
|
||||
}
|
||||
@@ -1161,6 +1269,8 @@ async function openSportas(id){
|
||||
</tbody>
|
||||
</table></div>` : '<div class="empty">Nema zabilježenih nagrada</div>'}
|
||||
</div>
|
||||
|
||||
${enrichBlock('sportas', d.id)}
|
||||
`;
|
||||
openPanel('Sportaš · '+(d.ime||'')+' '+(d.prezime||''), html);
|
||||
}
|
||||
@@ -1544,6 +1654,285 @@ function openManif(id){
|
||||
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; }
|
||||
_mreza.data = resp.data;
|
||||
_mreza.allNodes = (resp.data.nodes||[]).slice();
|
||||
_mreza.allEdges = (resp.data.edges||[]).slice();
|
||||
}
|
||||
renderMrezaShell();
|
||||
renderMrezaGraph();
|
||||
}
|
||||
|
||||
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">
|
||||
<input type="search" id="mr-osoba" placeholder="👤 Osoba…">
|
||||
<input type="search" id="mr-klub" placeholder="🏟 Klub / Savez…">
|
||||
<input type="search" id="mr-tvrtka" placeholder="🏢 Tvrtka / Entitet…">
|
||||
<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>
|
||||
<span class="tb-s" id="mr-cnt"></span>
|
||||
</div>
|
||||
|
||||
<div class="card" style="padding:0;overflow:hidden">
|
||||
<div id="mr-graph" style="width:100%;height:640px;background:radial-gradient(ellipse at center,var(--bg2) 0%,var(--bg0) 100%);position:relative">
|
||||
<svg id="mr-svg" style="width:100%;height:100%"></svg>
|
||||
</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</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
$('#mr-osoba').addEventListener('input', debounce(applyMrezaFilter, 200));
|
||||
$('#mr-klub').addEventListener('input', debounce(applyMrezaFilter, 200));
|
||||
$('#mr-tvrtka').addEventListener('input', debounce(applyMrezaFilter, 200));
|
||||
$('#mr-tip').addEventListener('change', applyMrezaFilter);
|
||||
}
|
||||
|
||||
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 svgEl = document.getElementById('mr-svg');
|
||||
if(!svgEl) return;
|
||||
const container = document.getElementById('mr-graph');
|
||||
const W = container.clientWidth || 800;
|
||||
const H = container.clientHeight || 640;
|
||||
|
||||
if(_mreza.sim){ try{_mreza.sim.stop();}catch(e){} _mreza.sim = null; }
|
||||
|
||||
const svg = d3.select(svgEl);
|
||||
svg.selectAll('*').remove();
|
||||
svg.attr('viewBox', '0 0 '+W+' '+H);
|
||||
|
||||
// Deep-copy so D3 sim doesn't mutate originals
|
||||
const N = nodes.map(n => Object.assign({}, n));
|
||||
const Nmap = new Map(N.map(n=>[n.id, n]));
|
||||
const E = edges.map(e => ({
|
||||
source: Nmap.get(e.source.id||e.source) || (e.source.id||e.source),
|
||||
target: Nmap.get(e.target.id||e.target) || (e.target.id||e.target),
|
||||
color: e.color, size: e.size
|
||||
})).filter(e => typeof e.source === 'object' && typeof e.target === 'object');
|
||||
|
||||
// Zoom/pan
|
||||
const g = svg.append('g');
|
||||
svg.call(d3.zoom().scaleExtent([0.2, 5]).on('zoom', (ev) => g.attr('transform', ev.transform)));
|
||||
|
||||
const sim = d3.forceSimulation(N)
|
||||
.force('link', d3.forceLink(E).id(d => d.id).distance(d => 60 + 20/(d.size||1)))
|
||||
.force('charge', d3.forceManyBody().strength(d => -50 - (d.size||5)*4))
|
||||
.force('center', d3.forceCenter(W/2, H/2))
|
||||
.force('collide', d3.forceCollide().radius(d => Math.max(6, (d.size||5)*0.7 + 4)));
|
||||
_mreza.sim = sim;
|
||||
|
||||
const link = g.append('g')
|
||||
.attr('stroke-opacity', 0.5)
|
||||
.selectAll('line').data(E).join('line')
|
||||
.attr('stroke', d => d.color || '#283560')
|
||||
.attr('stroke-width', d => Math.max(0.4, (d.size||0.4)));
|
||||
|
||||
const node = g.append('g')
|
||||
.selectAll('g').data(N).join('g')
|
||||
.style('cursor','pointer')
|
||||
.call(d3.drag()
|
||||
.on('start', (ev,d) => { if(!ev.active) sim.alphaTarget(0.3).restart(); d.fx=d.x; d.fy=d.y; })
|
||||
.on('drag', (ev,d) => { d.fx=ev.x; d.fy=ev.y; })
|
||||
.on('end', (ev,d) => { if(!ev.active) sim.alphaTarget(0); d.fx=null; d.fy=null; }))
|
||||
.on('click', (ev,d) => openMrezaNode(d));
|
||||
|
||||
node.append('circle')
|
||||
.attr('r', d => Math.max(5, (d.size||5)*0.7))
|
||||
.attr('fill', d => d.color || '#004CC4')
|
||||
.attr('stroke', '#0d1021')
|
||||
.attr('stroke-width', 1.5);
|
||||
|
||||
node.append('text')
|
||||
.text(d => (d.label||'').slice(0,28))
|
||||
.attr('x', d => Math.max(6, (d.size||5)*0.7) + 4)
|
||||
.attr('y', 4)
|
||||
.attr('fill', '#e2e6f0')
|
||||
.attr('font-size', '10px')
|
||||
.attr('font-family', 'Inter, sans-serif')
|
||||
.style('pointer-events','none');
|
||||
|
||||
node.append('title').text(d => (d.label||'')+' ['+d.type+']');
|
||||
|
||||
sim.on('tick', () => {
|
||||
link.attr('x1', d=>d.source.x).attr('y1', d=>d.source.y)
|
||||
.attr('x2', d=>d.target.x).attr('y2', d=>d.target.y);
|
||||
node.attr('transform', d => 'translate('+d.x+','+d.y+')');
|
||||
});
|
||||
|
||||
if(!$('#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,'"')})">
|
||||
<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:''}};
|
||||
|
||||
@@ -1858,6 +2247,7 @@ async function runForensicScan(){
|
||||
|
||||
//=========== INIT ===========
|
||||
function init(){
|
||||
restoreSidebar();
|
||||
buildNav();
|
||||
navTo('dashboard');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user