CC2 R3 frontend: login.html + admin_users.html (M1+M2+M10 UI)

- static/login.html: dark Palantir-style login with PGŽ branding,
  Prijava se / Zaboravljena lozinka, demo account quick-fills,
  GDPR cookie banner, autostore tokens (local/session)
- static/admin_users.html: full user-management admin panel:
  - Collapsible left sidebar (Pregled, Korisnici, Tenanti, Audit log,
    Sigurnost, GDPR, links to ERP/CRM)
  - Users table with filters (q, tenant, role, status, limit)
  - + Dodaj korisnika modal (CRUD via /api/admin/users/*)
  - Suspend / unsuspend / reset-password / delete actions
  - Audit log viewer + Security KPIs + GDPR queue
  - Self-service: change pwd, export data (Art. 20), erasure request (Art. 17)
- pgz_sport_api.py: /login and /admin/users URL routes
- auth/seed_demo.py: added tajnik@atletski.pgz.hr/Atl2026!,
  admin@ak-kvarner.hr/Kvarner2026! demo users

5/5 live tests pass: login JWT, /me, /admin/users, /gdpr/consent, /gdpr/export

Note: existing admin.html (CC4 ERP/OCR work) preserved intact;
admin_users.html is dedicated user-mgmt page linked from sidebar.
This commit is contained in:
Damir Radulić
2026-05-05 00:20:03 +02:00
parent cef4d2575b
commit 8fe2478b84
17 changed files with 10013 additions and 37 deletions
+266
View File
@@ -224,6 +224,88 @@ td.num { font-family: 'JetBrains Mono', monospace; text-align: right; }
<!-- ERP -->
<div class="tab-content" id="tab-erp">
<div class="kpi-grid" id="erpKpi"></div>
<!-- M5: OCR drag-and-drop upload -->
<div class="section">
<h3>📷 OCR — Skeniraj račun (gorivo, cestarina, hotel…)</h3>
<div id="ocrDrop" style="border:2px dashed var(--border);border-radius:8px;padding:30px;text-align:center;cursor:pointer;background:var(--bg-3);transition:.15s">
<div style="font-size:32px;color:var(--accent);margin-bottom:6px"></div>
<div style="font-size:14px;font-weight:600">Povuci PDF/JPG/PNG ovdje ili klikni za odabir</div>
<div style="font-size:11px;color:var(--text-3);margin-top:6px">Tesseract OCR + DeepSeek V3 izvuče izdavatelja, OIB, datum, iznos, PDV, IBAN, stavke</div>
<input id="ocrFile" type="file" accept=".pdf,.jpg,.jpeg,.png,.tif,.tiff,.webp" style="display:none">
</div>
<div id="ocrStatus" style="margin-top:10px;font-size:12px;color:var(--text-2);min-height:18px"></div>
<div id="ocrResult" style="display:none;margin-top:14px;padding:14px;background:var(--bg-3);border-radius:6px;border:1px solid var(--border)">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;font-size:13px">
<div><label style="font-size:11px;color:var(--text-3)">Izdavatelj</label><input id="oc_vendor_name" class="search" style="max-width:none;width:100%"></div>
<div><label style="font-size:11px;color:var(--text-3)">OIB izdavatelja</label><input id="oc_vendor_oib" class="search" style="max-width:none;width:100%"></div>
<div><label style="font-size:11px;color:var(--text-3)">Broj računa</label><input id="oc_invoice_no" class="search" style="max-width:none;width:100%"></div>
<div><label style="font-size:11px;color:var(--text-3)">Datum</label><input id="oc_invoice_date" type="date" class="search" style="max-width:none;width:100%"></div>
<div><label style="font-size:11px;color:var(--text-3)">Iznos neto</label><input id="oc_amount_net" type="number" step="0.01" class="search" style="max-width:none;width:100%"></div>
<div><label style="font-size:11px;color:var(--text-3)">PDV</label><input id="oc_amount_vat" type="number" step="0.01" class="search" style="max-width:none;width:100%"></div>
<div><label style="font-size:11px;color:var(--text-3)">Brutto (UKUPNO)</label><input id="oc_amount_gross" type="number" step="0.01" class="search" style="max-width:none;width:100%;border-color:var(--accent)"></div>
<div><label style="font-size:11px;color:var(--text-3)">Stopa PDV (%)</label><input id="oc_vat_rate" type="number" step="0.01" class="search" style="max-width:none;width:100%"></div>
<div><label style="font-size:11px;color:var(--text-3)">IBAN</label><input id="oc_iban" class="search" style="max-width:none;width:100%"></div>
<div><label style="font-size:11px;color:var(--text-3)">Vrsta troška</label>
<select id="oc_kind" class="search" style="max-width:none;width:100%">
<option value="gorivo">Gorivo</option>
<option value="cestarina">Cestarina</option>
<option value="hotel">Hotel</option>
<option value="restoran">Restoran</option>
<option value="oprema">Oprema</option>
<option value="ostalo" selected>Ostalo</option>
</select>
</div>
<div><label style="font-size:11px;color:var(--text-3)">Klub</label>
<select id="oc_klub" class="search" style="max-width:none;width:100%"></select>
</div>
<div><label style="font-size:11px;color:var(--text-3)">Valuta</label>
<select id="oc_currency" class="search" style="max-width:none;width:100%"><option>EUR</option><option>HRK</option></select>
</div>
</div>
<div style="margin-top:10px"><label style="font-size:11px;color:var(--text-3)">Opis</label><input id="oc_description" class="search" style="max-width:none;width:100%"></div>
<details style="margin-top:10px"><summary style="cursor:pointer;font-size:12px;color:var(--text-3)">Sirovi OCR tekst (preview)</summary>
<pre id="oc_raw" style="font-size:11px;background:var(--bg);padding:10px;border-radius:4px;margin-top:6px;max-height:200px;overflow:auto;white-space:pre-wrap"></pre>
</details>
<div style="margin-top:14px;display:flex;gap:8px;align-items:center">
<button id="ocSave" style="padding:8px 18px;background:var(--accent);color:var(--bg);border:0;border-radius:4px;cursor:pointer;font-weight:600">💾 Spremi račun</button>
<button id="ocCancel" style="padding:8px 14px;background:var(--bg-3);color:var(--text);border:1px solid var(--border);border-radius:4px;cursor:pointer">Odustani</button>
<span id="ocSaveStatus" style="font-size:12px;color:var(--text-3)"></span>
</div>
</div>
</div>
<!-- M6: Putni nalozi creation form -->
<div class="section">
<h3>🚗 Novi putni nalog (HR pravilnik 2025)</h3>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px;font-size:13px">
<div><label style="font-size:11px;color:var(--text-3)">Klub</label><select id="pn_klub" class="search" style="max-width:none;width:100%"></select></div>
<div><label style="font-size:11px;color:var(--text-3)">Voditelj</label><input id="pn_voditelj" class="search" style="max-width:none;width:100%" placeholder="Ime Prezime"></div>
<div><label style="font-size:11px;color:var(--text-3)">Putnici (zarezom razdvojeno)</label><input id="pn_putnici" class="search" style="max-width:none;width:100%"></div>
<div><label style="font-size:11px;color:var(--text-3)">Svrha</label><input id="pn_svrha" class="search" style="max-width:none;width:100%" placeholder="Natjecanje, treninzi…"></div>
<div><label style="font-size:11px;color:var(--text-3)">Od grada</label><input id="pn_od" class="search" style="max-width:none;width:100%" value="Rijeka"></div>
<div><label style="font-size:11px;color:var(--text-3)">Do grada</label><input id="pn_do" class="search" style="max-width:none;width:100%"></div>
<div><label style="font-size:11px;color:var(--text-3)">Polazak</label><input id="pn_from" type="datetime-local" class="search" style="max-width:none;width:100%"></div>
<div><label style="font-size:11px;color:var(--text-3)">Povratak</label><input id="pn_to" type="datetime-local" class="search" style="max-width:none;width:100%"></div>
<div><label style="font-size:11px;color:var(--text-3)">Zemlja</label><input id="pn_country" class="search" style="max-width:none;width:100%" value="Hrvatska"></div>
<div><label style="font-size:11px;color:var(--text-3)">Tip vozila</label>
<select id="pn_vehicle" class="search" style="max-width:none;width:100%">
<option>vlastiti automobil</option><option>službeno vozilo</option><option>kombi</option><option>autobus</option><option>vlak</option><option>avion</option>
</select>
</div>
<div><label style="font-size:11px;color:var(--text-3)">Registracija</label><input id="pn_plate" class="search" style="max-width:none;width:100%"></div>
<div><label style="font-size:11px;color:var(--text-3)">Kilometara</label><input id="pn_km" type="number" step="1" class="search" style="max-width:none;width:100%" value="0"></div>
<div><label style="font-size:11px;color:var(--text-3)">€/km</label><input id="pn_kmrate" type="number" step="0.01" class="search" style="max-width:none;width:100%" value="0.50"></div>
</div>
<div id="pn_preview" style="margin-top:14px;padding:12px;background:var(--bg-3);border-radius:6px;border:1px solid var(--border);font-size:13px;color:var(--text-2)">
Unesi datume za live obračun dnevnica…
</div>
<div style="margin-top:12px;display:flex;gap:8px">
<button id="pnSave" style="padding:8px 18px;background:var(--accent);color:var(--bg);border:0;border-radius:4px;cursor:pointer;font-weight:600">📝 Kreiraj putni nalog</button>
<span id="pnSaveStatus" style="font-size:12px;color:var(--text-3);align-self:center"></span>
</div>
</div>
<div class="section">
<h3>Računi</h3>
<table id="invTable"><thead><tr><th>Broj</th><th>Dobavljač</th><th>Klub</th><th class="num">Iznos</th><th>Status</th><th>Datum</th></tr></thead><tbody></tbody></table>
@@ -469,6 +551,190 @@ function activateTab(name) {
if (name === 'reports') loadReports();
}
// === M5: OCR upload (drag-and-drop) ===
const ERP_API = '/api/erp';
async function ocrLoadKlubSelectors() {
const sels = [document.getElementById('oc_klub'), document.getElementById('pn_klub')].filter(Boolean);
if (!sels.length) return;
// Use main API for klubovi list (admin-scoped)
const d = await fetch(`/api/klubovi?limit=400`).then(r => r.json()).catch(() => null);
if (!d) return;
const arr = Array.isArray(d) ? d : (d.rows || d.items || []);
const opts = '<option value="">— odaberi klub —</option>' + arr.map(k => `<option value="${k.id}">${k.naziv}</option>`).join('');
sels.forEach(s => { if (s) s.innerHTML = opts; });
}
let ocrParsed = null;
let ocrUploadId = null;
function ocrSetStatus(msg, color) {
const el = document.getElementById('ocrStatus');
if (el) { el.textContent = msg || ''; el.style.color = color || 'var(--text-2)'; }
}
async function ocrHandleFile(file) {
if (!file) return;
ocrSetStatus('⏳ Učitavam datoteku…', 'var(--yellow)');
const klubVal = document.getElementById('oc_klub')?.value || '';
const fd = new FormData();
fd.append('file', file);
if (klubVal) fd.append('klub_id', klubVal);
fd.append('tenant_id', currentTenant || 1);
fd.append('invoice_kind', document.getElementById('oc_kind')?.value || 'ostalo');
let r = await fetch(`${ERP_API}/ocr/upload`, {method: 'POST', body: fd});
if (!r.ok) { ocrSetStatus('❌ Upload pao: ' + r.status, 'var(--red)'); return; }
const j = await r.json();
ocrUploadId = j.upload_id;
ocrSetStatus(`✓ Uploaded (id=${ocrUploadId}, ${j.size} B). Pokrećem OCR + LLM ekstrakciju…`, 'var(--accent)');
const fd2 = new FormData();
fd2.append('upload_id', ocrUploadId);
fd2.append('use_llm', 'true');
r = await fetch(`${ERP_API}/ocr/parse`, {method: 'POST', body: fd2});
if (!r.ok) { ocrSetStatus('❌ Parse pao: ' + r.status, 'var(--red)'); return; }
const p = await r.json();
if (!p.ok) { ocrSetStatus('❌ ' + (p.error || 'Parse fail'), 'var(--red)'); return; }
ocrParsed = p.extracted || {};
document.getElementById('oc_vendor_name').value = ocrParsed.vendor_name || '';
document.getElementById('oc_vendor_oib').value = ocrParsed.vendor_oib || '';
document.getElementById('oc_invoice_no').value = ocrParsed.invoice_no || '';
document.getElementById('oc_invoice_date').value = ocrParsed.invoice_date || '';
document.getElementById('oc_amount_net').value = ocrParsed.amount_net ?? '';
document.getElementById('oc_amount_vat').value = ocrParsed.amount_vat ?? '';
document.getElementById('oc_amount_gross').value = ocrParsed.amount_gross ?? '';
document.getElementById('oc_vat_rate').value = ocrParsed.vat_rate ?? '';
document.getElementById('oc_iban').value = ocrParsed.iban || '';
document.getElementById('oc_kind').value = ocrParsed.category || 'ostalo';
document.getElementById('oc_currency').value = ocrParsed.currency || 'EUR';
document.getElementById('oc_description').value = ocrParsed.description || '';
document.getElementById('oc_raw').textContent = (p.raw_text_preview || '').slice(0, 4000);
document.getElementById('ocrResult').style.display = 'block';
ocrSetStatus(`✓ OCR ${p.ocr_method} (${p.raw_chars} znakova). Provjeri polja i klikni "Spremi račun".`, 'var(--green)');
}
function ocrInitDrop() {
const drop = document.getElementById('ocrDrop');
const inp = document.getElementById('ocrFile');
if (!drop || !inp) return;
drop.addEventListener('click', () => inp.click());
inp.addEventListener('change', e => { if (e.target.files[0]) ocrHandleFile(e.target.files[0]); });
['dragenter','dragover'].forEach(ev => drop.addEventListener(ev, e => { e.preventDefault(); drop.style.borderColor = 'var(--accent)'; }));
['dragleave','drop'].forEach(ev => drop.addEventListener(ev, e => { e.preventDefault(); drop.style.borderColor = 'var(--border)'; }));
drop.addEventListener('drop', e => { e.preventDefault(); const f = e.dataTransfer.files[0]; if (f) ocrHandleFile(f); });
document.getElementById('ocCancel')?.addEventListener('click', () => {
document.getElementById('ocrResult').style.display = 'none';
ocrParsed = null; ocrUploadId = null; ocrSetStatus('');
inp.value = '';
});
document.getElementById('ocSave')?.addEventListener('click', async () => {
const klub = document.getElementById('oc_klub').value;
if (!klub) { document.getElementById('ocSaveStatus').textContent = 'Odaberi klub'; return; }
const body = {
klub_id: parseInt(klub),
tenant_id: currentTenant || 1,
upload_id: ocrUploadId,
invoice_kind: document.getElementById('oc_kind').value || 'ostalo',
invoice_no: document.getElementById('oc_invoice_no').value,
vendor_name: document.getElementById('oc_vendor_name').value,
vendor_oib: document.getElementById('oc_vendor_oib').value,
invoice_date: document.getElementById('oc_invoice_date').value,
amount_net: parseFloat(document.getElementById('oc_amount_net').value) || null,
amount_vat: parseFloat(document.getElementById('oc_amount_vat').value) || null,
amount_gross: parseFloat(document.getElementById('oc_amount_gross').value),
vat_rate: parseFloat(document.getElementById('oc_vat_rate').value) || null,
iban_to: document.getElementById('oc_iban').value || null,
currency: document.getElementById('oc_currency').value || 'EUR',
category: document.getElementById('oc_kind').value || 'ostalo',
description: document.getElementById('oc_description').value || null,
};
document.getElementById('ocSaveStatus').textContent = '⏳ Spremam…';
const r = await fetch(`${ERP_API}/invoices`, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)});
const j = await r.json();
if (j.ok) {
document.getElementById('ocSaveStatus').textContent = `✓ Spremljen kao #${j.invoice.id}`;
document.getElementById('ocSaveStatus').style.color = 'var(--green)';
setTimeout(() => { document.getElementById('ocrResult').style.display = 'none'; loadERP(); }, 1500);
} else {
document.getElementById('ocSaveStatus').textContent = '❌ ' + (j.detail || 'Greška');
document.getElementById('ocSaveStatus').style.color = 'var(--red)';
}
});
}
// === M6: Putni nalog form with live dnevnice preview ===
let pnPreviewTimer = null;
async function pnRefreshPreview() {
const df = document.getElementById('pn_from')?.value;
const dt = document.getElementById('pn_to')?.value;
const country = document.getElementById('pn_country')?.value || 'Hrvatska';
const km = parseFloat(document.getElementById('pn_km')?.value || 0);
const km_rate = parseFloat(document.getElementById('pn_kmrate')?.value || 0.5);
const tgt = document.getElementById('pn_preview');
if (!df || !dt) { if (tgt) tgt.textContent = 'Unesi datume za live obračun dnevnica…'; return; }
const url = `${ERP_API}/putni-nalog/dnevnice/preview?date_from=${encodeURIComponent(df)}&date_to=${encodeURIComponent(dt)}&country=${encodeURIComponent(country)}&km=${km}&km_rate=${km_rate}`;
const r = await fetch(url).then(r => r.json()).catch(() => null);
if (!r || !r.ok) { tgt.textContent = '⚠ Neuspješan obračun'; return; }
const d = r.preview;
tgt.innerHTML = `
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:14px">
<div><div style="color:var(--text-3);font-size:11px">Sati</div><div style="font-size:18px;font-family:'JetBrains Mono'">${d.hours}h</div></div>
<div><div style="color:var(--text-3);font-size:11px">Pune dnevnice</div><div style="font-size:18px;color:var(--accent);font-family:'JetBrains Mono'">${d.days_full} ×${d.rate_full}</div></div>
<div><div style="color:var(--text-3);font-size:11px">Pola dnevnica</div><div style="font-size:18px;color:var(--yellow);font-family:'JetBrains Mono'">${d.days_half} ×${d.rate_half}</div></div>
<div><div style="color:var(--text-3);font-size:11px">Dnevnice ukupno</div><div style="font-size:18px;color:var(--green);font-family:'JetBrains Mono'">€${d.dnevnica_amount_total}</div></div>
<div><div style="color:var(--text-3);font-size:11px">Kilometara</div><div style="font-size:18px;font-family:'JetBrains Mono'">${d.km_driven} km</div></div>
<div><div style="color:var(--text-3);font-size:11px">Kilometrina</div><div style="font-size:18px;font-family:'JetBrains Mono'">€${d.km_amount}</div></div>
<div><div style="color:var(--text-3);font-size:11px">Zemlja</div><div style="font-size:14px;font-family:'JetBrains Mono'">${d.country}</div></div>
<div><div style="color:var(--text-3);font-size:11px">PROCJENA UKUPNO</div><div style="font-size:22px;color:var(--accent);font-family:'JetBrains Mono';font-weight:700">€${d.total_estimated}</div></div>
</div>`;
}
function pnInit() {
['pn_from','pn_to','pn_country','pn_km','pn_kmrate'].forEach(id => {
const el = document.getElementById(id);
if (el) el.addEventListener('input', () => {
clearTimeout(pnPreviewTimer);
pnPreviewTimer = setTimeout(pnRefreshPreview, 250);
});
});
document.getElementById('pnSave')?.addEventListener('click', async () => {
const klub = document.getElementById('pn_klub').value;
if (!klub) { document.getElementById('pnSaveStatus').textContent = 'Odaberi klub'; return; }
const body = {
klub_id: parseInt(klub),
tenant_id: currentTenant || 1,
voditelj_ime: document.getElementById('pn_voditelj').value,
putnici: (document.getElementById('pn_putnici').value || '').split(',').map(s => s.trim()).filter(Boolean),
svrha: document.getElementById('pn_svrha').value,
od_grada: document.getElementById('pn_od').value,
do_grada: document.getElementById('pn_do').value,
datum_polaska: document.getElementById('pn_from').value,
datum_povratka: document.getElementById('pn_to').value,
country: document.getElementById('pn_country').value,
vehicle_type: document.getElementById('pn_vehicle').value,
registracija_vozila: document.getElementById('pn_plate').value,
kilometara: parseFloat(document.getElementById('pn_km').value) || 0,
km_rate: parseFloat(document.getElementById('pn_kmrate').value) || 0.5,
};
document.getElementById('pnSaveStatus').textContent = '⏳ Spremam…';
const r = await fetch(`${ERP_API}/putni-nalog`, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)});
const j = await r.json();
if (j.ok) {
const pn = j.putni_nalog;
document.getElementById('pnSaveStatus').innerHTML = `✓ Putni nalog #${pn.id} kreiran (€${pn.cost_total})`;
document.getElementById('pnSaveStatus').style.color = 'var(--green)';
loadERP();
} else {
document.getElementById('pnSaveStatus').textContent = '❌ ' + (j.detail || 'Greška');
document.getElementById('pnSaveStatus').style.color = 'var(--red)';
}
});
}
ocrInitDrop();
pnInit();
ocrLoadKlubSelectors();
// Init
$$('.nav-item').forEach(n => n.addEventListener('click', () => activateTab(n.dataset.tab)));