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:
+123
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user