Files
pgz-sport/static/shared/sidebar.js
T
damir f07fdad919 Crisis V7 MEGA: sufinanciranje_sport + panel + CRM auth
DB:
- pgz_sport.sufinanciranje_sport.je_klub flag (RSS programi/totals false)
- pgz_sport.sufinanciranje_sport.klub_id matched

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

Frontend:
- sport2.html PANEL FORCE HIDE CSS (right:-100vw default)
- crm_v2.html: redirect to /login only on actual 401, not on page load
2026-05-05 15:02:47 +02:00

384 lines
16 KiB
JavaScript

/* PGŽ SPORT — Unified Sectioned Sidebar v2.0
* dradulic@outlook.com / damir@rinet.one — 2026-05-05
* Reference: app.rinet.one/klasik/dabi
*
* Usage:
* <link rel="stylesheet" href="/static/shared/sidebar.css">
* <script src="/sport/static/shared/sidebar.js" defer
* data-active="dashboard" // active item id
* data-portal="portal"></script> // active portal hint (optional)
*
* Auto-mounts <aside id="pgz-sb"> at start of <body> and adds class "pgz-has-sb" to <body>
* for layout. ADMIN section is gated by user role from /api/auth/me.
*/
(function(){
'use strict';
// Sectioned menu (DABI-style).
// href can be:
// "/<page>" → cross-portal navigation (full page load)
// "/sport/<page>#<hash>" → cross-portal + intent on that page
// "#<id>" → in-page anchor (handled by host page on hashchange)
const SIDEBAR_SECTIONS = [
{title:'PORTAL', items: [
{id:'dashboard', ic:'\u{1F4CA}', label:'Dashboard', href:'/static/sport2.html#dashboard'},
{id:'savezi', ic:'\u{1F3C5}', label:'Savezi', href:'/static/sport2.html#savezi'},
{id:'klubovi', ic:'⬢', label:'Klubovi', href:'/static/sport2.html#klubovi'},
{id:'sportasi', ic:'\u{1F464}', label:'Sportaši', href:'/static/sport2.html#sportasi'},
{id:'manifestacije', ic:'\u{1F4C5}', label:'Manifestacije', href:'/static/sport2.html#manifestacije'},
{id:'dokumentilib', ic:'\u{1F4DA}', label:'Dokumenti', href:'/sport/dokumenti'}
]},
{title:'OPERATIVA', items: [
{id:'profil', ic:'\u{1F464}', label:'Moj profil', href:'/app#profil'},
{id:'app', ic:'\u{1F4F1}', label:'Aplikacija', href:'/app'},
{id:'kalendar', ic:'\u{1F4C5}', label:'Kalendar', href:'/app#kalendar'},
{id:'notif', ic:'\u{1F514}', label:'Notifikacije', href:'/app#notif'}
]},
{title:'CRM', items: [
{id:'crm_v2', ic:'\u{1F3AF}', label:'CRM (Salesforce-Lite)', href:'/sport/crm_v2'},
{id:'clanarine', ic:'\u{1F4B3}', label:'Članarine', href:'/crm#clanarine'},
{id:'lijecnicki',ic:'⚕', label:'Liječnički', href:'/crm#lijecnicki'},
{id:'obrasci', ic:'\u{1F4CB}', label:'Obrasci', href:'/crm#obrasci'},
{id:'dokumenti', ic:'\u{1F4C4}', label:'Dokumenti', href:'/crm#dokumenti'}
]},
{title:'ERP', items: [
{id:'erpfull', ic:'\u{1F4D2}', label:'ERP Full (SAP-Lite)', href:'/erp/full'},
{id:'racuni', ic:'\u{1F9FE}', label:'Računi (OCR)', href:'/erp#racuni'},
{id:'putni', ic:'✈', label:'Putni nalozi', href:'/erp#putni'},
{id:'placanja', ic:'\u{1F4B0}', label:'Plaćanja', href:'/erp#placanja'},
{id:'xlsx', ic:'\u{1F4C8}', label:'XLSX export', href:'/erp#xlsx'}
]},
{title:'ANALITIKA', items: [
{id:'kpi', ic:'\u{1F4C8}', label:'KPI Dashboard', href:'/kpi'},
{id:'financije', ic:'€', label:'Financije', href:'/static/sport2.html#financije'},
{id:'mreza', ic:'\u{1F578}', label:'Mreža (graf)', href:'/static/sport2.html#mreza'},
{id:'forenzika', ic:'⚠', label:'Forenzika', href:'/static/sport2.html#forenzika'},
{id:'audit', ic:'\u{1F512}', label:'Audit log', href:'/audit'}
]},
{title:'ADMIN', requireRole:['pgz_admin','super_admin'], items: [
{id:'users', ic:'\u{1F465}', label:'Korisnici', href:'/sport/admin/users#users'},
{id:'tenants', ic:'\u{1F3E2}', label:'Tenanti', href:'/sport/admin/users#tenants'},
{id:'security', ic:'\u{1F6E1}', label:'Sigurnost', href:'/sport/admin/users#security'},
{id:'rbac', ic:'\u{1F511}', label:'RBAC matrica', href:'/sport/admin/users#rbac'},
{id:'auditlog', ic:'\u{1F512}', label:'Audit log', href:'/sport/admin/users#audit'},
{id:'gdpr', ic:'\u{1F510}', label:'GDPR', href:'/sport/admin/users#gdpr'}
]}
];
const STATE_KEY = 'sidebarCollapsed';
const $ = (s, root) => (root||document).querySelector(s);
const esc = s => String(s==null?'':s).replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));
// Tracks the currently-loaded user (null = guest). Set by setUserDisplay().
let _currentUser = null;
function readToken(){
try {
return localStorage.getItem('pgz_access')
|| sessionStorage.getItem('pgz_access')
|| localStorage.getItem('jwt')
|| localStorage.getItem('access_token')
|| '';
} catch(e){ return ''; }
}
async function logout(){
if(!confirm('Odjava iz aplikacije?')) return;
// Revoke server-side (matches app.html logout flow, commit e07292b)
try {
const tok = readToken();
if(tok){
await fetch('/sport/api/auth/logout', {
method:'POST',
headers:{'Authorization':'Bearer '+tok}
}).catch(()=>{});
}
} catch(e){}
// Clear ALL session keys
['pgz_access','pgz_refresh','pgz_user','app-role','jwt','access_token','refresh_token','pgz_session_id'].forEach(k => {
try { localStorage.removeItem(k); sessionStorage.removeItem(k); } catch(e){}
});
location.href = '/sport/login';
}
function gotoLogin(){
// Preserve return URL so we land back here after sign-in
try {
const ret = encodeURIComponent(location.pathname + location.search + location.hash);
location.href = '/sport/login?next=' + ret;
} catch(e){
location.href = '/sport/login';
}
}
function initials(n){
if(!n) return '?';
const p = String(n).trim().split(/\s+/);
return ((p[0]||'')[0]||'').toUpperCase() + ((p[1]||'')[0]||'').toUpperCase();
}
// Determine current page intent
function getActiveKey(){
const cs = document.currentScript || Array.from(document.scripts).find(s => /sidebar\.js/.test(s.src||''));
return (cs && cs.dataset && cs.dataset.active) || '';
}
function userCanSeeSection(sec, user){
if(!sec.requireRole || !sec.requireRole.length) return true;
if(!user) return false;
return sec.requireRole.indexOf(user.user_type) >= 0;
}
function renderSections(activeKey, user){
const out = [];
for(const sec of SIDEBAR_SECTIONS){
if(!userCanSeeSection(sec, user)) continue;
out.push(`<div class="pgz-sb-section ${sec.title==='ADMIN'?'pgz-admin':''}">${esc(sec.title)}</div>`);
for(const it of sec.items){
const active = it.id === activeKey;
const cross = it.href && it.href.startsWith('/sport/') && !sameOrigin(it.href);
out.push(`<a class="pgz-nav-i ${active?'active':''} ${cross?'cross-portal':''}"
href="${esc(it.href)}" data-id="${esc(it.id)}" data-label="${esc(it.label)}">
<span class="ic">${it.ic||'•'}</span>
<span class="lbl">${esc(it.label)}</span>
${it.badge?`<span class="badge">${esc(it.badge)}</span>`:''}
</a>`);
}
}
return out.join('');
}
// is this href same as current page (just hash navigation)?
function sameOrigin(href){
try {
const u = new URL(href, location.href);
return u.origin === location.origin && u.pathname === location.pathname;
} catch(e){ return false; }
}
function renderShell(activeKey, user){
const sb = document.createElement('aside');
sb.id = 'pgz-sb';
sb.innerHTML = `
<div class="pgz-sb-h">
<div class="pgz-mark">PGŽ</div>
<div class="pgz-htxt">
<a href="/" class="pgz-logo" style="text-decoration:none;color:inherit;cursor:pointer" title="Početna">PGŽ <span class="g">SPORT</span></a>
<div class="pgz-sub">Odjel za sport</div>
</div>
<div class="pgz-sb-toggle" onclick="PGZSidebar.toggle()" title="Skupi/raširi">←</div>
<div class="pgz-sb-mx" onclick="PGZSidebar.closeMobile()" title="Zatvori">✕</div>
</div>
<nav class="pgz-sb-nav" id="pgz-sb-nav">${renderSections(activeKey, user)}</nav>
<div class="pgz-sb-foot" id="pgz-sb-foot"
role="button" tabindex="0"
title="Klikni za prijavu / odjavu"
onclick="PGZSidebar.handleFootClick(event)"
onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();PGZSidebar.handleFootClick(event);}">
<div class="av" id="pgz-sb-av">?</div>
<div class="ui">
<div class="un" id="pgz-sb-un">Gost</div>
<div class="ur" id="pgz-sb-ur">Klikni za prijavu</div>
</div>
<div class="caret" id="pgz-sb-caret" title="Otvori izbornik" onclick="event.stopPropagation();PGZSidebar.toggleUserMenu(event)">▾</div>
<div class="pgz-user-menu" id="pgz-user-menu" onclick="event.stopPropagation()">
<a href="/app#profil"><span>👤</span><span>Moj profil</span></a>
<a href="/app#postavke"><span>⚙</span><span>Postavke</span></a>
<a href="/static/sport2.html"><span>🌐</span><span>Public portal</span></a>
<div class="sep"></div>
<a href="/sport/login" id="pgz-menu-login"><span>🔑</span><span>Prijava</span></a>
<a class="danger" id="pgz-menu-logout" onclick="PGZSidebar.logout()" style="display:none"><span>⎋</span><span>Odjava</span></a>
</div>
</div>
`;
return sb;
}
function renderBurger(){
if(document.getElementById('pgz-sb-burger')) return;
const b = document.createElement('div');
b.id = 'pgz-sb-burger';
b.className = 'pgz-sb-burger';
b.innerHTML = '≡';
b.onclick = () => PGZSidebar.openMobile();
document.body.appendChild(b);
}
function setUserDisplay(me){
_currentUser = me || null;
const un = $('#pgz-sb-un'), ur = $('#pgz-sb-ur'), av = $('#pgz-sb-av');
const foot = document.getElementById('pgz-sb-foot');
const loginLink = document.getElementById('pgz-menu-login');
const logoutLink = document.getElementById('pgz-menu-logout');
if(!me){
if(un) un.textContent = 'Gost';
if(ur) ur.textContent = 'Klikni za prijavu';
if(av){ av.textContent = '?'; av.innerHTML = av.innerHTML; }
if(foot) foot.setAttribute('title', 'Klikni za prijavu');
if(loginLink) loginLink.style.display = 'flex';
if(logoutLink) logoutLink.style.display = 'none';
return;
}
const name = me.full_name || ((me.ime||'')+' '+(me.prezime||'')).trim() || me.email || '—';
const role = me.user_type || '';
const avSrc = me.avatar_url || me.google_picture;
if(un) un.textContent = name;
if(ur) ur.textContent = role || 'Korisnik';
if(av){
if(avSrc) av.innerHTML = `<img src="${esc(avSrc)}" alt="">`;
else av.textContent = initials(name);
}
if(foot) foot.setAttribute('title', 'Klikni za odjavu (' + (me.email || name) + ')');
if(loginLink) loginLink.style.display = 'none';
if(logoutLink) logoutLink.style.display = 'flex';
}
function applyCollapsedFromStorage(){
let col = false;
try { col = localStorage.getItem(STATE_KEY) === '1'; } catch(e){}
const sb = document.getElementById('pgz-sb');
if(!sb) return;
sb.classList.toggle('pgz-collapsed', col);
document.body.classList.toggle('pgz-sb-col', col);
}
async function tryLoadMe(){
const tok = readToken(); if(!tok) return null;
try {
const r = await fetch('/sport/api/auth/me', {headers:{'Authorization':'Bearer '+tok}});
if(!r.ok) return null;
return await r.json();
} catch(e){ return null; }
}
// ────────── Public API ──────────
const PGZSidebar = {
SIDEBAR_SECTIONS,
async mount(opts){
opts = opts || {};
const activeKey = opts.activeKey || getActiveKey();
const existing = document.getElementById('pgz-sb');
if(existing) existing.remove();
// Try /api/auth/me first so ADMIN section is gated correctly
const me = await tryLoadMe();
const sb = renderShell(activeKey, me);
document.body.insertBefore(sb, document.body.firstChild);
document.body.classList.add('pgz-has-sb');
renderBurger();
applyCollapsedFromStorage();
setUserDisplay(me);
// listen for hashchange to update active item without reload
window.addEventListener('hashchange', () => {
const h = location.hash.replace(/^#/,'');
if(!h) return;
document.querySelectorAll('#pgz-sb .pgz-nav-i').forEach(el => {
el.classList.toggle('active', el.dataset.id===h);
});
});
// ─── notif bell badge polling (30s) ───
try {
const refreshBadge = async () => {
try {
const tok = readToken();
const r = await fetch('/sport/api/v2/notif/count', {
headers: tok ? {'Authorization':'Bearer '+tok} : {}
});
if(!r.ok) return;
const d = await r.json();
const n = (d && d.unread) || 0;
const el = document.querySelector('#pgz-sb .pgz-nav-i[data-id="notif"]');
if(!el) return;
let b = el.querySelector('.badge.notif-badge');
if(n > 0){
if(!b){
b = document.createElement('span');
b.className = 'badge notif-badge';
b.style.cssText = 'background:#dc2626;color:#fff;border-radius:10px;padding:1px 6px;font-size:10px;font-weight:700;margin-left:auto';
el.appendChild(b);
}
b.textContent = String(n);
} else if(b){
b.remove();
}
} catch(e){}
};
refreshBadge();
setInterval(refreshBadge, 30000);
} catch(e){}
},
toggle(){
const sb = document.getElementById('pgz-sb');
if(!sb) return;
const col = sb.classList.toggle('pgz-collapsed');
document.body.classList.toggle('pgz-sb-col', col);
try { localStorage.setItem(STATE_KEY, col ? '1' : '0'); } catch(e){}
},
openMobile(){
const sb = document.getElementById('pgz-sb');
if(!sb) return;
sb.classList.add('pgz-mobile-open');
document.body.classList.add('pgz-mobile-sb-open');
const closer = (ev) => {
if(!sb.contains(ev.target) && ev.target.id !== 'pgz-sb-burger'){
PGZSidebar.closeMobile();
document.removeEventListener('click', closer, true);
}
};
setTimeout(() => document.addEventListener('click', closer, true), 50);
},
closeMobile(){
const sb = document.getElementById('pgz-sb');
if(!sb) return;
sb.classList.remove('pgz-mobile-open');
document.body.classList.remove('pgz-mobile-sb-open');
},
toggleUserMenu(ev){
ev && ev.stopPropagation();
const f = document.getElementById('pgz-sb-foot');
const m = document.getElementById('pgz-user-menu');
if(!f || !m) return;
const open = f.classList.toggle('menu-open');
m.classList.toggle('open', open);
if(open){
const closer = (e) => {
if(!f.contains(e.target)){
f.classList.remove('menu-open');
m.classList.remove('open');
document.removeEventListener('click', closer, true);
}
};
setTimeout(() => document.addEventListener('click', closer, true), 50);
}
},
/* Single-click footer handler:
* - Guest (no user) → /sport/login
* - Logged in → logout() (revokes JWT, clears storage, redirects to login)
* The caret (▾) opens the full user menu (profil/postavke/portal/logout).
*/
handleFootClick(ev){
ev && ev.stopPropagation();
if(_currentUser){
return logout();
}
return gotoLogin();
},
gotoLogin,
logout,
// exposed for debugging / tests
_state: () => ({ user: _currentUser })
};
window.PGZSidebar = PGZSidebar;
// Auto-mount unless data-inline=1
const cs = document.currentScript;
const inline = cs && cs.dataset && cs.dataset.inline === '1';
if(!inline){
if(document.readyState === 'loading'){
document.addEventListener('DOMContentLoaded', () => PGZSidebar.mount({}));
} else {
PGZSidebar.mount({});
}
}
})();