7-sub sprint UI residual: footer login + kalendar CRUD + notif center + CRM extra tabs

A: shared/sidebar.js footer onclick → handleFootClick (Guest→/sport/login, logged-in→logout()), a11y role+keyboard, popup link fix
B: app.html SECTIONS['kalendar'] kalOpenModal/Save/Edit/Delete + Akcije kolona, mock savez:kalendar maknut
C: app.html renderNotifCenter() (sve 4 role) + sidebar bell unread badge (30s poll)
F: crm_v2.html +443 linija — Članarine, Liječnički, Obrasci tabovi (split view + dynamic schema modal)
G: index.html minor + sidebar dokumenti link refresh

Note: backend (kalendar_router, notif_router, crm_router, erp_full_router uploads, dokumenti unified) već u commit f7b5114.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Damir Radulić
2026-05-05 13:55:33 +02:00
parent f7b5114f58
commit ce544e660c
4 changed files with 603 additions and 0 deletions
+443
View File
@@ -1192,10 +1192,452 @@ async function delCase(id) {
catch (e) { toast(e.message, 'err'); }
}
// ══════════════════════════════════════════════════════════════════
// AGENT F — Članarine / Liječnički / Obrasci
// ══════════════════════════════════════════════════════════════════
let CURRENT_USER = null;
async function ensureMe() {
if (CURRENT_USER) return CURRENT_USER;
const candidates = ['/sport/api/auth/me', '/sport/api/v2/auth/me', '/sport/api/v2/me'];
for (const url of candidates) {
try {
const r = await fetch(url, {headers:{'Authorization':'Bearer '+TOKEN}});
if (r.ok) { CURRENT_USER = await r.json(); break; }
} catch {}
}
return CURRENT_USER;
}
function isAdminUser() {
if (!CURRENT_USER) return false;
const t = CURRENT_USER.user_type || CURRENT_USER.role || (CURRENT_USER.user && CURRENT_USER.user.user_type) || '';
return t === 'super_admin' || t === 'pgz_admin';
}
// ────── Članarine ──────
async function loadClanarine() {
const klub = document.getElementById('cln-klub').value.trim();
const clan = document.getElementById('cln-clan').value.trim();
const god = document.getElementById('cln-godina').value.trim();
const st = document.getElementById('cln-status').value;
const qs = new URLSearchParams();
if (klub) qs.set('klub_id', klub);
if (clan) qs.set('clan_id', clan);
if (god) qs.set('godina', god);
if (st) qs.set('status', st);
try {
const data = await api('/clanarine?'+qs.toString());
const tb = document.querySelector('#t-clanarine tbody');
tb.innerHTML = (data.items||[]).map(c => `
<tr onclick="editClanarina(${c.id})">
<td><strong>${esc(c.clan_naziv||'—')}</strong> <span style="color:var(--t3)">#${c.clan_id||''}</span></td>
<td>${esc(c.klub_naziv||'—')}</td>
<td>${c.godina}</td>
<td>${esc(c.razdoblje||'—')}</td>
<td>${fmtEur(c.iznos_propisan)}</td>
<td>${fmtEur(c.iznos_placen)}</td>
<td>${fmtDate(c.datum_uplate)}</td>
<td><span class="chip ${c.status}">${c.status}</span></td>
<td>${isAdminUser() ? `<button class="btn sm" onclick="event.stopPropagation();delClanarina(${c.id})">×</button>` : ''}</td>
</tr>
`).join('') || '<tr><td colspan="9" class="empty">Nema članarina za odabrane filtere.</td></tr>';
document.getElementById('cnt-clanarine').textContent = data.count ?? (data.items||[]).length;
} catch (e) { toast('Članarine err: '+e.message, 'err'); }
}
function clanarinaFormHTML(c={}) {
return `
<div class="fld-row">
<div class="fld"><label>Klub ID</label><input id="f-klub_id" type="number" value="${c.klub_id||''}"></div>
<div class="fld"><label>Član ID</label><input id="f-clan_id" type="number" value="${c.clan_id||''}"></div>
</div>
<div class="fld-row">
<div class="fld"><label>Godina*</label><input id="f-godina" type="number" value="${c.godina||(new Date()).getFullYear()}"></div>
<div class="fld"><label>Razdoblje</label><input id="f-razdoblje" value="${esc(c.razdoblje||'')}" placeholder="npr. cijela godina"></div>
</div>
<div class="fld-row">
<div class="fld"><label>Iznos propisan (EUR)*</label><input id="f-iznos_propisan" type="number" step="0.01" value="${c.iznos_propisan||''}"></div>
<div class="fld"><label>Iznos plaćen (EUR)</label><input id="f-iznos_placen" type="number" step="0.01" value="${c.iznos_placen||0}"></div>
</div>
<div class="fld-row">
<div class="fld"><label>Datum uplate</label><input id="f-datum_uplate" type="date" value="${(c.datum_uplate||'').slice(0,10)}"></div>
<div class="fld"><label>Način uplate</label><input id="f-nacin_uplate" value="${esc(c.nacin_uplate||'')}" placeholder="virman / kartica / gotovina"></div>
</div>
<div class="fld-row">
<div class="fld"><label>Referenca</label><input id="f-referenca" value="${esc(c.referenca||'')}"></div>
<div class="fld"><label>Račun broj</label><input id="f-racun_broj" value="${esc(c.racun_broj||'')}"></div>
</div>
<div class="fld"><label>Status</label>
<select id="f-status">
${['nepodmireno','djelomicno','podmireno','storno'].map(x=>`<option value="${x}" ${c.status===x?'selected':''}>${x}</option>`).join('')}
</select>
</div>
<div class="fld"><label>Napomena</label><textarea id="f-napomena">${esc(c.napomena||'')}</textarea></div>
`;
}
function readClanarinaForm() {
return {
klub_id: parseInt(document.getElementById('f-klub_id').value)||null,
clan_id: parseInt(document.getElementById('f-clan_id').value)||null,
godina: parseInt(document.getElementById('f-godina').value)||null,
razdoblje: document.getElementById('f-razdoblje').value.trim()||null,
iznos_propisan: parseFloat(document.getElementById('f-iznos_propisan').value)||0,
iznos_placen: parseFloat(document.getElementById('f-iznos_placen').value)||0,
datum_uplate: document.getElementById('f-datum_uplate').value||null,
nacin_uplate: document.getElementById('f-nacin_uplate').value.trim()||null,
referenca: document.getElementById('f-referenca').value.trim()||null,
racun_broj: document.getElementById('f-racun_broj').value.trim()||null,
status: document.getElementById('f-status').value,
napomena: document.getElementById('f-napomena').value||null,
};
}
function openClanarinaModal(c) {
const isEdit = !!(c && c.id);
showModal(isEdit?'Uredi članarinu':'Nova članarina',
clanarinaFormHTML(c||{godina:(new Date()).getFullYear(), status:'nepodmireno'}),
async () => {
const body = readClanarinaForm();
if (!body.godina || !body.iznos_propisan) { toast('Godina i iznos propisan su obavezni', 'err'); return; }
try {
if (isEdit) await api('/clanarine/'+c.id, {method:'PUT', body:JSON.stringify(body)});
else await api('/clanarine', {method:'POST', body:JSON.stringify(body)});
toast('Spremljeno'); closeModal(); loadClanarine();
} catch (e) { toast('Greška: '+e.message, 'err'); }
});
}
async function editClanarina(id) {
try { const c = await api('/clanarine/'+id); openClanarinaModal(c); }
catch (e) { toast(e.message, 'err'); }
}
async function delClanarina(id) {
if (!confirm('Obrisati članarinu #'+id+'?')) return;
try { await api('/clanarine/'+id, {method:'DELETE'}); toast('Obrisano'); loadClanarine(); }
catch (e) { toast(e.message, 'err'); }
}
// ────── Liječnički ──────
async function loadLijecnicki() {
const klub = document.getElementById('lij-klub').value.trim();
const clan = document.getElementById('lij-clan').value.trim();
const exp = document.getElementById('lij-expiring').value;
const qs = new URLSearchParams();
if (klub) qs.set('klub_id', klub);
if (clan) qs.set('clan_id', clan);
if (exp) qs.set('expiring', exp);
try {
const data = await api('/lijecnicki?'+qs.toString());
const tb = document.querySelector('#t-lijecnicki tbody');
const today = new Date().toISOString().slice(0,10);
tb.innerHTML = (data.items||[]).map(l => {
const expired = l.vrijedi_do && l.vrijedi_do < today;
return `
<tr onclick="editLijecnicki(${l.id})">
<td><strong>${esc(l.clan_naziv||'—')}</strong> <span style="color:var(--t3)">#${l.clan_id||''}</span></td>
<td>${esc(l.klub_naziv||'—')}</td>
<td>${fmtDate(l.datum_pregleda)}</td>
<td>${esc(l.vrsta_pregleda||'—')}</td>
<td>${fmtDate(l.vrijedi_do)} ${expired?'<span class="chip nepodmireno" style="margin-left:6px;">istekao</span>':''}</td>
<td>${esc(l.lijecnik||'—')}</td>
<td>${l.spreman_za_natjecanje ? '<span class="chip spreman">DA</span>' : '<span class="chip nije-spreman">NE</span>'}</td>
<td>${l.placeno ? '<span class="chip podmireno">DA</span>' : '<span class="chip nepodmireno">NE</span>'}</td>
<td>${isAdminUser() ? `<button class="btn sm" onclick="event.stopPropagation();delLijecnicki(${l.id})">×</button>` : ''}</td>
</tr>`;
}).join('') || '<tr><td colspan="9" class="empty">Nema liječničkih pregleda.</td></tr>';
document.getElementById('cnt-lijecnicki').textContent = data.count ?? (data.items||[]).length;
} catch (e) { toast('Liječnički err: '+e.message, 'err'); }
}
function lijecnickiFormHTML(l={}) {
return `
<div class="fld-row">
<div class="fld"><label>Član ID*</label><input id="f-clan_id" type="number" value="${l.clan_id||''}"></div>
<div class="fld"><label>Klub ID</label><input id="f-klub_id" type="number" value="${l.klub_id||''}"></div>
</div>
<div class="fld-row">
<div class="fld"><label>Datum pregleda*</label><input id="f-datum_pregleda" type="date" value="${(l.datum_pregleda||'').slice(0,10)}"></div>
<div class="fld"><label>Vrijedi do</label><input id="f-vrijedi_do" type="date" value="${(l.vrijedi_do||'').slice(0,10)}"></div>
</div>
<div class="fld-row">
<div class="fld"><label>Vrsta pregleda</label>
<select id="f-vrsta_pregleda">
<option value="">—</option>
${['osnovni','prosireni','specijalisticki','kontrolni','povratak_nakon_ozljede'].map(x=>`<option value="${x}" ${l.vrsta_pregleda===x?'selected':''}>${x}</option>`).join('')}
</select>
</div>
<div class="fld"><label>Ustanova</label><input id="f-ustanova" value="${esc(l.ustanova||'')}"></div>
</div>
<div class="fld"><label>Liječnik</label><input id="f-lijecnik" value="${esc(l.lijecnik||'')}"></div>
<div class="fld-row">
<div class="fld"><label><input id="f-spreman" type="checkbox" ${l.spreman_za_natjecanje!==false?'checked':''}> Spreman za natjecanje</label></div>
<div class="fld"><label><input id="f-placeno" type="checkbox" ${l.placeno?'checked':''}> Plaćeno</label></div>
</div>
<div class="fld-row">
<div class="fld"><label><input id="f-ekg" type="checkbox" ${l.ekg?'checked':''}> EKG</label></div>
<div class="fld"><label><input id="f-krv" type="checkbox" ${l.krv?'checked':''}> Krvna slika</label></div>
<div class="fld"><label><input id="f-spirometrija" type="checkbox" ${l.spirometrija?'checked':''}> Spirometrija</label></div>
</div>
<div class="fld-row">
<div class="fld"><label>Iznos ukupno (EUR)</label><input id="f-iznos" type="number" step="0.01" value="${l.iznos||''}"></div>
<div class="fld"><label>Datum plaćanja</label><input id="f-datum_placanja" type="date" value="${(l.datum_placanja||'').slice(0,10)}"></div>
</div>
<div class="fld-row">
<div class="fld"><label>ZZJZ EUR</label><input id="f-iznos_zzjz" type="number" step="0.01" value="${l.iznos_zzjz||0}"></div>
<div class="fld"><label>Klub EUR</label><input id="f-iznos_klub" type="number" step="0.01" value="${l.iznos_klub||0}"></div>
<div class="fld"><label>Član EUR</label><input id="f-iznos_clan" type="number" step="0.01" value="${l.iznos_clan||0}"></div>
</div>
<div class="fld"><label>Nalaz</label><textarea id="f-nalaz">${esc(l.nalaz||'')}</textarea></div>
<div class="fld"><label>Komentar liječnika</label><textarea id="f-komentar_lijecnika">${esc(l.komentar_lijecnika||'')}</textarea></div>
<div class="fld"><label>Napomena</label><textarea id="f-napomena">${esc(l.napomena||'')}</textarea></div>
`;
}
function readLijecnickiForm() {
return {
clan_id: parseInt(document.getElementById('f-clan_id').value)||null,
klub_id: parseInt(document.getElementById('f-klub_id').value)||null,
datum_pregleda: document.getElementById('f-datum_pregleda').value||null,
vrijedi_do: document.getElementById('f-vrijedi_do').value||null,
vrsta_pregleda: document.getElementById('f-vrsta_pregleda').value||null,
ustanova: document.getElementById('f-ustanova').value.trim()||null,
lijecnik: document.getElementById('f-lijecnik').value.trim()||null,
spreman_za_natjecanje: document.getElementById('f-spreman').checked,
ekg: document.getElementById('f-ekg').checked,
krv: document.getElementById('f-krv').checked,
spirometrija: document.getElementById('f-spirometrija').checked,
nalaz: document.getElementById('f-nalaz').value||null,
komentar_lijecnika: document.getElementById('f-komentar_lijecnika').value||null,
iznos: parseFloat(document.getElementById('f-iznos').value)||null,
iznos_zzjz: parseFloat(document.getElementById('f-iznos_zzjz').value)||0,
iznos_klub: parseFloat(document.getElementById('f-iznos_klub').value)||0,
iznos_clan: parseFloat(document.getElementById('f-iznos_clan').value)||0,
datum_placanja: document.getElementById('f-datum_placanja').value||null,
placeno: document.getElementById('f-placeno').checked,
napomena: document.getElementById('f-napomena').value||null,
};
}
function openLijecnickiModal(l) {
const isEdit = !!(l && l.id);
showModal(isEdit?'Uredi liječnički pregled':'Novi liječnički pregled',
lijecnickiFormHTML(l||{spreman_za_natjecanje:true}),
async () => {
const body = readLijecnickiForm();
if (!body.clan_id || !body.datum_pregleda) { toast('Član i datum su obavezni', 'err'); return; }
try {
if (isEdit) await api('/lijecnicki/'+l.id, {method:'PUT', body:JSON.stringify(body)});
else await api('/lijecnicki', {method:'POST', body:JSON.stringify(body)});
toast('Spremljeno'); closeModal(); loadLijecnicki();
} catch (e) { toast('Greška: '+e.message, 'err'); }
});
}
async function editLijecnicki(id) {
try { const l = await api('/lijecnicki/'+id); openLijecnickiModal(l); }
catch (e) { toast(e.message, 'err'); }
}
async function delLijecnicki(id) {
if (!confirm('Obrisati pregled #'+id+'?')) return;
try { await api('/lijecnicki/'+id, {method:'DELETE'}); toast('Obrisano'); loadLijecnicki(); }
catch (e) { toast(e.message, 'err'); }
}
// ────── Obrasci ──────
let OBR_TEMPLATES = [];
let OBR_SELECTED_TPL = null;
async function loadObrasciTemplates() {
try {
const data = await api('/obrasci');
OBR_TEMPLATES = data.items||[];
const list = document.getElementById('obr-tpl-list');
if (!OBR_TEMPLATES.length) {
list.innerHTML = '<div class="empty">Nema predložaka.</div>';
return;
}
list.innerHTML = OBR_TEMPLATES.map(t => `
<div class="tpl-row" data-id="${t.id}" onclick="selectObrasciTpl(${t.id})">
<div class="tpl-n">${esc(t.naziv)}</div>
<div class="tpl-c">${esc(t.kategorija||'—')} · ${esc(t.code)}</div>
</div>
`).join('');
document.getElementById('cnt-obrasci').textContent = OBR_TEMPLATES.length;
} catch (e) { toast('Obrasci err: '+e.message, 'err'); }
}
function selectObrasciTpl(id) {
OBR_SELECTED_TPL = OBR_TEMPLATES.find(t => t.id === id);
document.querySelectorAll('#obr-tpl-list .tpl-row').forEach(r =>
r.classList.toggle('sel', parseInt(r.dataset.id) === id));
document.getElementById('obr-right-title').textContent =
'Predložak: ' + (OBR_SELECTED_TPL?.naziv || '');
openObrasciSubmitModal();
}
function obrasciSubmitFormHTML(t) {
const sch = (t.schema_json && t.schema_json.fields) || [];
let inner = '';
if (sch.length) {
inner = sch.map((f, i) => {
const lbl = esc(f.label || f.name || ('Polje '+(i+1)));
const req = f.required ? '*' : '';
const name = esc(f.name || ('f'+i));
const type = f.type || 'text';
if (type === 'textarea') {
return `<div class="fld"><label>${lbl}${req}</label><textarea data-fname="${name}"></textarea></div>`;
}
if (type === 'select' && Array.isArray(f.options)) {
return `<div class="fld"><label>${lbl}${req}</label><select data-fname="${name}">
${f.options.map(o => `<option value="${esc(o)}">${esc(o)}</option>`).join('')}
</select></div>`;
}
const it = (type==='number'?'number': type==='date'?'date': type==='email'?'email':'text');
return `<div class="fld"><label>${lbl}${req}</label><input data-fname="${name}" type="${it}"></div>`;
}).join('');
} else {
inner = `<div class="fld"><label>Podaci (JSON)</label><textarea data-fname="__json" placeholder='{"polje":"vrijednost"}'></textarea></div>`;
}
return `
<div style="background:var(--bg3); padding:8px 10px; border-radius:4px; margin-bottom:10px; font-size:11px; color:var(--t2);">
<strong>${esc(t.naziv)}</strong> · ${esc(t.kategorija||'—')}<br>
${esc(t.opis||'')}
</div>
<div class="fld-row">
<div class="fld"><label>Klub ID</label><input id="f-sub-klub" type="number"></div>
<div class="fld"><label>Član ID</label><input id="f-sub-clan" type="number"></div>
</div>
${inner}
<div class="fld"><label>Status</label>
<select id="f-sub-status">
<option value="draft">draft</option>
<option value="submitted" selected>submitted</option>
</select>
</div>
`;
}
function openObrasciSubmitModal() {
const t = OBR_SELECTED_TPL; if (!t) return;
showModal('Podnesi: '+t.naziv, obrasciSubmitFormHTML(t), async () => {
const klub_id = parseInt(document.getElementById('f-sub-klub').value)||null;
const clan_id = parseInt(document.getElementById('f-sub-clan').value)||null;
const status = document.getElementById('f-sub-status').value;
const data = {};
document.querySelectorAll('#m-body [data-fname]').forEach(el => {
const k = el.dataset.fname;
if (k === '__json') {
try { Object.assign(data, JSON.parse(el.value||'{}')); }
catch(e) { toast('JSON nije validan', 'err'); throw e; }
} else {
data[k] = el.value;
}
});
try {
await api('/obrasci/submission', {method:'POST', body:JSON.stringify({
template_id: t.id, template_code: t.code, klub_id, clan_id, data, status
})});
toast('Obrazac podnesen'); closeModal(); loadObrasciSubmissions();
} catch (e) { toast('Greška: '+e.message, 'err'); }
});
}
async function loadObrasciSubmissions() {
const st = document.getElementById('obr-status').value;
const klb = document.getElementById('obr-klub').value.trim();
const qs = new URLSearchParams();
if (st) qs.set('status', st);
if (klb) qs.set('klub_id', klb);
try {
const data = await api('/obrasci/submission?'+qs.toString());
const tb = document.querySelector('#t-obr-sub tbody');
const admin = isAdminUser();
tb.innerHTML = (data.items||[]).map(s => `
<tr onclick="openObrasciSubDetail(${s.id})">
<td><strong>#${s.id}</strong></td>
<td>${esc(s.template_naziv||s.template_code||'—')}</td>
<td>${esc(s.klub_naziv||'—')}</td>
<td>${esc(s.clan_naziv||'—')}</td>
<td><span class="chip ${s.status}">${s.status}</span></td>
<td>${fmtDT(s.submitted_at)}</td>
<td>${fmtDT(s.approved_at)}</td>
<td>${admin && (s.status==='submitted'||s.status==='draft') ? `
<button class="btn sm gold" onclick="event.stopPropagation();subStatus(${s.id},'approved')">✓</button>
<button class="btn sm danger" onclick="event.stopPropagation();subStatus(${s.id},'rejected')">×</button>
` : ''}</td>
</tr>
`).join('') || '<tr><td colspan="8" class="empty">Nema podnesenih obrazaca.</td></tr>';
} catch (e) { toast('Submissions err: '+e.message, 'err'); }
}
async function openObrasciSubDetail(id) {
try {
const s = await api('/obrasci/submission/'+id);
const admin = isAdminUser();
const dataPretty = JSON.stringify(s.data||{}, null, 2);
const body = `
<div style="background:var(--bg3); padding:9px 11px; border-radius:4px; margin-bottom:10px; font-size:11px;">
<div><strong>${esc(s.template_naziv||'—')}</strong> <span style="color:var(--t3)">(${esc(s.template_code||'')})</span></div>
<div style="margin-top:3px; color:var(--t2);">
Klub: ${esc(s.klub_naziv||'—')} · Član: ${esc(s.clan_naziv||'—')}<br>
Submitter: ${esc(s.submitter_email||'—')} · Status: <span class="chip ${s.status}">${s.status}</span>
</div>
<div style="margin-top:4px; color:var(--t3); font-family:var(--mono); font-size:10px;">
submitted: ${fmtDT(s.submitted_at)} · reviewed: ${fmtDT(s.reviewed_at)} · approved: ${fmtDT(s.approved_at)}
${s.rejected_reason ? '<br>rejected_reason: '+esc(s.rejected_reason) : ''}
</div>
</div>
<div class="fld"><label>Podaci (JSON)</label>
<textarea readonly style="font-family:var(--mono); min-height:200px;">${esc(dataPretty)}</textarea>
</div>
`;
document.getElementById('m-title').textContent = 'Obrazac #'+s.id;
document.getElementById('m-body').innerHTML = body;
const foot = document.getElementById('m-foot');
foot.innerHTML = `<button class="btn" onclick="closeModal()">Zatvori</button>` +
(admin && (s.status==='submitted'||s.status==='draft') ? `
<button class="btn danger" onclick="subStatusFromModal(${s.id},'rejected')">Odbij</button>
<button class="btn primary" onclick="subStatusFromModal(${s.id},'approved')">Odobri</button>
` : '') +
((s.status==='draft' && (CURRENT_USER && (CURRENT_USER.user_id===s.user_id || admin))) ?
`<button class="btn gold" onclick="subStatusFromModal(${s.id},'submitted')">Pošalji</button>` : '');
document.getElementById('modal').classList.add('on');
} catch (e) { toast(e.message, 'err'); }
}
async function subStatusFromModal(id, status) {
let reason = null;
if (status === 'rejected') {
reason = prompt('Razlog odbijanja:'); if (reason === null) return;
}
try {
await api('/obrasci/submission/'+id+'/status', {
method:'PUT', body:JSON.stringify({status, rejected_reason: reason})
});
toast('Status: '+status);
closeModal();
// Restore footer
document.getElementById('m-foot').innerHTML = `
<button class="btn" onclick="closeModal()">Odustani</button>
<button class="btn primary" id="m-save">Spremi</button>`;
loadObrasciSubmissions();
} catch (e) { toast('Greška: '+e.message, 'err'); }
}
async function subStatus(id, status) {
let reason = null;
if (status === 'rejected') {
reason = prompt('Razlog odbijanja:'); if (reason === null) return;
}
try {
await api('/obrasci/submission/'+id+'/status', {
method:'PUT', body:JSON.stringify({status, rejected_reason: reason})
});
toast('Status: '+status); loadObrasciSubmissions();
} catch (e) { toast('Greška: '+e.message, 'err'); }
}
// ────── Modal helpers ──────
function showModal(title, bodyHTML, onSave) {
document.getElementById('m-title').textContent = title;
document.getElementById('m-body').innerHTML = bodyHTML;
// Restore the standard footer (it may have been replaced by detail views)
document.getElementById('m-foot').innerHTML =
'<button class="btn" onclick="closeModal()">Odustani</button>' +
'<button class="btn primary" id="m-save">Spremi</button>';
document.getElementById('m-save').onclick = onSave;
document.getElementById('modal').classList.add('on');
}
@@ -1206,6 +1648,7 @@ document.getElementById('modal').addEventListener('click', e => {
// ────── Init ──────
loadMe();
ensureMe();
loadPipeline();
</script>
</body>