PDF link target=_blank + nginx timeouts + priority filteri (samo s podacima)

nginx (sport.rinet.one):
- proxy_read_timeout 60s → 300s
- proxy_send_timeout 300s
- proxy_buffering off (PDF stream)
- client_max_body_size 50M → 100M

Endpoints:
- /api/v2/klubovi/financirani: +with_data filter (samo s potporama/godišnjakom/HNS)
- /api/v2/sportasi/filtered: +samo_priority +samo_s_hns

Frontend:
- PDF link target=_blank rel=noopener
- window._klub_only_priority = true (default)
- window._sportas_only_priority = true (default)

DB View:
- pgz_sport.v_nogomet_priority (prima_potpore, u_godisnjaku, ima_hns_roster)
This commit is contained in:
2026-05-05 13:51:07 +02:00
parent c6a5ec62aa
commit f7b5114f58
289 changed files with 37204 additions and 363 deletions
+238 -49
View File
@@ -512,8 +512,10 @@ const NAV_BY_ROLE = {
{id:'erp', ic:'\u{1F4BC}', label:'ERP', href:'/erp/full'},
{id:'crm', ic:'\u{1F4DD}', label:'CRM', href:'/crm/v2'},
{id:'dokumenti', ic:'\u{1F4D6}', label:'Dokumenti'},
{id:'racuni', ic:'\u{1F9FE}', label:'Računi (OCR)'},
{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'},
{id:'kalendar', ic:'\u{1F4C5}', label:'Kalendar'},
{id:'notif', ic:'\u{1F514}', label:'Notifikacije'},
{id:'audit', ic:'\u{1F50D}', label:'Audit log'},
{id:'forenzika', ic:'⚠', label:'Forenzika', badge:11},
],
@@ -524,8 +526,10 @@ const NAV_BY_ROLE = {
{id:'sportasi', ic:'\u{1F464}', label:'Naši sportaši'},
{id:'zahtjevi', ic:'\u{1F4D1}', label:'Zahtjevi PGŽ', badge:3},
{id:'kalendar', ic:'\u{1F4C5}', label:'Kalendar'},
{id:'notif', ic:'\u{1F514}', label:'Notifikacije'},
{id:'lijecnicki',ic:'⚕', label:'Liječnički'},
{id:'racuni', ic:'\u{1F9FE}', label:'Računi (OCR)'},
{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'},
],
klub: [
{id:'profil', ic:'\u{1F464}', label:'Moj profil'},
@@ -535,8 +539,10 @@ const NAV_BY_ROLE = {
{id:'lijecnicki',ic:'⚕', label:'Liječnički'},
{id:'dokumenti', ic:'\u{1F4C4}', label:'Dokumenti'},
{id:'kalendar', ic:'\u{1F4C5}', label:'Kalendar'},
{id:'notif', ic:'\u{1F514}', label:'Notifikacije'},
{id:'manifestacije', ic:'\u{1F4C5}', label:'Manifestacije'},
{id:'racuni', ic:'\u{1F9FE}', label:'Računi (OCR)'},
{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'},
],
sportas: [
{id:'profil', ic:'\u{1F464}', label:'Moj profil'},
@@ -546,6 +552,7 @@ const NAV_BY_ROLE = {
{id:'dokumenti', ic:'\u{1F4C4}', label:'Moji dokumenti'},
{id:'obrasci', ic:'\u{1F4DD}', label:'Obrasci', badge:1},
{id:'kalendar', ic:'\u{1F4C5}', label:'Kalendar'},
{id:'notif', ic:'\u{1F514}', label:'Notifikacije'},
{id:'manifestacije', ic:'\u{1F4C5}', label:'Manifestacije'},
],
};
@@ -754,13 +761,16 @@ function setRole(r){
//=========== NAV ===========
function buildNav(){
const items = NAV_BY_ROLE[_state.role] || [];
$('#nav').innerHTML = items.map(n =>
`<div class="nav-i ${n.id===_state.section?'active':''}" data-id="${n.id}" data-label="${esc(n.label)}" onclick="navTo('${n.id}')">
$('#nav').innerHTML = items.map((n, idx) => {
const click = n.href
? `onclick="window.location.href='${n.href}'"`
: `onclick="navTo('${n.id}')"`;
return `<div class="nav-i ${n.id===_state.section?'active':''}" data-id="${n.id}" data-label="${esc(n.label)}" ${click}>
<span class="ic">${n.ic}</span>
<span class="lbl">${esc(n.label)}</span>
${n.badge?`<span class="badge">${n.badge}</span>`:''}
</div>`
).join('');
</div>`;
}).join('');
}
window.addEventListener('hashchange', () => {
const h = (location.hash||'').replace(/^#/,'');
@@ -802,9 +812,11 @@ const TITLES = {
klubovi:['Klubovi','Sportski klubovi PGŽ'],
sportasi:['Sportaši','Registrirani članovi'],
financije:['Financije','Sufinanciranje sporta'],
racuni:['Računi (OCR)','OCR upload + obrada'],
racuni:['Računi (OCR)','OCR upload + obrada (ERP Full)'],
putni:['Putni nalozi','Otvara ERP Full → Putni nalozi'],
crm:['CRM','Članarine + liječnički'],
kalendar:['Kalendar','Liječnički termini, manifestacije, eventi'],
notif:['Notifikacije','Centar obavijesti — InApp poruke'],
audit:['Audit log','Sve aktivnosti sustava'],
forenzika:['Forenzika','Sumnjive transakcije / PEP'],
},
@@ -815,8 +827,10 @@ const TITLES = {
sportasi:['Naši sportaši','Registrirani sportaši saveza'],
zahtjevi:['Zahtjevi PGŽ','Sufinanciranje aktivnosti'],
kalendar:['Kalendar','Manifestacije saveza'],
notif:['Notifikacije','Centar obavijesti — InApp poruke'],
lijecnicki:['Liječnički','Pregledi članova saveza'],
racuni:['Računi','Računi saveza'],
racuni:['Računi','Računi saveza (ERP Full)'],
putni:['Putni nalozi','ERP Full → Putni nalozi'],
},
klub: {
profil:['Moj profil','Osobni podaci'],
@@ -826,8 +840,10 @@ const TITLES = {
lijecnicki:['Liječnički','Pregledi članova'],
dokumenti:['Dokumenti','Dokumenti kluba'],
kalendar:['Kalendar','Liječnički termini + manifestacije'],
notif:['Notifikacije','Centar obavijesti — InApp poruke'],
manifestacije:['Manifestacije','Nadolazeće aktivnosti'],
racuni:['Računi','Troškovi kluba'],
racuni:['Računi','Troškovi kluba (ERP Full)'],
putni:['Putni nalozi','ERP Full → Putni nalozi'],
},
sportas: {
profil:['Moj profil','Osobni podaci'],
@@ -837,6 +853,7 @@ const TITLES = {
dokumenti:['Moji dokumenti','Suglasnosti, ugovori'],
obrasci:['Obrasci','Za potpis'],
kalendar:['Kalendar','Moji termini i događaji'],
notif:['Notifikacije','Centar obavijesti — InApp poruke'],
manifestacije:['Manifestacije','Moje aktivnosti'],
},
};
@@ -1164,7 +1181,7 @@ SECTIONS['pgz:dashboard'] = async () => {
<div style="display:grid;gap:8px">
<button class="btn primary" onclick="setRole('pgz');navTo('korisnici')">+ Dodaj korisnika</button>
<button class="btn" onclick="navTo('forenzika')">⚠ Pregled forenzike</button>
<button class="btn" onclick="navTo('racuni')">🧾 Skeniraj račun (OCR)</button>
<button class="btn" onclick="window.location.href='/erp/full?tab=uploads'">🧾 Skeniraj račun (OCR)</button>
<button class="btn" onclick="navTo('audit')">🔍 Audit log</button>
<button class="btn gold" onclick="window.open('/sport/','_blank')">🌐 Public portal</button>
</div>
@@ -1309,22 +1326,22 @@ SECTIONS['pgz:financije'] = async () => {
</div>`;
};
SECTIONS['pgz:racuni'] = () => `
<div class="card">
<div class="card-h"><div class="card-t">🧾 OCR upload (drag & drop)</div></div>
<div style="border:2px dashed var(--rim2);border-radius:8px;padding:40px;text-align:center;background:var(--bg3);cursor:pointer" onclick="alert('OCR upload — backend M5 (CC4)')">
<div style="font-size:48px;margin-bottom:8px">📷</div>
<div style="font-weight:700;color:var(--t0);margin-bottom:4px">Dovuci ovdje sliku ili PDF računa</div>
<div style="font-size:11px;color:var(--t2)">ili klikni za odabir · cestarina, gorivo, hotel, dnevnice...</div>
<button class="btn primary" style="margin-top:12px">📸 Snimi kamerom</button>
</div>
<div style="font-size:11px;color:var(--t4);margin-top:10px">Backend: Tesseract OCR + Ri.NET AI Engine ekstrakcija polja → invoices DB</div>
</div>
<div class="card">
<div class="card-h"><div class="card-t">📋 Nedavni računi</div></div>
<table><thead><tr><th>Datum</th><th>Izdavatelj</th><th>OIB</th><th>Vrsta</th><th class="num">Iznos</th><th>Status</th></tr></thead>
<tbody>${MOCK.invoices.map(r => `<tr><td>${esc(r.datum)}</td><td><b>${esc(r.izdavatelj)}</b></td><td>${esc(formatOib(r.oib))}</td><td><span class="tag ${r.tag}">${esc(r.vrsta)}</span></td><td class="num">${fmtEur(r.iznos)}</td><td>${esc(r.status)}</td></tr>`).join('')}</tbody></table>
// Računi (OCR) je premješten u ERP Full → tab Uploads (consolidation 2026-05-05).
// Legacy entry preusmjerava korisnika.
SECTIONS['pgz:racuni'] = () => {
setTimeout(() => { window.location.href = '/erp/full?tab=uploads'; }, 50);
return `<div class="card"><div class="card-h"><div class="card-t">🧾 Računi (OCR) — premješteno u ERP Full</div></div>
<p style="color:var(--t2);margin:8px 0">Otvaranje <b>ERP Full → Uploads (OCR)</b>…</p>
<a class="btn primary" href="/erp/full?tab=uploads" style="text-decoration:none">📎 Otvori sada</a>
</div>`;
};
SECTIONS['pgz:putni'] = () => {
setTimeout(() => { window.location.href = '/erp/full?tab=putni'; }, 50);
return `<div class="card"><div class="card-h"><div class="card-t">✈ Putni nalozi — premješteno u ERP Full</div></div>
<p style="color:var(--t2);margin:8px 0">Otvaranje <b>ERP Full → Putni nalozi</b>…</p>
<a class="btn primary" href="/erp/full?tab=putni" style="text-decoration:none">✈ Otvori sada</a>
</div>`;
};
SECTIONS['pgz:crm'] = () => `
<div style="margin-bottom:12px">
@@ -1367,24 +1384,192 @@ SECTIONS['pgz:audit'] = () => `
// =======================================================================
// CC5 R5 — KALENDAR (liječnički termini + manifestacije + eventi)
// Agent B 2026-05-05: Added CRUD on pgz_sport.kalendar_events
// =======================================================================
// ─── KALENDAR CRUD helpers ─────────────────────────────────────────────
window._kalState = window._kalState || { events: [], ym: null };
async function kalLoadEvents(ym){
const [Y, M] = ym.split('-').map(Number);
const from = `${Y}-${String(M).padStart(2,'0')}-01`;
const nm = M===12 ? {y:Y+1,m:1} : {y:Y,m:M+1};
const to = `${nm.y}-${String(nm.m).padStart(2,'0')}-01`;
try {
const d = await apiAuth(`/v2/kalendar/events?from=${from}&to=${to}`);
return (d && d.rows) || [];
} catch(e){ return []; }
}
function _kalFmtLocal(iso){
if(!iso) return '';
const d = new Date(iso);
if(isNaN(d.getTime())) return '';
const pad = n => String(n).padStart(2,'0');
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function kalEventModalHtml(ev){
ev = ev || {};
const isEdit = !!ev.id;
return `
<div id="kalModalBg" style="position:fixed;inset:0;background:rgba(0,0,0,0.6);z-index:9999;display:flex;align-items:center;justify-content:center" onclick="if(event.target===this) kalCloseModal()">
<div style="background:var(--bg2);border:1px solid var(--rim);border-radius:8px;width:min(560px,92vw);max-height:90vh;overflow:auto">
<div style="padding:14px 16px;border-bottom:1px solid var(--rim);display:flex;justify-content:space-between;align-items:center">
<div style="font-weight:600;font-size:15px">${isEdit?'Uredi termin #'+ev.id:'Novi termin'}</div>
<button class="btn sm" onclick="kalCloseModal()">✕</button>
</div>
<div style="padding:14px 16px;display:flex;flex-direction:column;gap:10px">
<label style="display:flex;flex-direction:column;gap:4px;font-size:11px;color:var(--t3);text-transform:uppercase;font-weight:600">
Naziv *
<input id="kalF_title" value="${esc(ev.title||'')}" style="background:var(--bg1);border:1px solid var(--rim);color:var(--t1);padding:8px;border-radius:4px;font-size:13px">
</label>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
<label style="display:flex;flex-direction:column;gap:4px;font-size:11px;color:var(--t3);text-transform:uppercase;font-weight:600">
Početak *
<input id="kalF_start" type="datetime-local" value="${_kalFmtLocal(ev.start_at)}" style="background:var(--bg1);border:1px solid var(--rim);color:var(--t1);padding:8px;border-radius:4px;font-size:13px">
</label>
<label style="display:flex;flex-direction:column;gap:4px;font-size:11px;color:var(--t3);text-transform:uppercase;font-weight:600">
Kraj
<input id="kalF_end" type="datetime-local" value="${_kalFmtLocal(ev.end_at)}" style="background:var(--bg1);border:1px solid var(--rim);color:var(--t1);padding:8px;border-radius:4px;font-size:13px">
</label>
</div>
<label style="display:flex;flex-direction:column;gap:4px;font-size:11px;color:var(--t3);text-transform:uppercase;font-weight:600">
Lokacija
<input id="kalF_loc" value="${esc(ev.location||'')}" style="background:var(--bg1);border:1px solid var(--rim);color:var(--t1);padding:8px;border-radius:4px;font-size:13px">
</label>
<label style="display:flex;flex-direction:column;gap:4px;font-size:11px;color:var(--t3);text-transform:uppercase;font-weight:600">
Opis
<textarea id="kalF_desc" rows="3" style="background:var(--bg1);border:1px solid var(--rim);color:var(--t1);padding:8px;border-radius:4px;font-size:13px;resize:vertical">${esc(ev.description||'')}</textarea>
</label>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
<label style="display:flex;flex-direction:column;gap:4px;font-size:11px;color:var(--t3);text-transform:uppercase;font-weight:600">
Tip
<select id="kalF_type" style="background:var(--bg1);border:1px solid var(--rim);color:var(--t1);padding:8px;border-radius:4px;font-size:13px">
${['event','meeting','manif','training','medical','other'].map(t => `<option value="${t}" ${ev.event_type===t?'selected':''}>${t}</option>`).join('')}
</select>
</label>
<label style="display:flex;flex-direction:column;gap:4px;font-size:11px;color:var(--t3);text-transform:uppercase;font-weight:600">
Boja
<select id="kalF_color" style="background:var(--bg1);border:1px solid var(--rim);color:var(--t1);padding:8px;border-radius:4px;font-size:13px">
<option value="b" ${(!ev.color||ev.color==='b')?'selected':''}>plava</option>
<option value="g" ${ev.color==='g'?'selected':''}>zelena</option>
<option value="a" ${ev.color==='a'?'selected':''}>amber</option>
<option value="r" ${ev.color==='r'?'selected':''}>crvena</option>
</select>
</label>
</div>
</div>
<div style="padding:12px 16px;border-top:1px solid var(--rim);display:flex;justify-content:space-between;gap:8px">
<div>
${isEdit ? `<button class="btn sm" style="background:rgba(220,38,38,0.18);border-color:rgba(220,38,38,0.5);color:#fca5a5" onclick="kalDelete(${ev.id})">🗑 Obriši</button>` : ''}
</div>
<div style="display:flex;gap:8px">
<button class="btn sm" onclick="kalCloseModal()">Odustani</button>
<button class="btn primary sm" onclick="kalSave(${ev.id||'null'})">${isEdit?'Spremi':'Kreiraj'}</button>
</div>
</div>
</div>
</div>`;
}
function kalOpenModal(ev){
const wrap = document.createElement('div');
wrap.id = 'kalModalWrap';
wrap.innerHTML = kalEventModalHtml(ev);
document.body.appendChild(wrap);
setTimeout(() => { try { document.getElementById('kalF_title').focus(); } catch(e){} }, 50);
}
function kalCloseModal(){
const w = document.getElementById('kalModalWrap');
if(w) w.remove();
}
async function kalRefresh(){
const ym = window._kalState.ym || (new Date().getFullYear()+'-'+String(new Date().getMonth()+1).padStart(2,'0'));
$('#content').innerHTML = '<div class=loading>Učitavanje...</div>';
$('#content').innerHTML = await renderKalendar({ym});
}
async function kalSave(eid){
const title = (document.getElementById('kalF_title').value||'').trim();
const start = document.getElementById('kalF_start').value;
const end = document.getElementById('kalF_end').value;
const loc = document.getElementById('kalF_loc').value;
const desc = document.getElementById('kalF_desc').value;
const typ = document.getElementById('kalF_type').value;
const col = document.getElementById('kalF_color').value;
if(!title){ alert('Naziv je obavezan.'); return; }
if(!start){ alert('Početak je obavezan.'); return; }
const toIso = (s) => s ? new Date(s).toISOString() : null;
const body = {
title,
start_at: toIso(start),
end_at: end ? toIso(end) : null,
location: loc || null,
description: desc || null,
event_type: typ,
color: col,
};
let res;
if(eid && eid !== 'null'){
res = await apiAuth('/v2/kalendar/events/'+eid, {method:'PUT', body: JSON.stringify(body)});
} else {
res = await apiAuth('/v2/kalendar/events', {method:'POST', body: JSON.stringify(body)});
}
if(!res || res.__error || res.__unauthorized){
alert('Greška pri spremanju (status '+(res && res.status || '?')+').');
return;
}
kalCloseModal();
await kalRefresh();
}
async function kalEdit(eid){
const r = await apiAuth('/v2/kalendar/events/'+eid);
if(!r || r.__error){ alert('Ne mogu dohvatiti termin.'); return; }
kalOpenModal(r);
}
async function kalDelete(eid){
if(!confirm('Obrisati termin #'+eid+'?')) return;
const r = await apiAuth('/v2/kalendar/events/'+eid, {method:'DELETE'});
if(!r || r.__error){ alert('Greška pri brisanju.'); return; }
kalCloseModal();
await kalRefresh();
}
async function renderKalendar(opts){
opts = opts || {};
const today = new Date();
const ym = opts.ym || (today.getFullYear() + '-' + String(today.getMonth()+1).padStart(2,'0'));
window._kalState.ym = ym;
const [Y, M] = ym.split('-').map(Number);
const first = new Date(Y, M-1, 1);
const last = new Date(Y, M, 0);
// Učitaj sve liječničke koji ističu unutar +180 dana, manifestacije iz API-ja, i mock eventove
let lij = [], manif = [], notif = [];
// Učitaj liječničke + manifestacije + notifikacije + kalendar_events
let lij = [], manif = [], notif = [], kalEvents = [];
try { const d = await fetch('/sport/api/crm/lijecnicki/uskoro-isticu?days=180&include_expired=false').then(r=>r.json()); lij = d.rows || []; } catch(e){}
try { const d = await fetch('/sport/api/manifestacije').then(r=>r.json()); manif = d.rows || d || []; } catch(e){}
try { const d = await fetch('/sport/api/crm/notifications?limit=50').then(r=>r.json()); notif = d.rows || []; } catch(e){}
kalEvents = await kalLoadEvents(ym);
window._kalState.events = kalEvents;
const events = [];
lij.forEach(l => events.push({date: l.vrijedi_do, type:'lij', title:`⚕ Pregled ističe: ${l.clan}`, klub:l.klub, color:'a'}));
manif.forEach(m => { if (m.datum) events.push({date: m.datum, type:'manif', title:`📅 ${m.naziv || m.title || 'Manifestacija'}`, klub:m.lokacija || m.grad, color:'b'}); });
// CRUD events from kalendar_events table
kalEvents.forEach(k => events.push({
id: k.id,
date: (k.start_at||'').substring(0,10),
type: k.event_type || 'event',
title: k.title,
klub: k.location || '',
color: k.color || 'b',
crud: true,
}));
// Eventi: ZZJZ termini mock — sljedećih 7 dana po radnim danima
for(let d=0; d<14; d++){
const dt = new Date(); dt.setDate(dt.getDate()+d);
@@ -1423,16 +1608,27 @@ async function renderKalendar(opts){
const k = `${Y}-${String(M).padStart(2,'0')}-${String(d).padStart(2,'0')}`;
const ev = byDate[k] || [];
const isToday = (k === today.toISOString().slice(0,10));
const evHtml = ev.slice(0,3).map(e => `<div style="font-size:10px;background:rgba(${e.color==='a'?'245,158,11':e.color==='b'?'26,115,232':'34,197,94'},0.18);padding:2px 4px;border-radius:3px;margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(e.title)}${e.klub?' — '+esc(e.klub):''}">${esc(e.title.substring(0,28))}</div>`).join('');
const evHtml = ev.slice(0,3).map(e => {
const click = e.crud && e.id ? `onclick="event.stopPropagation();kalEdit(${e.id})"` : '';
const cur = e.crud ? 'cursor:pointer;' : '';
return `<div ${click} style="${cur}font-size:10px;background:rgba(${e.color==='a'?'245,158,11':e.color==='b'?'26,115,232':'34,197,94'},0.18);padding:2px 4px;border-radius:3px;margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(e.title)}${e.klub?' — '+esc(e.klub):''}${e.crud?' (klikni za uredi)':''}">${esc(e.title.substring(0,28))}</div>`;
}).join('');
const more = ev.length > 3 ? `<div style="font-size:9px;color:var(--t3);margin-top:2px">+${ev.length-3} više</div>` : '';
grid += `<div style="background:${isToday?'rgba(26,115,232,0.15)':'var(--bg2)'};border:1px solid ${isToday?'var(--pgz-blue)':'var(--rim)'};border-radius:6px;padding:6px;min-height:90px;${ev.length?'cursor:pointer':''}" ><div style="font-weight:600;font-size:13px;color:${isToday?'var(--pgz-blue)':'var(--t1)'}">${d}</div>${evHtml}${more}</div>`;
// Click on empty cell → create event for that date at 09:00
const cellClick = `onclick="kalOpenModal({start_at:'${k}T09:00:00'})"`;
grid += `<div ${cellClick} style="background:${isToday?'rgba(26,115,232,0.15)':'var(--bg2)'};border:1px solid ${isToday?'var(--pgz-blue)':'var(--rim)'};border-radius:6px;padding:6px;min-height:90px;cursor:pointer" title="Klik za novi termin"><div style="font-weight:600;font-size:13px;color:${isToday?'var(--pgz-blue)':'var(--t1)'}">${d}</div>${evHtml}${more}</div>`;
}
grid += '</div>';
// Lista nadolazećih (top 10)
const upcoming = events.filter(e => e.date && e.date >= today.toISOString().slice(0,10))
.sort((a,b) => a.date.localeCompare(b.date)).slice(0, 10);
const upcomingHtml = upcoming.map(e => `<tr><td>${esc(e.date)}</td><td>${esc(e.title)}</td><td>${esc(e.klub||'—')}</td><td><span class="tag ${e.color==='a'?'am':e.color==='b'?'bl':'gr'}">${e.type}</span></td></tr>`).join('');
const upcomingHtml = upcoming.map(e => {
const actions = e.crud && e.id
? `<button class="btn sm" onclick="kalEdit(${e.id})">Uredi</button> <button class="btn sm" style="color:#fca5a5" onclick="kalDelete(${e.id})">🗑</button>`
: '<span style="color:var(--t3);font-size:11px">—</span>';
return `<tr><td>${esc(e.date)}</td><td>${esc(e.title)}</td><td>${esc(e.klub||'—')}</td><td><span class="tag ${e.color==='a'?'am':e.color==='b'?'bl':'gr'}">${e.type}</span></td><td>${actions}</td></tr>`;
}).join('');
return `
<div class="kpi-grid" style="margin-bottom:12px">
@@ -1448,16 +1644,19 @@ async function renderKalendar(opts){
<button class="btn sm" onclick="$('#content').innerHTML='<div class=loading>Učitavanje...</div>';renderKalendar({ym:'${prevYm}'}).then(h=>$('#content').innerHTML=h)">←</button>
<input type="month" value="${ym}" onchange="$('#content').innerHTML='<div class=loading>...</div>';renderKalendar({ym:this.value}).then(h=>$('#content').innerHTML=h)" style="background:var(--bg2);border:1px solid var(--rim);color:var(--t1);padding:4px 8px;border-radius:4px">
<button class="btn sm" onclick="$('#content').innerHTML='<div class=loading>Učitavanje...</div>';renderKalendar({ym:'${nextYm}'}).then(h=>$('#content').innerHTML=h)">→</button>
<button class="btn primary sm" onclick="fetch('/sport/api/crm/lijecnicki/notify-scan',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'}).then(r=>r.json()).then(d=>alert('Skenirano: '+d.created+' notifikacija kreirano'))">🔔 Scan isteke → notifikacije</button>
<button class="btn primary sm" onclick="kalOpenModal({})"> Novi termin</button>
<button class="btn sm" onclick="fetch('/sport/api/crm/lijecnicki/notify-scan',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'}).then(r=>r.json()).then(d=>alert('Skenirano: '+d.created+' notifikacija kreirano'))">🔔 Scan isteke → notifikacije</button>
</div>
</div>
<div style="padding:14px">${grid}</div>
</div>
<div class="card">
<div class="card-h"><div class="card-t">📋 Nadolazeći eventi (10)</div></div>
<div class="card-h"><div class="card-t">📋 Nadolazeći eventi (10)</div>
<div class="card-actions"><button class="btn primary sm" onclick="kalOpenModal({})"> Novi termin</button></div>
</div>
<table>
<thead><tr><th>Datum</th><th>Naziv</th><th>Lokacija/Klub</th><th>Tip</th></tr></thead>
<tbody>${upcomingHtml || '<tr><td colspan="4" class="empty">Nema nadolazećih eventa.</td></tr>'}</tbody>
<thead><tr><th>Datum</th><th>Naziv</th><th>Lokacija/Klub</th><th>Tip</th><th>Akcije</th></tr></thead>
<tbody>${upcomingHtml || '<tr><td colspan="5" class="empty">Nema nadolazećih eventa.</td></tr>'}</tbody>
</table>
</div>
<div class="card">
@@ -1620,19 +1819,7 @@ SECTIONS['savez:zahtjevi'] = () => `
</div>`).join('')}
</div>`;
SECTIONS['savez:kalendar'] = () => `
<div class="card"><div class="card-h"><div class="card-t">📅 Kalendar manifestacija — Svibanj 2026</div></div>
<div class="cal-grid">
${'PON UTO SRI ČET PET SUB NED'.split(' ').map(h => `<div class="cal-h">${h}</div>`).join('')}
${[...Array(31)].map((_,i) => {
const day = i+1;
const ev = [4,11,18,25,9,16,30].includes(day);
const today = day===5;
return `<div class="cal-d ${today?'t':''} ${ev?'has-event':''}"><b>${day}</b>${ev?`<div style="font-size:9px;color:var(--pgz-gold);margin-top:2px">Trening / utakmica</div>`:''}</div>`;
}).join('')}
</div>
<div style="margin-top:14px;font-size:11px;color:var(--t2)">● Trening kamp Platak (46.5) · ● Liga PGŽ atletika (11.5) · ● Open senior (18.5) · ● Memorijalna utrka (25.5)</div>
</div>`;
// (Stari savez:kalendar mock obrisan — sada koristi renderKalendar s CRUD-om iz pgz_sport.kalendar_events)
SECTIONS['savez:lijecnicki'] = () => `
<div class="card"><div class="card-h"><div class="card-t">⚕ Liječnički pregledi članova saveza</div><div class="card-actions"><button class="btn primary">📅 Bulk ZZJZ rezervacija</button></div></div>
@@ -1646,6 +1833,7 @@ SECTIONS['savez:lijecnicki'] = () => `
</div>`;
SECTIONS['savez:racuni'] = SECTIONS['pgz:racuni'];
SECTIONS['savez:putni'] = SECTIONS['pgz:putni'];
// =======================================================================
// KLUB ADMIN — Dashboard + sub-pages
@@ -1685,7 +1873,7 @@ SECTIONS['klub:dashboard'] = () => {
<div class="card-h"><div class="card-t">⚡ Brze akcije</div></div>
<div style="display:grid;gap:8px">
<button class="btn primary" onclick="navTo('clanovi')">+ Dodaj člana</button>
<button class="btn gold" onclick="navTo('racuni')">🧾 Skeniraj račun (OCR)</button>
<button class="btn gold" onclick="window.location.href='/erp/full?tab=uploads'">🧾 Skeniraj račun (OCR)</button>
<button class="btn" onclick="navTo('clanarine')">€ Članarine + HUB-3</button>
<button class="btn" onclick="navTo('lijecnicki')">⚕ Liječnički bulk ZZJZ</button>
<button class="btn" onclick="alert('Obrazac sufinanciranja — M9')">📑 Predaj zahtjev PGŽ</button>
@@ -1765,6 +1953,7 @@ SECTIONS['klub:manifestacije'] = () => `
</div>`;
SECTIONS['klub:racuni'] = SECTIONS['pgz:racuni'];
SECTIONS['klub:putni'] = SECTIONS['pgz:putni'];
// =======================================================================
// SPORTAŠ — Dashboard + sub-pages
+104
View File
@@ -103,6 +103,21 @@ table tr.sel td { background:rgba(26,115,232,.15); }
.chip.high { background:#3a2e1a; color:#fbbf24; }
.chip.normal { background:#1e293b; color:#94a3b8; }
.chip.low { background:#1f1f1f; color:#71717a; }
.chip.nepodmireno { background:#3a1e1e; color:#f87171; }
.chip.djelomicno { background:#3a2e1a; color:#fbbf24; }
.chip.podmireno { background:#1a3a2a; color:#4ade80; }
.chip.storno { background:#1f1f1f; color:#71717a; }
.chip.draft { background:#1e293b; color:#94a3b8; }
.chip.submitted { background:#1e3a5f; color:#60a5fa; }
.chip.approved { background:#1a3a2a; color:#4ade80; }
.chip.rejected { background:#3a1e1e; color:#f87171; }
.chip.spreman { background:#1a3a2a; color:#4ade80; }
.chip.nije-spreman{ background:#3a1e1e; color:#f87171; }
.tpl-row { padding:9px 12px; border-bottom:1px solid var(--rim); cursor:pointer; }
.tpl-row:hover { background:var(--bg3); }
.tpl-row.sel { background:rgba(26,115,232,.18); border-left:3px solid var(--pgz-blue); padding-left:9px; }
.tpl-row .tpl-n { font-weight:600; font-size:12px; color:var(--t1); }
.tpl-row .tpl-c { font-size:10px; color:var(--t3); margin-top:2px; text-transform:uppercase; letter-spacing:.4px; }
/* Kanban */
.kanban { display:grid; grid-template-columns:repeat(6,1fr); gap:10px; min-height:60vh; }
@@ -190,6 +205,9 @@ footer { height:36px; background:var(--bg2); border-top:1px solid var(--rim);
<div class="tab" data-tab="opportunities">Opportunities <span class="count" id="cnt-opps">·</span></div>
<div class="tab" data-tab="activities">Activities <span class="count" id="cnt-activities">·</span></div>
<div class="tab" data-tab="cases">Cases <span class="count" id="cnt-cases">·</span></div>
<div class="tab" data-tab="clanarine">Članarine <span class="count" id="cnt-clanarine">·</span></div>
<div class="tab" data-tab="lijecnicki">Liječnički <span class="count" id="cnt-lijecnicki">·</span></div>
<div class="tab" data-tab="obrasci">Obrasci <span class="count" id="cnt-obrasci">·</span></div>
</div>
<div class="main">
@@ -364,6 +382,89 @@ footer { height:36px; background:var(--bg2); border-top:1px solid var(--rim);
</div></div>
</div>
<!-- ────── ČLANARINE ────── -->
<div class="tab-c" id="tc-clanarine">
<div class="toolbar">
<input id="cln-klub" type="number" placeholder="Klub ID" style="max-width:120px">
<input id="cln-clan" type="number" placeholder="Član ID" style="max-width:120px">
<input id="cln-godina" type="number" placeholder="Godina" style="max-width:120px">
<select id="cln-status">
<option value="">— Svi statusi —</option>
<option value="nepodmireno">nepodmireno</option>
<option value="djelomicno">djelomično</option>
<option value="podmireno">podmireno</option>
<option value="storno">storno</option>
</select>
<button class="btn" onclick="loadClanarine()">Filtriraj</button>
<span class="grow"></span>
<button class="btn primary" onclick="openClanarinaModal()">+ Nova članarina</button>
</div>
<div class="card"><div class="card-b" style="padding:0">
<table id="t-clanarine"><thead><tr>
<th>Član</th><th>Klub</th><th>Godina</th><th>Razdoblje</th>
<th>Propisan</th><th>Plaćen</th><th>Datum uplate</th><th>Status</th><th></th>
</tr></thead><tbody></tbody></table>
</div></div>
</div>
<!-- ────── LIJEČNIČKI ────── -->
<div class="tab-c" id="tc-lijecnicki">
<div class="toolbar">
<input id="lij-klub" type="number" placeholder="Klub ID" style="max-width:120px">
<input id="lij-clan" type="number" placeholder="Član ID" style="max-width:120px">
<select id="lij-expiring">
<option value="">— Svi —</option>
<option value="true">Ističu (≤30d) ili istekli</option>
</select>
<button class="btn" onclick="loadLijecnicki()">Filtriraj</button>
<span class="grow"></span>
<button class="btn primary" onclick="openLijecnickiModal()">+ Novi pregled</button>
</div>
<div class="card"><div class="card-b" style="padding:0">
<table id="t-lijecnicki"><thead><tr>
<th>Član</th><th>Klub</th><th>Datum</th><th>Vrsta</th>
<th>Vrijedi do</th><th>Liječnik</th><th>Spreman</th><th>Plaćeno</th><th></th>
</tr></thead><tbody></tbody></table>
</div></div>
</div>
<!-- ────── OBRASCI ────── -->
<div class="tab-c" id="tc-obrasci">
<div style="display:grid; grid-template-columns:300px 1fr; gap:12px; align-items:start;">
<!-- Templates list (left) -->
<div class="card">
<div class="card-h"><div class="card-t">Predlošci</div>
<button class="btn sm" onclick="loadObrasciTemplates()"></button>
</div>
<div class="card-b" style="padding:0; max-height:70vh; overflow:auto;" id="obr-tpl-list">
<div class="empty">Učitavanje…</div>
</div>
</div>
<!-- Submissions / detail (right) -->
<div>
<div class="toolbar">
<strong id="obr-right-title">Podnešeni obrasci</strong>
<span class="grow"></span>
<select id="obr-status">
<option value="">— Svi statusi —</option>
<option value="draft">draft</option>
<option value="submitted">submitted</option>
<option value="approved">approved</option>
<option value="rejected">rejected</option>
</select>
<input id="obr-klub" type="number" placeholder="Klub ID" style="max-width:120px">
<button class="btn" onclick="loadObrasciSubmissions()">Filtriraj</button>
</div>
<div class="card"><div class="card-b" style="padding:0" id="obr-right-body">
<table id="t-obr-sub"><thead><tr>
<th>ID</th><th>Predložak</th><th>Klub</th><th>Član</th>
<th>Status</th><th>Submitted</th><th>Approved</th><th></th>
</tr></thead><tbody></tbody></table>
</div></div>
</div>
</div>
</div>
</div><!-- /main -->
<footer>
@@ -439,6 +540,9 @@ function switchTab(name) {
if (name==='opportunities') loadOpps();
if (name==='activities') loadActivities();
if (name==='cases') loadCases();
if (name==='clanarine') loadClanarine();
if (name==='lijecnicki') loadLijecnicki();
if (name==='obrasci') { loadObrasciTemplates(); loadObrasciSubmissions(); }
}
// ────── /me ──────
+194 -14
View File
@@ -1,9 +1,10 @@
<!DOCTYPE html>
<!--
PGŽ Sport — Dokumenti UI v1.0
PGŽ Sport — Dokumenti UI v1.1
dradulic@outlook.com / damir@rinet.one — 2026-05-05
Library svih dokumenata: godišnjaci, publikacije, pravilnici, javni pozivi.
Tabovi + filteri + drill-down modal s RAG citatima + XLSX export + globalna pretraga.
Library svih dokumenata: godišnjaci, publikacije, pravilnici, javni pozivi,
+ unified "Svi dokumenti" view (tip × izdavatelj) preko /api/v2/dokumenti/unified.
Tabovi + filteri + drill-down modal s RAG citatima + inline PDF iframe + XLSX export.
-->
<html lang="hr">
<head>
@@ -150,9 +151,16 @@
.modal-bg.open { display:flex; }
.modal {
background:var(--bg1); border:1px solid #2a3144; border-radius:12px;
width:100%; max-width:880px; padding:0; overflow:hidden;
width:100%; max-width:1100px; padding:0; overflow:hidden;
box-shadow:0 12px 60px rgba(0,0,0,0.6);
}
.pdf-frame {
width:100%; height:72vh; border:0; background:#0a0c12; border-radius:6px;
}
.pdf-fallback {
padding:30px; text-align:center; color:var(--muted);
background:var(--bg2); border-radius:6px;
}
.modal-h {
padding:18px 22px; border-bottom:1px solid var(--line); display:flex;
align-items:flex-start; justify-content:space-between; gap:12px;
@@ -239,6 +247,7 @@
</div>
<div class="modal-tabs">
<button class="modal-tab active" data-mtab="info" onclick="setModalTab('info')">️ Info</button>
<button class="modal-tab" data-mtab="pdf" onclick="setModalTab('pdf')">📄 PDF</button>
<button class="modal-tab" data-mtab="search" onclick="setModalTab('search')">🔎 Pretraži sadržaj</button>
<button class="modal-tab" data-mtab="citati" onclick="setModalTab('citati')">📎 Citati</button>
</div>
@@ -254,7 +263,9 @@
// Tab definitions (vrste mapping)
// ─────────────────────────────────────────
const TABS = [
{ id:'godisnjaci', label:'📅 Godišnjaci ZSPGZ',
{ id:'svi', label:'🗂️ Svi dokumenti',
unified:true, view:'cards' },
{ id:'godisnjaci', label:'📅 Godišnjaci ZSPGZ',
vrste:['godisnjak'], view:'cards-year' },
{ id:'publikacije', label:'📰 Publikacije',
vrste:['program','plan','strategija','izvjestaj','raspodjela','erasmus'], view:'cards' },
@@ -269,6 +280,28 @@ const TABS = [
view:'cards' },
];
// Unified library — derived classifications from the API
const TIP_OPTIONS = [
{ v:'', l:'Svi tipovi' },
{ v:'godisnjak', l:'Godišnjak' },
{ v:'publikacija', l:'Publikacija' },
{ v:'pravilnik', l:'Pravilnik / Statut / Zakon' },
{ v:'javni-poziv', l:'Javni poziv' },
{ v:'natjecaj', l:'Natjecaj' },
{ v:'ostalo', l:'Ostalo' },
];
const IZDAVATELJ_OPTIONS = [
{ v:'', l:'Svi izdavatelji' },
{ v:'ZSPGZ', l:'Zajednica sportova PGŽ' },
{ v:'PGŽ', l:'PGŽ (Županija)' },
{ v:'RSS', l:'Riječki sportski savez' },
{ v:'HOO', l:'Hrvatski olimpijski odbor' },
{ v:'JLS', l:'Grad / Općina (JLS)' },
{ v:'savez', l:'Nacionalni sportski savez' },
{ v:'klub', l:'Klub' },
{ v:'ostalo',l:'Ostalo' },
];
const VRSTE_HIDDEN = ['corpus','corpus_v2','corpus_v3','novost_savez','audit','godisnjak_facts'];
const API = '/sport/api/v2'; // hosted under /sport/ via reverse proxy
// Fallback: when running directly on :8095, /sport prefix is stripped — try both
@@ -277,13 +310,16 @@ const API_DIRECT = '/api/v2';
// ─────────────────────────────────────────
// State
// ─────────────────────────────────────────
let activeTab = 'godisnjaci';
let activeTab = 'svi';
let allDocs = []; // all loaded docs (cached after first fetch)
let unifiedDocs = []; // separately cached unified-view docs (with tip/izdavatelj)
let unifiedFacets = null; // counts {by_tip, by_izdavatelj}
let displayDocs = []; // currently filtered/visible
let lastSearchHits = null; // for export of search results
let currentDocId = null;
let currentDocFull = null;
const filterState = {}; // per-tab filter state {tab: {godinaMin, godinaMax, vrste:Set, q}}
const filterState = {}; // per-tab filter state
const unifiedState = { tip:'', izdavatelj:'', q:'', godinaMin:null, godinaMax:null };
// ─────────────────────────────────────────
// Utilities
@@ -332,12 +368,108 @@ function setTab(id){
$$('.tab').forEach(b => b.classList.toggle('active', b.dataset.tab===id));
if(id==='search'){
renderSearchView();
} else if(id==='svi'){
renderUnifiedFilters();
loadUnified();
} else {
renderFilters();
applyFilters();
}
}
// ─────────────────────────────────────────
// Unified mode (Svi dokumenti)
// ─────────────────────────────────────────
function renderUnifiedFilters(){
const tipSel = TIP_OPTIONS.map(o =>
`<option value="${esc(o.v)}" ${unifiedState.tip===o.v?'selected':''}>${esc(o.l)}</option>`).join('');
const izdSel = IZDAVATELJ_OPTIONS.map(o =>
`<option value="${esc(o.v)}" ${unifiedState.izdavatelj===o.v?'selected':''}>${esc(o.l)}</option>`).join('');
$('#filters-wrap').innerHTML = `
<div class="filters">
<div class="filter-grp">
<label>Tip:</label>
<select id="uTip" onchange="onUnifiedFilterChange()">${tipSel}</select>
</div>
<div class="filter-grp">
<label>Izdavatelj:</label>
<select id="uIzd" onchange="onUnifiedFilterChange()">${izdSel}</select>
</div>
<div class="filter-grp">
<label>Godina:</label>
<input type="number" id="uYmin" value="${unifiedState.godinaMin||''}" placeholder="od" min="1990" max="2030" onchange="onUnifiedFilterChange()" style="width:80px">
<span></span>
<input type="number" id="uYmax" value="${unifiedState.godinaMax||''}" placeholder="do" min="1990" max="2030" onchange="onUnifiedFilterChange()" style="width:80px">
</div>
<div class="filter-grp">
<label>Pretraga:</label>
<input type="text" id="uQ" value="${esc(unifiedState.q)}" placeholder="naziv / opis / organizacija..." oninput="onUnifiedQDebounce()">
</div>
<div style="margin-left:auto;color:var(--muted);font-size:0.85rem;" id="filterCount">…</div>
</div>`;
}
let _uQTimer = null;
function onUnifiedQDebounce(){
clearTimeout(_uQTimer);
_uQTimer = setTimeout(() => {
unifiedState.q = $('#uQ').value || '';
loadUnified();
}, 350);
}
function onUnifiedFilterChange(){
unifiedState.tip = $('#uTip').value || '';
unifiedState.izdavatelj = $('#uIzd').value || '';
unifiedState.godinaMin = parseInt($('#uYmin').value) || null;
unifiedState.godinaMax = parseInt($('#uYmax').value) || null;
unifiedState.q = $('#uQ').value || '';
loadUnified();
}
async function loadUnified(){
$('#content').innerHTML = '<div class="loading">Učitavam dokumente…</div>';
const params = new URLSearchParams();
if(unifiedState.tip) params.set('tip', unifiedState.tip);
if(unifiedState.izdavatelj) params.set('izdavatelj', unifiedState.izdavatelj);
if(unifiedState.q) params.set('q', unifiedState.q);
if(unifiedState.godinaMin) params.set('godina_min', unifiedState.godinaMin);
if(unifiedState.godinaMax) params.set('godina_max', unifiedState.godinaMax);
params.set('limit', 500);
try {
const r = await api('/dokumenti/unified?' + params.toString());
const j = await r.json();
unifiedDocs = j.dokumenti || [];
displayDocs = unifiedDocs;
$('#filterCount') && ($('#filterCount').textContent = `${unifiedDocs.length} dokumenata`);
renderUnifiedContent(unifiedDocs);
} catch(e){
$('#content').innerHTML = `<div class="empty">⚠️ Greška: ${esc(e.message)}</div>`;
}
}
function renderUnifiedContent(docs){
if(docs.length === 0){
$('#content').innerHTML = `<div class="empty">Nema dokumenata za odabrane filtere.</div>`;
return;
}
const html = docs.map(d => unifiedCardHTML(d)).join('');
$('#content').innerHTML = `<div class="grid">${html}</div>`;
}
function unifiedCardHTML(d){
const meta = [];
if(d.izdavatelj) meta.push(`<span class="chip" style="background:rgba(0,48,135,0.5);color:#aac8ff">${esc(d.izdavatelj)}</span>`);
if(d.organizacija) meta.push(`<span style="color:var(--muted)">🏛️ ${esc(d.organizacija.slice(0,28))}${d.organizacija.length>28?'…':''}</span>`);
if(d.izdano_datum) meta.push(`<span style="color:var(--muted)">📅 ${fmtDate(d.izdano_datum)}</span>`);
else if(d.godina) meta.push(`<span style="color:var(--muted)">📅 ${d.godina}</span>`);
return `
<div class="card" onclick="openModal(${d.id})">
<span class="cardvrsta">${esc(d.tip||d.vrsta||'—')}</span>
<div class="cardtitle">${esc(d.title || '(bez naslova)')}</div>
<div class="cardmeta" style="flex-wrap:wrap;gap:6px;">${meta.join('')}</div>
</div>`;
}
// ─────────────────────────────────────────
// Filters UI
// ─────────────────────────────────────────
@@ -507,24 +639,42 @@ function cardHTML(d, yearStyle){
// Data load
// ─────────────────────────────────────────
async function loadAll(){
// Load both legacy list (for the 4 vrsta-tabs) and unified facets (for "Svi" badge).
$('#content').innerHTML = '<div class="loading">Učitavam dokumente…</div>';
try {
const r = await api('/dokumenti?limit=1000');
const j = await r.json();
const [r1, r2] = await Promise.all([
api('/dokumenti?limit=1000'),
api('/dokumenti/facets'),
]);
const j = await r1.json();
allDocs = (j.dokumenti || []).filter(d => !VRSTE_HIDDEN.includes(d.vrsta));
try { unifiedFacets = await r2.json(); } catch(_){ unifiedFacets = null; }
updateTabCounts();
renderFilters();
applyFilters();
if(activeTab === 'svi'){
renderUnifiedFilters();
loadUnified();
} else {
renderFilters();
applyFilters();
}
} catch(e){
$('#content').innerHTML = `<div class="empty">⚠️ Greška pri učitavanju: ${esc(e.message)}</div>`;
}
}
function updateTabCounts(){
// "svi" tab uses unified facets total; the rest count over allDocs.
for(const t of TABS){
const old = activeTab; activeTab = t.id;
const c = tabDocs().length;
activeTab = old;
let c = 0;
if(t.id === 'svi'){
if(unifiedFacets && unifiedFacets.by_tip){
c = unifiedFacets.by_tip.reduce((s,x) => s + (x.broj||0), 0);
} else { c = '—'; }
} else {
const old = activeTab; activeTab = t.id;
c = tabDocs().length;
activeTab = old;
}
const el = document.getElementById('cnt-'+t.id);
if(el) el.textContent = c;
}
@@ -658,6 +808,31 @@ function setModalTab(t){
${d.izvor_url ? `<div class="info-row"><div class="lab">Izvor URL</div><div class="val"><a href="${esc(d.izvor_url)}" target="_blank">${esc(d.izvor_url)}</a></div></div>` : ''}
<div class="info-row"><div class="lab">RAG chunks</div><div class="val">${currentDocFull.chunks_count || 0}</div></div>
`;
} else if(t === 'pdf'){
// Inline PDF viewer — try server stream, fall back to external pdf_url, then "open in new tab".
const localPdf = `${API}/dokumenti/${d.id}/pdf`;
const fallbackPdf = `${API_DIRECT}/dokumenti/${d.id}/pdf`;
const externalPdf = d.pdf_url || d.izvor_url || null;
$('#mBody').innerHTML = `
<div style="margin-bottom:8px;display:flex;gap:8px;flex-wrap:wrap;">
<a class="btn gold sm" target="_blank" rel="noopener" href="${esc(localPdf)}">⤴ Otvori u novom tabu</a>
${externalPdf ? `<a class="btn secondary sm" target="_blank" rel="noopener" href="${esc(externalPdf)}">🔗 Originalni izvor</a>` : ''}
</div>
<iframe class="pdf-frame" id="pdfFrame" src="${esc(localPdf)}"
onerror="this.src='${esc(fallbackPdf)}'"></iframe>
<div id="pdfFallback" style="display:none;" class="pdf-fallback">
PDF nije dostupan u inline pregledniku.
${externalPdf ? `Pokušaj <a href="${esc(externalPdf)}" target="_blank">originalni izvor</a>.` : ''}
</div>
`;
// Detect 404 on iframe load — best-effort timeout
setTimeout(()=>{
try {
const fr = $('#pdfFrame');
if(!fr || !fr.contentWindow) return;
// Same-origin? May read; cross-origin we can't introspect — leave as-is.
} catch(_){}
}, 1500);
} else if(t === 'search'){
$('#mBody').innerHTML = `
<div class="inline-search">
@@ -721,10 +896,15 @@ function exportXLSX(){
return;
}
const isSearch = activeTab === 'search';
const isUnified = activeTab === 'svi';
const rows = isSearch ? (lastSearchHits || []).map(h => ({
id:h.id, title:h.title, vrsta:h.vrsta, godina:h.godina,
izdano_datum:h.izdano_datum, izvor_url:h.izvor_url,
excerpt:(h.excerpt||'').replace(/\s+/g,' ').slice(0,300)
})) : isUnified ? displayDocs.map(d => ({
id:d.id, title:d.title, tip:d.tip, izdavatelj:d.izdavatelj,
vrsta:d.vrsta, godina:d.godina, organizacija:d.organizacija,
izdano_datum:d.izdano_datum, izvor_url:d.izvor_url, chars:d.chars
})) : displayDocs.map(d => ({
id:d.id, title:d.title, vrsta:d.vrsta, godina:d.godina,
organizacija:d.organizacija, izdano_datum:d.izdano_datum,
+79 -5
View File
@@ -222,14 +222,24 @@ table tbody tr:hover{background:var(--bg3)}
<!-- ============ INVOICE UPLOADS (OCR) ============ -->
<section class="panel" id="panel-uploads">
<div class="card">
<div class="card-h"><div class="card-t">Invoice Uploads (OCR/AI extraction)</div></div>
<div class="card-h">
<div class="card-t">Računi (OCR) — Invoice Uploads / AI extraction</div>
<div style="display:flex;gap:6px">
<button class="btn primary" onclick="document.getElementById('up-file').click()">📎 Upload novi račun</button>
<input type="file" id="up-file" accept="application/pdf,image/*" style="display:none" onchange="uploadInvoiceFile(this.files[0])">
</div>
</div>
<div id="up-drop" style="border:2px dashed var(--rim2);border-radius:8px;padding:18px;text-align:center;color:var(--t2);margin-bottom:10px;background:var(--bg3)">
Dovuci PDF / JPG / PNG ovdje (max 25 MB) ili koristi gumb gore.
<div id="up-progress" style="margin-top:6px;font-size:11px;color:var(--t1)"></div>
</div>
<div class="toolbar">
<input id="up-q" placeholder="Datoteka / vendor / broj…">
<label>Status <select id="up-status"><option value="">— svi —</option><option value="pending">pending</option><option value="ocr_done">ocr_done</option><option value="approved">approved</option><option value="rejected">rejected</option></select></label>
<button class="btn" onclick="loadUploads()">Osvježi</button>
</div>
<div class="tbl-wrap">
<table id="up-tbl"><thead><tr><th>#</th><th>Datoteka</th><th class="num">Veličina</th><th>Vendor</th><th>OIB</th><th>Br. računa</th><th>Datum</th><th class="num">Brutto</th><th>OCR status</th><th class="num">Conf</th><th>Račun</th></tr></thead><tbody><tr><td colspan="11" style="color:var(--t2);text-align:center;padding:18px">Klikni "Osvježi"…</td></tr></tbody></table>
<table id="up-tbl"><thead><tr><th>#</th><th>Datoteka</th><th class="num">Veličina</th><th>Vendor</th><th>OIB</th><th>Br. računa</th><th>Datum</th><th class="num">Brutto</th><th>OCR status</th><th class="num">Conf</th><th>Račun</th><th>Akcije</th></tr></thead><tbody><tr><td colspan="12" style="color:var(--t2);text-align:center;padding:18px">Klikni "Osvježi"…</td></tr></tbody></table>
</div>
</div>
</section>
@@ -973,7 +983,7 @@ async function loadProracun(){
// ===== INVOICE UPLOADS =====
async function loadUploads(){
const tbody = document.querySelector('#up-tbl tbody');
tbody.innerHTML = `<tr><td colspan="11" style="color:var(--t2);text-align:center;padding:14px">Učitavam…</td></tr>`;
tbody.innerHTML = `<tr><td colspan="12" style="color:var(--t2);text-align:center;padding:14px">Učitavam…</td></tr>`;
try {
const q = (document.getElementById('up-q').value||'').trim();
const st = document.getElementById('up-status').value;
@@ -994,13 +1004,55 @@ async function loadUploads(){
<td><span class="badge ${r.ocr_status||''}">${r.ocr_status||'—'}</span></td>
<td class="num">${r.ocr_confidence!=null?fmt(r.ocr_confidence)+' %':''}</td>
<td>${r.invoice_id?('#'+r.invoice_id):'—'}</td>
<td><a class="btn sec" href="/uploads/${r.file_path||''}" target="_blank">Otvori</a></td>
</tr>`).join('')
: `<tr><td colspan="11" style="color:var(--t2);text-align:center;padding:14px">Nema uploadova.</td></tr>`;
: `<tr><td colspan="12" style="color:var(--t2);text-align:center;padding:14px">Nema uploadova.</td></tr>`;
} catch(e) {
tbody.innerHTML = `<tr><td colspan="11" style="color:var(--red)">Greška: ${e.message}</td></tr>`;
tbody.innerHTML = `<tr><td colspan="12" style="color:var(--red)">Greška: ${e.message}</td></tr>`;
}
}
// Upload new invoice file via multipart
async function uploadInvoiceFile(file){
if(!file) return;
const prog = document.getElementById('up-progress');
prog.textContent = 'Šaljem ' + file.name + ' (' + Math.round(file.size/1024) + ' KB)…';
try {
const fd = new FormData();
fd.append('file', file);
// Note: no Content-Type header — browser sets multipart boundary
const r = await fetch(API + '/invoice-uploads', {
method:'POST', body: fd, headers: AUTH()
});
if(!r.ok) throw new Error('HTTP ' + r.status + ' ' + (await r.text()));
const j = await r.json();
prog.textContent = '✓ Uploaded #' + j.id + ' (' + Math.round((j.file_size||0)/1024) + ' KB) — OCR pending.';
document.getElementById('up-file').value = '';
loadUploads();
} catch(e) {
prog.textContent = '✗ Greška: ' + e.message;
}
}
// Drag & drop on uploads card
document.addEventListener('DOMContentLoaded', () => {
const drop = document.getElementById('up-drop');
if(!drop) return;
['dragenter','dragover'].forEach(ev => drop.addEventListener(ev, e => {
e.preventDefault(); e.stopPropagation();
drop.style.background='var(--bg2)';
}));
['dragleave','drop'].forEach(ev => drop.addEventListener(ev, e => {
e.preventDefault(); e.stopPropagation();
drop.style.background='var(--bg3)';
}));
drop.addEventListener('drop', e => {
if(e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0]){
uploadInvoiceFile(e.dataTransfer.files[0]);
}
});
});
// ===== PUTNI NALOZI / EXPENSE REPORTS =====
async function loadExpenseReports(){
const tbody = document.querySelector('#pn-tbl tbody');
@@ -1148,10 +1200,32 @@ const loaders = {
kontni: loadKontniPlan
};
// Switch programmatically (used by deep links: ?tab=uploads / #tab=putni)
function activateTab(panelId){
const t = document.querySelector('.tab[data-panel="' + panelId + '"]');
if(!t) return false;
t.click();
return true;
}
// Initial
document.addEventListener('DOMContentLoaded', () => {
loadKontoCache();
loadPartnerCache();
// Deep-link support: ?tab=<panel> or #tab=<panel>
let target = null;
try {
const u = new URL(window.location.href);
target = u.searchParams.get('tab');
if(!target && u.hash){
const m = u.hash.match(/tab=([a-z]+)/i);
if(m) target = m[1];
}
} catch(e) {}
if(target && activateTab(target)){
// tab.click() already triggers loader
return;
}
loadDnevnik();
});
</script>
+64 -15
View File
@@ -69,19 +69,45 @@
const $ = (s, root) => (root||document).querySelector(s);
const esc = s => String(s==null?'':s).replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));
// Tracks the currently-loaded user (null = guest). Set by setUserDisplay().
let _currentUser = null;
function readToken(){
try { return localStorage.getItem('jwt') || localStorage.getItem('access_token') || ''; }
catch(e){ return ''; }
}
function logout(){
if(!confirm('Odjava iz aplikacije?')) return;
try {
localStorage.removeItem('jwt');
localStorage.removeItem('access_token');
localStorage.removeItem('app-role');
return localStorage.getItem('pgz_access')
|| sessionStorage.getItem('pgz_access')
|| localStorage.getItem('jwt')
|| localStorage.getItem('access_token')
|| '';
} catch(e){ return ''; }
}
async function logout(){
if(!confirm('Odjava iz aplikacije?')) return;
// Revoke server-side (matches app.html logout flow, commit e07292b)
try {
const tok = readToken();
if(tok){
await fetch('/sport/api/auth/logout', {
method:'POST',
headers:{'Authorization':'Bearer '+tok}
}).catch(()=>{});
}
} catch(e){}
// Clear ALL session keys
['pgz_access','pgz_refresh','pgz_user','app-role','jwt','access_token','refresh_token','pgz_session_id'].forEach(k => {
try { localStorage.removeItem(k); sessionStorage.removeItem(k); } catch(e){}
});
location.href = '/sport/login';
}
function gotoLogin(){
// Preserve return URL so we land back here after sign-in
try {
const ret = encodeURIComponent(location.pathname + location.search + location.hash);
location.href = '/sport/login?next=' + ret;
} catch(e){
location.href = '/sport/login';
}
}
function initials(n){
if(!n) return '?';
const p = String(n).trim().split(/\s+/);
@@ -141,19 +167,23 @@
<div class="pgz-sb-mx" onclick="PGZSidebar.closeMobile()" title="Zatvori">✕</div>
</div>
<nav class="pgz-sb-nav" id="pgz-sb-nav">${renderSections(activeKey, user)}</nav>
<div class="pgz-sb-foot" id="pgz-sb-foot" onclick="PGZSidebar.toggleUserMenu(event)">
<div class="av" id="pgz-sb-av">PG</div>
<div class="pgz-sb-foot" id="pgz-sb-foot"
role="button" tabindex="0"
title="Klikni za prijavu / odjavu"
onclick="PGZSidebar.handleFootClick(event)"
onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();PGZSidebar.handleFootClick(event);}">
<div class="av" id="pgz-sb-av">?</div>
<div class="ui">
<div class="un" id="pgz-sb-un">Gost</div>
<div class="ur" id="pgz-sb-ur">Demo</div>
<div class="ur" id="pgz-sb-ur">Klikni za prijavu</div>
</div>
<div class="caret">▾</div>
<div class="caret" id="pgz-sb-caret" title="Otvori izbornik" onclick="event.stopPropagation();PGZSidebar.toggleUserMenu(event)">▾</div>
<div class="pgz-user-menu" id="pgz-user-menu" onclick="event.stopPropagation()">
<a href="/app#profil"><span>👤</span><span>Moj profil</span></a>
<a href="/app#postavke"><span>⚙</span><span>Postavke</span></a>
<a href="/static/sport2.html"><span>🌐</span><span>Public portal</span></a>
<div class="sep"></div>
<a href="/login" id="pgz-menu-login"><span>🔑</span><span>Prijava</span></a>
<a href="/sport/login" id="pgz-menu-login"><span>🔑</span><span>Prijava</span></a>
<a class="danger" id="pgz-menu-logout" onclick="PGZSidebar.logout()" style="display:none"><span>⎋</span><span>Odjava</span></a>
</div>
</div>
@@ -171,13 +201,16 @@
}
function setUserDisplay(me){
_currentUser = me || null;
const un = $('#pgz-sb-un'), ur = $('#pgz-sb-ur'), av = $('#pgz-sb-av');
const foot = document.getElementById('pgz-sb-foot');
const loginLink = document.getElementById('pgz-menu-login');
const logoutLink = document.getElementById('pgz-menu-logout');
if(!me){
if(un) un.textContent = 'Gost';
if(ur) ur.textContent = 'Klikni za prijavu';
if(av){ av.textContent = '?'; av.innerHTML = av.innerHTML; }
if(foot) foot.setAttribute('title', 'Klikni za prijavu');
if(loginLink) loginLink.style.display = 'flex';
if(logoutLink) logoutLink.style.display = 'none';
return;
@@ -186,11 +219,12 @@
const role = me.user_type || '';
const avSrc = me.avatar_url || me.google_picture;
if(un) un.textContent = name;
if(ur) ur.textContent = role;
if(ur) ur.textContent = role || 'Korisnik';
if(av){
if(avSrc) av.innerHTML = `<img src="${esc(avSrc)}" alt="">`;
else av.textContent = initials(name);
}
if(foot) foot.setAttribute('title', 'Klikni za odjavu (' + (me.email || name) + ')');
if(loginLink) loginLink.style.display = 'none';
if(logoutLink) logoutLink.style.display = 'flex';
}
@@ -286,7 +320,22 @@
setTimeout(() => document.addEventListener('click', closer, true), 50);
}
},
logout
/* Single-click footer handler:
* - Guest (no user) → /sport/login
* - Logged in → logout() (revokes JWT, clears storage, redirects to login)
* The caret (▾) opens the full user menu (profil/postavke/portal/logout).
*/
handleFootClick(ev){
ev && ev.stopPropagation();
if(_currentUser){
return logout();
}
return gotoLogin();
},
gotoLogin,
logout,
// exposed for debugging / tests
_state: () => ({ user: _currentUser })
};
window.PGZSidebar = PGZSidebar;
+57 -13
View File
@@ -1956,10 +1956,12 @@ async function openSportas(id){
.forEach(k => { if((d[k]===null||d[k]===undefined||d[k]==='') && p[k]!==null && p[k]!==undefined && p[k]!=='') d[k]=p[k]; });
}
const stats = d.stats || {};
const sezone = d.clan_sezona || [];
const utakmice = d.utakmice || [];
const sezone = (d.clan_sezona && d.clan_sezona.length) ? d.clan_sezona : ((dV2 && dV2.hns_seasons) || []);
const utakmice = (d.utakmice && d.utakmice.length) ? d.utakmice : ((dV2 && dV2.hns_matches) || []);
const nagrade = d.nagrade || [];
const godisnjaci = d.godisnjak_godine || d.godisnjaci || [];
// SUB6 2026-05-05: M2M kategorije (clan_kategorije) iz /v2/clan/{id}/full
const kategorije = (dV2 && dV2.kategorije) || [];
const initials = (((d.ime||'?')[0]||'?')+((d.prezime||'?')[0]||'?')).toUpperCase();
const photo = d.slika_url ? '<img src="'+esc(d.slika_url)+'" alt="" onerror="this.style.display=\'none\';if(this.parentElement)this.parentElement.innerHTML=\'<div class=\\\'no\\\'>'+initials+'</div>\'">' : '<div class="no">'+initials+'</div>';
const dob = d.datum_rodjenja || d.datum_rodenja;
@@ -2020,6 +2022,7 @@ async function openSportas(id){
<div class="tabs">
<div class="tab active" onclick="switchPlayerTab(this,'p-sez')">🏆 HNS Karijera (${sezone.length})</div>
<div class="tab" onclick="switchPlayerTab(this,'p-utak')">📅 Posljednje utakmice (${utakmice.length})</div>
<div class="tab" onclick="switchPlayerTab(this,'p-kat')">🏷️ Kategorije (${kategorije.length})</div>
<div class="tab" onclick="switchPlayerTab(this,'p-bio')">Bio</div>
<div class="tab" onclick="switchPlayerTab(this,'p-god')">Godišnjaci (${godisnjaci.length})</div>
<div class="tab" onclick="switchPlayerTab(this,'p-nag')">Nagrade (${nagrade.length})</div>
@@ -2035,11 +2038,11 @@ async function openSportas(id){
<td>${esc(s.natjecanje||'')}</td>
<td>${esc(s.klub_naziv||'')}</td>
<td class="num">${fmtNum(s.nastupi)}</td>
<td class="num"><b style="color:var(--pgz-gold)">${fmtNum(s.pogoci)}</b></td>
<td class="num"><b style="color:var(--pgz-gold)">${fmtNum(s.pogoci ?? s.golovi)}</b></td>
<td class="num">${fmtNum(s.asistencije)}</td>
<td class="num">${fmtNum(s.zuti_kartoni)}</td>
<td class="num">${fmtNum(s.crveni_kartoni)}</td>
<td>${s.natjecanje_url?'<a href="'+esc(s.natjecanje_url)+'" target="_blank">↗</a>':''}</td>
<td class="num">${fmtNum(s.zuti_kartoni ?? s.zuti)}</td>
<td class="num">${fmtNum(s.crveni_kartoni ?? s.crveni)}</td>
<td>${(s.natjecanje_url||s.source_url)?'<a href="'+esc(s.natjecanje_url||s.source_url)+'" target="_blank">↗</a>':''}</td>
</tr>`).join('')}
</tbody>
</table></div>` : '<div class="empty">Nema sezonskih podataka</div>'}
@@ -2048,22 +2051,45 @@ async function openSportas(id){
<div id="p-utak" class="ptab" style="display:none">
<div class="pp-section-h">📅 Posljednje utakmice <span class="cnt">${utakmice.length} zabilježeno</span></div>
${utakmice.length ? `<div style="overflow-x:auto;max-height:500px;overflow-y:auto"><table>
<thead><tr><th>Datum</th><th>Natjecanje</th><th colspan="3">Susret</th><th class="num">Gol.</th><th class="num">Min.</th><th></th></tr></thead>
<tbody>${utakmice.map(u => `
<thead><tr><th>Datum</th><th>Natjecanje</th><th colspan="3">Susret</th><th>Pozicija</th><th class="num">Gol.</th><th class="num">Min.</th><th></th></tr></thead>
<tbody>${utakmice.map(u => {
const dom = u.klub_dom || u.domacin || '';
const gost = u.klub_gost || u.gost || '';
const golovi = (u.pogodaka != null ? u.pogodaka : u.golovi);
const minute = (u.minute != null) ? u.minute : ((u.minute_od != null && u.minute_do != null) ? (u.minute_do - u.minute_od) : null);
return `
<tr class="no-click">
<td>${fmtDate(u.datum)}</td>
<td>${esc(u.natjecanje||'')}</td>
<td>${u.klub_dom_logo?'<img src="'+esc(u.klub_dom_logo)+'" class="utlogo" onerror="this.style.display=\'none\'">':''}${esc(u.klub_dom||'')}</td>
<td>${u.klub_dom_logo?'<img src="'+esc(u.klub_dom_logo)+'" class="utlogo" onerror="this.style.display=\'none\'">':''}${esc(dom)}</td>
<td><b>${esc(u.rezultat||'-')}</b></td>
<td>${u.klub_gost_logo?'<img src="'+esc(u.klub_gost_logo)+'" class="utlogo" onerror="this.style.display=\'none\'">':''}${esc(u.klub_gost||'')}</td>
<td class="num"><b style="color:var(--pgz-gold)">${fmtNum(u.pogodaka)}</b></td>
<td class="num">${fmtNum(u.minute)}</td>
<td>${u.klub_gost_logo?'<img src="'+esc(u.klub_gost_logo)+'" class="utlogo" onerror="this.style.display=\'none\'">':''}${esc(gost)}</td>
<td>${esc(u.pozicija||'—')}</td>
<td class="num"><b style="color:var(--pgz-gold)">${fmtNum(golovi)}</b></td>
<td class="num">${fmtNum(minute)}</td>
<td>${u.source_url?'<a href="'+esc(u.source_url)+'" target="_blank">↗</a>':''}</td>
</tr>`).join('')}
</tr>`;
}).join('')}
</tbody>
</table></div>` : '<div class="empty">Nema podataka o utakmicama</div>'}
</div>
<div id="p-kat" class="ptab" style="display:none">
<div class="pp-section-h">🏷️ Kategorije <span class="cnt">${kategorije.length} ${kategorije.length===1?'zapis':(kategorije.length<5&&kategorije.length>1?'zapisa':'zapisa')}</span></div>
${kategorije.length ? `<div style="overflow-x:auto;max-height:500px;overflow-y:auto"><table>
<thead><tr><th>Sezona</th><th>Kategorija</th><th>Klub</th><th>Izvor</th><th></th></tr></thead>
<tbody>${kategorije.map(k => `
<tr class="no-click">
<td><b>${esc(k.sezona||'—')}</b></td>
<td><span class="tag b">${esc(k.kategorija||'—')}</span></td>
<td>${k.klub_id ? '<a class="link-chip" onclick="closePanel();setTimeout(()=>openKlub('+k.klub_id+'),250)">'+esc(k.klub_naziv||('Klub #'+k.klub_id))+'</a>' : (k.klub_naziv?esc(k.klub_naziv):'—')}</td>
<td>${esc(k.source||'—')}</td>
<td>${k.source_url?'<a href="'+esc(k.source_url)+'" target="_blank" rel="noopener">↗</a>':''}</td>
</tr>`).join('')}
</tbody>
</table></div>` : '<div class="empty">Nema M2M kategorija (clan_kategorije)</div>'}
</div>
<div id="p-bio" class="ptab" style="display:none">
<div class="kv">
<div class="k">OIB</div><div class="v">${d.oib?(canSeeFullOib({klub_id:d.klub_id,savez_id:d.savez_id})?'<a class="link-chip" onclick="openOIB(&quot;'+esc(d.oib)+'&quot;)">'+esc(d.oib)+'</a>':maskOib(d.oib)):'—'}</div>
@@ -3343,6 +3369,24 @@ function init(){
navTo('dashboard');
}
window.addEventListener('DOMContentLoaded', init);
// PRIORITY FILTERS (CRISIS V5) — default: prikazujemo samo klubove koji imaju podatke
window._klub_only_priority = true; // by default: only klubovi koji primaju novac ili su u godisnjaku ili imaju HNS roster
window._sportas_only_priority = true; // by default: only iz priority klubova
window._sportas_only_hns = false; // dodatni filter: samo s HNS profilom
window.toggleKlubPriority = function(){
window._klub_only_priority = !window._klub_only_priority;
if(typeof loadKlubovi === 'function') loadKlubovi();
};
window.toggleSportasPriority = function(){
window._sportas_only_priority = !window._sportas_only_priority;
if(typeof loadSportasi === 'function') loadSportasi();
};
window.toggleSportasHNS = function(){
window._sportas_only_hns = !window._sportas_only_hns;
if(typeof loadSportasi === 'function') loadSportasi();
};
</script>
</body>
</html>