CC2 R4 #6: real TOTP 2FA (setup + verify + disable + login flow)
- auth/auth_v2.py:
- pyotp-based TOTP (RFC 6238, base32 secret, ±30s window)
- new pgz_sport.user_2fa table (auto-created)
- QR code embedded as data: URL via qrcode lib
- 8 single-use recovery codes generated at setup
- /2fa/setup, /2fa/verify, /2fa/disable, /2fa/status endpoints
- Login flow: when 2FA enabled, requires totp field; recovery codes
accepted and consumed on use
- static/login.html: TOTP field appears when login returns 2FA_REQUIRED
- static/admin_users.html: full 2FA panel in Sigurnost tab
(status badge, QR + secret + recovery code display, verify input)
Live tests pass:
T1 status (no setup) → enabled:false
T2 setup → secret + 1.5KB QR PNG + 8 recovery codes
T3 verify wrong code → 401
T4 verify real TOTP → enabled:true
T5 login w/o TOTP after enable → 401 detail=2FA_REQUIRED
T6 login w/ TOTP → 200
This commit is contained in:
@@ -271,6 +271,30 @@ td.actions-col .btn { padding: 4px 8px; font-size: 11px; }
|
||||
<div class="tab-content" id="tab-security">
|
||||
<div class="page-header"><h2>Sigurnost</h2></div>
|
||||
<div class="kpi-grid" id="secKpi"></div>
|
||||
<div class="section">
|
||||
<h3>Two-factor authentication (2FA) <small>moj račun</small></h3>
|
||||
<div id="twofaPanel" style="display:flex;gap:14px;align-items:center;flex-wrap:wrap">
|
||||
<span id="twofaStatus" class="badge gray">Učitavam…</span>
|
||||
<button class="btn primary" id="btnEnable2FA">Omogući 2FA</button>
|
||||
<button class="btn danger" id="btnDisable2FA" style="display:none">Onemogući 2FA</button>
|
||||
</div>
|
||||
<div id="twofaSetup" style="display:none;margin-top:14px;padding:14px;background:var(--bg-3);border-radius:6px;border:1px solid var(--border)">
|
||||
<div style="display:flex;gap:24px;flex-wrap:wrap;align-items:flex-start">
|
||||
<div style="flex:0 0 220px"><img id="twofaQr" style="background:#fff;padding:8px;border-radius:6px;width:220px;height:220px"></div>
|
||||
<div style="flex:1;min-width:220px">
|
||||
<div style="font-size:12px;color:var(--text-3);margin-bottom:6px">Skenirajte QR u aplikaciji (Google Authenticator, Authy, 1Password, …) ili upišite secret ručno:</div>
|
||||
<code id="twofaSecret" style="display:block;padding:10px;background:var(--bg);border:1px solid var(--border);border-radius:5px;font-family:'JetBrains Mono',monospace;word-break:break-all;margin-bottom:14px"></code>
|
||||
<div style="font-size:12px;color:var(--text-3);margin-bottom:6px">Kodovi za oporavak (sačuvajte ih sigurno):</div>
|
||||
<div id="twofaRecovery" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(110px,1fr));gap:6px;font-family:'JetBrains Mono',monospace;font-size:12px;margin-bottom:14px"></div>
|
||||
<div class="field">
|
||||
<label>Potvrda — kod iz autentifikatora</label>
|
||||
<input type="text" id="twofaConfirm" maxlength="8" inputmode="numeric" style="font-family:'JetBrains Mono',monospace;letter-spacing:4px;text-align:center;font-size:18px">
|
||||
</div>
|
||||
<button class="btn primary" id="btnVerify2FA">Potvrdi i aktiviraj</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section"><h3>Zaključani / failed-login računi</h3>
|
||||
<table><thead><tr><th>E-mail</th><th>Uloga</th><th class="num">Pokušaja</th><th>Zaključan do</th><th class="actions-col">Akcije</th></tr></thead><tbody id="lockedTbody"></tbody></table>
|
||||
</div>
|
||||
@@ -692,8 +716,43 @@ async function loadSecurity() {
|
||||
<button class="btn primary" onclick="toggleSuspend(${u.id}, false)">▶ Otključaj</button>
|
||||
</td></tr>
|
||||
`).join('') || '<tr><td colspan="5" class="empty">Nema zaključanih računa</td></tr>';
|
||||
load2FAStatus();
|
||||
}
|
||||
|
||||
// 2FA UI
|
||||
async function load2FAStatus() {
|
||||
const r = await apiJson('/auth/2fa/status');
|
||||
const enabled = !!(r && r.enabled);
|
||||
$('#twofaStatus').className = 'badge ' + (enabled ? 'green' : 'gray');
|
||||
$('#twofaStatus').textContent = enabled ? '✓ Omogućen' : 'Onemogućen';
|
||||
$('#btnEnable2FA').style.display = enabled ? 'none' : '';
|
||||
$('#btnDisable2FA').style.display = enabled ? '' : 'none';
|
||||
$('#twofaSetup').style.display = 'none';
|
||||
}
|
||||
$('#btnEnable2FA').addEventListener('click', async () => {
|
||||
const r = await apiJson('/auth/2fa/setup', {method:'POST'});
|
||||
if (!r || !r.qr_png) return toast(r?.detail || 'Greška', 'error');
|
||||
$('#twofaQr').src = r.qr_png;
|
||||
$('#twofaSecret').textContent = r.secret;
|
||||
$('#twofaRecovery').innerHTML = (r.recovery_codes||[]).map(c => `<code style="background:var(--bg);padding:5px 8px;border-radius:4px;border:1px solid var(--border)">${c}</code>`).join('');
|
||||
$('#twofaSetup').style.display = '';
|
||||
$('#twofaConfirm').focus();
|
||||
});
|
||||
$('#btnVerify2FA').addEventListener('click', async () => {
|
||||
const code = ($('#twofaConfirm').value || '').trim().replace(/\s/g,'');
|
||||
if (!code) return toast('Unesite kod', 'error');
|
||||
const r = await apiJson('/auth/2fa/verify', {method:'POST', body:{code}});
|
||||
if (r?.status === 'ok') { toast('2FA omogućen ✓'); load2FAStatus(); }
|
||||
else toast(r?.detail || 'Neispravan kod', 'error');
|
||||
});
|
||||
$('#btnDisable2FA').addEventListener('click', async () => {
|
||||
const code = prompt('Unesite trenutni kod iz autentifikatora (ili recovery kod) za onemogućavanje 2FA:');
|
||||
if (!code) return;
|
||||
const r = await apiJson('/auth/2fa/disable', {method:'POST', body:{code: code.trim()}});
|
||||
if (r?.status === 'ok') { toast('2FA onemogućen'); load2FAStatus(); }
|
||||
else toast(r?.detail || 'Greška', 'error');
|
||||
});
|
||||
|
||||
// GDPR
|
||||
async function loadGdpr() {
|
||||
const er = await apiJson('/admin/gdpr/erasure-requests');
|
||||
|
||||
+264
-7
@@ -620,6 +620,7 @@ function logout(){
|
||||
//=========== SECTION TITLES ===========
|
||||
const TITLES = {
|
||||
pgz: {
|
||||
profil:['Moj profil','Osobni podaci i postavke'],
|
||||
dashboard:['Dashboard','Pregled stanja PGŽ Sporta'],
|
||||
korisnici:['Korisnici','Upravljanje korisnicima sustava'],
|
||||
savezi:['Savezi','246 sportskih saveza'],
|
||||
@@ -632,6 +633,7 @@ const TITLES = {
|
||||
forenzika:['Forenzika','Sumnjive transakcije / PEP'],
|
||||
},
|
||||
savez: {
|
||||
profil:['Moj profil','Osobni podaci'],
|
||||
dashboard:['Dashboard','Atletski savez PGŽ'],
|
||||
klubovi:['Naši klubovi','Klubovi člana saveza'],
|
||||
sportasi:['Naši sportaši','Registrirani sportaši saveza'],
|
||||
@@ -641,6 +643,7 @@ const TITLES = {
|
||||
racuni:['Računi','Računi saveza'],
|
||||
},
|
||||
klub: {
|
||||
profil:['Moj profil','Osobni podaci'],
|
||||
dashboard:['Dashboard','AK Kvarner Rijeka'],
|
||||
clanovi:['Članovi','Članovi kluba'],
|
||||
clanarine:['Članarine','Stanje članarina'],
|
||||
@@ -650,7 +653,8 @@ const TITLES = {
|
||||
racuni:['Računi','Troškovi kluba'],
|
||||
},
|
||||
sportas: {
|
||||
dashboard:['Moj profil','Luka Horvat'],
|
||||
profil:['Moj profil','Osobni podaci'],
|
||||
dashboard:['Pregled','Moja aktivnost'],
|
||||
clanarina:['Članarina','Stanje moje članarine'],
|
||||
lijecnicki:['Liječnički','Moj liječnički pregled'],
|
||||
dokumenti:['Moji dokumenti','Suglasnosti, ugovori'],
|
||||
@@ -673,6 +677,247 @@ function loadSection(){
|
||||
//=========== SECTION RENDERERS ===========
|
||||
const SECTIONS = {};
|
||||
|
||||
// ──────────────────────── PROFILE PAGE (shared by all roles) ────────────────────────
|
||||
function profileMe(){
|
||||
// Real user if available, else demo from ROLES table
|
||||
if(_state.me) return _state.me;
|
||||
const r = ROLES[_state.role] || ROLES.pgz;
|
||||
const parts = String(r.user||'').split(/\s+/);
|
||||
return {
|
||||
id: 0, email:'demo@pgz.hr',
|
||||
full_name: r.user, ime: parts[0]||'', prezime: parts.slice(1).join(' '),
|
||||
user_type: _state.role==='pgz'?'pgz_admin':_state.role==='savez'?'savez_admin':_state.role==='klub'?'klub_admin':'klub_clan',
|
||||
tenant_type: _state.role==='pgz'?'pgz':_state.role,
|
||||
tenant_name: r.sub, tenant_id: null, tier: _state.role==='pgz'?0:_state.role==='savez'?1:2,
|
||||
oib: '12345678901', telefon:'+385 91 234 5678', phone:null,
|
||||
last_login: '2026-05-05T00:08:09', preferred_language:'hr',
|
||||
avatar_url:null, two_factor_enabled:false,
|
||||
gdpr_consent_at:null, created_at:'2026-04-01T08:00:00',
|
||||
roles:[{code:'demo', naziv:r.name}]
|
||||
};
|
||||
}
|
||||
function profileRender(){
|
||||
const u = profileMe();
|
||||
const name = u.full_name || ((u.ime||'')+' '+(u.prezime||'')).trim() || u.email || '—';
|
||||
const av = u.avatar_url ? `<img src="${esc(u.avatar_url)}" alt="">`
|
||||
: (u.google_picture ? `<img src="${esc(u.google_picture)}" alt="">` : esc(initials(name)));
|
||||
const lastLogin = u.last_login ? new Date(u.last_login).toLocaleString('hr-HR') : '—';
|
||||
const created = u.created_at ? new Date(u.created_at).toLocaleString('hr-HR') : '—';
|
||||
const gdpr = u.gdpr_consent_at ? new Date(u.gdpr_consent_at).toLocaleDateString('hr-HR') : null;
|
||||
const roleLabel = (ROLES[_state.role]||{}).name || u.user_type || 'Korisnik';
|
||||
return `
|
||||
<div class="profile-page">
|
||||
<div class="profile-banner">
|
||||
<div class="profile-avatar-big" id="prof-av-big" onclick="pickAvatar()" title="Klik za upload nove slike">
|
||||
${av}
|
||||
<div class="upload-hint">📷 Promijeni sliku</div>
|
||||
</div>
|
||||
<div class="profile-banner-info">
|
||||
<h1>${esc(name)}</h1>
|
||||
<div class="role-line">${esc(roleLabel)} · ${esc(u.tenant_name || u.tenant_type || '')}</div>
|
||||
<div class="tags-row">
|
||||
<span class="tag b">${esc(u.user_type||'')}</span>
|
||||
${u.aktivan!==false ? '<span class="tag gr">Aktivan</span>' : '<span class="tag rd">Suspended</span>'}
|
||||
${u.two_factor_enabled ? '<span class="tag-2fa-on">2FA ON</span>' : '<span class="tag-2fa-off">2FA OFF</span>'}
|
||||
${gdpr ? `<span class="tag-gdpr">GDPR ${esc(gdpr)}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="profile-banner-actions">
|
||||
<button class="btn" onclick="pickAvatar()">📷 Slika</button>
|
||||
<button class="btn primary" onclick="profileEditAll()">✏ Uredi profil</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="profile-section">
|
||||
<h3>Osobni podaci <span class="edit-link" onclick="profileEditAll()">✏ Uredi sva polja</span></h3>
|
||||
<div class="profile-row" data-f="ime">
|
||||
<div class="k">Ime</div>
|
||||
<div class="v">${esc(u.ime||'')||'<span class="empty">—</span>'}</div>
|
||||
<div class="a"><button class="btn sm" onclick="profileEditField('ime','Ime')">✏</button></div>
|
||||
</div>
|
||||
<div class="profile-row" data-f="prezime">
|
||||
<div class="k">Prezime</div>
|
||||
<div class="v">${esc(u.prezime||'')||'<span class="empty">—</span>'}</div>
|
||||
<div class="a"><button class="btn sm" onclick="profileEditField('prezime','Prezime')">✏</button></div>
|
||||
</div>
|
||||
<div class="profile-row" data-f="full_name">
|
||||
<div class="k">Puno ime</div>
|
||||
<div class="v">${esc(u.full_name||'')||'<span class="empty">—</span>'}</div>
|
||||
<div class="a"><button class="btn sm" onclick="profileEditField('full_name','Puno ime')">✏</button></div>
|
||||
</div>
|
||||
<div class="profile-row" data-f="email">
|
||||
<div class="k">Email</div>
|
||||
<div class="v">${esc(u.email||'—')}</div>
|
||||
<div class="a"><span class="tag">read-only</span></div>
|
||||
</div>
|
||||
<div class="profile-row" data-f="telefon">
|
||||
<div class="k">Telefon</div>
|
||||
<div class="v">${esc(u.telefon||u.phone||'')||'<span class="empty">—</span>'}</div>
|
||||
<div class="a"><button class="btn sm" onclick="profileEditField('telefon','Telefon')">✏</button></div>
|
||||
</div>
|
||||
<div class="profile-row" data-f="oib">
|
||||
<div class="k">OIB</div>
|
||||
<div class="v">${esc(u.oib||'')||'<span class="empty">—</span>'}</div>
|
||||
<div class="a"><button class="btn sm" onclick="profileEditField('oib','OIB')">✏</button></div>
|
||||
</div>
|
||||
<div class="profile-row" data-f="preferred_language">
|
||||
<div class="k">Jezik sučelja</div>
|
||||
<div class="v">${esc(u.preferred_language||'hr')}</div>
|
||||
<div class="a"><button class="btn sm" onclick="profileEditField('preferred_language','Jezik (hr/en)')">✏</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="profile-section">
|
||||
<h3>Tenant i ovlasti</h3>
|
||||
<div class="profile-row"><div class="k">Tenant</div><div class="v">${esc(u.tenant_name || '—')}</div><div class="a"></div></div>
|
||||
<div class="profile-row"><div class="k">Tip tenanta</div><div class="v"><span class="tag b">${esc(u.tenant_type || '—')}</span></div><div class="a"></div></div>
|
||||
<div class="profile-row"><div class="k">Tier</div><div class="v">${u.tier!=null?u.tier:'—'} ${u.tier===0?'(PGŽ)':u.tier===1?'(savez)':u.tier===2?'(klub)':''}</div><div class="a"></div></div>
|
||||
<div class="profile-row"><div class="k">User type</div><div class="v"><span class="tag gd">${esc(u.user_type || '—')}</span></div><div class="a"></div></div>
|
||||
<div class="profile-row"><div class="k">Dodatne uloge</div><div class="v">${(u.roles||[]).map(r => `<span class="tag b" style="margin-right:4px">${esc(r.code)}</span>`).join('')||'<span class="empty">—</span>'}</div><div class="a"></div></div>
|
||||
</div>
|
||||
|
||||
<div class="profile-section">
|
||||
<h3>Sigurnost <span class="edit-link" onclick="profileChangePassword()">🔑 Promijeni lozinku</span></h3>
|
||||
<div class="profile-row"><div class="k">2FA</div><div class="v">${u.two_factor_enabled?'<span class="tag-2fa-on">Uključeno</span>':'<span class="tag-2fa-off">Nije postavljeno</span>'}</div><div class="a"><button class="btn sm primary" onclick="profileSetup2FA()">${u.two_factor_enabled?'Provjeri':'Postavi'}</button></div></div>
|
||||
<div class="profile-row"><div class="k">Mora promijeniti lozinku</div><div class="v">${u.must_change_pwd?'<span class="tag rd">DA</span>':'<span class="tag gr">NE</span>'}</div><div class="a"></div></div>
|
||||
<div class="profile-row"><div class="k">GDPR pristanak</div><div class="v">${gdpr || '<span class="empty">Nije zabilježen</span>'}</div><div class="a">${gdpr?'':'<button class="btn sm">Dodijeli</button>'}</div></div>
|
||||
<div class="profile-row"><div class="k">Status računa</div><div class="v"><span class="tag ${u.aktivan===false?'rd':'gr'}">${u.aktivan===false?'Suspended':'Aktivan'}</span></div><div class="a"></div></div>
|
||||
</div>
|
||||
|
||||
<div class="profile-section">
|
||||
<h3>Aktivnost</h3>
|
||||
<div class="profile-row"><div class="k">Zadnji login</div><div class="v" style="font-family:var(--mono);font-size:11.5px">${esc(lastLogin)}</div><div class="a"></div></div>
|
||||
<div class="profile-row"><div class="k">Račun kreiran</div><div class="v" style="font-family:var(--mono);font-size:11.5px">${esc(created)}</div><div class="a"></div></div>
|
||||
<div class="profile-row"><div class="k">User ID</div><div class="v" style="font-family:var(--mono)">#${esc(u.id||0)}</div><div class="a"></div></div>
|
||||
</div>
|
||||
|
||||
<div class="profile-section">
|
||||
<h3>GDPR i podaci</h3>
|
||||
<div style="font-size:11.5px;color:var(--t2);line-height:1.6;margin-bottom:10px">
|
||||
Imaš pravo na pristup, izmjenu i brisanje svojih osobnih podataka prema GDPR uredbi (čl. 15–17, 20).
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<button class="btn" onclick="alert('Izvoz JSON svih podataka — backend M10')">📤 Izvezi moje podatke (JSON)</button>
|
||||
<button class="btn" onclick="alert('Pregled audit zapisa o pristupu — M10')">🔍 Audit pristupa mojim podacima</button>
|
||||
<button class="btn" style="border-color:var(--red);color:var(--red)" onclick="profileDeleteAccount()">🗑 Zatraži brisanje računa</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
SECTIONS['pgz:profil'] = profileRender;
|
||||
SECTIONS['savez:profil'] = profileRender;
|
||||
SECTIONS['klub:profil'] = profileRender;
|
||||
SECTIONS['sportas:profil']= profileRender;
|
||||
|
||||
// Profile actions
|
||||
function pickAvatar(){
|
||||
if(!getToken()){
|
||||
alert('Avatar upload zahtijeva login (JWT). U demo modu nije dostupan.');
|
||||
return;
|
||||
}
|
||||
$('#avatar-input').click();
|
||||
}
|
||||
async function onAvatarPick(input){
|
||||
const f = input.files && input.files[0];
|
||||
if(!f) return;
|
||||
if(f.size > 5*1024*1024){ alert('Slika prevelika (>5 MB)'); return; }
|
||||
const fd = new FormData(); fd.append('file', f);
|
||||
const av = $('#prof-av-big');
|
||||
if(av) av.innerHTML = '<div style="font-size:14px;color:var(--t1)">⏳</div>';
|
||||
const r = await apiAuth('/auth/me/avatar', {method:'POST', body:fd});
|
||||
input.value = '';
|
||||
if(r && r.avatar_url){
|
||||
if(_state.me) _state.me.avatar_url = r.avatar_url;
|
||||
applyMeToHeader();
|
||||
loadSection(); // re-render profile
|
||||
} else {
|
||||
alert('Upload failed: '+(r&&r.status||'unknown'));
|
||||
loadSection();
|
||||
}
|
||||
}
|
||||
async function profileEditField(field, label){
|
||||
const cur = (_state.me && _state.me[field]) || '';
|
||||
const v = prompt(`${label}:`, cur);
|
||||
if(v == null) return;
|
||||
if(!getToken()){
|
||||
if(_state.me){ _state.me[field] = v; }
|
||||
else {
|
||||
// demo: persist on local copy
|
||||
if(!window._demoMe) window._demoMe = profileMe();
|
||||
window._demoMe[field] = v;
|
||||
_state.me = window._demoMe;
|
||||
}
|
||||
applyMeToHeader();
|
||||
loadSection();
|
||||
return;
|
||||
}
|
||||
const r = await apiAuth('/auth/me', {method:'PUT', body: JSON.stringify({[field]: v})});
|
||||
if(r && !r.__error){ _state.me = r; applyMeToHeader(); loadSection(); }
|
||||
else alert('Greška pri spremanju: '+(r&&r.status||'unknown'));
|
||||
}
|
||||
async function profileEditAll(){
|
||||
// Open drill-down panel with full edit form
|
||||
const u = profileMe();
|
||||
openDetail('Uredi profil', `
|
||||
<form id="prof-edit-form" onsubmit="return profileSaveAll(event)">
|
||||
${['ime','prezime','full_name','telefon','oib'].map(f => `
|
||||
<div style="margin-bottom:12px">
|
||||
<label style="display:block;font-size:11px;color:var(--t2);margin-bottom:4px;font-weight:600;text-transform:uppercase">${f}</label>
|
||||
<input name="${f}" value="${esc(u[f]||'')}" style="background:var(--bg3);border:1px solid var(--rim);border-radius:5px;padding:8px 10px;color:var(--t1);font-size:13px;width:100%">
|
||||
</div>`).join('')}
|
||||
<div style="margin-bottom:12px">
|
||||
<label style="display:block;font-size:11px;color:var(--t2);margin-bottom:4px;font-weight:600;text-transform:uppercase">Jezik</label>
|
||||
<select name="preferred_language" style="background:var(--bg3);border:1px solid var(--rim);border-radius:5px;padding:8px 10px;color:var(--t1);font-size:13px;width:100%">
|
||||
<option value="hr" ${(u.preferred_language||'hr')==='hr'?'selected':''}>Hrvatski</option>
|
||||
<option value="en" ${u.preferred_language==='en'?'selected':''}>English</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;margin-top:18px">
|
||||
<button type="submit" class="btn primary">💾 Spremi</button>
|
||||
<button type="button" class="btn" onclick="closeDetail()">Odustani</button>
|
||||
</div>
|
||||
</form>`);
|
||||
}
|
||||
async function profileSaveAll(ev){
|
||||
ev.preventDefault();
|
||||
const fd = new FormData(ev.target);
|
||||
const obj = {}; fd.forEach((v,k) => { obj[k]=v; });
|
||||
if(!getToken()){
|
||||
Object.assign(_state.me || {}, obj);
|
||||
applyMeToHeader(); closeDetail(); loadSection();
|
||||
return false;
|
||||
}
|
||||
const r = await apiAuth('/auth/me', {method:'PUT', body: JSON.stringify(obj)});
|
||||
if(r && !r.__error){ _state.me = r; applyMeToHeader(); closeDetail(); loadSection(); }
|
||||
else alert('Greška: '+(r&&r.status||'unknown'));
|
||||
return false;
|
||||
}
|
||||
async function profileChangePassword(){
|
||||
if(!getToken()){ alert('Login potreban (demo mode).'); return; }
|
||||
const oldp = prompt('Stara lozinka:'); if(oldp==null) return;
|
||||
const newp = prompt('Nova lozinka (min 8 znakova):'); if(newp==null) return;
|
||||
if(newp.length < 8){ alert('Nova lozinka mora imati barem 8 znakova'); return; }
|
||||
const r = await apiAuth('/auth/password/change', {method:'POST', body: JSON.stringify({old_password:oldp,new_password:newp})});
|
||||
if(r && r.status==='ok') alert('Lozinka promijenjena ✓'); else alert('Greška: '+(r&&r.status||'unknown'));
|
||||
}
|
||||
async function profileSetup2FA(){
|
||||
if(!getToken()){ alert('Login potreban (demo mode).'); return; }
|
||||
const r = await apiAuth('/auth/2fa/setup', {method:'POST'});
|
||||
if(r && r.qr_url) {
|
||||
openDetail('Postavi 2FA', `<div style="text-align:center"><img src="${esc(r.qr_url)}" style="max-width:240px"><div style="margin-top:10px;font-size:12px;color:var(--t2)">Skeniraj QR kod u Google Authenticator / Authy</div><div style="margin-top:14px"><input id="totp-code" placeholder="6-cifreni kod" style="background:var(--bg3);border:1px solid var(--rim);border-radius:5px;padding:8px;color:var(--t1);width:140px"><button class="btn primary" onclick="profileVerify2FA()">Potvrdi</button></div></div>`);
|
||||
} else alert('2FA setup failed');
|
||||
}
|
||||
async function profileVerify2FA(){
|
||||
const code = $('#totp-code')?.value;
|
||||
const r = await apiAuth('/auth/2fa/verify', {method:'POST', body: JSON.stringify({code})});
|
||||
if(r && r.status==='ok'){ alert('2FA aktivirano ✓'); closeDetail(); loadSection(); }
|
||||
else alert('Pogrešan kod.');
|
||||
}
|
||||
function profileDeleteAccount(){
|
||||
if(!confirm('Zaista zatraži brisanje računa? GDPR brisanje je nepovratno.')) return;
|
||||
alert('Zahtjev za brisanje poslan na PGŽ admin (M10 — backend).');
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// PGŽ ADMIN — Dashboard
|
||||
// =======================================================================
|
||||
@@ -689,7 +934,7 @@ SECTIONS['pgz:dashboard'] = async () => {
|
||||
</div>`;
|
||||
|
||||
const reqHtml = MOCK.zahtjevi_pending.map(z => `
|
||||
<div class="req-i" onclick="alert('Otvaranje zahtjeva ${esc(z.id)} — production: navigira na detalj')">
|
||||
<div class="req-i" onclick="showDetail('zahtjev','${esc(z.id)}','Zahtjev ${esc(z.id)}')">
|
||||
<div class="rh">
|
||||
<div>
|
||||
<div class="rt">${esc(z.naziv)}</div>
|
||||
@@ -705,7 +950,7 @@ SECTIONS['pgz:dashboard'] = async () => {
|
||||
</div>`).join('');
|
||||
|
||||
const auditHtml = MOCK.audit.map(a =>
|
||||
`<div class="audit-i"><div class="ts">${esc(a.ts)}</div><div class="who">${esc(a.who)}</div><div class="what">${a.what}</div></div>`
|
||||
`<div class="audit-i" style="cursor:pointer" onclick='showDetail("audit",${JSON.stringify(a.what)},"Audit zapis")'><div class="ts">${esc(a.ts)}</div><div class="who">${esc(a.who)}</div><div class="what">${a.what}</div></div>`
|
||||
).join('');
|
||||
|
||||
return `
|
||||
@@ -803,12 +1048,12 @@ SECTIONS['pgz:savezi'] = async () => {
|
||||
const d = await api('/savezi') || {rows:[]};
|
||||
const top = (d.rows||[]).slice(0,30);
|
||||
const rows = top.map(s => `
|
||||
<tr>
|
||||
<tr style="cursor:pointer" onclick="showDetail('savez',${s.id},${JSON.stringify(s.naziv)})">
|
||||
<td><b>${esc(s.naziv)}</b></td>
|
||||
<td class="num">${fmt(s.broj_klubova||'—')}</td>
|
||||
<td class="num">${fmt(s.broj_sportasa||'—')}</td>
|
||||
<td>${esc(s.predsjednik||'—')}</td>
|
||||
<td><button class="btn sm" onclick="window.open('/sport/?savez=${s.id}','_blank')">Detalji</button></td>
|
||||
<td><button class="btn sm" onclick="event.stopPropagation();showDetail('savez',${s.id},${JSON.stringify(s.naziv)})">Detalji</button></td>
|
||||
</tr>`).join('');
|
||||
return `<div class="card"><div class="card-h"><div class="card-t">🏅 Savezi PGŽ — top 30 (od ${d.count||246})</div></div>
|
||||
<table><thead><tr><th>Naziv</th><th class="num">Klubovi</th><th class="num">Sportaši</th><th>Predsjednik</th><th></th></tr></thead><tbody>${rows||'<tr><td colspan=5 class="empty">Učitavam...</td></tr>'}</tbody></table>
|
||||
@@ -818,7 +1063,7 @@ SECTIONS['pgz:savezi'] = async () => {
|
||||
SECTIONS['pgz:klubovi'] = async () => {
|
||||
const d = await api('/klubovi?limit=40') || {rows:[]};
|
||||
const rows = (d.rows||[]).slice(0,40).map(k => `
|
||||
<tr>
|
||||
<tr style="cursor:pointer" onclick="showDetail('klub',${k.id},${JSON.stringify(k.naziv)})">
|
||||
<td><b>${esc(k.naziv)}</b></td>
|
||||
<td>${esc(k.savez||'—')}</td>
|
||||
<td>${esc(k.grad||'—')}</td>
|
||||
@@ -1433,14 +1678,26 @@ const MOCK = {
|
||||
};
|
||||
|
||||
//=========== INIT ===========
|
||||
function init(){
|
||||
async function init(){
|
||||
try {
|
||||
const r = localStorage.getItem('app-role');
|
||||
if(r && ROLES[r]) _state.role = r;
|
||||
} catch(e){}
|
||||
restoreSidebar();
|
||||
buildRoleSwitch();
|
||||
|
||||
// Try real auth (JWT)
|
||||
const me = await loadCurrentUser();
|
||||
if(me){
|
||||
// Real-auth mode — hide demo role switcher (only super_admin can switch personas)
|
||||
if(me.user_type !== 'super_admin'){
|
||||
const rs = $('#role-switch'); if(rs) rs.style.display='none';
|
||||
}
|
||||
applyMeToHeader();
|
||||
}
|
||||
// First page after login: Moj profil
|
||||
setRole(_state.role);
|
||||
navTo('profil');
|
||||
}
|
||||
window.addEventListener('DOMContentLoaded', init);
|
||||
</script>
|
||||
|
||||
+353
-2
@@ -992,13 +992,364 @@ async function reSign(sid) {
|
||||
} catch (e) { toast('Greška: ' + e.message, true); }
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════
|
||||
// MODUL 4 — ČLANOVI / DASHBOARD osobe (CRM Dashboard)
|
||||
// ════════════════════════════════════════════════════
|
||||
|
||||
let CLANOVI_LAST_QUERY = '';
|
||||
|
||||
async function loadClanovi() {
|
||||
const root = $('#page-clanovi');
|
||||
root.innerHTML = `
|
||||
<div class="toolbar">
|
||||
<input id="cl-q" type="text" placeholder="Pretraži po imenu / OIB-u (min 2 slova)…" style="min-width:340px;flex:1" oninput="searchClanovi(this.value)">
|
||||
<input id="cl-klub-filter" type="number" placeholder="Klub ID (filter)" onchange="searchClanovi($('#cl-q').value)">
|
||||
<div class="grow"></div>
|
||||
<span style="font-size:11px;color:var(--t3)">Klik na karticu → puni dashboard člana</span>
|
||||
</div>
|
||||
<div id="cl-results"><div class="loading">Upišite ime za pretragu…</div></div>
|
||||
`;
|
||||
// initial: load nekoliko poznatih ID-ova kao primjer
|
||||
if (!CLANOVI_LAST_QUERY) {
|
||||
document.getElementById('cl-q').value = 'Mateo';
|
||||
searchClanovi('Mateo');
|
||||
}
|
||||
}
|
||||
|
||||
let _searchTimer;
|
||||
function searchClanovi(q) {
|
||||
clearTimeout(_searchTimer);
|
||||
CLANOVI_LAST_QUERY = q;
|
||||
if (!q || q.length < 2) {
|
||||
$('#cl-results').innerHTML = '<div class="loading">Upišite ime za pretragu (min 2 slova)…</div>';
|
||||
return;
|
||||
}
|
||||
_searchTimer = setTimeout(async () => {
|
||||
const klub = $('#cl-klub-filter').value;
|
||||
const params = new URLSearchParams({q, limit: 30});
|
||||
if (klub) params.append('klub_id', klub);
|
||||
try {
|
||||
const data = await apiR('/clanovi/search?' + params);
|
||||
$('#cnt-clanovi').textContent = data.count;
|
||||
const cards = (data.rows || []).map(r => `
|
||||
<div class="card" style="margin-bottom:8px;cursor:pointer" onclick="openClanPanel(${r.id})">
|
||||
<div class="card-b" style="display:flex;align-items:center;gap:14px">
|
||||
<div style="width:48px;height:48px;border-radius:50%;background:var(--bg3);overflow:hidden;display:flex;align-items:center;justify-content:center;flex-shrink:0;border:1px solid var(--rim)">
|
||||
${r.slika_url ? `<img src="${esc(r.slika_url)}" style="width:100%;height:100%;object-fit:cover" onerror="this.style.display='none'">` : `<span style="font-size:18px;font-weight:600;color:var(--t2)">${esc((r.ime||'?')[0]+(r.prezime||'?')[0])}</span>`}
|
||||
</div>
|
||||
<div style="flex:1">
|
||||
<div style="font-weight:600">${esc(r.ime)} ${esc(r.prezime)}</div>
|
||||
<div style="font-size:11px;color:var(--t3)">${esc(r.klub || '—')} · ${esc(r.pozicija || '—')}${r.broj_dresa ? ' · #'+r.broj_dresa : ''}</div>
|
||||
</div>
|
||||
<div><span class="tag bl">#${r.id}</span></div>
|
||||
</div>
|
||||
</div>`).join('');
|
||||
$('#cl-results').innerHTML = `
|
||||
<div style="color:var(--t3);font-size:12px;margin-bottom:8px">${data.count} rezultat${data.count==1?'':'a'}</div>
|
||||
${cards || '<div class="empty">Nema rezultata.</div>'}`;
|
||||
} catch (e) { $('#cl-results').innerHTML = `<div class="empty">Greška: ${esc(e.message)}</div>`; }
|
||||
}, 250);
|
||||
}
|
||||
|
||||
window._OPEN_PANEL_CID = null;
|
||||
|
||||
async function openClanPanel(cid) {
|
||||
window._OPEN_PANEL_CID = cid;
|
||||
loadClanPanel(cid);
|
||||
}
|
||||
|
||||
function closeClanPanel() {
|
||||
window._OPEN_PANEL_CID = null;
|
||||
closeModal();
|
||||
}
|
||||
|
||||
async function loadClanPanel(cid) {
|
||||
let d, perms;
|
||||
try {
|
||||
d = await apiR('/clanovi/' + cid + '/full');
|
||||
perms = await apiR('/clanovi/permissions?role=' + encodeURIComponent(CURRENT_ROLE));
|
||||
} catch (e) { return toast('Greška: ' + e.message, true); }
|
||||
const c = d.clan, k = d.klub || {};
|
||||
const editable = perms.editable; // 'ALL' ili lista polja
|
||||
const canEdit = (field) => editable === 'ALL' || (Array.isArray(editable) && editable.includes(field));
|
||||
const canUploadAvatar = canEdit('slika_url');
|
||||
|
||||
const av = c.slika_url_full || c.slika_url || '';
|
||||
const initials = ((c.ime||'?')[0]+(c.prezime||'?')[0]).toUpperCase();
|
||||
|
||||
// helper za render polja s edit/no-edit
|
||||
const f = (key, label, val, type='text') => {
|
||||
const ed = canEdit(key);
|
||||
const safe = val == null || val === '' ? '—' : String(val);
|
||||
return `
|
||||
<div class="payment-row">
|
||||
<div class="l">${esc(label)}${ed?'':' <span style="color:var(--t3);font-size:9px">🔒</span>'}</div>
|
||||
<div class="v" style="display:flex;align-items:center;gap:6px">
|
||||
<span id="fld-${key}-display" style="font-family:inherit">${esc(safe)}</span>
|
||||
${ed ? `<button class="btn sm" onclick="editFieldInline('${key}', '${esc(label)}', ${JSON.stringify(val||'').replace(/"/g,'"')}, '${type}', ${cid})">✎</button>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
};
|
||||
|
||||
const kpiHtml = `
|
||||
<div class="kpi-grid" style="margin-bottom:12px">
|
||||
<div class="kpi b"><div class="kpi-l">Sezone</div><div class="kpi-v">${fmt(d.kpi.broj_sezona)}</div></div>
|
||||
<div class="kpi g"><div class="kpi-l">Nastupi</div><div class="kpi-v">${fmt(d.kpi.nastupi_total)}</div></div>
|
||||
<div class="kpi a"><div class="kpi-l">Pogoci</div><div class="kpi-v">${fmt(d.kpi.pogoci_total)}</div></div>
|
||||
<div class="kpi r"><div class="kpi-l">Dug članarina</div><div class="kpi-v">${fmtEur(d.kpi.dug_clanarina_eur)}</div></div>
|
||||
<div class="kpi ${d.kpi.lijecnicki_status==='vazeci'?'g':d.kpi.lijecnicki_status==='uskoro'?'a':'r'}"><div class="kpi-l">Liječnički</div><div class="kpi-v" style="font-size:14px">${esc(d.kpi.lijecnicki_status||'—')}</div><div class="kpi-s">${d.kpi.lijecnicki_dana_do_isteka != null ? d.kpi.lijecnicki_dana_do_isteka+' dana' : ''}</div></div>
|
||||
</div>`;
|
||||
|
||||
const sectionPersonal = `
|
||||
<div class="card-h" style="background:transparent;border:none;padding:6px 0;margin-top:6px"><div class="card-t">📋 Osobni podaci</div></div>
|
||||
<div class="payment-card">
|
||||
${f('ime','Ime',c.ime)}
|
||||
${f('prezime','Prezime',c.prezime)}
|
||||
${f('oib','OIB',c.oib)}
|
||||
${f('datum_rodenja','Datum rođenja',c.datum_rodenja||c.datum_rodjenja,'date')}
|
||||
${f('mjesto_rodenja','Mjesto rođenja',c.mjesto_rodenja||c.mjesto_rodjenja)}
|
||||
${f('spol','Spol',c.spol)}
|
||||
</div>`;
|
||||
|
||||
const sectionKontakt = `
|
||||
<div class="card-h" style="background:transparent;border:none;padding:6px 0;margin-top:6px"><div class="card-t">📞 Kontakt</div></div>
|
||||
<div class="payment-card">
|
||||
${f('email','E-mail',c.email)}
|
||||
${f('telefon','Telefon',c.telefon)}
|
||||
${f('adresa','Adresa',c.adresa)}
|
||||
${f('grad','Grad',c.grad)}
|
||||
${f('postanski_broj','Pošt. broj',c.postanski_broj)}
|
||||
</div>`;
|
||||
|
||||
const sectionSport = `
|
||||
<div class="card-h" style="background:transparent;border:none;padding:6px 0;margin-top:6px"><div class="card-t">⚽ Sport</div></div>
|
||||
<div class="payment-card">
|
||||
${f('sport','Sport',c.sport)}
|
||||
${f('kategorija','Kategorija',c.kategorija)}
|
||||
${f('podkategorija','Podkategorija',c.podkategorija)}
|
||||
${f('pozicija','Pozicija',c.pozicija)}
|
||||
${f('dominantna_noga','Dominantna noga',c.dominantna_noga)}
|
||||
${f('visina_cm','Visina (cm)',c.visina_cm,'number')}
|
||||
${f('tezina_kg','Težina (kg)',c.tezina_kg,'number')}
|
||||
${f('broj_dresa','Broj dresa',c.broj_dresa,'number')}
|
||||
${f('uloga','Uloga',c.uloga)}
|
||||
${f('uloga_detalj','Uloga (detalj)',c.uloga_detalj)}
|
||||
</div>`;
|
||||
|
||||
const sectionStatus = `
|
||||
<div class="card-h" style="background:transparent;border:none;padding:6px 0;margin-top:6px"><div class="card-t">📊 Status</div></div>
|
||||
<div class="payment-card">
|
||||
${f('aktivan','Aktivan',c.aktivan)}
|
||||
${f('datum_pristupa','Datum pristupa',c.datum_pristupa,'date')}
|
||||
${f('datum_napustanja','Datum napuštanja',c.datum_napustanja,'date')}
|
||||
${f('kategoriziran','Kategoriziran',c.kategoriziran)}
|
||||
${f('kategorija_hoo','HOO kategorija',c.kategorija_hoo,'number')}
|
||||
${f('reprezentativac','Reprezentativac',c.reprezentativac)}
|
||||
${f('reprezentacija_kategorija','Reprezentacija (kat.)',c.reprezentacija_kategorija)}
|
||||
${f('stipendiran','Stipendiran',c.stipendiran)}
|
||||
${f('stipendija_iznos','Stipendija (€)',c.stipendija_iznos,'number')}
|
||||
${f('licenca_broj','Licenca broj',c.licenca_broj)}
|
||||
${f('licenca_vrijedi_do','Licenca vrijedi do',c.licenca_vrijedi_do,'date')}
|
||||
${f('radno_pravni_status','Radno-pravni status',c.radno_pravni_status)}
|
||||
</div>`;
|
||||
|
||||
const sectionKlub = `
|
||||
<div class="card-h" style="background:transparent;border:none;padding:6px 0;margin-top:6px"><div class="card-t">⬢ Klub</div></div>
|
||||
<div class="payment-card">
|
||||
<div class="payment-row"><div class="l">Trenutni klub</div><div class="v">${esc(k.naziv || '—')}</div></div>
|
||||
${k.savez_naziv ? `<div class="payment-row"><div class="l">Savez</div><div class="v">${esc(k.savez_naziv)}</div></div>` : ''}
|
||||
${k.oib ? `<div class="payment-row"><div class="l">OIB kluba</div><div class="v">${esc(k.oib)}</div></div>` : ''}
|
||||
${k.iban ? `<div class="payment-row"><div class="l">IBAN</div><div class="v">${esc(k.iban)}</div></div>` : ''}
|
||||
</div>
|
||||
${d.povijest_klubova && d.povijest_klubova.length ? `
|
||||
<div style="margin-top:8px"><b>Povijest klubova (${d.povijest_klubova.length}):</b></div>
|
||||
<table style="margin-top:6px"><thead><tr><th>Klub</th><th>Od</th><th>Do</th><th># sezona</th></tr></thead>
|
||||
<tbody>${d.povijest_klubova.map(p=>`<tr><td>${esc(p.klub_naziv)}</td><td>${esc(p.od)}</td><td>${esc(p.do_)}</td><td>${p.broj_sezona}</td></tr>`).join('')}</tbody>
|
||||
</table>` : ''}
|
||||
`;
|
||||
|
||||
const tabSezone = `
|
||||
<table><thead><tr><th>Sezona</th><th>Klub</th><th>Natjecanje</th><th>Nast.</th><th>Pog.</th><th>Asist.</th><th>Žuti</th><th>Crv.</th><th>Min.</th></tr></thead>
|
||||
<tbody>${(d.sezone||[]).map(s=>`<tr><td><b>${esc(s.sezona)}</b></td><td>${esc(s.klub_naziv||'—')}</td><td>${esc(s.natjecanje||'—')}</td><td>${fmt(s.nastupi)}</td><td>${fmt(s.pogoci)}</td><td>${fmt(s.asistencije)}</td><td>${fmt(s.zuti_kartoni)}</td><td>${fmt(s.crveni_kartoni)}</td><td>${fmt(s.minute_total)}</td></tr>`).join('') || '<tr><td colspan="9" class="empty">Nema podataka.</td></tr>'}</tbody>
|
||||
</table>`;
|
||||
|
||||
const tabUtakmice = `
|
||||
<table><thead><tr><th>Datum</th><th>Domaćin</th><th>Gost</th><th>Rezultat</th><th>Natj.</th><th>Pog.</th><th>Min.</th><th></th></tr></thead>
|
||||
<tbody>${(d.utakmice_zadnje20||[]).map(u=>`<tr><td>${fmtDate(u.datum)}</td><td>${esc(u.domacin||'—')}</td><td>${esc(u.gost||'—')}</td><td><b>${esc(u.rezultat||'—')}</b></td><td>${esc(u.natjecanje||'—')}</td><td>${fmt(u.pogoci)}</td><td>${fmt(u.minute)}</td><td>${u.utakmica_url?`<a class="btn sm" href="${esc(u.utakmica_url)}" target="_blank">↗</a>`:''}</td></tr>`).join('') || '<tr><td colspan="8" class="empty">Nema utakmica.</td></tr>'}</tbody>
|
||||
</table>`;
|
||||
|
||||
const tabLij = `
|
||||
<table><thead><tr><th>Datum</th><th>Vrijedi do</th><th>Status</th><th>Vrsta</th><th>Ustanova</th><th>Liječnik</th><th>Plaćeno</th></tr></thead>
|
||||
<tbody>${(d.lijecnicki||[]).map(l=>`<tr><td>${fmtDate(l.datum_pregleda)}</td><td>${fmtDate(l.vrijedi_do)}</td><td><span class="tag ${({vazeci:'gr',uskoro:'am',istekao:'rd'})[l.status_calc]||'gy'}">${esc(l.status_calc)} (${l.dana_do_isteka}d)</span></td><td>${esc(l.vrsta_pregleda||'—')}</td><td>${esc(l.ustanova||'—')}</td><td>${esc(l.lijecnik||'—')}</td><td>${l.placeno?'<span class="tag gr">DA</span>':'<span class="tag rd">NE</span>'}</td></tr>`).join('') || '<tr><td colspan="7" class="empty">Nema pregleda.</td></tr>'}</tbody>
|
||||
</table>`;
|
||||
|
||||
const tabClanarine = `
|
||||
<table><thead><tr><th>God.</th><th>Razdoblje</th><th>Propisan</th><th>Plaćeno</th><th>Dug</th><th>Status</th><th>Datum upl.</th><th></th></tr></thead>
|
||||
<tbody>${(d.clanarine||[]).map(cl=>`<tr><td>${esc(cl.godina)}</td><td>${esc(cl.razdoblje||'—')}</td><td>${fmtEur(cl.iznos_propisan)}</td><td>${fmtEur(cl.iznos_placen)}</td><td><b style="color:${cl.dug>0?'var(--err)':'var(--ok)'}">${fmtEur(cl.dug)}</b></td><td><span class="tag ${statusTag(cl.status)}">${esc(cl.status)}</span></td><td>${fmtDate(cl.datum_uplate)}</td><td><a class="btn sm" href="${API}/clanarine/${cl.id}/uplatnica.pdf" target="_blank">📄</a></td></tr>`).join('') || '<tr><td colspan="8" class="empty">Nema članarina.</td></tr>'}</tbody>
|
||||
</table>`;
|
||||
|
||||
const tabDokumenti = `
|
||||
<table><thead><tr><th>God.</th><th>Naslov</th><th>Vrsta</th><th>Snippet</th><th></th></tr></thead>
|
||||
<tbody>${(d.dokumenti||[]).map(dk=>`<tr><td>${esc(dk.godina)}</td><td><b>${esc(dk.title||'—')}</b></td><td>${esc(dk.vrsta||'—')}</td><td style="font-size:11px;color:var(--t3);max-width:280px">${esc((dk.snippet||'').substring(0,140))}</td><td>${dk.pdf_url?`<a class="btn sm" href="${esc(dk.pdf_url)}" target="_blank">📄</a>`:dk.url?`<a class="btn sm" href="${esc(dk.url)}" target="_blank">↗</a>`:''}</td></tr>`).join('') || '<tr><td colspan="5" class="empty">Nema dokumenata.</td></tr>'}</tbody>
|
||||
</table>`;
|
||||
|
||||
const tabObrasci = `
|
||||
<table><thead><tr><th>Obrazac</th><th>Ref.</th><th>Status</th><th>Predano</th><th></th></tr></thead>
|
||||
<tbody>${(d.obrasci||[]).map(o=>`<tr><td><b>${esc(o.template_naziv||o.template_code)}</b></td><td><code style="font-size:10px">${esc(o.reference_no||'')}</code></td><td><span class="tag ${({draft:'gy',submitted:'am',approved:'gr',rejected:'rd'})[o.status]||'gy'}">${esc(o.status)}</span></td><td>${fmtDate(o.submitted_at||o.created_at)}</td><td><a class="btn sm" href="${API}/forms/submissions/${o.id}/pdf" target="_blank">📄</a></td></tr>`).join('') || '<tr><td colspan="5" class="empty">Nema obrazaca.</td></tr>'}</tbody>
|
||||
</table>`;
|
||||
|
||||
const tabNagrade = (d.nagrade && d.nagrade.length) ? `
|
||||
<table><thead><tr><th>Godina</th><th>Natjecanje</th><th>Razina</th><th>Disciplina</th><th>Plasman</th><th>Klub</th></tr></thead>
|
||||
<tbody>${d.nagrade.map(n=>`<tr><td>${esc(n.godina)}</td><td>${esc(n.natjecanje||'—')}</td><td>${esc(n.razina_natjecanja||'—')}</td><td>${esc(n.disciplina||'—')}</td><td><b>${n.plasman||'—'}</b></td><td>${esc(n.klub_naziv||'—')}</td></tr>`).join('')}</tbody>
|
||||
</table>` : '';
|
||||
|
||||
// Modal — širi nego standardno
|
||||
$('#modal').style.maxWidth = '1100px';
|
||||
openModal(`
|
||||
<div class="modal-h">
|
||||
<div class="modal-t">👤 ${esc(c.ime)} ${esc(c.prezime)} <span style="color:var(--t3);font-size:11px;font-weight:400">#${cid} · ${esc(CURRENT_ROLE)} (${editable === 'ALL' ? 'full edit' : Array.isArray(editable) ? editable.length+' edit polja' : 'no edit'})</span></div>
|
||||
<div class="modal-x" onclick="closeClanPanel()">×</div>
|
||||
</div>
|
||||
<div class="modal-b">
|
||||
<div style="display:grid;grid-template-columns:140px 1fr;gap:18px;margin-bottom:14px">
|
||||
<div style="text-align:center">
|
||||
<div id="avatar-display" style="width:140px;height:140px;border-radius:8px;background:var(--bg3);border:1px solid var(--rim);display:flex;align-items:center;justify-content:center;overflow:hidden">
|
||||
${av ? `<img src="${esc(av)}?_=${Date.now()}" style="width:100%;height:100%;object-fit:cover">` : `<span style="font-size:48px;font-weight:700;color:var(--t2)">${esc(initials)}</span>`}
|
||||
</div>
|
||||
${canUploadAvatar ? `
|
||||
<input type="file" id="avatar-file" accept="image/*" style="display:none" onchange="uploadAvatar(${cid})">
|
||||
<button class="btn primary sm" style="margin-top:8px;width:100%" onclick="document.getElementById('avatar-file').click()">📷 Upload</button>
|
||||
` : `<div style="font-size:10px;color:var(--t3);margin-top:6px">🔒 Bez dozvole</div>`}
|
||||
</div>
|
||||
<div>
|
||||
${kpiHtml}
|
||||
<div style="font-size:12px;color:var(--t3)">Dob: <b style="color:var(--t1)">${c.dob_calc != null ? c.dob_calc + ' god.' : '—'}</b> · Slika: <code style="font-size:10px">${esc(c.slika_url || '—').substring(0,60)}</code></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cp-tabs" style="display:flex;gap:0;border-bottom:1px solid var(--rim);margin-bottom:12px;flex-wrap:wrap">
|
||||
<div class="cp-tab active" onclick="cpTab('osobni')" data-cpt="osobni" style="padding:10px 14px;cursor:pointer;border-bottom:2px solid var(--pgz-blue);font-weight:600">Osobni</div>
|
||||
<div class="cp-tab" onclick="cpTab('sezone')" data-cpt="sezone" style="padding:10px 14px;cursor:pointer;color:var(--t2)">Sezone (${d.sezone.length})</div>
|
||||
<div class="cp-tab" onclick="cpTab('utakmice')" data-cpt="utakmice" style="padding:10px 14px;cursor:pointer;color:var(--t2)">Utakmice (${d.utakmice_zadnje20.length})</div>
|
||||
<div class="cp-tab" onclick="cpTab('lij')" data-cpt="lij" style="padding:10px 14px;cursor:pointer;color:var(--t2)">Liječnički (${d.lijecnicki.length})</div>
|
||||
<div class="cp-tab" onclick="cpTab('clanarine')" data-cpt="clanarine" style="padding:10px 14px;cursor:pointer;color:var(--t2)">Članarine (${d.clanarine.length})</div>
|
||||
<div class="cp-tab" onclick="cpTab('dokumenti')" data-cpt="dokumenti" style="padding:10px 14px;cursor:pointer;color:var(--t2)">Dokumenti (${d.dokumenti.length})</div>
|
||||
<div class="cp-tab" onclick="cpTab('obrasci')" data-cpt="obrasci" style="padding:10px 14px;cursor:pointer;color:var(--t2)">Obrasci (${d.obrasci.length})</div>
|
||||
${tabNagrade ? `<div class="cp-tab" onclick="cpTab('nagrade')" data-cpt="nagrade" style="padding:10px 14px;cursor:pointer;color:var(--t2)">Nagrade (${d.nagrade.length})</div>` : ''}
|
||||
</div>
|
||||
|
||||
<div id="cp-osobni" class="cp-page">
|
||||
${sectionPersonal}
|
||||
${sectionKontakt}
|
||||
${sectionSport}
|
||||
${sectionStatus}
|
||||
${sectionKlub}
|
||||
<div class="card-h" style="background:transparent;border:none;padding:6px 0;margin-top:6px"><div class="card-t">📝 Napomena</div></div>
|
||||
<div class="payment-card">${f('napomena','Napomena',c.napomena)}</div>
|
||||
<div class="card-h" style="background:transparent;border:none;padding:6px 0;margin-top:6px"><div class="card-t">📖 Biografija</div></div>
|
||||
<div class="payment-card">${f('biografija','Biografija',c.biografija,'textarea')}</div>
|
||||
</div>
|
||||
<div id="cp-sezone" class="cp-page" style="display:none">${tabSezone}</div>
|
||||
<div id="cp-utakmice" class="cp-page" style="display:none">${tabUtakmice}</div>
|
||||
<div id="cp-lij" class="cp-page" style="display:none">${tabLij}</div>
|
||||
<div id="cp-clanarine" class="cp-page" style="display:none">${tabClanarine}</div>
|
||||
<div id="cp-dokumenti" class="cp-page" style="display:none">${tabDokumenti}</div>
|
||||
<div id="cp-obrasci" class="cp-page" style="display:none">${tabObrasci}</div>
|
||||
${tabNagrade ? `<div id="cp-nagrade" class="cp-page" style="display:none">${tabNagrade}</div>` : ''}
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
function cpTab(name) {
|
||||
$$('.cp-tab').forEach(t => {
|
||||
const active = t.dataset.cpt === name;
|
||||
t.style.borderBottom = active ? '2px solid var(--pgz-blue)' : '';
|
||||
t.style.color = active ? 'var(--t1)' : 'var(--t2)';
|
||||
t.style.fontWeight = active ? '600' : '500';
|
||||
t.classList.toggle('active', active);
|
||||
});
|
||||
$$('.cp-page').forEach(p => p.style.display = (p.id === 'cp-' + name) ? 'block' : 'none');
|
||||
}
|
||||
|
||||
function editFieldInline(key, label, currentVal, type, cid) {
|
||||
const inputType = type === 'date' ? 'date' : type === 'number' ? 'number' : 'text';
|
||||
const isTextarea = type === 'textarea';
|
||||
const isBool = typeof currentVal === 'boolean';
|
||||
let inputHtml;
|
||||
if (isBool) {
|
||||
inputHtml = `<select id="ef-input"><option value="true" ${currentVal===true?'selected':''}>true</option><option value="false" ${currentVal===false?'selected':''}>false</option></select>`;
|
||||
} else if (isTextarea) {
|
||||
inputHtml = `<textarea id="ef-input" style="width:100%;min-height:80px">${esc(currentVal||'')}</textarea>`;
|
||||
} else {
|
||||
inputHtml = `<input id="ef-input" type="${inputType}" value="${esc(currentVal||'')}" style="width:100%">`;
|
||||
}
|
||||
const promptHtml = `
|
||||
<div class="modal-h">
|
||||
<div class="modal-t">✎ ${esc(label)}</div>
|
||||
<div class="modal-x" onclick="closeModal();loadClanPanel(${cid})">×</div>
|
||||
</div>
|
||||
<div class="modal-b">
|
||||
<div class="field"><label>${esc(label)} (${esc(key)})</label>${inputHtml}</div>
|
||||
<div style="text-align:right;margin-top:14px">
|
||||
<button class="btn" onclick="closeModal();loadClanPanel(${cid})">Odustani</button>
|
||||
<button class="btn primary" onclick="saveField('${key}', ${cid}, ${isBool}, ${isTextarea ? 'false' : type==='number'?'true':'false'})">💾 Spremi</button>
|
||||
</div>
|
||||
</div>`;
|
||||
$('#modal').style.maxWidth = '500px';
|
||||
openModal(promptHtml);
|
||||
setTimeout(() => $('#ef-input')?.focus(), 50);
|
||||
}
|
||||
|
||||
async function saveField(key, cid, isBool, isNumber) {
|
||||
const el = $('#ef-input');
|
||||
let val = el.value;
|
||||
if (isBool) val = val === 'true';
|
||||
else if (isNumber) val = val === '' ? null : Number(val);
|
||||
else if (val === '') val = null;
|
||||
try {
|
||||
const r = await apiR('/clanovi/' + cid, {method:'PUT', body: {[key]: val}});
|
||||
if (r.rejected_fields && r.rejected_fields.includes(key)) {
|
||||
toast(`Polje "${key}" odbijeno za rolu ${CURRENT_ROLE}`, true);
|
||||
} else {
|
||||
toast(`✓ Polje ${key} spremljeno (rola ${CURRENT_ROLE})`);
|
||||
}
|
||||
closeModal();
|
||||
$('#modal').style.maxWidth = '1100px';
|
||||
loadClanPanel(cid);
|
||||
} catch (e) { toast('Greška: ' + e.message, true); }
|
||||
}
|
||||
|
||||
async function uploadAvatar(cid) {
|
||||
const inp = $('#avatar-file');
|
||||
if (!inp.files || !inp.files[0]) return;
|
||||
const fd = new FormData();
|
||||
fd.append('file', inp.files[0]);
|
||||
try {
|
||||
const r = await fetch(API + '/clanovi/' + cid + '/avatar', {
|
||||
method: 'POST',
|
||||
headers: {'X-Role': CURRENT_ROLE},
|
||||
body: fd,
|
||||
});
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}: ${await r.text()}`);
|
||||
const d = await r.json();
|
||||
toast(`✓ Avatar uploaded: ${d.size_bytes} bytes`);
|
||||
loadClanPanel(cid);
|
||||
} catch (e) { toast('Greška upload-a: ' + e.message, true); }
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────
|
||||
// init
|
||||
// ────────────────────────────────────────────────────
|
||||
loadClanarine();
|
||||
// preload counts
|
||||
// postavi role iz localStorage u dropdown
|
||||
const _roleSel = document.getElementById('g-role');
|
||||
if (_roleSel) _roleSel.value = CURRENT_ROLE;
|
||||
|
||||
loadClanovi();
|
||||
// preload counts za sve tabove
|
||||
(async () => {
|
||||
try {
|
||||
const cl = await api('/clanarine?limit=1');
|
||||
$('#cnt-clanarine').textContent = cl.summary?.total ?? '?';
|
||||
const lj = await api('/lijecnicki?limit=1');
|
||||
$('#cnt-lijecnicki').textContent = lj.summary?.total ?? '?';
|
||||
const fm = await api('/forms');
|
||||
|
||||
+467
-5
@@ -53,7 +53,31 @@ label.lbl { font-size:11px; color:var(--text-3); display:block; margin-bottom:4p
|
||||
.grid2 { display:grid; grid-template-columns:1fr 1fr; gap:10px; }
|
||||
.grid3 { display:grid; grid-template-columns:1fr 1fr 1fr; gap:10px; }
|
||||
.grid4 { display:grid; grid-template-columns:repeat(4,1fr); gap:14px; }
|
||||
@media(max-width:768px) { .app { grid-template-columns:1fr; } .sidebar { display:none; } .grid2,.grid3 { grid-template-columns:1fr; } }
|
||||
tr.clickable { cursor:pointer; }
|
||||
tr.clickable:hover { background:var(--bg-3); box-shadow:inset 3px 0 0 var(--accent); }
|
||||
.modal-bg { position:fixed; inset:0; background:rgba(0,0,0,.6); z-index:100; display:none; align-items:flex-start; justify-content:center; padding:30px; overflow-y:auto; }
|
||||
.modal-bg.show { display:flex; }
|
||||
.modal { background:var(--bg-2); border:1px solid var(--border); border-radius:10px; max-width:1100px; width:100%; padding:0; box-shadow:0 12px 48px rgba(0,0,0,.6); }
|
||||
.modal-h { display:flex; justify-content:space-between; align-items:center; padding:16px 22px; border-bottom:1px solid var(--border); }
|
||||
.modal-h h3 { color:var(--accent); font-size:16px; }
|
||||
.modal-h .x { background:transparent; border:0; color:var(--text-2); font-size:22px; cursor:pointer; }
|
||||
.modal-h .x:hover { color:var(--red); }
|
||||
.modal-body { padding:18px 22px; max-height:80vh; overflow-y:auto; }
|
||||
.col2 { display:grid; grid-template-columns:1fr 1fr; gap:18px; }
|
||||
.kv { display:grid; grid-template-columns:140px 1fr; gap:6px 12px; font-size:13px; }
|
||||
.kv > div:nth-child(odd) { color:var(--text-3); font-size:11px; text-transform:uppercase; letter-spacing:.5px; align-self:center; }
|
||||
.kv > div:nth-child(even) { font-family:'JetBrains Mono',monospace; }
|
||||
.preview-img { max-width:100%; max-height:480px; border:1px solid var(--border); border-radius:6px; background:var(--bg); }
|
||||
.audit-row { display:grid; grid-template-columns:140px 110px 130px 1fr; gap:8px; padding:6px 0; border-bottom:1px dashed var(--border); font-size:12px; }
|
||||
.audit-row:last-child { border-bottom:0; }
|
||||
.audit-row .ts { color:var(--text-3); font-family:'JetBrains Mono',monospace; font-size:11px; }
|
||||
.audit-row .op { color:var(--accent); font-weight:600; }
|
||||
.audit-row .who { color:var(--text-2); }
|
||||
.btn.green { background:var(--green); color:var(--bg); }
|
||||
.btn.red { background:var(--red); color:#fff; }
|
||||
.btn.yellow { background:var(--yellow); color:var(--bg); }
|
||||
.actions-row { display:flex; flex-wrap:wrap; gap:8px; margin-top:14px; padding-top:14px; border-top:1px solid var(--border); }
|
||||
@media(max-width:768px) { .app { grid-template-columns:1fr; } .sidebar { display:none; } .grid2,.grid3 { grid-template-columns:1fr; } .col2 { grid-template-columns:1fr; } .audit-row { grid-template-columns:1fr; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -173,6 +197,153 @@ label.lbl { font-size:11px; color:var(--text-3); display:block; margin-bottom:4p
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- ============ INVOICE DETAIL MODAL (M5.5) ============ -->
|
||||
<div id="invModal" class="modal-bg" onclick="if(event.target===this)closeModal('invModal')">
|
||||
<div class="modal">
|
||||
<div class="modal-h">
|
||||
<h3 id="invModalTitle">Račun</h3>
|
||||
<button class="x" onclick="closeModal('invModal')">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="col2">
|
||||
<div>
|
||||
<h4 style="font-size:12px;color:var(--text-3);text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px">Skenirana datoteka</h4>
|
||||
<div id="inv_preview" style="text-align:center"></div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 style="font-size:12px;color:var(--text-3);text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px">Podaci računa</h4>
|
||||
<div class="kv" id="inv_kv"></div>
|
||||
<div id="inv_status_block" style="margin-top:14px;padding:12px;background:var(--bg-3);border-radius:6px;border:1px solid var(--border)"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions-row" id="inv_actions"></div>
|
||||
<div style="margin-top:18px">
|
||||
<h4 style="font-size:12px;color:var(--text-3);text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px">Audit log</h4>
|
||||
<div id="inv_audit"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ PAY INVOICE MODAL (M5.5) ============ -->
|
||||
<div id="payModal" class="modal-bg" onclick="if(event.target===this)closeModal('payModal')">
|
||||
<div class="modal" style="max-width:560px">
|
||||
<div class="modal-h">
|
||||
<h3>💰 Označi kao plaćen</h3>
|
||||
<button class="x" onclick="closeModal('payModal')">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="grid2" style="gap:12px">
|
||||
<div><label class="lbl">IBAN primatelja</label><input id="pay_iban_to" class="fld" placeholder="HRxxxxxxxxxxxxxxxxxxx"></div>
|
||||
<div><label class="lbl">IBAN platitelja</label><input id="pay_iban_from" class="fld" placeholder="HRxxxxxxxxxxxxxxxxxxx"></div>
|
||||
<div><label class="lbl">Datum uplate</label><input id="pay_date" type="date" class="fld"></div>
|
||||
<div><label class="lbl">Iznos (€)</label><input id="pay_amount" type="number" step="0.01" class="fld"></div>
|
||||
<div><label class="lbl">Poziv na broj / referenca</label><input id="pay_ref" class="fld" placeholder="HR00 12345-67890"></div>
|
||||
<div><label class="lbl">Tx ID (banka)</label><input id="pay_tx" class="fld"></div>
|
||||
</div>
|
||||
<div class="actions-row">
|
||||
<button class="btn green" id="payConfirm">✓ Potvrdi plaćanje</button>
|
||||
<button class="btn sec" onclick="closeModal('payModal')">Odustani</button>
|
||||
<span id="payStatus" style="font-size:12px;color:var(--text-3);align-self:center"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ COMMENT MODAL (M5.5) ============ -->
|
||||
<div id="commentModal" class="modal-bg" onclick="if(event.target===this)closeModal('commentModal')">
|
||||
<div class="modal" style="max-width:520px">
|
||||
<div class="modal-h">
|
||||
<h3>💬 Komentar (savez/admin)</h3>
|
||||
<button class="x" onclick="closeModal('commentModal')">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<textarea id="commentText" class="fld" rows="5" style="resize:vertical;font-family:inherit"></textarea>
|
||||
<div class="actions-row">
|
||||
<button class="btn" id="commentSave">Spremi komentar</button>
|
||||
<button class="btn sec" onclick="closeModal('commentModal')">Odustani</button>
|
||||
<span id="commentStatus" style="font-size:12px;color:var(--text-3);align-self:center"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ PUTNI NALOG DETAIL MODAL (M6.3) ============ -->
|
||||
<div id="pnModal" class="modal-bg" onclick="if(event.target===this)closeModal('pnModal')">
|
||||
<div class="modal">
|
||||
<div class="modal-h">
|
||||
<h3 id="pnModalTitle">Putni nalog</h3>
|
||||
<button class="x" onclick="closeModal('pnModal')">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="col2">
|
||||
<div>
|
||||
<h4 style="font-size:12px;color:var(--text-3);text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px">Voditelj + putnici, ruta, vozilo</h4>
|
||||
<div class="kv" id="pn_kv"></div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 style="font-size:12px;color:var(--text-3);text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px">Obračun (HR pravilnik 2025)</h4>
|
||||
<div class="kv" id="pn_obracun"></div>
|
||||
<div id="pn_status_block" style="margin-top:14px;padding:12px;background:var(--bg-3);border-radius:6px;border:1px solid var(--border)"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:18px">
|
||||
<h4 style="font-size:12px;color:var(--text-3);text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px">📎 Vezani računi (gorivo, cestarina, hotel...)</h4>
|
||||
<table id="pn_invoices_table"><thead><tr><th>#</th><th>Vrsta</th><th>Dobavljač</th><th>OIB</th><th>Datum</th><th class="num">Brutto</th><th>Status</th></tr></thead><tbody></tbody></table>
|
||||
</div>
|
||||
<div class="actions-row" id="pn_actions"></div>
|
||||
<div style="margin-top:18px">
|
||||
<h4 style="font-size:12px;color:var(--text-3);text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px">Audit log</h4>
|
||||
<div id="pn_audit"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ PAY PUTNI NALOG MODAL ============ -->
|
||||
<div id="payPnModal" class="modal-bg" onclick="if(event.target===this)closeModal('payPnModal')">
|
||||
<div class="modal" style="max-width:560px">
|
||||
<div class="modal-h">
|
||||
<h3>💰 Isplata putnog naloga</h3>
|
||||
<button class="x" onclick="closeModal('payPnModal')">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="grid2" style="gap:12px">
|
||||
<div><label class="lbl">IBAN primatelja</label><input id="ppn_iban_to" class="fld"></div>
|
||||
<div><label class="lbl">IBAN platitelja</label><input id="ppn_iban_from" class="fld"></div>
|
||||
<div><label class="lbl">Datum uplate</label><input id="ppn_date" type="date" class="fld"></div>
|
||||
<div><label class="lbl">Iznos (€)</label><input id="ppn_amount" type="number" step="0.01" class="fld"></div>
|
||||
<div><label class="lbl">Referenca</label><input id="ppn_ref" class="fld"></div>
|
||||
<div><label class="lbl">Tx ID</label><input id="ppn_tx" class="fld"></div>
|
||||
</div>
|
||||
<div class="actions-row">
|
||||
<button class="btn green" id="ppnConfirm">✓ Potvrdi isplatu</button>
|
||||
<button class="btn sec" onclick="closeModal('payPnModal')">Odustani</button>
|
||||
<span id="ppnStatus" style="font-size:12px;color:var(--text-3);align-self:center"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ REJECT PUTNI NALOG MODAL ============ -->
|
||||
<div id="rejectModal" class="modal-bg" onclick="if(event.target===this)closeModal('rejectModal')">
|
||||
<div class="modal" style="max-width:480px">
|
||||
<div class="modal-h">
|
||||
<h3>❌ Odbij putni nalog</h3>
|
||||
<button class="x" onclick="closeModal('rejectModal')">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<label class="lbl">Razlog odbijanja</label>
|
||||
<textarea id="rejectText" class="fld" rows="4" style="resize:vertical;font-family:inherit"></textarea>
|
||||
<div class="actions-row">
|
||||
<button class="btn red" id="rejectConfirm">Odbij</button>
|
||||
<button class="btn sec" onclick="closeModal('rejectModal')">Odustani</button>
|
||||
<span id="rejectStatus" style="font-size:12px;color:var(--text-3);align-self:center"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const ERP_API = '/api/erp';
|
||||
const $ = s => document.querySelector(s);
|
||||
@@ -343,10 +514,10 @@ function pnInit() {
|
||||
}
|
||||
|
||||
async function loadInvoices() {
|
||||
const r = await fetch(`${ERP_API}/invoices?limit=50`).then(r=>r.json()).catch(()=>null);
|
||||
const r = await fetch(`${ERP_API}/invoices?limit=50`, {headers: AUTH_HDR()}).then(r=>r.json()).catch(()=>null);
|
||||
if (!r || !r.rows) return;
|
||||
$('#invTable tbody').innerHTML = r.rows.length ? r.rows.map(i=>`
|
||||
<tr><td>${i.id}</td><td>${i.invoice_kind||'—'}</td><td>${i.invoice_no||'—'}</td>
|
||||
<tr class="clickable" onclick="openInvoice(${i.id})"><td>${i.id}</td><td>${i.invoice_kind||'—'}</td><td>${i.invoice_no||'—'}</td>
|
||||
<td>${i.vendor_name||'—'}</td><td style="font-family:'JetBrains Mono'">${i.vendor_oib||'—'}</td>
|
||||
<td>${i.klub_naziv||'—'}</td><td class="num">${fmtEur(i.amount_gross)}</td>
|
||||
<td>${sBadge(i.payment_status)}</td><td>${fmtDate(i.invoice_date)}</td></tr>`).join('')
|
||||
@@ -354,10 +525,10 @@ async function loadInvoices() {
|
||||
}
|
||||
|
||||
async function loadPutni() {
|
||||
const r = await fetch(`${ERP_API}/putni-nalog?limit=50`).then(r=>r.json()).catch(()=>null);
|
||||
const r = await fetch(`${ERP_API}/putni-nalog?limit=50`, {headers: AUTH_HDR()}).then(r=>r.json()).catch(()=>null);
|
||||
if (!r || !r.rows) return;
|
||||
$('#pnTable tbody').innerHTML = r.rows.length ? r.rows.map(p=>`
|
||||
<tr><td>${p.id}</td><td>${p.klub_naziv||'—'}</td><td>${p.destination||'—'}</td>
|
||||
<tr class="clickable" onclick="openPutni(${p.id})"><td>${p.id}</td><td>${p.klub_naziv||'—'}</td><td>${p.destination||'—'}</td>
|
||||
<td>${fmtDate(p.date_from)}</td><td>${fmtDate(p.date_to)}</td>
|
||||
<td class="num">${fmtEur(p.dnevnice_amount)}</td>
|
||||
<td class="num">${fmtEur(p.cost_transport)}</td>
|
||||
@@ -366,6 +537,297 @@ async function loadPutni() {
|
||||
: '<tr><td colspan="9" style="color:var(--text-3);text-align:center;padding:20px">Nema podataka</td></tr>';
|
||||
}
|
||||
|
||||
// ===== AUTH (JWT iz localStorage ili admin token fallback) =====
|
||||
function AUTH_HDR(extra) {
|
||||
const h = Object.assign({}, extra || {});
|
||||
let t = null;
|
||||
try { t = localStorage.getItem('jwt') || sessionStorage.getItem('jwt'); } catch(e){}
|
||||
if (!t) t = 'admin-pgz-2026';
|
||||
h['Authorization'] = 'Bearer ' + t;
|
||||
return h;
|
||||
}
|
||||
function AUTH_HDR_JSON() { return AUTH_HDR({'Content-Type': 'application/json'}); }
|
||||
|
||||
function openModal(id) { document.getElementById(id).classList.add('show'); }
|
||||
function closeModal(id) { document.getElementById(id).classList.remove('show'); }
|
||||
|
||||
function escHtml(s) {
|
||||
if (s == null) return '';
|
||||
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
|
||||
function renderAudit(audit) {
|
||||
if (!audit || !audit.length) return '<div style="color:var(--text-3);font-size:12px">Nema audit zapisa.</div>';
|
||||
return audit.map(a => `
|
||||
<div class="audit-row">
|
||||
<div class="ts">${(a.timestamp||'').replace('T',' ').substring(0,19)}</div>
|
||||
<div class="op">${escHtml(a.operacija)}</div>
|
||||
<div class="who">${escHtml(a.korisnik||'—')}</div>
|
||||
<div>${escHtml(a.promijenjeno_polje||'')}: <span style="color:var(--text-3)">${escHtml(a.stara_vrijednost||'∅')}</span> → <span style="color:var(--green)">${escHtml(a.nova_vrijednost||'∅')}</span></div>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
// ===== INVOICE DETAIL =====
|
||||
let _currentInvoice = null;
|
||||
|
||||
async function openInvoice(id) {
|
||||
const r = await fetch(`${ERP_API}/invoices/${id}`, {headers: AUTH_HDR()}).then(r=>r.json()).catch(()=>null);
|
||||
if (!r || !r.ok) { alert('Greška pri učitavanju računa #' + id); return; }
|
||||
_currentInvoice = r;
|
||||
const i = r.invoice;
|
||||
$('#invModalTitle').textContent = `Račun #${i.id} · ${i.invoice_no || '—'}`;
|
||||
|
||||
// Preview slike
|
||||
const pv = $('#inv_preview');
|
||||
if (r.uploads && r.uploads.length) {
|
||||
const up = r.uploads[0];
|
||||
const fileUrl = `${ERP_API}/invoices/${id}/file`;
|
||||
const isPdf = (up.mime || '').includes('pdf') || (up.file_name || '').toLowerCase().endsWith('.pdf');
|
||||
if (isPdf) {
|
||||
pv.innerHTML = `<embed src="${fileUrl}" type="application/pdf" style="width:100%;height:480px;border:1px solid var(--border);border-radius:6px"><div style="margin-top:6px;font-size:11px;color:var(--text-3)">${escHtml(up.file_name)} · ${escHtml(up.mime||'')}</div>`;
|
||||
} else {
|
||||
pv.innerHTML = `<a href="${fileUrl}" target="_blank"><img class="preview-img" src="${fileUrl}" alt="skena"></a><div style="margin-top:6px;font-size:11px;color:var(--text-3)">${escHtml(up.file_name)} · OCR ${escHtml(up.ocr_engine||up.ocr_status||'')}</div>`;
|
||||
}
|
||||
} else {
|
||||
pv.innerHTML = '<div style="padding:60px;background:var(--bg-3);border-radius:6px;color:var(--text-3);font-size:12px">Bez priložene datoteke</div>';
|
||||
}
|
||||
|
||||
// KV polja
|
||||
$('#inv_kv').innerHTML = `
|
||||
<div>Izdavatelj</div><div>${escHtml(i.vendor_name||'—')}</div>
|
||||
<div>OIB izdavatelja</div><div>${escHtml(i.vendor_oib||'—')}</div>
|
||||
<div>Broj računa</div><div>${escHtml(i.invoice_no||'—')}</div>
|
||||
<div>Datum</div><div>${fmtDate(i.invoice_date)}</div>
|
||||
<div>Klub</div><div>${escHtml(i.klub_naziv||'—')}</div>
|
||||
<div>Vrsta</div><div>${escHtml(i.invoice_kind||'—')}</div>
|
||||
<div>Iznos neto</div><div>${fmtEur(i.amount_net)}</div>
|
||||
<div>PDV (${i.vat_rate||'—'}%)</div><div>${fmtEur(i.amount_vat)}</div>
|
||||
<div>Brutto</div><div style="color:var(--accent);font-weight:700">${fmtEur(i.amount_gross)}</div>
|
||||
<div>Valuta</div><div>${escHtml(i.currency||'EUR')}</div>
|
||||
<div>Opis</div><div>${escHtml(i.description||'—')}</div>
|
||||
`;
|
||||
|
||||
// Status block
|
||||
const status = (i.payment_status||'unpaid').toLowerCase();
|
||||
let sb = `<div style="display:flex;align-items:center;gap:10px"><span style="font-size:11px;color:var(--text-3)">STATUS</span> ${sBadge(i.payment_status)}</div>`;
|
||||
if (status === 'paid') {
|
||||
const lastPay = (r.payments && r.payments.length) ? r.payments[0] : {};
|
||||
sb += `<div style="margin-top:10px;font-size:12px;line-height:1.7">
|
||||
<div><span style="color:var(--text-3)">IBAN primatelja:</span> <span style="font-family:'JetBrains Mono'">${escHtml(lastPay.iban_to || i.iban_to || '—')}</span></div>
|
||||
<div><span style="color:var(--text-3)">IBAN platitelja:</span> <span style="font-family:'JetBrains Mono'">${escHtml(lastPay.iban_from || i.iban_from || '—')}</span></div>
|
||||
<div><span style="color:var(--text-3)">Datum uplate:</span> ${fmtDate(i.paid_date) || fmtDate(lastPay.payment_date)}</div>
|
||||
<div><span style="color:var(--text-3)">Iznos uplate:</span> <strong style="color:var(--green)">${fmtEur(lastPay.amount || i.amount_gross)}</strong></div>
|
||||
<div><span style="color:var(--text-3)">Referenca:</span> <span style="font-family:'JetBrains Mono'">${escHtml(lastPay.reference||'—')}</span></div>
|
||||
<div><span style="color:var(--text-3)">Tx ID:</span> <span style="font-family:'JetBrains Mono'">${escHtml(lastPay.bank_transaction_id||'—')}</span></div>
|
||||
</div>`;
|
||||
} else if (status === 'cancelled' || status === 'otkazan') {
|
||||
sb += `<div style="margin-top:8px;color:var(--red);font-size:12px">Račun je otkazan.</div>`;
|
||||
} else {
|
||||
sb += `<div style="margin-top:8px;color:var(--yellow);font-size:12px">Neplaćen — čeka uplatu.</div>`;
|
||||
}
|
||||
$('#inv_status_block').innerHTML = sb;
|
||||
|
||||
// Actions po permission-ima
|
||||
const a = r.actions || {};
|
||||
const acts = [];
|
||||
if (a.pay && status !== 'paid') acts.push(`<button class="btn green" onclick="openPayModal(${id})">💰 Označi kao plaćen</button>`);
|
||||
if (a.edit && status !== 'paid') acts.push(`<button class="btn yellow" onclick="alert('Edit u UI: koristi M5 OCR formu — ovaj panel je read-only za prikaz')">✏ Korekcija polja</button>`);
|
||||
if (a.comment) acts.push(`<button class="btn sec" onclick="openCommentModal(${id})">💬 Komentar</button>`);
|
||||
if (r.uploads && r.uploads.length) acts.push(`<a href="${ERP_API}/invoices/${id}/file" target="_blank" class="btn sec" style="text-decoration:none">📥 Preuzmi sken</a>`);
|
||||
if (a.delete) acts.push(`<button class="btn red" onclick="if(confirm('Obrisati račun #${id}?')){alert('Brisanje: TODO endpoint')}">🗑 Obriši</button>`);
|
||||
if (!acts.length) acts.push('<span style="color:var(--text-3);font-size:12px">Bez dostupnih akcija (samo pregled).</span>');
|
||||
$('#inv_actions').innerHTML = acts.join('');
|
||||
|
||||
$('#inv_audit').innerHTML = renderAudit(r.audit);
|
||||
openModal('invModal');
|
||||
}
|
||||
|
||||
function openPayModal(id) {
|
||||
const inv = _currentInvoice && _currentInvoice.invoice;
|
||||
if (inv) {
|
||||
$('#pay_iban_to').value = inv.iban_to || '';
|
||||
$('#pay_amount').value = inv.amount_gross || '';
|
||||
}
|
||||
$('#pay_date').value = new Date().toISOString().substring(0,10);
|
||||
$('#payStatus').textContent = '';
|
||||
openModal('payModal');
|
||||
$('#payConfirm').onclick = async () => {
|
||||
const body = {
|
||||
iban_to: $('#pay_iban_to').value.trim(),
|
||||
iban_from: $('#pay_iban_from').value.trim(),
|
||||
paid_date: $('#pay_date').value,
|
||||
amount: parseFloat($('#pay_amount').value) || undefined,
|
||||
reference: $('#pay_ref').value.trim(),
|
||||
bank_transaction_id: $('#pay_tx').value.trim(),
|
||||
payment_method: 'transfer',
|
||||
};
|
||||
$('#payStatus').textContent = '⏳ Spremam…';
|
||||
const r = await fetch(`${ERP_API}/invoices/${id}/pay`, {method:'POST', headers: AUTH_HDR_JSON(), body: JSON.stringify(body)}).then(r=>r.json()).catch(()=>({ok:false,detail:'net'}));
|
||||
if (r.ok) {
|
||||
$('#payStatus').textContent = '✓ Plaćeno';
|
||||
$('#payStatus').style.color = 'var(--green)';
|
||||
setTimeout(() => { closeModal('payModal'); openInvoice(id); loadInvoices(); }, 700);
|
||||
} else {
|
||||
$('#payStatus').textContent = '❌ ' + (r.detail || 'Greška');
|
||||
$('#payStatus').style.color = 'var(--red)';
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function openCommentModal(id) {
|
||||
$('#commentText').value = '';
|
||||
$('#commentStatus').textContent = '';
|
||||
openModal('commentModal');
|
||||
$('#commentSave').onclick = async () => {
|
||||
const txt = $('#commentText').value.trim();
|
||||
if (!txt) { $('#commentStatus').textContent = 'Komentar je prazan'; return; }
|
||||
$('#commentStatus').textContent = '⏳';
|
||||
const r = await fetch(`${ERP_API}/invoices/${id}/comment`, {method:'POST', headers: AUTH_HDR_JSON(), body: JSON.stringify({comment: txt})}).then(r=>r.json()).catch(()=>({ok:false,detail:'net'}));
|
||||
if (r.ok) {
|
||||
$('#commentStatus').textContent = '✓ Spremljeno';
|
||||
$('#commentStatus').style.color = 'var(--green)';
|
||||
setTimeout(() => { closeModal('commentModal'); openInvoice(id); }, 600);
|
||||
} else {
|
||||
$('#commentStatus').textContent = '❌ ' + (r.detail || 'Greška');
|
||||
$('#commentStatus').style.color = 'var(--red)';
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ===== PUTNI NALOG DETAIL =====
|
||||
let _currentPn = null;
|
||||
|
||||
async function openPutni(id) {
|
||||
const r = await fetch(`${ERP_API}/putni-nalog/${id}`, {headers: AUTH_HDR()}).then(r=>r.json()).catch(()=>null);
|
||||
if (!r || !r.ok) { alert('Greška pri učitavanju putnog naloga #' + id); return; }
|
||||
_currentPn = r;
|
||||
const p = r.putni_nalog;
|
||||
$('#pnModalTitle').textContent = `Putni nalog #${p.id} · ${p.klub_naziv||'—'}`;
|
||||
|
||||
const att = p.attachments || {};
|
||||
const dnv = att.dnevnice_calc || {};
|
||||
const putnici = (att.putnici || []).join(', ');
|
||||
const voditelj = att.voditelj || '—';
|
||||
const country = att.country || '—';
|
||||
const fromCity = att.from_city || '—', toCity = att.to_city || '—';
|
||||
|
||||
$('#pn_kv').innerHTML = `
|
||||
<div>Voditelj</div><div>${escHtml(voditelj)}</div>
|
||||
<div>Putnici</div><div>${escHtml(putnici||'—')}</div>
|
||||
<div>Svrha</div><div>${escHtml(p.purpose||'—')}</div>
|
||||
<div>Ruta</div><div>${escHtml(fromCity)} → ${escHtml(toCity)}</div>
|
||||
<div>Zemlja</div><div>${escHtml(country)}</div>
|
||||
<div>Polazak</div><div>${fmtDate(p.date_from)}</div>
|
||||
<div>Povratak</div><div>${fmtDate(p.date_to)}</div>
|
||||
<div>Vozilo</div><div>${escHtml(p.vehicle_type||'—')} ${escHtml(p.vehicle_plate||'')}</div>
|
||||
<div>Kilometara</div><div>${p.km_driven||0} km × €${p.km_rate||0.5}</div>
|
||||
`;
|
||||
|
||||
$('#pn_obracun').innerHTML = `
|
||||
<div>Pune dnevnice</div><div style="color:var(--accent)">${dnv.days_full||0} × €${dnv.rate_full||0}</div>
|
||||
<div>Pola dnevnica</div><div style="color:var(--yellow)">${dnv.days_half||0} × €${dnv.rate_half||0}</div>
|
||||
<div>Dnevnice ukupno</div><div style="color:var(--green)">${fmtEur(p.dnevnice_amount)}</div>
|
||||
<div>Kilometrina</div><div>${fmtEur(p.cost_transport)}</div>
|
||||
<div>Smještaj</div><div>${fmtEur(p.cost_lodging)}</div>
|
||||
<div>Hrana / ostalo</div><div>${fmtEur((p.cost_meals||0)+(p.cost_other||0))}</div>
|
||||
<div style="font-weight:700">UKUPNO</div><div style="color:var(--accent);font-weight:700;font-size:18px">${fmtEur(p.cost_total)}</div>
|
||||
`;
|
||||
|
||||
// Status block
|
||||
const status = (p.status||'draft').toLowerCase();
|
||||
let sb = `<div style="display:flex;align-items:center;gap:10px"><span style="font-size:11px;color:var(--text-3)">STATUS</span> ${sBadge(p.status)}</div>`;
|
||||
if (status === 'isplacen') {
|
||||
const lastPay = (r.payments && r.payments.length) ? r.payments[0] : {};
|
||||
sb += `<div style="margin-top:10px;font-size:12px;line-height:1.7">
|
||||
<div><span style="color:var(--text-3)">IBAN primatelja:</span> <span style="font-family:'JetBrains Mono'">${escHtml(lastPay.iban_to||'—')}</span></div>
|
||||
<div><span style="color:var(--text-3)">Datum isplate:</span> ${fmtDate(p.paid_at) || fmtDate(lastPay.payment_date)}</div>
|
||||
<div><span style="color:var(--text-3)">Iznos isplate:</span> <strong style="color:var(--green)">${fmtEur(lastPay.amount||p.cost_total)}</strong></div>
|
||||
<div><span style="color:var(--text-3)">Referenca:</span> <span style="font-family:'JetBrains Mono'">${escHtml(lastPay.reference||'—')}</span></div>
|
||||
<div><span style="color:var(--text-3)">Tx ID:</span> <span style="font-family:'JetBrains Mono'">${escHtml(lastPay.bank_transaction_id||'—')}</span></div>
|
||||
</div>`;
|
||||
} else if (status === 'odbijen') {
|
||||
sb += `<div style="margin-top:8px;color:var(--red);font-size:12px">${escHtml(p.notes||'Odbijen').slice(-200)}</div>`;
|
||||
} else {
|
||||
sb += `<div style="margin-top:8px;color:var(--yellow);font-size:12px">${status === 'odobren' || status === 'zatvoren' ? 'Čeka isplatu.' : status === 'poslan' ? 'Čeka odobrenje.' : 'Draft — još nije poslan na odobrenje.'}</div>`;
|
||||
}
|
||||
$('#pn_status_block').innerHTML = sb;
|
||||
|
||||
// Vezani računi
|
||||
const invs = r.invoices || [];
|
||||
$('#pn_invoices_table tbody').innerHTML = invs.length ? invs.map(i => `
|
||||
<tr class="clickable" onclick="closeModal('pnModal'); setTimeout(()=>openInvoice(${i.id}), 100)">
|
||||
<td>${i.id}</td><td>${escHtml(i.invoice_kind||'—')}</td><td>${escHtml(i.vendor_name||'—')}</td>
|
||||
<td style="font-family:'JetBrains Mono'">${escHtml(i.vendor_oib||'—')}</td>
|
||||
<td>${fmtDate(i.invoice_date)}</td>
|
||||
<td class="num">${fmtEur(i.amount_gross)}</td>
|
||||
<td>${sBadge(i.payment_status)}</td>
|
||||
</tr>`).join('') : '<tr><td colspan="7" style="color:var(--text-3);text-align:center;padding:14px">Nema vezanih računa</td></tr>';
|
||||
|
||||
// Actions
|
||||
const a = r.actions || {};
|
||||
const acts = [];
|
||||
if (a.submit) acts.push(`<button class="btn yellow" onclick="submitPn(${id})">📤 Pošalji na odobrenje</button>`);
|
||||
if (a.approve) acts.push(`<button class="btn green" onclick="approvePn(${id})">✓ Odobri</button>`);
|
||||
if (a.reject) acts.push(`<button class="btn red" onclick="openRejectModal(${id})">✗ Odbij</button>`);
|
||||
if (a.pay) acts.push(`<button class="btn green" onclick="openPayPnModal(${id})">💰 Isplati</button>`);
|
||||
if (a.edit) acts.push(`<button class="btn sec" onclick="alert('Edit drafta — koristi M6 formu \\'Novi putni nalog\\' s prefilanim poljima (TODO UI)')">✏ Edit</button>`);
|
||||
if (!acts.length) acts.push('<span style="color:var(--text-3);font-size:12px">Bez dostupnih akcija (samo pregled).</span>');
|
||||
$('#pn_actions').innerHTML = acts.join('');
|
||||
|
||||
$('#pn_audit').innerHTML = renderAudit(r.audit);
|
||||
openModal('pnModal');
|
||||
}
|
||||
|
||||
async function submitPn(id) {
|
||||
if (!confirm('Poslati putni nalog #' + id + ' na odobrenje?')) return;
|
||||
const r = await fetch(`${ERP_API}/putni-nalog/${id}/posalji`, {method:'POST', headers: AUTH_HDR_JSON()}).then(r=>r.json()).catch(()=>null);
|
||||
if (r && r.ok) { openPutni(id); loadPutni(); } else alert('Greška: ' + (r && r.detail || ''));
|
||||
}
|
||||
async function approvePn(id) {
|
||||
if (!confirm('Odobriti putni nalog #' + id + '?')) return;
|
||||
const r = await fetch(`${ERP_API}/putni-nalog/${id}/odobriti`, {method:'POST', headers: AUTH_HDR_JSON(), body: '{}'}).then(r=>r.json()).catch(()=>null);
|
||||
if (r && r.ok) { openPutni(id); loadPutni(); } else alert('Greška: ' + (r && r.detail || ''));
|
||||
}
|
||||
function openRejectModal(id) {
|
||||
$('#rejectText').value = '';
|
||||
$('#rejectStatus').textContent = '';
|
||||
openModal('rejectModal');
|
||||
$('#rejectConfirm').onclick = async () => {
|
||||
const reason = $('#rejectText').value.trim();
|
||||
if (!reason) { $('#rejectStatus').textContent = 'Razlog je obavezan'; return; }
|
||||
const r = await fetch(`${ERP_API}/putni-nalog/${id}/odbij`, {method:'POST', headers: AUTH_HDR_JSON(), body: JSON.stringify({razlog: reason})}).then(r=>r.json()).catch(()=>null);
|
||||
if (r && r.ok) { closeModal('rejectModal'); openPutni(id); loadPutni(); }
|
||||
else $('#rejectStatus').textContent = '❌ ' + (r && r.detail || 'Greška');
|
||||
};
|
||||
}
|
||||
function openPayPnModal(id) {
|
||||
const pn = _currentPn && _currentPn.putni_nalog;
|
||||
if (pn) $('#ppn_amount').value = pn.cost_total || '';
|
||||
$('#ppn_date').value = new Date().toISOString().substring(0,10);
|
||||
$('#ppnStatus').textContent = '';
|
||||
openModal('payPnModal');
|
||||
$('#ppnConfirm').onclick = async () => {
|
||||
const body = {
|
||||
iban_to: $('#ppn_iban_to').value.trim(),
|
||||
iban_from: $('#ppn_iban_from').value.trim(),
|
||||
paid_date: $('#ppn_date').value,
|
||||
amount: parseFloat($('#ppn_amount').value) || undefined,
|
||||
reference: $('#ppn_ref').value.trim(),
|
||||
bank_transaction_id: $('#ppn_tx').value.trim(),
|
||||
};
|
||||
$('#ppnStatus').textContent = '⏳';
|
||||
const r = await fetch(`${ERP_API}/putni-nalog/${id}/isplati`, {method:'POST', headers: AUTH_HDR_JSON(), body: JSON.stringify(body)}).then(r=>r.json()).catch(()=>null);
|
||||
if (r && r.ok) {
|
||||
$('#ppnStatus').textContent = '✓ Isplaćeno';
|
||||
$('#ppnStatus').style.color = 'var(--green)';
|
||||
setTimeout(() => { closeModal('payPnModal'); openPutni(id); loadPutni(); }, 700);
|
||||
} else {
|
||||
$('#ppnStatus').textContent = '❌ ' + (r && r.detail || 'Greška');
|
||||
$('#ppnStatus').style.color = 'var(--red)';
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function activate(name) {
|
||||
$$('.nav-item').forEach(n => n.classList.toggle('active', n.dataset.tab === name));
|
||||
$$('.tab').forEach(t => t.classList.toggle('active', t.id === 'tab-' + name));
|
||||
|
||||
+18
-4
@@ -352,6 +352,10 @@ body {
|
||||
<label for="password">Lozinka</label>
|
||||
<input type="password" id="password" name="password" required autocomplete="current-password" placeholder="••••••••">
|
||||
</div>
|
||||
<div class="field" id="totpField" style="display:none">
|
||||
<label for="totp">Kod autentifikatora (2FA)</label>
|
||||
<input type="text" id="totp" name="totp" inputmode="numeric" pattern="[0-9 ]*" autocomplete="one-time-code" placeholder="123456" maxlength="8" style="font-family:'JetBrains Mono',monospace;letter-spacing:4px;text-align:center;font-size:18px">
|
||||
</div>
|
||||
<div class="row">
|
||||
<label><input type="checkbox" id="remember" checked> Zapamti me</label>
|
||||
<a href="#" id="forgotLink">Zaboravljena lozinka?</a>
|
||||
@@ -407,19 +411,28 @@ function showAlert(msg, type) {
|
||||
}
|
||||
}
|
||||
|
||||
async function doLogin(email, password) {
|
||||
async function doLogin(email, password, totp) {
|
||||
const btn = $('#submitBtn');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner"></span>Prijavljujem…';
|
||||
try {
|
||||
const body = { email, password };
|
||||
if (totp) body.totp = totp;
|
||||
const r = await fetch(API + '/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password })
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!r.ok) {
|
||||
showAlert(data.detail || 'Neispravni podaci');
|
||||
if (r.status === 401 && (data.detail === '2FA_REQUIRED' || /2FA/i.test(data.detail||''))) {
|
||||
// Show TOTP field and stop
|
||||
$('#totpField').style.display = '';
|
||||
$('#totp').focus();
|
||||
showAlert('Unesite kod iz autentifikatora.');
|
||||
} else {
|
||||
showAlert(data.detail || 'Neispravni podaci');
|
||||
}
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Prijavi se';
|
||||
return;
|
||||
@@ -461,8 +474,9 @@ $('#loginForm').addEventListener('submit', e => {
|
||||
e.preventDefault();
|
||||
const email = $('#email').value.trim().toLowerCase();
|
||||
const pwd = $('#password').value;
|
||||
const totp = ($('#totp').value || '').trim().replace(/\s/g,'') || null;
|
||||
if (!email || !pwd) return;
|
||||
doLogin(email, pwd);
|
||||
doLogin(email, pwd, totp);
|
||||
});
|
||||
|
||||
document.querySelectorAll('.demo').forEach(el => {
|
||||
|
||||
|
Before Width: | Height: | Size: 176 B After Width: | Height: | Size: 176 B |
Reference in New Issue
Block a user