CC3 R3 M3+M4: sport2 sidebar + app.html operativna aplikacija
M3 (sport2.html): - ≡ toggle gumb u sidebar headeru, .sb.collapsed -> 58px (samo ikone) - localStorage 'sidebar-state' (expanded|collapsed) - restoreSidebar() pri DOMContentLoaded, prije buildNav - Hover tooltip na collapsed nav itemima preko data-label M4 (static/app.html — novi): - 4 dashboard varijante po roli: PGŽ admin, Savez admin, Klub admin, Sportaš - Role switch u topbar-u (demo) + localStorage 'app-role' - Sidebar collapse (M3 logika), tooltip-ovi na collapsed - Sidebar footer s avatar/username/role i Odjava (⎋) gumbom - Klikabilni KPI/cards -> detail sub-stranice (savezi, klubovi, financije...) - PGŽ: KPI + zahtjevi pending + audit log + Chart.js trend grafikon - Savez: klubovi grid + zahtjevi PGŽ + lijecnicki uskoro istek + kalendar - Klub: clanovi tablica + clanarine + lijecnicki + dokumenti + manifestacije + HUB-3 placeholder - Sportaš: profile card + clanarina + lijecnicki + ZZJZ link + obrasci za potpis - Iste CSS varijable kao sport2.html (PGŽ blue/gold dark theme) - Real API: /sport/api/dashboard, /api/savezi, /api/klubovi, /api/clanovi, /api/proracun - Mock fallback gdje API još ne postoji (M5/M7/M9 produkti) Backups: static/sport2.html.bak.cc3.m3*, static/app.html.bak.cc3.m4* Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+183
-11
@@ -204,6 +204,15 @@ a.tag:hover,.tag[onclick]:hover{transform:translateY(-1px);filter:brightness(1.1
|
||||
|
||||
.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)}
|
||||
@@ -1754,12 +1763,53 @@ async function loadMreza(){
|
||||
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();
|
||||
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(){
|
||||
@@ -1775,22 +1825,23 @@ function renderMrezaShell(){
|
||||
<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…">
|
||||
<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
|
||||
🖱 Drag • Scroll zoom • Right-drag pan • Click node • Enter pretraga
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1804,12 +1855,94 @@ function renderMrezaShell(){
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
$('#mr-osoba').addEventListener('input', debounce(applyMrezaFilter, 200));
|
||||
$('#mr-klub').addEventListener('input', debounce(applyMrezaFilter, 200));
|
||||
$('#mr-tvrtka').addEventListener('input', debounce(applyMrezaFilter, 200));
|
||||
// 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();
|
||||
@@ -2326,10 +2459,49 @@ function renderAlertPanel(a){
|
||||
<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');
|
||||
|
||||
Reference in New Issue
Block a user