diff --git a/static/app.html b/static/app.html
index f387bce..fd20395 100644
--- a/static/app.html
+++ b/static/app.html
@@ -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('')}
`;
+// =======================================================================
+// 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 += `
`;
+ dayNames.forEach(d => grid += `
${d}
`);
+ for(let i=0; i
`;
+ 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 => `${esc(e.title.substring(0,28))}
`).join('');
+ const more = ev.length > 3 ? `+${ev.length-3} više
` : '';
+ grid += `x.title+(x.klub?' — '+x.klub:'')).join('\\n').replace(/'/g,'\\\\\\'')\)}')"`:''}>
${d}
${evHtml}${more}
`;
+ }
+ grid += '';
+
+ // 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 => `| ${esc(e.date)} | ${esc(e.title)} | ${esc(e.klub||'—')} | ${e.type} |
`).join('');
+
+ return `
+
+
⚕ Liječnički isteci
${cntLij}
≤ 180 dana
+
📅 Manifestacije
${cntManif}
+
🔔 InApp neprocitano
${cntNotif}
+
Eventa u kalendaru
${events.length}
+
+
+
+
📅 ${esc(monthName)}
+
+
+
+
+
+
+
+
${grid}
+
+
+
+
+ | Datum | Naziv | Lokacija/Klub | Tip |
+ ${upcomingHtml || '| Nema nadolazećih eventa. |
'}
+
+
+
+
🔔 Aktivne InApp notifikacije (10)
+
+
+
+ ${notif.filter(n=>!n.read_at && n.channel==='inapp').slice(0,10).map(n => `
+
+
${n.subject.includes('ISTEKAO')?'⚠':'⚕'}
+
+
${esc(n.subject)}
+
${esc((n.body||'').substring(0,140))}…
+
+
+
`).join('') || '
Nema neprocitanih notifikacija. Pokreni "Scan isteke" da generiraš nove.
'}
+
+
+ `;
+}
+
+SECTIONS['pgz:kalendar'] = renderKalendar;
+SECTIONS['savez:kalendar'] = renderKalendar;
+SECTIONS['klub:kalendar'] = renderKalendar;
+SECTIONS['sportas:kalendar'] = renderKalendar;
+
SECTIONS['pgz:forenzika'] = () => `
⚠ Forenzika — sumnjive transakcije
diff --git a/static/crm.html b/static/crm.html
index 4947eba..612574c 100644
--- a/static/crm.html
+++ b/static/crm.html
@@ -156,6 +156,8 @@ table tr:hover td { background: rgba(26, 115, 232, 0.05); }
€ Članarine …
⚕ Liječnički pregledi …
📝 Obrasci …
+
📊 Statistika
+
🔔 Notifikacije …
ROLA:
@@ -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() {
-
+
+
+
+
+
+
0 odabrano
+
·
+
Ukupno dug: 0,00 €
+
+
+
`;
- const rows = (data.rows || []).map(r => `
-
+ const rowHtml = r => `
+
+ |
${esc(r.clan)} ${esc(r.klub || '')} |
${esc(r.godina)} |
${esc(r.razdoblje || '')} |
@@ -303,18 +320,83 @@ async function loadClanarine() {
📄
-
`).join('');
+ `;
+ const rows = (data.rows || []).map(rowHtml).join('');
root.innerHTML = kpi + tools + `
Lista članarina (${data.count})
`;
}
+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(`
+
📄 Generirane uplatnice (${r.count}) — ukupno ${fmtEur(r.total_dug_eur)}
×
+
+
Klikom na PDF/QR otvarate uplatnicu u novom tabu. Svaka se generira on-demand.
+
| Klub | Sportaš | God. | Iznos | Akcije |
+ ${r.uplatnice.map(u => `| ${esc(u.klub||'—')} | ${esc(u.clan)} | ${esc(u.godina)} | ${fmtEur(u.iznos_eur)} | 📄 PDF 📱 QR |
`).join('')}
+
`);
+ } 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 => `
-
+
+ |
${esc(r.clan)} ${esc(r.klub || '')} |
${esc(r.godina)} |
${esc(r.razdoblje || '')} |
@@ -343,7 +426,8 @@ async function loadClanarineFiltered() {
📄
-
`).join('') || '
| Nema zapisa. |
';
+ `).join('') || '
| Nema zapisa. |
';
+ clearSelection();
}
async function openPayment(id) {
@@ -1007,6 +1091,7 @@ async function loadClanovi() {
+
📥 Export XLSX
Klik na karticu → puni dashboard člana
@@ -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 = 'Učitavanje statistike…
';
+ let d;
+ try { d = await api('/stats'); } catch (e) { root.innerHTML = `Greška: ${esc(e.message)}
`; 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 `
+ ${esc(t.mjesec.substring(2))}
+ ${fmt(Math.round(t.iznos_total))}`;
+ }).join('');
+
+ const spolHtml = (d.po_spolu || []).map(s => `| ${esc(s.spol||'?')} | ${fmt(s.n)} |
`).join('');
+ const katHtml = (d.po_kategoriji || []).map(k => `| ${esc(k.kategorija)} | ${fmt(k.n)} |
`).join('');
+ const noviHtml = (d.najnovije_uplate || []).map(u => `| ${fmtDate(u.datum_uplate)} | ${esc(u.clan)} | ${esc(u.klub||'—')} | ${esc(u.godina)} | ${fmtEur(u.iznos_placen)} |
`).join('');
+
+ root.innerHTML = `
+
+
Aktivni članovi
${fmt(c.aktivni)}
${aktPct}% od ${fmt(c.total)}
+
Neaktivni
${fmt(c.neaktivni)}
+
Reprezentativci
${fmt(c.reprezentativci)}
+
Kategorizirani
${fmt(c.kategorizirani)}
+
Stipendirani
${fmt(c.stipendirani)}
+
+
+
Članarine podmirene
${fmt(cl.n_podmireno)}
${podPct}% od ${fmt(cl.total)}
+
Djelomično
${fmt(cl.n_djelomicno)}
+
Nepodmireno
${fmt(cl.n_nepodmireno)}
+
Plaćeno (€)
${fmtEur(cl.placen)}
+
+
+
+
Liječnički važeći
${fmt(lj.vazeci)}
+
Uskoro istek
${fmt(lj.uskoro)}
+
Istekli
${fmt(lj.istekli)}
+
+
+
📈 Trend uplata članarina (zadnjih 12 mjeseci)
+
+ ${tr.length ? `
` : '
Nema podataka o uplatama u zadnjih 12 mjeseci.
'}
+
+
+
+
+
+
| Spol | Broj |
${spolHtml || '| — |
'}
+
+
+
+
| Kategorija | Broj |
${katHtml || '| — |
'}
+
+
+
+
| Datum | Sportaš | Klub | God. | Iznos |
${noviHtml || '| — |
'}
+
+
`;
+}
+
+// ════════════════════════════════════════════════════
+// MODUL 6 — NOTIFIKACIJE (R5 #6)
+// ════════════════════════════════════════════════════
+
+async function loadNotifs() {
+ const root = $('#page-notifs');
+ root.innerHTML = 'Učitavanje notifikacija…
';
+ let d;
+ try { d = await api('/notifications?limit=200'); }
+ catch (e) { root.innerHTML = `Greška: ${esc(e.message)}
`; return; }
+ $('#cnt-notifs').textContent = d.summary?.unread_inapp ?? d.count;
+
+ const tools = `
+ `;
+ const kpi = `
+
+
Ukupno
${fmt(d.summary?.total)}
+
Pending
${fmt(d.summary?.pending)}
+
Sent
${fmt(d.summary?.sent)}
+
InApp neprocitano
${fmt(d.summary?.unread_inapp)}
+
`;
+ const list = (d.rows || []).map(n => `
+
+
+
+
+
${esc(n.subject)}
+
${esc(fmtDate(n.scheduled_at))} · ${esc(n.channel)} · ${esc(n.status)}${n.read_at ? ' · pročitano '+esc(fmtDate(n.read_at)) : ''}
+
${esc((n.body||'').substring(0,260))}${(n.body||'').length>260?'…':''}
+ ${n.meta?.zakazi_url ? `
📅 Zakaži ZZJZ` : ''}
+ ${n.meta?.uplatnica_url ? `
📄 Uplatnica` : ''}
+
+ ${!n.read_at ? `
` : ''}
+
+
+
`).join('');
+
+ root.innerHTML = kpi + tools + (list || 'Nema notifikacija. Pokreni "Scan liječničke" za generiranje.
');
+
+ // 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) {}
})();