CC5 R5 UI: Kalendar + Stats + Notifs + bulk akcije + XLSX export

app.html — Kalendar sekcija (NOVO za sve role):
- Mjesečni grid (pon-ned), klik na dan = prikaz svih eventa
- KPI: liječnički isteci, manifestacije, neprocitano InApp, ukupno eventa
- Eventi: liječnički termini + manifestacije iz API + ZZJZ termin slot mock
- Navigacija prev/next + month picker
- "Scan isteke → notifikacije" gumb (one-click backend POST)
- Lista nadolazećih (10) + lista InApp neprocitanih s mark-read

crm.html — 2 nova taba:
- 📊 Statistika: aktivni vs neaktivni, reprezentativci, kategorizirani,
  članarine summary, liječnički status, SVG bar chart trend uplata 12 mj,
  podjela po spolu/kategoriji, top 10 najnovijih uplata
- 🔔 Notifikacije: lista InApp+Email s filterima (channel/status), gumb
  za scan liječničkih (kreira 30/15/7 + expired bucket), mark-read pojedinačno
  i bulk, deep-link na /lijecnicki/{id}/zakazi i /clanarine/{id}/uplatnica.pdf
  iz meta polja

Bulk akcije za clanarine (R5 #3):
- Checkbox po retku + master + "Sve nepladene" gumb
- Bulk bar pokazuje selected count + total dug
- "Pošalji opomenu" → POST /bulk/notify (sa specifičnim ids ili sve dužnike)
- "Generiraj uplatnice" → POST /bulk/uplatnice → modal s linkovima na PDF/QR

XLSX export (R5 #4):
- "📥 Export XLSX" gumb na Članovi tab → otvara /clanovi/export.xlsx
  s trenutnim filterima (klub_id, q)
This commit is contained in:
Damir Radulić
2026-05-05 01:36:45 +02:00
parent 6752ecaf07
commit 7e674ad1ec
2 changed files with 389 additions and 8 deletions
+125
View File
@@ -364,6 +364,7 @@ const NAV_BY_ROLE = {
{id:'financije', ic:'€', label:'Financije'},
{id:'racuni', ic:'\u{1F9FE}', label:'Računi (OCR)'},
{id:'crm', ic:'\u{1F4DD}', label:'CRM'},
{id:'kalendar', ic:'\u{1F4C5}', label:'Kalendar'},
{id:'audit', ic:'\u{1F50D}', label:'Audit log'},
{id:'forenzika', ic:'⚠', label:'Forenzika', badge:11},
],
@@ -384,6 +385,7 @@ const NAV_BY_ROLE = {
{id:'clanarine', ic:'€', label:'Članarine'},
{id:'lijecnicki',ic:'⚕', label:'Liječnički'},
{id:'dokumenti', ic:'\u{1F4C4}', label:'Dokumenti'},
{id:'kalendar', ic:'\u{1F4C5}', label:'Kalendar'},
{id:'manifestacije', ic:'\u{1F4C5}', label:'Manifestacije'},
{id:'racuni', ic:'\u{1F9FE}', label:'Računi (OCR)'},
],
@@ -394,6 +396,7 @@ const NAV_BY_ROLE = {
{id:'lijecnicki',ic:'⚕', label:'Liječnički'},
{id:'dokumenti', ic:'\u{1F4C4}', label:'Moji dokumenti'},
{id:'obrasci', ic:'\u{1F4DD}', label:'Obrasci', badge:1},
{id:'kalendar', ic:'\u{1F4C5}', label:'Kalendar'},
{id:'manifestacije', ic:'\u{1F4C5}', label:'Manifestacije'},
],
};
@@ -653,6 +656,7 @@ const TITLES = {
financije:['Financije','Sufinanciranje sporta'],
racuni:['Računi (OCR)','OCR upload + obrada'],
crm:['CRM','Članarine + liječnički'],
kalendar:['Kalendar','Liječnički termini, manifestacije, eventi'],
audit:['Audit log','Sve aktivnosti sustava'],
forenzika:['Forenzika','Sumnjive transakcije / PEP'],
},
@@ -673,6 +677,7 @@ const TITLES = {
clanarine:['Članarine','Stanje članarina'],
lijecnicki:['Liječnički','Pregledi članova'],
dokumenti:['Dokumenti','Dokumenti kluba'],
kalendar:['Kalendar','Liječnički termini + manifestacije'],
manifestacije:['Manifestacije','Nadolazeće aktivnosti'],
racuni:['Računi','Troškovi kluba'],
},
@@ -683,6 +688,7 @@ const TITLES = {
lijecnicki:['Liječnički','Moj liječnički pregled'],
dokumenti:['Moji dokumenti','Suglasnosti, ugovori'],
obrasci:['Obrasci','Za potpis'],
kalendar:['Kalendar','Moji termini i događaji'],
manifestacije:['Manifestacije','Moje aktivnosti'],
},
};
@@ -1189,6 +1195,125 @@ SECTIONS['pgz:audit'] = () => `
).join('')}
</div>`;
// =======================================================================
// CC5 R5 — KALENDAR (liječnički termini + manifestacije + eventi)
// =======================================================================
async function renderKalendar(opts){
opts = opts || {};
const today = new Date();
const ym = opts.ym || (today.getFullYear() + '-' + String(today.getMonth()+1).padStart(2,'0'));
const [Y, M] = ym.split('-').map(Number);
const first = new Date(Y, M-1, 1);
const last = new Date(Y, M, 0);
// Učitaj sve liječničke koji ističu unutar +180 dana, manifestacije iz API-ja, i mock eventove
let lij = [], manif = [], notif = [];
try { const d = await fetch('/sport/api/crm/lijecnicki/uskoro-isticu?days=180&include_expired=false').then(r=>r.json()); lij = d.rows || []; } catch(e){}
try { const d = await fetch('/sport/api/manifestacije').then(r=>r.json()); manif = d.rows || d || []; } catch(e){}
try { const d = await fetch('/sport/api/crm/notifications?limit=50').then(r=>r.json()); notif = d.rows || []; } catch(e){}
const events = [];
lij.forEach(l => events.push({date: l.vrijedi_do, type:'lij', title:`⚕ Pregled ističe: ${l.clan}`, klub:l.klub, color:'a'}));
manif.forEach(m => { if (m.datum) events.push({date: m.datum, type:'manif', title:`📅 ${m.naziv || m.title || 'Manifestacija'}`, klub:m.lokacija || m.grad, color:'b'}); });
// Eventi: ZZJZ termini mock — sljedećih 7 dana po radnim danima
for(let d=0; d<14; d++){
const dt = new Date(); dt.setDate(dt.getDate()+d);
if (dt.getDay()===0 || dt.getDay()===6) continue;
if ((dt.getDate() + d) % 5 === 0) {
events.push({date: dt.toISOString().slice(0,10), type:'event', title:'🏥 ZZJZ termin slot (mock)', color:'g'});
}
}
// KPI / sažetak
const cntLij = lij.length, cntManif = manif.length, cntNotif = notif.filter(n=>!n.read_at && n.channel==='inapp').length;
// Group events by date
const byDate = {};
events.forEach(e => {
if (!e.date) return;
const k = String(e.date).substring(0,10);
(byDate[k] = byDate[k] || []).push(e);
});
// Header s navigacijom
const prevYm = (() => { const d = new Date(Y, M-2, 1); return d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0'); })();
const nextYm = (() => { const d = new Date(Y, M, 1); return d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0'); })();
const monthName = first.toLocaleString('hr-HR', {month:'long', year:'numeric'});
// Build kalendar grid (start ponedjeljak)
let firstDow = first.getDay(); if (firstDow === 0) firstDow = 7; // pon=1, ned=7
const blanks = firstDow - 1;
const days = last.getDate();
let grid = '';
const dayNames = ['Pon','Uto','Sri','Čet','Pet','Sub','Ned'];
grid += `<div style="display:grid;grid-template-columns:repeat(7,1fr);gap:6px">`;
dayNames.forEach(d => grid += `<div style="font-size:11px;color:var(--t3);text-align:center;font-weight:600;text-transform:uppercase;padding:4px 0">${d}</div>`);
for(let i=0; i<blanks; i++) grid += `<div></div>`;
for(let d=1; d<=days; d++){
const k = `${Y}-${String(M).padStart(2,'0')}-${String(d).padStart(2,'0')}`;
const ev = byDate[k] || [];
const isToday = (k === today.toISOString().slice(0,10));
const evHtml = ev.slice(0,3).map(e => `<div style="font-size:10px;background:rgba(${e.color==='a'?'245,158,11':e.color==='b'?'26,115,232':'34,197,94'},0.18);padding:2px 4px;border-radius:3px;margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(e.title)}${e.klub?' — '+esc(e.klub):''}">${esc(e.title.substring(0,28))}</div>`).join('');
const more = ev.length > 3 ? `<div style="font-size:9px;color:var(--t3);margin-top:2px">+${ev.length-3} više</div>` : '';
grid += `<div style="background:${isToday?'rgba(26,115,232,0.15)':'var(--bg2)'};border:1px solid ${isToday?'var(--pgz-blue)':'var(--rim)'};border-radius:6px;padding:6px;min-height:90px;${ev.length?'cursor:pointer':''}" ${ev.length?`onclick="alert('${esc(ev.map(x=>x.title+(x.klub?' — '+x.klub:'')).join('\\n').replace(/'/g,'\\\\\\'')\)}')"`:''}><div style="font-weight:600;font-size:13px;color:${isToday?'var(--pgz-blue)':'var(--t1)'}">${d}</div>${evHtml}${more}</div>`;
}
grid += '</div>';
// Lista nadolazećih (top 10)
const upcoming = events.filter(e => e.date && e.date >= today.toISOString().slice(0,10))
.sort((a,b) => a.date.localeCompare(b.date)).slice(0, 10);
const upcomingHtml = upcoming.map(e => `<tr><td>${esc(e.date)}</td><td>${esc(e.title)}</td><td>${esc(e.klub||'')}</td><td><span class="tag ${e.color==='a'?'am':e.color==='b'?'bl':'gr'}">${e.type}</span></td></tr>`).join('');
return `
<div class="kpi-grid" style="margin-bottom:12px">
<div class="kpi a"><div class="kpi-l">⚕ Liječnički isteci</div><div class="kpi-v">${cntLij}</div><div class="kpi-s">≤ 180 dana</div></div>
<div class="kpi b"><div class="kpi-l">📅 Manifestacije</div><div class="kpi-v">${cntManif}</div></div>
<div class="kpi r"><div class="kpi-l">🔔 InApp neprocitano</div><div class="kpi-v">${cntNotif}</div></div>
<div class="kpi g"><div class="kpi-l">Eventa u kalendaru</div><div class="kpi-v">${events.length}</div></div>
</div>
<div class="card">
<div class="card-h">
<div class="card-t">📅 ${esc(monthName)}</div>
<div class="card-actions" style="display:flex;gap:6px;align-items:center">
<button class="btn sm" onclick="$('#content').innerHTML='<div class=loading>Učitavanje...</div>';renderKalendar({ym:'${prevYm}'}).then(h=>$('#content').innerHTML=h)">←</button>
<input type="month" value="${ym}" onchange="$('#content').innerHTML='<div class=loading>...</div>';renderKalendar({ym:this.value}).then(h=>$('#content').innerHTML=h)" style="background:var(--bg2);border:1px solid var(--rim);color:var(--t1);padding:4px 8px;border-radius:4px">
<button class="btn sm" onclick="$('#content').innerHTML='<div class=loading>Učitavanje...</div>';renderKalendar({ym:'${nextYm}'}).then(h=>$('#content').innerHTML=h)">→</button>
<button class="btn primary sm" onclick="fetch('/sport/api/crm/lijecnicki/notify-scan',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'}).then(r=>r.json()).then(d=>alert('Skenirano: '+d.created+' notifikacija kreirano'))">🔔 Scan isteke → notifikacije</button>
</div>
</div>
<div style="padding:14px">${grid}</div>
</div>
<div class="card">
<div class="card-h"><div class="card-t">📋 Nadolazeći eventi (10)</div></div>
<table>
<thead><tr><th>Datum</th><th>Naziv</th><th>Lokacija/Klub</th><th>Tip</th></tr></thead>
<tbody>${upcomingHtml || '<tr><td colspan="4" class="empty">Nema nadolazećih eventa.</td></tr>'}</tbody>
</table>
</div>
<div class="card">
<div class="card-h"><div class="card-t">🔔 Aktivne InApp notifikacije (10)</div>
<div class="card-actions"><button class="btn sm" onclick="fetch('/sport/api/crm/notifications/mark-all-read',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({channel:'inapp'})}).then(r=>r.json()).then(d=>{alert('Označeno '+d.marked_read+' kao pročitano');loadSection();})">Označi sve pročitano</button></div>
</div>
<div style="padding:8px 14px">
${notif.filter(n=>!n.read_at && n.channel==='inapp').slice(0,10).map(n => `
<div style="display:flex;gap:10px;align-items:start;padding:8px 0;border-bottom:1px solid var(--rim)">
<div style="font-size:18px">${n.subject.includes('ISTEKAO')?'⚠':'⚕'}</div>
<div style="flex:1">
<div style="font-weight:600;font-size:13px">${esc(n.subject)}</div>
<div style="font-size:11px;color:var(--t3);margin-top:2px">${esc((n.body||'').substring(0,140))}…</div>
</div>
<button class="btn sm" onclick="fetch('/sport/api/crm/notifications/${n.id}/read',{method:'POST'}).then(()=>loadSection())">✓</button>
</div>`).join('') || '<div class="empty">Nema neprocitanih notifikacija. Pokreni "Scan isteke" da generiraš nove.</div>'}
</div>
</div>
`;
}
SECTIONS['pgz:kalendar'] = renderKalendar;
SECTIONS['savez:kalendar'] = renderKalendar;
SECTIONS['klub:kalendar'] = renderKalendar;
SECTIONS['sportas:kalendar'] = renderKalendar;
SECTIONS['pgz:forenzika'] = () => `
<div class="card">
<div class="card-h"><div class="card-t">⚠ Forenzika — sumnjive transakcije</div></div>
+264 -8
View File
@@ -156,6 +156,8 @@ table tr:hover td { background: rgba(26, 115, 232, 0.05); }
<div class="tab" data-tab="clanarine" onclick="setTab('clanarine')">€ Članarine <span class="count" id="cnt-clanarine"></span></div>
<div class="tab" data-tab="lijecnicki" onclick="setTab('lijecnicki')">⚕ Liječnički pregledi <span class="count" id="cnt-lijecnicki"></span></div>
<div class="tab" data-tab="obrasci" onclick="setTab('obrasci')">📝 Obrasci <span class="count" id="cnt-obrasci"></span></div>
<div class="tab" data-tab="stats" onclick="setTab('stats')">📊 Statistika</div>
<div class="tab" data-tab="notifs" onclick="setTab('notifs')">🔔 Notifikacije <span class="count" id="cnt-notifs"></span></div>
<div style="margin-left:auto;display:flex;align-items:center;gap:8px;padding:0 14px">
<span style="font-size:11px;color:var(--t3)">ROLA:</span>
<select id="g-role" onchange="setRole(this.value)" style="background:var(--bg3);border:1px solid var(--rim);color:var(--t1);padding:4px 8px;border-radius:4px;font-size:12px">
@@ -174,6 +176,8 @@ table tr:hover td { background: rgba(26, 115, 232, 0.05); }
<div id="page-clanarine" class="page" style="display:none"></div>
<div id="page-lijecnicki" class="page" style="display:none"></div>
<div id="page-obrasci" class="page" style="display:none"></div>
<div id="page-stats" class="page" style="display:none"></div>
<div id="page-notifs" class="page" style="display:none"></div>
</div>
<div id="modal-bg" class="modal-bg" onclick="if(event.target===this)closeModal()">
@@ -252,6 +256,8 @@ function setTab(name) {
if (name === 'clanarine') loadClanarine();
if (name === 'lijecnicki') loadLijecnicki();
if (name === 'obrasci') loadObrasci();
if (name === 'stats') loadStats();
if (name === 'notifs') loadNotifs();
}
// ════════════════════════════════════════════════════
@@ -286,11 +292,22 @@ async function loadClanarine() {
<input id="cl-godina" type="number" placeholder="Godina" min="2020" max="2030" onchange="loadClanarineFiltered()">
<input id="cl-klub" type="number" placeholder="Klub ID" onchange="loadClanarineFiltered()">
<div class="grow"></div>
<button class="btn primary" onclick="bulkNotify()">📧 Notify dužnike</button>
<button class="btn" onclick="selectAllUnpaid()">☑ Sve nepladene</button>
<button class="btn primary" onclick="bulkNotifySelected()">📧 Pošalji opomenu</button>
<button class="btn" onclick="bulkUplatniceSelected()">📄 Generiraj uplatnice</button>
<button class="btn" onclick="newClanarinaModal()">+ Novo zaduženje</button>
</div>
<div id="cl-bulkbar" style="display:none;background:var(--bg3);border:1px solid var(--pgz-blue);border-radius:6px;padding:8px 14px;margin-bottom:10px;align-items:center;gap:14px">
<span><b id="cl-selcount">0</b> odabrano</span>
<span style="color:var(--t3)">·</span>
<span>Ukupno dug: <b id="cl-seldug">0,00 €</b></span>
<div style="margin-left:auto;display:flex;gap:6px">
<button class="btn sm" onclick="clearSelection()">Poništi</button>
</div>
</div>`;
const rows = (data.rows || []).map(r => `
<tr>
const rowHtml = r => `
<tr data-id="${r.id}" data-dug="${r.dug||0}" data-paid="${r.dug<=0?1:0}">
<td><input type="checkbox" class="cl-cb" onchange="updateBulkBar()"></td>
<td><b>${esc(r.clan)}</b><div style="font-size:11px;color:var(--t3)">${esc(r.klub || '')}</div></td>
<td>${esc(r.godina)}</td>
<td>${esc(r.razdoblje || '')}</td>
@@ -303,18 +320,83 @@ async function loadClanarine() {
<button class="btn sm" onclick="openUplata(${r.id})" title="Registriraj uplatu">+€</button>
<a class="btn sm" href="${API}/clanarine/${r.id}/uplatnica.pdf" target="_blank" title="HUB-3 PDF">📄</a>
</td>
</tr>`).join('');
</tr>`;
const rows = (data.rows || []).map(rowHtml).join('');
root.innerHTML = kpi + tools + `
<div class="card">
<div class="card-h"><div class="card-t">Lista članarina (${data.count})</div></div>
<table>
<thead><tr><th>Sportaš/Klub</th><th>God.</th><th>Razdoblje</th><th>Propisan</th><th>Plaćeno</th><th>Dug</th><th>Status</th><th></th></tr></thead>
<tbody>${rows || '<tr><td colspan="8" class="empty">Nema zapisa.</td></tr>'}</tbody>
<thead><tr><th style="width:30px"><input type="checkbox" id="cl-cb-all" onchange="toggleAllCheckboxes(this.checked)"></th><th>Sportaš/Klub</th><th>God.</th><th>Razdoblje</th><th>Propisan</th><th>Plaćeno</th><th>Dug</th><th>Status</th><th></th></tr></thead>
<tbody>${rows || '<tr><td colspan="9" class="empty">Nema zapisa.</td></tr>'}</tbody>
</table>
</div>`;
}
function toggleAllCheckboxes(checked) {
$$('#page-clanarine .cl-cb').forEach(cb => cb.checked = checked);
updateBulkBar();
}
function selectAllUnpaid() {
let n = 0;
$$('#page-clanarine tr[data-id]').forEach(tr => {
const cb = tr.querySelector('.cl-cb');
if (tr.dataset.paid === '0') { cb.checked = true; n++; } else cb.checked = false;
});
updateBulkBar();
toast(`Odabrano ${n} nepladenih.`);
}
function clearSelection() {
$$('#page-clanarine .cl-cb').forEach(cb => cb.checked = false);
const all = $('#cl-cb-all'); if (all) all.checked = false;
updateBulkBar();
}
function getSelectedClanarine() {
const rows = $$('#page-clanarine tr[data-id]').filter(tr => tr.querySelector('.cl-cb')?.checked);
return rows.map(tr => ({id: parseInt(tr.dataset.id), dug: parseFloat(tr.dataset.dug || 0)}));
}
function updateBulkBar() {
const sel = getSelectedClanarine();
const bar = $('#cl-bulkbar');
if (!bar) return;
if (sel.length === 0) { bar.style.display = 'none'; return; }
bar.style.display = 'flex';
$('#cl-selcount').textContent = sel.length;
$('#cl-seldug').textContent = fmtEur(sel.reduce((a,b) => a+b.dug, 0));
}
async function bulkNotifySelected() {
const sel = getSelectedClanarine();
if (sel.length === 0) {
if (!confirm('Nije odabrano ništa. Pošalji opomene SVIM dužnicima?')) return;
return doBulkNotify({});
}
if (!confirm(`Pošalji opomenu za ${sel.length} odabrane članarine?`)) return;
return doBulkNotify({ids: sel.map(s => s.id)});
}
async function doBulkNotify(body) {
try {
const r = await api('/clanarine/bulk/notify', {method:'POST', body});
toast(`✓ Matched ${r.matched}, queued ${r.queued_inapp} InApp + ${r.queued_email} Email.`);
} catch (e) { toast('Greška: ' + e.message, true); }
}
async function bulkUplatniceSelected() {
const sel = getSelectedClanarine();
const body = sel.length ? {ids: sel.map(s => s.id)} : {};
try {
const r = await api('/clanarine/bulk/uplatnice', {method:'POST', body});
if (r.count === 0) { toast('Nema uplatnica.'); return; }
openModal(`
<div class="modal-h"><div class="modal-t">📄 Generirane uplatnice (${r.count}) — ukupno ${fmtEur(r.total_dug_eur)}</div><div class="modal-x" onclick="closeModal()">×</div></div>
<div class="modal-b">
<p style="color:var(--t2);font-size:12px">Klikom na PDF/QR otvarate uplatnicu u novom tabu. Svaka se generira on-demand.</p>
<table><thead><tr><th>Klub</th><th>Sportaš</th><th>God.</th><th>Iznos</th><th>Akcije</th></tr></thead>
<tbody>${r.uplatnice.map(u => `<tr><td>${esc(u.klub||'—')}</td><td>${esc(u.clan)}</td><td>${esc(u.godina)}</td><td><b>${fmtEur(u.iznos_eur)}</b></td><td><a class="btn sm" href="${esc(u.pdf_url)}" target="_blank">📄 PDF</a> <a class="btn sm" href="${esc(u.qr_url)}" target="_blank">📱 QR</a></td></tr>`).join('')}</tbody></table>
</div>`);
} catch (e) { toast('Greška: ' + e.message, true); }
}
function statusTag(s) {
return ({nepodmireno:'rd', djelomicno:'am', podmireno:'gr', storno:'gy'})[s] || 'gy';
}
@@ -330,7 +412,8 @@ async function loadClanarineFiltered() {
const data = await api('/clanarine?' + params);
const tbody = $('#page-clanarine table tbody');
tbody.innerHTML = (data.rows || []).map(r => `
<tr>
<tr data-id="${r.id}" data-dug="${r.dug||0}" data-paid="${r.dug<=0?1:0}">
<td><input type="checkbox" class="cl-cb" onchange="updateBulkBar()"></td>
<td><b>${esc(r.clan)}</b><div style="font-size:11px;color:var(--t3)">${esc(r.klub || '')}</div></td>
<td>${esc(r.godina)}</td>
<td>${esc(r.razdoblje || '')}</td>
@@ -343,7 +426,8 @@ async function loadClanarineFiltered() {
<button class="btn sm" onclick="openUplata(${r.id})">+€</button>
<a class="btn sm" href="${API}/clanarine/${r.id}/uplatnica.pdf" target="_blank">📄</a>
</td>
</tr>`).join('') || '<tr><td colspan="8" class="empty">Nema zapisa.</td></tr>';
</tr>`).join('') || '<tr><td colspan="9" class="empty">Nema zapisa.</td></tr>';
clearSelection();
}
async function openPayment(id) {
@@ -1007,6 +1091,7 @@ async function loadClanovi() {
<input id="cl-q" type="text" placeholder="Pretraži po imenu / OIB-u (min 2 slova)…" style="min-width:340px;flex:1" oninput="searchClanovi(this.value)">
<input id="cl-klub-filter" type="number" placeholder="Klub ID (filter)" onchange="searchClanovi($('#cl-q').value)">
<div class="grow"></div>
<a class="btn primary" onclick="exportClanoviXlsx()">📥 Export XLSX</a>
<span style="font-size:11px;color:var(--t3)">Klik na karticu → puni dashboard člana</span>
</div>
<div id="cl-results"><div class="loading">Upišite ime za pretragu…</div></div>
@@ -1339,6 +1424,175 @@ async function uploadAvatar(cid) {
} catch (e) { toast('Greška upload-a: ' + e.message, true); }
}
function exportClanoviXlsx() {
const klub = $('#cl-klub-filter')?.value;
const q = $('#cl-q')?.value;
const params = new URLSearchParams();
if (klub) params.append('klub_id', klub);
if (q) params.append('q', q);
const url = API + '/clanovi/export.xlsx' + (params.toString() ? '?' + params : '');
window.open(url, '_blank');
toast('XLSX export pokrenut…');
}
// ════════════════════════════════════════════════════
// MODUL 5 — STATISTIKA (R5 #5)
// ════════════════════════════════════════════════════
async function loadStats() {
const root = $('#page-stats');
root.innerHTML = '<div class="loading">Učitavanje statistike…</div>';
let d;
try { d = await api('/stats'); } catch (e) { root.innerHTML = `<div class="empty">Greška: ${esc(e.message)}</div>`; return; }
const c = d.clanovi, cl = d.clanarine, lj = d.lijecnicki;
const aktPct = c.total ? Math.round(c.aktivni / c.total * 100) : 0;
const podPct = cl.total ? Math.round(cl.n_podmireno / cl.total * 100) : 0;
// Trend uplata bar chart (jednostavan SVG)
const tr = d.trend_uplata_12m || [];
const maxIz = Math.max(...tr.map(t => parseFloat(t.iznos_total || 0)), 1);
const barW = 50, gap = 8;
const barsHtml = tr.map((t, i) => {
const h = Math.round(parseFloat(t.iznos_total || 0) / maxIz * 160);
const x = i * (barW + gap) + 30;
return `<g><rect x="${x}" y="${180 - h}" width="${barW}" height="${h}" fill="url(#g1)" rx="3"/>
<text x="${x + barW/2}" y="195" text-anchor="middle" font-size="9" fill="#9aa3b6">${esc(t.mjesec.substring(2))}</text>
<text x="${x + barW/2}" y="${178 - h}" text-anchor="middle" font-size="9" fill="#e6e8ef">${fmt(Math.round(t.iznos_total))}</text></g>`;
}).join('');
const spolHtml = (d.po_spolu || []).map(s => `<tr><td>${esc(s.spol||'?')}</td><td><b>${fmt(s.n)}</b></td></tr>`).join('');
const katHtml = (d.po_kategoriji || []).map(k => `<tr><td>${esc(k.kategorija)}</td><td><b>${fmt(k.n)}</b></td></tr>`).join('');
const noviHtml = (d.najnovije_uplate || []).map(u => `<tr><td>${fmtDate(u.datum_uplate)}</td><td>${esc(u.clan)}</td><td>${esc(u.klub||'—')}</td><td>${esc(u.godina)}</td><td><b>${fmtEur(u.iznos_placen)}</b></td></tr>`).join('');
root.innerHTML = `
<div class="kpi-grid">
<div class="kpi g"><div class="kpi-l">Aktivni članovi</div><div class="kpi-v">${fmt(c.aktivni)}</div><div class="kpi-s">${aktPct}% od ${fmt(c.total)}</div></div>
<div class="kpi r"><div class="kpi-l">Neaktivni</div><div class="kpi-v">${fmt(c.neaktivni)}</div></div>
<div class="kpi a"><div class="kpi-l">Reprezentativci</div><div class="kpi-v">${fmt(c.reprezentativci)}</div></div>
<div class="kpi b"><div class="kpi-l">Kategorizirani</div><div class="kpi-v">${fmt(c.kategorizirani)}</div></div>
<div class="kpi"><div class="kpi-l">Stipendirani</div><div class="kpi-v">${fmt(c.stipendirani)}</div></div>
</div>
<div class="kpi-grid">
<div class="kpi g"><div class="kpi-l">Članarine podmirene</div><div class="kpi-v">${fmt(cl.n_podmireno)}</div><div class="kpi-s">${podPct}% od ${fmt(cl.total)}</div></div>
<div class="kpi a"><div class="kpi-l">Djelomično</div><div class="kpi-v">${fmt(cl.n_djelomicno)}</div></div>
<div class="kpi r"><div class="kpi-l">Nepodmireno</div><div class="kpi-v">${fmt(cl.n_nepodmireno)}</div></div>
<div class="kpi b"><div class="kpi-l">Plaćeno (€)</div><div class="kpi-v">${fmtEur(cl.placen)}</div></div>
<div class="kpi r"><div class="kpi-l">Dug (€)</div><div class="kpi-v">${fmtEur(cl.dug)}</div></div>
</div>
<div class="kpi-grid">
<div class="kpi g"><div class="kpi-l">Liječnički važeći</div><div class="kpi-v">${fmt(lj.vazeci)}</div></div>
<div class="kpi a"><div class="kpi-l">Uskoro istek</div><div class="kpi-v">${fmt(lj.uskoro)}</div></div>
<div class="kpi r"><div class="kpi-l">Istekli</div><div class="kpi-v">${fmt(lj.istekli)}</div></div>
</div>
<div class="card">
<div class="card-h"><div class="card-t">📈 Trend uplata članarina (zadnjih 12 mjeseci)</div></div>
<div style="padding:14px;text-align:center">
${tr.length ? `<svg viewBox="0 0 ${tr.length*(barW+gap)+60} 210" style="width:100%;max-width:900px">
<defs><linearGradient id="g1" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#1a73e8"/><stop offset="100%" stop-color="#1e3a8a"/></linearGradient></defs>
${barsHtml}
</svg>` : '<div class="empty">Nema podataka o uplatama u zadnjih 12 mjeseci.</div>'}
</div>
</div>
<div class="row" style="display:grid;grid-template-columns:1fr 1fr 1.4fr;gap:14px">
<div class="card">
<div class="card-h"><div class="card-t">👥 Po spolu</div></div>
<table><thead><tr><th>Spol</th><th>Broj</th></tr></thead><tbody>${spolHtml || '<tr><td colspan="2" class="empty">—</td></tr>'}</tbody></table>
</div>
<div class="card">
<div class="card-h"><div class="card-t">🏷 Top kategorije</div></div>
<table><thead><tr><th>Kategorija</th><th>Broj</th></tr></thead><tbody>${katHtml || '<tr><td colspan="2" class="empty">—</td></tr>'}</tbody></table>
</div>
<div class="card">
<div class="card-h"><div class="card-t">💸 Najnovije uplate</div></div>
<table><thead><tr><th>Datum</th><th>Sportaš</th><th>Klub</th><th>God.</th><th>Iznos</th></tr></thead><tbody>${noviHtml || '<tr><td colspan="5" class="empty">—</td></tr>'}</tbody></table>
</div>
</div>`;
}
// ════════════════════════════════════════════════════
// MODUL 6 — NOTIFIKACIJE (R5 #6)
// ════════════════════════════════════════════════════
async function loadNotifs() {
const root = $('#page-notifs');
root.innerHTML = '<div class="loading">Učitavanje notifikacija…</div>';
let d;
try { d = await api('/notifications?limit=200'); }
catch (e) { root.innerHTML = `<div class="empty">Greška: ${esc(e.message)}</div>`; return; }
$('#cnt-notifs').textContent = d.summary?.unread_inapp ?? d.count;
const tools = `
<div class="toolbar">
<button class="btn primary" onclick="scanLijecnicki()">🔄 Scan liječničke → kreiraj notifikacije (30/15/7 dana)</button>
<button class="btn" onclick="markAllReadUI()">✓ Označi sve pročitano</button>
<div class="grow"></div>
<select id="nf-channel" onchange="loadNotifs()" style="min-width:120px">
<option value="">Svi kanali</option>
<option value="inapp">InApp</option>
<option value="email">Email</option>
</select>
<select id="nf-status" onchange="loadNotifs()" style="min-width:120px">
<option value="">Svi statusi</option>
<option value="pending">Pending</option>
<option value="sent">Sent</option>
</select>
</div>`;
const kpi = `
<div class="kpi-grid">
<div class="kpi b"><div class="kpi-l">Ukupno</div><div class="kpi-v">${fmt(d.summary?.total)}</div></div>
<div class="kpi a"><div class="kpi-l">Pending</div><div class="kpi-v">${fmt(d.summary?.pending)}</div></div>
<div class="kpi g"><div class="kpi-l">Sent</div><div class="kpi-v">${fmt(d.summary?.sent)}</div></div>
<div class="kpi r"><div class="kpi-l">InApp neprocitano</div><div class="kpi-v">${fmt(d.summary?.unread_inapp)}</div></div>
</div>`;
const list = (d.rows || []).map(n => `
<div class="card" style="margin-bottom:8px;border-left:3px solid ${n.read_at ? 'var(--t3)' : (n.subject.includes('ISTEKAO') || n.subject.includes('Opomena') ? 'var(--err)' : 'var(--warn)')}">
<div class="card-b" style="padding:12px">
<div style="display:flex;justify-content:space-between;align-items:start;gap:10px">
<div style="flex:1">
<div style="font-weight:600">${esc(n.subject)}</div>
<div style="font-size:11px;color:var(--t3);margin-top:3px">${esc(fmtDate(n.scheduled_at))} · <span class="tag ${n.channel==='inapp'?'bl':'gr'}">${esc(n.channel)}</span> · <span class="tag ${n.status==='pending'?'am':'gr'}">${esc(n.status)}</span>${n.read_at ? ' · pročitano '+esc(fmtDate(n.read_at)) : ''}</div>
<div style="font-size:12px;color:var(--t2);margin-top:6px;white-space:pre-wrap">${esc((n.body||'').substring(0,260))}${(n.body||'').length>260?'…':''}</div>
${n.meta?.zakazi_url ? `<a class="btn sm primary" style="margin-top:6px;display:inline-block" href="${esc(n.meta.zakazi_url)}" target="_blank">📅 Zakaži ZZJZ</a>` : ''}
${n.meta?.uplatnica_url ? `<a class="btn sm primary" style="margin-top:6px;display:inline-block" href="${esc(n.meta.uplatnica_url)}" target="_blank">📄 Uplatnica</a>` : ''}
</div>
${!n.read_at ? `<button class="btn sm" onclick="markRead(${n.id})">✓</button>` : ''}
</div>
</div>
</div>`).join('');
root.innerHTML = kpi + tools + (list || '<div class="empty">Nema notifikacija. Pokreni "Scan liječničke" za generiranje.</div>');
// restore filter selection
const ch = localStorage.getItem('nf-channel'); if (ch && $('#nf-channel')) $('#nf-channel').value = ch;
const st = localStorage.getItem('nf-status'); if (st && $('#nf-status')) $('#nf-status').value = st;
}
async function scanLijecnicki() {
if (!confirm('Skenirati sve liječničke i kreirati notifikacije za istečene + 30/15/7 dana?')) return;
try {
const r = await api('/lijecnicki/notify-scan', {method:'POST', body: {}});
toast(`✓ Kreirano ${r.created} notifikacija (thresholds: ${r.thresholds_dana.join('/')} dana)`);
loadNotifs();
} catch (e) { toast('Greška: ' + e.message, true); }
}
async function markRead(nid) {
try {
await api('/notifications/' + nid + '/read', {method:'POST'});
toast('✓ Označeno pročitano');
loadNotifs();
} catch (e) { toast('Greška: ' + e.message, true); }
}
async function markAllReadUI() {
if (!confirm('Označiti sve InApp notifikacije kao pročitane?')) return;
try {
const r = await api('/notifications/mark-all-read', {method:'POST', body: {channel: 'inapp'}});
toast(`✓ Označeno ${r.marked_read} kao pročitano`);
loadNotifs();
} catch (e) { toast('Greška: ' + e.message, true); }
}
// ────────────────────────────────────────────────────
// init
// ────────────────────────────────────────────────────
@@ -1356,6 +1610,8 @@ loadClanovi();
$('#cnt-lijecnicki').textContent = lj.summary?.total ?? '?';
const fm = await api('/forms');
$('#cnt-obrasci').textContent = fm.count;
const nf = await api('/notifications?limit=1');
$('#cnt-notifs').textContent = nf.summary?.unread_inapp ?? 0;
} catch (e) {}
})();
</script>