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 `
+
⚠ 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();