feat: /api/v2/analiza/* endpoints - sport analytics backend
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
/* _ai_widget.js — global floating DABI AI assistant
|
||||
* Inject with: <script src="/static/_ai_widget.js" defer></script>
|
||||
*
|
||||
* Self-contained, no dependencies. Idempotent (refuses to mount twice).
|
||||
* Reads JWT from localStorage.pgz_access (falls back to sessionStorage /
|
||||
* localStorage.access_token). POSTs to /sport/api/v2/ai/ask with page
|
||||
* context (path + hash) so the AI can ground answers in where the user is.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
if (window.__ai_widget_mounted) return;
|
||||
window.__ai_widget_mounted = true;
|
||||
|
||||
// ─────────── config ───────────
|
||||
var ENDPOINT = '/sport/api/v2/ai/ask';
|
||||
var STORAGE_HISTORY_KEY = '_ai_widget_history';
|
||||
var MAX_HISTORY = 30;
|
||||
|
||||
function getToken() {
|
||||
try {
|
||||
return localStorage.getItem('pgz_access')
|
||||
|| sessionStorage.getItem('pgz_access')
|
||||
|| localStorage.getItem('access_token')
|
||||
|| '';
|
||||
} catch (e) { return ''; }
|
||||
}
|
||||
function pageContext() {
|
||||
return {
|
||||
path: location.pathname || '',
|
||||
hash: (location.hash || '').replace(/^#/, ''),
|
||||
title: document.title || ''
|
||||
};
|
||||
}
|
||||
function loadHistory() {
|
||||
try {
|
||||
var raw = sessionStorage.getItem(STORAGE_HISTORY_KEY);
|
||||
var arr = raw ? JSON.parse(raw) : [];
|
||||
return Array.isArray(arr) ? arr.slice(-MAX_HISTORY) : [];
|
||||
} catch (e) { return []; }
|
||||
}
|
||||
function saveHistory(arr) {
|
||||
try {
|
||||
sessionStorage.setItem(STORAGE_HISTORY_KEY, JSON.stringify(arr.slice(-MAX_HISTORY)));
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// ─────────── DOM ───────────
|
||||
var btn = document.createElement('button');
|
||||
btn.id = '_ai_widget_btn';
|
||||
btn.type = 'button';
|
||||
btn.title = 'DABI AI Copilot';
|
||||
btn.setAttribute('aria-label', 'Otvori DABI AI Copilot');
|
||||
btn.textContent = '🤖';
|
||||
btn.style.cssText = [
|
||||
'position:fixed', 'bottom:20px', 'right:20px', 'z-index:99998',
|
||||
'width:54px', 'height:54px', 'border-radius:50%',
|
||||
'background:#2563eb', 'color:#fff', 'border:0', 'font-size:24px',
|
||||
'cursor:pointer', 'box-shadow:0 6px 18px rgba(0,0,0,0.25)',
|
||||
'transition:transform 0.15s ease', 'line-height:1'
|
||||
].join(';');
|
||||
btn.onmouseenter = function () { btn.style.transform = 'scale(1.06)'; };
|
||||
btn.onmouseleave = function () { btn.style.transform = 'scale(1.0)'; };
|
||||
|
||||
var panel = document.createElement('div');
|
||||
panel.id = '_ai_widget_panel';
|
||||
panel.style.cssText = [
|
||||
'position:fixed', 'bottom:88px', 'right:20px', 'z-index:99999',
|
||||
'width:380px', 'max-width:calc(100vw - 32px)',
|
||||
'height:520px', 'max-height:calc(100vh - 120px)',
|
||||
'display:none', 'flex-direction:column',
|
||||
'background:#0f172a', 'color:#e2e8f0',
|
||||
'border:1px solid #334155', 'border-radius:12px',
|
||||
'box-shadow:0 16px 48px rgba(0,0,0,0.5)',
|
||||
'font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif',
|
||||
'font-size:13px', 'overflow:hidden'
|
||||
].join(';');
|
||||
panel.innerHTML = [
|
||||
'<div id="_ai_widget_header" style="display:flex;align-items:center;gap:8px;padding:10px 12px;border-bottom:1px solid #334155;background:#1e293b">',
|
||||
' <span style="font-size:16px">🤖</span>',
|
||||
' <span style="font-weight:600">DABI AI Copilot</span>',
|
||||
' <span id="_ai_widget_status" style="margin-left:auto;font-size:11px;color:#94a3b8"></span>',
|
||||
' <button id="_ai_widget_close" type="button" aria-label="Zatvori" style="background:transparent;border:0;color:#94a3b8;cursor:pointer;font-size:18px;padding:0 4px;line-height:1">×</button>',
|
||||
'</div>',
|
||||
'<div id="_ai_widget_msgs" style="flex:1;overflow-y:auto;padding:12px;display:flex;flex-direction:column;gap:8px"></div>',
|
||||
'<div style="border-top:1px solid #334155;padding:8px;display:flex;gap:6px;align-items:flex-end;background:#1e293b">',
|
||||
' <textarea id="_ai_widget_q" rows="2" placeholder="Pitaj DABI…"',
|
||||
' style="flex:1;padding:8px 10px;border:1px solid #334155;border-radius:6px;background:#0f172a;color:#e2e8f0;font-size:13px;resize:none;outline:none;font-family:inherit"></textarea>',
|
||||
' <button id="_ai_widget_send" type="button"',
|
||||
' style="padding:8px 12px;border:0;border-radius:6px;background:#2563eb;color:#fff;font-weight:600;cursor:pointer;font-size:13px">Pošalji</button>',
|
||||
'</div>'
|
||||
].join('');
|
||||
|
||||
function mount() {
|
||||
if (!document.body) {
|
||||
document.addEventListener('DOMContentLoaded', mount);
|
||||
return;
|
||||
}
|
||||
document.body.appendChild(btn);
|
||||
document.body.appendChild(panel);
|
||||
wire();
|
||||
rerender();
|
||||
}
|
||||
|
||||
// ─────────── behaviour ───────────
|
||||
var messages = loadHistory();
|
||||
|
||||
function setStatus(s) {
|
||||
var el = document.getElementById('_ai_widget_status');
|
||||
if (el) el.textContent = s || '';
|
||||
}
|
||||
function escHTML(s) {
|
||||
return String(s).replace(/[&<>"']/g, function (c) {
|
||||
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
||||
});
|
||||
}
|
||||
function rerender() {
|
||||
var box = document.getElementById('_ai_widget_msgs');
|
||||
if (!box) return;
|
||||
if (!messages.length) {
|
||||
box.innerHTML = '<div style="color:#64748b;font-size:12px;text-align:center;padding:20px">Postavi pitanje DABI-ju.<br>Kontekst trenutne stranice se šalje automatski.</div>';
|
||||
return;
|
||||
}
|
||||
box.innerHTML = messages.map(function (m) {
|
||||
var bubbleStyle = m.role === 'user'
|
||||
? 'align-self:flex-end;background:#2563eb;color:#fff'
|
||||
: (m.role === 'error'
|
||||
? 'align-self:flex-start;background:#7f1d1d;color:#fecaca'
|
||||
: 'align-self:flex-start;background:#1e293b;color:#e2e8f0;border:1px solid #334155');
|
||||
return '<div style="max-width:85%;padding:8px 10px;border-radius:8px;white-space:pre-wrap;line-height:1.45;' + bubbleStyle + '">'
|
||||
+ escHTML(m.text) + '</div>';
|
||||
}).join('');
|
||||
box.scrollTop = box.scrollHeight;
|
||||
}
|
||||
|
||||
function open() {
|
||||
panel.style.display = 'flex';
|
||||
btn.style.display = 'none';
|
||||
setTimeout(function () {
|
||||
var q = document.getElementById('_ai_widget_q');
|
||||
if (q) q.focus();
|
||||
}, 50);
|
||||
}
|
||||
function close() {
|
||||
panel.style.display = 'none';
|
||||
btn.style.display = 'block';
|
||||
}
|
||||
|
||||
function pushMsg(role, text) {
|
||||
messages.push({ role: role, text: text, t: Date.now() });
|
||||
if (messages.length > MAX_HISTORY) messages = messages.slice(-MAX_HISTORY);
|
||||
saveHistory(messages);
|
||||
rerender();
|
||||
}
|
||||
|
||||
async function ask() {
|
||||
var inp = document.getElementById('_ai_widget_q');
|
||||
var sendBtn = document.getElementById('_ai_widget_send');
|
||||
if (!inp || !sendBtn) return;
|
||||
var q = (inp.value || '').trim();
|
||||
if (!q) return;
|
||||
|
||||
var tok = getToken();
|
||||
if (!tok) {
|
||||
pushMsg('error', '⚠ Prijava potrebna. Otvori /login pa se vrati.');
|
||||
setStatus('traži prijavu');
|
||||
return;
|
||||
}
|
||||
|
||||
pushMsg('user', q);
|
||||
inp.value = '';
|
||||
sendBtn.disabled = true;
|
||||
sendBtn.textContent = '…';
|
||||
setStatus('razmišljam…');
|
||||
|
||||
try {
|
||||
var ctx = pageContext();
|
||||
var body = {
|
||||
question: q, query: q, q: q,
|
||||
context: ctx,
|
||||
page_path: ctx.path, page_hash: ctx.hash, page_title: ctx.title
|
||||
};
|
||||
var r = await fetch(ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + tok },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (r.status === 401) {
|
||||
pushMsg('error', '⚠ Sesija je istekla. Otvori /login.');
|
||||
setStatus('401');
|
||||
return;
|
||||
}
|
||||
if (!r.ok) {
|
||||
var t = '';
|
||||
try { t = await r.text(); } catch (e) {}
|
||||
pushMsg('error', '❌ HTTP ' + r.status + (t ? ' — ' + t.slice(0, 200) : ''));
|
||||
setStatus('greška');
|
||||
return;
|
||||
}
|
||||
var data = await r.json();
|
||||
var answer = data.answer || data.response || data.text
|
||||
|| (typeof data === 'string' ? data : JSON.stringify(data, null, 2).slice(0, 1500));
|
||||
pushMsg('assistant', answer);
|
||||
setStatus('odgovor');
|
||||
} catch (e) {
|
||||
pushMsg('error', '❌ ' + (e && e.message ? e.message : String(e)));
|
||||
setStatus('greška');
|
||||
} finally {
|
||||
sendBtn.disabled = false;
|
||||
sendBtn.textContent = 'Pošalji';
|
||||
}
|
||||
}
|
||||
|
||||
function wire() {
|
||||
btn.addEventListener('click', open);
|
||||
var closeBtn = document.getElementById('_ai_widget_close');
|
||||
if (closeBtn) closeBtn.addEventListener('click', close);
|
||||
var sendBtn = document.getElementById('_ai_widget_send');
|
||||
if (sendBtn) sendBtn.addEventListener('click', ask);
|
||||
var q = document.getElementById('_ai_widget_q');
|
||||
if (q) {
|
||||
q.addEventListener('keydown', function (ev) {
|
||||
if (ev.key === 'Enter' && !ev.shiftKey) { ev.preventDefault(); ask(); }
|
||||
if (ev.key === 'Escape') { close(); }
|
||||
});
|
||||
}
|
||||
document.addEventListener('keydown', function (ev) {
|
||||
// Cmd/Ctrl + K opens the widget — convenience shortcut
|
||||
if ((ev.metaKey || ev.ctrlKey) && ev.key.toLowerCase() === 'k') {
|
||||
ev.preventDefault();
|
||||
if (panel.style.display === 'none') open(); else close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
mount();
|
||||
})();
|
||||
@@ -160,6 +160,7 @@ td.num { font-family: 'JetBrains Mono', monospace; text-align: right; }
|
||||
<link rel="stylesheet" href="/static/shared/sidebar.css">
|
||||
<script src="/static/shared/sidebar.js" defer data-active="korisnici"></script>
|
||||
<script src="/static/oib_format.js" defer></script>
|
||||
<script src="/static/shared/sortable.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
@@ -766,5 +767,6 @@ $('#tenantSel').addEventListener('change', e => {
|
||||
await loadDashboard();
|
||||
})();
|
||||
</script>
|
||||
<script src="/static/_ai_widget.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -142,6 +142,7 @@ td.actions-col .btn { padding: 4px 8px; font-size: 11px; }
|
||||
}
|
||||
</style>
|
||||
<script src="/static/oib_format.js" defer></script>
|
||||
<script src="/static/shared/sortable.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app" id="appShell">
|
||||
|
||||
+15
-9
@@ -345,6 +345,7 @@ table tbody tr:hover{background:var(--bg3)}
|
||||
<link rel="stylesheet" href="/static/shared/sidebar.css">
|
||||
<script src="/static/shared/sidebar.js" defer data-active="profil"></script>
|
||||
<script src="/static/oib_format.js" defer></script>
|
||||
<script src="/static/shared/sortable.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -481,7 +482,7 @@ async function apiAuth(path, opts){
|
||||
const onLogin = location.pathname.includes('/login');
|
||||
if(!onLogin && !window.__pgz_redirecting){
|
||||
window.__pgz_redirecting = true;
|
||||
window.(window.__pgz_made_api_call ? location.href = '/login?reason=unauthorized' : console.warn('[auth] no token but no API call yet, skipping redirect'));
|
||||
if(window.__pgz_made_api_call){location.href='/login?reason=unauthorized';}else{console.warn('[auth] no token but no API call yet, skipping redirect');}
|
||||
}
|
||||
return {__unauthorized:true, status:401};
|
||||
}
|
||||
@@ -511,7 +512,7 @@ const NAV_BY_ROLE = {
|
||||
{id:'sportasi', ic:'\u{1F464}', label:'Sportaši'},
|
||||
{id:'financije', ic:'€', label:'Financije'},
|
||||
{id:'erp', ic:'\u{1F4BC}', label:'ERP', href:'/erp/full'},
|
||||
{id:'crm', ic:'\u{1F4DD}', label:'CRM', href:'/crm/v2'},
|
||||
{id:'crm', ic:'\u{1F4DD}', label:'CRM', href:'/crm'},
|
||||
{id:'dokumenti', ic:'\u{1F4D6}', label:'Dokumenti'},
|
||||
{id:'racuni', ic:'\u{1F9FE}', label:'Računi (OCR)', href:'/erp/full?tab=uploads'},
|
||||
{id:'putni', ic:'\u{2708}', label:'Putni nalozi', href:'/erp/full?tab=putni'},
|
||||
@@ -593,11 +594,13 @@ function applyMeToHeader(){
|
||||
$('#user-name').innerHTML = esc(name) + `<span class="role-badge" id="user-role-badge">${esc(me.user_type||'')}</span>`;
|
||||
$('#user-tenant').textContent = tenant;
|
||||
$('#user-role-label')?.replaceChildren(document.createTextNode(roleLabel));
|
||||
// Avatar topbar
|
||||
// Avatar topbar — onError replaces <img> with initials so a 404 / dead URL doesn't show a broken-image icon
|
||||
const _avInits = esc(initials(name));
|
||||
const _avFallback = `this.onerror=null;this.style.display='none';if(this.parentElement){this.parentElement.textContent='${_avInits}';}`;
|
||||
if(me.avatar_url){
|
||||
$('#user-av').innerHTML = `<img src="${esc(me.avatar_url)}${me.avatar_url.includes('?')?'&':'?'}t=${Date.now()}" alt="">`;
|
||||
$('#user-av').innerHTML = `<img src="${esc(me.avatar_url)}${me.avatar_url.includes('?')?'&':'?'}t=${Date.now()}" alt="" onerror="${_avFallback}">`;
|
||||
} else if(me.google_picture){
|
||||
$('#user-av').innerHTML = `<img src="${esc(me.google_picture)}" alt="">`;
|
||||
$('#user-av').innerHTML = `<img src="${esc(me.google_picture)}" alt="" onerror="${_avFallback}">`;
|
||||
} else {
|
||||
$('#user-av').textContent = initials(name);
|
||||
}
|
||||
@@ -605,8 +608,8 @@ function applyMeToHeader(){
|
||||
if($('#sf-name')) $('#sf-name').textContent = name;
|
||||
if($('#sf-role')) $('#sf-role').textContent = roleLabel;
|
||||
if($('#sf-av')){
|
||||
if(me.avatar_url) $('#sf-av').innerHTML = `<img src="${esc(me.avatar_url)}${me.avatar_url.includes('?')?'&':'?'}t=${Date.now()}" alt="" style="width:100%;height:100%;object-fit:cover;border-radius:50%">`;
|
||||
else if(me.google_picture) $('#sf-av').innerHTML = `<img src="${esc(me.google_picture)}" alt="" style="width:100%;height:100%;object-fit:cover;border-radius:50%">`;
|
||||
if(me.avatar_url) $('#sf-av').innerHTML = `<img src="${esc(me.avatar_url)}${me.avatar_url.includes('?')?'&':'?'}t=${Date.now()}" alt="" style="width:100%;height:100%;object-fit:cover;border-radius:50%" onerror="${_avFallback}">`;
|
||||
else if(me.google_picture) $('#sf-av').innerHTML = `<img src="${esc(me.google_picture)}" alt="" style="width:100%;height:100%;object-fit:cover;border-radius:50%" onerror="${_avFallback}">`;
|
||||
else $('#sf-av').textContent = initials(name);
|
||||
}
|
||||
if($('#role-sub')) $('#role-sub').textContent = tenant || roleLabel;
|
||||
@@ -895,8 +898,10 @@ function profileMe(){
|
||||
function profileRender(){
|
||||
const u = profileMe();
|
||||
const name = u.full_name || ((u.ime||'')+' '+(u.prezime||'')).trim() || u.email || '—';
|
||||
const av = u.avatar_url ? `<img src="${esc(u.avatar_url)}" alt="">`
|
||||
: (u.google_picture ? `<img src="${esc(u.google_picture)}" alt="">` : esc(initials(name)));
|
||||
const _avInits2 = esc(initials(name));
|
||||
const _avFallback2 = `this.onerror=null;this.style.display='none';if(this.parentElement){this.parentElement.textContent='${_avInits2}';}`;
|
||||
const av = u.avatar_url ? `<img src="${esc(u.avatar_url)}" alt="" onerror="${_avFallback2}">`
|
||||
: (u.google_picture ? `<img src="${esc(u.google_picture)}" alt="" onerror="${_avFallback2}">` : esc(initials(name)));
|
||||
const lastLogin = u.last_login ? new Date(u.last_login).toLocaleString('hr-HR') : '—';
|
||||
const created = u.created_at ? new Date(u.created_at).toLocaleString('hr-HR') : '—';
|
||||
const gdpr = u.gdpr_consent_at ? new Date(u.gdpr_consent_at).toLocaleDateString('hr-HR') : null;
|
||||
@@ -2498,5 +2503,6 @@ window.renderPGZToggleBtn = function(){
|
||||
};
|
||||
</script>
|
||||
<script src="/static/js/export_dropdown.js"></script>
|
||||
<script src="/static/_ai_widget.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
</style>
|
||||
<link rel="stylesheet" href="/static/shared/sidebar.css">
|
||||
<script src="/static/shared/sidebar.js" defer data-active="audit"></script>
|
||||
<script src="/static/shared/sortable.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<h1><a href="/" style="text-decoration:none;color:inherit" title="Početna">📜 Audit Log</a></h1>
|
||||
@@ -149,5 +150,6 @@ async function load() {
|
||||
load();
|
||||
setInterval(load, 30000);
|
||||
</script>
|
||||
<script src="/static/_ai_widget.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
+181
-12
@@ -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>
|
||||
|
||||
+5
-2
@@ -229,6 +229,7 @@ footer { height:36px; background:var(--bg2); border-top:1px solid var(--rim);
|
||||
display:flex; align-items:center; padding:0 18px; font-size:10px;
|
||||
color:var(--t3); font-family:var(--mono); justify-content:space-between; }
|
||||
</style>
|
||||
<script src="/static/shared/sortable.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -238,7 +239,7 @@ footer { height:36px; background:var(--bg2); border-top:1px solid var(--rim);
|
||||
<span class="title">CRM v2 — Salesforce-Lite</span>
|
||||
<div class="right">
|
||||
<span id="me">…</span>
|
||||
<a href="/sport/platform">Platform</a>
|
||||
<a href="/platform">Platform</a>
|
||||
<a href="/sport/erp">ERP</a>
|
||||
<a href="/sport/crm">CRM</a>
|
||||
<a href="#" id="logout">Odjava</a>
|
||||
@@ -804,7 +805,8 @@ function exportTab(tab, fmt) {
|
||||
th{background:#eee;text-align:left;padding:5px 7px;border:1px solid #999;text-transform:uppercase;font-size:9px;letter-spacing:.4px}
|
||||
td{padding:4px 7px;border:1px solid #ccc}
|
||||
tr:nth-child(even) td{background:#fafafa}
|
||||
</style></head><body>
|
||||
</style><script src="/static/shared/sortable.js" defer></script>
|
||||
</head><body>
|
||||
<h2>PGŽ Sport CRM — ${tab.toUpperCase()} <small style="font-weight:400;color:#666">(${new Date().toLocaleString('hr-HR')})</small></h2>
|
||||
<table><thead><tr>${headers.map(h=>'<th>'+h+'</th>').join('')}</tr></thead>
|
||||
<tbody>${rows.map(r=>'<tr>'+r.map(c=>'<td>'+(c==null?'':String(c).replace(/&/g,'&').replace(/</g,'<'))+'</td>').join('')+'</tr>').join('')}</tbody></table>
|
||||
@@ -2181,5 +2183,6 @@ document.addEventListener('DOMContentLoaded', function(){
|
||||
});
|
||||
</script>
|
||||
<script src="/static/js/export_dropdown.js"></script>
|
||||
<script src="/static/_ai_widget.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+146
-55
@@ -23,19 +23,34 @@
|
||||
background:#1a1a1e;border:1px solid #2a2a2e;color:#fff;padding:8px 12px;border-radius:5px;font-size:13px
|
||||
}
|
||||
.filters input{min-width:240px}
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:16px}
|
||||
.card{background:#0c1016;border:1px solid #1a1a1e;border-radius:8px;padding:16px;cursor:pointer;transition:all .2s}
|
||||
.card:hover{border-color:#5fb6ff;transform:translateY(-2px);box-shadow:0 6px 20px rgba(95,182,255,.2)}
|
||||
.card-year{font-size:32px;font-weight:800;color:#5fb6ff;margin-bottom:4px}
|
||||
.card-title{font-size:14px;font-weight:600;margin-bottom:8px;color:#fff;line-height:1.3}
|
||||
.card-org{font-size:11px;color:#888;margin-bottom:6px}
|
||||
.card-meta{font-size:10px;color:#666;display:flex;gap:8px;flex-wrap:wrap}
|
||||
.card-meta span{background:#1a1a1e;padding:2px 8px;border-radius:3px}
|
||||
.empty{text-align:center;padding:40px;color:#666}
|
||||
.stats{font-size:13px;color:#888;margin-bottom:16px}
|
||||
.toolbar{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;gap:12px;flex-wrap:wrap}
|
||||
.stats{font-size:13px;color:#888}
|
||||
.stats b{color:#5fb6ff;font-weight:600}
|
||||
.view-toggle{display:inline-flex;background:#0c1016;border:1px solid #2a2a2e;border-radius:6px;overflow:hidden}
|
||||
.view-toggle button{background:transparent;border:0;color:#888;padding:6px 12px;font-size:12px;cursor:pointer;display:inline-flex;align-items:center;gap:6px;font-family:inherit}
|
||||
.view-toggle button.active{background:#1e3a5f;color:#5fb6ff}
|
||||
.view-toggle button:hover:not(.active){color:#fff}
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:16px}
|
||||
.card{background:#0c1016;border:1px solid #1a1a1e;border-radius:8px;padding:14px;cursor:pointer;transition:all .2s;display:flex;gap:12px;align-items:flex-start}
|
||||
.card:hover{border-color:#5fb6ff;transform:translateY(-2px);box-shadow:0 6px 20px rgba(95,182,255,.2)}
|
||||
.card-thumb{flex:0 0 56px;width:56px;height:72px;border-radius:4px;background:linear-gradient(160deg,#1e3a5f 0%,#0c1016 100%);border:1px solid #2a2a2e;display:flex;align-items:center;justify-content:center;font-size:24px}
|
||||
.card-body{flex:1;min-width:0}
|
||||
.card-title{font-size:13px;font-weight:600;margin-bottom:6px;color:#fff;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
|
||||
.card-org{font-size:11px;color:#888;margin-bottom:6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.card-meta{font-size:10px;color:#666;display:flex;gap:6px;flex-wrap:wrap}
|
||||
.card-meta span{background:#1a1a1e;padding:2px 6px;border-radius:3px}
|
||||
table.docs{width:100%;border-collapse:collapse;background:#0c1016;border:1px solid #1a1a1e;border-radius:8px;overflow:hidden}
|
||||
table.docs th, table.docs td{padding:8px 12px;text-align:left;font-size:12px;border-bottom:1px solid #1a1a1e}
|
||||
table.docs th{background:#0a0e15;color:#888;font-weight:600;text-transform:uppercase;font-size:10px;letter-spacing:.5px;white-space:nowrap}
|
||||
table.docs tr{cursor:pointer;transition:background .15s}
|
||||
table.docs tbody tr:hover{background:#11161f}
|
||||
table.docs td.t-title{color:#fff;font-weight:500;max-width:480px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
table.docs td.t-num{font-variant-numeric:tabular-nums;color:#aaa;text-align:right;white-space:nowrap}
|
||||
table.docs td.t-tag{color:#888}
|
||||
.empty{text-align:center;padding:40px;color:#666;background:#0c1016;border:1px solid #1a1a1e;border-radius:8px}
|
||||
.badge-godisnjak{background:#1e3a5f;color:#5fb6ff;padding:2px 6px;border-radius:3px;font-size:10px;font-weight:600}
|
||||
</style>
|
||||
<script src="/static/shared/sortable.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
@@ -83,6 +98,29 @@
|
||||
<option value="vaterpolo">Vaterpolo</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Godina</label><br>
|
||||
<select id="f-godina" onchange="loadDocs()">
|
||||
<option value="">Sve godine</option>
|
||||
<option value="2026">2026</option>
|
||||
<option value="2025">2025</option>
|
||||
<option value="2024">2024</option>
|
||||
<option value="2023">2023</option>
|
||||
<option value="2022">2022</option>
|
||||
<option value="2021">2021</option>
|
||||
<option value="2020">2020</option>
|
||||
<option value="2019">2019</option>
|
||||
<option value="2018">2018</option>
|
||||
<option value="2017">2017</option>
|
||||
<option value="2016">2016</option>
|
||||
<option value="2015">2015</option>
|
||||
<option value="2014">2014</option>
|
||||
<option value="2013">2013</option>
|
||||
<option value="2012">2012</option>
|
||||
<option value="2011">2011</option>
|
||||
<option value="2010">2010</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Pretraga</label><br>
|
||||
<input type="text" id="f-q" placeholder="Naziv, opis…" onkeyup="if(event.key==='Enter') loadDocs()">
|
||||
@@ -90,64 +128,117 @@
|
||||
<button onclick="loadDocs()" style="background:#5fb6ff;color:#000;border:none;padding:10px 16px;border-radius:5px;font-weight:600;cursor:pointer">🔍 Pretraži</button>
|
||||
</div>
|
||||
|
||||
<div class="stats" id="stats">Učitavanje…</div>
|
||||
<div class="grid" id="docs-grid">
|
||||
<div class="toolbar">
|
||||
<div class="stats" id="stats">Učitavanje…</div>
|
||||
<div class="view-toggle" role="tablist" aria-label="Pogled">
|
||||
<button id="view-card" type="button" onclick="setView('card')" title="Kartice"><span aria-hidden="true">▦</span> Cards</button>
|
||||
<button id="view-table" type="button" onclick="setView('table')" title="Tablica"><span aria-hidden="true">≡</span> Table</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="docs-out">
|
||||
<div class="empty">Učitavanje dokumenata…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const VIEW_KEY = 'pgz_dok_view';
|
||||
let _docsCache = [];
|
||||
let _view = (function(){
|
||||
const saved = localStorage.getItem(VIEW_KEY);
|
||||
if (saved === 'card' || saved === 'table') return saved;
|
||||
return (window.matchMedia && window.matchMedia('(max-width: 760px)').matches) ? 'card' : 'table';
|
||||
})();
|
||||
|
||||
function fmtSize(chars){
|
||||
if (!chars) return '—';
|
||||
const kb = chars / 1024;
|
||||
return kb < 1024 ? Math.round(kb)+' KB' : (kb/1024).toFixed(1)+' MB';
|
||||
}
|
||||
function fmtDate(iso){
|
||||
if (!iso) return '—';
|
||||
try { return new Date(iso).toLocaleDateString('hr-HR'); } catch(_) { return '—'; }
|
||||
}
|
||||
function rowDate(r){ return r.izdano_datum || r.scraped_at || null; }
|
||||
function rowUrl(r){ return r.izvor_url || ('/sport/api/v2/dokumenti/'+r.id); }
|
||||
function esc(s){ return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
||||
|
||||
function renderCards(rows){
|
||||
return '<div class="grid">' + rows.map(r => {
|
||||
const isGod = r.vrsta === 'godisnjak';
|
||||
return `<div class="card" onclick="window.open('${esc(rowUrl(r))}', '_blank', 'noopener')">
|
||||
<div class="card-thumb" aria-hidden="true">${isGod ? '📖' : '📄'}</div>
|
||||
<div class="card-body">
|
||||
<div class="card-title">${esc(r.title || 'Dokument')}</div>
|
||||
<div class="card-org">${esc(r.organizacija || '—')}</div>
|
||||
<div class="card-meta">
|
||||
${isGod ? '<span class="badge-godisnjak">GODIŠNJAK</span>' : ''}
|
||||
${r.vrsta ? '<span>'+esc(r.vrsta)+'</span>' : ''}
|
||||
${r.godina ? '<span>'+esc(r.godina)+'</span>' : ''}
|
||||
<span>${fmtSize(r.chars)}</span>
|
||||
<span>${fmtDate(rowDate(r))}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('') + '</div>';
|
||||
}
|
||||
function renderTable(rows){
|
||||
return `<table class="docs">
|
||||
<thead><tr>
|
||||
<th>Title</th><th>Type</th><th>Year</th><th style="text-align:right">Size</th><th>Date</th>
|
||||
</tr></thead>
|
||||
<tbody>${rows.map(r => `
|
||||
<tr onclick="window.open('${esc(rowUrl(r))}', '_blank', 'noopener')">
|
||||
<td class="t-title">${esc(r.title || 'Dokument')}${r.vrsta==='godisnjak' ? ' <span class="badge-godisnjak">G</span>' : ''}</td>
|
||||
<td class="t-tag">${esc(r.vrsta || '—')}</td>
|
||||
<td class="t-num">${esc(r.godina || '—')}</td>
|
||||
<td class="t-num">${fmtSize(r.chars)}</td>
|
||||
<td class="t-tag">${fmtDate(rowDate(r))}</td>
|
||||
</tr>`).join('')}</tbody>
|
||||
</table>`;
|
||||
}
|
||||
function paint(){
|
||||
const out = document.getElementById('docs-out');
|
||||
document.getElementById('view-card').classList.toggle('active', _view === 'card');
|
||||
document.getElementById('view-table').classList.toggle('active', _view === 'table');
|
||||
if (!_docsCache.length){ out.innerHTML = '<div class="empty">Nema dokumenata po filtru</div>'; return; }
|
||||
out.innerHTML = _view === 'card' ? renderCards(_docsCache) : renderTable(_docsCache);
|
||||
}
|
||||
function setView(v){
|
||||
if (v !== 'card' && v !== 'table') return;
|
||||
_view = v;
|
||||
try { localStorage.setItem(VIEW_KEY, v); } catch(_) {}
|
||||
paint();
|
||||
}
|
||||
|
||||
async function loadDocs(){
|
||||
const vrsta = document.getElementById('f-vrsta').value;
|
||||
const org = document.getElementById('f-org').value;
|
||||
const sport = document.getElementById('f-sport').value;
|
||||
const q = document.getElementById('f-q').value;
|
||||
|
||||
const vrsta = document.getElementById('f-vrsta').value;
|
||||
const org = document.getElementById('f-org').value;
|
||||
const sport = document.getElementById('f-sport').value;
|
||||
const godina = document.getElementById('f-godina').value;
|
||||
const q = document.getElementById('f-q').value;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if(vrsta) params.set('vrsta', vrsta);
|
||||
if(sport) params.set('sport', sport);
|
||||
if(q) params.set('q', q);
|
||||
if(org) params.set('organizacija', org);
|
||||
if(vrsta) params.set('vrsta', vrsta);
|
||||
if(sport) params.set('sport', sport);
|
||||
if(godina) params.set('godina', godina);
|
||||
if(q) params.set('q', q);
|
||||
if(org) params.set('organizacija', org);
|
||||
params.set('limit', '500');
|
||||
|
||||
document.getElementById('docs-grid').innerHTML = '<div class="empty">Učitavanje…</div>';
|
||||
|
||||
try{
|
||||
|
||||
document.getElementById('docs-out').innerHTML = '<div class="empty">Učitavanje…</div>';
|
||||
try {
|
||||
const r = await fetch('/sport/api/v2/dokumenti?'+params.toString());
|
||||
const d = await r.json();
|
||||
let rows = d.rows || d.dokumenti || [];
|
||||
|
||||
document.getElementById('stats').innerHTML = `<b>${rows.length}</b> dokumenata po filtru (filter: ${[vrsta,sport,org,q].filter(Boolean).join(', ') || 'bez filtera'})`;
|
||||
|
||||
if(rows.length === 0){
|
||||
document.getElementById('docs-grid').innerHTML = '<div class="empty">Nema dokumenata po filtru</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('docs-grid').innerHTML = rows.map(r => {
|
||||
const year = r.godina || '—';
|
||||
const url = r.izvor_url || ('/sport/api/v2/dokumenti/'+r.id);
|
||||
const isGod = r.vrsta === 'godisnjak';
|
||||
return `
|
||||
<div class="card" onclick="window.open('${url}', '_blank')">
|
||||
<div class="card-year">${year}</div>
|
||||
<div class="card-title">${r.title || r.fname || 'Dokument'}</div>
|
||||
<div class="card-org">${r.organizacija || '—'}</div>
|
||||
<div class="card-meta">
|
||||
${isGod ? '<span class="badge-godisnjak">📖 GODIŠNJAK</span>' : ''}
|
||||
<span>${r.vrsta || ''}</span>
|
||||
${r.sport ? '<span>'+r.sport+'</span>' : ''}
|
||||
${r.sadrzaj_size ? '<span>'+(Math.round(r.sadrzaj_size/1000))+'KB</span>' : ''}
|
||||
<span>📄 PDF</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}catch(e){
|
||||
document.getElementById('docs-grid').innerHTML = '<div class="empty">Greška: '+e.message+'</div>';
|
||||
_docsCache = d.rows || d.dokumenti || [];
|
||||
document.getElementById('stats').innerHTML =
|
||||
`<b>${_docsCache.length}</b> dokumenata po filtru (filter: ${[vrsta,sport,godina,org,q].filter(Boolean).join(', ') || 'bez filtera'})`;
|
||||
paint();
|
||||
} catch(e) {
|
||||
document.getElementById('docs-out').innerHTML = '<div class="empty">Greška: '+esc(e.message)+'</div>';
|
||||
}
|
||||
}
|
||||
loadDocs();
|
||||
</script>
|
||||
<script src="/static/_ai_widget.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
<!DOCTYPE html>
|
||||
<!--
|
||||
dokumenti.html — Lista dokumenata: godišnjaci, publikacije, izdanja
|
||||
Author: Damir Radulić | v1.0 | 05.05.2026
|
||||
-->
|
||||
<html lang="hr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>📚 Dokumenti — PGŽ Sport</title>
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font:14px system-ui;background:#06080d;color:#e0e0e0;padding:0}
|
||||
header{background:#0a0e15;padding:12px 20px;border-bottom:1px solid #2a2a2e;display:flex;justify-content:space-between;align-items:center}
|
||||
header h1{font-size:20px;color:#5fb6ff}
|
||||
header a{color:#888;text-decoration:none;margin-left:16px;font-size:14px}
|
||||
header a:hover{color:#fff}
|
||||
.container{padding:20px;max-width:1400px;margin:0 auto}
|
||||
.filters{background:#0c1016;padding:16px;border-radius:8px;margin-bottom:20px;display:flex;gap:12px;flex-wrap:wrap;align-items:center}
|
||||
.filters label{font-size:12px;color:#888}
|
||||
.filters select, .filters input {
|
||||
background:#1a1a1e;border:1px solid #2a2a2e;color:#fff;padding:8px 12px;border-radius:5px;font-size:13px
|
||||
}
|
||||
.filters input{min-width:240px}
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:16px}
|
||||
.card{background:#0c1016;border:1px solid #1a1a1e;border-radius:8px;padding:16px;cursor:pointer;transition:all .2s}
|
||||
.card:hover{border-color:#5fb6ff;transform:translateY(-2px);box-shadow:0 6px 20px rgba(95,182,255,.2)}
|
||||
.card-year{font-size:32px;font-weight:800;color:#5fb6ff;margin-bottom:4px}
|
||||
.card-title{font-size:14px;font-weight:600;margin-bottom:8px;color:#fff;line-height:1.3}
|
||||
.card-org{font-size:11px;color:#888;margin-bottom:6px}
|
||||
.card-meta{font-size:10px;color:#666;display:flex;gap:8px;flex-wrap:wrap}
|
||||
.card-meta span{background:#1a1a1e;padding:2px 8px;border-radius:3px}
|
||||
.empty{text-align:center;padding:40px;color:#666}
|
||||
.stats{font-size:13px;color:#888;margin-bottom:16px}
|
||||
.stats b{color:#5fb6ff;font-weight:600}
|
||||
.badge-godisnjak{background:#1e3a5f;color:#5fb6ff;padding:2px 6px;border-radius:3px;font-size:10px;font-weight:600}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1><a href="/" style="color:#5fb6ff;text-decoration:none">📚 Dokumenti</a></h1>
|
||||
<div>
|
||||
<a href="/">🏠 Home</a>
|
||||
<a href="/app">📊 App</a>
|
||||
<a href="/admin/users">👥 Admin</a>
|
||||
<a href="/erp/full">💼 ERP</a>
|
||||
<a href="/crm/v2">📝 CRM</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="container">
|
||||
<div class="filters">
|
||||
<div>
|
||||
<label>Vrsta</label><br>
|
||||
<select id="f-vrsta" onchange="loadDocs()">
|
||||
<option value="">Sve</option>
|
||||
<option value="godisnjak">Godišnjak</option>
|
||||
<option value="manifestacija">Manifestacija</option>
|
||||
<option value="natjecaj">Natječaj</option>
|
||||
<option value="izvjestaj">Izvještaj</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Izdavatelj</label><br>
|
||||
<select id="f-org" onchange="loadDocs()">
|
||||
<option value="">Svi</option>
|
||||
<option value="Zajednica športskih saveza PGŽ">ZSP PGŽ</option>
|
||||
<option value="Riječki sportski savez">RSS</option>
|
||||
<option value="Grad Rijeka">Grad Rijeka</option>
|
||||
<option value="HOO">HOO</option>
|
||||
<option value="Grad/Općina">JLS</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Sport</label><br>
|
||||
<select id="f-sport" onchange="loadDocs()">
|
||||
<option value="">Svi</option>
|
||||
<option value="nogomet">Nogomet</option>
|
||||
<option value="košarka">Košarka</option>
|
||||
<option value="rukomet">Rukomet</option>
|
||||
<option value="odbojka">Odbojka</option>
|
||||
<option value="vaterpolo">Vaterpolo</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Pretraga</label><br>
|
||||
<input type="text" id="f-q" placeholder="Naziv, opis…" onkeyup="if(event.key==='Enter') loadDocs()">
|
||||
</div>
|
||||
<button onclick="loadDocs()" style="background:#5fb6ff;color:#000;border:none;padding:10px 16px;border-radius:5px;font-weight:600;cursor:pointer">🔍 Pretraži</button>
|
||||
</div>
|
||||
|
||||
<div class="stats" id="stats">Učitavanje…</div>
|
||||
<div class="grid" id="docs-grid">
|
||||
<div class="empty">Učitavanje dokumenata…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function loadDocs(){
|
||||
const vrsta = document.getElementById('f-vrsta').value;
|
||||
const org = document.getElementById('f-org').value;
|
||||
const sport = document.getElementById('f-sport').value;
|
||||
const q = document.getElementById('f-q').value;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if(vrsta) params.set('vrsta', vrsta);
|
||||
if(sport) params.set('sport', sport);
|
||||
if(q) params.set('q', q);
|
||||
if(org) params.set('organizacija', org);
|
||||
params.set('limit', '500');
|
||||
|
||||
document.getElementById('docs-grid').innerHTML = '<div class="empty">Učitavanje…</div>';
|
||||
|
||||
try{
|
||||
const r = await fetch('/sport/api/v2/dokumenti?'+params.toString());
|
||||
const d = await r.json();
|
||||
let rows = d.rows || d.dokumenti || [];
|
||||
|
||||
document.getElementById('stats').innerHTML = `<b>${rows.length}</b> dokumenata po filtru (filter: ${[vrsta,sport,org,q].filter(Boolean).join(', ') || 'bez filtera'})`;
|
||||
|
||||
if(rows.length === 0){
|
||||
document.getElementById('docs-grid').innerHTML = '<div class="empty">Nema dokumenata po filtru</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('docs-grid').innerHTML = rows.map(r => {
|
||||
const year = r.godina || '—';
|
||||
const url = r.izvor_url || ('/sport/api/v2/dokumenti/'+r.id);
|
||||
const isGod = r.vrsta === 'godisnjak';
|
||||
return `
|
||||
<div class="card" onclick="window.open('${url}', '_blank')">
|
||||
<div class="card-year">${year}</div>
|
||||
<div class="card-title">${r.title || r.fname || 'Dokument'}</div>
|
||||
<div class="card-org">${r.organizacija || '—'}</div>
|
||||
<div class="card-meta">
|
||||
${isGod ? '<span class="badge-godisnjak">📖 GODIŠNJAK</span>' : ''}
|
||||
<span>${r.vrsta || ''}</span>
|
||||
${r.sport ? '<span>'+r.sport+'</span>' : ''}
|
||||
${r.sadrzaj_size ? '<span>'+(Math.round(r.sadrzaj_size/1000))+'KB</span>' : ''}
|
||||
<span>📄 PDF</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}catch(e){
|
||||
document.getElementById('docs-grid').innerHTML = '<div class="empty">Greška: '+e.message+'</div>';
|
||||
}
|
||||
}
|
||||
loadDocs();
|
||||
</script>
|
||||
<script src="/static/_ai_widget.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -83,6 +83,7 @@ tr.clickable:hover { background:var(--bg-3); box-shadow:inset 3px 0 0 var(--acce
|
||||
<link rel="stylesheet" href="/static/shared/sidebar.css">
|
||||
<script src="/static/shared/sidebar.js" defer data-active="racuni"></script>
|
||||
<script src="/static/oib_format.js" defer></script>
|
||||
<script src="/static/shared/sortable.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
|
||||
+84
-2
@@ -104,6 +104,7 @@ table tbody tr:hover{background:var(--bg3)}
|
||||
</style>
|
||||
<link rel="stylesheet" href="/static/shared/sidebar.css">
|
||||
<script src="/static/shared/sidebar.js" defer data-active="erp_full"></script>
|
||||
<script src="/static/shared/sortable.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -623,11 +624,92 @@ table tbody tr:hover{background:var(--bg3)}
|
||||
|
||||
<script>
|
||||
const API = '/api/v2/erp';
|
||||
const AUTH = () => ({ 'Authorization': 'Bearer ' + (localStorage.getItem('jwt') || localStorage.getItem('access_token') || 'admin-pgz-2026') });
|
||||
// ━━━ MZ4-04 (2026-05-10): hardened auth helper — no 'admin-pgz-2026' fallback ━━━
|
||||
function getToken(){
|
||||
try {
|
||||
return localStorage.getItem('pgz_access')
|
||||
|| sessionStorage.getItem('pgz_access')
|
||||
|| localStorage.getItem('jwt')
|
||||
|| localStorage.getItem('access_token')
|
||||
|| '';
|
||||
} catch(e){ return ''; }
|
||||
}
|
||||
function isJwtExpired(tok){
|
||||
if(!tok) return true;
|
||||
try {
|
||||
const payload = JSON.parse(atob(tok.split('.')[1]));
|
||||
return !!payload.exp && payload.exp * 1000 < Date.now();
|
||||
} catch(e){ return false; } // not parseable — let server decide
|
||||
}
|
||||
function clearAuthAndRedirect(reason){
|
||||
['pgz_access','pgz_refresh','pgz_user','jwt','access_token','refresh_token'].forEach(k => {
|
||||
try{ localStorage.removeItem(k); sessionStorage.removeItem(k); }catch(e){}
|
||||
});
|
||||
if(!window.__pgz_redirecting){
|
||||
window.__pgz_redirecting = true;
|
||||
window.location.href = '/sport/login?reason='+(reason||'unauthorized');
|
||||
}
|
||||
}
|
||||
async function tryRefreshToken(){
|
||||
const rt = localStorage.getItem('pgz_refresh') || localStorage.getItem('refresh_token');
|
||||
if(!rt) return null;
|
||||
try {
|
||||
const r = await fetch('/api/auth/refresh', {
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({refresh_token: rt})
|
||||
});
|
||||
if(!r.ok) return null;
|
||||
const d = await r.json();
|
||||
if(d.access_token){
|
||||
localStorage.setItem('pgz_access', d.access_token);
|
||||
localStorage.setItem('access_token', d.access_token);
|
||||
return d.access_token;
|
||||
}
|
||||
} catch(e){}
|
||||
return null;
|
||||
}
|
||||
function AUTH(){
|
||||
const tok = getToken();
|
||||
return tok ? { 'Authorization': 'Bearer ' + tok } : {};
|
||||
}
|
||||
const fmt = n => (Number(n||0)).toLocaleString('hr-HR',{minimumFractionDigits:2,maximumFractionDigits:2});
|
||||
|
||||
async function api(path, opts={}) {
|
||||
const r = await fetch(API + path, { headers: { 'Content-Type':'application/json', ...AUTH() }, ...opts });
|
||||
let tok = getToken();
|
||||
if(tok && isJwtExpired(tok)){
|
||||
console.warn('[erp.api] access_token expired client-side; refreshing');
|
||||
const fresh = await tryRefreshToken();
|
||||
if(!fresh){ clearAuthAndRedirect('expired'); throw new Error('401: token expired, refresh failed'); }
|
||||
tok = fresh;
|
||||
}
|
||||
const headers = Object.assign(
|
||||
{ 'Content-Type':'application/json' },
|
||||
tok ? {'Authorization':'Bearer '+tok} : {},
|
||||
(opts.headers||{})
|
||||
);
|
||||
const r = await fetch(API + path, Object.assign({}, opts, {headers}));
|
||||
if (r.status === 401) {
|
||||
// One-shot refresh + retry
|
||||
const fresh = await tryRefreshToken();
|
||||
if(fresh){
|
||||
const retryHeaders = Object.assign(
|
||||
{ 'Content-Type':'application/json' },
|
||||
{ 'Authorization':'Bearer '+fresh },
|
||||
(opts.headers||{})
|
||||
);
|
||||
const r2 = await fetch(API + path, Object.assign({}, opts, {headers: retryHeaders}));
|
||||
if (r2.ok) {
|
||||
if (r2.headers.get('content-type')?.includes('application/json')) return r2.json();
|
||||
return {__ok:true};
|
||||
}
|
||||
if (r2.status === 401) { clearAuthAndRedirect('unauthorized'); throw new Error('401: refresh did not restore auth'); }
|
||||
const detail2 = await r2.text();
|
||||
throw new Error(`${r2.status}: ${detail2}`);
|
||||
}
|
||||
clearAuthAndRedirect('unauthorized');
|
||||
throw new Error('401: not authenticated');
|
||||
}
|
||||
if (!r.ok) {
|
||||
let detail = await r.text();
|
||||
throw new Error(`${r.status}: ${detail}`);
|
||||
|
||||
@@ -472,6 +472,7 @@ table.dt tr:hover td { background:rgba(0,212,255,.025) }
|
||||
}
|
||||
</style>
|
||||
<script src="/static/oib_format.js" defer></script>
|
||||
<script src="/static/shared/sortable.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
</style>
|
||||
<link rel="stylesheet" href="/static/shared/sidebar.css">
|
||||
<script src="/static/shared/sidebar.js" defer data-active="kpi"></script>
|
||||
<script src="/static/shared/sortable.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<h1><a href="/" style="text-decoration:none;color:inherit" title="Početna">RINET KPI Dashboard</a> <span class="updated" id="updated"></span> <button class="refresh" onclick="load()">↻</button></h1>
|
||||
|
||||
@@ -599,5 +599,6 @@ $('#cookieMore').addEventListener('click', e => { e.preventDefault(); $('#privac
|
||||
}, 100);
|
||||
})();
|
||||
</script>
|
||||
<script src="/static/_ai_widget.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -43,10 +43,10 @@
|
||||
]},
|
||||
{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'}
|
||||
{id:'racuni', ic:'\u{1F9FE}', label:'Računi (OCR)', href:'/erp/full?tab=racuni'},
|
||||
{id:'putni', ic:'✈', label:'Putni nalozi', href:'/erp/full?tab=putni'},
|
||||
{id:'placanja', ic:'\u{1F4B0}', label:'Plaćanja', href:'/erp/full?tab=payments'},
|
||||
{id:'xlsx', ic:'\u{1F4C8}', label:'XLSX export', href:'/erp/full?tab=izvjestaji'}
|
||||
]},
|
||||
{title:'ANALITIKA', items: [
|
||||
{id:'kpi', ic:'\u{1F4C8}', label:'KPI Dashboard', href:'/kpi'},
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// shared/sortable.js | v1.0.0 | 2026-05-10 | task N5
|
||||
// Auto-attaches click-to-sort to every <table> with a <thead><tr><th>.
|
||||
//
|
||||
// Usage: <script src="/static/shared/sortable.js" defer></script>
|
||||
// Skips:
|
||||
// • tables with [data-sort-skip] attribute
|
||||
// • tables already using the legacy sportHeader() pattern (those have
|
||||
// sort handlers wired in their <th> already; we detect them and bail)
|
||||
//
|
||||
// Click behavior per <th>:
|
||||
// 1st click → ASC
|
||||
// 2nd click → DESC
|
||||
// 3rd click → unsorted (original order restored)
|
||||
//
|
||||
// Type detection per column (sampled from first 8 non-empty rows):
|
||||
// numeric → integers / floats / "1.234,56 €" / "12 kn"
|
||||
// date → ISO yyyy-mm-dd or hr-HR dd.mm.yyyy
|
||||
// string → fallback, locale-aware (hr-HR collation)
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
(function(){
|
||||
'use strict';
|
||||
|
||||
var ARROW_NEUTRAL = ' ↕';
|
||||
var ARROW_ASC = ' ▲';
|
||||
var ARROW_DESC = ' ▼';
|
||||
var COLLATOR = (typeof Intl !== 'undefined' && Intl.Collator)
|
||||
? new Intl.Collator('hr-HR', { numeric: true, sensitivity: 'base' })
|
||||
: null;
|
||||
|
||||
function txt(node){ return (node.textContent || '').trim(); }
|
||||
|
||||
function detectType(samples){
|
||||
// Decide column type from up to 8 sample cell values
|
||||
var nNum = 0, nDate = 0, n = 0;
|
||||
for (var i = 0; i < samples.length && n < 8; i++){
|
||||
var v = samples[i];
|
||||
if (v === '' || v === '—' || v === '-' || v == null) continue;
|
||||
n++;
|
||||
if (parseHRNumber(v) !== null) nNum++;
|
||||
else if (parseDate(v)) nDate++;
|
||||
}
|
||||
if (n === 0) return 'string';
|
||||
if (nNum / n >= 0.6) return 'numeric';
|
||||
if (nDate / n >= 0.6) return 'date';
|
||||
return 'string';
|
||||
}
|
||||
|
||||
function parseHRNumber(s){
|
||||
// Accept: "1234", "1.234,56", "1,234.56", "1 234", "12 kn", "€ 12,5", "0.45"
|
||||
var raw = String(s).replace(/[^\d,.\- ]/g, '').trim();
|
||||
if (raw === '' || raw === '-') return null;
|
||||
// Detect decimal separator: if both . and , present, the rightmost is decimal
|
||||
var hasComma = raw.indexOf(',') >= 0, hasDot = raw.indexOf('.') >= 0;
|
||||
var cleaned;
|
||||
if (hasComma && hasDot){
|
||||
if (raw.lastIndexOf(',') > raw.lastIndexOf('.')){
|
||||
cleaned = raw.replace(/\./g, '').replace(/ /g, '').replace(',', '.');
|
||||
} else {
|
||||
cleaned = raw.replace(/,/g, '').replace(/ /g, '');
|
||||
}
|
||||
} else if (hasComma){
|
||||
// could be 1234,56 or 1,234 — treat , as decimal if exactly one and 1-2 digits after
|
||||
var parts = raw.split(',');
|
||||
if (parts.length === 2 && parts[1].replace(/ /g,'').length <= 2){
|
||||
cleaned = parts[0].replace(/ /g,'').replace(/\./g,'') + '.' + parts[1].replace(/ /g,'');
|
||||
} else {
|
||||
cleaned = raw.replace(/,/g, '').replace(/ /g, '');
|
||||
}
|
||||
} else {
|
||||
cleaned = raw.replace(/ /g, '');
|
||||
}
|
||||
var n = parseFloat(cleaned);
|
||||
return isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
var DATE_ISO = /^\d{4}-\d{2}-\d{2}/;
|
||||
var DATE_HR = /^(\d{1,2})\.\s*(\d{1,2})\.\s*(\d{4})/;
|
||||
function parseDate(s){
|
||||
s = String(s).trim();
|
||||
if (DATE_ISO.test(s)) return Date.parse(s.slice(0, 10));
|
||||
var m = s.match(DATE_HR);
|
||||
if (m) return Date.parse(m[3] + '-' + ('0'+m[2]).slice(-2) + '-' + ('0'+m[1]).slice(-2));
|
||||
return null;
|
||||
}
|
||||
|
||||
function compare(type, a, b){
|
||||
if (type === 'numeric'){
|
||||
var na = parseHRNumber(a), nb = parseHRNumber(b);
|
||||
if (na === null && nb === null) return 0;
|
||||
if (na === null) return 1;
|
||||
if (nb === null) return -1;
|
||||
return na - nb;
|
||||
}
|
||||
if (type === 'date'){
|
||||
var da = parseDate(a), db = parseDate(b);
|
||||
if (da == null && db == null) return 0;
|
||||
if (da == null) return 1;
|
||||
if (db == null) return -1;
|
||||
return da - db;
|
||||
}
|
||||
return COLLATOR ? COLLATOR.compare(a, b) : a.localeCompare(b);
|
||||
}
|
||||
|
||||
function sampleColumn(rows, colIdx){
|
||||
var out = [];
|
||||
for (var i = 0; i < rows.length; i++){
|
||||
var c = rows[i].cells && rows[i].cells[colIdx];
|
||||
if (c) out.push(txt(c));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function attach(table){
|
||||
if (table.__sortableAttached) return;
|
||||
if (table.hasAttribute('data-sort-skip')) return;
|
||||
// skip tables using legacy sortHeader() — they already have <th onclick=...>
|
||||
var firstTh = table.querySelector('thead th, tr th');
|
||||
if (!firstTh) return;
|
||||
var legacy = !!table.querySelector('thead th[onclick*="setSort"], thead th[onclick*="_sort"]');
|
||||
if (legacy) return;
|
||||
|
||||
var thead = table.tHead || (table.querySelector('tr') && table.querySelector('tr').parentNode);
|
||||
if (!thead) return;
|
||||
var headerRow = thead.querySelector('tr') || (table.rows[0] && table.rows[0].parentNode === thead ? table.rows[0] : null);
|
||||
if (!headerRow) return;
|
||||
var ths = headerRow.cells;
|
||||
if (!ths || ths.length === 0) return;
|
||||
|
||||
// Snapshot original order so 3rd-click can revert
|
||||
var tbody = table.tBodies && table.tBodies[0];
|
||||
if (!tbody) return;
|
||||
var originalRows = Array.prototype.slice.call(tbody.rows);
|
||||
if (originalRows.length < 2) return; // nothing to sort
|
||||
|
||||
// State per table
|
||||
var state = { col: -1, dir: 0 }; // 0 = unsorted, 1 = asc, -1 = desc
|
||||
|
||||
for (var i = 0; i < ths.length; i++){
|
||||
(function(idx, th){
|
||||
// Don't make non-data columns sortable (action icons etc.)
|
||||
if (th.hasAttribute('data-sort-skip')) return;
|
||||
var label = txt(th);
|
||||
if (label === '') return; // skip empty headers (icons)
|
||||
th.style.cursor = 'pointer';
|
||||
th.style.userSelect = 'none';
|
||||
th.title = 'Klikni za sort · ASC → DESC → reset';
|
||||
// Append neutral arrow if no existing sort marker
|
||||
if (!/[↕▲▼]/.test(label)){
|
||||
var span = document.createElement('span');
|
||||
span.className = '__sort-ind';
|
||||
span.style.opacity = '0.45';
|
||||
span.textContent = ARROW_NEUTRAL;
|
||||
th.appendChild(span);
|
||||
}
|
||||
th.addEventListener('click', function(){
|
||||
// Cycle: idx is current → asc; same idx asc → desc; same idx desc → reset
|
||||
if (state.col !== idx){ state.col = idx; state.dir = 1; }
|
||||
else if (state.dir === 1){ state.dir = -1; }
|
||||
else if (state.dir === -1){ state.col = -1; state.dir = 0; }
|
||||
else { state.dir = 1; }
|
||||
|
||||
// Update arrow indicators on all ths
|
||||
for (var j = 0; j < ths.length; j++){
|
||||
var ind = ths[j].querySelector('.__sort-ind');
|
||||
if (!ind) continue;
|
||||
if (j === state.col){
|
||||
ind.style.opacity = '1';
|
||||
ind.textContent = state.dir === 1 ? ARROW_ASC : ARROW_DESC;
|
||||
} else {
|
||||
ind.style.opacity = '0.45';
|
||||
ind.textContent = ARROW_NEUTRAL;
|
||||
}
|
||||
}
|
||||
|
||||
if (state.dir === 0){
|
||||
// Restore original order
|
||||
for (var k = 0; k < originalRows.length; k++) tbody.appendChild(originalRows[k]);
|
||||
return;
|
||||
}
|
||||
|
||||
var rows = Array.prototype.slice.call(tbody.rows);
|
||||
var samples = sampleColumn(rows, idx);
|
||||
var type = detectType(samples);
|
||||
rows.sort(function(a, b){
|
||||
var av = a.cells[idx] ? txt(a.cells[idx]) : '';
|
||||
var bv = b.cells[idx] ? txt(b.cells[idx]) : '';
|
||||
var c = compare(type, av, bv);
|
||||
return state.dir === 1 ? c : -c;
|
||||
});
|
||||
for (var r = 0; r < rows.length; r++) tbody.appendChild(rows[r]);
|
||||
});
|
||||
})(i, ths[i]);
|
||||
}
|
||||
table.__sortableAttached = true;
|
||||
}
|
||||
|
||||
function attachAll(scope){
|
||||
var root = scope || document;
|
||||
var tables = root.querySelectorAll ? root.querySelectorAll('table') : [];
|
||||
for (var i = 0; i < tables.length; i++) attach(tables[i]);
|
||||
}
|
||||
|
||||
// Auto-init on DOMContentLoaded; also re-scan when DOM mutates (SPAs that
|
||||
// re-render tables in-place).
|
||||
function boot(){
|
||||
attachAll(document);
|
||||
if (typeof MutationObserver !== 'undefined'){
|
||||
var rescan = function(){ attachAll(document); };
|
||||
var obs = new MutationObserver(function(muts){
|
||||
for (var i = 0; i < muts.length; i++){
|
||||
if (muts[i].addedNodes && muts[i].addedNodes.length){
|
||||
// throttle — debounce 100ms
|
||||
if (boot._t) clearTimeout(boot._t);
|
||||
boot._t = setTimeout(rescan, 100);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
obs.observe(document.body, { childList: true, subtree: true });
|
||||
}
|
||||
}
|
||||
if (document.readyState === 'loading'){
|
||||
document.addEventListener('DOMContentLoaded', boot);
|
||||
} else {
|
||||
boot();
|
||||
}
|
||||
|
||||
// Public API for explicit re-scans from app code
|
||||
window.attachSortable = attachAll;
|
||||
})();
|
||||
+621
-101
@@ -592,18 +592,31 @@ async function api(path){
|
||||
return null;
|
||||
}
|
||||
}
|
||||
async function apiPost(path, body){
|
||||
async function apiPost(path, body, opts){
|
||||
// apiPost: 30s timeout (added 2026-05-10) — protects against slow enrich endpoints.
|
||||
// Pass {timeoutMs: N} to override.
|
||||
const timeoutMs = (opts && opts.timeoutMs) || 30000;
|
||||
const ctrl = new AbortController();
|
||||
const tid = setTimeout(() => ctrl.abort(), timeoutMs);
|
||||
try{
|
||||
const tok = localStorage.getItem('pgz_access') || sessionStorage.getItem('pgz_access') || localStorage.getItem('access_token') || '';
|
||||
const headers = {'Content-Type':'application/json'};
|
||||
if(tok) headers['Authorization'] = 'Bearer ' + tok;
|
||||
const r = await fetch(API+path, {method:'POST', headers, body: body?JSON.stringify(body):'{}'});
|
||||
if(!r.ok){
|
||||
const errText = await r.text().catch(()=>(''));
|
||||
throw new Error('HTTP '+r.status+(errText? ': '+errText.slice(0,150):''));
|
||||
const r = await fetch(API+path, {method:'POST', headers, signal: ctrl.signal, body: body?JSON.stringify(body):'{}'});
|
||||
clearTimeout(tid);
|
||||
if(!r.ok){
|
||||
const errText = await r.text().catch(()=>(''));
|
||||
throw new Error('HTTP '+r.status+(errText? ': '+errText.slice(0,150):''));
|
||||
}
|
||||
return await r.json();
|
||||
}catch(e){
|
||||
clearTimeout(tid);
|
||||
if(e.name === 'AbortError'){
|
||||
const msg = `Timeout (${(timeoutMs/1000)|0}s) — server presporo odgovara`;
|
||||
console.error('API POST timeout', path);
|
||||
if(typeof showToast === 'function') showToast(msg, 'err');
|
||||
return null;
|
||||
}
|
||||
console.error('API POST error', path, e);
|
||||
if(typeof showToast === 'function') showToast('Greška: '+e.message, 'err');
|
||||
return null;
|
||||
@@ -1139,6 +1152,21 @@ async function loadDash(){
|
||||
const total2026 = proracun2026 ? proracun2026.ukupno : d.proracun_aktualni;
|
||||
|
||||
root.innerHTML = `
|
||||
<div class="ai-bar" style="display:flex;flex-direction:column;gap:8px;margin-bottom:12px;padding:12px;border:1px solid var(--bd);border-radius:8px;background:var(--bg2)">
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
<span style="font-size:14px">🤖</span>
|
||||
<span style="font-weight:600;font-size:13px">DABI AI Copilot</span>
|
||||
<span id="dash-ai-status" style="font-size:11px;color:var(--t2);margin-left:auto"></span>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px">
|
||||
<input id="dash-ai-q" type="text" placeholder="Pitaj DABI… (npr. Koliko klubova ima PGŽ?)"
|
||||
onkeydown="if(event.key==='Enter'){event.preventDefault();dashAiAsk();}"
|
||||
style="flex:1;padding:8px 10px;border:1px solid var(--bd);border-radius:6px;background:var(--bg);color:var(--t0);font-size:13px;outline:none">
|
||||
<button onclick="dashAiAsk()" id="dash-ai-btn"
|
||||
style="padding:8px 14px;border:0;border-radius:6px;background:var(--accent);color:#fff;font-size:13px;font-weight:600;cursor:pointer">Pitaj</button>
|
||||
</div>
|
||||
<div id="dash-ai-out" style="display:none;padding:10px;border-top:1px solid var(--bd);font-size:13px;line-height:1.5;white-space:pre-wrap;color:var(--t0);max-height:400px;overflow-y:auto"></div>
|
||||
</div>
|
||||
<div class="kpi-grid">
|
||||
<div class="kpi"><div class="kpi-l">Saveza</div><div class="kpi-v">${fmtNum(d.aktivnih_saveza)}</div><div class="kpi-s">aktivnih</div></div>
|
||||
<div class="kpi b"><div class="kpi-l">Klubova</div><div class="kpi-v">${fmtNum(d.aktivnih_klubova)}</div><div class="kpi-s">${d.nositelja_kvalitete||0} nositelja kvalitete</div></div>
|
||||
@@ -1188,6 +1216,38 @@ async function loadDash(){
|
||||
refreshDashNositelji();
|
||||
}
|
||||
|
||||
async function dashAiAsk(){
|
||||
const inp = document.getElementById('dash-ai-q');
|
||||
const out = document.getElementById('dash-ai-out');
|
||||
const btn = document.getElementById('dash-ai-btn');
|
||||
const stat = document.getElementById('dash-ai-status');
|
||||
if(!inp || !out || !btn) return;
|
||||
const q = (inp.value||'').trim();
|
||||
if(!q) return;
|
||||
const tok = localStorage.getItem('pgz_access') || sessionStorage.getItem('pgz_access') || localStorage.getItem('access_token') || '';
|
||||
if(!tok){
|
||||
out.style.display='block';
|
||||
out.textContent = '⚠ Prijava potrebna. Idi na /login pa se vrati ovamo.';
|
||||
if(stat) stat.textContent = 'traži prijavu';
|
||||
return;
|
||||
}
|
||||
btn.disabled = true; btn.textContent = '…';
|
||||
if(stat) stat.textContent = 'razmišljam…';
|
||||
out.style.display='block';
|
||||
out.textContent = '⏳ DABI razmišlja…';
|
||||
try{
|
||||
const headers = {'Content-Type':'application/json','Authorization':'Bearer '+tok};
|
||||
const r = await fetch(API+'/v2/ai/ask', {method:'POST', headers, body: JSON.stringify({question:q, query:q, q:q})});
|
||||
if(r.status===401){ out.textContent = '⚠ Sesija je istekla. Idi na /login.'; if(stat) stat.textContent='401'; return; }
|
||||
if(!r.ok){ const t = await r.text().catch(()=>''); out.textContent = '❌ Greška: HTTP '+r.status+(t?' — '+t.slice(0,200):''); if(stat) stat.textContent='greška'; return; }
|
||||
const data = await r.json();
|
||||
const answer = data.answer || data.response || data.text || JSON.stringify(data, null, 2).slice(0,1200);
|
||||
out.textContent = answer;
|
||||
if(stat) stat.textContent = 'odgovor spreman';
|
||||
}catch(e){ out.textContent = '❌ '+(e.message||String(e)); if(stat) stat.textContent='greška'; }
|
||||
finally{ btn.disabled = false; btn.textContent = 'Pitaj'; }
|
||||
}
|
||||
|
||||
async function refreshDashNositelji(){
|
||||
const selG = $('#dash-god');
|
||||
const selD = $('#dash-davatelj');
|
||||
@@ -1487,7 +1547,6 @@ function renderSaveziTable(rows){
|
||||
|
||||
async function openSavez(id){
|
||||
openPanel('Savez', '<div class="loading">Učitavanje saveza…</div>');
|
||||
setTimeout(() => loadSavezKpi(id), 100);
|
||||
const s = await api('/savezi/'+id);
|
||||
if(!s || s.detail){
|
||||
openPanel('Savez', '<div class="empty">Savez nije pronađen</div>');
|
||||
@@ -1761,6 +1820,64 @@ async function exportKlubovi(format){
|
||||
} catch(e){ window.toast && window.toast('Export greška: '+(e.message||e), 'error', 4000); }
|
||||
}
|
||||
|
||||
async function openGodisnjak(godina){
|
||||
// Drill-down panel za jedan godišnjak (PDF + popis spomenutih sportaša).
|
||||
// Endpoint: /api/v2/godisnjak/{godina}/sportasi (vraća count + sportasi[])
|
||||
// PDF: /api/v2/dokumenti/godisnjak/{godina}
|
||||
godina = parseInt(godina, 10);
|
||||
if(!godina) return;
|
||||
openPanel('📚 Godišnjak '+godina, '<div class="loading">Učitavanje godišnjaka…</div>');
|
||||
const d = await api('/v2/godisnjak/'+godina+'/sportasi?limit=500');
|
||||
if(!d || d.detail){
|
||||
openPanel('📚 Godišnjak '+godina, '<div class="empty">Godišnjak '+godina+' nije pronađen.</div>');
|
||||
return;
|
||||
}
|
||||
const sportasi = d.sportasi || [];
|
||||
const cnt = sportasi.length;
|
||||
const pdfHref = '/sport/api/v2/dokumenti/godisnjak/'+godina;
|
||||
// distinct sports for filter chips
|
||||
const sports = Array.from(new Set(sportasi.map(s => s.sport).filter(Boolean))).sort((a,b)=>a.localeCompare(b,'hr'));
|
||||
const 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)">Sportski godišnjak ${godina}</div>
|
||||
<div style="font-size:12px;color:var(--t2);margin-top:4px">${fmtNum(cnt)} sportaša spomenuto</div>
|
||||
</div>
|
||||
<a href="${pdfHref}" target="_blank" rel="noopener"
|
||||
class="btn" style="background:var(--accent);color:#fff;padding:6px 12px;border-radius:6px;font-weight:600;font-size:12px;text-decoration:none">
|
||||
📄 Otvori PDF ↗
|
||||
</a>
|
||||
</div>
|
||||
${sports.length ? `<div style="margin-bottom:10px;display:flex;flex-wrap:wrap;gap:4px;align-items:center">
|
||||
<span style="font-size:11px;color:var(--t2)">Sport:</span>
|
||||
<button class="tag" data-sport="" onclick="window._godSportFilter('${godina}','')" style="cursor:pointer">svi</button>
|
||||
${sports.map(sp=>`<button class="tag" data-sport="${esc(sp)}" onclick="window._godSportFilter('${godina}',this.dataset.sport)" style="cursor:pointer">${esc(sp)}</button>`).join('')}
|
||||
</div>` : ''}
|
||||
<div id="god-${godina}-list" style="overflow-x:auto"><table>
|
||||
<thead><tr><th>Sportaš</th><th>Sport</th><th>Klub</th><th style="text-align:center">🥇</th><th style="text-align:center">📊</th><th>Ključne riječi</th></tr></thead>
|
||||
<tbody>${sportasi.map(s => `
|
||||
<tr onclick="panelDrill(openSportas,${s.clan_id})" style="cursor:pointer" data-sport="${esc(s.sport||'')}">
|
||||
<td><b>${esc((s.ime||'')+' '+(s.prezime||''))}</b></td>
|
||||
<td>${esc(s.sport||'—')}</td>
|
||||
<td>${esc(s.klub||'—')}</td>
|
||||
<td style="text-align:center">${s.has_medal ? '<span class="tag gd" title="Medalja">🥇</span>' : ''}</td>
|
||||
<td style="text-align:center">${s.has_kategorija ? '<span class="tag b" title="Kategorija">K</span>' : ''}</td>
|
||||
<td>${(s.keywords||[]).slice(0,4).map(k=>'<span class="tag" style="font-size:10px">'+esc(k)+'</span>').join(' ')}</td>
|
||||
</tr>`).join('')}
|
||||
</tbody>
|
||||
</table></div>
|
||||
`;
|
||||
openPanel('📚 Godišnjak '+godina, html);
|
||||
}
|
||||
// Sport filter helper (column index 1 = "Sport")
|
||||
window._godSportFilter = function(godina, sp){
|
||||
const tbody = document.querySelector('#god-'+godina+'-list tbody');
|
||||
if(!tbody) return;
|
||||
tbody.querySelectorAll('tr').forEach(tr => {
|
||||
tr.style.display = (!sp || tr.dataset.sport === sp) ? '' : 'none';
|
||||
});
|
||||
};
|
||||
|
||||
async function openKlub(id){
|
||||
openPanel('Klub', '<div class="loading">Učitavanje kluba…</div>');
|
||||
const k = await api('/klubovi/'+id);
|
||||
@@ -2414,9 +2531,9 @@ async function openSportas(id){
|
||||
|
||||
${godisnjaci.length ? `<div class="pp-section-h" style="margin-top:18px">📚 Godišnjaci <span class="cnt">${godisnjaci.length}</span></div>
|
||||
<div class="kv">
|
||||
<div class="k">Prvi godišnjak</div><div class="v">${esc(d.godisnjak_prvi||godisnjaci[0])}</div>
|
||||
<div class="k">Zadnji godišnjak</div><div class="v">${esc(d.godisnjak_zadnji||godisnjaci[godisnjaci.length-1])}</div>
|
||||
<div class="k">Sve godine</div><div class="v">${godisnjaci.map(g=>'<span class="tag b">'+esc(g)+'</span>').join(' ')}</div>
|
||||
<div class="k">Prvi godišnjak</div><div class="v">${(()=>{const _g=d.godisnjak_prvi||godisnjaci[0];return _g?'<a class="tag b" href="javascript:void(0)" onclick="openGodisnjak('+_g+');return false;" title="Otvori godišnjak '+esc(_g)+'">'+esc(_g)+'</a>':'—';})()}</div>
|
||||
<div class="k">Zadnji godišnjak</div><div class="v">${(()=>{const _g=d.godisnjak_zadnji||godisnjaci[godisnjaci.length-1];return _g?'<a class="tag b" href="javascript:void(0)" onclick="openGodisnjak('+_g+');return false;" title="Otvori godišnjak '+esc(_g)+'">'+esc(_g)+'</a>':'—';})()}</div>
|
||||
<div class="k">Sve godine</div><div class="v">${godisnjaci.map(g=>'<a class="tag b" href="javascript:void(0)" onclick="openGodisnjak('+g+');return false;" title="Otvori godišnjak '+esc(g)+'">'+esc(g)+'</a>').join(' ')}</div>
|
||||
</div>` : ''}
|
||||
|
||||
${nagrade.length ? `<div class="pp-section-h" style="margin-top:18px">🏅 Nagrade <span class="cnt">${nagrade.length}</span></div>
|
||||
@@ -2470,9 +2587,11 @@ async function loadFinancije(){
|
||||
<select id="fi-god">
|
||||
${meta.godine.map(g => `<option value="${g.godina}">${g.godina} (${g.broj}, ${fmtEur(g.suma||0)})</option>`).join('')}
|
||||
</select>
|
||||
<select id="fi-davatelj" title="Platitelj">
|
||||
<option value="all">Svi platitelji</option>
|
||||
${meta.davatelji.map(d => `<option value="${d}">${d.includes('rijeka') ? '🌆 Grad Rijeka' : '🏛 PGŽ'} (${d})</option>`).join('')}
|
||||
<select id="fi-davatelj" title="Izvor">
|
||||
<option value="all">Svi izvori</option>
|
||||
<option value="pgz">🏛 PGŽ</option>
|
||||
<option value="rss">🌊 RSS (Riječki sportski savez)</option>
|
||||
<option value="grad_rijeka">🌆 Grad Rijeka</option>
|
||||
</select>
|
||||
<select id="fi-sport" title="Sport">
|
||||
<option value="">Svi sportovi</option>
|
||||
@@ -2483,6 +2602,10 @@ async function loadFinancije(){
|
||||
${meta.vrste.map(v => `<option value="${v}">${v.replace('_',' ')}</option>`).join('')}
|
||||
</select>
|
||||
<input type="search" id="fi-q" placeholder="🔍 Pretraži korisnika…">
|
||||
<label class="tb-s" style="display:flex;align-items:center;gap:6px;cursor:pointer;user-select:none" title="Samo plaćanja koja imaju vezu na realni klub (klub_id IS NOT NULL). Isključuje skrečane PDF section-headere.">
|
||||
<input type="checkbox" id="fi-samo-klubovi" checked style="cursor:pointer"> samo klubovi
|
||||
</label>
|
||||
<button id="fi-manual-btn" class="btn sm" style="background:var(--pgz-gold,#c5a040);color:#000" title="Dodaj plaćanje za poznati klub (pgz_admin / super_admin)">+ Ručni unos</button>
|
||||
<div class="toggle">
|
||||
<button id="fi-card" class="${_state.viewFinancije==='card'?'active':''}" onclick="setFinancijeView('card')">Kartice</button>
|
||||
<button id="fi-table-btn" class="${_state.viewFinancije==='table'?'active':''}" onclick="setFinancijeView('table')">Tablica</button>
|
||||
@@ -2507,6 +2630,8 @@ async function loadFinancije(){
|
||||
if($('#fi-sport')) $('#fi-sport').addEventListener('change', refreshFinancije);
|
||||
if($('#fi-vrsta')) $('#fi-vrsta').addEventListener('change', refreshFinancije);
|
||||
$('#fi-q').addEventListener('input', debounce(refreshFinancije, 200));
|
||||
if($('#fi-samo-klubovi')) $('#fi-samo-klubovi').addEventListener('change', refreshFinancije);
|
||||
if($('#fi-manual-btn')) $('#fi-manual-btn').addEventListener('click', openManualFinancijeForm);
|
||||
refreshFinancije();
|
||||
}
|
||||
async function refreshFinancije(){
|
||||
@@ -2515,23 +2640,53 @@ async function refreshFinancije(){
|
||||
const sport = $('#fi-sport') ? $('#fi-sport').value : '';
|
||||
const vrsta = $('#fi-vrsta') ? $('#fi-vrsta').value : '';
|
||||
const q = ($('#fi-q').value || '').toLowerCase().trim();
|
||||
|
||||
// Map davatelj 'rijeka.hr' → 'rijeka', 'www2.pgz.hr' → 'pgz'
|
||||
let davParam = '';
|
||||
if(dav.includes('rijeka')) davParam = '&davatelj=rijeka';
|
||||
else if(dav.includes('pgz')) davParam = '&davatelj=pgz';
|
||||
|
||||
const params = `?godina=${god}${davParam}${sport?'&sport='+encodeURIComponent(sport):''}${vrsta?'&vrsta='+encodeURIComponent(vrsta):''}`;
|
||||
|
||||
const [analytics, byyear] = await Promise.all([
|
||||
|
||||
// PGŽ + Grad Rijeka live in pgz_sport.sufinanciranje_sport (served by /v2/potpore/by-year).
|
||||
// RSS (Riječki sportski savez) lives separately and is served by /dashboard/top-primatelji.
|
||||
// Merge both into a unified row shape so the user can filter "Svi izvori" in one table.
|
||||
let pgzRijekaParam = '';
|
||||
if (dav === 'grad_rijeka') pgzRijekaParam = '&davatelj=rijeka';
|
||||
else if (dav === 'pgz') pgzRijekaParam = '&davatelj=pgz';
|
||||
// For dav='rss' we skip /v2/potpore/by-year entirely; for dav='all' we keep it unfiltered.
|
||||
const fetchPgzRijeka = (dav !== 'rss');
|
||||
const fetchRss = (dav === 'all' || dav === 'rss');
|
||||
|
||||
const samoKlubovi = $('#fi-samo-klubovi') ? !!$('#fi-samo-klubovi').checked : true;
|
||||
const params = `?godina=${god}${pgzRijekaParam}${sport?'&sport='+encodeURIComponent(sport):''}${vrsta?'&vrsta='+encodeURIComponent(vrsta):''}&samo_klubovi=${samoKlubovi}`;
|
||||
|
||||
const [analytics, byyear, rssJson] = await Promise.all([
|
||||
api('/v2/analytics/proracun-sport?godina='+god),
|
||||
api('/v2/potpore/by-year'+params)
|
||||
fetchPgzRijeka ? api('/v2/potpore/by-year'+params) : Promise.resolve({results:[], total:0}),
|
||||
fetchRss ? api('/dashboard/top-primatelji?godina='+god+'&limit=500').catch(()=>({rows:[]})) : Promise.resolve({rows:[]}),
|
||||
]);
|
||||
const total = (analytics && analytics.total) || (byyear && byyear.total) || 0;
|
||||
const poSportu = (analytics && analytics.po_sportu) || [];
|
||||
let rows = (byyear && byyear.results) || [];
|
||||
|
||||
// Normalize PGŽ + Grad Rijeka rows: izvor stays 'rijeka.hr' or 'www2.pgz.hr'.
|
||||
const pgzRijekaRows = (byyear && byyear.results) || [];
|
||||
// Normalize RSS rows from /dashboard/top-primatelji to the same shape used by the table.
|
||||
const rssRows = ((rssJson && rssJson.rows) || []).map(r => ({
|
||||
korisnik: r.naziv_kluba,
|
||||
sport: r.sport,
|
||||
vrsta: r.vrsta || 'javne_potrebe',
|
||||
iznos_eur: r.iznos,
|
||||
razina: 'rss',
|
||||
izvor: 'rss.hr',
|
||||
source_url: r.pdf_url, // /sport/api/v2/dokumenti/godisnjak/<god>
|
||||
godina: r.godina,
|
||||
klub_id: r.klub_id,
|
||||
napomena: r.napomena_short || r.napomena,
|
||||
_davatelj_label: r.davatelj || 'RSS',
|
||||
}));
|
||||
// Apply the in-flight sport / vrsta filter to RSS too (by-year already applies it server-side).
|
||||
let rssFiltered = rssRows;
|
||||
if (sport) rssFiltered = rssFiltered.filter(r => (r.sport||'').toLowerCase().includes(sport.toLowerCase()));
|
||||
if (vrsta) rssFiltered = rssFiltered.filter(r => (r.vrsta||'').toLowerCase().includes(vrsta.toLowerCase()));
|
||||
|
||||
let rows = pgzRijekaRows.concat(rssFiltered);
|
||||
if(q) rows = rows.filter(r => (r.korisnik||'').toLowerCase().includes(q) || (r.sport||'').toLowerCase().includes(q));
|
||||
|
||||
const total = rows.reduce((s,r) => s + Number(r.iznos_eur||0), 0);
|
||||
const poSportu = (analytics && analytics.po_sportu) || [];
|
||||
|
||||
$('#fi-kpi').innerHTML = `
|
||||
<div class="kpi-grid">
|
||||
<div class="kpi"><div class="kpi-l">Ukupno ${god}</div><div class="kpi-v">${fmtEur(total)}</div></div>
|
||||
@@ -2575,21 +2730,133 @@ async function refreshFinancije(){
|
||||
</div>`).join('')+'</div>';
|
||||
} else {
|
||||
$('#fi-table').innerHTML = `<div style="overflow-x:auto"><table>
|
||||
<thead><tr><th>#</th>${sortHeader('financije','korisnik','Korisnik','')}${sortHeader('financije','sport','Sport','')}${sortHeader('financije','vrsta','Vrsta','')}${sortHeader('financije','iznos_eur','Iznos','num')}${sortHeader('financije','izvor','Izvor','')}<th>PDF</th></tr></thead>
|
||||
<tbody>${sortedRows.map((r,i) => `
|
||||
<tr onclick='openPrimateljDetail(${JSON.stringify(r).replace(/'/g,"'")})'>
|
||||
<thead><tr><th></th><th>#</th>${sortHeader('financije','korisnik','Korisnik','')}${sortHeader('financije','sport','Sport','')}${sortHeader('financije','godina','God','num')}${sortHeader('financije','iznos_eur','Iznos','num')}${sortHeader('financije','izvor','Izvor','')}<th>PDF</th></tr></thead>
|
||||
<tbody>${sortedRows.map((r,i) => {
|
||||
const rid = 'fi-row-' + i;
|
||||
const izvLabel = financijeIzvorLabel(r.izvor);
|
||||
const pdfBtn = r.source_url
|
||||
? '<a href="'+esc(financijePdfUrl(r))+'" target="_blank" rel="noreferrer" onclick="event.stopPropagation()" class="btn sm" title="Otvori izvorni PDF i skoči na klub">📄 Otvori PDF</a>'
|
||||
: '<span style="color:var(--t3)">—</span>';
|
||||
return `
|
||||
<tr id="${rid}" onclick='toggleFinancijeDrill(${JSON.stringify(rid).replace(/'/g,"'")}, ${JSON.stringify(r.korisnik).replace(/'/g,"'")})' style="cursor:pointer">
|
||||
<td style="width:24px;text-align:center;color:var(--t3)">▸</td>
|
||||
<td>${i+1}</td>
|
||||
<td><b>${esc(r.korisnik)}</b></td>
|
||||
<td>${txt(r.sport)}</td>
|
||||
<td>${txt(r.vrsta)}</td>
|
||||
<td class="num">${esc(r.godina||'')}</td>
|
||||
<td class="num"><b>${fmtEurFull(r.iznos_eur)}</b></td>
|
||||
<td>${txt(r.izvor)}</td>
|
||||
<td>${r.source_url?'<a href="'+esc(r.source_url)+'" target="_blank" onclick="event.stopPropagation()">📄</a>':'—'}</td>
|
||||
</tr>`).join('')}
|
||||
<td><span class="badge" style="font-size:10px">${esc(izvLabel)}</span></td>
|
||||
<td onclick="event.stopPropagation()">${pdfBtn}</td>
|
||||
</tr>`;
|
||||
}).join('')}
|
||||
</tbody>
|
||||
</table></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────── helpers added 2026-05-09 (Task 03b) ─────────────
|
||||
|
||||
// Map raw izvor strings → human label.
|
||||
function financijeIzvorLabel(izvor){
|
||||
const v = (izvor||'').toLowerCase();
|
||||
if (v.includes('rijeka.hr')) return 'Grad Rijeka';
|
||||
if (v.includes('rss')) return 'RSS';
|
||||
if (v.includes('pgz')) return 'PGŽ';
|
||||
return izvor || '—';
|
||||
}
|
||||
|
||||
// Build a PDF link with #search=<korisnik> so most browsers' built-in PDF viewer
|
||||
// scrolls to the first match. Falls back gracefully when source_url is missing.
|
||||
function financijePdfUrl(r){
|
||||
if (!r.source_url) return '#';
|
||||
const sep = r.source_url.includes('#') ? '&' : '#';
|
||||
// PDF.js + Chrome built-in viewer respect "#search=…"; for non-PDF docs the
|
||||
// fragment is harmless.
|
||||
return r.source_url + sep + 'search=' + encodeURIComponent(r.korisnik || '');
|
||||
}
|
||||
|
||||
// Toggle inline expansion under the clicked row. Loads all-years × all-izvor
|
||||
// rows for the same korisnik and renders a sub-table grouped by izvor.
|
||||
async function toggleFinancijeDrill(rowId, korisnik){
|
||||
const row = document.getElementById(rowId);
|
||||
if (!row) return;
|
||||
const next = row.nextElementSibling;
|
||||
if (next && next.classList && next.classList.contains('fi-drill')) {
|
||||
next.remove();
|
||||
row.firstElementChild.textContent = '▸';
|
||||
return;
|
||||
}
|
||||
row.firstElementChild.textContent = '▾';
|
||||
const drill = document.createElement('tr');
|
||||
drill.className = 'fi-drill';
|
||||
const colspan = row.children.length;
|
||||
drill.innerHTML = `<td colspan="${colspan}" style="background:var(--bg2);padding:10px 16px"><div class="loading">Učitavanje povijesti za <b>${esc(korisnik)}</b>…</div></td>`;
|
||||
row.parentNode.insertBefore(drill, row.nextSibling);
|
||||
|
||||
// Fetch all years from both data sources (by-year accepts q, top-primatelji loops years).
|
||||
const meta = window._fiMeta || (window._fiMeta = await api('/v2/potpore/meta'));
|
||||
const years = (meta.godine || []).map(g => g.godina);
|
||||
// Cap to last ~12 years to avoid blowing the request count.
|
||||
const yearList = years.slice(0, 12);
|
||||
|
||||
const aggregateByYear = await api('/v2/potpore/aggregate?q=' + encodeURIComponent(korisnik) + '&limit=50').catch(() => ({results:[]}));
|
||||
// For RSS we have to loop years (no per-korisnik filter on top-primatelji).
|
||||
const rssFetches = await Promise.all(yearList.map(y =>
|
||||
api('/dashboard/top-primatelji?godina=' + y + '&limit=500').catch(() => ({rows:[]}))
|
||||
));
|
||||
const rssAll = [];
|
||||
rssFetches.forEach(j => (j.rows || []).forEach(r => {
|
||||
if ((r.naziv_kluba||'').toLowerCase().includes(korisnik.toLowerCase())) {
|
||||
rssAll.push({
|
||||
korisnik: r.naziv_kluba, sport: r.sport, godina: r.godina,
|
||||
iznos_eur: r.iznos, izvor: 'rss.hr', source_url: r.pdf_url,
|
||||
klub_id: r.klub_id,
|
||||
});
|
||||
}
|
||||
}));
|
||||
|
||||
// Pull aggregate hits as flattened rows (one per matched korisnik · tip group).
|
||||
const aggRows = (aggregateByYear.results || []).filter(a =>
|
||||
(a.korisnik||'').toLowerCase() === korisnik.toLowerCase()
|
||||
);
|
||||
|
||||
// Build per-izvor summary.
|
||||
const all = rssAll.slice();
|
||||
// Add aggregate results as summary rows (they sum iznos across years per tip).
|
||||
const aggSummary = aggRows.map(a => ({
|
||||
korisnik: a.korisnik, sport: a.sport, godina: `${a.od_god}–${a.do_god}`,
|
||||
iznos_eur: a.ukupno_eur, izvor: a.izvori || a.tip,
|
||||
source_url: a.source_url, _is_summary: true, _n: a.n_potpore,
|
||||
}));
|
||||
|
||||
if (all.length === 0 && aggSummary.length === 0) {
|
||||
drill.firstChild.innerHTML = `<div class="empty" style="padding:8px">Nema dodatnih zapisa za <b>${esc(korisnik)}</b>.</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const groupHtml = (label, rows) => {
|
||||
if (!rows.length) return '';
|
||||
const sum = rows.reduce((s,r) => s + Number(r.iznos_eur||0), 0);
|
||||
return `
|
||||
<div style="margin-top:8px">
|
||||
<div style="font-size:11px;color:var(--t2);text-transform:uppercase;letter-spacing:0.6px;margin-bottom:4px">${label} <span style="color:var(--t3)">· ${rows.length} stavki · ${fmtEurFull(sum)}</span></div>
|
||||
<table style="width:100%;font-size:12px">
|
||||
<thead><tr><th style="text-align:left">Godina</th><th style="text-align:left">Sport / razina</th><th class="num">Iznos</th><th>Izvor</th><th>PDF</th></tr></thead>
|
||||
<tbody>${rows.map(r => {
|
||||
const pdf = r.source_url ? '<a href="'+esc(financijePdfUrl(r))+'" target="_blank" onclick="event.stopPropagation()">📄</a>' : '—';
|
||||
return `<tr><td>${esc(r.godina)}</td><td>${txt(r.sport)}</td><td class="num"><b>${fmtEurFull(r.iznos_eur)}</b></td><td>${esc(financijeIzvorLabel(r.izvor))}</td><td>${pdf}</td></tr>`;
|
||||
}).join('')}</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
};
|
||||
|
||||
drill.firstChild.innerHTML = `
|
||||
<div style="padding:4px 0">
|
||||
<div style="font-weight:700;font-size:13px;margin-bottom:4px">📜 Povijest financiranja: ${esc(korisnik)}</div>
|
||||
${groupHtml('🌊 RSS — godišnjaci', rssAll.sort((a,b)=>b.godina-a.godina))}
|
||||
${groupHtml('🏛 PGŽ + 🌆 Grad Rijeka — agregat', aggSummary)}
|
||||
</div>`;
|
||||
}
|
||||
function setFinancijeView(v){
|
||||
_state.viewFinancije = v;
|
||||
const cb = $('#fi-card'), tb = $('#fi-table-btn');
|
||||
@@ -2695,18 +2962,41 @@ function renderObjektiGrid(rows){
|
||||
function renderObjektiTable(rows){
|
||||
if(!rows.length) return '<div class="empty">Nema rezultata</div>';
|
||||
return `<div class="card" style="padding:0;overflow-x:auto"><table>
|
||||
<thead><tr>${sortHeader('objekti','naziv','Naziv','')}${sortHeader('objekti','tip','Tip','')}${sortHeader('objekti','grad','Grad','')}${sortHeader('objekti','upravitelj','Upravitelj','')}${sortHeader('objekti','izgradeno','Izgrađeno','num')}${sortHeader('objekti','lat','GPS','')}</tr></thead>
|
||||
<tbody>${rows.map(o => `
|
||||
<thead><tr>${sortHeader('objekti','naziv','Naziv','')}${sortHeader('objekti','tip','Tip','')}${sortHeader('objekti','sportovi','Sport','')}${sortHeader('objekti','kapacitet','Kapacitet','num')}<th>Adresa</th>${sortHeader('objekti','upravitelj','Upravitelj / klub','')}<th>Karta</th></tr></thead>
|
||||
<tbody>${rows.map(o => {
|
||||
const sportLabel = Array.isArray(o.sportovi) && o.sportovi.length ? o.sportovi.slice(0,2).join(', ') + (o.sportovi.length>2?` +${o.sportovi.length-2}`:'') : '—';
|
||||
const adresaLabel = [o.adresa, o.grad].filter(Boolean).join(', ') || '—';
|
||||
return `
|
||||
<tr onclick="openObjekt(${o.id})">
|
||||
<td><b>${esc(o.naziv)}</b></td>
|
||||
<td>${txt(o.tip)}</td>
|
||||
<td>${txt(o.grad)}</td>
|
||||
<td>${esc(sportLabel)}</td>
|
||||
<td class="num">${o.kapacitet ? fmtNum(o.kapacitet) : '—'}</td>
|
||||
<td>${esc(adresaLabel)}</td>
|
||||
<td>${txt(o.upravitelj)}</td>
|
||||
<td class="num">${txt(o.izgradeno)}</td>
|
||||
<td>${o.lat&&o.lng?'<span class="tag gr">📍</span>':'—'}</td>
|
||||
</tr>`).join('')}</tbody>
|
||||
<td onclick="event.stopPropagation()">${objektMapsButton(o)}</td>
|
||||
</tr>`;
|
||||
}).join('')}</tbody>
|
||||
</table></div>`;
|
||||
}
|
||||
|
||||
// Build a Google Maps deep-link button for an objekt row.
|
||||
// Spec: prefer lat/lng if present, fall back to address search. We
|
||||
// additionally offer the address-search button alongside the pin so
|
||||
// users can sanity-check pin placement (some seed coordinates were
|
||||
// inaccurate — see 22_objekti_maps.md).
|
||||
function objektMapsButton(o){
|
||||
const addrParts = [o.naziv, o.adresa, o.grad].filter(Boolean).join(', ') || (o.naziv || '');
|
||||
const addrUrl = 'https://www.google.com/maps/search/?api=1&query=' + encodeURIComponent(addrParts);
|
||||
if (o.lat != null && o.lng != null) {
|
||||
const pinUrl = 'https://www.google.com/maps/search/?api=1&query=' + Number(o.lat) + ',' + Number(o.lng);
|
||||
return (
|
||||
'<a class="btn sm" href="' + esc(pinUrl) + '" target="_blank" rel="noreferrer" title="Otvori spremljene koordinate u Google Maps">📍 Pin</a> ' +
|
||||
'<a class="btn sm" href="' + esc(addrUrl) + '" target="_blank" rel="noreferrer" title="Otvori adresu (sigurnija opcija ako pin izgleda krivo)">🔍 Adresa</a>'
|
||||
);
|
||||
}
|
||||
return '<a class="btn sm" href="' + esc(addrUrl) + '" target="_blank" rel="noreferrer" title="Otvori adresu u Google Maps (nema spremljenih koordinata)">🔍 Adresa</a>';
|
||||
}
|
||||
function openObjekt(id){
|
||||
const o = (_cache.objekti||[]).find(x => x.id===id);
|
||||
if(!o){ openPanel('Objekt', '<div class="empty">Objekt nije pronađen</div>'); return; }
|
||||
@@ -2744,9 +3034,10 @@ function openObjekt(id){
|
||||
|
||||
//=========== MANIFESTACIJE ===========
|
||||
// View mode persisted in localStorage as `_manifViewMode` ('card'|'table')
|
||||
const _manifFilter = {mjesto:'', razina:'', organizator:'', q:''};
|
||||
const _manifFilter = {mjesto:'', razina:'', organizator:'', q:'', godina:'', sport:'', savez_id:''};
|
||||
let _manifMeta = null;
|
||||
let _manifLoadSeq = 0;
|
||||
const _manifExpanded = new Set();
|
||||
|
||||
async function loadManifestacije(){
|
||||
const root = $('#pg-manifestacije');
|
||||
@@ -2755,7 +3046,7 @@ async function loadManifestacije(){
|
||||
if(saved==='card' || saved==='table') _state.viewManif = saved;
|
||||
if(!_manifMeta){
|
||||
root.innerHTML = '<div class="loading">Učitavanje manifestacija…</div>';
|
||||
_manifMeta = await api('/v2/manifestacije/meta') || {mjesta:[], razine:[], organizatori:[]};
|
||||
_manifMeta = await api('/v2/manifestacije/meta') || {mjesta:[], razine:[], organizatori:[], godine:[], sportovi:[], savezi:[]};
|
||||
}
|
||||
renderManifShell();
|
||||
await reloadManifestacije();
|
||||
@@ -2771,6 +3062,9 @@ async function reloadManifestacije(){
|
||||
if(_manifFilter.razina) params.set('razina', _manifFilter.razina);
|
||||
if(_manifFilter.organizator) params.set('organizator', _manifFilter.organizator);
|
||||
if(_manifFilter.q) params.set('q', _manifFilter.q);
|
||||
if(_manifFilter.godina) params.set('godina', _manifFilter.godina);
|
||||
if(_manifFilter.sport) params.set('sport', _manifFilter.sport);
|
||||
if(_manifFilter.savez_id) params.set('savez_id', _manifFilter.savez_id);
|
||||
params.set('limit', '500');
|
||||
const qs = params.toString();
|
||||
const d = await api('/v2/manifestacije'+(qs?'?'+qs:''));
|
||||
@@ -2780,15 +3074,20 @@ async function reloadManifestacije(){
|
||||
return;
|
||||
}
|
||||
_cache.manifestacije = d.rows || [];
|
||||
_manifExpanded.clear();
|
||||
renderManifBody();
|
||||
}
|
||||
function renderManifShell(){
|
||||
const root = $('#pg-manifestacije');
|
||||
const meta = _manifMeta || {mjesta:[], razine:[], organizatori:[]};
|
||||
const meta = _manifMeta || {mjesta:[], razine:[], organizatori:[], godine:[], sportovi:[], savezi:[]};
|
||||
const optList = (arr) => (arr||[]).filter(x=>x!==null && x!==undefined && x!=='').map(v=>'<option value="'+esc(v)+'">'+esc(v)+'</option>').join('');
|
||||
const optSavezi = (meta.savezi||[]).map(s=>'<option value="'+esc(s.id)+'">'+esc(s.naziv)+'</option>').join('');
|
||||
root.innerHTML = `
|
||||
<div class="toolbar">
|
||||
<input type="search" id="mn-q" placeholder="🔍 Pretraži manifestaciju…" value="${esc(_manifFilter.q)}">
|
||||
<select id="mn-godina" title="Godina"><option value="">Sve godine</option>${optList(meta.godine)}</select>
|
||||
<select id="mn-sport" title="Sport"><option value="">Svi sportovi</option>${optList(meta.sportovi)}</select>
|
||||
<select id="mn-savez" title="Savez"><option value="">Svi savezi</option>${optSavezi}</select>
|
||||
<select id="mn-mjesto" title="Mjesto"><option value="">Sva mjesta</option>${optList(meta.mjesta)}</select>
|
||||
<select id="mn-raz" title="Razina"><option value="">Sve razine</option>${optList(meta.razine)}</select>
|
||||
<select id="mn-org" title="Organizator"><option value="">Svi organizatori</option>${optList(meta.organizatori)}</select>
|
||||
@@ -2805,13 +3104,21 @@ function renderManifShell(){
|
||||
if($('#mn-mjesto')) $('#mn-mjesto').value = _manifFilter.mjesto;
|
||||
if($('#mn-raz')) $('#mn-raz').value = _manifFilter.razina;
|
||||
if($('#mn-org')) $('#mn-org').value = _manifFilter.organizator;
|
||||
if($('#mn-godina')) $('#mn-godina').value = _manifFilter.godina;
|
||||
if($('#mn-sport')) $('#mn-sport').value = _manifFilter.sport;
|
||||
if($('#mn-savez')) $('#mn-savez').value = _manifFilter.savez_id;
|
||||
$('#mn-q').addEventListener('input', debounce(()=>{ _manifFilter.q = $('#mn-q').value.trim(); reloadManifestacije(); }, 250));
|
||||
$('#mn-mjesto').addEventListener('change', ()=>{ _manifFilter.mjesto = $('#mn-mjesto').value; reloadManifestacije(); });
|
||||
$('#mn-raz').addEventListener('change', ()=>{ _manifFilter.razina = $('#mn-raz').value; reloadManifestacije(); });
|
||||
$('#mn-org').addEventListener('change', ()=>{ _manifFilter.organizator = $('#mn-org').value; reloadManifestacije(); });
|
||||
$('#mn-godina').addEventListener('change', ()=>{ _manifFilter.godina = $('#mn-godina').value; reloadManifestacije(); });
|
||||
$('#mn-sport').addEventListener('change', ()=>{ _manifFilter.sport = $('#mn-sport').value; reloadManifestacije(); });
|
||||
$('#mn-savez').addEventListener('change', ()=>{ _manifFilter.savez_id = $('#mn-savez').value; reloadManifestacije(); });
|
||||
$('#mn-reset').addEventListener('click', ()=>{
|
||||
_manifFilter.mjesto=''; _manifFilter.razina=''; _manifFilter.organizator=''; _manifFilter.q='';
|
||||
_manifFilter.godina=''; _manifFilter.sport=''; _manifFilter.savez_id='';
|
||||
$('#mn-q').value=''; $('#mn-mjesto').value=''; $('#mn-raz').value=''; $('#mn-org').value='';
|
||||
$('#mn-godina').value=''; $('#mn-sport').value=''; $('#mn-savez').value='';
|
||||
reloadManifestacije();
|
||||
});
|
||||
}
|
||||
@@ -2830,89 +3137,117 @@ function renderManifBody(){
|
||||
}
|
||||
// Backwards-compat: existing handlers (e.g. sortHeader) call applyManifFilter()
|
||||
function applyManifFilter(){ renderManifBody(); }
|
||||
function manifLinkFor(m){
|
||||
if(m && m.source_url) return m.source_url;
|
||||
const gq = encodeURIComponent(((m&&m.naziv)||'')+' '+((m&&m.mjesto)||'')+' sport');
|
||||
return 'https://www.google.com/search?q='+gq;
|
||||
function manifDetailHTML(m){
|
||||
const gq = encodeURIComponent((m.naziv||'')+' '+(m.mjesto||'')+' sport');
|
||||
const googleUrl = 'https://www.google.com/search?q='+gq;
|
||||
const created = m.created_at ? new Date(m.created_at).toLocaleDateString('hr') : '—';
|
||||
const scraped = m.last_scraped_at ? new Date(m.last_scraped_at).toLocaleDateString('hr') : '—';
|
||||
const savezLink = m.savez_id ? '<a href="#" onclick="event.preventDefault();event.stopPropagation();openSavez('+m.savez_id+')">'+esc(m.savez_naziv||('Savez '+m.savez_id))+'</a>' : '—';
|
||||
return `
|
||||
<div class="kv">
|
||||
<div class="k">Sport</div><div class="v">${m.sport?'<span class="tag b">'+esc(m.sport)+'</span>':'—'}</div>
|
||||
<div class="k">Savez</div><div class="v">${savezLink}</div>
|
||||
<div class="k">Razina</div><div class="v">${m.razina?'<span class="tag b">'+esc(m.razina)+'</span>':'—'}</div>
|
||||
<div class="k">Mjesto</div><div class="v">${txt(m.mjesto)}</div>
|
||||
<div class="k">Organizator (klub)</div><div class="v">${txt(m.organizator)}</div>
|
||||
<div class="k">Godina od</div><div class="v">${txt(m.godina_od)}</div>
|
||||
<div class="k">Sudionici</div><div class="v">${txt(m.broj_ucesnika)}</div>
|
||||
<div class="k">Spol/kategorija</div><div class="v">${txt(m.spol_kategorija)}</div>
|
||||
<div class="k">Izvor</div><div class="v">${txt(m.source)}</div>
|
||||
<div class="k">Web savez</div><div class="v">${m.savez_web?'<a href="'+esc(m.savez_web)+'" target="_blank" rel="noopener" onclick="event.stopPropagation()">'+esc(m.savez_web)+' ↗</a>':'—'}</div>
|
||||
<div class="k">Unos</div><div class="v">${esc(created)}</div>
|
||||
<div class="k">Zadnji scrape</div><div class="v">${esc(scraped)}</div>
|
||||
</div>
|
||||
${m.napomena ? '<div style="margin-top:10px;font-size:12px;line-height:1.5;color:var(--t1);padding:10px;background:var(--bg3);border-radius:5px">'+esc(m.napomena)+'</div>' : ''}
|
||||
<div style="margin-top:10px;text-align:right">
|
||||
<a href="${googleUrl}" target="_blank" class="btn" rel="noopener" onclick="event.stopPropagation()" style="text-decoration:none">🔍 Google</a>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
function manifOtvoriBtn(m){
|
||||
if(m.source_url){
|
||||
return '<a class="btn primary" href="'+esc(m.source_url)+'" target="_blank" rel="noopener" onclick="event.stopPropagation()" style="text-decoration:none">Otvori ↗</a>';
|
||||
}
|
||||
const open = _manifExpanded.has(m.id);
|
||||
return '<button class="btn" type="button" onclick="event.stopPropagation();toggleManif('+m.id+')">'+(open?'▴ Sakrij':'▾ Detalji')+'</button>';
|
||||
}
|
||||
function renderManifGrid(rows){
|
||||
if(!rows.length) return '<div class="empty">Nema manifestacija za zadane filtere</div>';
|
||||
return '<div class="grid">'+rows.map(m => {
|
||||
const url = manifLinkFor(m);
|
||||
const linkIcon = '<a class="et-link" href="'+esc(url)+'" target="_blank" rel="noopener" onclick="event.stopPropagation()" title="'+(m.source_url?'Otvori izvor':'Pretraži online')+'">🔗</a>';
|
||||
const open = _manifExpanded.has(m.id);
|
||||
const sportTag = m.sport ? '<span class="tag b">'+esc(m.sport)+'</span>' : '';
|
||||
return `
|
||||
<div class="entity" onclick="openManif(${m.id})">
|
||||
${m.razina?'<div class="et-tag">'+esc(m.razina)+'</div>':''}
|
||||
<div class="et">${esc(m.naziv)} ${linkIcon}</div>
|
||||
<div class="es">${txt(m.mjesto,'—')}${m.spol_kategorija?' · '+esc(m.spol_kategorija):''}${m.godina_od?' · od '+esc(m.godina_od):''}</div>
|
||||
<div class="et">${esc(m.naziv)}</div>
|
||||
<div class="es">${sportTag}${m.godina_od?' <span class="tag">'+esc(m.godina_od)+'</span>':''} ${txt(m.mjesto,'—')}</div>
|
||||
<div class="em">
|
||||
${m.organizator?'<span>'+esc((m.organizator||'').slice(0,50))+'</span>':''}
|
||||
${m.broj_ucesnika?'<span><b>'+esc(m.broj_ucesnika)+'</b> sudionika</span>':''}
|
||||
${m.organizator?'<span>'+esc((m.organizator||'').slice(0,40))+'</span>':''}
|
||||
</div>
|
||||
<div style="margin-top:8px;text-align:right">${manifOtvoriBtn(m)}</div>
|
||||
${open?'<div class="card" style="margin-top:10px;padding:10px" onclick="event.stopPropagation()">'+manifDetailHTML(m)+'</div>':''}
|
||||
</div>`;
|
||||
}).join('')+'</div>';
|
||||
}
|
||||
function renderManifTable(rows){
|
||||
if(!rows.length) return '<div class="empty">Nema manifestacija za zadane filtere</div>';
|
||||
return `<div class="card" style="padding:0;overflow-x:auto"><table>
|
||||
<thead><tr>${sortHeader('manifestacije','naziv','Naziv','')}${sortHeader('manifestacije','mjesto','Mjesto','')}${sortHeader('manifestacije','razina','Razina','')}${sortHeader('manifestacije','organizator','Organizator','')}${sortHeader('manifestacije','broj_ucesnika','Sudionici','')}<th>Link</th></tr></thead>
|
||||
<thead><tr>${sortHeader('manifestacije','naziv','Naziv','')}${sortHeader('manifestacije','sport','Sport','')}${sortHeader('manifestacije','godina_od','Datum','')}${sortHeader('manifestacije','mjesto','Mjesto','')}${sortHeader('manifestacije','organizator','Klub/Organizator','')}<th></th></tr></thead>
|
||||
<tbody>${rows.map(m => {
|
||||
const url = manifLinkFor(m);
|
||||
const open = _manifExpanded.has(m.id);
|
||||
return `
|
||||
<tr onclick="openManif(${m.id})">
|
||||
<td><b>${esc(m.naziv)}</b></td>
|
||||
<tr onclick="openManif(${m.id})" style="cursor:pointer">
|
||||
<td><b>${esc(m.naziv)}</b>${m.razina?' <span class="tag b">'+esc(m.razina)+'</span>':''}</td>
|
||||
<td>${m.sport?'<span class="tag b">'+esc(m.sport)+'</span>':'—'}</td>
|
||||
<td>${txt(m.godina_od)}</td>
|
||||
<td>${txt(m.mjesto)}</td>
|
||||
<td>${m.razina?'<span class="tag b">'+esc(m.razina)+'</span>':'—'}</td>
|
||||
<td>${txt(m.organizator)}</td>
|
||||
<td>${txt(m.broj_ucesnika)}</td>
|
||||
<td><a href="${esc(url)}" target="_blank" rel="noopener" onclick="event.stopPropagation()" title="${m.source_url?'Otvori izvor':'Pretraži online'}">🔗</a></td>
|
||||
</tr>`;
|
||||
<td style="text-align:right">${manifOtvoriBtn(m)}</td>
|
||||
</tr>
|
||||
${open?'<tr onclick="event.stopPropagation()"><td colspan="6" style="background:var(--bg3);padding:14px">'+manifDetailHTML(m)+'</td></tr>':''}`;
|
||||
}).join('')}</tbody>
|
||||
</table></div>`;
|
||||
}
|
||||
function toggleManif(id){
|
||||
if(_manifExpanded.has(id)) _manifExpanded.delete(id);
|
||||
else _manifExpanded.add(id);
|
||||
renderManifBody();
|
||||
}
|
||||
function openManif(id){
|
||||
const m = (_cache.manifestacije||[]).find(x => x.id===id);
|
||||
if(!m){ openPanel('Manifestacija', '<div class="empty">Nije pronađeno</div>'); return; }
|
||||
// If we have a source_url, open it directly in a new tab
|
||||
if(!m) return;
|
||||
if(m.source_url){
|
||||
window.open(m.source_url, '_blank', 'noopener');
|
||||
return;
|
||||
}
|
||||
// Otherwise show details + Google search fallback
|
||||
const gq = encodeURIComponent((m.naziv||'')+' '+(m.mjesto||'')+' sport');
|
||||
const googleUrl = 'https://www.google.com/search?q='+gq;
|
||||
const 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(m.naziv)}</div>
|
||||
<div style="font-size:12px;color:var(--t2);margin-top:4px">${txt(m.mjesto,'—')} · ${txt(m.razina,'')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-h"><div class="card-t">📋 Detalji</div></div>
|
||||
<div class="kv">
|
||||
<div class="k">Organizator</div><div class="v">${txt(m.organizator)}</div>
|
||||
<div class="k">Razina</div><div class="v">${m.razina?'<span class="tag b">'+esc(m.razina)+'</span>':'—'}</div>
|
||||
<div class="k">Sudionici</div><div class="v">${txt(m.broj_ucesnika)}</div>
|
||||
<div class="k">Spol/kategorija</div><div class="v">${txt(m.spol_kategorija)}</div>
|
||||
<div class="k">Godina od</div><div class="v">${txt(m.godina_od)}</div>
|
||||
<div class="k">Mjesto</div><div class="v">${txt(m.mjesto)}</div>
|
||||
</div>
|
||||
${m.napomena ? '<div style="margin-top:14px;font-size:12px;line-height:1.5;color:var(--t1);padding:10px;background:var(--bg3);border-radius:5px">'+esc(m.napomena)+'</div>' : ''}
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-h"><div class="card-t">🌐 Online izvori</div></div>
|
||||
<div class="empty" style="padding:14px">Nema poznatog izvornog URL-a. Pokušaj pronaći više informacija online:</div>
|
||||
<div style="text-align:center;margin-top:10px">
|
||||
<a href="${googleUrl}" target="_blank" class="btn primary" style="display:inline-block;text-decoration:none">🔍 Pretraži na Googleu</a>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
openPanel('Manifestacija · '+m.naziv, html);
|
||||
toggleManif(id);
|
||||
}
|
||||
|
||||
//=========== MREŽA (Network Graph) ===========
|
||||
const _mreza = {data:null, sim:null, allNodes:null, allEdges:null, filter:{osoba:'', klub:'', tvrtka:'', tip:''}};
|
||||
const _mreza = {data:null, sim:null, allNodes:null, allEdges:null, filter:{osoba:'', klub:'', tvrtka:'', tip:''}, toggle:{osoba:true, entitet:true, klub_savez:true}};
|
||||
|
||||
// Heuristic to decide whether an entity is actually a sports klub/savez.
|
||||
// Matches: NK/HNK/MOK/RK/KK/VK/HK/TK/JK/ŠK token, or the words "klub"/"savez" anywhere.
|
||||
function _mrezaIsKlubSavez(label){
|
||||
if(!label) return false;
|
||||
const s = String(label);
|
||||
if(/\b(NK|HNK|MOK|RK|KK|VK|HK|TK|JK|ŠK|FK|BK|GK)\b/.test(s)) return true;
|
||||
if(/\b(klub|savez)\b/i.test(s)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Map a node to one of three logical filter groups; returns null for nodes that
|
||||
// shouldn't participate in toggling (e.g. user-injected suggestions).
|
||||
function _mrezaCategory(n){
|
||||
if(!n) return null;
|
||||
if(n.type === 'person') return 'osoba';
|
||||
if(n.type === 'pgz_savez') return 'klub_savez';
|
||||
if(n.type === 'entity' || n.type === 'supplier'){
|
||||
return _mrezaIsKlubSavez(n.label) ? 'klub_savez' : 'entitet';
|
||||
}
|
||||
return null; // injected / unknown — never hidden by toggles
|
||||
}
|
||||
|
||||
async function loadMreza(){
|
||||
const root = $('#pg-mreza');
|
||||
@@ -2944,6 +3279,17 @@ async function loadMreza(){
|
||||
edges.push({source:anchorId, target:t.id, color:'#F4C43055', size:0.6});
|
||||
}
|
||||
}
|
||||
// Tag each node with a logical filter category (osoba / entitet / klub_savez).
|
||||
// Recolor sport klubovi/savezi so they visually align with the green Klub/Savez toggle,
|
||||
// and bump procurement suppliers (which were also green) to amber to avoid the colour clash.
|
||||
for(const n of nodes){
|
||||
n.category = _mrezaCategory(n);
|
||||
if(n.category === 'klub_savez' && n.type !== 'pgz_savez'){
|
||||
n.color = '#00e68a';
|
||||
} else if(n.type === 'supplier'){
|
||||
n.color = '#ffaa00';
|
||||
}
|
||||
}
|
||||
_mreza.data = {nodes, edges};
|
||||
_mreza.allNodes = nodes;
|
||||
_mreza.allEdges = edges;
|
||||
@@ -2982,8 +3328,8 @@ function renderMrezaShell(){
|
||||
<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 class="kpi g"><div class="kpi-l">Osoba</div><div class="kpi-v">${(_mreza.allNodes||[]).filter(n=>n.category==='osoba').length}</div></div>
|
||||
<div class="kpi r"><div class="kpi-l">Klubova / saveza</div><div class="kpi-v">${(_mreza.allNodes||[]).filter(n=>n.category==='klub_savez').length}</div></div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar" style="margin-bottom:10px;align-items:flex-start">
|
||||
@@ -3007,12 +3353,21 @@ function renderMrezaShell(){
|
||||
</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 · 3D force graph (drag rotate, scroll zoom)</div>
|
||||
<div class="card-h"><div class="card-t">🎨 Legenda & filteri tipova</div></div>
|
||||
<div id="mr-toggles" style="display:flex;gap:8px;flex-wrap:wrap;font-size:12px;align-items:center">
|
||||
<button type="button" class="mr-tg" data-cat="osoba" aria-pressed="true"
|
||||
style="display:inline-flex;align-items:center;gap:6px;padding:7px 12px;min-height:36px;border-radius:18px;border:1px solid #283560;background:rgba(139,92,246,0.12);color:var(--t1);font-size:12px;cursor:pointer;line-height:1">
|
||||
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#8b5cf6"></span>Osoba <span class="mr-tg-n" style="opacity:.7">${(_mreza.allNodes||[]).filter(n=>n.category==='osoba').length}</span>
|
||||
</button>
|
||||
<button type="button" class="mr-tg" data-cat="entitet" aria-pressed="true"
|
||||
style="display:inline-flex;align-items:center;gap:6px;padding:7px 12px;min-height:36px;border-radius:18px;border:1px solid #283560;background:rgba(255,68,102,0.12);color:var(--t1);font-size:12px;cursor:pointer;line-height:1">
|
||||
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#ff4466"></span>Entitet <span class="mr-tg-n" style="opacity:.7">${(_mreza.allNodes||[]).filter(n=>n.category==='entitet').length}</span>
|
||||
</button>
|
||||
<button type="button" class="mr-tg" data-cat="klub_savez" aria-pressed="true"
|
||||
style="display:inline-flex;align-items:center;gap:6px;padding:7px 12px;min-height:36px;border-radius:18px;border:1px solid #283560;background:rgba(0,230,138,0.14);color:var(--t1);font-size:12px;cursor:pointer;line-height:1">
|
||||
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#00e68a"></span>Klub/Savez <span class="mr-tg-n" style="opacity:.7">${(_mreza.allNodes||[]).filter(n=>n.category==='klub_savez').length}</span>
|
||||
</button>
|
||||
<div style="color:var(--t2);margin-left:6px">Klikni za uključi/isključi · Veličina = risk / promet · 3D force graph (drag rotate, scroll zoom)</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -3028,6 +3383,25 @@ function renderMrezaShell(){
|
||||
el.addEventListener('blur', () => setTimeout(() => closeSuggest(el), 200));
|
||||
});
|
||||
$('#mr-tip').addEventListener('change', applyMrezaFilter);
|
||||
// Wire 3 category toggle pills (Osoba / Entitet / Klub-Savez)
|
||||
document.querySelectorAll('#mr-toggles .mr-tg').forEach(btn => {
|
||||
// Sync visual state with current _mreza.toggle (preserve across re-render)
|
||||
const cat = btn.dataset.cat;
|
||||
if(_mreza.toggle && _mreza.toggle[cat] === false){
|
||||
btn.setAttribute('aria-pressed','false');
|
||||
btn.style.opacity = '0.4';
|
||||
btn.style.textDecoration = 'line-through';
|
||||
}
|
||||
btn.addEventListener('click', () => {
|
||||
const c = btn.dataset.cat;
|
||||
_mreza.toggle[c] = !_mreza.toggle[c];
|
||||
const on = _mreza.toggle[c];
|
||||
btn.setAttribute('aria-pressed', on ? 'true' : 'false');
|
||||
btn.style.opacity = on ? '1' : '0.4';
|
||||
btn.style.textDecoration = on ? 'none' : 'line-through';
|
||||
applyMrezaFilter();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchSuggest(inputEl){
|
||||
@@ -3111,6 +3485,13 @@ function applyMrezaFilter(){
|
||||
const tip = $('#mr-tip').value;
|
||||
|
||||
let nodes = (_mreza.allNodes||[]).slice();
|
||||
// Category toggles: hide nodes whose category is switched off (null category = always shown).
|
||||
const tg = _mreza.toggle || {osoba:true, entitet:true, klub_savez:true};
|
||||
nodes = nodes.filter(n => {
|
||||
const c = n.category;
|
||||
if(!c) return true;
|
||||
return tg[c] !== false;
|
||||
});
|
||||
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)
|
||||
@@ -3897,7 +4278,8 @@ window._wrapOpener = function(name){
|
||||
};
|
||||
|
||||
// Wrap all known root openers (idempotent)
|
||||
['openSavez','openKlub','openSportas','openObjekt','openManif',
|
||||
// openManif intentionally excluded: uses inline expand, not the side panel
|
||||
['openSavez','openKlub','openSportas','openObjekt',
|
||||
'openPrimateljDetail','openProracunDrill','openSavezByName',
|
||||
'openMrezaNode','openForensicDetail','openOIB']
|
||||
.forEach(function(n){ try { window._wrapOpener(n); } catch(e) {} });
|
||||
@@ -3913,7 +4295,145 @@ window.closePanel = function(){
|
||||
const ov = document.getElementById('panel-overlay');
|
||||
if(ov){ ov.classList.remove('open'); ov.style.removeProperty('display'); }
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// SPORT-S4: Ručni unos financije (admin only)
|
||||
// ═══════════════════════════════════════════════════════
|
||||
window._klubsCache = null;
|
||||
async function loadKlubsForPicker(){
|
||||
if(window._klubsCache) return window._klubsCache;
|
||||
try {
|
||||
const r = await api('/v2/klubovi?limit=2000');
|
||||
const list = r && (r.results || r.rows || []) || [];
|
||||
window._klubsCache = list.map(k => ({id:k.id, naziv:k.naziv, sport:k.sport||''}))
|
||||
.sort((a,b)=>(a.naziv||'').localeCompare(b.naziv||''));
|
||||
} catch(e){ console.warn('klubovi load fail', e); window._klubsCache = []; }
|
||||
return window._klubsCache;
|
||||
}
|
||||
|
||||
async function openManualFinancijeForm(){
|
||||
const klubs = await loadKlubsForPicker();
|
||||
if(!klubs.length){ alert('Nije moguće učitati popis klubova.'); return; }
|
||||
const yearNow = new Date().getFullYear();
|
||||
const yearsOpts = [];
|
||||
for(let y=yearNow+1; y>=yearNow-10; y--) yearsOpts.push(y);
|
||||
|
||||
// Modal overlay
|
||||
const old = document.getElementById('fi-manual-modal');
|
||||
if(old) old.remove();
|
||||
const m = document.createElement('div');
|
||||
m.id = 'fi-manual-modal';
|
||||
m.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:9999;display:flex;align-items:center;justify-content:center;padding:14px;font-family:inherit';
|
||||
m.innerHTML = `
|
||||
<div style="background:var(--bg,#101018);border:1px solid var(--border,#333);border-radius:10px;padding:22px;max-width:520px;width:100%;max-height:90vh;overflow:auto;color:var(--text,#e8e8f0)">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px">
|
||||
<h3 style="margin:0;font-size:17px">+ Ručni unos financija (klubu)</h3>
|
||||
<button onclick="document.getElementById('fi-manual-modal').remove()" style="background:none;border:none;color:#aaa;font-size:24px;cursor:pointer;line-height:1">×</button>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:10px;font-size:13px">
|
||||
<label>Godina
|
||||
<select id="fm-god" style="width:100%;padding:7px;background:var(--surface,#1a1a25);border:1px solid var(--border,#333);color:inherit;border-radius:5px">
|
||||
${yearsOpts.map(y => '<option value="'+y+'"'+(y===yearNow?' selected':'')+'>'+y+'</option>').join('')}
|
||||
</select>
|
||||
</label>
|
||||
<label>Klub (${klubs.length} u registru)
|
||||
<input id="fm-klub-q" type="text" placeholder="Pretraži klub po nazivu…" autocomplete="off" style="width:100%;padding:7px;background:var(--surface,#1a1a25);border:1px solid var(--border,#333);color:inherit;border-radius:5px">
|
||||
<select id="fm-klub" size="6" style="width:100%;margin-top:4px;padding:4px;background:var(--surface,#1a1a25);border:1px solid var(--border,#333);color:inherit;border-radius:5px;font-size:12px">
|
||||
${klubs.slice(0,200).map(k => '<option value="'+k.id+'">'+esc(k.naziv)+(k.sport?' · '+esc(k.sport):'')+'</option>').join('')}
|
||||
</select>
|
||||
</label>
|
||||
<label>Iznos (€)
|
||||
<input id="fm-iznos" type="number" step="0.01" min="0.01" placeholder="npr. 12500.00" style="width:100%;padding:7px;background:var(--surface,#1a1a25);border:1px solid var(--border,#333);color:inherit;border-radius:5px">
|
||||
</label>
|
||||
<label>Razina
|
||||
<select id="fm-razina" style="width:100%;padding:7px;background:var(--surface,#1a1a25);border:1px solid var(--border,#333);color:inherit;border-radius:5px">
|
||||
<option value="ručni_unos" selected>ručni_unos</option>
|
||||
<option value="županija">županija (PGŽ)</option>
|
||||
<option value="grad_rijeka">grad_rijeka</option>
|
||||
<option value="opcina">opcina</option>
|
||||
<option value="ministarstvo">ministarstvo</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Vrsta (kratki tag)
|
||||
<input id="fm-vrsta" type="text" value="ručni_unos" style="width:100%;padding:7px;background:var(--surface,#1a1a25);border:1px solid var(--border,#333);color:inherit;border-radius:5px">
|
||||
</label>
|
||||
<label>Opis / Napomena
|
||||
<textarea id="fm-opis" rows="3" placeholder="npr. ugovor 2025/RSS-117 — Treninzi i natjecanja" style="width:100%;padding:7px;background:var(--surface,#1a1a25);border:1px solid var(--border,#333);color:inherit;border-radius:5px;font-family:inherit"></textarea>
|
||||
</label>
|
||||
<label>Source URL (PDF link, opcionalno)
|
||||
<input id="fm-url" type="url" placeholder="https://…" style="width:100%;padding:7px;background:var(--surface,#1a1a25);border:1px solid var(--border,#333);color:inherit;border-radius:5px">
|
||||
</label>
|
||||
<div id="fm-msg" style="font-size:12px;margin-top:4px;min-height:18px"></div>
|
||||
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:6px">
|
||||
<button onclick="document.getElementById('fi-manual-modal').remove()" style="padding:8px 14px;background:none;border:1px solid var(--border,#333);color:inherit;border-radius:5px;cursor:pointer">Odustani</button>
|
||||
<button id="fm-save-btn" onclick="saveManualFinancije()" style="padding:8px 14px;background:var(--pgz-gold,#c5a040);border:none;color:#000;border-radius:5px;cursor:pointer;font-weight:600">💾 Spremi</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(m);
|
||||
|
||||
// Live klub filter
|
||||
const qIn = document.getElementById('fm-klub-q');
|
||||
const sel = document.getElementById('fm-klub');
|
||||
qIn.addEventListener('input', () => {
|
||||
const q = qIn.value.toLowerCase().trim();
|
||||
const filtered = q
|
||||
? klubs.filter(k => (k.naziv||'').toLowerCase().includes(q) || (k.sport||'').toLowerCase().includes(q))
|
||||
: klubs;
|
||||
sel.innerHTML = filtered.slice(0, 200).map(k =>
|
||||
'<option value="'+k.id+'">'+esc(k.naziv)+(k.sport?' · '+esc(k.sport):'')+'</option>'
|
||||
).join('');
|
||||
if(filtered.length) sel.value = filtered[0].id;
|
||||
});
|
||||
}
|
||||
|
||||
async function saveManualFinancije(){
|
||||
const msg = document.getElementById('fm-msg');
|
||||
const btn = document.getElementById('fm-save-btn');
|
||||
const payload = {
|
||||
godina: parseInt(document.getElementById('fm-god').value),
|
||||
klub_id: parseInt(document.getElementById('fm-klub').value || 0),
|
||||
iznos_eur: parseFloat(document.getElementById('fm-iznos').value || 0),
|
||||
razina: document.getElementById('fm-razina').value,
|
||||
vrsta: (document.getElementById('fm-vrsta').value || 'ručni_unos').trim() || 'ručni_unos',
|
||||
opis: (document.getElementById('fm-opis').value || '').trim(),
|
||||
napomena: (document.getElementById('fm-opis').value || '').trim(),
|
||||
source_url:(document.getElementById('fm-url').value || '').trim() || null,
|
||||
};
|
||||
if(!payload.klub_id){ msg.textContent = '❌ Odaberi klub.'; msg.style.color = '#ef4444'; return; }
|
||||
if(!payload.iznos_eur || payload.iznos_eur <= 0){ msg.textContent = '❌ Iznos mora biti > 0.'; msg.style.color = '#ef4444'; return; }
|
||||
|
||||
btn.disabled = true; btn.textContent = 'Spremam…';
|
||||
msg.style.color = ''; msg.textContent = 'Spremam…';
|
||||
try {
|
||||
const r = await fetch('/sport/api/v2/financije/manual-entry', {
|
||||
method:'POST',
|
||||
headers:{
|
||||
'Content-Type':'application/json',
|
||||
...(window._AUTH_TOKEN ? {'Authorization':'Bearer '+window._AUTH_TOKEN} : {}),
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if(!r.ok){
|
||||
const t = await r.text();
|
||||
throw new Error('HTTP '+r.status+': '+t.slice(0,200));
|
||||
}
|
||||
const d = await r.json();
|
||||
msg.style.color = '#22c55e';
|
||||
msg.textContent = '✅ Spremljeno (id='+d.id+'). Tablica se osvježava…';
|
||||
setTimeout(() => {
|
||||
const modal = document.getElementById('fi-manual-modal');
|
||||
if(modal) modal.remove();
|
||||
if(typeof refreshFinancije === 'function') refreshFinancije();
|
||||
}, 900);
|
||||
} catch(e){
|
||||
msg.style.color = '#ef4444';
|
||||
msg.textContent = '❌ '+(e.message||e);
|
||||
btn.disabled = false; btn.textContent = '💾 Spremi';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script src="/static/js/export_dropdown.js"></script>
|
||||
<script src="/static/_ai_widget.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -186,6 +186,7 @@ table tbody tr.no-click:hover{background:transparent}
|
||||
}
|
||||
</style>
|
||||
<script src="/static/oib_format.js" defer></script>
|
||||
<script src="/static/shared/sortable.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
<!doctype html>
|
||||
<html lang="hr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Sportaš profil — PGŽ Sport</title>
|
||||
<style>
|
||||
:root{--bg:#0a0a0f;--card:#0c0c14;--border:#1a1a24;--text:#e8e8f0;--muted:#8888a0;--subtle:#6a6a80;--accent:#6366f1;--green:#10b981}
|
||||
*{box-sizing:border-box}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,"Inter","Segoe UI",sans-serif;background:var(--bg);color:var(--text);margin:0;line-height:1.55}
|
||||
.topbar{border-bottom:1px solid var(--border);padding:12px 20px;background:rgba(10,10,15,.85);position:sticky;top:0;z-index:50;display:flex;justify-content:space-between;align-items:center}
|
||||
.brand{font-weight:700;font-size:15px;display:flex;align-items:center;gap:9px;color:inherit;text-decoration:none}
|
||||
.brand-icon{width:30px;height:30px;background:linear-gradient(135deg,#6366f1,#4f46e5);border-radius:7px;display:flex;align-items:center;justify-content:center;font-weight:700;color:#fff;font-size:14px}
|
||||
main{max-width:1080px;margin:0 auto;padding:24px 20px}
|
||||
.hero{display:grid;grid-template-columns:140px 1fr;gap:24px;margin-bottom:24px;align-items:start}
|
||||
@media (max-width:700px){.hero{grid-template-columns:96px 1fr}}
|
||||
.photo{width:140px;height:140px;background:linear-gradient(135deg,#1a1a26,#0c0c14);border:1px solid var(--border);border-radius:12px;display:flex;align-items:center;justify-content:center;color:var(--muted);font-size:48px;font-weight:700}
|
||||
@media (max-width:700px){.photo{width:96px;height:96px;font-size:34px}}
|
||||
.head h1{margin:0 0 8px;font-size:30px;letter-spacing:-.02em}
|
||||
.head .sub{color:var(--muted);font-size:14px;margin:0 0 12px}
|
||||
.pills{display:flex;flex-wrap:wrap;gap:6px;margin-top:10px}
|
||||
.pill{background:#1a1a26;color:var(--text);padding:4px 12px;border-radius:99px;font-size:11.5px;letter-spacing:.02em}
|
||||
.pill.fed{background:rgba(99,102,241,.15);color:#a5b4fc;border:1px solid rgba(99,102,241,.35)}
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:14px;margin-bottom:24px}
|
||||
.stat{background:var(--card);border:1px solid var(--border);border-radius:11px;padding:14px 16px}
|
||||
.stat .label{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin-bottom:4px}
|
||||
.stat .val{font-size:22px;font-weight:700;letter-spacing:-.02em;line-height:1.1}
|
||||
.section{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:18px;margin-bottom:18px}
|
||||
.section h2{margin:0 0 14px;font-size:15px;font-weight:600;letter-spacing:.02em;color:var(--text)}
|
||||
.section table{width:100%;border-collapse:collapse;font-size:13px}
|
||||
.section th,.section td{text-align:left;padding:7px 8px;border-bottom:1px solid var(--border)}
|
||||
.section th{color:var(--muted);font-weight:600;font-size:11px;text-transform:uppercase;letter-spacing:.06em}
|
||||
.section td.num{text-align:right;font-variant-numeric:tabular-nums}
|
||||
.empty{color:var(--subtle);font-size:13px;padding:8px 0;font-style:italic}
|
||||
.row-flex{display:flex;flex-wrap:wrap;gap:8px 24px;font-size:13px;color:var(--muted)}
|
||||
.row-flex b{color:var(--text);font-weight:600}
|
||||
a{color:var(--accent);text-decoration:none}
|
||||
a:hover{text-decoration:underline}
|
||||
.err{background:rgba(239,68,68,.12);border:1px solid rgba(239,68,68,.35);color:#fca5a5;padding:14px 16px;border-radius:9px}
|
||||
.banner{background:rgba(245,158,11,.08);border:1px solid rgba(245,158,11,.3);border-radius:9px;padding:10px 14px;font-size:12px;color:#fcd34d;margin-bottom:18px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<a class="brand" href="/sport/dashboard"><span class="brand-icon">S</span> PGŽ Sport · Sportaš</a>
|
||||
<div><a href="javascript:history.back()" style="font-size:13px;color:var(--muted)">← natrag</a></div>
|
||||
</header>
|
||||
<main id="root">
|
||||
<p style="color:var(--muted)">Učitavam...</p>
|
||||
</main>
|
||||
<script>
|
||||
const $ = id => document.getElementById(id);
|
||||
const fmt = n => (n == null ? '—' : Number(n).toLocaleString('hr-HR'));
|
||||
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
||||
|
||||
function sid() {
|
||||
const m = location.pathname.match(/\/sport\/sportas\/(\d+)/);
|
||||
return m ? parseInt(m[1]) : null;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const id = sid();
|
||||
if (!id) {
|
||||
document.getElementById('root').innerHTML = '<div class="err">Nevažeći URL — očekivano /sport/sportas/{id}.</div>';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const r = await fetch('/api/v2/sport/sportas/' + id);
|
||||
if (!r.ok) {
|
||||
document.getElementById('root').innerHTML = '<div class="err">HTTP ' + r.status + ': sportas ' + id + ' nije pronađen.</div>';
|
||||
return;
|
||||
}
|
||||
render(await r.json());
|
||||
} catch (e) {
|
||||
document.getElementById('root').innerHTML = '<div class="err">Greška: ' + esc(e.message) + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function initials(ime, prez) {
|
||||
return ((ime||'?')[0] + (prez||'?')[0]).toUpperCase();
|
||||
}
|
||||
|
||||
function render(d) {
|
||||
const s = d.sportas || {};
|
||||
const k = d.klub || {};
|
||||
const f = d.federation || {};
|
||||
const stats = d.stats || {};
|
||||
const sezone = d.sezone || [];
|
||||
const nagrade = d.nagrade || [];
|
||||
const utakmice = d.utakmice || [];
|
||||
const socials = d.socials || {};
|
||||
|
||||
const pills = [];
|
||||
if (s.pozicija) pills.push('<span class="pill">' + esc(s.pozicija) + '</span>');
|
||||
if (s.rod_godina) pills.push('<span class="pill">' + s.rod_godina + '</span>');
|
||||
if (s.visina_cm) pills.push('<span class="pill">' + s.visina_cm + ' cm</span>');
|
||||
if (s.tezina_kg) pills.push('<span class="pill">' + s.tezina_kg + ' kg</span>');
|
||||
if (s.ekipa) pills.push('<span class="pill">' + esc(s.ekipa) + '</span>');
|
||||
if (f.code) pills.push('<span class="pill fed">' + esc(f.code) + '</span>');
|
||||
|
||||
const socHtml = Object.entries(socials).map(([k,v]) =>
|
||||
`<a href="${esc(v)}" target="_blank" rel="noopener">${esc(k)}</a>`
|
||||
).join(' · ') || '';
|
||||
|
||||
const sezTable = sezone.length ? `
|
||||
<table>
|
||||
<thead><tr><th>Sezona</th><th>Klub</th><th>Natjecanje</th>
|
||||
<th class="num">Nastupi</th><th class="num">Pogoci</th><th class="num">Asistencije</th></tr></thead>
|
||||
<tbody>${sezone.map(r => `<tr>
|
||||
<td>${esc(r.sezona)}</td><td>${esc(r.klub_naziv||'—')}</td><td>${esc(r.natjecanje||'—')}</td>
|
||||
<td class="num">${fmt(r.nastupi)}</td><td class="num">${fmt(r.pogoci)}</td><td class="num">${fmt(r.asistencije)}</td>
|
||||
</tr>`).join('')}</tbody>
|
||||
</table>` : '<div class="empty">Nema podataka o sezonama (sportašov ID nije povezan s legacy clan_* tablicom).</div>';
|
||||
|
||||
const nagTable = nagrade.length ? `
|
||||
<table>
|
||||
<thead><tr><th>Godina</th><th>Sezona</th><th>Natjecanje</th><th>Razina</th><th class="num">Plasman</th></tr></thead>
|
||||
<tbody>${nagrade.map(r => `<tr>
|
||||
<td>${esc(r.godina)}</td><td>${esc(r.sezona||'—')}</td><td>${esc(r.natjecanje||'—')}</td>
|
||||
<td>${esc(r.razina_natjecanja||'—')}</td><td class="num">${fmt(r.plasman)}</td>
|
||||
</tr>`).join('')}</tbody>
|
||||
</table>` : '<div class="empty">Nema upisanih postignuća.</div>';
|
||||
|
||||
const utTable = utakmice.length ? `
|
||||
<table>
|
||||
<thead><tr><th>Datum</th><th>Domaćin</th><th>Gost</th><th>Rezultat</th><th>Natjecanje</th>
|
||||
<th class="num">Pogoci</th><th class="num">🟨</th><th class="num">🟥</th></tr></thead>
|
||||
<tbody>${utakmice.map(r => `<tr>
|
||||
<td>${r.datum ? new Date(r.datum).toLocaleDateString('hr-HR') : '—'}</td>
|
||||
<td>${esc(r.domacin||'—')}</td><td>${esc(r.gost||'—')}</td>
|
||||
<td>${esc(r.rezultat||'—')}</td><td>${esc(r.natjecanje||'—')}</td>
|
||||
<td class="num">${fmt(r.pogoci)}</td>
|
||||
<td class="num">${fmt(r.zuti)}</td><td class="num">${fmt(r.crveni)}</td>
|
||||
</tr>`).join('')}</tbody>
|
||||
</table>` : '<div class="empty">Nema upisanih utakmica.</div>';
|
||||
|
||||
let banner = '';
|
||||
if (!d.linked_legacy_clan_id) {
|
||||
banner = '<div class="banner">⚠ Ovaj sportaš (iz nove <code>pgz_sport.sportasi</code> tablice) <b>nije povezan</b> s legacy <code>pgz_sport.clanovi</code> zapisom — povijest sezona/utakmica/nagrada nije dostupna dok ne uspostavimo cross-link po OIB-u ili imenu.</div>';
|
||||
}
|
||||
|
||||
document.title = `${s.ime||''} ${s.prezime||''} — Sportaš profil`;
|
||||
document.getElementById('root').innerHTML = `
|
||||
${banner}
|
||||
<div class="hero">
|
||||
<div class="photo">${initials(s.ime, s.prezime)}</div>
|
||||
<div class="head">
|
||||
<h1>${esc(s.ime||'?')} ${esc(s.prezime||'?')}</h1>
|
||||
<div class="sub">
|
||||
${s.sport ? esc(s.sport) : ''} ${k.naziv ? '· <a href="/klubovi/'+(k.id||'')+'">'+esc(k.naziv)+'</a>' : ''}
|
||||
${k.grad ? '· '+esc(k.grad) : ''}
|
||||
</div>
|
||||
<div class="pills">${pills.join('')}</div>
|
||||
${s.oib_redacted ? '<div class="row-flex" style="margin-top:12px"><span>OIB (delkated): <b>'+esc(s.oib_redacted)+'</b></span></div>' : ''}
|
||||
${socHtml ? '<div class="row-flex" style="margin-top:8px"><span>Social: '+socHtml+'</span></div>' : ''}
|
||||
${f.web ? '<div class="row-flex" style="margin-top:8px"><span>Federacija: <a href="'+esc(f.web)+'" target="_blank" rel="noopener">'+esc(f.name||f.code)+'</a></span></div>' : ''}
|
||||
${s.federation_url ? '<div class="row-flex" style="margin-top:4px"><span><a href="'+esc(s.federation_url)+'" target="_blank" rel="noopener">Profil na ' + esc(f.code || 'savezu') + ' →</a></span></div>' : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<div class="stat"><div class="label">Sezone</div><div class="val">${fmt(stats.sezone_count) || 0}</div></div>
|
||||
<div class="stat"><div class="label">Ukupno nastupa</div><div class="val">${fmt(stats.nastupi_total) || 0}</div></div>
|
||||
<div class="stat"><div class="label">Ukupno pogodaka</div><div class="val">${fmt(stats.pogoci_total) || 0}</div></div>
|
||||
<div class="stat"><div class="label">Asistencija</div><div class="val">${fmt(stats.asistencije_total) || 0}</div></div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Klubska povijest po sezonama</h2>
|
||||
${sezTable}
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Postignuća</h2>
|
||||
${nagTable}
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Aktualne utakmice (zadnjih 20)</h2>
|
||||
${utTable}
|
||||
</div>
|
||||
|
||||
<div class="section" style="opacity:.7">
|
||||
<h2>Tehnički detalji</h2>
|
||||
<div class="row-flex">
|
||||
<span>ID: <b>${s.id}</b></span>
|
||||
<span>Izvor: <b>${esc(s.source||'—')}</b></span>
|
||||
<span>Sezona scraper: <b>${esc(s.sezona||'—')}</b></span>
|
||||
<span>Federation ID: <b>${esc(s.federation_player_id||'—')}</b></span>
|
||||
<span>Scraped: <b>${s.scraped_at ? new Date(s.scraped_at).toLocaleString('hr-HR') : '—'}</b></span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
window.addEventListener('load', load);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user