From ce544e660cd6098630991ccdf411db213e3ce1c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damir=20Raduli=C4=87?= Date: Tue, 5 May 2026 13:55:33 +0200 Subject: [PATCH] 7-sub sprint UI residual: footer login + kalendar CRUD + notif center + CRM extra tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- static/app.html | 123 +++++++++++ static/crm_v2.html | 443 +++++++++++++++++++++++++++++++++++++++ static/index.html | 6 + static/shared/sidebar.js | 31 +++ 4 files changed, 603 insertions(+) diff --git a/static/app.html b/static/app.html index e7015fb..4c1cc86 100644 --- a/static/app.html +++ b/static/app.html @@ -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 + ? `↗ Otvori` + : ''; + const readBtn = n.is_read + ? `Pročitano` + : ``; + return ` +
+
+
${k.ic} ${k.label}
+
+
+
${esc(n.title || n.subject || '—')}
+
${esc((n.body||'').substring(0,400))}${(n.body||'').length>400?'…':''}
+
${esc(_notifRel(n.created_at))}${n.user_id?' · za korisnika #'+n.user_id:' · sustavna obavijest'}
+
+
+ ${linkBtn} + ${readBtn} + +
+
`; + }; + + return ` +
+
+
🔔 Notifikacijski centar ${rows.length} ukupno ${unread?`${unread} nepročitano`:''}
+
+ + +
+
+
+ ${rows.length ? rows.map(card).join('') : '
Nema notifikacija.
'} +
+
`; +} +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'] = () => `
⚠ Forenzika — sumnjive transakcije
diff --git a/static/crm_v2.html b/static/crm_v2.html index bf3448a..2181861 100644 --- a/static/crm_v2.html +++ b/static/crm_v2.html @@ -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 => ` + + ${esc(c.clan_naziv||'—')} #${c.clan_id||''} + ${esc(c.klub_naziv||'—')} + ${c.godina} + ${esc(c.razdoblje||'—')} + ${fmtEur(c.iznos_propisan)} + ${fmtEur(c.iznos_placen)} + ${fmtDate(c.datum_uplate)} + ${c.status} + ${isAdminUser() ? `` : ''} + + `).join('') || 'Nema članarina za odabrane filtere.'; + document.getElementById('cnt-clanarine').textContent = data.count ?? (data.items||[]).length; + } catch (e) { toast('Članarine err: '+e.message, 'err'); } +} +function clanarinaFormHTML(c={}) { + return ` +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+ `; +} +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 ` + + ${esc(l.clan_naziv||'—')} #${l.clan_id||''} + ${esc(l.klub_naziv||'—')} + ${fmtDate(l.datum_pregleda)} + ${esc(l.vrsta_pregleda||'—')} + ${fmtDate(l.vrijedi_do)} ${expired?'istekao':''} + ${esc(l.lijecnik||'—')} + ${l.spreman_za_natjecanje ? 'DA' : 'NE'} + ${l.placeno ? 'DA' : 'NE'} + ${isAdminUser() ? `` : ''} + `; + }).join('') || 'Nema liječničkih pregleda.'; + document.getElementById('cnt-lijecnicki').textContent = data.count ?? (data.items||[]).length; + } catch (e) { toast('Liječnički err: '+e.message, 'err'); } +} +function lijecnickiFormHTML(l={}) { + return ` +
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `; +} +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 = '
Nema predložaka.
'; + return; + } + list.innerHTML = OBR_TEMPLATES.map(t => ` +
+
${esc(t.naziv)}
+
${esc(t.kategorija||'—')} · ${esc(t.code)}
+
+ `).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 `
`; + } + if (type === 'select' && Array.isArray(f.options)) { + return `
`; + } + const it = (type==='number'?'number': type==='date'?'date': type==='email'?'email':'text'); + return `
`; + }).join(''); + } else { + inner = `
`; + } + return ` +
+ ${esc(t.naziv)} · ${esc(t.kategorija||'—')}
+ ${esc(t.opis||'')} +
+
+
+
+
+ ${inner} +
+ +
+ `; +} + +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 => ` + + #${s.id} + ${esc(s.template_naziv||s.template_code||'—')} + ${esc(s.klub_naziv||'—')} + ${esc(s.clan_naziv||'—')} + ${s.status} + ${fmtDT(s.submitted_at)} + ${fmtDT(s.approved_at)} + ${admin && (s.status==='submitted'||s.status==='draft') ? ` + + + ` : ''} + + `).join('') || 'Nema podnesenih obrazaca.'; + } 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 = ` +
+
${esc(s.template_naziv||'—')} (${esc(s.template_code||'')})
+
+ Klub: ${esc(s.klub_naziv||'—')} · Član: ${esc(s.clan_naziv||'—')}
+ Submitter: ${esc(s.submitter_email||'—')} · Status: ${s.status} +
+
+ submitted: ${fmtDT(s.submitted_at)} · reviewed: ${fmtDT(s.reviewed_at)} · approved: ${fmtDT(s.approved_at)} + ${s.rejected_reason ? '
rejected_reason: '+esc(s.rejected_reason) : ''} +
+
+
+ +
+ `; + document.getElementById('m-title').textContent = 'Obrazac #'+s.id; + document.getElementById('m-body').innerHTML = body; + const foot = document.getElementById('m-foot'); + foot.innerHTML = `` + + (admin && (s.status==='submitted'||s.status==='draft') ? ` + + + ` : '') + + ((s.status==='draft' && (CURRENT_USER && (CURRENT_USER.user_id===s.user_id || admin))) ? + `` : ''); + 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 = ` + + `; + 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 = + '' + + ''; 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(); diff --git a/static/index.html b/static/index.html index 1019f18..20d1cd2 100644 --- a/static/index.html +++ b/static/index.html @@ -490,6 +490,12 @@ table.dt tr:hover td { background:rgba(0,212,255,.025) }
diff --git a/static/shared/sidebar.js b/static/shared/sidebar.js index 0712975..c0ad5f8 100644 --- a/static/shared/sidebar.js +++ b/static/shared/sidebar.js @@ -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(){