CC2 R4 #2+#5: remove legacy unauth /api/admin/users — close 401 gap

The bare @app.get/post('/api/admin/users') decorators in pgz_sport_api.py
were registered before app.include_router(admin_users_router) and shadowed
the JWT-protected M2 routes, leaking user list to anyone.

Removed all three: GET /api/admin/users, POST /api/admin/users,
POST /api/admin/users/{uid}/toggle. The auth.admin_users router now owns
this prefix exclusively and gates every method with require_user.

Verified: no-auth → 401, invalid token → 401, valid Bearer → 200.
This commit is contained in:
Damir Radulić
2026-05-05 00:44:50 +02:00
parent cb3faee731
commit f5c6570d47
20 changed files with 11746 additions and 110 deletions
File diff suppressed because it is too large Load Diff
+974
View File
@@ -0,0 +1,974 @@
<!DOCTYPE html>
<html lang="hr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>PGŽ Sport — CRM (Članarine • Liječnički • Obrasci)</title>
<style>
:root {
--pgz-blue:#1a73e8; --pgz-blue2:#1e3a8a;
--bg:#0f1115; --bg2:#171a21; --bg3:#1f242d;
--rim:#293040; --t1:#e6e8ef; --t2:#9aa3b6; --t3:#6b748b;
--ok:#22c55e; --warn:#f59e0b; --err:#ef4444; --info:#3b82f6;
}
* { box-sizing: border-box; }
body { margin:0; font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
background: var(--bg); color: var(--t1); font-size: 14px; }
.topbar {
height: 54px; background: linear-gradient(90deg, var(--pgz-blue2), var(--pgz-blue));
display: flex; align-items: center; padding: 0 18px; gap: 16px;
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
}
.topbar .logo { font-weight: 700; font-size: 16px; }
.topbar .sep { color: rgba(255,255,255,0.5); }
.topbar .title { font-size: 14px; opacity: 0.95; }
.topbar .right { margin-left: auto; display: flex; gap: 10px; align-items: center; font-size: 12px; }
.topbar a { color: #fff; text-decoration: none; opacity: 0.8; padding: 6px 10px; border-radius: 4px; }
.topbar a:hover { opacity: 1; background: rgba(255,255,255,0.1); }
.tabs { display: flex; background: var(--bg2); border-bottom: 1px solid var(--rim); padding: 0 18px; }
.tab { padding: 14px 20px; cursor: pointer; color: var(--t2); border-bottom: 2px solid transparent;
font-weight: 500; user-select: none; }
.tab:hover { color: var(--t1); }
.tab.active { color: var(--pgz-blue); border-bottom-color: var(--pgz-blue); background: var(--bg3); }
.tab .count { background: var(--bg3); color: var(--t2); padding: 2px 8px; border-radius: 10px;
font-size: 11px; margin-left: 6px; }
.tab.active .count { background: var(--pgz-blue); color: #fff; }
.container { padding: 18px; }
.toolbar { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 14px; align-items: center; }
.toolbar input, .toolbar select {
background: var(--bg2); border: 1px solid var(--rim); color: var(--t1);
padding: 7px 11px; border-radius: 5px; font-size: 13px; min-width: 140px;
}
.toolbar input:focus, .toolbar select:focus { outline: none; border-color: var(--pgz-blue); }
.toolbar .grow { flex: 1; }
.btn { background: var(--bg3); color: var(--t1); border: 1px solid var(--rim);
padding: 7px 13px; border-radius: 5px; cursor: pointer; font-size: 13px;
font-family: inherit; }
.btn:hover { background: var(--bg2); border-color: var(--pgz-blue); }
.btn.primary { background: linear-gradient(135deg, var(--pgz-blue), var(--pgz-blue2)); border-color: var(--pgz-blue); color:#fff; }
.btn.primary:hover { filter: brightness(1.1); }
.btn.danger { color: var(--err); border-color: var(--err); }
.btn.sm { padding: 4px 8px; font-size: 12px; }
.kpi-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px; margin-bottom: 14px; }
.kpi { background: var(--bg2); border: 1px solid var(--rim); padding: 12px 14px; border-radius: 8px; }
.kpi.g { border-left: 3px solid var(--ok); }
.kpi.r { border-left: 3px solid var(--err); }
.kpi.a { border-left: 3px solid var(--warn); }
.kpi.b { border-left: 3px solid var(--pgz-blue); }
.kpi-l { font-size: 11px; color: var(--t2); text-transform: uppercase; letter-spacing: 0.5px; }
.kpi-v { font-size: 22px; font-weight: 700; margin-top: 4px; }
.kpi-s { font-size: 11px; color: var(--t3); margin-top: 2px; }
.card { background: var(--bg2); border: 1px solid var(--rim); border-radius: 8px;
margin-bottom: 14px; overflow: hidden; }
.card-h { padding: 12px 16px; border-bottom: 1px solid var(--rim); display: flex; align-items: center;
justify-content: space-between; background: var(--bg3); }
.card-t { font-weight: 600; font-size: 14px; }
.card-b { padding: 14px 16px; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
table th, table td { padding: 9px 12px; text-align: left; border-bottom: 1px solid var(--rim); }
table th { background: var(--bg3); color: var(--t2); font-weight: 600; font-size: 11px;
text-transform: uppercase; letter-spacing: 0.4px; }
table tr:hover td { background: rgba(26, 115, 232, 0.05); }
.tag { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; }
.tag.gr { background: rgba(34,197,94,0.2); color: var(--ok); }
.tag.am { background: rgba(245,158,11,0.2); color: var(--warn); }
.tag.rd { background: rgba(239,68,68,0.2); color: var(--err); }
.tag.bl { background: rgba(26,115,232,0.2); color: var(--pgz-blue); }
.tag.gy { background: rgba(154,163,182,0.2); color: var(--t2); }
.empty { text-align: center; padding: 40px; color: var(--t3); }
.loading { text-align: center; padding: 30px; color: var(--t2); }
.modal-bg { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.7);
display: none; justify-content: center; align-items: flex-start; padding-top: 5vh; z-index: 1000; }
.modal-bg.open { display: flex; }
.modal { background: var(--bg2); border: 1px solid var(--rim); border-radius: 8px;
width: 92%; max-width: 720px; max-height: 90vh; overflow-y: auto; }
.modal-h { padding: 14px 18px; border-bottom: 1px solid var(--rim); display: flex;
justify-content: space-between; align-items: center; background: var(--bg3); }
.modal-t { font-weight: 600; font-size: 15px; }
.modal-x { cursor: pointer; color: var(--t2); font-size: 22px; line-height: 1; padding: 0 4px; }
.modal-x:hover { color: var(--err); }
.modal-b { padding: 18px; }
.field { margin-bottom: 12px; }
.field label { display: block; font-size: 12px; color: var(--t2); margin-bottom: 4px;
text-transform: uppercase; letter-spacing: 0.3px; }
.field label.req::after { content: " *"; color: var(--err); }
.field input, .field select, .field textarea {
width: 100%; background: var(--bg); border: 1px solid var(--rim); color: var(--t1);
padding: 8px 12px; border-radius: 5px; font-size: 13px; font-family: inherit;
}
.field input:focus, .field select:focus, .field textarea:focus { outline: none; border-color: var(--pgz-blue); }
.field textarea { min-height: 70px; resize: vertical; }
.field .help { font-size: 11px; color: var(--t3); margin-top: 3px; }
.payment-card { background: var(--bg); border: 1px solid var(--rim); border-radius: 6px;
padding: 14px; margin-top: 12px; }
.payment-row { display: flex; justify-content: space-between; padding: 6px 0; border-bottom: 1px dashed var(--rim); }
.payment-row:last-child { border-bottom: none; }
.payment-row .l { color: var(--t2); font-size: 12px; }
.payment-row .v { font-weight: 600; font-family: 'SF Mono', Consolas, monospace; }
.payment-row .v.big { font-size: 18px; color: var(--pgz-blue); }
.qr-box { display: flex; gap: 16px; align-items: center; margin: 14px 0; }
.qr-box img { width: 160px; height: 160px; background: #fff; padding: 8px; border-radius: 6px; }
.qr-box .qr-info { flex: 1; }
.signature-box { background: var(--bg); border: 1px solid var(--rim); border-radius: 6px;
padding: 14px; margin-top: 14px; font-family: 'SF Mono', Consolas, monospace;
font-size: 11px; word-break: break-all; }
.signature-box .sha { color: var(--ok); }
.toast { position: fixed; bottom: 20px; right: 20px; background: var(--bg3); border: 1px solid var(--rim);
padding: 10px 16px; border-radius: 6px; font-size: 13px; z-index: 2000;
border-left: 3px solid var(--ok); transform: translateX(120%); transition: transform 0.3s; }
.toast.show { transform: translateX(0); }
.toast.err { border-left-color: var(--err); }
</style>
</head>
<body>
<div class="topbar">
<div class="logo">⬢ PGŽ SPORT</div>
<div class="sep">·</div>
<div class="title">CRM — Članarine • Liječnički • Obrasci</div>
<div class="right">
<span style="opacity:.7">Round 3 / CC5</span>
<a href="/sport/static/sport2.html">← portal</a>
<a href="/sport/static/app.html">app →</a>
</div>
</div>
<div class="tabs">
<div class="tab active" 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>
<div class="container">
<div id="page-clanarine" class="page"></div>
<div id="page-lijecnicki" class="page" style="display:none"></div>
<div id="page-obrasci" class="page" style="display:none"></div>
</div>
<div id="modal-bg" class="modal-bg" onclick="if(event.target===this)closeModal()">
<div class="modal" id="modal"></div>
</div>
<div id="toast" class="toast"></div>
<script>
// ────────────────────────────────────────────────────
// Helpers
// ────────────────────────────────────────────────────
const API = '/sport/api/crm';
const $ = (s, root=document) => root.querySelector(s);
const $$ = (s, root=document) => Array.from(root.querySelectorAll(s));
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const fmtEur = v => (v == null) ? '—' : Number(v).toLocaleString('hr-HR', {minimumFractionDigits:2, maximumFractionDigits:2}) + ' €';
const fmt = v => (v == null) ? '—' : Number(v).toLocaleString('hr-HR');
const fmtDate = d => !d ? '—' : new Date(d).toLocaleDateString('hr-HR');
async function api(path, opts={}) {
const o = Object.assign({headers: {'Content-Type':'application/json'}}, opts);
if (o.body && typeof o.body !== 'string') o.body = JSON.stringify(o.body);
const r = await fetch(API + path, o);
if (!r.ok) {
const msg = await r.text().catch(()=>r.statusText);
throw new Error(`HTTP ${r.status}: ${msg.substring(0,200)}`);
}
return r.json();
}
function toast(msg, isErr=false) {
const t = $('#toast');
t.textContent = msg;
t.classList.toggle('err', isErr);
t.classList.add('show');
setTimeout(() => t.classList.remove('show'), 3500);
}
function openModal(html) {
$('#modal').innerHTML = html;
$('#modal-bg').classList.add('open');
}
function closeModal() {
$('#modal-bg').classList.remove('open');
$('#modal').innerHTML = '';
}
function setTab(name) {
$$('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === name));
$$('.page').forEach(p => p.style.display = (p.id === 'page-' + name) ? 'block' : 'none');
if (name === 'clanarine') loadClanarine();
if (name === 'lijecnicki') loadLijecnicki();
if (name === 'obrasci') loadObrasci();
}
// ════════════════════════════════════════════════════
// MODUL 1 — ČLANARINE (M7)
// ════════════════════════════════════════════════════
async function loadClanarine() {
const root = $('#page-clanarine');
root.innerHTML = '<div class="loading">Učitavanje članarina…</div>';
let data;
try {
data = await api('/clanarine?limit=200');
} catch (e) { root.innerHTML = `<div class="empty">Greška: ${esc(e.message)}</div>`; return; }
$('#cnt-clanarine').textContent = data.count;
const s = data.summary || {};
const kpi = `
<div class="kpi-grid">
<div class="kpi b"><div class="kpi-l">Ukupno zaduženja</div><div class="kpi-v">${fmt(s.total)}</div></div>
<div class="kpi g"><div class="kpi-l">Naplaćeno</div><div class="kpi-v">${fmtEur(s.total_placen)}</div></div>
<div class="kpi r"><div class="kpi-l">Dug</div><div class="kpi-v">${fmtEur(s.total_dug)}</div></div>
<div class="kpi a"><div class="kpi-l">Nepodmireno</div><div class="kpi-v">${fmt(s.n_nepodmireno)}</div></div>
</div>`;
const tools = `
<div class="toolbar">
<select id="cl-status" onchange="loadClanarineFiltered()">
<option value="">Svi statusi</option>
<option value="nepodmireno">Nepodmireno</option>
<option value="djelomicno">Djelomično</option>
<option value="podmireno">Podmireno</option>
<option value="storno">Storno</option>
</select>
<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="newClanarinaModal()">+ Novo zaduženje</button>
</div>`;
const rows = (data.rows || []).map(r => `
<tr>
<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>
<td>${fmtEur(r.iznos_propisan)}</td>
<td>${fmtEur(r.iznos_placen)}</td>
<td><b style="color:${r.dug>0?'var(--err)':'var(--ok)'}">${fmtEur(r.dug)}</b></td>
<td><span class="tag ${statusTag(r.status)}">${esc(r.status)}</span></td>
<td>
<button class="btn sm" onclick="openPayment(${r.id})" title="Pregled plaćanja">💳</button>
<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('');
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>
</table>
</div>`;
}
function statusTag(s) {
return ({nepodmireno:'rd', djelomicno:'am', podmireno:'gr', storno:'gy'})[s] || 'gy';
}
async function loadClanarineFiltered() {
const status = $('#cl-status').value;
const godina = $('#cl-godina').value;
const klub = $('#cl-klub').value;
const params = new URLSearchParams({limit: 200});
if (status) params.append('status', status);
if (godina) params.append('godina', godina);
if (klub) params.append('klub_id', klub);
const data = await api('/clanarine?' + params);
const tbody = $('#page-clanarine table tbody');
tbody.innerHTML = (data.rows || []).map(r => `
<tr>
<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>
<td>${fmtEur(r.iznos_propisan)}</td>
<td>${fmtEur(r.iznos_placen)}</td>
<td><b style="color:${r.dug>0?'var(--err)':'var(--ok)'}">${fmtEur(r.dug)}</b></td>
<td><span class="tag ${statusTag(r.status)}">${esc(r.status)}</span></td>
<td>
<button class="btn sm" onclick="openPayment(${r.id})">💳</button>
<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>';
}
async function openPayment(id) {
let info;
try { info = await api('/clanarine/' + id + '/payment-info'); }
catch (e) { return toast('Greška: ' + e.message, true); }
openModal(`
<div class="modal-h">
<div class="modal-t">💳 Podaci za plaćanje #${id}</div>
<div class="modal-x" onclick="closeModal()">×</div>
</div>
<div class="modal-b">
<div class="qr-box">
<img src="${API}/clanarine/${id}/qr.png" alt="EPC QR">
<div class="qr-info">
<p style="margin:0 0 8px;color:var(--t2);font-size:12px">Skenirajte QR mobilnom bankom (Zaba / PBZ / Erste / OTP / RBA) — popunit će sve podatke za uplatu.</p>
<a class="btn primary" href="${API}/clanarine/${id}/uplatnica.pdf" target="_blank">📄 HUB-3 PDF (uplatnica)</a>
</div>
</div>
<div class="payment-card">
<div class="payment-row"><div class="l">Iznos za uplatu</div><div class="v big">${fmtEur(info.iznos_eur)}</div></div>
<div class="payment-row"><div class="l">Primatelj</div><div class="v">${esc(info.primatelj)}</div></div>
<div class="payment-row"><div class="l">IBAN</div><div class="v">${esc(info.iban)}</div></div>
<div class="payment-row"><div class="l">Model</div><div class="v">${esc(info.model)}</div></div>
<div class="payment-row"><div class="l">Poziv na broj</div><div class="v">${esc(info.poziv_na_broj)}</div></div>
<div class="payment-row"><div class="l">Opis</div><div class="v">${esc(info.opis)}</div></div>
</div>
<details style="margin-top:14px">
<summary style="cursor:pointer;color:var(--t2);font-size:12px">EPC QR payload (BCD/002 SCT)</summary>
<pre style="background:var(--bg);padding:10px;border-radius:5px;font-size:11px;overflow:auto;margin-top:6px">${esc(info.epc_payload)}</pre>
</details>
</div>`);
}
function openUplata(id) {
openModal(`
<div class="modal-h">
<div class="modal-t">+€ Registriraj uplatu (članarina #${id})</div>
<div class="modal-x" onclick="closeModal()">×</div>
</div>
<div class="modal-b">
<form onsubmit="submitUplata(event, ${id})">
<div class="field"><label class="req">Iznos uplate (EUR)</label>
<input name="iznos" type="number" step="0.01" min="0.01" required></div>
<div class="field"><label>Datum uplate</label>
<input name="datum_uplate" type="date" value="${new Date().toISOString().slice(0,10)}"></div>
<div class="field"><label>Način uplate</label>
<select name="nacin_uplate">
<option value="transakcijski">Transakcijski račun</option>
<option value="gotovina">Gotovina</option>
<option value="kartica">Kartica</option>
</select></div>
<div class="field"><label>Referenca / broj naloga</label>
<input name="referenca" type="text"></div>
<div style="text-align:right;margin-top:14px">
<button type="button" class="btn" onclick="closeModal()">Odustani</button>
<button type="submit" class="btn primary">💾 Spremi uplatu</button>
</div>
</form>
</div>`);
}
async function submitUplata(e, id) {
e.preventDefault();
const f = e.target;
const body = {
iznos: parseFloat(f.iznos.value),
datum_uplate: f.datum_uplate.value || null,
nacin_uplate: f.nacin_uplate.value,
referenca: f.referenca.value || null,
};
try {
const r = await api('/clanarine/' + id + '/uplata', {method:'POST', body});
closeModal();
toast(`Uplata ${fmtEur(body.iznos)} registrirana. Status: ${r.status}`);
loadClanarine();
} catch (err) { toast('Greška: ' + err.message, true); }
}
function newClanarinaModal() {
openModal(`
<div class="modal-h">
<div class="modal-t">+ Novo zaduženje članarine</div>
<div class="modal-x" onclick="closeModal()">×</div>
</div>
<div class="modal-b">
<form onsubmit="submitNewClanarina(event)">
<div class="field"><label class="req">Član ID</label>
<input name="clan_id" type="number" required></div>
<div class="field"><label>Klub ID (auto ako se ne unese)</label>
<input name="klub_id" type="number"></div>
<div class="field"><label class="req">Godina</label>
<input name="godina" type="number" required value="${new Date().getFullYear()}"></div>
<div class="field"><label>Razdoblje</label>
<input name="razdoblje" type="text" value="godišnja"></div>
<div class="field"><label class="req">Iznos propisan (EUR)</label>
<input name="iznos_propisan" type="number" step="0.01" required></div>
<div class="field"><label>Iznos plaćen (ako odmah)</label>
<input name="iznos_placen" type="number" step="0.01" value="0"></div>
<div class="field"><label>Napomena</label>
<textarea name="napomena"></textarea></div>
<div style="text-align:right">
<button type="button" class="btn" onclick="closeModal()">Odustani</button>
<button type="submit" class="btn primary">💾 Kreiraj</button>
</div>
</form>
</div>`);
}
async function submitNewClanarina(e) {
e.preventDefault();
const f = e.target;
const body = {
clan_id: parseInt(f.clan_id.value),
klub_id: f.klub_id.value ? parseInt(f.klub_id.value) : null,
godina: parseInt(f.godina.value),
razdoblje: f.razdoblje.value,
iznos_propisan: parseFloat(f.iznos_propisan.value),
iznos_placen: parseFloat(f.iznos_placen.value || 0),
napomena: f.napomena.value || null,
};
try {
await api('/clanarine', {method:'POST', body});
closeModal();
toast('Članarina kreirana.');
loadClanarine();
} catch (err) { toast('Greška: ' + err.message, true); }
}
async function bulkNotify() {
if (!confirm('Pošalji notifikaciju svim dužnicima?')) return;
try {
const r = await api('/clanarine/notify-bulk', {method:'POST', body: {}});
toast(`Postavljeno ${r.queued} primatelja u red. (Mock — SMTP nije konfiguriran.)`);
} catch (err) { toast('Greška: ' + err.message, true); }
}
// ════════════════════════════════════════════════════
// MODUL 2 — LIJEČNIČKI PREGLEDI (M8)
// ════════════════════════════════════════════════════
async function loadLijecnicki() {
const root = $('#page-lijecnicki');
root.innerHTML = '<div class="loading">Učitavanje pregleda…</div>';
let data;
try { data = await api('/lijecnicki?limit=200'); }
catch (e) { root.innerHTML = `<div class="empty">Greška: ${esc(e.message)}</div>`; return; }
$('#cnt-lijecnicki').textContent = data.count;
const s = data.summary || {};
const kpi = `
<div class="kpi-grid">
<div class="kpi b"><div class="kpi-l">Ukupno pregleda</div><div class="kpi-v">${fmt(s.total)}</div></div>
<div class="kpi g"><div class="kpi-l">Važeći</div><div class="kpi-v">${fmt(s.vazeci)}</div></div>
<div class="kpi a"><div class="kpi-l">Uskoro istek (30d)</div><div class="kpi-v">${fmt(s.uskoro)}</div></div>
<div class="kpi r"><div class="kpi-l">Istekli</div><div class="kpi-v">${fmt(s.istekli)}</div></div>
</div>`;
const tools = `
<div class="toolbar">
<select id="lj-status" onchange="loadLijecnickiFiltered()">
<option value="">Svi statusi</option>
<option value="vazeci">Važeći</option>
<option value="uskoro">Uskoro istek</option>
<option value="istekao">Istekao</option>
</select>
<input id="lj-klub" type="number" placeholder="Klub ID" onchange="loadLijecnickiFiltered()">
<div class="grow"></div>
<button class="btn" onclick="loadZZJZ()">🏥 ZZJZ PGŽ termini</button>
<button class="btn" onclick="newLijecnickiModal()">+ Novi pregled</button>
</div>`;
const rows = (data.rows || []).map(r => `
<tr>
<td><b>${esc(r.clan)}</b><div style="font-size:11px;color:var(--t3)">${esc(r.klub || '')}</div></td>
<td>${fmtDate(r.datum_pregleda)}</td>
<td>${fmtDate(r.vrijedi_do)}</td>
<td><span class="tag ${({vazeci:'gr', uskoro:'am', istekao:'rd'})[r.status_calc]||'gy'}">
${r.status_calc}${r.dana_do_isteka != null ? ' ('+r.dana_do_isteka+'d)' : ''}</span></td>
<td>${esc(r.ustanova || '')}</td>
<td>${esc(r.lijecnik || '')}</td>
<td>${r.placeno ? '<span class="tag gr">DA</span>' : '<span class="tag rd">NE</span>'}</td>
<td>
<button class="btn sm" onclick="openZakaziModal(${r.id}, '${esc(r.clan)}')" title="Zakaži termin">📅</button>
<button class="btn sm" onclick="openLijecnickiDetalji(${r.id})" title="Detalji">👁</button>
</td>
</tr>`).join('');
root.innerHTML = kpi + tools + `
<div class="card">
<div class="card-h"><div class="card-t">Lista pregleda (${data.count})</div></div>
<table>
<thead><tr><th>Sportaš/Klub</th><th>Datum pregleda</th><th>Vrijedi do</th><th>Status</th><th>Ustanova</th><th>Liječnik</th><th>Plaćeno</th><th></th></tr></thead>
<tbody>${rows || '<tr><td colspan="8" class="empty">Nema zapisa.</td></tr>'}</tbody>
</table>
</div>`;
}
async function loadLijecnickiFiltered() {
const status = $('#lj-status').value;
const klub = $('#lj-klub').value;
const params = new URLSearchParams({limit: 200});
if (status) params.append('status', status);
if (klub) params.append('klub_id', klub);
const data = await api('/lijecnicki?' + params);
const tbody = $('#page-lijecnicki table tbody');
tbody.innerHTML = (data.rows || []).map(r => `
<tr>
<td><b>${esc(r.clan)}</b><div style="font-size:11px;color:var(--t3)">${esc(r.klub || '')}</div></td>
<td>${fmtDate(r.datum_pregleda)}</td>
<td>${fmtDate(r.vrijedi_do)}</td>
<td><span class="tag ${({vazeci:'gr', uskoro:'am', istekao:'rd'})[r.status_calc]||'gy'}">
${r.status_calc}${r.dana_do_isteka != null ? ' ('+r.dana_do_isteka+'d)' : ''}</span></td>
<td>${esc(r.ustanova || '')}</td>
<td>${esc(r.lijecnik || '')}</td>
<td>${r.placeno ? '<span class="tag gr">DA</span>' : '<span class="tag rd">NE</span>'}</td>
<td>
<button class="btn sm" onclick="openZakaziModal(${r.id}, '${esc(r.clan)}')">📅</button>
<button class="btn sm" onclick="openLijecnickiDetalji(${r.id})">👁</button>
</td>
</tr>`).join('') || '<tr><td colspan="8" class="empty">Nema zapisa.</td></tr>';
}
async function loadZZJZ() {
let info, termini;
try {
info = await api('/zzjz/info');
termini = await api('/zzjz/termini');
} catch (e) { return toast('Greška: ' + e.message, true); }
const booking = info.online_booking || {};
const bookingHtml = booking.available
? `<a class="btn primary" target="_blank" href="${esc(booking.url)}">🔗 Otvori online sustav (${esc(booking.kind)})</a>`
: `<div class="tag am">Online sustav nije pronađen — koristi e-mail kontakt</div>
<div style="margin-top:8px"><a class="btn primary" href="mailto:${esc(info.email)}">✉ E-mail: ${esc(info.email)}</a></div>`;
const termHtml = (termini.termini || []).slice(0, 30).map(t => `
<tr>
<td>${esc(t.datum)}</td><td>${esc(t.vrijeme)}</td>
<td>${esc(t.doktor)}</td>
<td>${t.available ? '<span class="tag gr">slobodno</span>' : '<span class="tag rd">zauzeto</span>'}</td>
<td>${fmtEur(t.iznos_eur)}</td>
</tr>`).join('');
openModal(`
<div class="modal-h">
<div class="modal-t">🏥 ZZJZ PGŽ — Sportska medicina</div>
<div class="modal-x" onclick="closeModal()">×</div>
</div>
<div class="modal-b">
<div class="payment-card">
<div class="payment-row"><div class="l">Naziv</div><div class="v">${esc(info.naziv)}</div></div>
<div class="payment-row"><div class="l">Adresa</div><div class="v">${esc(info.adresa)}</div></div>
<div class="payment-row"><div class="l">Telefon</div><div class="v">${esc(info.telefon)}</div></div>
<div class="payment-row"><div class="l">E-mail</div><div class="v">${esc(info.email)}</div></div>
<div class="payment-row"><div class="l">Web</div><div class="v"><a href="${esc(info.url_sportska_medicina)}" target="_blank" style="color:var(--pgz-blue)">${esc(info.url_sportska_medicina)}</a></div></div>
</div>
<div style="margin:14px 0">${bookingHtml}</div>
<div class="card-h" style="background:transparent;border:none;padding:8px 0">
<div class="card-t">Dostupni termini (mock — tjedan ${esc(termini.week_start)})</div>
<div style="font-size:11px;color:var(--t3)">${termini.available} slobodno / ${termini.count} ukupno</div>
</div>
<table>
<thead><tr><th>Datum</th><th>Vrijeme</th><th>Doktor</th><th>Status</th><th>Iznos</th></tr></thead>
<tbody>${termHtml || '<tr><td colspan="5" class="empty">Nema termina.</td></tr>'}</tbody>
</table>
</div>`);
}
function openZakaziModal(lid, clan) {
openModal(`
<div class="modal-h">
<div class="modal-t">📅 Zakaži pregled — ${esc(clan)}</div>
<div class="modal-x" onclick="closeModal()">×</div>
</div>
<div class="modal-b">
<p style="color:var(--t2);font-size:13px;margin-top:0">Sustav će zakazati termin u ZZJZ PGŽ. Ako online sustav nije dostupan, otvorit će mailto: link.</p>
<form onsubmit="submitZakazi(event, ${lid})">
<div class="field"><label class="req">Datum</label>
<input name="datum" type="date" required value="${new Date(Date.now()+7*86400000).toISOString().slice(0,10)}"></div>
<div class="field"><label>Vrijeme</label>
<input name="vrijeme" type="time" value="09:00"></div>
<div class="field"><label>Ustanova</label>
<input name="ustanova" type="text" value="ZZJZ PGŽ"></div>
<div class="field"><label>Napomena</label>
<textarea name="napomena"></textarea></div>
<div style="text-align:right">
<button type="button" class="btn" onclick="closeModal()">Odustani</button>
<button type="submit" class="btn primary">📅 Zakaži</button>
</div>
</form>
</div>`);
}
async function submitZakazi(e, lid) {
e.preventDefault();
const f = e.target;
const body = {
datum: f.datum.value, vrijeme: f.vrijeme.value,
ustanova: f.ustanova.value, napomena: f.napomena.value || null,
};
try {
const r = await api('/lijecnicki/' + lid + '/zakazi', {method:'POST', body});
closeModal();
toast('Termin zakazan: ' + r.zakazano_za);
if (r.booking && r.booking.available) {
window.open(r.booking.url, '_blank');
} else if (r.mailto) {
window.location.href = r.mailto;
}
loadLijecnicki();
} catch (err) { toast('Greška: ' + err.message, true); }
}
async function openLijecnickiDetalji(lid) {
let l;
try { l = await api('/lijecnicki/' + lid); }
catch (e) { return toast('Greška: ' + e.message, true); }
openModal(`
<div class="modal-h">
<div class="modal-t">⚕ Pregled #${l.id} — ${esc(l.clan)}</div>
<div class="modal-x" onclick="closeModal()">×</div>
</div>
<div class="modal-b">
<div class="payment-card">
<div class="payment-row"><div class="l">Sportaš</div><div class="v">${esc(l.clan)}</div></div>
<div class="payment-row"><div class="l">Klub</div><div class="v">${esc(l.klub || '')}</div></div>
<div class="payment-row"><div class="l">Datum pregleda</div><div class="v">${fmtDate(l.datum_pregleda)}</div></div>
<div class="payment-row"><div class="l">Vrijedi do</div><div class="v">${fmtDate(l.vrijedi_do)}</div></div>
<div class="payment-row"><div class="l">Status</div><div class="v"><span class="tag ${({vazeci:'gr',uskoro:'am',istekao:'rd'})[l.status_calc]||'gy'}">${l.status_calc} (${l.dana_do_isteka}d)</span></div></div>
<div class="payment-row"><div class="l">Vrsta</div><div class="v">${esc(l.vrsta_pregleda || '')}</div></div>
<div class="payment-row"><div class="l">Ustanova</div><div class="v">${esc(l.ustanova || '')}</div></div>
<div class="payment-row"><div class="l">Liječnik</div><div class="v">${esc(l.lijecnik || '')}</div></div>
<div class="payment-row"><div class="l">EKG / Krv / Spirometrija</div><div class="v">${l.ekg?'✓':'✗'} / ${l.krv?'✓':'✗'} / ${l.spirometrija?'✓':'✗'}</div></div>
<div class="payment-row"><div class="l">Spreman za natjecanje</div><div class="v">${l.spreman_za_natjecanje?'<span class="tag gr">DA</span>':'<span class="tag rd">NE</span>'}</div></div>
<div class="payment-row"><div class="l">Iznos / plaćeno</div><div class="v">${fmtEur(l.iznos)} ${l.placeno?'<span class="tag gr">DA</span>':'<span class="tag rd">NE</span>'}</div></div>
</div>
${l.komentar_lijecnika ? `<div style="margin-top:12px;padding:10px;background:var(--bg);border-left:3px solid var(--pgz-blue);border-radius:5px"><div style="font-size:11px;color:var(--t3);margin-bottom:4px">KOMENTAR LIJEČNIKA</div>${esc(l.komentar_lijecnika)}</div>` : ''}
${l.napomena ? `<div style="margin-top:8px;padding:10px;background:var(--bg);border-left:3px solid var(--warn);border-radius:5px"><div style="font-size:11px;color:var(--t3);margin-bottom:4px">NAPOMENA</div>${esc(l.napomena)}</div>` : ''}
<div style="text-align:right;margin-top:14px">
<button class="btn" onclick="openZakaziModal(${l.id}, '${esc(l.clan)}')">📅 Zakaži novi termin</button>
</div>
</div>`);
}
function newLijecnickiModal() {
openModal(`
<div class="modal-h">
<div class="modal-t">+ Novi liječnički pregled</div>
<div class="modal-x" onclick="closeModal()">×</div>
</div>
<div class="modal-b">
<form onsubmit="submitNewLijecnicki(event)">
<div class="field"><label class="req">Član ID</label>
<input name="clan_id" type="number" required></div>
<div class="field"><label class="req">Datum pregleda</label>
<input name="datum_pregleda" type="date" required value="${new Date().toISOString().slice(0,10)}"></div>
<div class="field"><label>Vrijedi do (auto +1 god)</label>
<input name="vrijedi_do" type="date"></div>
<div class="field"><label>Vrsta pregleda</label>
<select name="vrsta_pregleda">
<option value="temeljni">Temeljni</option>
<option value="kontrolni">Kontrolni</option>
<option value="izvanredni">Izvanredni</option>
</select></div>
<div class="field"><label>Ustanova</label>
<input name="ustanova" type="text" value="ZZJZ PGŽ"></div>
<div class="field"><label>Liječnik</label>
<input name="lijecnik" type="text"></div>
<div class="field"><label>Iznos (EUR)</label>
<input name="iznos" type="number" step="0.01" value="60"></div>
<div style="text-align:right">
<button type="button" class="btn" onclick="closeModal()">Odustani</button>
<button type="submit" class="btn primary">💾 Spremi pregled</button>
</div>
</form>
</div>`);
}
async function submitNewLijecnicki(e) {
e.preventDefault();
const f = e.target;
const body = {
clan_id: parseInt(f.clan_id.value),
datum_pregleda: f.datum_pregleda.value,
vrijedi_do: f.vrijedi_do.value || null,
vrsta_pregleda: f.vrsta_pregleda.value,
ustanova: f.ustanova.value,
lijecnik: f.lijecnik.value || null,
iznos: parseFloat(f.iznos.value || 0),
};
try {
await api('/lijecnicki', {method:'POST', body});
closeModal();
toast('Pregled spremljen.');
loadLijecnicki();
} catch (err) { toast('Greška: ' + err.message, true); }
}
// ════════════════════════════════════════════════════
// MODUL 3 — OBRASCI (M9)
// ════════════════════════════════════════════════════
async function loadObrasci() {
const root = $('#page-obrasci');
root.innerHTML = '<div class="loading">Učitavanje obrazaca…</div>';
let templates, submissions;
try {
templates = await api('/forms');
submissions = await api('/forms/submissions?limit=50');
} catch (e) { root.innerHTML = `<div class="empty">Greška: ${esc(e.message)}</div>`; return; }
$('#cnt-obrasci').textContent = templates.count;
const ss = submissions.summary || {};
const kpi = `
<div class="kpi-grid">
<div class="kpi b"><div class="kpi-l">Templati</div><div class="kpi-v">${fmt(templates.count)}</div></div>
<div class="kpi g"><div class="kpi-l">Predani</div><div class="kpi-v">${fmt(ss.submitted)}</div></div>
<div class="kpi a"><div class="kpi-l">Draft</div><div class="kpi-v">${fmt(ss.draft)}</div></div>
<div class="kpi b"><div class="kpi-l">Odobreni</div><div class="kpi-v">${fmt(ss.approved)}</div></div>
</div>`;
const cards = (templates.forms || []).map(f => `
<div class="card" style="margin-bottom:10px">
<div class="card-b" style="display:flex;justify-content:space-between;align-items:center">
<div>
<div style="font-weight:600">${esc(f.naziv)}</div>
<div style="font-size:11px;color:var(--t3);margin-top:3px">${esc(f.code)} · ${esc(f.kategorija || '—')} · ${f.field_count} polja${f.opis ? ' · ' + esc(f.opis.substring(0,80)) : ''}</div>
</div>
<button class="btn primary" onclick="openFormFill('${esc(f.code)}')">📝 Otvori obrazac</button>
</div>
</div>`).join('');
const subRows = (submissions.rows || []).map(s => `
<tr>
<td><b>${esc(s.template_naziv || s.template_code)}</b><div style="font-size:11px;color:var(--t3)">${esc(s.reference_no || '')}</div></td>
<td>${esc(s.klub_naziv || '—')}</td>
<td>${fmtDate(s.created_at)}</td>
<td><span class="tag ${({draft:'gy',submitted:'am',approved:'gr',rejected:'rd'})[s.status]||'gy'}">${esc(s.status)}</span></td>
<td><code style="font-size:10px;color:var(--ok)">${esc((s.signature_sha256 || '').substring(0,12))}${s.signature_sha256?'…':''}</code></td>
<td>
<button class="btn sm" onclick="openSubmissionDetalji(${s.id})" title="Detalji">👁</button>
<a class="btn sm" href="${API}/forms/submissions/${s.id}/pdf" target="_blank" title="PDF">📄</a>
</td>
</tr>`).join('');
root.innerHTML = kpi + `
<div class="row" style="display:grid;grid-template-columns:1fr 1.4fr;gap:14px">
<div>
<div class="card-h" style="border-radius:8px 8px 0 0;background:var(--bg2);border:1px solid var(--rim);border-bottom:none">
<div class="card-t">📋 Dostupni obrasci (${templates.count})</div>
</div>
<div style="background:var(--bg2);border:1px solid var(--rim);border-top:none;border-radius:0 0 8px 8px;padding:12px;max-height:600px;overflow-y:auto">${cards}</div>
</div>
<div>
<div class="card">
<div class="card-h"><div class="card-t">Predani obrasci (${submissions.count})</div></div>
<table>
<thead><tr><th>Obrazac</th><th>Klub</th><th>Datum</th><th>Status</th><th>SHA-256</th><th></th></tr></thead>
<tbody>${subRows || '<tr><td colspan="6" class="empty">Nema predanih obrazaca.</td></tr>'}</tbody>
</table>
</div>
</div>
</div>`;
}
async function openFormFill(code) {
let tpl, prefill;
try {
tpl = await api('/forms/' + code);
// prefill bez klub_id pretpostavlja prazan
prefill = await api(`/forms/${code}/prefill`);
} catch (e) { return toast('Greška: ' + e.message, true); }
const fields = (tpl.schema_json && tpl.schema_json.fields) || [];
const pre = prefill.prefill || {};
const fieldsHtml = fields.map(f => {
const v = pre[f.name] != null ? pre[f.name] : '';
const reqClass = f.required ? 'req' : '';
let inp = '';
if (f.type === 'textarea') {
inp = `<textarea name="${esc(f.name)}">${esc(v)}</textarea>`;
} else if (f.type === 'select' && Array.isArray(f.options)) {
inp = `<select name="${esc(f.name)}"><option value=""></option>${f.options.map(o => `<option ${o===v?'selected':''}>${esc(o)}</option>`).join('')}</select>`;
} else if (f.type === 'date') {
inp = `<input type="date" name="${esc(f.name)}" value="${esc(v)}">`;
} else if (f.type === 'number') {
inp = `<input type="number" name="${esc(f.name)}" value="${esc(v)}" ${f.required?'required':''}>`;
} else if (f.type === 'file') {
inp = `<input type="text" name="${esc(f.name)}" placeholder="(file upload — TODO)">`;
} else {
inp = `<input type="text" name="${esc(f.name)}" value="${esc(v)}" ${f.required?'required':''}>`;
}
return `<div class="field"><label class="${reqClass}">${esc(f.label || f.name)}</label>${inp}${f.help ? `<div class="help">${esc(f.help)}</div>` : ''}</div>`;
}).join('');
openModal(`
<div class="modal-h">
<div class="modal-t">📝 ${esc(tpl.naziv)}</div>
<div class="modal-x" onclick="closeModal()">×</div>
</div>
<div class="modal-b">
<p style="color:var(--t2);font-size:12px;margin-top:0">${esc(tpl.opis || '')} <br><span style="color:var(--t3)">Polja označena * su obavezna. Submit = digitalni potpis (sha256) + status "submitted".</span></p>
<form onsubmit="submitFormFill(event, '${esc(code)}')">
<div class="field"><label>Klub ID (opcionalno — za bolju autopopulaciju)</label>
<input id="fill-klub" type="number" placeholder="npr. 10" onchange="reloadPrefill('${esc(code)}', this.value)"></div>
${fieldsHtml}
<div class="field"><label>Vaše ime/prezime (digitalni potpis)</label>
<input name="__signer" type="text" placeholder="npr. Damir Radulić" required></div>
<div style="text-align:right;margin-top:14px">
<button type="button" class="btn" onclick="closeModal()">Odustani</button>
<button type="button" class="btn" onclick="saveFormDraft(event, '${esc(code)}', this)">💾 Spremi draft</button>
<button type="submit" class="btn primary">✍ Potpiši i predaj</button>
</div>
</form>
</div>`);
}
async function reloadPrefill(code, klubId) {
if (!klubId) return;
try {
const data = await api(`/forms/${code}/prefill?klub_id=${parseInt(klubId)}`);
Object.entries(data.prefill || {}).forEach(([k, v]) => {
const el = document.querySelector(`[name="${k}"]`);
if (el && !el.value) el.value = v;
});
toast(`Autopopulirano ${data.applied_fields.length} polja iz kluba ${klubId}`);
} catch (err) { toast('Prefill greška: ' + err.message, true); }
}
function _collectFormData(form) {
const data = {};
let signer = null;
let klubId = null;
Array.from(form.elements).forEach(el => {
if (!el.name) return;
if (el.name === '__signer') { signer = el.value; return; }
if (el.id === 'fill-klub') { klubId = el.value ? parseInt(el.value) : null; return; }
data[el.name] = el.value;
});
return {data, signer, klubId};
}
async function submitFormFill(e, code) {
e.preventDefault();
const {data, signer, klubId} = _collectFormData(e.target);
try {
// create draft
const draft = await api('/forms/submissions', {method:'POST', body: {
template_code: code, klub_id: klubId, data,
}});
// submit + sign
const signed = await api('/forms/submissions/' + draft.id + '/submit', {method:'POST', body: {
full_name: signer, confirm: true,
}});
closeModal();
toast('Obrazac potpisan i predan. SHA-256: ' + signed.signature_sha256.substring(0,12) + '…');
showSignatureConfirm(signed);
loadObrasci();
} catch (err) { toast('Greška: ' + err.message, true); }
}
async function saveFormDraft(e, code, btn) {
const form = btn.closest('form');
const {data, klubId} = _collectFormData(form);
try {
const draft = await api('/forms/submissions', {method:'POST', body: {
template_code: code, klub_id: klubId, data,
}});
closeModal();
toast('Spremljen draft #' + draft.id + ' (REF ' + draft.reference_no + ')');
loadObrasci();
} catch (err) { toast('Greška: ' + err.message, true); }
}
function showSignatureConfirm(signed) {
setTimeout(() => openModal(`
<div class="modal-h">
<div class="modal-t">✓ Obrazac digitalno potpisan</div>
<div class="modal-x" onclick="closeModal()">×</div>
</div>
<div class="modal-b">
<div class="payment-card">
<div class="payment-row"><div class="l">Submission ID</div><div class="v">#${signed.id}</div></div>
<div class="payment-row"><div class="l">Status</div><div class="v"><span class="tag am">${esc(signed.status)}</span></div></div>
<div class="payment-row"><div class="l">Potpisao</div><div class="v">${esc(signed.signed_by)}</div></div>
<div class="payment-row"><div class="l">Vrijeme</div><div class="v" style="font-size:11px">${esc(signed.signed_at)}</div></div>
</div>
<div class="signature-box">
<div style="color:var(--t2);margin-bottom:6px">DIGITALNI POTPIS — SHA-256</div>
<div class="sha">${esc(signed.signature_sha256)}</div>
</div>
<div style="text-align:right;margin-top:14px">
<a class="btn primary" href="${API}/forms/submissions/${signed.id}/pdf" target="_blank">📄 Preuzmi PDF</a>
</div>
</div>`), 200);
}
async function openSubmissionDetalji(sid) {
let s;
try { s = await api('/forms/submissions/' + sid); }
catch (e) { return toast('Greška: ' + e.message, true); }
const data = s.data || {};
const fields = (s.schema_json && s.schema_json.fields) || [];
const fieldsHtml = fields.filter(f => !f.name.startsWith('__')).map(f => {
const v = data[f.name];
if (v == null || v === '') return '';
return `<div class="payment-row"><div class="l">${esc(f.label || f.name)}</div><div class="v">${esc(v).substring(0,200)}</div></div>`;
}).join('');
const sig = data.__signature_sha256;
openModal(`
<div class="modal-h">
<div class="modal-t">📋 Submission #${s.id} — ${esc(s.template_naziv)}</div>
<div class="modal-x" onclick="closeModal()">×</div>
</div>
<div class="modal-b">
<div class="payment-card">
<div class="payment-row"><div class="l">Reference</div><div class="v">${esc(s.reference_no || '')}</div></div>
<div class="payment-row"><div class="l">Klub</div><div class="v">${esc(s.klub_naziv || '—')}</div></div>
<div class="payment-row"><div class="l">Status</div><div class="v"><span class="tag ${({draft:'gy',submitted:'am',approved:'gr',rejected:'rd'})[s.status]||'gy'}">${esc(s.status)}</span></div></div>
<div class="payment-row"><div class="l">Predano</div><div class="v">${fmtDate(s.submitted_at)}</div></div>
</div>
<div class="card-h" style="background:transparent;border:none;padding:8px 0;margin-top:14px"><div class="card-t">Sadržaj</div></div>
<div class="payment-card">${fieldsHtml || '<div style="color:var(--t3)">Prazno.</div>'}</div>
${sig ? `<div class="signature-box"><div style="color:var(--t2);margin-bottom:6px">DIGITALNI POTPIS — SHA-256</div><div class="sha">${esc(sig)}</div><div style="margin-top:6px;color:var(--t3)">Potpisao: ${esc(data.__signed_by||'')} • ${esc(data.__signed_at||'')}</div></div>` : '<div style="color:var(--err);margin-top:10px;font-size:12px">⚠ Nije digitalno potpisan</div>'}
<div style="text-align:right;margin-top:14px;display:flex;gap:8px;justify-content:flex-end">
${s.status === 'submitted' ? `
<button class="btn" onclick="approveSub(${s.id})">✓ Odobri</button>
<button class="btn danger" onclick="rejectSub(${s.id})">✗ Odbij</button>
` : ''}
<button class="btn" onclick="reSign(${s.id})">✍ Potpiši ponovno</button>
<a class="btn primary" href="${API}/forms/submissions/${s.id}/pdf" target="_blank">📄 PDF</a>
</div>
</div>`);
}
async function approveSub(sid) {
if (!confirm('Odobri submission #' + sid + '?')) return;
try {
await api('/forms/submissions/' + sid + '/approve', {method:'POST', body: {user_id: 1}});
closeModal(); toast('Submission #' + sid + ' odobren.'); loadObrasci();
} catch (e) { toast('Greška: ' + e.message, true); }
}
async function rejectSub(sid) {
const reason = prompt('Razlog odbijanja:');
if (!reason) return;
try {
await api('/forms/submissions/' + sid + '/reject', {method:'POST', body: {user_id: 1, reason}});
closeModal(); toast('Submission #' + sid + ' odbijen.'); loadObrasci();
} catch (e) { toast('Greška: ' + e.message, true); }
}
async function reSign(sid) {
const name = prompt('Vaše ime za potpis:');
if (!name) return;
try {
const r = await api('/forms/submissions/' + sid + '/sign', {method:'POST', body: {full_name: name, user_id: 1}});
closeModal(); toast('Potpisano. SHA-256: ' + r.signature_sha256.substring(0,12) + '…'); loadObrasci();
} catch (e) { toast('Greška: ' + e.message, true); }
}
// ────────────────────────────────────────────────────
// init
// ────────────────────────────────────────────────────
loadClanarine();
// preload counts
(async () => {
try {
const lj = await api('/lijecnicki?limit=1');
$('#cnt-lijecnicki').textContent = lj.summary?.total ?? '?';
const fm = await api('/forms');
$('#cnt-obrasci').textContent = fm.count;
} catch (e) {}
})();
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff