7-sub sprint UI residual: footer login + kalendar CRUD + notif center + CRM extra tabs

A: shared/sidebar.js footer onclick → handleFootClick (Guest→/sport/login, logged-in→logout()), a11y role+keyboard, popup link fix
B: app.html SECTIONS['kalendar'] kalOpenModal/Save/Edit/Delete + Akcije kolona, mock savez:kalendar maknut
C: app.html renderNotifCenter() (sve 4 role) + sidebar bell unread badge (30s poll)
F: crm_v2.html +443 linija — Članarine, Liječnički, Obrasci tabovi (split view + dynamic schema modal)
G: index.html minor + sidebar dokumenti link refresh

Note: backend (kalendar_router, notif_router, crm_router, erp_full_router uploads, dokumenti unified) već u commit f7b5114.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Damir Radulić
2026-05-05 13:55:33 +02:00
parent f7b5114f58
commit ce544e660c
4 changed files with 603 additions and 0 deletions
+123
View File
@@ -1683,6 +1683,129 @@ SECTIONS['savez:kalendar'] = renderKalendar;
SECTIONS['klub:kalendar'] = renderKalendar;
SECTIONS['sportas:kalendar'] = renderKalendar;
// ─── NOTIF CENTER (W5 / Agent C) ────────────────────────────────────────────
// Backend: /api/v2/notif/{list,count,{id}/read,mark-all-read,DELETE {id}}
// Renders Palantir-style cards with kind badge + relative time + actions.
const _NOTIF_KIND = {
info: {bg:'#1e3a5f', fg:'#7dd3fc', label:'INFO', ic:''},
warning: {bg:'#5a4a16', fg:'#fcd34d', label:'WARNING', ic:'⚠'},
alert: {bg:'#5a1f1f', fg:'#fca5a5', label:'ALERT', ic:'!'},
success: {bg:'#1f5a2c', fg:'#86efac', label:'OK', ic:'✓'},
};
function _notifRel(iso){
if(!iso) return '';
try {
const d = new Date(iso);
const s = Math.max(0, Math.floor((Date.now()-d.getTime())/1000));
if(s < 60) return 'upravo sada';
if(s < 3600) return Math.floor(s/60)+' min';
if(s < 86400) return Math.floor(s/3600)+' h';
if(s < 604800) return Math.floor(s/86400)+' d';
return d.toLocaleDateString('hr-HR');
} catch(e){ return ''; }
}
async function notifApi(path, opts){
opts = opts || {};
opts.headers = Object.assign({'Content-Type':'application/json'}, opts.headers||{});
const tok = (typeof getToken==='function' ? getToken() : '') || (localStorage.getItem('jwt')||localStorage.getItem('access_token')||'');
if(tok) opts.headers['Authorization'] = 'Bearer '+tok;
const r = await fetch('/sport'+path, opts);
return r.json();
}
async function renderNotifCenter(){
let data = {rows:[], count:0};
try { data = await notifApi('/api/v2/notif/list?limit=100'); } catch(e){}
const rows = data.rows || [];
const unread = rows.filter(r => !r.is_read).length;
const card = (n) => {
const k = _NOTIF_KIND[n.kind] || _NOTIF_KIND.info;
const linkBtn = n.link
? `<a class="btn sm" href="${esc(n.link)}" style="text-decoration:none">↗ Otvori</a>`
: '';
const readBtn = n.is_read
? `<span class="tag" style="opacity:0.55">Pročitano</span>`
: `<button class="btn sm primary" onclick="notifMarkRead(${n.id})">✓ Pročitano</button>`;
return `
<div class="alert-card" style="border-left:3px solid ${k.fg};display:flex;gap:12px;align-items:flex-start;padding:12px;margin-bottom:8px;background:${n.is_read?'rgba(255,255,255,0.02)':'rgba(255,255,255,0.04)'};border-radius:6px">
<div style="flex:0 0 64px;text-align:center">
<div style="background:${k.bg};color:${k.fg};font-weight:700;font-size:10px;letter-spacing:0.5px;padding:4px 6px;border-radius:3px;display:inline-block">${k.ic} ${k.label}</div>
</div>
<div style="flex:1;min-width:0">
<div style="font-weight:600;font-size:13px;color:var(--t1,#e6edf3)">${esc(n.title || n.subject || '—')}</div>
<div style="font-size:12px;color:var(--t2,#a8b3bd);margin-top:4px;white-space:pre-wrap">${esc((n.body||'').substring(0,400))}${(n.body||'').length>400?'…':''}</div>
<div style="font-size:11px;color:var(--t3,#788490);margin-top:6px">${esc(_notifRel(n.created_at))}${n.user_id?' · za korisnika #'+n.user_id:' · sustavna obavijest'}</div>
</div>
<div style="display:flex;flex-direction:column;gap:6px;align-items:flex-end">
${linkBtn}
${readBtn}
<button class="btn sm" onclick="notifDelete(${n.id})" title="Obriši">🗑</button>
</div>
</div>`;
};
return `
<div class="card">
<div class="card-h">
<div class="card-t">🔔 Notifikacijski centar <span class="tag" style="margin-left:8px">${rows.length} ukupno</span> ${unread?`<span class="tag rd" style="margin-left:4px">${unread} nepročitano</span>`:''}</div>
<div class="card-actions">
<button class="btn sm" onclick="notifReload()">↻ Osvježi</button>
<button class="btn sm primary" onclick="notifMarkAllRead()" ${unread?'':'disabled'}>✓ Označi sve kao pročitano</button>
</div>
</div>
<div style="padding:8px 0">
${rows.length ? rows.map(card).join('') : '<div class="empty" style="padding:32px;text-align:center;color:var(--t3,#788490)">Nema notifikacija.</div>'}
</div>
</div>`;
}
async function notifMarkRead(id){
try { await notifApi('/api/v2/notif/'+id+'/read', {method:'POST'}); } catch(e){}
notifReload(); notifRefreshBadge();
}
async function notifDelete(id){
if(!confirm('Obrisati notifikaciju?')) return;
try { await notifApi('/api/v2/notif/'+id, {method:'DELETE'}); } catch(e){}
notifReload(); notifRefreshBadge();
}
async function notifMarkAllRead(){
try { await notifApi('/api/v2/notif/mark-all-read', {method:'POST', body:'{}'}); } catch(e){}
notifReload(); notifRefreshBadge();
}
function notifReload(){
if(_state.section === 'notif') loadSection();
}
async function notifRefreshBadge(){
try {
const d = await notifApi('/api/v2/notif/count');
const n = (d && d.unread) || 0;
// Update in-page nav badge
document.querySelectorAll('.nav-i[data-id="notif"], #pgz-sb .pgz-nav-i[data-id="notif"]').forEach(el => {
let b = el.querySelector('.badge.notif-badge');
if(n > 0){
if(!b){
b = document.createElement('span');
b.className = 'badge notif-badge';
b.style.cssText = 'background:#dc2626;color:#fff;border-radius:10px;padding:1px 6px;font-size:10px;font-weight:700;margin-left:auto';
el.appendChild(b);
}
b.textContent = String(n);
} else if(b){
b.remove();
}
});
} catch(e){}
}
SECTIONS['pgz:notif'] = renderNotifCenter;
SECTIONS['savez:notif'] = renderNotifCenter;
SECTIONS['klub:notif'] = renderNotifCenter;
SECTIONS['sportas:notif'] = renderNotifCenter;
// Start polling badge every 30s + once on load
try {
notifRefreshBadge();
setInterval(notifRefreshBadge, 30000);
} catch(e){}
SECTIONS['pgz:forenzika'] = () => `
<div class="card">
<div class="card-h"><div class="card-t">⚠ Forenzika — sumnjive transakcije</div></div>
+443
View File
@@ -1192,10 +1192,452 @@ async function delCase(id) {
catch (e) { toast(e.message, 'err'); }
}
// ══════════════════════════════════════════════════════════════════
// AGENT F — Članarine / Liječnički / Obrasci
// ══════════════════════════════════════════════════════════════════
let CURRENT_USER = null;
async function ensureMe() {
if (CURRENT_USER) return CURRENT_USER;
const candidates = ['/sport/api/auth/me', '/sport/api/v2/auth/me', '/sport/api/v2/me'];
for (const url of candidates) {
try {
const r = await fetch(url, {headers:{'Authorization':'Bearer '+TOKEN}});
if (r.ok) { CURRENT_USER = await r.json(); break; }
} catch {}
}
return CURRENT_USER;
}
function isAdminUser() {
if (!CURRENT_USER) return false;
const t = CURRENT_USER.user_type || CURRENT_USER.role || (CURRENT_USER.user && CURRENT_USER.user.user_type) || '';
return t === 'super_admin' || t === 'pgz_admin';
}
// ────── Članarine ──────
async function loadClanarine() {
const klub = document.getElementById('cln-klub').value.trim();
const clan = document.getElementById('cln-clan').value.trim();
const god = document.getElementById('cln-godina').value.trim();
const st = document.getElementById('cln-status').value;
const qs = new URLSearchParams();
if (klub) qs.set('klub_id', klub);
if (clan) qs.set('clan_id', clan);
if (god) qs.set('godina', god);
if (st) qs.set('status', st);
try {
const data = await api('/clanarine?'+qs.toString());
const tb = document.querySelector('#t-clanarine tbody');
tb.innerHTML = (data.items||[]).map(c => `
<tr onclick="editClanarina(${c.id})">
<td><strong>${esc(c.clan_naziv||'—')}</strong> <span style="color:var(--t3)">#${c.clan_id||''}</span></td>
<td>${esc(c.klub_naziv||'—')}</td>
<td>${c.godina}</td>
<td>${esc(c.razdoblje||'—')}</td>
<td>${fmtEur(c.iznos_propisan)}</td>
<td>${fmtEur(c.iznos_placen)}</td>
<td>${fmtDate(c.datum_uplate)}</td>
<td><span class="chip ${c.status}">${c.status}</span></td>
<td>${isAdminUser() ? `<button class="btn sm" onclick="event.stopPropagation();delClanarina(${c.id})">×</button>` : ''}</td>
</tr>
`).join('') || '<tr><td colspan="9" class="empty">Nema članarina za odabrane filtere.</td></tr>';
document.getElementById('cnt-clanarine').textContent = data.count ?? (data.items||[]).length;
} catch (e) { toast('Članarine err: '+e.message, 'err'); }
}
function clanarinaFormHTML(c={}) {
return `
<div class="fld-row">
<div class="fld"><label>Klub ID</label><input id="f-klub_id" type="number" value="${c.klub_id||''}"></div>
<div class="fld"><label>Član ID</label><input id="f-clan_id" type="number" value="${c.clan_id||''}"></div>
</div>
<div class="fld-row">
<div class="fld"><label>Godina*</label><input id="f-godina" type="number" value="${c.godina||(new Date()).getFullYear()}"></div>
<div class="fld"><label>Razdoblje</label><input id="f-razdoblje" value="${esc(c.razdoblje||'')}" placeholder="npr. cijela godina"></div>
</div>
<div class="fld-row">
<div class="fld"><label>Iznos propisan (EUR)*</label><input id="f-iznos_propisan" type="number" step="0.01" value="${c.iznos_propisan||''}"></div>
<div class="fld"><label>Iznos plaćen (EUR)</label><input id="f-iznos_placen" type="number" step="0.01" value="${c.iznos_placen||0}"></div>
</div>
<div class="fld-row">
<div class="fld"><label>Datum uplate</label><input id="f-datum_uplate" type="date" value="${(c.datum_uplate||'').slice(0,10)}"></div>
<div class="fld"><label>Način uplate</label><input id="f-nacin_uplate" value="${esc(c.nacin_uplate||'')}" placeholder="virman / kartica / gotovina"></div>
</div>
<div class="fld-row">
<div class="fld"><label>Referenca</label><input id="f-referenca" value="${esc(c.referenca||'')}"></div>
<div class="fld"><label>Račun broj</label><input id="f-racun_broj" value="${esc(c.racun_broj||'')}"></div>
</div>
<div class="fld"><label>Status</label>
<select id="f-status">
${['nepodmireno','djelomicno','podmireno','storno'].map(x=>`<option value="${x}" ${c.status===x?'selected':''}>${x}</option>`).join('')}
</select>
</div>
<div class="fld"><label>Napomena</label><textarea id="f-napomena">${esc(c.napomena||'')}</textarea></div>
`;
}
function readClanarinaForm() {
return {
klub_id: parseInt(document.getElementById('f-klub_id').value)||null,
clan_id: parseInt(document.getElementById('f-clan_id').value)||null,
godina: parseInt(document.getElementById('f-godina').value)||null,
razdoblje: document.getElementById('f-razdoblje').value.trim()||null,
iznos_propisan: parseFloat(document.getElementById('f-iznos_propisan').value)||0,
iznos_placen: parseFloat(document.getElementById('f-iznos_placen').value)||0,
datum_uplate: document.getElementById('f-datum_uplate').value||null,
nacin_uplate: document.getElementById('f-nacin_uplate').value.trim()||null,
referenca: document.getElementById('f-referenca').value.trim()||null,
racun_broj: document.getElementById('f-racun_broj').value.trim()||null,
status: document.getElementById('f-status').value,
napomena: document.getElementById('f-napomena').value||null,
};
}
function openClanarinaModal(c) {
const isEdit = !!(c && c.id);
showModal(isEdit?'Uredi članarinu':'Nova članarina',
clanarinaFormHTML(c||{godina:(new Date()).getFullYear(), status:'nepodmireno'}),
async () => {
const body = readClanarinaForm();
if (!body.godina || !body.iznos_propisan) { toast('Godina i iznos propisan su obavezni', 'err'); return; }
try {
if (isEdit) await api('/clanarine/'+c.id, {method:'PUT', body:JSON.stringify(body)});
else await api('/clanarine', {method:'POST', body:JSON.stringify(body)});
toast('Spremljeno'); closeModal(); loadClanarine();
} catch (e) { toast('Greška: '+e.message, 'err'); }
});
}
async function editClanarina(id) {
try { const c = await api('/clanarine/'+id); openClanarinaModal(c); }
catch (e) { toast(e.message, 'err'); }
}
async function delClanarina(id) {
if (!confirm('Obrisati članarinu #'+id+'?')) return;
try { await api('/clanarine/'+id, {method:'DELETE'}); toast('Obrisano'); loadClanarine(); }
catch (e) { toast(e.message, 'err'); }
}
// ────── Liječnički ──────
async function loadLijecnicki() {
const klub = document.getElementById('lij-klub').value.trim();
const clan = document.getElementById('lij-clan').value.trim();
const exp = document.getElementById('lij-expiring').value;
const qs = new URLSearchParams();
if (klub) qs.set('klub_id', klub);
if (clan) qs.set('clan_id', clan);
if (exp) qs.set('expiring', exp);
try {
const data = await api('/lijecnicki?'+qs.toString());
const tb = document.querySelector('#t-lijecnicki tbody');
const today = new Date().toISOString().slice(0,10);
tb.innerHTML = (data.items||[]).map(l => {
const expired = l.vrijedi_do && l.vrijedi_do < today;
return `
<tr onclick="editLijecnicki(${l.id})">
<td><strong>${esc(l.clan_naziv||'—')}</strong> <span style="color:var(--t3)">#${l.clan_id||''}</span></td>
<td>${esc(l.klub_naziv||'—')}</td>
<td>${fmtDate(l.datum_pregleda)}</td>
<td>${esc(l.vrsta_pregleda||'—')}</td>
<td>${fmtDate(l.vrijedi_do)} ${expired?'<span class="chip nepodmireno" style="margin-left:6px;">istekao</span>':''}</td>
<td>${esc(l.lijecnik||'—')}</td>
<td>${l.spreman_za_natjecanje ? '<span class="chip spreman">DA</span>' : '<span class="chip nije-spreman">NE</span>'}</td>
<td>${l.placeno ? '<span class="chip podmireno">DA</span>' : '<span class="chip nepodmireno">NE</span>'}</td>
<td>${isAdminUser() ? `<button class="btn sm" onclick="event.stopPropagation();delLijecnicki(${l.id})">×</button>` : ''}</td>
</tr>`;
}).join('') || '<tr><td colspan="9" class="empty">Nema liječničkih pregleda.</td></tr>';
document.getElementById('cnt-lijecnicki').textContent = data.count ?? (data.items||[]).length;
} catch (e) { toast('Liječnički err: '+e.message, 'err'); }
}
function lijecnickiFormHTML(l={}) {
return `
<div class="fld-row">
<div class="fld"><label>Član ID*</label><input id="f-clan_id" type="number" value="${l.clan_id||''}"></div>
<div class="fld"><label>Klub ID</label><input id="f-klub_id" type="number" value="${l.klub_id||''}"></div>
</div>
<div class="fld-row">
<div class="fld"><label>Datum pregleda*</label><input id="f-datum_pregleda" type="date" value="${(l.datum_pregleda||'').slice(0,10)}"></div>
<div class="fld"><label>Vrijedi do</label><input id="f-vrijedi_do" type="date" value="${(l.vrijedi_do||'').slice(0,10)}"></div>
</div>
<div class="fld-row">
<div class="fld"><label>Vrsta pregleda</label>
<select id="f-vrsta_pregleda">
<option value="">—</option>
${['osnovni','prosireni','specijalisticki','kontrolni','povratak_nakon_ozljede'].map(x=>`<option value="${x}" ${l.vrsta_pregleda===x?'selected':''}>${x}</option>`).join('')}
</select>
</div>
<div class="fld"><label>Ustanova</label><input id="f-ustanova" value="${esc(l.ustanova||'')}"></div>
</div>
<div class="fld"><label>Liječnik</label><input id="f-lijecnik" value="${esc(l.lijecnik||'')}"></div>
<div class="fld-row">
<div class="fld"><label><input id="f-spreman" type="checkbox" ${l.spreman_za_natjecanje!==false?'checked':''}> Spreman za natjecanje</label></div>
<div class="fld"><label><input id="f-placeno" type="checkbox" ${l.placeno?'checked':''}> Plaćeno</label></div>
</div>
<div class="fld-row">
<div class="fld"><label><input id="f-ekg" type="checkbox" ${l.ekg?'checked':''}> EKG</label></div>
<div class="fld"><label><input id="f-krv" type="checkbox" ${l.krv?'checked':''}> Krvna slika</label></div>
<div class="fld"><label><input id="f-spirometrija" type="checkbox" ${l.spirometrija?'checked':''}> Spirometrija</label></div>
</div>
<div class="fld-row">
<div class="fld"><label>Iznos ukupno (EUR)</label><input id="f-iznos" type="number" step="0.01" value="${l.iznos||''}"></div>
<div class="fld"><label>Datum plaćanja</label><input id="f-datum_placanja" type="date" value="${(l.datum_placanja||'').slice(0,10)}"></div>
</div>
<div class="fld-row">
<div class="fld"><label>ZZJZ EUR</label><input id="f-iznos_zzjz" type="number" step="0.01" value="${l.iznos_zzjz||0}"></div>
<div class="fld"><label>Klub EUR</label><input id="f-iznos_klub" type="number" step="0.01" value="${l.iznos_klub||0}"></div>
<div class="fld"><label>Član EUR</label><input id="f-iznos_clan" type="number" step="0.01" value="${l.iznos_clan||0}"></div>
</div>
<div class="fld"><label>Nalaz</label><textarea id="f-nalaz">${esc(l.nalaz||'')}</textarea></div>
<div class="fld"><label>Komentar liječnika</label><textarea id="f-komentar_lijecnika">${esc(l.komentar_lijecnika||'')}</textarea></div>
<div class="fld"><label>Napomena</label><textarea id="f-napomena">${esc(l.napomena||'')}</textarea></div>
`;
}
function readLijecnickiForm() {
return {
clan_id: parseInt(document.getElementById('f-clan_id').value)||null,
klub_id: parseInt(document.getElementById('f-klub_id').value)||null,
datum_pregleda: document.getElementById('f-datum_pregleda').value||null,
vrijedi_do: document.getElementById('f-vrijedi_do').value||null,
vrsta_pregleda: document.getElementById('f-vrsta_pregleda').value||null,
ustanova: document.getElementById('f-ustanova').value.trim()||null,
lijecnik: document.getElementById('f-lijecnik').value.trim()||null,
spreman_za_natjecanje: document.getElementById('f-spreman').checked,
ekg: document.getElementById('f-ekg').checked,
krv: document.getElementById('f-krv').checked,
spirometrija: document.getElementById('f-spirometrija').checked,
nalaz: document.getElementById('f-nalaz').value||null,
komentar_lijecnika: document.getElementById('f-komentar_lijecnika').value||null,
iznos: parseFloat(document.getElementById('f-iznos').value)||null,
iznos_zzjz: parseFloat(document.getElementById('f-iznos_zzjz').value)||0,
iznos_klub: parseFloat(document.getElementById('f-iznos_klub').value)||0,
iznos_clan: parseFloat(document.getElementById('f-iznos_clan').value)||0,
datum_placanja: document.getElementById('f-datum_placanja').value||null,
placeno: document.getElementById('f-placeno').checked,
napomena: document.getElementById('f-napomena').value||null,
};
}
function openLijecnickiModal(l) {
const isEdit = !!(l && l.id);
showModal(isEdit?'Uredi liječnički pregled':'Novi liječnički pregled',
lijecnickiFormHTML(l||{spreman_za_natjecanje:true}),
async () => {
const body = readLijecnickiForm();
if (!body.clan_id || !body.datum_pregleda) { toast('Član i datum su obavezni', 'err'); return; }
try {
if (isEdit) await api('/lijecnicki/'+l.id, {method:'PUT', body:JSON.stringify(body)});
else await api('/lijecnicki', {method:'POST', body:JSON.stringify(body)});
toast('Spremljeno'); closeModal(); loadLijecnicki();
} catch (e) { toast('Greška: '+e.message, 'err'); }
});
}
async function editLijecnicki(id) {
try { const l = await api('/lijecnicki/'+id); openLijecnickiModal(l); }
catch (e) { toast(e.message, 'err'); }
}
async function delLijecnicki(id) {
if (!confirm('Obrisati pregled #'+id+'?')) return;
try { await api('/lijecnicki/'+id, {method:'DELETE'}); toast('Obrisano'); loadLijecnicki(); }
catch (e) { toast(e.message, 'err'); }
}
// ────── Obrasci ──────
let OBR_TEMPLATES = [];
let OBR_SELECTED_TPL = null;
async function loadObrasciTemplates() {
try {
const data = await api('/obrasci');
OBR_TEMPLATES = data.items||[];
const list = document.getElementById('obr-tpl-list');
if (!OBR_TEMPLATES.length) {
list.innerHTML = '<div class="empty">Nema predložaka.</div>';
return;
}
list.innerHTML = OBR_TEMPLATES.map(t => `
<div class="tpl-row" data-id="${t.id}" onclick="selectObrasciTpl(${t.id})">
<div class="tpl-n">${esc(t.naziv)}</div>
<div class="tpl-c">${esc(t.kategorija||'—')} · ${esc(t.code)}</div>
</div>
`).join('');
document.getElementById('cnt-obrasci').textContent = OBR_TEMPLATES.length;
} catch (e) { toast('Obrasci err: '+e.message, 'err'); }
}
function selectObrasciTpl(id) {
OBR_SELECTED_TPL = OBR_TEMPLATES.find(t => t.id === id);
document.querySelectorAll('#obr-tpl-list .tpl-row').forEach(r =>
r.classList.toggle('sel', parseInt(r.dataset.id) === id));
document.getElementById('obr-right-title').textContent =
'Predložak: ' + (OBR_SELECTED_TPL?.naziv || '');
openObrasciSubmitModal();
}
function obrasciSubmitFormHTML(t) {
const sch = (t.schema_json && t.schema_json.fields) || [];
let inner = '';
if (sch.length) {
inner = sch.map((f, i) => {
const lbl = esc(f.label || f.name || ('Polje '+(i+1)));
const req = f.required ? '*' : '';
const name = esc(f.name || ('f'+i));
const type = f.type || 'text';
if (type === 'textarea') {
return `<div class="fld"><label>${lbl}${req}</label><textarea data-fname="${name}"></textarea></div>`;
}
if (type === 'select' && Array.isArray(f.options)) {
return `<div class="fld"><label>${lbl}${req}</label><select data-fname="${name}">
${f.options.map(o => `<option value="${esc(o)}">${esc(o)}</option>`).join('')}
</select></div>`;
}
const it = (type==='number'?'number': type==='date'?'date': type==='email'?'email':'text');
return `<div class="fld"><label>${lbl}${req}</label><input data-fname="${name}" type="${it}"></div>`;
}).join('');
} else {
inner = `<div class="fld"><label>Podaci (JSON)</label><textarea data-fname="__json" placeholder='{"polje":"vrijednost"}'></textarea></div>`;
}
return `
<div style="background:var(--bg3); padding:8px 10px; border-radius:4px; margin-bottom:10px; font-size:11px; color:var(--t2);">
<strong>${esc(t.naziv)}</strong> · ${esc(t.kategorija||'—')}<br>
${esc(t.opis||'')}
</div>
<div class="fld-row">
<div class="fld"><label>Klub ID</label><input id="f-sub-klub" type="number"></div>
<div class="fld"><label>Član ID</label><input id="f-sub-clan" type="number"></div>
</div>
${inner}
<div class="fld"><label>Status</label>
<select id="f-sub-status">
<option value="draft">draft</option>
<option value="submitted" selected>submitted</option>
</select>
</div>
`;
}
function openObrasciSubmitModal() {
const t = OBR_SELECTED_TPL; if (!t) return;
showModal('Podnesi: '+t.naziv, obrasciSubmitFormHTML(t), async () => {
const klub_id = parseInt(document.getElementById('f-sub-klub').value)||null;
const clan_id = parseInt(document.getElementById('f-sub-clan').value)||null;
const status = document.getElementById('f-sub-status').value;
const data = {};
document.querySelectorAll('#m-body [data-fname]').forEach(el => {
const k = el.dataset.fname;
if (k === '__json') {
try { Object.assign(data, JSON.parse(el.value||'{}')); }
catch(e) { toast('JSON nije validan', 'err'); throw e; }
} else {
data[k] = el.value;
}
});
try {
await api('/obrasci/submission', {method:'POST', body:JSON.stringify({
template_id: t.id, template_code: t.code, klub_id, clan_id, data, status
})});
toast('Obrazac podnesen'); closeModal(); loadObrasciSubmissions();
} catch (e) { toast('Greška: '+e.message, 'err'); }
});
}
async function loadObrasciSubmissions() {
const st = document.getElementById('obr-status').value;
const klb = document.getElementById('obr-klub').value.trim();
const qs = new URLSearchParams();
if (st) qs.set('status', st);
if (klb) qs.set('klub_id', klb);
try {
const data = await api('/obrasci/submission?'+qs.toString());
const tb = document.querySelector('#t-obr-sub tbody');
const admin = isAdminUser();
tb.innerHTML = (data.items||[]).map(s => `
<tr onclick="openObrasciSubDetail(${s.id})">
<td><strong>#${s.id}</strong></td>
<td>${esc(s.template_naziv||s.template_code||'—')}</td>
<td>${esc(s.klub_naziv||'—')}</td>
<td>${esc(s.clan_naziv||'—')}</td>
<td><span class="chip ${s.status}">${s.status}</span></td>
<td>${fmtDT(s.submitted_at)}</td>
<td>${fmtDT(s.approved_at)}</td>
<td>${admin && (s.status==='submitted'||s.status==='draft') ? `
<button class="btn sm gold" onclick="event.stopPropagation();subStatus(${s.id},'approved')">✓</button>
<button class="btn sm danger" onclick="event.stopPropagation();subStatus(${s.id},'rejected')">×</button>
` : ''}</td>
</tr>
`).join('') || '<tr><td colspan="8" class="empty">Nema podnesenih obrazaca.</td></tr>';
} catch (e) { toast('Submissions err: '+e.message, 'err'); }
}
async function openObrasciSubDetail(id) {
try {
const s = await api('/obrasci/submission/'+id);
const admin = isAdminUser();
const dataPretty = JSON.stringify(s.data||{}, null, 2);
const body = `
<div style="background:var(--bg3); padding:9px 11px; border-radius:4px; margin-bottom:10px; font-size:11px;">
<div><strong>${esc(s.template_naziv||'—')}</strong> <span style="color:var(--t3)">(${esc(s.template_code||'')})</span></div>
<div style="margin-top:3px; color:var(--t2);">
Klub: ${esc(s.klub_naziv||'—')} · Član: ${esc(s.clan_naziv||'—')}<br>
Submitter: ${esc(s.submitter_email||'—')} · Status: <span class="chip ${s.status}">${s.status}</span>
</div>
<div style="margin-top:4px; color:var(--t3); font-family:var(--mono); font-size:10px;">
submitted: ${fmtDT(s.submitted_at)} · reviewed: ${fmtDT(s.reviewed_at)} · approved: ${fmtDT(s.approved_at)}
${s.rejected_reason ? '<br>rejected_reason: '+esc(s.rejected_reason) : ''}
</div>
</div>
<div class="fld"><label>Podaci (JSON)</label>
<textarea readonly style="font-family:var(--mono); min-height:200px;">${esc(dataPretty)}</textarea>
</div>
`;
document.getElementById('m-title').textContent = 'Obrazac #'+s.id;
document.getElementById('m-body').innerHTML = body;
const foot = document.getElementById('m-foot');
foot.innerHTML = `<button class="btn" onclick="closeModal()">Zatvori</button>` +
(admin && (s.status==='submitted'||s.status==='draft') ? `
<button class="btn danger" onclick="subStatusFromModal(${s.id},'rejected')">Odbij</button>
<button class="btn primary" onclick="subStatusFromModal(${s.id},'approved')">Odobri</button>
` : '') +
((s.status==='draft' && (CURRENT_USER && (CURRENT_USER.user_id===s.user_id || admin))) ?
`<button class="btn gold" onclick="subStatusFromModal(${s.id},'submitted')">Pošalji</button>` : '');
document.getElementById('modal').classList.add('on');
} catch (e) { toast(e.message, 'err'); }
}
async function subStatusFromModal(id, status) {
let reason = null;
if (status === 'rejected') {
reason = prompt('Razlog odbijanja:'); if (reason === null) return;
}
try {
await api('/obrasci/submission/'+id+'/status', {
method:'PUT', body:JSON.stringify({status, rejected_reason: reason})
});
toast('Status: '+status);
closeModal();
// Restore footer
document.getElementById('m-foot').innerHTML = `
<button class="btn" onclick="closeModal()">Odustani</button>
<button class="btn primary" id="m-save">Spremi</button>`;
loadObrasciSubmissions();
} catch (e) { toast('Greška: '+e.message, 'err'); }
}
async function subStatus(id, status) {
let reason = null;
if (status === 'rejected') {
reason = prompt('Razlog odbijanja:'); if (reason === null) return;
}
try {
await api('/obrasci/submission/'+id+'/status', {
method:'PUT', body:JSON.stringify({status, rejected_reason: reason})
});
toast('Status: '+status); loadObrasciSubmissions();
} catch (e) { toast('Greška: '+e.message, 'err'); }
}
// ────── Modal helpers ──────
function showModal(title, bodyHTML, onSave) {
document.getElementById('m-title').textContent = title;
document.getElementById('m-body').innerHTML = bodyHTML;
// Restore the standard footer (it may have been replaced by detail views)
document.getElementById('m-foot').innerHTML =
'<button class="btn" onclick="closeModal()">Odustani</button>' +
'<button class="btn primary" id="m-save">Spremi</button>';
document.getElementById('m-save').onclick = onSave;
document.getElementById('modal').classList.add('on');
}
@@ -1206,6 +1648,7 @@ document.getElementById('modal').addEventListener('click', e => {
// ────── Init ──────
loadMe();
ensureMe();
loadPipeline();
</script>
</body>
+6
View File
@@ -490,6 +490,12 @@ table.dt tr:hover td { background:rgba(0,212,255,.025) }
<div class="nav-a" onclick="G('chat')">AI Chat</div>
</div>
<div class="nav-right">
<a href="/api/v2/export/sportasi" download
title="Preuzmi XLSX svih sportaša (PGŽ + HNS data)"
style="text-decoration:none;color:#fff;background:#1a1f2e;border:1px solid #3a4356;padding:6px 10px;border-radius:4px;font-size:11px;letter-spacing:.04em;margin-right:8px;">📊 Export sportaši</a>
<a href="/api/v2/export/klubovi-roster" download
title="Preuzmi XLSX rostera po svim klubovima (multi-sheet)"
style="text-decoration:none;color:#fff;background:#1a1f2e;border:1px solid #3a4356;padding:6px 10px;border-radius:4px;font-size:11px;letter-spacing:.04em;margin-right:10px;">📊 Export klubovi</a>
<div class="live-pill"><div class="live-dot"></div>LIVE DATA</div>
</div>
</nav>
+31
View File
@@ -274,6 +274,37 @@
el.classList.toggle('active', el.dataset.id===h);
});
});
// ─── notif bell badge polling (30s) ───
try {
const refreshBadge = async () => {
try {
const tok = readToken();
const r = await fetch('/sport/api/v2/notif/count', {
headers: tok ? {'Authorization':'Bearer '+tok} : {}
});
if(!r.ok) return;
const d = await r.json();
const n = (d && d.unread) || 0;
const el = document.querySelector('#pgz-sb .pgz-nav-i[data-id="notif"]');
if(!el) return;
let b = el.querySelector('.badge.notif-badge');
if(n > 0){
if(!b){
b = document.createElement('span');
b.className = 'badge notif-badge';
b.style.cssText = 'background:#dc2626;color:#fff;border-radius:10px;padding:1px 6px;font-size:10px;font-weight:700;margin-left:auto';
el.appendChild(b);
}
b.textContent = String(n);
} else if(b){
b.remove();
}
} catch(e){}
};
refreshBadge();
setInterval(refreshBadge, 30000);
} catch(e){}
},
toggle(){