feat: /api/v2/analiza/* endpoints - sport analytics backend

This commit is contained in:
Damir Radulic
2026-05-16 00:28:12 +02:00
parent 7ca5d7d94e
commit aca5051418
1355 changed files with 321891 additions and 4128 deletions
+181 -12
View File
@@ -139,6 +139,7 @@ table tr:hover td { background: rgba(26, 115, 232, 0.05); }
<script src="/static/shared/sidebar.js" defer data-active="clanarine"></script>
<style>body{padding-top:0}</style>
<script src="/static/oib_format.js" defer></script>
<script src="/static/shared/sortable.js" defer></script>
</head>
<body>
@@ -174,6 +175,25 @@ table tr:hover td { background: rgba(26, 115, 232, 0.05); }
</div>
</div>
<!-- ════════════ Tenant scope picker (added 2026-05-09) ════════════ -->
<div id="scope-bar" style="display:flex;align-items:center;gap:10px;padding:8px 14px;border-bottom:1px solid var(--rim);background:var(--bg2);font-size:12px;flex-wrap:wrap">
<span style="color:var(--t3);font-size:11px;text-transform:uppercase;letter-spacing:0.6px">Kontekst</span>
<label style="display:flex;align-items:center;gap:6px">
<span style="color:var(--t2)">Klub:</span>
<select id="scope-klub" onchange="onScopeKlubChange(this.value)" style="background:var(--bg3);border:1px solid var(--rim);color:var(--t1);padding:4px 8px;border-radius:4px;font-size:12px;min-width:240px">
<option value="">Učitavanje…</option>
</select>
</label>
<label style="display:flex;align-items:center;gap:6px" id="scope-clan-wrap">
<span style="color:var(--t2)">Sportaš:</span>
<select id="scope-clan" onchange="onScopeClanChange(this.value)" style="background:var(--bg3);border:1px solid var(--rim);color:var(--t1);padding:4px 8px;border-radius:4px;font-size:12px;min-width:220px" disabled>
<option value="">— svi —</option>
</select>
</label>
<span id="scope-summary" style="color:var(--t3);font-size:11px;margin-left:auto"></span>
<button id="scope-clear" onclick="clearScope()" style="background:transparent;border:1px solid var(--rim);color:var(--t2);padding:3px 10px;border-radius:4px;font-size:11px;cursor:pointer" title="Resetiraj filtere">⟲ resetiraj</button>
</div>
<div class="container">
<div id="page-clanovi" class="page"></div>
<div id="page-clanarine" class="page" style="display:none"></div>
@@ -263,6 +283,150 @@ function closeModal() {
$('#modal').innerHTML = '';
}
// ────────────────────────────────────────────────────
// Tenant-aware scope state (added 2026-05-09)
// ────────────────────────────────────────────────────
//
// SCOPE_KLUB_ID — null = "Svi klubovi" (PGŽ users only); else int klub id
// SCOPE_CLAN_ID — null = svi sportaši u klubu; else int clan id
// SCOPE_USER — JWT payload of logged-in user (or null if anon)
// SCOPE_KLUBOVI — list of klubs the current user may pick from
//
// Backend forces klub_id for klub_user/klub_admin regardless of UI state, so
// these globals are purely UX scaffolding.
let SCOPE_KLUB_ID = (() => { const v = localStorage.getItem('crm-scope-klub'); return v ? parseInt(v,10) : null; })();
let SCOPE_CLAN_ID = (() => { const v = localStorage.getItem('crm-scope-clan'); return v ? parseInt(v,10) : null; })();
let SCOPE_USER = null;
let SCOPE_KLUBOVI = [];
function _decodeJwt(t) {
try { return JSON.parse(atob(t.split('.')[1].replace(/-/g,'+').replace(/_/g,'/'))); }
catch (e) { return null; }
}
function scopeQs(extraParams) {
// Append klub_id / clan_id to a URLSearchParams or plain object.
const p = (extraParams instanceof URLSearchParams) ? extraParams : new URLSearchParams(extraParams || {});
if (SCOPE_KLUB_ID && !p.has('klub_id')) p.set('klub_id', String(SCOPE_KLUB_ID));
if (SCOPE_CLAN_ID && !p.has('clan_id')) p.set('clan_id', String(SCOPE_CLAN_ID));
return p;
}
async function initScope() {
const tok = getJwt();
SCOPE_USER = tok ? _decodeJwt(tok) : null;
const role = SCOPE_USER?.role || '';
const isPgz = ['super_admin','pgz_admin','pgz_user','pgz_finance','pgz_zzjz'].includes(role);
// Fetch tenant-scoped klubovi list (backend filters for klub users automatically).
let klubs = [];
try {
const r = await fetch('/sport/api/v2/klubovi?limit=2000', {
headers: tok ? {'Authorization':'Bearer ' + tok} : {},
});
if (r.ok) { const d = await r.json(); klubs = d.rows || []; }
} catch (e) { console.warn('initScope klubovi fetch failed', e); }
SCOPE_KLUBOVI = klubs;
const sel = $('#scope-klub');
if (sel) {
const opts = [];
if (isPgz || !SCOPE_USER) {
opts.push('<option value="">— Svi klubovi —</option>');
}
for (const k of klubs) {
const sel2 = (SCOPE_KLUB_ID === k.id) ? ' selected' : '';
opts.push(`<option value="${k.id}"${sel2}>${esc(k.naziv)}</option>`);
}
sel.innerHTML = opts.join('');
// For klub users: pre-select their (only) klub if not chosen yet.
if (!isPgz && klubs.length === 1 && !SCOPE_KLUB_ID) {
SCOPE_KLUB_ID = klubs[0].id;
localStorage.setItem('crm-scope-klub', String(SCOPE_KLUB_ID));
sel.value = String(SCOPE_KLUB_ID);
}
if (!isPgz) sel.disabled = (klubs.length <= 1);
}
await populateClanPicker();
refreshScopeSummary();
}
async function populateClanPicker() {
const sel = $('#scope-clan');
if (!sel) return;
if (!SCOPE_KLUB_ID) {
sel.innerHTML = '<option value="">— odaberi klub prvo —</option>';
sel.disabled = true;
return;
}
sel.disabled = false;
sel.innerHTML = '<option value="">Učitavanje…</option>';
try {
const tok = getJwt();
const r = await fetch(`/sport/api/v2/klubovi/${SCOPE_KLUB_ID}/clanovi?limit=500`, {
headers: tok ? {'Authorization':'Bearer ' + tok} : {},
});
if (!r.ok) throw new Error('HTTP ' + r.status);
const d = await r.json();
const arr = d.sportasi || d.rows || d.clanovi || [];
const opts = ['<option value="">— svi sportaši —</option>'];
for (const c of arr) {
const lbl = `${c.ime || ''} ${c.prezime || ''}`.trim() || `#${c.id}`;
const sel2 = (SCOPE_CLAN_ID === c.id) ? ' selected' : '';
opts.push(`<option value="${c.id}"${sel2}>${esc(lbl)}</option>`);
}
sel.innerHTML = opts.join('');
} catch (e) {
sel.innerHTML = '<option value="">— greška u dohvatu —</option>';
}
}
function refreshScopeSummary() {
const el = $('#scope-summary');
if (!el) return;
const k = SCOPE_KLUBOVI.find(x => x.id === SCOPE_KLUB_ID);
const role = SCOPE_USER?.role || 'anon';
const klubTxt = k ? `klub: ${k.naziv}` : 'svi klubovi';
const clanTxt = SCOPE_CLAN_ID ? ` · sportaš: #${SCOPE_CLAN_ID}` : '';
el.textContent = `${role} · ${klubTxt}${clanTxt}`;
}
function onScopeKlubChange(v) {
SCOPE_KLUB_ID = v ? parseInt(v, 10) : null;
SCOPE_CLAN_ID = null;
if (SCOPE_KLUB_ID) localStorage.setItem('crm-scope-klub', String(SCOPE_KLUB_ID));
else localStorage.removeItem('crm-scope-klub');
localStorage.removeItem('crm-scope-clan');
populateClanPicker().then(refreshScopeSummary);
reloadCurrentTab();
}
function onScopeClanChange(v) {
SCOPE_CLAN_ID = v ? parseInt(v, 10) : null;
if (SCOPE_CLAN_ID) localStorage.setItem('crm-scope-clan', String(SCOPE_CLAN_ID));
else localStorage.removeItem('crm-scope-clan');
refreshScopeSummary();
reloadCurrentTab();
}
function clearScope() {
// PGŽ users → reset both. Klub users → reset only sportaš (klub is forced).
const role = SCOPE_USER?.role || '';
const isPgz = ['super_admin','pgz_admin','pgz_user','pgz_finance','pgz_zzjz'].includes(role);
if (isPgz || !SCOPE_USER) {
SCOPE_KLUB_ID = null;
localStorage.removeItem('crm-scope-klub');
const sel = $('#scope-klub'); if (sel) sel.value = '';
}
SCOPE_CLAN_ID = null;
localStorage.removeItem('crm-scope-clan');
populateClanPicker().then(refreshScopeSummary);
reloadCurrentTab();
}
function reloadCurrentTab() {
const active = document.querySelector('.tab.active');
if (active && active.dataset.tab) setTab(active.dataset.tab);
}
// Globalna rola (postavlja se preko dropdowna u topbaru)
let CURRENT_ROLE = localStorage.getItem('crm-role') || 'pgz_admin';
@@ -307,7 +471,8 @@ async function loadClanarine() {
root.innerHTML = '<div class="loading">Učitavanje članarina…</div>';
let data;
try {
data = await api('/clanarine?limit=200');
const qs = scopeQs({limit: 200});
data = await api('/clanarine?' + qs.toString());
} catch (e) { root.innerHTML = `<div class="empty">Greška: ${esc(e.message)}</div>`; return; }
$('#cnt-clanarine').textContent = data.count;
const s = data.summary || {};
@@ -474,11 +639,12 @@ async function loadClanarineFiltered() {
const status = $('#cl-status').value;
const godina = $('#cl-godina').value;
const klub = $('#cl-klub').value;
const params = new URLSearchParams({limit: 200});
if (status) params.append('status', status);
if (godina) params.append('godina', godina);
if (klub) params.append('klub_id', klub);
const data = await api('/clanarine?' + params);
const params = scopeQs({limit: 200});
if (status) params.set('status', status);
if (godina) params.set('godina', godina);
// Inline klub filter overrides scope picker (back-compat with old toolbar input)
if (klub) params.set('klub_id', klub);
const data = await api('/clanarine?' + params.toString());
const tbody = $('#page-clanarine table tbody');
tbody.innerHTML = (data.rows || []).map(r => `
<tr data-id="${r.id}" data-dug="${r.dug||0}" data-paid="${r.dug<=0?1:0}">
@@ -642,7 +808,7 @@ async function loadLijecnicki() {
const root = $('#page-lijecnicki');
root.innerHTML = '<div class="loading">Učitavanje pregleda…</div>';
let data;
try { data = await api('/lijecnicki?limit=200'); }
try { data = await api('/lijecnicki?' + scopeQs({limit: 200}).toString()); }
catch (e) { root.innerHTML = `<div class="empty">Greška: ${esc(e.message)}</div>`; return; }
$('#cnt-lijecnicki').textContent = data.count;
const s = data.summary || {};
@@ -694,10 +860,10 @@ async function loadLijecnicki() {
async function loadLijecnickiFiltered() {
const status = $('#lj-status').value;
const klub = $('#lj-klub').value;
const params = new URLSearchParams({limit: 200});
if (status) params.append('status', status);
if (klub) params.append('klub_id', klub);
const data = await api('/lijecnicki?' + params);
const params = scopeQs({limit: 200});
if (status) params.set('status', status);
if (klub) params.set('klub_id', klub);
const data = await api('/lijecnicki?' + params.toString());
const tbody = $('#page-lijecnicki table tbody');
tbody.innerHTML = (data.rows || []).map(r => `
<tr>
@@ -1840,7 +2006,9 @@ async function createTpl(e) {
const _roleSel = document.getElementById('g-role');
if (_roleSel) _roleSel.value = CURRENT_ROLE;
loadClanovi();
// init scope picker (klubovi + sportaši) prije prvog tab loada — ako klub user
// nema linkanih klubova, backend će ionako vratiti 403 pa UI pokazuje prazno.
initScope().then(() => loadClanovi()).catch(() => loadClanovi());
// preload counts za sve tabove
(async () => {
try {
@@ -1856,5 +2024,6 @@ loadClanovi();
})();
</script>
<script src="/static/_ai_widget.js" defer></script>
</body>
</html>