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 += `
${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}
+
+
+
📋 Nadolazeći eventi (10)
+ + + ${upcomingHtml || ''} +
DatumNazivLokacija/KlubTip
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:
- + + + +
+ `; - 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})
- - ${rows || ''} + + ${rows || ''}
Sportaš/KlubGod.RazdobljePropisanPlaćenoDugStatus
Nema zapisa.
Sportaš/KlubGod.RazdobljePropisanPlaćenoDugStatus
Nema zapisa.
`; } +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(` + + `); + } 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
Upišite ime za pretragu…
@@ -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)}
+
Dug (€)
${fmtEur(cl.dug)}
+
+
+
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 ? ` + + ${barsHtml} + ` : '
Nema podataka o uplatama u zadnjih 12 mjeseci.
'} +
+
+
+
+
👥 Po spolu
+ ${spolHtml || ''}
SpolBroj
+
+
+
🏷 Top kategorije
+ ${katHtml || ''}
KategorijaBroj
+
+
+
💸 Najnovije uplate
+ ${noviHtml || ''}
DatumSportašKlubGod.Iznos
+
+
`; +} + +// ════════════════════════════════════════════════════ +// 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) {} })();