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:
@@ -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)));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user