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>