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:
Damir Radulić
2026-05-05 00:16:29 +02:00
parent b93ca9a8bf
commit 59a537388d
2 changed files with 1390 additions and 11 deletions
+183 -11
View File
@@ -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');